Skip to content

Plan 10 — Frontend Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A browser app (en / ms / zh) that lets an organization’s people file, approve, pay and account for claims, advances and petty cash, and lets its administrators configure workflows, payout channels, roles, org structure and the ledger. Every status chip, filter and action button comes from the API, and no amount is ever held in a float.

Architecture: web/ is a Vite + React 19 + TypeScript SPA. It is served same-origin with /v1 by an nginx container that also proxies the API. The typed client is generated from api/openapi.yaml (openapi-typescript + openapi-fetch). Server state lives in TanStack Query, routes and their search params in TanStack Router, and UI state in zustand. components/workflow/ is the only place workflow state is rendered. Tasks 1–4 first add the additive API seams the screens need (spec §2).

Tech Stack: Go 1.26 (seams only, house patterns) · React 19 · Vite · TypeScript (strict) · TanStack Router / Query / Table · Radix UI primitives + Tailwind v4 (shadcn pattern, components copied into the repo) · zustand · i18next + react-i18next · openapi-typescript + openapi-fetch · elkjs (workflow graph layout) · Vitest + Testing Library + MSW · Playwright · pnpm · Node 24

Spec: docs/superpowers/specs/2026-09-23-plan-10-frontend-design.md. Read it before any task. Its rulings F1–F21 bind every task. Also: the architecture spec §13 (frontend), §5.5 (semantics), §11A.3 (record locales), and AGENTS.md.

Figma: file Uz1j7iSQYOYdCLKTm6bRsT. Every task that builds an F screen (spec §5) reads its frames through the Figma MCP (get_design_context, get_screenshot) before writing JSX. Node ids are in spec §0. The Figma MCP times out on whole pages, so query single frames.

Every task’s requirements implicitly include this section.

  • A float never touches money. Amounts are strings end to end in web/. Formatting and the one subtraction (F16) live in web/src/lib/money.ts and use string/BigInt arithmetic only. parseFloat, Number(, toFixed, unary + and Math.* never touch an amount. Task 5’s drift test enforces this.
  • No hex colour outside web/src/styles/tokens.css. Components use Tailwind theme tokens only. Task 5’s drift test enforces this.
  • Every UI string is an i18n key, present in en, ms and zh. Bundles are per-feature namespaces at web/src/lib/i18n/locales/<locale>/<namespace>.json, so parallel tasks never edit the same file. Task 6’s parity test enforces this.
  • Server-supplied labels go through pickLabel(labels, locale) (Task 6). The UI never translates a state key, category or channel name itself.
  • The frontend never evaluates a gate, a threshold, a variance or a total. Buttons come from available_transitions, and every figure comes from the API (spec §4, F13).
  • No hand-written API types. Import request and response types from web/src/lib/api/schema.d.ts (generated) through the aliases in web/src/lib/api/types.ts (Task 5).
  • Access token in memory only (F7). Nothing auth-related goes in localStorage. The only localStorage keys are ui.locale, ui.theme and ui.sidebar.
  • Shared frontend files are append-only: web/src/app/routes.ts (one import and one array entry per feature), web/src/app/shell/nav.ts (nav items), web/package.json dependencies (add with pnpm add, never hand-edit; a lockfile conflict is resolved by re-running pnpm install). Every other file belongs to the task that creates it.
  • Backend ranges: migrations 00170–00189; provisioning steps 130–139 (none expected); permission org.roles.manage only; OpenAPI paths /me, /org/members, /org/permissions, /org/roles…, plus the additive query parameters and fields in spec §2. api/openapi.yaml, constants.yaml, api/public_routes.txt, internal/app/app.go and internal/app/routes_test.go are append-only. Generated files (internal/gen/…, packages/contracts/vocab.ts, internal/kernel/vocab/vocab.gen.go, web/src/lib/api/schema.d.ts) are regenerated with make generate, never hand-merged.
  • Go house rules apply to Tasks 1–4 unchanged: every request in a transaction, RLS on every business table, raw SQL literals with org_id = $1 on each tenant table, audit.Write on the same tx for every mutation, errors recorded with c.Error(apperr.New(...)), every route through httpx.Protected or listed in api/public_routes.txt with its argument, and presence before absence in every filter or refusal test.
  • Tests while iterating: Go runs only the touched packages (go test ./internal/modules/x/... -count=1). Web runs pnpm -C web test -- <path>. The one full go test -p 2 -count=1 ./... plus pnpm -C web test plus Playwright is in Task 28 only (user instruction: avoid full-suite Docker churn).
  • Runtime check: every screen task ends by loading its screen in the in-app browser (preview_start with the web launch config, Task 5) against the dev stack (Task 8 provides the demo org), at 1440 and 390 widths, light and dark, and en and zh. It records what was checked in the task report. A screen task is not done on green unit tests alone.
  • Accessibility floor: every interactive element is reachable by keyboard and has an accessible name. Dialogs and drawers trap focus and close on Esc. Chips carry text, not colour alone. Radix provides most of this; do not replace its primitives with divs.
  • Git commits end with Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.

Rulings (plan level; the spec’s F1–F21 also bind)

Section titled “Rulings (plan level; the spec’s F1–F21 also bind)”
  • P1 The router is code-based TanStack Router. Each feature exports routes from web/src/features/<f>/routes.tsx, and web/src/app/routes.ts concatenates them. This is the only shared routing file, and it is append-only.
  • P2 The nav config is web/src/app/shell/nav.ts: NavItem { group: 'workspace'|'requests'|'approvals'|'manage'; to: string; labelKey: string; icon: IconName; permission?: Permission | Permission[] (any-of) }. The shell filters by useCan.
  • P3 Icons are lucide-react. They are the closest match to the Figma icon set (wallet, coins, file, check, filter, download). Figma-only icons (the astronaut illustration, the logo) are exported as SVG assets via download_assets into web/src/assets/.
  • P4 Fonts: Roboto (Figma), self-hosted through @fontsource/roboto (400/500/700) plus @fontsource/noto-sans-sc for zh. No third-party font CDN, because records stay in Malaysia and the CSP is self.
  • P5 The query-key convention is [resource, params], e.g. ['claims', {scope, state, q, month}]. Mutations invalidate by resource prefix. The drawers’ ‹ › pager reads the list query’s cached pages; it never refetches to find a neighbour.
  • P6 The dev stack (Task 8): scripts/dev-stack.sh starts Postgres 17 in Docker, runs migrate up, then orgadmin create-org for the demo org, and runs web/e2e/seed.ts over the API to create members, roles, positions, a float and categories. Then it starts cmd/api and cmd/worker. .claude/launch.json has api and web entries. The seed goes through the public API, so it exercises the same paths the UI uses.
  • P7 Every list page puts its filters in typed search params (validateSearch with a zod schema). Refreshing or sharing the URL restores the view.
  • P8 Forms use react-hook-form with zod resolvers. A zod schema validates shape only (required, pattern, max length). Business rules are the server’s; 422 field errors map back through applyProblemToForm(form, problem) (Task 6).
api/openapi.yaml (T1–T4: additive)
constants.yaml (T2: + org.roles.manage)
api/public_routes.txt (T1: /v1/me, /v1/org/members)
db/migrations/00170_org_roles_manage.sql (T2)
internal/modules/identity/me_handlers.go (+_test) T1
internal/modules/orgstructure/member_directory.go (+_test) T1
internal/platform/identity/role/role.go (+_test) T2
internal/modules/identity/role_handlers.go (+_test) T2
internal/platform/expenses/claim.go, summary.go (+_test) T3
internal/modules/expenses/claim_handlers.go (+_test) T3
internal/platform/pettycash/…, internal/modules/pettycash/… T4 (to_act)
internal/platform/advances/summary.go, modules/advances/… T4
internal/gen/web_schema_test.go? → see Task 5 (drift test lives in tools/)
Makefile (T5: generate + web targets)
.github/workflows/ci.yml (T5: web job; T27: e2e job)
.claude/launch.json (T5)
scripts/dev-stack.sh (T8)
web/
package.json pnpm-lock.yaml tsconfig*.json vite.config.ts eslint.config.js
.prettierrc index.html Dockerfile nginx.conf (T5, T27)
e2e/ playwright.config.ts seed.ts *.spec.ts (T8, T28)
src/
main.tsx
app/ router.tsx routes.ts providers.tsx RootErrorBoundary.tsx
shell/ AppShell.tsx Sidebar.tsx TopBar.tsx OrgSwitcher.tsx UserMenu.tsx nav.ts (T8)
styles/ tokens.css globals.css (T5, T7)
lib/ api/{schema.d.ts,types.ts,client.ts,problem.ts,auth.ts,query.ts} (T5, T6)
money.ts date.ts cn.ts (T6)
i18n/{index.ts,pickLabel.ts,locales/<en|ms|zh>/<ns>.json} (T6)
components/ui/* (T7) components/workflow/* (T9) components/upload/* (T10)
components/money/* (T6)
features/
auth/ (T8) activity/ (T8) members/useMemberNames.ts (T9)
claims/ (T11–T13) settings/ (T14) approvals/ (T15) pettycash/ (T16, T26)
advances/ (T17) team/ (T18, T19) workflow/ (T20) payout/ (T21)
accounting/ (T22–T24) reports/ (T25)
test/ setup.ts msw/{server.ts,handlers.ts,fixtures.ts} (T5, T6)
drift/ nofloat.test.ts nohex.test.ts i18nparity.test.ts (T5, T6)

Task bodies follow the list. Each has Files, Interfaces (Consumes / Produces), and TDD steps with code.

# Task Group
1 Seams: GET /v1/me, GET /v1/org/members A
2 Seams: roles API + org.roles.manage + migration 00170 A
3 Seams: claims list filters, fields, /expenses/claims/summary A
4 Seams: petty cash to_act, /advances/summary A
4b Seams: close-out list, OrgChartHolder.assignment_id, payout channel names, Problem.code A (after 4)
5 Web scaffold, generated client, CI, drift tests (float, hex) B
6 Core libs: api client + auth refresh, problem, money, date, i18n + parity C
7 Design-system primitives and tokens from the Figma Components page C
8 Auth screens, app shell, nav, org switcher, activity; dev stack and seed D
9 Workflow components and member names D
10 Document upload and viewer D
11 Claims list E
12 Claim detail drawer E
13 Submit-claim wizard and draft editing E
14 Settings: my payout details, sessions, delegations E
15 Approvals queue F
16 Petty cash page, request, return, spend F
17 Advances list, detail, acquittal F
18 Team: members, invitations, roles F
19 Team: org structure and readiness F
20 Workflow definition editor G
21 Payout channel editor G
22 Chart of accounts G
23 Journal entries, manual journal, reversal G
24 Fiscal periods and close G
25 Reports and exports G
26 Float register (custodian view) G
27 Serving: Dockerfile, nginx, CSP, deploy notes H
28 End to end (Playwright), roadmap, full run H

A group starts when the previous group has merged. Tasks inside a group touch disjoint files, apart from the append-only files above, so they may run concurrently in separate worktrees and merge in number order.

Group Tasks Notes
A 1, 2, 3, 4 Go only. api/openapi.yaml is append-only; the second to merge re-runs make generate
B 5 Needs A’s merged openapi.yaml for the first generated schema
C 6, 7
D 8, 9, 10 9 and 10 use MSW only; 8 brings up the dev stack
E 11, 12, 13, 14 12 and 13 consume 11’s list query keys (P5)
F 15, 16, 17, 18, 19
G 20–26
H 27, then 28

Interface registry (binding on every task)

Section titled “Interface registry (binding on every task)”

These names and signatures are fixed here so that tasks written and built in parallel agree. A task that needs a change to one of these amends this section in its own commit and says so in its report.

Backend (Tasks 1–4), OpenAPI component names

Section titled “Backend (Tasks 1–4), OpenAPI component names”
  • Me = { user_id: uuid, email: string, name: string|null, member_id: uuid, organization: MeOrganization, organizations: OrgSummary[], permissions: string[] }
  • MeOrganization = { id: uuid, name: string, base_currency: string, country: string }
  • MemberDirectoryEntry = { member_id: uuid, name: string|null, active: boolean }, and MemberDirectory = { members: MemberDirectoryEntry[] }, operationId listMemberDirectory
  • getMe → Me
  • PermissionInfo = { key: string, module: string }, and PermissionCatalogue = { permissions: PermissionInfo[] }, operationId listPermissions
  • Role = { id: uuid, name: string, system: boolean, permissions: string[], member_ids: uuid[] }, and RoleList = { roles: Role[] }. The operationIds are listRoles, createRole ({name}), renameRole ({name}), setRolePermissions ({permissions: string[]}), setMemberRoles ({role_ids: uuid[]} → {member_id, role_ids})
  • The lockout refusals are 409 problems with type ending /conflict and code either owner_role_manage_required or self_lockout
  • ExpenseClaim gains created_at: date-time, submitted_at: date-time|null and category_ids: uuid[]
  • listExpenseClaims gains query params state, q and month (^\d{4}-(0[1-9]|1[0-2])$)
  • ExpenseClaimSummaryRow = { state: string, semantic: WorkflowSemantic, count: integer, total: Money }, and ExpenseClaimSummary = { currency: string, rows: ExpenseClaimSummaryRow[] }, operationId summarizeExpenseClaims, params scope, month
  • listPettyCashSpends and listPettyCashTopUps: scope enum gains to_act
  • AdvanceSummary = the same shape as ExpenseClaimSummary, operationId summarizeAdvances, param scope
// lib/api/types.ts — aliases only, all from schema.d.ts
export type Schemas = components['schemas'];
export type Me = Schemas['Me']; export type Money = string;
export type ExpenseClaim = Schemas['ExpenseClaim']; export type ExpenseClaimView = Schemas['ExpenseClaimView'];
export type WorkflowDefinition = Schemas['WorkflowDefinition']; export type WorkflowState = Schemas['WorkflowState'];
export type WorkflowSemantic = Schemas['WorkflowSemantic']; export type AvailableTransition = Schemas['WorkflowAvailableTransition'];
export type Labels = Schemas['WorkflowLabels']; // Record<string,string>
export type Permission = (typeof PERMISSIONS)[number] | 'org.roles.manage' | string; // from packages/contracts/vocab.ts
// …further aliases are added by the task that first needs them (append-only)
// lib/api/client.ts
export const api: ReturnType<typeof createClient<paths>>; // baseUrl '/v1', middleware below
export function setAccessToken(token: string | null): void;
export function getAccessToken(): string | null;
export function refreshSession(): Promise<boolean>; // single-flight; true = new token set
export function onSessionExpired(cb: () => void): () => void; // unsubscribe
export function newIdempotencyKey(): string; // crypto.randomUUID()
// lib/api/problem.ts
export class ProblemError extends Error {
status: number; type: string; title: string; detail?: string; code?: string;
errors?: { pointer: string; detail: string }[]; requestId?: string;
}
export function unwrap<T>(r: { data?: T; error?: unknown; response: Response }): T; // throws ProblemError
export function applyProblemToForm(form: UseFormReturn<any>, e: unknown): boolean; // true if mapped to fields
export function problemMessage(e: unknown, t: TFunction): string; // toast text
// lib/api/query.ts
export const queryClient: QueryClient; // reads retry 2 (not 4xx), mutations retry 0, staleTime 30s
// lib/money.ts (string arithmetic only)
export function minorUnits(currency: string): number; // MYR 2, JPY 0, default 2
export function formatMoney(amount: string, currency: string, locale: string, opts?: { sign?: 'auto'|'always'; code?: boolean }): string;
export function isMoneyInput(s: string): boolean; // ^\d{1,15}(\.\d{0,4})?$
export function normalizeMoneyInput(s: string): string; // "1.5" -> "1.5", "" -> ""
export function subtractMoney(a: string, b: string): string; // exact, BigInt of 1e-4 units
export function compareMoney(a: string, b: string): -1 | 0 | 1;
export function isZeroMoney(a: string): boolean;
// lib/date.ts (display in Asia/Kuala_Lumpur; wire stays ISO)
export function formatDate(iso: string, locale: string): string; // en "28 Aug 2026", zh "2026年8月28日"
export function formatDateTime(iso: string, locale: string): string;
export function todayISO(): string; // YYYY-MM-DD in KL
export function monthKey(d?: Date): string; // YYYY-MM in KL
export function relativeTime(iso: string, locale: string): string;
// lib/i18n
export const SUPPORTED_LOCALES = ['en', 'ms', 'zh'] as const; export type UiLocale = typeof SUPPORTED_LOCALES[number];
export function initI18n(): Promise<void>;
export function useLocale(): [UiLocale, (l: UiLocale) => void];
export function pickLabel(labels: Record<string, string> | undefined | null, locale: UiLocale): string; // locale → en → first → ''
export const RECORD_LOCALES = ['en', 'ms'] as const;
// lib/cn.ts
export function cn(...c: ClassValue[]): string; // clsx + tailwind-merge
// components/money
export function MoneyText(p: { amount: string; currency: string; sign?: 'auto'|'always'; className?: string }): JSX.Element;
export function MoneyInput(p: { value: string; onChange(v: string): void; currency: string; id?: string; invalid?: boolean; disabled?: boolean }): JSX.Element;

Radix-backed, token-styled, named exports, one file each: Button (variants primary | secondary | outline | ghost | danger | success-outline | danger-outline; sizes sm | md; loading), IconButton, Input, Textarea, Label, Field (label + control + hint + error), Select (options: {value,label}[]), Combobox (searchable, async options), Checkbox, RadioCards (the payment-method cards), DatePicker (ISO string in and out), MonthPicker, Tabs, Dialog (title, description, footer), Drawer (right side, header, sticky), ConfirmDialog, Tooltip, Tag (tone neutral | amber | green | green-solid | teal | purple | red | muted | blue), CountBadge, StatCard (tone, label, primary, secondary, icon), Card, DataTable (TanStack Table wrapper: columns, data, onRowClick, loading, empty, row aria-selected), EmptyState, Skeleton, LoadMore, Toaster + toast.success|error|info(msg), ProgressBar, Kbd, PageHeader (title, subtitle, actions), Breadcrumb, Spinner.

export function useMe(): UseQueryResult<Me>; // ['me']
export function useCan(p: Permission | Permission[]): boolean; // any-of
export function RequirePermission(p: { permission: Permission | Permission[]; children: ReactNode }): JSX.Element; // 403 panel otherwise
export function useSwitchOrg(): (orgId: string) => Promise<void>; // POST /auth/switch-org → setAccessToken → queryClient.clear()
export function useSignOut(): () => Promise<void>;
// app/shell/nav.ts
export type NavGroup = 'workspace' | 'requests' | 'approvals' | 'manage';
export interface NavItem { group: NavGroup; to: string; labelKey: string; icon: LucideIcon; permission?: Permission | Permission[]; badge?: () => number | undefined }
export const navItems: NavItem[]; // append-only
export function useWorkflowDefinition(kind: string, version?: number): UseQueryResult<WorkflowDefinition>; // staleTime Infinity for a version
export function semanticTone(s: WorkflowSemantic): TagTone; // spec §3.4 table
export function StatusChip(p: { kind: string; version?: number; state: string; semantic?: WorkflowSemantic }): JSX.Element;
export function StateFilterChips(p: { kind: string; counts: Record<string, number> | undefined; value: string | undefined; onChange(state: string | undefined): void; total?: number }): JSX.Element;
export function TransitionBar(p: { transitions: AvailableTransition[]; onTake(t: AvailableTransition, reason?: string): Promise<unknown>; layout?: 'bar' | 'inline' }): JSX.Element;
export function ReasonDialog(p: { open: boolean; title: string; onConfirm(reason: string): void; onCancel(): void }): JSX.Element;
export function DecisionTimeline(p: { kind: string; version: number; currentState: string; decisions: { from: string; to: string; actor_member_id: string; decided_at: string; reason?: string; action_key: string }[] }): JSX.Element;
export function useMemberNames(): (memberId: string | null | undefined) => string; // name, or '…' + last 4 of the id
export function useMemberDirectory(): UseQueryResult<MemberDirectoryEntry[]>;
export async function uploadDocument(file: File, onProgress?: (pct: number) => void, signal?: AbortSignal): Promise<{ id: string; filename: string; size: number; content_type: string }>;
export function DocumentUploader(p: { accept?: string; maxBytes?: number; multiple?: boolean; onUploaded(d: UploadedDoc): void; onRemoved?(id: string): void; value?: UploadedDoc[] }): JSX.Element; // default accept jpg/png/pdf, 10 MB
export function DocumentThumb(p: { documentId: string; onOpen?(): void }): JSX.Element;
export function DocumentViewer(p: { documentIds: string[]; index: number; open: boolean; onOpenChange(o: boolean): void }): JSX.Element;

Web: claims (Task 11, consumed by 12, 13, 15)

Section titled “Web: claims (Task 11, consumed by 12, 13, 15)”
export const claimKeys = { all: ['claims'] as const, list: (s: ClaimListSearch) => ['claims', 'list', s] as const, summary: (s: { scope: Scope; month?: string }) => ['claims', 'summary', s] as const, detail: (id: string) => ['claims', 'detail', id] as const };
export type Scope = 'mine' | 'to_act' | 'all';
export interface ClaimListSearch { scope: Scope; state?: string; q?: string; month?: string; group?: string }
export function useClaimList(s: ClaimListSearch): UseInfiniteQueryResult<ExpenseClaim[]>; // pages of 10 (F10)
export function useClaim(id: string): UseQueryResult<ExpenseClaimView>;
export function useClaimTransition(id: string): UseMutationResult<ExpenseClaimView, ProblemError, { to: string; reason?: string; revision: number }>;
export function useNeighbours<T extends { id: string }>(items: T[], id: string): { index: number; total: number; prev?: string; next?: string }; // components/workflow/useNeighbours.ts (Task 9)

Registry amendments (from assembling the task bodies; these override anything above)

Section titled “Registry amendments (from assembling the task bodies; these override anything above)”

The task bodies below were written in parallel against the registry above, and the writers flagged where it was incomplete. These amendments settle each point. Where a task body disagrees with this section, this section wins. Every consumer task starts with a Step 0: verify imports: list the real exports of the tasks it consumes and adapt import paths and components/ui prop names in its own code, never in the other task’s file.

Routing (owner: Task 8).

  • @/app/layouts exports rootRoute, publicRoute and appRoute. appRoute is the signed-in layout route, the parent of every feature route.
  • @/app/routes exports appRoutes and publicRoutes. Each feature’s routes.tsx exports routes (children of appRoute) and optionally publicRoutes.
  • @/app/router exports router and also buildRouteTree(opts: { shell: boolean }), for tests. It does not export a layout route.
  • Where a task body says featureRoutes, read appRoutes.
  • Where Part C’s R2 text says router.tsx exports appRoute, read @/app/layouts.

Test helpers (owner: Task 8, append-only afterwards).

  • @/test/renderWithApp: Part C’s R1 code (route-based: renderWithApp({ path, permissions, members?, shell? })).
  • @/test/render: Task 5/6’s renderWithQuery and createTestQueryClient, plus renderWithProviders(ui, { permissions?, locale? }) (element-based, used by Tasks 20–26). Both helpers stub GET /v1/me and GET /v1/org/members with MSW.
  • @/test/msw/* fixtures are Task 6’s. Workflow fixtures are in components/workflow/__fixtures__/ (Task 9).

Signature changes.

  • TransitionBar props gain kind?: string; version?: number, so reject and cancel buttons get the danger style.
  • DecisionTimeline makes version? and decisions[].action_key? optional. Claim, advance and petty-cash decisions carry neither.
  • useClaimTransition(id) mutation variables are { from: string; to: string; reason?: string; payout_channel_id?: string; proof_document_id?: string }, sent with a fresh Idempotency-Key. The endpoint takes no If-Match.
  • subtractMoney accepts and returns signed values (Task 23’s running difference).
  • ProblemError.retryAfter?: number. applyTokenResponse(t: TokenResponse) (Task 6) sets the token and caches the organizations.
  • Task 7 adds DropdownMenu (row overflow menus) and exports TagTone, buttonVariants, and DataTable.renderCard (the card layout below 768px).
  • Task 10 exports the UploadedDoc type, plus UploadError and useDocument.
  • Task 14 produces FieldSchemaForm at web/src/features/settings/payout/FieldSchemaForm.tsx, props { fields, values, onChange(key, value), errors?, hiddenSaved?, disabled?, idPrefix? }, which is what Task 14’s body builds. Task 21 consumes it with that API: its own preview adapts, and it imports from settings/payout/.
  • Task 4 also produces the unexported helpers actableByOthers, mayActNow, toActCeiling and toActCandidates in the pettycash platform package, which Task 4b reuses.
  • web/src/app/routes.ts and web/src/app/shell/nav.ts have no generator. When parallel tasks merge, keep every side’s entries (a union), then run pnpm -C web typecheck.
  • Task 12 produces ClaimDrawer, which Task 15 consumes. Task 15 produces useApprovalsCount (the nav badge). Task 16 produces pettyCashKeys.
  • Query keys are shared per Part C convention C4.

Backend amendments.

  • Problem gains an optional code.
  • AdvanceSummaryRow exists.
  • Both summaries return server-computed kpis buckets (Tasks 3, 4), so no KPI total is summed in the browser (F13).
  • Seeded roles cannot be renamed (422), because Owner is found by name.
  • Task 4b adds the petty-cash close-out list, OrgChartHolder.assignment_id, and payout channel names.

Ordering (replaces the parallel-groups table where they differ).

  • A: 1, 2, 3, 4 in parallel, then 4b.
  • B: 5.
  • C: 6, then 7 (7 needs cn, i18n and the test setup).
  • D: 8, then 9 and 10 in parallel.
  • E: 14, then 11, then 12, then 13 (the list links to the drawer and wizard routes, and the wizard links to /settings/payout).
  • F: 15–19 in parallel (15 after 12’s ClaimDrawer, which E guarantees).
  • G: 22 first (23–25 import its queries), then 20, 21, 23, 24, 25 and 26 in parallel.
  • H: 27, then 28.

Rulings added at assembly.

  • F20 presets:
    • The Employee preset includes workflow.definition.read. Status chips and the submit button need the definition, and seeded non-Owner roles hold nothing.
    • The Admin preset includes org.roles.manage and org.members.invite.
    • The invite dialog needs both, because its role picker lists roles.
  • F18 CSP, amended: connect-src and frame-src also allow the object store’s public origin. Uploads use presigned PUTs and the PDF viewer frames presigned GETs. style-src 'self' 'unsafe-inline' is allowed for Radix’s injected styles. No 'unsafe-eval' (Task 27 checks elkjs under the CSP).
  • Tone divergence accepted: in the seeded graph approved and reviewed carry the pending semantic, so they render amber, whereas Figma shows green and blue. F9 is kept, because colour by semantic is what makes tenant-renamed steps render correctly. A tenant who wants green there can change the step’s semantic.
  • Org choice on reload: a reload returns to the default organization. The chosen org is held in memory only, per F7.

API follow-ups deliberately left out of Plan 10 (for the roadmap, not blockers):

  • A manual journal post takes no Idempotency-Key. The client never auto-retries writes, but a double click on a slow network could post twice. The button is disabled while pending.
  • Reversal has no reason field.
  • The advances list has no state or month filters.
  • There is no petty-cash summary endpoint.
  • Advances, spends and top-ups carry no timestamps.
  • The current step’s eligible approvers are not returned.
  • The claims list has no supplier or foreign-currency column.
  • Accept-Language must carry the UI locale for payout channel labels (Task 6’s client sends it).

Task 1: Seams — GET /v1/me (S1) and GET /v1/org/members (S2)

Section titled “Task 1: Seams — GET /v1/me (S1) and GET /v1/org/members (S2)”

Group A. Go only. Runs beside Tasks 2–4. It shares api/openapi.yaml, api/public_routes.txt and internal/modules/identity/routes.go with Task 2. All three are append-only, so the second task to merge resolves the conflict by keeping both sides and re-running make generate.

Where each endpoint lives, and why:

  • GET /v1/me goes in internal/modules/identity. It reads the caller’s login, membership, permissions and organizations, which is the identity module’s data. The email is on users, which is RLS-exempt (db/rls_exempt.txt). TestModulesNeverReferenceRLSExemptTables (internal/kernel/layering) text-searches every file under internal/modules for that table name, comments included. So the email is read in internal/platform/identity/member, whose package comment makes it “the only place” that joins users to organization_member. Do not write the word users in any file under internal/modules.
  • GET /v1/org/members goes in internal/modules/orgstructure, as the plan’s File Structure says. The query lives in the same new platform file, beside LoadProfile, because a member’s name is party.legal_name reached through organization_member.party_id. That join is the member package’s.

Files:

  • Create: internal/platform/identity/member/profile.go: Profile, LoadProfile, DirectoryEntry, Directory. This file is new and is not in the plan’s File Structure; see Part A notes.
  • Test: internal/platform/identity/member/profile_test.go (new). It reuses memberSetup, seedOrg and seedMember from member_test.go in the same member_test package.
  • Create: internal/modules/identity/me_handlers.go
  • Test: internal/modules/identity/me_handlers_test.go (new). It reuses apiSetupTwoOrgs, grantExtra, seedCompanyProfile and accessTokenOf from the existing identity_test files.
  • Modify: internal/modules/identity/routes.go (one route and its comment)
  • Modify: internal/modules/identity/routes_test.go (append one test)
  • Create: internal/modules/orgstructure/member_directory.go
  • Test: internal/modules/orgstructure/member_directory_test.go (new). It reuses newEnv, tenant, seedMember, mint, do and decode from handlers_test.go.
  • Modify: internal/modules/orgstructure/routes.go (one route and its comment)
  • Modify: internal/modules/orgstructure/routes_test.go. Its gin-table assertion currently requires every /v1/org/ route to be Protected.
  • Modify: api/public_routes.txt (append to [no-permission], above the [outside-v1] header)
  • Modify: api/openapi.yaml (two paths and four schemas), then make generate

Interfaces:

  • Consumes: httpx.IdentityFromContext; (*db.DB).InTx; member.Permissions(ctx, tx, memberID) (map[string]struct{}, error); member.MembershipsOnTx(ctx, tx, userID) ([]member.Membership, error); member.ErrNotAMember; company.Get(ctx, tx) (company.Profile, error) and company.ErrNotProvisioned; summaries(ms []member.Membership) []orgSummaryDTO (internal/modules/identity/handlers.go); tenancy.OrgFromContext.

  • Produces:

    package member // internal/platform/identity/member/profile.go
    type Profile struct{ Email, LegalName, OrgName string }
    func LoadProfile(ctx context.Context, tx pgx.Tx, userID, memberID uuid.UUID) (Profile, error) // ErrNotAMember
    type DirectoryEntry struct {
    MemberID uuid.UUID
    Name string
    Active bool
    }
    func Directory(ctx context.Context, tx pgx.Tx) ([]DirectoryEntry, error) // legal name, then id
    package identity // GET /v1/me operationId getMe → Me
    func (h handlers) me(c *gin.Context)
    package orgstructure // GET /v1/org/members operationId listMemberDirectory → MemberDirectory
    func (h handlers) memberDirectory(c *gin.Context)

    OpenAPI components Me, MeOrganization, MemberDirectoryEntry and MemberDirectory, exactly as the interface registry gives them.

  • Step 1: Write the failing platform tests. internal/platform/identity/member/profile_test.go:

package member_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/tenancy"
"github.com/mcom/erp/internal/platform/identity/identitytest"
"github.com/mcom/erp/internal/platform/identity/member"
)
// TestLoadProfileReadsTheLoginEmailAndThePartyName: PRESENCE — the caller's
// own membership resolves to their email, legal name and organization;
// ABSENCE — the same member id presented with somebody else's user id, or
// under another organization's context, resolves to nothing.
func TestLoadProfileReadsTheLoginEmailAndThePartyName(t *testing.T) {
d, h, ctx, org := memberSetup(t)
userID := identitytest.SeedUser(t, h.Admin, "aisyah@example.com", "not-a-real-hash")
m := seedMember(t, h.Admin, org, userID, "E-001")
var got member.Profile
require.NoError(t, d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
var err error
got, err = member.LoadProfile(ctx, tx, userID, m)
return err
}))
require.Equal(t, member.Profile{
Email: "aisyah@example.com", LegalName: "Party for E-001", OrgName: "Alpha",
}, got)
err := d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
_, err := member.LoadProfile(ctx, tx, uuid.New(), m)
return err
})
require.ErrorIs(t, err, member.ErrNotAMember,
"a member id is only ever read together with the user it belongs to")
other := seedOrg(t, h.Admin, "Beta")
err = d.InTx(tenancy.WithOrg(context.Background(), other), func(ctx context.Context, tx pgx.Tx) error {
_, err := member.LoadProfile(ctx, tx, userID, m)
return err
})
require.ErrorIs(t, err, member.ErrNotAMember,
"another organization's context cannot see this membership")
}
// TestDirectoryIsThisOrganizationsMembersActiveOrNot: PRESENCE — Beta's
// directory, read in Beta, holds Beta's member, so the query reaches rows;
// ABSENCE — Alpha's does not hold it. Alpha's leaver is listed, flagged
// inactive: old claims still name them.
func TestDirectoryIsThisOrganizationsMembersActiveOrNot(t *testing.T) {
d, h, ctx, org := memberSetup(t)
a := seedMember(t, h.Admin, org, uuid.New(), "E-001")
b := seedMember(t, h.Admin, org, uuid.New(), "E-002")
_, err := h.Admin.Exec(context.Background(),
`UPDATE organization_member SET active = false WHERE id = $1`, b)
require.NoError(t, err)
other := seedOrg(t, h.Admin, "Beta")
c := seedMember(t, h.Admin, other, uuid.New(), "E-900")
read := func(ctx context.Context) []member.DirectoryEntry {
t.Helper()
var out []member.DirectoryEntry
require.NoError(t, d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
var err error
out, err = member.Directory(ctx, tx)
return err
}))
return out
}
require.Equal(t, []member.DirectoryEntry{{MemberID: c, Name: "Party for E-900", Active: true}},
read(tenancy.WithOrg(context.Background(), other)))
require.Equal(t, []member.DirectoryEntry{
{MemberID: a, Name: "Party for E-001", Active: true},
{MemberID: b, Name: "Party for E-002", Active: false},
}, read(ctx))
}
  • Step 2: Run them and watch them fail. go test ./internal/platform/identity/member/ -run 'TestLoadProfile|TestDirectory' -count=1 fails to compile with undefined: member.Profile, member.LoadProfile, member.DirectoryEntry and member.Directory.

  • Step 3: Implement. internal/platform/identity/member/profile.go:

package member
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/tenancy"
)
// Profile is what a signed-in member is told about themselves (GET /v1/me):
// the login's email, their legal name in this organization, and the
// organization's name.
type Profile struct {
Email string
LegalName string
OrgName string
}
// LoadProfile reads the caller's own profile. userID and memberID both come
// from the verified claim, and the membership row must carry both: a member
// id is never read on its own, so a claim pairing somebody's user id with
// somebody else's member id reads nothing. A missing or inactive membership
// is ErrNotAMember, as in ForUser.
//
// The email lives on the global login shell, which only this package may
// join to organization_member (member.go). It is read by the user id the
// membership row has just confirmed.
func LoadProfile(ctx context.Context, tx pgx.Tx, userID, memberID uuid.UUID) (Profile, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return Profile{}, fmt.Errorf("member: profile: %w", tenancy.ErrNoOrg)
}
var p Profile
err := tx.QueryRow(ctx, `
SELECT pa.legal_name, o.name
FROM organization_member m
JOIN party pa ON pa.org_id = m.org_id AND pa.id = m.party_id
JOIN organization o ON o.org_id = m.org_id AND o.id = m.org_id
WHERE m.org_id = $1 AND m.id = $2 AND m.user_id = $3 AND m.active`,
orgID, memberID, userID).Scan(&p.LegalName, &p.OrgName)
if errors.Is(err, pgx.ErrNoRows) {
return Profile{}, ErrNotAMember
}
if err != nil {
return Profile{}, fmt.Errorf("member: profile: %w", err)
}
// email is CITEXT, an extension type with no fixed OID; the cast makes
// the scan target unambiguous.
if err := tx.QueryRow(ctx, `SELECT email::text FROM users WHERE id = $1`, userID).
Scan(&p.Email); err != nil {
return Profile{}, fmt.Errorf("member: profile email: %w", err)
}
return p, nil
}
// DirectoryEntry is one member as the directory shows them: an id, the
// legal name, and whether they are still active. Nothing else about a
// member is in it.
type DirectoryEntry struct {
MemberID uuid.UUID
Name string
Active bool
}
// Directory is every member of the context organization, active or not,
// ordered by legal name and then id. Inactive members are included because
// records they filed or decided still name them.
func Directory(ctx context.Context, tx pgx.Tx) ([]DirectoryEntry, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return nil, fmt.Errorf("member: directory: %w", tenancy.ErrNoOrg)
}
rows, err := tx.Query(ctx, `
SELECT m.id, pa.legal_name, m.active
FROM organization_member m
JOIN party pa ON pa.org_id = m.org_id AND pa.id = m.party_id
WHERE m.org_id = $1
ORDER BY pa.legal_name, m.id`, orgID)
if err != nil {
return nil, fmt.Errorf("member: directory: %w", err)
}
defer rows.Close()
out := []DirectoryEntry{}
for rows.Next() {
var e DirectoryEntry
if err := rows.Scan(&e.MemberID, &e.Name, &e.Active); err != nil {
return nil, fmt.Errorf("member: directory scan: %w", err)
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("member: directory: %w", err)
}
return out, nil
}
  • Step 4: Run and pass. Run go test ./internal/platform/identity/member/ -count=1, then go test ./internal/kernel/layering/ -count=1. The layering run covers the tenant-predicate guard, whose union-find must accept pa.org_id = m.org_id and o.org_id = m.org_id. It also shows the file-level guards that walk internal/platform still pass. Expected: PASS.

  • Step 5: Write the failing HTTP tests for /v1/me. internal/modules/identity/me_handlers_test.go:

package identity_test
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/httpx"
"github.com/mcom/erp/internal/modules/identity"
)
type meResp struct {
UserID uuid.UUID `json:"user_id"`
Email string `json:"email"`
Name *string `json:"name"`
MemberID uuid.UUID `json:"member_id"`
Organization struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
BaseCurrency string `json:"base_currency"`
Country string `json:"country"`
} `json:"organization"`
Organizations []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"organizations"`
Permissions []string `json:"permissions"`
}
func (e *apiEnv) me(t *testing.T, tok string) meResp {
t.Helper()
w := e.get("/v1/me", tok)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var out meResp
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out), w.Body.String())
return out
}
// TestMeIsTheCallerFromTheirOwnClaim: every field comes from the signed
// claim's ids, and nothing in the request can name anybody else.
func TestMeIsTheCallerFromTheirOwnClaim(t *testing.T) {
e := apiSetupTwoOrgs(t)
seedCompanyProfile(t, e, e.orgA)
tok := e.login(t)
got := e.me(t, tok)
require.Equal(t, e.userID, got.UserID)
require.Equal(t, "ziyang@example.com", got.Email)
require.NotNil(t, got.Name)
require.Equal(t, "Party for E-001", *got.Name)
require.Equal(t, e.memberIn[e.orgA], got.MemberID)
require.Equal(t, e.orgA, got.Organization.ID.String())
require.Equal(t, "Alpha", got.Organization.Name)
require.Equal(t, "MYR", got.Organization.BaseCurrency)
require.Equal(t, "MY", got.Organization.Country)
require.Len(t, got.Organizations, 2, "every organization the caller may switch to")
require.Equal(t, []string{"identity.org.administer"}, got.Permissions)
require.Equal(t, http.StatusUnauthorized, e.get("/v1/me", "").Code)
}
// TestMePermissionsAreThisOrganizationsOnly: PRESENCE — a key granted in Beta
// shows on Beta's /me; ABSENCE — it does not show on Alpha's.
func TestMePermissionsAreThisOrganizationsOnly(t *testing.T) {
e := apiSetupTwoOrgs(t)
grantExtra(t, e.h.Admin, uuid.MustParse(e.orgB), e.memberIn[e.orgB], "org.members.invite")
tok := e.login(t)
w := e.postAuth("/v1/auth/switch-org", fmt.Sprintf(`{"org_id":%q}`, e.orgB), tok)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
inB := e.me(t, accessTokenOf(t, w))
require.Equal(t, e.orgB, inB.Organization.ID.String())
require.Contains(t, inB.Permissions, "org.members.invite")
require.Empty(t, inB.Organization.BaseCurrency,
"Beta has no company profile; the field is empty rather than invented")
require.NotContains(t, e.me(t, tok).Permissions, "org.members.invite")
}
// TestMeCarriesNoPermission: PRESENCE — the route is registered; ABSENCE —
// it records no permission. api/public_routes.txt carries the argument.
func TestMeCarriesNoPermission(t *testing.T) {
gin.SetMode(gin.TestMode)
httpx.ResetAuthorizedRoutes()
r, api := httpx.NewRouter(httpx.Deps{})
identity.Register(api, httpx.PublicPaths(r, testPublicRoutes...), identity.Deps{})
var registered bool
for _, ri := range r.Routes() {
if ri.Method == http.MethodGet && ri.Path == "/v1/me" {
registered = true
}
}
require.True(t, registered, "GET /v1/me is not registered; the absence below would be vacuous")
_, guarded := httpx.AuthorizedRoutes()["GET /v1/me"]
require.False(t, guarded, "GET /v1/me must carry no permission: a member holding none must still learn that")
}

TestMeCarriesNoPermission goes in this file rather than in routes_test.go, so Task 2’s edits to routes_test.go never overlap this task’s.

  • Step 6: Run and watch fail. go test ./internal/modules/identity/ -run 'TestMe' -count=1 fails. The two HTTP tests get a 404 problem (“no such route”) where they expect 200. TestMeCarriesNoPermission fails on registered.

  • Step 7: Implement /v1/me. internal/modules/identity/me_handlers.go:

package identity
import (
"context"
"errors"
"net/http"
"sort"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
apperr "github.com/mcom/erp/internal/kernel/errors"
"github.com/mcom/erp/internal/kernel/httpx"
"github.com/mcom/erp/internal/platform/company"
"github.com/mcom/erp/internal/platform/identity/member"
)
// GET /v1/me (Plan 10, S1): who is signed in, where, and what they may do.
//
// Every id it reads comes from the verified claim. Permissions are read the
// same way httpx.Authorize reads them (member.Permissions); the token itself
// carries only a permission version, never the set. The organization's base
// currency and country come from the company profile, and are empty strings
// until that profile is provisioned. They are not invented, because a
// guessed currency would format every amount wrong.
type meOrganizationDTO struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
BaseCurrency string `json:"base_currency"`
Country string `json:"country"`
}
type meDTO struct {
UserID uuid.UUID `json:"user_id"`
Email string `json:"email"`
Name *string `json:"name"`
MemberID uuid.UUID `json:"member_id"`
Organization meOrganizationDTO `json:"organization"`
Organizations []orgSummaryDTO `json:"organizations"`
Permissions []string `json:"permissions"`
}
func (h handlers) me(c *gin.Context) {
caller, ok := httpx.IdentityFromContext(c.Request.Context())
if !ok {
_ = c.Error(apperr.New(apperr.KindUnauthorized, "authentication required"))
return
}
var out meDTO
err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
prof, err := member.LoadProfile(ctx, tx, caller.UserID, caller.MemberID)
if err != nil {
return err
}
held, err := member.Permissions(ctx, tx, caller.MemberID)
if err != nil {
return err
}
perms := make([]string, 0, len(held))
for k := range held {
perms = append(perms, k)
}
sort.Strings(perms)
org := meOrganizationDTO{ID: caller.OrgID, Name: prof.OrgName}
cp, err := company.Get(ctx, tx)
switch {
case errors.Is(err, company.ErrNotProvisioned):
// Left empty: see the file comment.
case err != nil:
return err
default:
org.BaseCurrency, org.Country = cp.BaseCurrency, cp.CountryCode
}
// Last, because it sets and then clears app.current_user on this
// transaction (member.MembershipsOnTx says why), and nothing after
// it reads a tenant table.
ms, err := member.MembershipsOnTx(ctx, tx, caller.UserID)
if err != nil {
return err
}
out = meDTO{
UserID: caller.UserID, Email: prof.Email, MemberID: caller.MemberID,
Organization: org, Organizations: summaries(ms), Permissions: perms,
}
if prof.LegalName != "" {
name := prof.LegalName
out.Name = &name
}
return nil
})
if err != nil {
if errors.Is(err, member.ErrNotAMember) {
// Deactivated between Authenticate's liveness check and this
// read. The next request fails liveness too; the remedy is the
// same as there.
_ = c.Error(apperr.New(apperr.KindUnauthorized, "the session is no longer valid"))
return
}
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, out)
}

In internal/modules/identity/routes.go, below g.DELETE("/auth/sessions/:id", h.revokeSession):

// The caller's own identity, organization and permissions (Plan 10,
// S1). Like switch-org and the session routes it acts on nothing but
// the claim, so it carries no permission, and api/public_routes.txt
// says why. A member holding no grant must still be able to learn so.
g.GET("/me", h.me)
  • Step 8: Write the failing directory test. internal/modules/orgstructure/member_directory_test.go:
package orgstructure_test
import (
"context"
"net/http"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
type directoryResp struct {
Members []struct {
MemberID uuid.UUID `json:"member_id"`
Name *string `json:"name"`
Active bool `json:"active"`
} `json:"members"`
}
// TestTheDirectoryNamesThisOrganizationsMembersToAnyMember: PRESENCE —
// Beta's directory, read by Beta, holds Beta's manager; ABSENCE — Alpha's,
// read by an Alpha member holding NO permission at all, does not. Alpha's
// leaver is listed and flagged.
func TestTheDirectoryNamesThisOrganizationsMembersToAnyMember(t *testing.T) {
e := newEnv(t)
alpha := e.tenant(t, "Alpha")
beta := e.tenant(t, "Beta")
nobody := seedMember(t, e.h.Admin, alpha.org, "Alpha Nobody")
leaver := seedMember(t, e.h.Admin, alpha.org, "Alpha Leaver")
_, err := e.h.Admin.Exec(context.Background(),
`UPDATE organization_member SET active = false WHERE id = $1`, leaver)
require.NoError(t, err)
w := e.do(t, http.MethodGet, "/v1/org/members", beta.readTok, nil)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
betaNames := map[uuid.UUID]string{}
for _, m := range decode[directoryResp](t, w).Members {
betaNames[m.MemberID] = *m.Name
}
require.Equal(t, "Beta Manager", betaNames[beta.manager])
w = e.do(t, http.MethodGet, "/v1/org/members", e.mint(t, alpha.org, nobody), nil)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
got := decode[directoryResp](t, w)
names, active := map[uuid.UUID]string{}, map[uuid.UUID]bool{}
for _, m := range got.Members {
require.NotNil(t, m.Name)
names[m.MemberID], active[m.MemberID] = *m.Name, m.Active
}
require.Len(t, got.Members, 4, "manager, reader, nobody and the leaver")
require.Equal(t, "Alpha Manager", names[alpha.manager])
require.Equal(t, "Alpha Nobody", names[nobody])
require.True(t, active[nobody])
require.False(t, active[leaver], "a leaver is listed so old records still resolve, flagged inactive")
require.NotContains(t, names, beta.manager)
require.Equal(t, http.StatusUnauthorized, e.do(t, http.MethodGet, "/v1/org/members", "", nil).Code)
}

Replace the whole of TestEveryRouteRequiresItsExactPermission in internal/modules/orgstructure/routes_test.go, keeping its doc comment and adding one paragraph to it, with:

// ...existing doc comment, plus:
//
// GET /v1/org/members, the member directory (Plan 10, S2), is the one route
// under /v1/org this module registers WITHOUT Protected. That is on purpose,
// and api/public_routes.txt argues it. It is asserted present, then asserted
// unguarded, so neither half passes vacuously.
func TestEveryRouteRequiresItsExactPermission(t *testing.T) {
gin.SetMode(gin.TestMode)
httpx.ResetAuthorizedRoutes()
r, api := httpx.NewRouter(httpx.Deps{})
orgstructure.Register(api, orgstructure.Deps{})
want := map[string]string{
"GET /v1/org/units": orgstructure.PermissionStructureRead,
"POST /v1/org/units": orgstructure.PermissionStructureManage,
"PATCH /v1/org/units/:id": orgstructure.PermissionStructureManage,
"GET /v1/org/positions": orgstructure.PermissionStructureRead,
"POST /v1/org/positions": orgstructure.PermissionStructureManage,
"PATCH /v1/org/positions/:id": orgstructure.PermissionStructureManage,
"POST /v1/org/members/:id/positions": orgstructure.PermissionStructureManage,
"DELETE /v1/org/members/:id/positions/:pid": orgstructure.PermissionStructureManage,
"GET /v1/org/chart": orgstructure.PermissionStructureRead,
}
require.Equal(t, want, httpx.AuthorizedRoutes())
require.Equal(t, "org.structure.read", orgstructure.PermissionStructureRead)
require.Equal(t, "org.structure.manage", orgstructure.PermissionStructureManage)
const directory = "GET /v1/org/members"
var registered []string
var sawDirectory bool
for _, ri := range r.Routes() {
key := ri.Method + " " + ri.Path
if key == directory {
sawDirectory = true
continue
}
if strings.HasPrefix(ri.Path, "/v1/org/") {
registered = append(registered, key)
}
}
require.True(t, sawDirectory, "the member directory is not registered; the absence below would be vacuous")
_, guarded := httpx.AuthorizedRoutes()[directory]
require.False(t, guarded, "the member directory carries no permission (api/public_routes.txt)")
require.Len(t, registered, len(want), "routes: %v", registered)
for _, k := range registered {
require.Contains(t, want, k)
}
}

(Add "strings" to that file’s imports.)

  • Step 9: Run and watch fail. go test ./internal/modules/orgstructure/ -run 'TestTheDirectory|TestEveryRouteRequires' -count=1 fails. The directory test gets 404 (no such route), and the routes test fails sawDirectory.

  • Step 10: Implement the directory. internal/modules/orgstructure/member_directory.go:

package orgstructure
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/platform/identity/member"
)
// GET /v1/org/members (Plan 10, S2): the names of this organization's
// members. Claims, advances and decisions carry member ids only; this is
// what turns them into names on a screen.
//
// It carries no permission. It exposes an id, a legal name and an active
// flag, and nothing else about a member: no email, no position, no payout
// details. It exposes them only for the caller's own organization, which
// member.Directory names in its WHERE clause and RLS confines again.
// api/public_routes.txt carries the full argument.
type memberDirectoryEntryDTO struct {
MemberID uuid.UUID `json:"member_id"`
Name *string `json:"name"`
Active bool `json:"active"`
}
func (h handlers) memberDirectory(c *gin.Context) {
var rows []member.DirectoryEntry
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
rows, err = member.Directory(ctx, tx)
return err
}); err != nil {
_ = c.Error(err)
return
}
out := make([]memberDirectoryEntryDTO, 0, len(rows))
for _, r := range rows {
d := memberDirectoryEntryDTO{MemberID: r.MemberID, Active: r.Active}
if r.Name != "" {
name := r.Name
d.Name = &name
}
out = append(out, d)
}
c.JSON(http.StatusOK, gin.H{"members": out})
}

In internal/modules/orgstructure/routes.go, after the last httpx.Protected line, add the route below. Also extend the Register doc comment’s last paragraph: “every route but the member directory goes through httpx.Protected”.

// The member directory (Plan 10, S2) is the one route here with no
// permission: names and ids of the caller's own organization only.
// api/public_routes.txt carries the argument, and routes_test.go asserts
// it is present and unguarded.
g.GET("/org/members", h.memberDirectory)
  • Step 11: Declare both exemptions. Append to api/public_routes.txt, in the [no-permission] section, directly above the [outside-v1] header line:
# --- who the caller is, and whom they work with (Plan 10, S1 and S2) ----
# These run behind Authenticate and Tenant, so the caller is a verified
# member of a live session in this organization.
#
# me answers about the caller and nobody else. Every id it reads
# (user, member, organization) is taken from the signed
# claim, never from the request, and every query names that
# id. The permissions it lists are the caller's own, read
# exactly as httpx.Authorize reads them. Gating it on a
# permission would leave a member who holds none unable to
# learn that, and the browser could not decide what to render.
# org/members names the members of the caller's own organization: id,
# legal name, active. member.Directory names that
# organization in its WHERE clause and RLS confines it again.
# These are the names a member already meets on the records
# they can read, such as a claim's approver or a decision's
# actor. Nothing else about a member is exposed: no email, no
# position, no payout details. Every seeded role but Owner
# holds no grants, so a permission here would leave every
# ordinary member reading raw ids where names belong (Plan 10
# spec, F21).
GET /v1/me
GET /v1/org/members
  • Step 12: The contract. In api/openapi.yaml, insert these paths directly above the top-level components: line, which ends the paths: map:
# --- the caller, and the people they work with (Plan 10, S1 and S2) ---
/me:
get:
operationId: getMe
summary: Who is signed in, in which organization, and what they may do
description: >
Everything is read from the caller's verified claim: no parameter
names a user, member or organization. permissions are the member's
effective keys in this organization, read the way the server's
authorization check reads them; the token carries only a permission
version, never the set. organization.base_currency and
organization.country are empty strings until the company profile is
provisioned. Carries no permission (api/public_routes.txt).
responses:
'200':
description: The caller
content:
application/json:
schema:
$ref: '#/components/schemas/Me'
'401':
$ref: '#/components/responses/Unauthenticated'
/org/members:
get:
operationId: listMemberDirectory
summary: The names of this organization's members
description: >
Every member of the caller's organization, active or not, by legal
name, so that ids on claims, advances and decisions can be shown as
names. Nothing else about a member is exposed. Ordered by name.
Carries no permission (api/public_routes.txt).
responses:
'200':
description: The directory
content:
application/json:
schema:
$ref: '#/components/schemas/MemberDirectory'
'401':
$ref: '#/components/responses/Unauthenticated'

Append these schemas at the end of the file, where components.schemas ends (after CreateAdvanceRequest, at the same four-space indent):

Me:
type: object
required: [user_id, email, name, member_id, organization, organizations, permissions]
properties:
user_id: { type: string, format: uuid }
email: { type: string, format: email }
name:
type: string
nullable: true
description: The member's legal name in this organization (party.legal_name).
member_id: { type: string, format: uuid }
organization: { $ref: '#/components/schemas/MeOrganization' }
organizations:
type: array
description: Every organization the caller may switch to, by name.
items: { $ref: '#/components/schemas/OrgSummary' }
permissions:
type: array
description: The member's effective permission keys in this organization, sorted.
items: { type: string }
MeOrganization:
type: object
required: [id, name, base_currency, country]
properties:
id: { type: string, format: uuid }
name: { type: string }
base_currency:
type: string
description: ISO 4217 code. Empty until the company profile is provisioned.
country:
type: string
description: ISO 3166-1 alpha-2 code. Empty until the company profile is provisioned.
MemberDirectoryEntry:
type: object
required: [member_id, name, active]
properties:
member_id: { type: string, format: uuid }
name: { type: string, nullable: true }
active: { type: boolean }
MemberDirectory:
type: object
required: [members]
properties:
members:
type: array
items: { $ref: '#/components/schemas/MemberDirectoryEntry' }

Run make generate. It regenerates internal/gen/api/api.gen.go; sqlc and vocab output are unchanged.

  • Step 13: Run everything this task touched.
go test ./internal/platform/identity/member/ ./internal/modules/identity/ ./internal/modules/orgstructure/ -count=1
go test ./internal/app/ -run 'TestEveryRouteIsAuthorized|TestTheOpenAPIDocumentSaysWhatTheRouterEnforces|TestEveryModuleContributesRoutesAndPermissions' -count=1
go test ./internal/gen/ ./internal/kernel/layering/ -count=1

Expected: PASS. TestEveryRouteIsAuthorized accepts both routes because of the [no-permission] entries. TestTheOpenAPIDocumentSaysWhatTheRouterEnforces accepts them because both are documented. If the stale-entry check fails instead, a route key in public_routes.txt does not match gin’s spelling. Fix the file, not the test.

Watch it fail once. Delete the GET /v1/me line from api/public_routes.txt. TestEveryRouteIsAuthorized must fail and name GET /v1/me under “Unaccounted for”. Restore the line.

  • Step 14: Commit.
Terminal window
git add internal/platform/identity/member/profile.go internal/platform/identity/member/profile_test.go \
internal/modules/identity/me_handlers.go internal/modules/identity/me_handlers_test.go \
internal/modules/identity/routes.go \
internal/modules/orgstructure/member_directory.go internal/modules/orgstructure/member_directory_test.go \
internal/modules/orgstructure/routes.go internal/modules/orgstructure/routes_test.go \
api/public_routes.txt api/openapi.yaml internal/gen/api/api.gen.go
git commit -m "$(cat <<'EOF'
Add GET /v1/me and GET /v1/org/members (Plan 10 S1, S2)
/me reports the caller's own user, member, organization (with base
currency and country from the company profile), the organizations they
may switch to and their effective permissions, all from the verified
claim. /org/members is the organization's directory of member ids,
legal names and active flags. Both carry no permission and are argued
in api/public_routes.txt. The users join stays in
platform/identity/member.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"

Task 2: Seams — the roles API (S7), org.roles.manage, and migration 00170

Section titled “Task 2: Seams — the roles API (S7), org.roles.manage, and migration 00170”

Group A. Go only. Shares api/openapi.yaml, internal/modules/identity/routes.go and internal/app/routes_test.go (all append-only) with Tasks 1, 3 and 4.

Facts this task rests on (read, not assumed):

  • Permissions are not in the token. httpx.Authorize calls the module’s permissionLoader, which calls member.Permissions against the database on every request. The token carries only PermVersion. liveness.Cached.Check refuses a token whose PermVersion differs from organization.perm_version, returning ErrStalePermissions, which httpx.Authenticate turns into 401. The client then refreshes, and mintFor mints with the current version. So a grant change takes effect on the next request whether or not the version moves. The bump exists so that no token outlives a change to the org’s authority map, which is member.BumpPermVersion’s documented contract: “Call it from every path that could change a member’s effective authority: editing a role, assigning or removing one”. It is per organization. There is no per-member version, so every edit makes every token in the org stale once. invitation_handlers.go already bumps on a new member for the same reason.
  • Liveness cache. liveness.Cached caches a positive answer for at most 5 s (New clamps the TTL) and exposes Invalidate(session, member) per pair. After a bump, a cached positive can outlive it by up to 5 s. That is a stale token, not a stale permission, because authorization re-reads permissions live. Nothing here calls Invalidate, as the invitation-accept bump does not either.
  • Grants and retention. role, role_permission and member_role get full CRUD from 00010_roles.sql and 00001’s default privileges. No migration revokes anything on them. All three are in the unruled list of internal/kernel/db/retention_test.go, and that list’s loop asserts DELETE is held (hasPrivilege(..., "DELETE") must be true). No grant change is needed. The list’s comment says “the plan that adds grant revocation decides whether that is DELETE or a dated close like member_position”. This task decides it and records the decision there (Step 11).
  • RLS. All three tables have ENABLE + FORCE RLS and an org_isolation policy with USING only. For INSERT and UPDATE, Postgres uses USING as the check, so tenant-scoped writes work as erp_app.
  • Owner seeding. provisioning.rolesStep.Run grants the Owner role every key in Input.OwnerPermissions. cmd/migrate and cmd/orgadmin pass member.RegisteredPermissionSet(), and both blank-import internal/modules/identity. Registering org.roles.manage in that module’s init() therefore gives every organization provisioned after this lands the permission on its Owner. TestEveryModuleContributesRoutesAndPermissions asserts every key in composedModules is in RegisteredPermissionSet(). Existing organizations never get it from re-provisioning, because rolesStep.Run returns early when Owner already holds any grant (docs/DEPLOY_NOTES.md §2 describes the same gap for pettycash.*). Migration 00170 closes that gap.
  • How the Owner role is identified. Provisioning (steps.go), onboarding (onboarding.go) and DEPLOY_NOTES.md all use system = true AND name = 'Owner'. Renaming a seeded role would silently break all three and the lockout guard below. So renaming a system role is refused with 422 (a ruling, see Part A notes). Renaming a custom role is allowed.
  • Problem extensions. apperr.Problem.With(key, v) adds RFC 7807 extension members, and MarshalJSON writes them. httpx.Errors renders a *apperr.Problem returned through c.Error as-is. db.InTx returns fn’s error unwrapped, so a Problem returned from inside the transaction reaches the client intact.

Files:

  • Create: internal/platform/identity/role/role.go
  • Test: internal/platform/identity/role/role_test.go, internal/platform/identity/role/migration_test.go (new)
  • Create: db/migrations/00170_org_roles_manage.sql
  • Create: internal/modules/identity/role_handlers.go
  • Test: internal/modules/identity/role_handlers_test.go (new)
  • Modify: internal/modules/identity/routes.go (six routes), internal/modules/identity/routes_test.go (append one test)
  • Modify: internal/app/routes_test.go: the "identity" entry of composedModules gains identity.PermissionRolesManage
  • Modify: internal/kernel/db/retention_test.go (comment only, unruled list)
  • Modify: constants.yaml (append org.roles.manage to permissions:), api/openapi.yaml (five paths, eight schemas, one optional Problem.code), then make generate

Interfaces:

  • Consumes: member.RegisteredPermissions() []member.Permission; member.Can(ctx, tx, memberID, key) (bool, error); member.BumpPermVersion(ctx, tx) error; member.RegisterPermission; audit.Write(ctx, tx, clk, audit.Entry{...}); tenancy.OrgFromContext; httpx.Protected; httpx.IdentityFromContext; apperr.New(...).With("code", ...); testdb.StartAt, testdb.MigrationsDir; goose.UpTo, goose.Down, goose.WithAllowMissing.

  • Produces:

    package role // internal/platform/identity/role
    const PermissionManage = "org.roles.manage"
    type Role struct {
    ID uuid.UUID
    Name string
    System bool
    Permissions []string // sorted, never nil
    MemberIDs []uuid.UUID // active holders, sorted, never nil
    }
    var ErrNotFound, ErrMemberNotFound, ErrInvalidName, ErrDuplicateName, ErrSystemRoleRename,
    ErrUnknownPermission, ErrUnknownRole, ErrOwnerManageRequired, ErrSelfLockout error
    func List(ctx context.Context, tx pgx.Tx) ([]Role, error)
    func Get(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Role, error)
    func Create(ctx context.Context, tx pgx.Tx, c clock.Clock, name string) (Role, error)
    func Rename(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, name string) (Role, error)
    func SetPermissions(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID, keys []string) (Role, error)
    func SetMemberRoles(ctx context.Context, tx pgx.Tx, c clock.Clock, memberID, actor uuid.UUID, roleIDs []uuid.UUID) ([]uuid.UUID, error)
    package identity
    const PermissionRolesManage = role.PermissionManage // registered in init()

    Routes, all httpx.Protected(…, PermissionRolesManage, …):

    Method Path (under /v1) operationId Body 2xx body
    GET /org/permissions listPermissions — 200 PermissionCatalogue
    GET /org/roles listRoles — 200 RoleList
    POST /org/roles createRole RoleNameInput {name} 201 Role
    PATCH /org/roles/{id} renameRole RoleNameInput {name} 200 Role
    PUT /org/roles/{id}/permissions setRolePermissions RolePermissionsInput {permissions} 200 Role
    PUT /org/members/{id}/roles setMemberRoles MemberRolesInput {role_ids} 200 MemberRoles {member_id, role_ids}

    Refusals: 404 for an unknown role or member. 422 for a blank or too-long name, an unregistered key, a role id that is not this org’s, or renaming a system role. 409 for a duplicate name. The two lockouts are 409 with type https://errors.mcom.erp/conflict and code owner_role_manage_required or self_lockout.

  • Step 1: Write the failing platform tests. internal/platform/identity/role/role_test.go:

package role_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/db"
"github.com/mcom/erp/internal/kernel/db/testdb"
"github.com/mcom/erp/internal/kernel/eventbus"
"github.com/mcom/erp/internal/kernel/tenancy"
"github.com/mcom/erp/internal/platform/identity/member"
"github.com/mcom/erp/internal/platform/identity/role"
)
// The registry is process-global and filled by module init()s; this test
// binary links no module, so it registers the keys it grants. Keys already
// registered by some other import are left alone.
func registerForTest(t *testing.T, keys ...string) {
t.Helper()
have := map[string]bool{}
for _, p := range member.RegisteredPermissions() {
have[p.Key] = true
}
for _, k := range keys {
if have[k] {
continue
}
member.RegisterPermission(k, "test")
t.Cleanup(func() { member.UnregisterPermissionForTest(k) })
}
}
type world struct {
d *db.DB
h *testdb.Handles
ctx context.Context
org uuid.UUID
owner uuid.UUID // the system Owner role, granting org.roles.manage
clk clock.Clock
}
func newWorld(t *testing.T) *world {
t.Helper()
registerForTest(t, role.PermissionManage, "zz.role.other")
h := testdb.Start(t)
d, err := db.Open(context.Background(), h.AppDSN, eventbus.New())
require.NoError(t, err)
t.Cleanup(d.Close)
org := seedOrg(t, h.Admin, "Alpha")
w := &world{d: d, h: h, ctx: tenancy.WithOrg(context.Background(), org), org: org,
clk: clock.Fixed(testNow)}
w.owner = seedRole(t, h.Admin, org, "Owner", true, role.PermissionManage)
return w
}
func (w *world) tx(t *testing.T, fn func(ctx context.Context, tx pgx.Tx) error) error {
t.Helper()
return w.d.InTx(w.ctx, fn)
}
func seedOrg(t *testing.T, admin *pgxpool.Pool, name string) uuid.UUID {
t.Helper()
id := uuid.New()
_, err := admin.Exec(context.Background(),
`INSERT INTO organization (id, org_id, name) VALUES ($1,$1,$2)`, id, name)
require.NoError(t, err)
return id
}
func seedMember(t *testing.T, admin *pgxpool.Pool, org uuid.UUID, name string) uuid.UUID {
t.Helper()
ctx := context.Background()
var party, m uuid.UUID
require.NoError(t, admin.QueryRow(ctx,
`INSERT INTO party (org_id, kind, legal_name) VALUES ($1,'employee',$2) RETURNING id`,
org, name).Scan(&party))
require.NoError(t, admin.QueryRow(ctx,
`INSERT INTO organization_member (org_id, user_id, party_id) VALUES ($1,$2,$3) RETURNING id`,
org, uuid.New(), party).Scan(&m))
return m
}
func seedRole(t *testing.T, admin *pgxpool.Pool, org uuid.UUID, name string, system bool, keys ...string) uuid.UUID {
t.Helper()
ctx := context.Background()
var id uuid.UUID
require.NoError(t, admin.QueryRow(ctx,
`INSERT INTO role (org_id, name, system) VALUES ($1,$2,$3) RETURNING id`, org, name, system).Scan(&id))
for _, k := range keys {
_, err := admin.Exec(ctx,
`INSERT INTO role_permission (org_id, role_id, permission_key) VALUES ($1,$2,$3)`, org, id, k)
require.NoError(t, err)
}
return id
}
func hold(t *testing.T, admin *pgxpool.Pool, org, memberID, roleID uuid.UUID) {
t.Helper()
_, err := admin.Exec(context.Background(),
`INSERT INTO member_role (org_id, member_id, role_id) VALUES ($1,$2,$3)`, org, memberID, roleID)
require.NoError(t, err)
}
func keysOf(t *testing.T, admin *pgxpool.Pool, roleID uuid.UUID) []string {
t.Helper()
rows, err := admin.Query(context.Background(),
`SELECT permission_key FROM role_permission WHERE role_id = $1 ORDER BY permission_key`, roleID)
require.NoError(t, err)
out, err := pgx.CollectRows(rows, pgx.RowTo[string])
require.NoError(t, err)
return out
}
func permVersion(t *testing.T, admin *pgxpool.Pool, org uuid.UUID) int64 {
t.Helper()
var v int64
require.NoError(t, admin.QueryRow(context.Background(),
`SELECT perm_version FROM organization WHERE id = $1`, org).Scan(&v))
return v
}
// TestTheOwnerRoleKeepsRolesManage: PRESENCE — a set that still holds the
// key is accepted on Owner; ABSENCE — one without it is refused, and a
// non-Owner role may drop the key freely.
func TestTheOwnerRoleKeepsRolesManage(t *testing.T) {
w := newWorld(t)
actor := seedMember(t, w.h.Admin, w.org, "Actor")
hold(t, w.h.Admin, w.org, actor, w.owner)
deputy := seedRole(t, w.h.Admin, w.org, "Deputy", false, role.PermissionManage)
require.NoError(t, w.tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := role.SetPermissions(ctx, tx, w.clk, w.owner, actor,
[]string{role.PermissionManage, "zz.role.other"})
return err
}))
require.Equal(t, []string{role.PermissionManage, "zz.role.other"}, keysOf(t, w.h.Admin, w.owner))
err := w.tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := role.SetPermissions(ctx, tx, w.clk, w.owner, actor, []string{"zz.role.other"})
return err
})
require.ErrorIs(t, err, role.ErrOwnerManageRequired)
require.Contains(t, keysOf(t, w.h.Admin, w.owner), role.PermissionManage, "the refusal wrote nothing")
require.NoError(t, w.tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := role.SetPermissions(ctx, tx, w.clk, deputy, actor, []string{})
return err
}), "the refusal is about Owner, not about the key")
}
// TestAChangeNeverTakesRolesManageFromItsActor covers both roads to a
// self-lockout: re-permissioning a role the actor relies on, and replacing
// the actor's own roles. PRESENCE first each time: the same kind of change
// that leaves the actor holding the key goes through.
func TestAChangeNeverTakesRolesManageFromItsActor(t *testing.T) {
w := newWorld(t)
actor := seedMember(t, w.h.Admin, w.org, "Actor")
deputy := seedRole(t, w.h.Admin, w.org, "Deputy", false, role.PermissionManage, "zz.role.other")
plain := seedRole(t, w.h.Admin, w.org, "Plain", false)
hold(t, w.h.Admin, w.org, actor, deputy)
pv := permVersion(t, w.h.Admin, w.org)
// Road 1: the actor's only manage-holding role loses the key.
require.NoError(t, w.tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := role.SetPermissions(ctx, tx, w.clk, deputy, actor, []string{role.PermissionManage})
return err
}))
require.Equal(t, pv+1, permVersion(t, w.h.Admin, w.org), "a changed grant moves the permission version")
err := w.tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := role.SetPermissions(ctx, tx, w.clk, deputy, actor, []string{})
return err
})
require.ErrorIs(t, err, role.ErrSelfLockout)
require.Equal(t, []string{role.PermissionManage}, keysOf(t, w.h.Admin, deputy), "rolled back")
require.Equal(t, pv+1, permVersion(t, w.h.Admin, w.org), "the refused change's bump rolled back with it")
// Road 2: the actor's own roles. Keeping Deputy is fine; dropping it is not.
require.NoError(t, w.tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := role.SetMemberRoles(ctx, tx, w.clk, actor, actor, []uuid.UUID{deputy, plain})
return err
}))
err = w.tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := role.SetMemberRoles(ctx, tx, w.clk, actor, actor, []uuid.UUID{plain})
return err
})
require.ErrorIs(t, err, role.ErrSelfLockout)
// And somebody else's roles are not the actor's lockout to worry about.
other := seedMember(t, w.h.Admin, w.org, "Other")
hold(t, w.h.Admin, w.org, other, deputy)
require.NoError(t, w.tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := role.SetMemberRoles(ctx, tx, w.clk, other, actor, []uuid.UUID{})
return err
}))
}
// TestRolesAreThisOrganizationsOnly: PRESENCE — Beta's role is listed and
// assignable in Beta; ABSENCE — Alpha neither lists nor assigns it.
func TestRolesAreThisOrganizationsOnly(t *testing.T) {
w := newWorld(t)
beta := seedOrg(t, w.h.Admin, "Beta")
betaRole := seedRole(t, w.h.Admin, beta, "Beta Only", false)
betaMember := seedMember(t, w.h.Admin, beta, "Beta Member")
alphaMember := seedMember(t, w.h.Admin, w.org, "Alpha Member")
hold(t, w.h.Admin, w.org, alphaMember, w.owner)
// PRESENCE: in Beta, Beta's role is listed and readable.
var inBeta []role.Role
require.NoError(t, w.d.InTx(tenancy.WithOrg(context.Background(), beta), func(ctx context.Context, tx pgx.Tx) error {
var err error
if inBeta, err = role.List(ctx, tx); err != nil {
return err
}
_, err = role.Get(ctx, tx, betaRole)
return err
}))
require.Len(t, inBeta, 1)
require.Equal(t, betaRole, inBeta[0].ID)
_ = betaMember
// ABSENCE: in Alpha, it is not listed, not readable, not assignable.
var inAlpha []role.Role
require.NoError(t, w.tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
inAlpha, err = role.List(ctx, tx)
return err
}))
for _, r := range inAlpha {
require.NotEqual(t, betaRole, r.ID)
}
err := w.tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := role.Get(ctx, tx, betaRole)
return err
})
require.ErrorIs(t, err, role.ErrNotFound)
err = w.tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := role.SetMemberRoles(ctx, tx, w.clk, alphaMember, alphaMember, []uuid.UUID{w.owner, betaRole})
return err
})
require.ErrorIs(t, err, role.ErrUnknownRole)
}
var testNow = time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)

(Add "time" to the imports. Drop the unused betaMember seed if the linter objects to the blank assignment.)

  • Step 2: Write the failing migration test. internal/platform/identity/role/migration_test.go:
package role_test
import (
"context"
"database/sql"
"testing"
"github.com/google/uuid"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/db/testdb"
)
// TestMigration00170GrantsRolesManageToEveryOwner runs the real migration
// against rows written BEFORE it. StartAt boots its own container, which
// AGENTS.md reserves for migration tests like this one.
//
// ABSENCE before, PRESENCE after, on the Owner; ABSENCE after, on a
// non-Owner seeded role; and the Down removes exactly what the Up added.
func TestMigration00170GrantsRolesManageToEveryOwner(t *testing.T) {
h := testdb.StartAt(t, 152)
ctx := context.Background()
org := uuid.New()
_, err := h.Admin.Exec(ctx, `INSERT INTO organization (id, org_id, name) VALUES ($1,$1,'Alpha')`, org)
require.NoError(t, err)
var owner, employee uuid.UUID
require.NoError(t, h.Admin.QueryRow(ctx,
`INSERT INTO role (org_id, name, system) VALUES ($1,'Owner',true) RETURNING id`, org).Scan(&owner))
require.NoError(t, h.Admin.QueryRow(ctx,
`INSERT INTO role (org_id, name, system) VALUES ($1,'Employee',true) RETURNING id`, org).Scan(&employee))
_, err = h.Admin.Exec(ctx,
`INSERT INTO role_permission (org_id, role_id, permission_key) VALUES ($1,$2,'identity.org.administer')`,
org, owner)
require.NoError(t, err)
count := func(roleID uuid.UUID) int {
t.Helper()
var n int
require.NoError(t, h.Admin.QueryRow(ctx,
`SELECT count(*) FROM role_permission WHERE role_id = $1 AND permission_key = 'org.roles.manage'`,
roleID).Scan(&n))
return n
}
pv := func() int64 {
t.Helper()
var v int64
require.NoError(t, h.Admin.QueryRow(ctx, `SELECT perm_version FROM organization WHERE id = $1`, org).Scan(&v))
return v
}
require.Equal(t, 0, count(owner), "the key must not exist before the migration")
before := pv()
conn, err := sql.Open("pgx", h.AdminDSN)
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
require.NoError(t, goose.SetDialect("postgres"))
require.NoError(t, goose.UpTo(conn, testdb.MigrationsDir(t), 170, goose.WithAllowMissing()))
require.Equal(t, 1, count(owner))
require.Equal(t, 0, count(employee), "only the Owner is granted")
require.Equal(t, before+1, pv(), "a grant is a change to the org's authority map")
require.NoError(t, goose.Down(conn, testdb.MigrationsDir(t)))
require.Equal(t, 0, count(owner), "the Down removes the grant")
var still int
require.NoError(t, h.Admin.QueryRow(ctx,
`SELECT count(*) FROM role_permission WHERE role_id = $1`, owner).Scan(&still))
require.Equal(t, 1, still, "and nothing else the Owner held")
require.NoError(t, goose.UpTo(conn, testdb.MigrationsDir(t), 170, goose.WithAllowMissing()))
require.Equal(t, 1, count(owner), "Up again is still one row: ON CONFLICT DO NOTHING")
}

Verify: goose.UpTo(db *sql.DB, dir string, version int64, opts ...goose.OptionsFunc) is the v3.28.0 signature ($(go env GOMODCACHE)/github.com/pressly/goose/v3@v3.28.0/up.go). If goose.WithAllowMissing is not accepted there, drop it. It only matters when another branch has added a migration numbered below 170 that this database skipped.

  • Step 3: Run and watch fail. go test ./internal/platform/identity/role/ -count=1 fails to compile (package role does not exist).

  • Step 4: The migration. db/migrations/00170_org_roles_manage.sql:

-- +goose Up
-- Plan 10 (S7): org.roles.manage gates the role editor. A new organization's
-- Owner receives it from provisioning (rolesStep seeds every registered key),
-- but an existing organization's Owner never will: rolesStep returns early
-- once Owner holds any grant, deliberately (see provisioning/steps.go and
-- DEPLOY_NOTES.md §2). Without this, no existing organization could open
-- its role editor at all.
--
-- The Owner role is found exactly as provisioning and onboarding find it:
-- system AND name = 'Owner'. Seeded roles cannot be renamed through the API
-- (internal/platform/identity/role, ErrSystemRoleRename), which keeps that
-- true.
--
-- Runs as the migration role, which bypasses RLS; as erp_app this INSERT
-- would match no rows under FORCE ROW LEVEL SECURITY.
INSERT INTO role_permission (org_id, role_id, permission_key)
SELECT r.org_id, r.id, 'org.roles.manage'
FROM role r
WHERE r.system AND r.name = 'Owner'
ON CONFLICT (role_id, permission_key) DO NOTHING;
-- A grant is a change to the organization's authority map, and
-- member.BumpPermVersion's contract is that every such change moves the
-- version. Authorization reads permissions live, so this is not what makes
-- the grant take effect; it keeps "no token outlives an authority change"
-- true without an exception for migrations. Cost: each live session
-- refreshes once.
UPDATE organization o
SET perm_version = o.perm_version + 1
WHERE EXISTS (SELECT 1 FROM role r WHERE r.org_id = o.id AND r.system AND r.name = 'Owner');
-- +goose Down
-- Removes the key from every Owner, including Owners provisioned after the
-- Up ran (they were seeded with it from the registry). That is the rollback
-- this is for: the code that registers and enforces the key is being
-- removed with it. Grants of the key to other roles, made through the
-- editor, are left alone; they name a permission nothing enforces any more
-- and are inert.
DELETE FROM role_permission rp
USING role r
WHERE r.org_id = rp.org_id AND r.id = rp.role_id
AND r.system AND r.name = 'Owner'
AND rp.permission_key = 'org.roles.manage';
UPDATE organization o
SET perm_version = o.perm_version + 1
WHERE EXISTS (SELECT 1 FROM role r WHERE r.org_id = o.id AND r.system AND r.name = 'Owner');
  • Step 5: The platform package. internal/platform/identity/role/role.go:
// Package role is the role editor (spec §4.2, Plan 10 S7): the roles an
// organization defines, the permission keys each grants, and which members
// hold which.
//
// Two refusals are why this package exists. Either change would leave nobody
// able to edit roles again, which is the lock-out §4.2 forbids:
//
// - The Owner role never loses PermissionManage (ErrOwnerManageRequired).
// - A change never takes PermissionManage away from the member making it
// (ErrSelfLockout). This is checked AFTER the write, inside the same
// transaction, with member.Can, so it holds whichever road the change
// took: the actor's own roles, or a role they rely on being
// re-permissioned. The caller's transaction rolls the write back.
//
// Every change is audited on the same transaction with its before and after
// sets. Every change to what a role grants or who holds it bumps the
// organization's permission version (member.BumpPermVersion), so tokens
// minted before it are refused and re-minted.
//
// A revoked grant is a DELETE. These rows are current state. The record of
// who could do what, and when, is audit_log's before/after sets, which are
// hash-chained and append-only. internal/kernel/db/retention_test.go records
// the ruling.
package role
import (
"bytes"
"context"
"errors"
"fmt"
"slices"
"strings"
"unicode/utf8"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/tenancy"
"github.com/mcom/erp/internal/platform/identity/member"
)
// PermissionManage gates the role editor and is the key both refusals
// protect. internal/modules/identity registers it (PermissionRolesManage).
// It is declared here because the refusals must name it.
const PermissionManage = "org.roles.manage"
// ownerName is how provisioning, onboarding and migration 00170 find the
// Owner role: system AND name = 'Owner'. Seeded roles keep their names
// (ErrSystemRoleRename) so that this stays true.
const ownerName = "Owner"
const maxNameLength = 100
var (
ErrNotFound = errors.New("role: no such role in this organization")
ErrMemberNotFound = errors.New("role: no such member in this organization")
ErrInvalidName = errors.New("role: a role name is required and must be at most 100 characters")
ErrDuplicateName = errors.New("role: a role with that name already exists")
ErrSystemRoleRename = errors.New("role: a seeded role keeps its name")
ErrUnknownPermission = errors.New("role: no such permission")
ErrUnknownRole = errors.New("role: role_ids names a role that is not this organization's")
ErrOwnerManageRequired = errors.New("role: the Owner role must keep " + PermissionManage)
ErrSelfLockout = errors.New("role: this change would take " + PermissionManage + " away from you")
)
// Role is one role with what it grants and who holds it.
type Role struct {
ID uuid.UUID
Name string
System bool
Permissions []string // sorted, never nil
MemberIDs []uuid.UUID // ACTIVE holders, sorted, never nil
}
func (r Role) isOwner() bool { return r.System && r.Name == ownerName }
// roleSelect ties every table to $1: role directly, and role_permission,
// member_role and organization_member through their org_id joins. That is
// what the tenant-predicate guard reads.
const roleSelect = `
SELECT r.id, r.name, r.system,
ARRAY(SELECT rp.permission_key FROM role_permission rp
WHERE rp.org_id = r.org_id AND rp.role_id = r.id
ORDER BY rp.permission_key),
ARRAY(SELECT mr.member_id FROM member_role mr
JOIN organization_member m ON m.org_id = mr.org_id AND m.id = mr.member_id
WHERE mr.org_id = r.org_id AND mr.role_id = r.id AND m.active
ORDER BY mr.member_id)
FROM role r
WHERE r.org_id = $1`
func scan(row pgx.Row) (Role, error) {
var r Role
if err := row.Scan(&r.ID, &r.Name, &r.System, &r.Permissions, &r.MemberIDs); err != nil {
return Role{}, err
}
if r.Permissions == nil {
r.Permissions = []string{}
}
if r.MemberIDs == nil {
r.MemberIDs = []uuid.UUID{}
}
return r, nil
}
func orgOf(ctx context.Context, op string) (uuid.UUID, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return uuid.Nil, fmt.Errorf("role: %s: %w", op, tenancy.ErrNoOrg)
}
return orgID, nil
}
// List is every role of the context organization: seeded roles first, then
// by name.
func List(ctx context.Context, tx pgx.Tx) ([]Role, error) {
orgID, err := orgOf(ctx, "list")
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, roleSelect+` ORDER BY r.system DESC, r.name`, orgID)
if err != nil {
return nil, fmt.Errorf("role: list: %w", err)
}
defer rows.Close()
out := []Role{}
for rows.Next() {
r, err := scan(rows)
if err != nil {
return nil, fmt.Errorf("role: list scan: %w", err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("role: list: %w", err)
}
return out, nil
}
// Get reads one role. ErrNotFound covers another organization's role too.
func Get(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Role, error) {
orgID, err := orgOf(ctx, "get")
if err != nil {
return Role{}, err
}
r, err := scan(tx.QueryRow(ctx, roleSelect+` AND r.id = $2`, orgID, id))
if errors.Is(err, pgx.ErrNoRows) {
return Role{}, ErrNotFound
}
if err != nil {
return Role{}, fmt.Errorf("role: get: %w", err)
}
return r, nil
}
// lock holds the role row for the rest of the transaction, so two
// replacements of one role's set serialize rather than interleave their
// DELETE and INSERT.
func lock(ctx context.Context, tx pgx.Tx, orgID, id uuid.UUID) error {
var got uuid.UUID
err := tx.QueryRow(ctx, `SELECT id FROM role WHERE org_id = $1 AND id = $2 FOR UPDATE`, orgID, id).Scan(&got)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("role: lock: %w", err)
}
return nil
}
func validName(name string) (string, error) {
n := strings.TrimSpace(name)
if n == "" || utf8.RuneCountInString(n) > maxNameLength {
return "", ErrInvalidName
}
return n, nil
}
// Create adds a custom (non-system) role that grants nothing and nobody
// holds. It changes nobody's authority, so the permission version stays.
func Create(ctx context.Context, tx pgx.Tx, c clock.Clock, name string) (Role, error) {
orgID, err := orgOf(ctx, "create")
if err != nil {
return Role{}, err
}
n, err := validName(name)
if err != nil {
return Role{}, err
}
var id uuid.UUID
err = tx.QueryRow(ctx, `
INSERT INTO role (org_id, name, system) VALUES ($1, $2, false)
ON CONFLICT (org_id, name) DO NOTHING
RETURNING id`, orgID, n).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return Role{}, ErrDuplicateName
}
if err != nil {
return Role{}, fmt.Errorf("role: create: %w", err)
}
if err := audit.Write(ctx, tx, c, audit.Entry{
EntityKind: "role", EntityID: id, Action: "created",
After: map[string]any{"name": n},
}); err != nil {
return Role{}, err
}
return Get(ctx, tx, id)
}
// Rename renames a custom role. A seeded role keeps its name: the Owner
// role is identified by it everywhere (ownerName).
func Rename(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, name string) (Role, error) {
orgID, err := orgOf(ctx, "rename")
if err != nil {
return Role{}, err
}
n, err := validName(name)
if err != nil {
return Role{}, err
}
if err := lock(ctx, tx, orgID, id); err != nil {
return Role{}, err
}
cur, err := Get(ctx, tx, id)
if err != nil {
return Role{}, err
}
if cur.System {
return Role{}, ErrSystemRoleRename
}
if cur.Name == n {
return cur, nil
}
tag, err := tx.Exec(ctx, `
UPDATE role SET name = $3
WHERE org_id = $1 AND id = $2
AND NOT EXISTS (SELECT 1 FROM role o WHERE o.org_id = $1 AND o.name = $3)`, orgID, id, n)
if err != nil {
return Role{}, fmt.Errorf("role: rename: %w", err)
}
if tag.RowsAffected() == 0 {
return Role{}, ErrDuplicateName
}
if err := audit.Write(ctx, tx, c, audit.Entry{
EntityKind: "role", EntityID: id, Action: "renamed",
Before: map[string]any{"name": cur.Name}, After: map[string]any{"name": n},
}); err != nil {
return Role{}, err
}
return Get(ctx, tx, id)
}
// SetPermissions replaces what a role grants with keys. Every key must be
// registered: a grant of a key no module enforces means nothing, and the
// editor shows only the catalogue. Repeats are ignored and an empty set is
// allowed, except that Owner keeps PermissionManage. An unchanged set writes
// nothing.
func SetPermissions(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID, keys []string) (Role, error) {
orgID, err := orgOf(ctx, "set permissions")
if err != nil {
return Role{}, err
}
want, err := permissionSet(keys)
if err != nil {
return Role{}, err
}
if err := lock(ctx, tx, orgID, id); err != nil {
return Role{}, err
}
cur, err := Get(ctx, tx, id)
if err != nil {
return Role{}, err
}
if cur.isOwner() && !slices.Contains(want, PermissionManage) {
return Role{}, ErrOwnerManageRequired
}
if slices.Equal(cur.Permissions, want) {
return cur, nil
}
if _, err := tx.Exec(ctx, `
DELETE FROM role_permission
WHERE org_id = $1 AND role_id = $2 AND permission_key <> ALL($3::text[])`,
orgID, id, want); err != nil {
return Role{}, fmt.Errorf("role: revoke permissions: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO role_permission (org_id, role_id, permission_key)
SELECT $1, $2, k FROM unnest($3::text[]) AS k
ON CONFLICT (role_id, permission_key) DO NOTHING`, orgID, id, want); err != nil {
return Role{}, fmt.Errorf("role: grant permissions: %w", err)
}
if err := afterAuthorityChange(ctx, tx, actor); err != nil {
return Role{}, err
}
if err := audit.Write(ctx, tx, c, audit.Entry{
EntityKind: "role", EntityID: id, Action: "permissions_set",
Before: map[string]any{"permissions": cur.Permissions},
After: map[string]any{"permissions": want},
}); err != nil {
return Role{}, err
}
return Get(ctx, tx, id)
}
// SetMemberRoles replaces the roles memberID holds with roleIDs, which must
// all be this organization's. It returns the new set, sorted.
func SetMemberRoles(ctx context.Context, tx pgx.Tx, c clock.Clock, memberID, actor uuid.UUID, roleIDs []uuid.UUID) ([]uuid.UUID, error) {
orgID, err := orgOf(ctx, "set member roles")
if err != nil {
return nil, err
}
want := slices.Clone(roleIDs)
slices.SortFunc(want, func(a, b uuid.UUID) int { return bytes.Compare(a[:], b[:]) })
want = slices.Compact(want)
if want == nil {
want = []uuid.UUID{}
}
var got uuid.UUID
err = tx.QueryRow(ctx,
`SELECT id FROM organization_member WHERE org_id = $1 AND id = $2 FOR UPDATE`, orgID, memberID).Scan(&got)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrMemberNotFound
}
if err != nil {
return nil, fmt.Errorf("role: lock member: %w", err)
}
var known int
if err := tx.QueryRow(ctx,
`SELECT count(*) FROM role WHERE org_id = $1 AND id = ANY($2::uuid[])`, orgID, want).Scan(&known); err != nil {
return nil, fmt.Errorf("role: check roles: %w", err)
}
if known != len(want) {
return nil, ErrUnknownRole
}
before, err := heldBy(ctx, tx, orgID, memberID)
if err != nil {
return nil, err
}
if slices.Equal(before, want) {
return want, nil
}
if _, err := tx.Exec(ctx, `
DELETE FROM member_role
WHERE org_id = $1 AND member_id = $2 AND role_id <> ALL($3::uuid[])`, orgID, memberID, want); err != nil {
return nil, fmt.Errorf("role: revoke roles: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO member_role (org_id, member_id, role_id)
SELECT $1, $2, r FROM unnest($3::uuid[]) AS r
ON CONFLICT (member_id, role_id) DO NOTHING`, orgID, memberID, want); err != nil {
return nil, fmt.Errorf("role: grant roles: %w", err)
}
if err := afterAuthorityChange(ctx, tx, actor); err != nil {
return nil, err
}
if err := audit.Write(ctx, tx, c, audit.Entry{
EntityKind: "organization_member", EntityID: memberID, Action: "roles_set",
Before: map[string]any{"role_ids": before},
After: map[string]any{"role_ids": want},
}); err != nil {
return nil, err
}
return want, nil
}
// heldBy is memberID's current roles, in the order Postgres sorts uuids
// (bytewise), which is the order SetMemberRoles sorts want into.
func heldBy(ctx context.Context, tx pgx.Tx, orgID, memberID uuid.UUID) ([]uuid.UUID, error) {
rows, err := tx.Query(ctx,
`SELECT role_id FROM member_role WHERE org_id = $1 AND member_id = $2 ORDER BY role_id`, orgID, memberID)
if err != nil {
return nil, fmt.Errorf("role: held roles: %w", err)
}
out, err := pgx.CollectRows(rows, pgx.RowTo[uuid.UUID])
if err != nil {
return nil, fmt.Errorf("role: held roles: %w", err)
}
if out == nil {
out = []uuid.UUID{}
}
return out, nil
}
// permissionSet sorts and de-duplicates keys, and refuses any key no module
// registered.
func permissionSet(keys []string) ([]string, error) {
registered := map[string]bool{}
for _, p := range member.RegisteredPermissions() {
registered[p.Key] = true
}
out := make([]string, 0, len(keys))
for _, k := range keys {
if !registered[k] {
return nil, fmt.Errorf("%w: %q", ErrUnknownPermission, k)
}
out = append(out, k)
}
slices.Sort(out)
return slices.Compact(out), nil
}
// afterAuthorityChange runs after every write that can change who holds
// what: the permission version moves, and the actor must still hold
// PermissionManage.
func afterAuthorityChange(ctx context.Context, tx pgx.Tx, actor uuid.UUID) error {
if err := member.BumpPermVersion(ctx, tx); err != nil {
return err
}
ok, err := member.Can(ctx, tx, actor, PermissionManage)
if err != nil {
return err
}
if !ok {
return ErrSelfLockout
}
return nil
}
  • Step 6: Run and pass. Run go test ./internal/platform/identity/role/ -count=1, then go test ./internal/kernel/layering/ -count=1. Expected: PASS. Verify: the tenant-predicate guard (TestEveryTenantQueryCarriesAnExplicitOrgPredicate) accepts roleSelect’s two ARRAY(SELECT …) subqueries through rp.org_id = r.org_id and m.org_id = mr.org_id AND mr.org_id = r.org_id. If it reports either subquery, do not add an exemption. Split roleSelect into the bare role read plus two queries keyed WHERE org_id = $1 AND role_id = ANY($2), and assemble the slices in Go. Verify: an empty ARRAY(...) scans as a non-nil empty slice, which the nil guards in scan cover either way. Also check that pgx encodes a []uuid.UUID for $2::uuid[]. internal/platform/reporting/generalledger.go already binds uuid slices to ANY($n), so it should.

  • Step 7: Write the failing HTTP tests. internal/modules/identity/role_handlers_test.go:

package identity_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/modules/identity"
)
type roleResp struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
System bool `json:"system"`
Permissions []string `json:"permissions"`
MemberIDs []uuid.UUID `json:"member_ids"`
}
type problemResp struct {
Type string `json:"type"`
Status int `json:"status"`
Code string `json:"code"`
Detail string `json:"detail"`
}
// rolesEnv is apiSetup's Alpha with the caller's (system, 'Owner') role also
// granting org.roles.manage, and a second member holding nothing.
type rolesEnv struct {
*apiEnv
org, me, other, owner uuid.UUID
}
func newRolesEnv(t *testing.T) *rolesEnv {
t.Helper()
e := apiSetup(t)
org := uuid.MustParse(e.orgA)
me := e.memberIn[e.orgA]
grantExtra(t, e.h.Admin, org, me, identity.PermissionRolesManage)
other := seedMember(t, e.h.Admin, org, uuid.New(), "E-099")
return &rolesEnv{apiEnv: e, org: org, me: me, other: other, owner: roleIDOf(t, e.h.Admin, org, me)}
}
// send is postAuth for any method.
func (e *apiEnv) send(method, path, body, tok string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tok)
return e.do(req)
}
func decodeAs[T any](t *testing.T, w *httptest.ResponseRecorder) T {
t.Helper()
var v T
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &v), w.Body.String())
return v
}
func (e *rolesEnv) keysOf(t *testing.T, roleID uuid.UUID) []string {
t.Helper()
rows, err := e.h.Admin.Query(context.Background(),
`SELECT permission_key FROM role_permission WHERE role_id = $1 ORDER BY permission_key`, roleID)
require.NoError(t, err)
defer rows.Close()
var out []string
for rows.Next() {
var k string
require.NoError(t, rows.Scan(&k))
out = append(out, k)
}
return out
}
func TestTheCatalogueIsEveryRegisteredKeyByModule(t *testing.T) {
e := newRolesEnv(t)
w := e.get("/v1/org/permissions", e.login(t))
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
got := decodeAs[struct {
Permissions []struct{ Key, Module string } `json:"permissions"`
}](t, w)
modules := map[string]string{}
for _, p := range got.Permissions {
modules[p.Key] = p.Module
}
require.Equal(t, "org", modules["org.roles.manage"])
require.Equal(t, "identity", modules["identity.org.administer"])
}
func TestRolesAreCreatedRenamedAndListed(t *testing.T) {
e := newRolesEnv(t)
tok := e.login(t)
w := e.send(http.MethodPost, "/v1/org/roles", `{"name":"Approvers"}`, tok)
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
created := decodeAs[roleResp](t, w)
require.False(t, created.System)
require.Empty(t, created.Permissions)
require.NotNil(t, created.Permissions, "[] on the wire, never null")
require.Equal(t, 1, e.countAudit(t, "role", "created"))
w = e.send(http.MethodPost, "/v1/org/roles", `{"name":"Approvers"}`, tok)
require.Equal(t, http.StatusConflict, w.Code, w.Body.String())
require.Equal(t, http.StatusUnprocessableEntity,
e.send(http.MethodPost, "/v1/org/roles", `{"name":" "}`, tok).Code)
w = e.send(http.MethodPatch, "/v1/org/roles/"+created.ID.String(), `{"name":"Claim approvers"}`, tok)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.Equal(t, "Claim approvers", decodeAs[roleResp](t, w).Name)
require.Equal(t, 1, e.countAudit(t, "role", "renamed"))
// A seeded role keeps its name: the Owner is found by it.
w = e.send(http.MethodPatch, "/v1/org/roles/"+e.owner.String(), `{"name":"Boss"}`, tok)
require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String())
w = e.get("/v1/org/roles", tok)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
roles := decodeAs[struct {
Roles []roleResp `json:"roles"`
}](t, w).Roles
require.Len(t, roles, 2)
require.True(t, roles[0].System, "seeded roles first")
require.Equal(t, []uuid.UUID{e.me}, roles[0].MemberIDs)
require.Contains(t, roles[0].Permissions, identity.PermissionRolesManage)
}
// TestReplacingARolesPermissionsIsAuditedAndStalesTokens: PRESENCE — the
// token works before the change; ABSENCE — it is refused after, because the
// permission version moved.
func TestReplacingARolesPermissionsIsAuditedAndStalesTokens(t *testing.T) {
e := newRolesEnv(t)
tok := e.login(t)
created := decodeAs[roleResp](t, e.send(http.MethodPost, "/v1/org/roles", `{"name":"Admins"}`, tok))
require.Equal(t, http.StatusOK, e.get("/v1/org/roles", tok).Code)
pv := permVersion(t, e.h.Admin, e.org)
w := e.send(http.MethodPut, "/v1/org/roles/"+created.ID.String()+"/permissions",
`{"permissions":["org.roles.manage","identity.org.administer","org.roles.manage"]}`, tok)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.Equal(t, []string{"identity.org.administer", "org.roles.manage"}, decodeAs[roleResp](t, w).Permissions)
require.Equal(t, pv+1, permVersion(t, e.h.Admin, e.org))
var before, after string
require.NoError(t, e.h.Admin.QueryRow(context.Background(),
`SELECT before::text, after::text FROM audit_log
WHERE entity_kind = 'role' AND action = 'permissions_set' AND entity_id = $1`, created.ID).
Scan(&before, &after))
require.JSONEq(t, `{"permissions":[]}`, before)
require.JSONEq(t, `{"permissions":["identity.org.administer","org.roles.manage"]}`, after)
require.Equal(t, http.StatusUnauthorized, e.get("/v1/org/roles", tok).Code,
"a token minted before the change is stale")
tok = e.login(t)
w = e.send(http.MethodPut, "/v1/org/roles/"+created.ID.String()+"/permissions",
`{"permissions":["no.such.key"]}`, tok)
require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String())
require.Contains(t, decodeAs[problemResp](t, w).Detail, "no.such.key")
require.Equal(t, http.StatusNotFound, e.send(http.MethodPut,
"/v1/org/roles/"+uuid.NewString()+"/permissions", `{"permissions":[]}`, tok).Code)
}
// TestTheOwnerRoleKeepsRolesManage: PRESENCE — a set that keeps the key is
// accepted on Owner; ABSENCE — one without it is 409
// owner_role_manage_required, and the key is still held.
func TestTheOwnerRoleKeepsRolesManageOverHTTP(t *testing.T) {
e := newRolesEnv(t)
tok := e.login(t)
path := "/v1/org/roles/" + e.owner.String() + "/permissions"
w := e.send(http.MethodPut, path,
`{"permissions":["identity.org.administer","org.roles.manage","org.members.invite"]}`, tok)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
tok = e.login(t)
w = e.send(http.MethodPut, path, `{"permissions":["identity.org.administer","org.members.invite"]}`, tok)
require.Equal(t, http.StatusConflict, w.Code, w.Body.String())
p := decodeAs[problemResp](t, w)
require.True(t, strings.HasSuffix(p.Type, "/conflict"), p.Type)
require.Equal(t, "owner_role_manage_required", p.Code)
require.Contains(t, e.keysOf(t, e.owner), "org.roles.manage")
}
// TestAMemberCannotRemoveTheirOwnLastRolesManage: PRESENCE — removing
// SOMEBODY ELSE's roles, and replacing one's own with a set that keeps
// Owner, both go through; ABSENCE — replacing one's own with a set that
// drops the only role granting org.roles.manage is 409 self_lockout, and
// the caller still holds Owner.
func TestAMemberCannotRemoveTheirOwnLastRolesManage(t *testing.T) {
e := newRolesEnv(t)
tok := e.login(t)
employee := decodeAs[roleResp](t, e.send(http.MethodPost, "/v1/org/roles", `{"name":"Staff"}`, tok))
put := func(memberID uuid.UUID, ids ...uuid.UUID) *httptest.ResponseRecorder {
t.Helper()
raw, err := json.Marshal(map[string]any{"role_ids": ids})
require.NoError(t, err)
w := e.send(http.MethodPut, "/v1/org/members/"+memberID.String()+"/roles", string(raw), tok)
if w.Code == http.StatusOK {
tok = e.login(t) // the bump staled the token
}
return w
}
w := put(e.other, e.owner)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.Equal(t, http.StatusOK, put(e.other).Code, "removing another member's manage is allowed")
w = put(e.me, e.owner, employee.ID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
got := decodeAs[struct {
MemberID uuid.UUID `json:"member_id"`
RoleIDs []uuid.UUID `json:"role_ids"`
}](t, w)
require.Equal(t, e.me, got.MemberID)
require.ElementsMatch(t, []uuid.UUID{e.owner, employee.ID}, got.RoleIDs)
require.Equal(t, 3, e.countAudit(t, "organization_member", "roles_set"))
w = put(e.me, employee.ID)
require.Equal(t, http.StatusConflict, w.Code, w.Body.String())
require.Equal(t, "self_lockout", decodeAs[problemResp](t, w).Code)
var holdsOwner bool
require.NoError(t, e.h.Admin.QueryRow(context.Background(),
`SELECT EXISTS (SELECT 1 FROM member_role WHERE member_id = $1 AND role_id = $2)`, e.me, e.owner).
Scan(&holdsOwner))
require.True(t, holdsOwner, "still an Owner: the refused change wrote nothing")
require.Equal(t, http.StatusNotFound, put(uuid.New()).Code)
require.Equal(t, http.StatusUnprocessableEntity, put(e.other, uuid.New()).Code)
}
// TestAnotherOrganizationsRoleCannotBeAssigned: PRESENCE — this org's own
// role is assignable (the test above); ABSENCE — Beta's Owner role, named by
// id, is refused as unknown.
func TestAnotherOrganizationsRoleCannotBeAssigned(t *testing.T) {
e := apiSetupTwoOrgs(t)
org := uuid.MustParse(e.orgA)
me := e.memberIn[e.orgA]
grantExtra(t, e.h.Admin, org, me, identity.PermissionRolesManage)
betaOwner := roleIDOf(t, e.h.Admin, uuid.MustParse(e.orgB), e.memberIn[e.orgB])
alphaOwner := roleIDOf(t, e.h.Admin, org, me)
tok := e.login(t)
w := e.send(http.MethodPut, "/v1/org/members/"+me.String()+"/roles",
fmt.Sprintf(`{"role_ids":[%q,%q]}`, alphaOwner, betaOwner), tok)
require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String())
}

(roleIDOf is used only in newRolesEnv, before the caller holds a second role. It scans exactly one row.)

Append to internal/modules/identity/routes_test.go:

// TestTheRoleRoutesRequireRolesManage pins the six role-editor routes to
// org.roles.manage, and the key itself.
func TestTheRoleRoutesRequireRolesManage(t *testing.T) {
gin.SetMode(gin.TestMode)
httpx.ResetAuthorizedRoutes()
r, api := httpx.NewRouter(httpx.Deps{})
identity.Register(api, httpx.PublicPaths(r, testPublicRoutes...), identity.Deps{})
got := httpx.AuthorizedRoutes()
for _, k := range []string{
"GET /v1/org/permissions",
"GET /v1/org/roles",
"POST /v1/org/roles",
"PATCH /v1/org/roles/:id",
"PUT /v1/org/roles/:id/permissions",
"PUT /v1/org/members/:id/roles",
} {
require.Equal(t, identity.PermissionRolesManage, got[k], k)
}
require.Equal(t, "org.roles.manage", identity.PermissionRolesManage)
}
  • Step 8: Run and watch fail. go test ./internal/modules/identity/ -run 'TestTheCatalogue|TestRoles|TestReplacing|TestTheOwnerRole|TestAMemberCannot|TestAnotherOrganizations|TestTheRoleRoutes' -count=1 fails to compile: undefined: identity.PermissionRolesManage.

  • Step 9: Implement the handlers. internal/modules/identity/role_handlers.go:

package identity
import (
"context"
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
apperr "github.com/mcom/erp/internal/kernel/errors"
"github.com/mcom/erp/internal/kernel/httpx"
"github.com/mcom/erp/internal/platform/identity/member"
"github.com/mcom/erp/internal/platform/identity/role"
)
// The role editor (Plan 10, S7). A thin shell over
// internal/platform/identity/role, which owns both lockout refusals, the
// audit rows and the permission-version bump.
// PermissionRolesManage gates all six routes. Reading the catalogue and the
// roles is part of editing them.
const PermissionRolesManage = role.PermissionManage
func init() {
member.RegisterPermission(PermissionRolesManage,
"Create and rename roles, choose the permissions each grants, and assign roles to members")
}
type permissionInfoDTO struct {
Key string `json:"key"`
Module string `json:"module"`
}
type roleDTO struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
System bool `json:"system"`
Permissions []string `json:"permissions"`
MemberIDs []uuid.UUID `json:"member_ids"`
}
func toRoleDTO(r role.Role) roleDTO {
return roleDTO{ID: r.ID, Name: r.Name, System: r.System, Permissions: r.Permissions, MemberIDs: r.MemberIDs}
}
type memberRolesDTO struct {
MemberID uuid.UUID `json:"member_id"`
RoleIDs []uuid.UUID `json:"role_ids"`
}
// listPermissions is the registered catalogue. It reads the process's
// registry, not the database, so it opens no transaction. module is the key
// up to its first dot, which is how the editor groups its matrix.
func (h handlers) listPermissions(c *gin.Context) {
all := member.RegisteredPermissions()
out := make([]permissionInfoDTO, 0, len(all))
for _, p := range all {
module, _, _ := strings.Cut(p.Key, ".")
out = append(out, permissionInfoDTO{Key: p.Key, Module: module})
}
c.JSON(http.StatusOK, gin.H{"permissions": out})
}
func (h handlers) listRoles(c *gin.Context) {
var rows []role.Role
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
rows, err = role.List(ctx, tx)
return err
}); err != nil {
_ = c.Error(roleErr(err))
return
}
out := make([]roleDTO, 0, len(rows))
for _, r := range rows {
out = append(out, toRoleDTO(r))
}
c.JSON(http.StatusOK, gin.H{"roles": out})
}
type roleNameBody struct {
Name *string `json:"name"`
}
func (h handlers) createRole(c *gin.Context) {
var body roleNameBody
if err := c.ShouldBindJSON(&body); err != nil || body.Name == nil {
_ = c.Error(apperr.New(apperr.KindValidation, "the request body must be JSON with a name"))
return
}
var r role.Role
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
r, err = role.Create(ctx, tx, h.clock(), *body.Name)
return err
}); err != nil {
_ = c.Error(roleErr(err))
return
}
c.JSON(http.StatusCreated, toRoleDTO(r))
}
func (h handlers) renameRole(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
_ = c.Error(apperr.New(apperr.KindValidation, "the role id must be a UUID"))
return
}
var body roleNameBody
if err := c.ShouldBindJSON(&body); err != nil || body.Name == nil {
_ = c.Error(apperr.New(apperr.KindValidation, "the request body must be JSON with a name"))
return
}
var r role.Role
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
r, err = role.Rename(ctx, tx, h.clock(), id, *body.Name)
return err
}); err != nil {
_ = c.Error(roleErr(err))
return
}
c.JSON(http.StatusOK, toRoleDTO(r))
}
type rolePermissionsBody struct {
Permissions *[]string `json:"permissions"`
}
func (h handlers) setRolePermissions(c *gin.Context) {
caller, ok := httpx.IdentityFromContext(c.Request.Context())
if !ok {
_ = c.Error(apperr.New(apperr.KindUnauthorized, "authentication required"))
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
_ = c.Error(apperr.New(apperr.KindValidation, "the role id must be a UUID"))
return
}
var body rolePermissionsBody
if err := c.ShouldBindJSON(&body); err != nil || body.Permissions == nil {
_ = c.Error(apperr.New(apperr.KindValidation,
"the request body must be JSON with a permissions array (it may be empty)"))
return
}
var r role.Role
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
r, err = role.SetPermissions(ctx, tx, h.clock(), id, caller.MemberID, *body.Permissions)
return err
}); err != nil {
_ = c.Error(roleErr(err))
return
}
c.JSON(http.StatusOK, toRoleDTO(r))
}
type memberRolesBody struct {
RoleIDs *[]uuid.UUID `json:"role_ids"`
}
func (h handlers) setMemberRoles(c *gin.Context) {
caller, ok := httpx.IdentityFromContext(c.Request.Context())
if !ok {
_ = c.Error(apperr.New(apperr.KindUnauthorized, "authentication required"))
return
}
memberID, err := uuid.Parse(c.Param("id"))
if err != nil {
_ = c.Error(apperr.New(apperr.KindValidation, "the member id must be a UUID"))
return
}
var body memberRolesBody
if err := c.ShouldBindJSON(&body); err != nil || body.RoleIDs == nil {
_ = c.Error(apperr.New(apperr.KindValidation,
"the request body must be JSON with a role_ids array of UUIDs (it may be empty)"))
return
}
var ids []uuid.UUID
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
ids, err = role.SetMemberRoles(ctx, tx, h.clock(), memberID, caller.MemberID, *body.RoleIDs)
return err
}); err != nil {
_ = c.Error(roleErr(err))
return
}
c.JSON(http.StatusOK, memberRolesDTO{MemberID: memberID, RoleIDs: ids})
}
// roleErr maps the role package's refusals. The two lockouts are 409s with a
// code the client branches on (Plan 10 interface registry). Anything else
// passes through to httpx.Errors.
func roleErr(err error) error {
detail := strings.TrimPrefix(err.Error(), "role: ")
switch {
case errors.Is(err, role.ErrNotFound), errors.Is(err, role.ErrMemberNotFound):
return apperr.New(apperr.KindNotFound, detail)
case errors.Is(err, role.ErrInvalidName), errors.Is(err, role.ErrSystemRoleRename),
errors.Is(err, role.ErrUnknownPermission), errors.Is(err, role.ErrUnknownRole):
return apperr.New(apperr.KindValidation, detail)
case errors.Is(err, role.ErrDuplicateName):
return apperr.New(apperr.KindConflict, detail)
case errors.Is(err, role.ErrOwnerManageRequired):
return apperr.New(apperr.KindConflict, detail).With("code", "owner_role_manage_required")
case errors.Is(err, role.ErrSelfLockout):
return apperr.New(apperr.KindConflict, detail).With("code", "self_lockout")
}
return err
}

In internal/modules/identity/routes.go, after the three invitation routes:

// The role editor (Plan 10, S7). One permission covers all six: reading
// the catalogue and the roles is part of editing them. The two lockout
// refusals, the Owner keeping org.roles.manage and nobody removing it
// from themselves, live in internal/platform/identity/role.
httpx.Protected(g, perms, "GET", "/org/permissions", PermissionRolesManage, h.listPermissions)
httpx.Protected(g, perms, "GET", "/org/roles", PermissionRolesManage, h.listRoles)
httpx.Protected(g, perms, "POST", "/org/roles", PermissionRolesManage, h.createRole)
httpx.Protected(g, perms, "PATCH", "/org/roles/:id", PermissionRolesManage, h.renameRole)
httpx.Protected(g, perms, "PUT", "/org/roles/:id/permissions", PermissionRolesManage, h.setRolePermissions)
httpx.Protected(g, perms, "PUT", "/org/members/:id/roles", PermissionRolesManage, h.setMemberRoles)

In internal/app/routes_test.go, change the composedModules entry to:

"identity": {identity.PermissionOrgAdminister, identity.PermissionMembersInvite, identity.PermissionRolesManage},

Append - org.roles.manage to the end of constants.yaml’s permissions: list, after reports.export.

  • Step 10: Run and pass. Run go test ./internal/modules/identity/ -count=1. Expected: PASS. Watch it fail once. In role.SetPermissions, comment out the if cur.isOwner() && … guard. TestTheOwnerRoleKeepsRolesManageOverHTTP must fail with 200 where it wants 409, while the platform test with the Deputy actor still passes. That shows the guard, not the self-lockout, is what refused. Restore the guard. Next, comment out the if !ok { return ErrSelfLockout } branch. TestAMemberCannotRemoveTheirOwnLastRolesManage must fail. Restore it.

  • Step 11: Record the retention ruling. In internal/kernel/db/retention_test.go, replace the comment above "role", "role_permission", "member_role" in the unruled list. Only the comment changes; the list and its assertions stay as they are:

// Roles and what they grant. Plan 10 (S7) ruled on revocation: a
// revoked grant or role assignment is a DELETE
// (internal/platform/identity/role), not a dated close like
// member_position. The rows are current state. The evidence of who
// could do what, and when, is the audit_log row every change writes
// with its before and after sets, which is hash-chained and
// append-only. Approval authority itself is resolved from dated
// member_position rows and the workflow's grants, not from roles.
// They stay on this list because the list's assertion (DELETE held)
// is exactly the grant that ruling needs. Nothing deletes a role.
"role",
"role_permission",
"member_role",
  • Step 12: The contract. In api/openapi.yaml, add an optional code to the existing Problem schema’s properties, after instance:
code:
type: string
description: >
A stable refusal code the client branches on, where the status
alone is not enough: owner_role_manage_required and self_lockout
on the role editor's 409s.

Insert these paths above the top-level components: line:

# --- the role editor (Plan 10, S7) — every operation needs org.roles.manage ---
/org/permissions:
get:
operationId: listPermissions
summary: Every permission a role can grant
description: >
The keys the running modules registered, sorted, each with its module
(the key up to its first dot). A role can grant only these.
responses:
'200':
description: The catalogue
content:
application/json:
schema: { $ref: '#/components/schemas/PermissionCatalogue' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/RolesForbidden' }
/org/roles:
get:
operationId: listRoles
summary: This organization's roles, what each grants and who holds it
description: Seeded roles first, then by name. member_ids lists active holders only.
responses:
'200':
description: The roles
content:
application/json:
schema: { $ref: '#/components/schemas/RoleList' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/RolesForbidden' }
post:
operationId: createRole
summary: Add a role that grants nothing yet
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/RoleNameInput' }
responses:
'201':
description: The new role
content:
application/json:
schema: { $ref: '#/components/schemas/Role' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/RolesForbidden' }
'409':
description: A role with that name already exists.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'422':
description: The name is blank or longer than 100 characters.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
/org/roles/{id}:
patch:
operationId: renameRole
summary: Rename a role the organization created
description: >
A seeded role (system: true) keeps its name and is refused with 422:
the Owner role is identified by its name wherever the system seeds or
repairs it.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/RoleNameInput' }
responses:
'200':
description: The renamed role
content:
application/json:
schema: { $ref: '#/components/schemas/Role' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/RolesForbidden' }
'404':
description: No such role in this organization.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'409':
description: Another role already has that name.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'422':
description: The name is blank or too long, or the role is a seeded one.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
/org/roles/{id}/permissions:
put:
operationId: setRolePermissions
summary: Replace what a role grants
description: >
The set sent becomes the role's whole set; repeats are ignored.
Audited with the before and after sets, and every token in the
organization is then stale once (the client refreshes and retries).
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/RolePermissionsInput' }
responses:
'200':
description: The role as it now stands
content:
application/json:
schema: { $ref: '#/components/schemas/Role' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/RolesForbidden' }
'404':
description: No such role in this organization.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'409': { $ref: '#/components/responses/RoleLockout' }
'422':
description: A key is not in the catalogue; the detail names it.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
/org/members/{id}/roles:
put:
operationId: setMemberRoles
summary: Replace the roles a member holds
description: >
The set sent becomes the member's whole set. Audited with the before
and after sets; every token in the organization is then stale once.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/MemberRolesInput' }
responses:
'200':
description: The member's roles as they now stand
content:
application/json:
schema: { $ref: '#/components/schemas/MemberRoles' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/RolesForbidden' }
'404':
description: No such member in this organization.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'409': { $ref: '#/components/responses/RoleLockout' }
'422':
description: role_ids names a role that is not this organization's.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }

Append two entries at the end of components.responses, after AdvanceRevisionConflict and before parameters::

RolesForbidden:
description: The caller does not hold org.roles.manage.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
RoleLockout:
description: >
Refused because the organization would lock itself out. code is
owner_role_manage_required when the Owner role would lose
org.roles.manage, and self_lockout when the caller would no longer
hold org.roles.manage. Nothing was changed.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }

Append at the end of the file (components.schemas):

PermissionInfo:
type: object
required: [key, module]
properties:
key: { type: string }
module: { type: string, description: The key up to its first dot. }
PermissionCatalogue:
type: object
required: [permissions]
properties:
permissions: { type: array, items: { $ref: '#/components/schemas/PermissionInfo' } }
Role:
type: object
required: [id, name, system, permissions, member_ids]
properties:
id: { type: string, format: uuid }
name: { type: string, maxLength: 100 }
system:
type: boolean
description: Seeded at provisioning. A seeded role cannot be renamed.
permissions: { type: array, items: { type: string } }
member_ids:
type: array
description: Active members holding the role.
items: { type: string, format: uuid }
RoleList:
type: object
required: [roles]
properties:
roles: { type: array, items: { $ref: '#/components/schemas/Role' } }
RoleNameInput:
type: object
required: [name]
properties:
name: { type: string, minLength: 1, maxLength: 100 }
RolePermissionsInput:
type: object
required: [permissions]
properties:
permissions: { type: array, items: { type: string } }
MemberRolesInput:
type: object
required: [role_ids]
properties:
role_ids: { type: array, items: { type: string, format: uuid } }
MemberRoles:
type: object
required: [member_id, role_ids]
properties:
member_id: { type: string, format: uuid }
role_ids: { type: array, items: { type: string, format: uuid } }

Run make generate. It regenerates internal/gen/api/api.gen.go, internal/kernel/vocab/vocab.gen.go and packages/contracts/vocab.ts; the last two pick up org.roles.manage.

  • Step 13: Run everything this task touched.
go test ./internal/platform/identity/role/ ./internal/modules/identity/ -count=1
go test ./internal/app/ -run 'TestEveryRouteIsAuthorized|TestTheOpenAPIDocumentSaysWhatTheRouterEnforces|TestEveryModuleContributesRoutesAndPermissions' -count=1
go test ./internal/kernel/db/ -run TestOnlyOperationalTablesGrantDeleteToTheAppRole -count=1
go test ./internal/gen/ ./internal/kernel/vocab/ ./internal/kernel/layering/ -count=1

Expected: PASS. TestEveryModuleContributesRoutesAndPermissions now also proves org.roles.manage is in member.RegisteredPermissionSet(). That is the set orgadmin create-org seeds a new Owner with.

  • Step 14: Commit.
Terminal window
git add internal/platform/identity/role/ db/migrations/00170_org_roles_manage.sql \
internal/modules/identity/role_handlers.go internal/modules/identity/role_handlers_test.go \
internal/modules/identity/routes.go internal/modules/identity/routes_test.go \
internal/app/routes_test.go internal/kernel/db/retention_test.go \
constants.yaml api/openapi.yaml internal/gen/api/api.gen.go \
internal/kernel/vocab/vocab.gen.go packages/contracts/vocab.ts
git commit -m "$(cat <<'EOF'
Add the roles API and org.roles.manage (Plan 10 S7)
GET /org/permissions, GET/POST /org/roles, PATCH /org/roles/{id},
PUT /org/roles/{id}/permissions and PUT /org/members/{id}/roles, all
behind the new org.roles.manage. Every change is audited with its before
and after sets and bumps the permission version. The Owner role keeps
org.roles.manage (409 owner_role_manage_required) and nobody can remove
it from themselves (409 self_lockout). Seeded roles keep their names.
Migration 00170 grants the key to every existing Owner role; new
organizations get it from provisioning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"

Task 3: Seams — claims list filters and columns (S3), GET /v1/expenses/claims/summary (S4)

Section titled “Task 3: Seams — claims list filters and columns (S3), GET /v1/expenses/claims/summary (S4)”

Group A. Go only. Shares api/openapi.yaml (append-only) with Tasks 1, 2 and 4.

Facts this task rests on (read, not assumed):

  • Visibility. The list’s visibility is its scope. listClaims (internal/modules/expenses/claim_handlers.go) maps mine to ListFilter{Scope: ScopeMine, Member: who} (the claimant only). all needs expenses.claim.read_all and is checked with member.Can (403 otherwise). to_act is platformexpenses.ToAct: an allowed transition right now, per instance, through workflow.AvailableTransitions, excluding the caller’s own claims. CanRead (visibility.go) is the single-claim rule and is not used by the list. The new filters only narrow each scope. They never widen one, and the summary uses exactly the same three scopes.

  • Keyset. ListClaims pages on (c.created_at, c.id) < ($3, $4), ordered created_at DESC, id DESC, one row past the page. The new predicates are extra ANDs on the same statement. Order and cursor are untouched, so a filtered page’s cursor resumes the same filtered list. to_act stays cursorless, with its 1000-candidate window and 200-row ceiling. Its filters go into the candidate SQL, so a filter narrows the window instead of being applied after it has been eaten.

  • submitted_at. There is no column for it. It is the decided_at of the first workflow_decision row (by seq) whose from_key state has semantic draft and whose to_key state does not (00033_workflow_instance.sql). A claim that is revised and resubmitted keeps its first submission time.

  • category_ids. These are the distinct category_ids of the claim’s lines where withdrawn_at IS NULL, ordered by the first line_no each appears on.

  • month. This is the organization’s calendar month on created_at, in company_profile.timezone (default Asia/Kuala_Lumpur, 00011_company_profile.sql). Postgres computes the bounds (date::timestamp AT TIME ZONE tz), so Go needs no time.LoadLocation and no tzdata. Nothing in the repo embeds tzdata.

  • Money. Every total is computed in SQL: sum(total_amount) over NUMERIC(19,4), scanned into money.Amount. That includes to_act. Its queue is resolved per instance in Go (ToActFiltered), and its ids are then handed back to the same aggregate query (c.id = ANY($5)), so no total is ever added up in Go or in the browser. The currency is company.Get(...).BaseCurrency, because a claim is always in base currency (Ruling R5).

  • KPI buckets (design spec §5.2) are computed by the server with FILTER aggregates over the same scope:

    • pending = semantics pending + custom
    • approved = accrual
    • paid = payout + acknowledged
    • submitted = every semantic except draft

    A claim counts in submitted and in at most one of the other three. rejected, cancelled and dispute count in submitted only. Keying on semantic rather than state key means a customer’s renamed or added states land in the right card.

  • Route order. gin 1.12 serves a static segment beside a parameter. GET /advances/reminders beside GET /advances/:id already proves this in this codebase. /expenses/claims/summary is registered before /expenses/claims/:id, following that precedent.

  • ErrInvalidClaim and ErrBadCursor already map to 422 through claimErr (internal/modules/expenses/errors.go).

Files:

  • Create: internal/platform/expenses/filter.go (criteria, parseCriteria)
  • Create: internal/platform/expenses/extras.go (Extras, ClaimExtras)
  • Create: internal/platform/expenses/summary.go (Summary, SummaryRow, SummaryFilter, Summarize, ScopeToAct)
  • Modify: internal/platform/expenses/claim.go: ListFilter gains State, Q and Month; ListClaims binds them; ToAct becomes a wrapper over the new ToActFiltered
  • Modify: internal/modules/expenses/claim_handlers.go: claimDTO gains three fields, toClaimDTO takes Extras, and claimView and listClaims load extras and bind the new query params
  • Create: internal/modules/expenses/summary_handlers.go
  • Modify: internal/modules/expenses/routes.go (one route), internal/modules/expenses/routes_test.go (one want entry)
  • Test: internal/modules/expenses/claim_filters_test.go, internal/modules/expenses/summary_handlers_test.go (new). They reuse newClaimEnv, fileClaim, submittable, move, useNopPoster, decode and claimListResp from the existing expenses_test files.
  • Modify: api/openapi.yaml (edit listExpenseClaims and ExpenseClaim in place; append one path and four schemas), then make generate

Interfaces:

  • Consumes: company.Get; workflow.AvailableTransitions (via the existing mayAct); claimSelect, scanClaim, toActCandidates, toActCeiling (claim.go); mustActor, mapErr, member.Can, PermissionClaimReadAll (module).

  • Produces:

    package expenses // internal/platform/expenses
    const ScopeToAct = "to_act"
    type ListFilter struct { Scope string; Member uuid.UUID; GroupID *uuid.UUID; After *Cursor; Limit int
    State, Q, Month string } // new: state key; reference prefix / title contains; "YYYY-MM"
    type QueueFilter struct{ State, Q, Month string }
    func ToAct(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID) ([]Claim, error) // unchanged signature
    func ToActFiltered(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID, f QueueFilter) ([]Claim, error)
    type Extras struct { SubmittedAt *time.Time; CategoryIDs []uuid.UUID }
    func ClaimExtras(ctx context.Context, tx pgx.Tx, ids []uuid.UUID) (map[uuid.UUID]Extras, error)
    type SummaryRow struct { State string; Semantic posting.Semantic; Count int; Total money.Amount }
    type KPI struct { Count int; Total money.Amount }
    type KPIs struct { Pending, Approved, Paid, Submitted KPI }
    type Summary struct { Currency string; Rows []SummaryRow; KPIs KPIs }
    type SummaryFilter struct { Scope string; Member uuid.UUID; Month string }
    func Summarize(ctx context.Context, tx pgx.Tx, c clock.Clock, f SummaryFilter) (Summary, error)

    OpenAPI changes:

    • ExpenseClaim gains required created_at (date-time), submitted_at (date-time, nullable) and category_ids (uuid[]).
    • listExpenseClaims gains state, q and month (^\d{4}-(0[1-9]|1[0-2])$).
    • New ExpenseClaimSummaryRow {state, semantic: WorkflowSemantic, count, total: Money}, ExpenseClaimKpi {count, total: Money}, ExpenseClaimKpis {pending, approved, paid, submitted: ExpenseClaimKpi} and ExpenseClaimSummary {currency, rows, kpis}.
    • New operation summarizeExpenseClaims at GET /expenses/claims/summary with params scope and month, permission expenses.claim.read.
  • Step 1: Write the failing filter and column tests. internal/modules/expenses/claim_filters_test.go:

package expenses_test
// Plan 10 S3: the claim list's state, q and month filters and its three new
// columns. Every filter test shows the claim it excludes IS in the unfiltered
// answer first. Otherwise "the filter removed it" would be
// indistinguishable from "it was never there".
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/expenses/expensestest"
)
type claimColumnsResp struct {
ID uuid.UUID `json:"id"`
State string `json:"state"`
CreatedAt time.Time `json:"created_at"`
SubmittedAt *time.Time `json:"submitted_at"`
CategoryIDs []uuid.UUID `json:"category_ids"`
}
func (e *claimEnv) listIDs(t *testing.T, tok, query string) []uuid.UUID {
t.Helper()
w := e.do(t, http.MethodGet, "/v1/expenses/claims"+query, tok, nil)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
ids := []uuid.UUID{}
for _, cl := range decode[claimListResp](t, w).Claims {
ids = append(ids, cl.ID)
}
return ids
}
// backdate moves a draft claim's created_at. No trigger guards created_at:
// expense_claim_draft_guard fires on total, title, group and channel only,
// and expense_claim_identity_guard on claimant, instance, currency and
// reference.
func (e *claimEnv) backdate(t *testing.T, claimID uuid.UUID, at time.Time) {
t.Helper()
_, err := e.ex.H.Admin.Exec(context.Background(),
`UPDATE expense_claim SET created_at = $1 WHERE org_id = $2 AND id = $3`, at, e.ex.Org, claimID)
require.NoError(t, err)
}
func TestAClaimCarriesItsListColumns(t *testing.T) {
e := newClaimEnv(t)
useNopPoster(t)
draft, _ := e.fileClaim(t, e.clerkTok, "Hotel in Penang")
sub := e.submittable(t, e.clerkTok, e.clerk, "Taxi to KLIA")
e.move(t, sub.Claim.ID, "draft", "pending_review", e.clerk)
w := e.do(t, http.MethodGet, "/v1/expenses/claims", e.clerkTok, nil)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var body struct {
Claims []claimColumnsResp `json:"claims"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), w.Body.String())
byID := map[uuid.UUID]claimColumnsResp{}
for _, cl := range body.Claims {
byID[cl.ID] = cl
}
// PRESENCE: the submitted claim carries when it left draft and its
// line's category.
s := byID[sub.Claim.ID]
require.NotNil(t, s.SubmittedAt)
require.True(t, s.SubmittedAt.Equal(expensestest.T0), "the decision's decided_at: %v", s.SubmittedAt)
require.True(t, s.CreatedAt.Equal(expensestest.T0))
require.Equal(t, []uuid.UUID{e.ex.Categories["meals"]}, s.CategoryIDs)
// ABSENCE: the draft has neither. category_ids is [], never null.
d := byID[draft.Claim.ID]
require.Nil(t, d.SubmittedAt)
require.NotNil(t, d.CategoryIDs)
require.Empty(t, d.CategoryIDs)
// The single read carries the same columns.
w = e.do(t, http.MethodGet, "/v1/expenses/claims/"+sub.Claim.ID.String(), e.clerkTok, nil)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var view struct {
Claim claimColumnsResp `json:"claim"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &view))
require.NotNil(t, view.Claim.SubmittedAt)
require.Equal(t, []uuid.UUID{e.ex.Categories["meals"]}, view.Claim.CategoryIDs)
}
func TestTheListFiltersByState(t *testing.T) {
e := newClaimEnv(t)
useNopPoster(t)
draft, _ := e.fileClaim(t, e.clerkTok, "Hotel in Penang")
sub := e.submittable(t, e.clerkTok, e.clerk, "Taxi to KLIA")
e.move(t, sub.Claim.ID, "draft", "pending_review", e.clerk)
require.ElementsMatch(t, []uuid.UUID{draft.Claim.ID, sub.Claim.ID}, e.listIDs(t, e.clerkTok, ""))
require.Equal(t, []uuid.UUID{sub.Claim.ID}, e.listIDs(t, e.clerkTok, "?state=pending_review"))
require.Equal(t, []uuid.UUID{draft.Claim.ID}, e.listIDs(t, e.clerkTok, "?state=draft"))
require.Empty(t, e.listIDs(t, e.clerkTok, "?state=paid"))
}
func TestTheListSearchesReferenceAndTitle(t *testing.T) {
e := newClaimEnv(t)
a, _ := e.fileClaim(t, e.clerkTok, "Hotel in Penang")
b, _ := e.fileClaim(t, e.clerkTok, "Taxi to KLIA")
require.ElementsMatch(t, []uuid.UUID{a.Claim.ID, b.Claim.ID}, e.listIDs(t, e.clerkTok, ""))
require.Equal(t, []uuid.UUID{a.Claim.ID}, e.listIDs(t, e.clerkTok, "?q=penang"),
"title, anywhere in it, any case")
require.Equal(t, []uuid.UUID{b.Claim.ID},
e.listIDs(t, e.clerkTok, "?q="+url.QueryEscape(strings.ToLower(b.Claim.Reference))),
"reference, as a prefix, any case")
require.Empty(t, e.listIDs(t, e.clerkTok, "?q="+url.QueryEscape("%")),
"a LIKE wildcard is searched for as a character, not as everything")
require.Empty(t, e.listIDs(t, e.clerkTok, "?q=hotel_in"), "so is _")
}
func TestTheListFiltersByTheOrganizationsMonth(t *testing.T) {
e := newClaimEnv(t)
sep, _ := e.fileClaim(t, e.clerkTok, "Filed at T0")
kl1st, _ := e.fileClaim(t, e.clerkTok, "First of September in KL")
aug, _ := e.fileClaim(t, e.clerkTok, "Last of August in KL")
// 17:30Z on 31 Aug is 01:30 on 1 Sep in Kuala Lumpur (UTC+8): September's.
e.backdate(t, kl1st.Claim.ID, time.Date(2026, 8, 31, 17, 30, 0, 0, time.UTC))
// 15:00Z on 31 Aug is 23:00 the same day there: August's.
e.backdate(t, aug.Claim.ID, time.Date(2026, 8, 31, 15, 0, 0, 0, time.UTC))
require.ElementsMatch(t, []uuid.UUID{sep.Claim.ID, kl1st.Claim.ID}, e.listIDs(t, e.clerkTok, "?month=2026-09"))
require.Equal(t, []uuid.UUID{aug.Claim.ID}, e.listIDs(t, e.clerkTok, "?month=2026-08"))
require.Empty(t, e.listIDs(t, e.clerkTok, "?month=2026-07"))
}
// TestFiltersComposeWithTheCursor: a filtered list pages exactly like an
// unfiltered one, and no page carries a claim the filter excludes.
func TestFiltersComposeWithTheCursor(t *testing.T) {
e := newClaimEnv(t)
for _, title := range []string{"Penang one", "Penang two", "Penang three", "Taxi"} {
e.fileClaim(t, e.clerkTok, title)
}
first := e.do(t, http.MethodGet, "/v1/expenses/claims?q=penang&limit=2", e.clerkTok, nil)
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
p1 := decode[claimListResp](t, first)
require.Len(t, p1.Claims, 2)
require.NotNil(t, p1.NextCursor)
second := e.do(t, http.MethodGet,
"/v1/expenses/claims?q=penang&limit=2&cursor="+url.QueryEscape(*p1.NextCursor), e.clerkTok, nil)
require.Equal(t, http.StatusOK, second.Code, second.Body.String())
p2 := decode[claimListResp](t, second)
require.Len(t, p2.Claims, 1, "three matches, two pages")
require.Nil(t, p2.NextCursor)
for _, cl := range append(p1.Claims, p2.Claims...) {
require.Contains(t, cl.Title, "Penang")
}
}
// TestTheQueueTakesTheSameFilters: scope=to_act narrows by the same three
// filters, inside its candidate window.
func TestTheQueueTakesTheSameFilters(t *testing.T) {
e := newClaimEnv(t)
useNopPoster(t)
kl := e.submittable(t, e.clerkTok, e.clerk, "KL client visit")
e.move(t, kl.Claim.ID, "draft", "pending_review", e.clerk)
pg := e.submittable(t, e.clerkTok, e.clerk, "Penang supplier")
e.move(t, pg.Claim.ID, "draft", "pending_review", e.clerk)
require.ElementsMatch(t, []uuid.UUID{kl.Claim.ID, pg.Claim.ID}, e.listIDs(t, e.adminTok, "?scope=to_act"))
require.Equal(t, []uuid.UUID{pg.Claim.ID}, e.listIDs(t, e.adminTok, "?scope=to_act&q=penang"))
require.ElementsMatch(t, []uuid.UUID{kl.Claim.ID, pg.Claim.ID},
e.listIDs(t, e.adminTok, "?scope=to_act&state=pending_review"))
require.Empty(t, e.listIDs(t, e.adminTok, "?scope=to_act&state=draft"))
require.Empty(t, e.listIDs(t, e.adminTok, "?scope=to_act&month=2026-08"))
}
func TestBadFiltersAre422(t *testing.T) {
e := newClaimEnv(t)
for _, q := range []string{
"?state=Pending%20Review", "?month=2026-13", "?month=202609", "?month=2026-9",
"?q=" + strings.Repeat("x", 101), "?scope=to_act&month=26-09",
} {
w := e.do(t, http.MethodGet, "/v1/expenses/claims"+q, e.clerkTok, nil)
require.Equal(t, http.StatusUnprocessableEntity, w.Code, q+": "+w.Body.String())
}
}

claimHeaderResp already has Title and Reference (claim_handlers_test.go), so claimListResp carries them.

  • Step 2: Write the failing summary test. internal/modules/expenses/summary_handlers_test.go:
package expenses_test
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
type summaryRowResp struct {
State string `json:"state"`
Semantic string `json:"semantic"`
Count int `json:"count"`
Total string `json:"total"`
}
type kpiResp struct {
Count int `json:"count"`
Total string `json:"total"`
}
type summaryResp struct {
Currency string `json:"currency"`
Rows []summaryRowResp `json:"rows"`
KPIs struct {
Pending kpiResp `json:"pending"`
Approved kpiResp `json:"approved"`
Paid kpiResp `json:"paid"`
Submitted kpiResp `json:"submitted"`
} `json:"kpis"`
}
func (e *claimEnv) summary(t *testing.T, tok, query string) summaryResp {
t.Helper()
w := e.do(t, http.MethodGet, "/v1/expenses/claims/summary"+query, tok, nil)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
s := decode[summaryResp](t, w)
require.NotNil(t, s.Rows, "rows is [], never null")
return s
}
func byState(s summaryResp) map[string]summaryRowResp {
out := map[string]summaryRowResp{}
for _, r := range s.Rows {
out[r.State] = r
}
return out
}
// TestTheSummaryCountsAndTotalsPerState: every scope shows its PRESENCE
// before another scope, or a month, is shown to exclude the same claims.
func TestTheSummaryCountsAndTotalsPerState(t *testing.T) {
e := newClaimEnv(t)
useNopPoster(t)
e.fileClaim(t, e.clerkTok, "Hotel in Penang") // a draft with no lines
sub := e.submittable(t, e.clerkTok, e.clerk, "KL client visit")
e.move(t, sub.Claim.ID, "draft", "pending_review", e.clerk)
mine := e.summary(t, e.clerkTok, "")
require.Equal(t, "MYR", mine.Currency)
rows := byState(mine)
require.Equal(t, summaryRowResp{State: "draft", Semantic: "draft", Count: 1, Total: "0.0000"}, rows["draft"])
require.Equal(t, 1, rows["pending_review"].Count)
require.Equal(t, "45.6000", rows["pending_review"].Total, "money is a string, summed in NUMERIC")
require.Equal(t, 1, byState(e.summary(t, e.adminTok, "?scope=all"))["pending_review"].Count)
require.Empty(t, e.summary(t, e.adminTok, "").Rows, "the Owner's own paperwork is empty")
require.Equal(t, http.StatusForbidden,
e.do(t, http.MethodGet, "/v1/expenses/claims/summary?scope=all", e.clerkTok, nil).Code)
queue := byState(e.summary(t, e.adminTok, "?scope=to_act"))
require.Equal(t, 1, queue["pending_review"].Count)
require.Equal(t, "45.6000", queue["pending_review"].Total)
require.Empty(t, e.summary(t, e.clerkTok, "?scope=to_act").Rows, "nobody decides their own claim")
require.Len(t, e.summary(t, e.clerkTok, "?month=2026-09").Rows, 2)
require.Empty(t, e.summary(t, e.clerkTok, "?month=2026-08").Rows)
for _, q := range []string{"?scope=everyone", "?month=2026-9"} {
require.Equal(t, http.StatusUnprocessableEntity,
e.do(t, http.MethodGet, "/v1/expenses/claims/summary"+q, e.clerkTok, nil).Code, q)
}
}
// TestTheSummaryBucketsKPIsBySemantic: one claim in each of draft, pending,
// accrual and payout. The rows show all four exist, which is the PRESENCE.
// Each card then holds exactly its semantics, and "submitted" holds every
// one BUT the draft, which is the ABSENCE. The draft has a real amount, so
// leaving it out changes the total.
func TestTheSummaryBucketsKPIsBySemantic(t *testing.T) {
e := newClaimEnv(t)
useNopPoster(t)
draft, etag := e.fileClaim(t, e.clerkTok, "Still a draft")
e.addLine(t, e.clerkTok, draft.Claim.ID, etag, receiptBody(e.env, "meals", "2026-09-19", "12.00", "Coffee"))
pending := e.submittable(t, e.clerkTok, e.clerk, "Pending one")
e.move(t, pending.Claim.ID, "draft", "pending_review", e.clerk)
accrued := e.submittable(t, e.clerkTok, e.clerk, "Accrued one")
e.move(t, accrued.Claim.ID, "draft", "pending_review", e.clerk)
for _, edge := range [][2]string{{"pending_review", "reviewed"}, {"reviewed", "approved"},
{"approved", "category_verified"}} {
e.move(t, accrued.Claim.ID, edge[0], edge[1], e.admin)
}
// Paid: the same walk as TestAPaidClaimShowsOnlyMaskedPayoutDetails.
channel := e.ex.Channel(t, "bank_transfer")
e.ex.PayoutProfile(t, e.clerk, channel, map[string]string{
"payee_name": "Siti Aminah", "bank": "maybank", "account_no": "514012346789",
})
proof := e.ex.Document(t, e.admin, "image/jpeg", []byte("kpi transfer slip"))
paid := e.submittable(t, e.clerkTok, e.clerk, "Paid one")
e.move(t, paid.Claim.ID, "draft", "pending_review", e.clerk)
for _, edge := range [][2]string{{"pending_review", "reviewed"}, {"reviewed", "approved"},
{"approved", "category_verified"}} {
e.move(t, paid.Claim.ID, edge[0], edge[1], e.admin)
}
e.ex.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, _, err := platformexpenses.Transition(ctx, tx, e.ex.Clock, platformexpenses.TransitionInput{
ClaimID: paid.Claim.ID, From: "category_verified", To: "completed", Actor: e.admin,
PayoutChannelID: &channel, ProofDocumentID: &proof,
})
return err
})
s := e.summary(t, e.clerkTok, "")
rows := byState(s)
require.Equal(t, "12.0000", rows["draft"].Total, "PRESENCE: the draft is in scope, with money on it")
require.Equal(t, 1, rows["pending_review"].Count)
require.Equal(t, 1, rows["category_verified"].Count)
require.Equal(t, 1, rows["completed"].Count)
require.Equal(t, kpiResp{Count: 1, Total: "45.6000"}, s.KPIs.Pending, "pending + custom")
require.Equal(t, kpiResp{Count: 1, Total: "45.6000"}, s.KPIs.Approved, "accrual")
require.Equal(t, kpiResp{Count: 1, Total: "45.6000"}, s.KPIs.Paid, "payout + acknowledged")
require.Equal(t, kpiResp{Count: 3, Total: "136.8000"}, s.KPIs.Submitted,
"ABSENCE: the 12.00 draft is not submitted")
// An empty scope still answers every card, at zero, never null.
own := e.summary(t, e.adminTok, "")
require.Equal(t, kpiResp{Count: 0, Total: "0.0000"}, own.KPIs.Submitted)
// to_act's cards are the queue's, totalled by the same SQL: the Owner may
// decide the pending one; the accrued one waits on its payer.
queue := e.summary(t, e.adminTok, "?scope=to_act")
require.Equal(t, 1, queue.KPIs.Pending.Count)
require.Equal(t, "45.6000", queue.KPIs.Pending.Total)
}

Add "context", "github.com/jackc/pgx/v5" and platformexpenses "github.com/mcom/erp/internal/platform/expenses" to this file’s imports. addLine, receiptBody, e.ex.Channel, e.ex.PayoutProfile and e.ex.Document are the existing helpers claim_handlers_test.go uses. receiptBody takes the embedded *env, hence e.env.

The Owner may also be able to act on the accrued claim (pay it), depending on the seeded grants. The test therefore asserts only the queue’s pending card, which holds nothing but the pending claim either way.

In internal/modules/expenses/routes_test.go, add this line to want, after "GET /v1/expenses/claims": read,:

"GET /v1/expenses/claims/summary": read,
  • Step 3: Run and watch fail. go test ./internal/modules/expenses/ -run 'TestAClaimCarries|TestTheList|TestFiltersCompose|TestTheQueueTakes|TestBadFilters|TestTheSummary|TestEveryRouteRequires' -count=1 fails. The column test fails on SubmittedAt being nil because the field is not sent. The filter tests fail because unfiltered results come back. The summary test gets a 404 problem, since summary is read as a claim id. The routes test fails on the missing entry. Verify: each filter test fails on its filtered assertion, not on its presence line. A failure on the presence line means the fixture is wrong, and the test would be vacuous once it passes.

  • Step 4: The filters. internal/platform/expenses/filter.go:

// The filters the claim list, the approval queue and the summary share
// (Plan 10 S3–S4): a state key, a reference-or-title search, and the
// organization's calendar month on created_at.
package expenses
import (
"context"
"fmt"
"regexp"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/platform/company"
)
var (
statePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`)
monthPattern = regexp.MustCompile(`^\d{4}-(0[1-9]|1[0-2])$`)
// likeEscaper makes a search term literal under ILIKE, whose default
// escape character is the backslash: a claimant searching for "50%"
// means those three characters, not "50 followed by anything".
likeEscaper = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
)
const maxSearchLength = 100
// criteria are the filters as the queries bind them. A nil pointer is "no
// filter": each predicate is guarded by its own IS NULL, the shape
// ListClaims already uses for its cursor and group.
type criteria struct {
state *string
q *string // LIKE-escaped
month *time.Time // the first day of the month, as a DATE
tz string // company_profile.timezone; "UTC" when there is no month
}
// parseCriteria validates the three filters. The month is the
// organization's calendar month, so a claim filed at 01:00 on the 1st in
// Kuala Lumpur is that month's, even though in UTC it is still the previous
// day. Postgres applies the timezone (AT TIME ZONE), which is why the
// profile's timezone name is bound as text rather than loaded in Go.
func parseCriteria(ctx context.Context, tx pgx.Tx, state, q, month string) (criteria, error) {
cr := criteria{tz: "UTC"}
if state != "" {
if !statePattern.MatchString(state) {
return criteria{}, fmt.Errorf("%w: state must be a workflow state key", ErrInvalidClaim)
}
cr.state = &state
}
if q = strings.TrimSpace(q); q != "" {
if utf8.RuneCountInString(q) > maxSearchLength {
return criteria{}, fmt.Errorf("%w: q must be at most %d characters", ErrInvalidClaim, maxSearchLength)
}
escaped := likeEscaper.Replace(q)
cr.q = &escaped
}
if month != "" {
if !monthPattern.MatchString(month) {
return criteria{}, fmt.Errorf("%w: month must be YYYY-MM", ErrInvalidClaim)
}
start, err := time.Parse("2006-01", month)
if err != nil {
return criteria{}, fmt.Errorf("%w: month must be YYYY-MM", ErrInvalidClaim)
}
prof, err := company.Get(ctx, tx)
if err != nil {
return criteria{}, fmt.Errorf("expenses: month filter: %w", err)
}
cr.month, cr.tz = &start, prof.Timezone
}
return cr, nil
}

In internal/platform/expenses/claim.go, extend ListFilter:

type ListFilter struct {
Scope string
Member uuid.UUID
GroupID *uuid.UUID
After *Cursor
Limit int
// Plan 10 S3. Empty means no filter. State is a workflow state key; Q
// matches a reference by prefix or a title anywhere, ignoring case;
// Month is "YYYY-MM" in the organization's timezone, on created_at.
State, Q, Month string
}

In ListClaims, after the limit clamp:

cr, err := parseCriteria(ctx, tx, f.State, f.Q, f.Month)
if err != nil {
return nil, nil, err
}

Then replace its query with:

// $3/$4 are the cursor pair, $5 the group, and $7–$10 the Plan 10
// filters, each guarded by its own NULL test so one statement serves
// every combination. They only add ANDs: the order and the cursor
// predicate are unchanged, so a filtered page's cursor resumes the same
// filtered list.
rows, err := tx.Query(ctx, claimSelect+`
($2::uuid IS NULL OR c.claimant_member_id = $2)
AND ($3::timestamptz IS NULL OR (c.created_at, c.id) < ($3, $4))
AND ($5::uuid IS NULL OR c.group_id = $5)
AND ($7::text IS NULL OR i.state_key = $7)
AND ($8::text IS NULL OR c.reference ILIKE ($8 || '%') OR c.title ILIKE ('%' || $8 || '%'))
AND ($9::date IS NULL OR (c.created_at >= ($9::date::timestamp AT TIME ZONE $10::text)
AND c.created_at < (($9::date + interval '1 month') AT TIME ZONE $10::text)))
ORDER BY c.created_at DESC, c.id DESC
LIMIT $6`, orgID, member, afterAt, afterID, f.GroupID, limit+1, cr.state, cr.q, cr.month, cr.tz)

Replace ToAct with a wrapper, and move its body into ToActFiltered. Keep the doc comment on ToActFiltered and add one paragraph:

// QueueFilter narrows the approval queue by ListFilter's three filters.
type QueueFilter struct{ State, Q, Month string }
// ToAct is ToActFiltered with no filter. Its callers predate the filters.
func ToAct(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID) ([]Claim, error) {
return ToActFiltered(ctx, tx, c, member, QueueFilter{})
}
// ToActFiltered is the approval queue (Ruling R21): ... (ToAct's existing
// comment, unchanged) ...
//
// The filters go into the candidate query, not after it, so that a filter
// narrows the window instead of being applied to a window already spent on
// claims it excludes.
func ToActFiltered(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID, f QueueFilter) ([]Claim, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return nil, fmt.Errorf("expenses: claims to act on: %w", tenancy.ErrNoOrg)
}
cr, err := parseCriteria(ctx, tx, f.State, f.Q, f.Month)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, claimSelect+`
c.claimant_member_id <> $2
AND s.semantic NOT IN ('draft','acknowledged')
AND EXISTS (
SELECT 1 FROM workflow_edge e
JOIN workflow_state ts ON ts.org_id = e.org_id AND ts.definition_id = e.definition_id
AND ts.state_key = e.to_key
WHERE e.org_id = $1 AND e.definition_id = i.definition_id AND e.from_key = i.state_key
AND (e.gate <> 'reporter'
OR (ts.semantic <> 'acknowledged' AND EXISTS (
SELECT 1 FROM workflow_gate_grant g
WHERE g.org_id = $1 AND g.definition_id = e.definition_id AND g.all_states))))
AND ($4::text IS NULL OR i.state_key = $4)
AND ($5::text IS NULL OR c.reference ILIKE ($5 || '%') OR c.title ILIKE ('%' || $5 || '%'))
AND ($6::date IS NULL OR (c.created_at >= ($6::date::timestamp AT TIME ZONE $7::text)
AND c.created_at < (($6::date + interval '1 month') AT TIME ZONE $7::text)))
ORDER BY i.entered_state_at ASC, c.id ASC
LIMIT $3`, orgID, member, toActCandidates, cr.state, cr.q, cr.month, cr.tz)
// ... the rest of the old ToAct body, unchanged: collect candidates,
// close rows, then keep those mayAct allows, up to toActCeiling.
}
  • Step 5: The columns. internal/platform/expenses/extras.go:
package expenses
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/tenancy"
)
// Extras are the list columns a claim header does not carry itself
// (Plan 10 S3). They live apart from claimSelect so that GetClaim,
// LockClaim (FOR UPDATE OF c) and every transition keep reading exactly
// what they read before.
type Extras struct {
// SubmittedAt is the first decision out of a draft-semantic state, from
// the decision log. nil for a claim never submitted. A claim revised and
// resubmitted keeps its first submission.
SubmittedAt *time.Time
// CategoryIDs are the distinct categories of the active (not withdrawn)
// lines, in order of each category's first line. Never nil.
CategoryIDs []uuid.UUID
}
// ClaimExtras loads Extras for ids in one query. A claim missing from the
// result (another organization's, or no such claim) is missing from the
// map, and the caller reads the zero value.
func ClaimExtras(ctx context.Context, tx pgx.Tx, ids []uuid.UUID) (map[uuid.UUID]Extras, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return nil, fmt.Errorf("expenses: claim extras: %w", tenancy.ErrNoOrg)
}
out := make(map[uuid.UUID]Extras, len(ids))
if len(ids) == 0 {
return out, nil
}
rows, err := tx.Query(ctx, `
SELECT c.id,
(SELECT d.decided_at
FROM workflow_decision d
JOIN workflow_state fs ON fs.org_id = d.org_id AND fs.definition_id = d.definition_id
AND fs.state_key = d.from_key
JOIN workflow_state ts ON ts.org_id = d.org_id AND ts.definition_id = d.definition_id
AND ts.state_key = d.to_key
WHERE d.org_id = c.org_id AND d.instance_id = c.instance_id
AND fs.semantic = 'draft' AND ts.semantic <> 'draft'
ORDER BY d.seq
LIMIT 1),
ARRAY(SELECT l.category_id
FROM expense_claim_line l
WHERE l.org_id = c.org_id AND l.claim_id = c.id AND l.withdrawn_at IS NULL
GROUP BY l.category_id
ORDER BY min(l.line_no))
FROM expense_claim c
WHERE c.org_id = $1 AND c.id = ANY($2::uuid[])`, orgID, ids)
if err != nil {
return nil, fmt.Errorf("expenses: claim extras: %w", err)
}
defer rows.Close()
for rows.Next() {
var (
id uuid.UUID
x Extras
)
if err := rows.Scan(&id, &x.SubmittedAt, &x.CategoryIDs); err != nil {
return nil, fmt.Errorf("expenses: claim extras scan: %w", err)
}
if x.SubmittedAt != nil {
utc := x.SubmittedAt.UTC()
x.SubmittedAt = &utc
}
if x.CategoryIDs == nil {
x.CategoryIDs = []uuid.UUID{}
}
out[id] = x
}
return out, rows.Err()
}
  • Step 6: The summary. internal/platform/expenses/summary.go:
package expenses
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/money"
"github.com/mcom/erp/internal/kernel/tenancy"
"github.com/mcom/erp/internal/platform/company"
"github.com/mcom/erp/internal/platform/posting"
)
// ScopeToAct is the approval queue's scope name, as the list uses it.
const ScopeToAct = "to_act"
// SummaryRow is one state's count and total (Plan 10 S4). The KPI cards and
// the filter chips' counts read these; counting a page client-side would be
// wrong as soon as there is a second page.
type SummaryRow struct {
State string
Semantic posting.Semantic
Count int
Total money.Amount
}
// KPI is one card: how many claims and their total.
type KPI struct {
Count int
Total money.Amount
}
// KPIs are the design's four cards (Plan 10 spec §5.2), bucketed by
// semantic so that renamed or added states land in the right card:
// Pending = pending + custom; Approved = accrual; Paid = payout +
// acknowledged; Submitted = everything but draft. A claim counts in
// Submitted and in at most one other; rejected, cancelled and dispute count
// in Submitted only. Computed in SQL: the browser never adds money.
type KPIs struct {
Pending, Approved, Paid, Submitted KPI
}
// Summary is the rows, the four cards, and the currency every total is in:
// the organization's base currency, the only one a claim is in (Ruling R5).
// Rows are ordered by state key; a client orders chips by the definition's
// display_order.
type Summary struct {
Currency string
Rows []SummaryRow
KPIs KPIs
}
// SummaryFilter is the list's scopes and month filter. Whether the caller
// may ask for ScopeAll is the handler's question, as it is for the list.
type SummaryFilter struct {
Scope string
Member uuid.UUID
Month string
}
// summaryFrom is the one scope both summary queries read, so the rows and
// the cards can never disagree about which claims they counted. $2 is the
// claimant (mine), $3/$4 the month and timezone, and $5 the to_act queue's
// ids. Each is guarded by its own NULL test.
const summaryFrom = `
FROM expense_claim c
JOIN workflow_instance i ON i.org_id = c.org_id AND i.id = c.instance_id
JOIN workflow_state s ON s.org_id = i.org_id AND s.definition_id = i.definition_id
AND s.state_key = i.state_key
WHERE c.org_id = $1
AND ($2::uuid IS NULL OR c.claimant_member_id = $2)
AND ($3::date IS NULL OR (c.created_at >= ($3::date::timestamp AT TIME ZONE $4::text)
AND c.created_at < (($3::date + interval '1 month') AT TIME ZONE $4::text)))
AND ($5::uuid[] IS NULL OR c.id = ANY($5::uuid[]))`
// Summarize counts and totals the claims in f's scope, per state and per
// KPI card. Every total is a NUMERIC sum in SQL. For to_act, the queue is
// resolved per instance in Go, and its ids are then handed back to the same
// SQL rather than added up here.
func Summarize(ctx context.Context, tx pgx.Tx, c clock.Clock, f SummaryFilter) (Summary, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return Summary{}, fmt.Errorf("expenses: summarize: %w", tenancy.ErrNoOrg)
}
prof, err := company.Get(ctx, tx)
if err != nil {
return Summary{}, fmt.Errorf("expenses: summarize: %w", err)
}
var (
member *uuid.UUID
queue any // nil: no id filter; a []uuid.UUID (possibly empty) for to_act
)
switch f.Scope {
case ScopeMine:
m := f.Member
member = &m
case ScopeAll:
case ScopeToAct:
claims, err := ToActFiltered(ctx, tx, c, f.Member, QueueFilter{Month: f.Month})
if err != nil {
return Summary{}, err
}
ids := make([]uuid.UUID, 0, len(claims))
for _, cl := range claims {
ids = append(ids, cl.ID)
}
queue = ids
default:
return Summary{}, fmt.Errorf("%w: unknown scope %q", ErrInvalidClaim, f.Scope)
}
cr, err := parseCriteria(ctx, tx, "", "", f.Month)
if err != nil {
return Summary{}, err
}
args := []any{orgID, member, cr.month, cr.tz, queue}
out := Summary{Currency: prof.BaseCurrency, Rows: []SummaryRow{}}
rows, err := tx.Query(ctx, `
SELECT i.state_key, s.semantic, count(*), sum(c.total_amount)`+summaryFrom+`
GROUP BY i.state_key, s.semantic
ORDER BY i.state_key, s.semantic`, args...)
if err != nil {
return Summary{}, fmt.Errorf("expenses: summarize: %w", err)
}
for rows.Next() {
var (
r SummaryRow
semantic string
)
if err := rows.Scan(&r.State, &semantic, &r.Count, &r.Total); err != nil {
rows.Close()
return Summary{}, fmt.Errorf("expenses: summarize scan: %w", err)
}
r.Semantic = posting.Semantic(semantic)
out.Rows = append(out.Rows, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return Summary{}, fmt.Errorf("expenses: summarize: %w", err)
}
// The four cards (design spec §5.2). COALESCE because sum() over no row is
// NULL, and an empty scope's card is 0.0000, not null.
k := &out.KPIs
if err := tx.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE s.semantic IN ('pending','custom')),
COALESCE(sum(c.total_amount) FILTER (WHERE s.semantic IN ('pending','custom')), 0),
count(*) FILTER (WHERE s.semantic = 'accrual'),
COALESCE(sum(c.total_amount) FILTER (WHERE s.semantic = 'accrual'), 0),
count(*) FILTER (WHERE s.semantic IN ('payout','acknowledged')),
COALESCE(sum(c.total_amount) FILTER (WHERE s.semantic IN ('payout','acknowledged')), 0),
count(*) FILTER (WHERE s.semantic <> 'draft'),
COALESCE(sum(c.total_amount) FILTER (WHERE s.semantic <> 'draft'), 0)`+summaryFrom, args...).
Scan(&k.Pending.Count, &k.Pending.Total, &k.Approved.Count, &k.Approved.Total,
&k.Paid.Count, &k.Paid.Total, &k.Submitted.Count, &k.Submitted.Total); err != nil {
return Summary{}, fmt.Errorf("expenses: summarize kpis: %w", err)
}
return out, nil
}

Verify: pgx binds the untyped nil in queue as NULL for $5::uuid[]. It does for a nil any argument. COALESCE(sum(numeric), 0) scans into money.Amount as 0.0000, because money.Amount.Scan parses the numeric text and String() renders four places. If either fails, bind queue as a *[]uuid.UUID (nil pointer for “no filter”) and cast the COALESCE default as 0::numeric.

  • Step 7: The module. In internal/modules/expenses/claim_handlers.go:
    • claimDTO gains:
      CreatedAt time.Time `json:"created_at"`
      SubmittedAt *time.Time `json:"submitted_at"` // the first decision out of draft; null before
      CategoryIDs []uuid.UUID `json:"category_ids"` // active lines' categories, line order; never null
    • toClaimDTO becomes:

func toClaimDTO(cl platformexpenses.Claim, x platformexpenses.Extras) claimDTO { cats := x.CategoryIDs if cats == nil { cats = []uuid.UUID{} } return claimDTO{ ID: cl.ID, Reference: cl.Reference, ClaimantMemberID: cl.ClaimantID, Title: cl.Title, Currency: cl.Currency, Total: cl.Total, State: cl.State, Semantic: cl.Semantic, GroupID: cl.GroupID, PayoutChannelID: cl.PayoutChannelID, Revision: cl.Revision, CreatedAt: cl.CreatedAt, SubmittedAt: x.SubmittedAt, CategoryIDs: cats, } } ```

  • In claimView, replace out := claimViewDTO{Claim: toClaimDTO(cl)} with:
    extras, err := platformexpenses.ClaimExtras(ctx, tx, []uuid.UUID{cl.ID})
    if err != nil {
    return claimViewDTO{}, err
    }
    out := claimViewDTO{Claim: toClaimDTO(cl, extras[cl.ID])}
    Every mutation that answers with the view goes through claimView on its own transaction, after its write. So a claim that has just been submitted reports its new submitted_at in the same response.
  • In listClaims, set the filters after the group_id block:
    // Plan 10 S3. Validation is the platform's (parseCriteria): a bad
    // value is ErrInvalidClaim, 422 through mapErr.
    f.State, f.Q, f.Month = c.Query("state"), c.Query("q"), c.Query("month")
    Declare extras map[uuid.UUID]platformexpenses.Extras beside claims and next, and change the transaction body’s tail to:
    var err error
    if scope == scopeToAct {
    claims, err = platformexpenses.ToActFiltered(ctx, tx, h.deps.Clock, who,
    platformexpenses.QueueFilter{State: f.State, Q: f.Q, Month: f.Month})
    } else {
    claims, next, err = platformexpenses.ListClaims(ctx, tx, f)
    }
    if err != nil {
    return err
    }
    ids := make([]uuid.UUID, 0, len(claims))
    for _, cl := range claims {
    ids = append(ids, cl.ID)
    }
    extras, err = platformexpenses.ClaimExtras(ctx, tx, ids)
    return err
    Then use out.Claims = append(out.Claims, toClaimDTO(cl, extras[cl.ID])).

internal/modules/expenses/summary_handlers.go:

package expenses
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5"
apperr "github.com/mcom/erp/internal/kernel/errors"
"github.com/mcom/erp/internal/kernel/money"
platformexpenses "github.com/mcom/erp/internal/platform/expenses"
"github.com/mcom/erp/internal/platform/identity/member"
"github.com/mcom/erp/internal/platform/posting"
)
// GET /v1/expenses/claims/summary (Plan 10 S4): per state, how many claims
// the caller's scope holds and their total. Same scopes and the same
// read_all rule as the list, so a count never includes a claim the list
// would not show.
type summaryRowDTO struct {
State string `json:"state"`
Semantic posting.Semantic `json:"semantic"`
Count int `json:"count"`
Total money.Amount `json:"total"`
}
type kpiDTO struct {
Count int `json:"count"`
Total money.Amount `json:"total"`
}
type kpisDTO struct {
Pending kpiDTO `json:"pending"`
Approved kpiDTO `json:"approved"`
Paid kpiDTO `json:"paid"`
Submitted kpiDTO `json:"submitted"`
}
type summaryDTO struct {
Currency string `json:"currency"`
Rows []summaryRowDTO `json:"rows"`
KPIs kpisDTO `json:"kpis"`
}
func toKPI(k platformexpenses.KPI) kpiDTO { return kpiDTO{Count: k.Count, Total: k.Total} }
func (h handlers) summarizeClaims(c *gin.Context) {
who, err := mustActor(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
}
scope := c.DefaultQuery("scope", scopeMine)
switch scope {
case scopeMine, scopeAll, scopeToAct:
default:
_ = c.Error(apperr.New(apperr.KindValidation, "scope must be mine, all or to_act"))
return
}
var s platformexpenses.Summary
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
if scope == scopeAll {
ok, err := member.Can(ctx, tx, who, PermissionClaimReadAll)
if err != nil {
return err
}
if !ok {
return apperr.New(apperr.KindForbidden,
"summarizing every claim of the organization needs "+PermissionClaimReadAll)
}
}
var err error
s, err = platformexpenses.Summarize(ctx, tx, h.deps.Clock, platformexpenses.SummaryFilter{
Scope: scope, Member: who, Month: c.Query("month"),
})
return err
}); err != nil {
_ = c.Error(mapErr(err))
return
}
out := summaryDTO{Currency: s.Currency, Rows: make([]summaryRowDTO, 0, len(s.Rows)), KPIs: kpisDTO{
Pending: toKPI(s.KPIs.Pending), Approved: toKPI(s.KPIs.Approved),
Paid: toKPI(s.KPIs.Paid), Submitted: toKPI(s.KPIs.Submitted),
}}
for _, r := range s.Rows {
out.Rows = append(out.Rows, summaryRowDTO{State: r.State, Semantic: r.Semantic, Count: r.Count, Total: r.Total})
}
c.JSON(http.StatusOK, out)
}

The module constants scopeMine = "mine", scopeAll = "all" and scopeToAct = "to_act" already exist (claim_handlers.go). The platform’s ScopeMine and ScopeAll hold the same strings, and the new ScopeToAct completes the set, so passing scope straight through is exact.

In internal/modules/expenses/routes.go, directly above httpx.Protected(g, p, "GET", "/expenses/claims/:id", read, h.getClaim):

// The KPI counts (Plan 10 S4). Registered before /expenses/claims/:id,
// as /advances/reminders is before /advances/:id.
httpx.Protected(g, p, "GET", "/expenses/claims/summary", read, h.summarizeClaims)
  • Step 8: Run and pass. Run go test ./internal/platform/expenses/ ./internal/modules/expenses/ -count=1. The platform suite exercises the unchanged ToAct callers (visibility_test.go) and ListClaims (claim_test.go). Then run go test ./internal/kernel/layering/ -count=1. Expected: PASS. Verify: the tenant-predicate guard accepts ClaimExtras’s correlated subqueries (d.org_id = c.org_id, fs/ts.org_id = d.org_id, l.org_id = c.org_id) and the summary.go query. If it reports one, restructure the query; do not exempt it. Verify: pgx binds a *time.Time to $9::date (a nil pointer as NULL). If it refuses, bind cr.month as a *string formatted 2006-01-02 and keep the ::date cast. Watch it fail once (KPIs). In the KPI query, change the submitted filter to WHERE true. TestTheSummaryBucketsKPIsBySemantic must fail with {4 148.8000} where it wants {3 136.8000}, which shows the draft is excluded by the filter and not missing from the fixture. Restore it. Watch it fail once. Delete the AND ($8::text IS NULL OR …) line from ListClaims. TestTheListSearchesReferenceAndTitle must fail on ?q=penang returning two ids. Restore the line. Next, change likeEscaper to strings.NewReplacer(). The % assertion must fail. Restore it.

  • Step 9: The contract. In api/openapi.yaml:

    • ExpenseClaim: append created_at, submitted_at, category_ids to its required: list, and add to its properties:
      created_at:
      type: string
      format: date-time
      description: When the claim was filed. The list's order and its month filter are on this.
      submitted_at:
      type: string
      format: date-time
      nullable: true
      description: >
      When it first left draft: the first decision out of a
      draft-semantic state, from the decision log. null until then.
      category_ids:
      type: array
      description: >
      The distinct categories of the active lines, in order of each
      category's first line. [] for a claim with no active line.
      items: { type: string, format: uuid }
    • listExpenseClaims (/expenses/claims → get → parameters): after group_id, add
      - name: state
      in: query
      required: false
      schema: { type: string, pattern: '^[a-z][a-z0-9_]{0,62}$' }
      description: Only claims in this workflow state (a state key). Works with every scope.
      - name: q
      in: query
      required: false
      schema: { type: string, maxLength: 100 }
      description: >
      A reference prefix or any part of the title, ignoring case. % and _
      are searched for literally.
      - name: month
      in: query
      required: false
      schema: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' }
      description: >
      YYYY-MM. Claims filed (created_at) in that calendar month in the
      organization's timezone.
      Then extend its 422 description to: “scope is not one of the three; limit is outside 1..200; group_id is not a UUID; cursor is not one this API wrote; or state, q or month is malformed.” Add to the operation description: “state, q and month narrow every scope and compose with the cursor; on to_act they narrow the candidates before authority is resolved.”
    • Insert above the top-level components: line:

    /expenses/claims/summary: get: operationId: summarizeExpenseClaims summary: Per state, how many claims and their total, for the caller’s scope description: > The same scopes as listExpenseClaims, and the same rule for scope=all (expenses.claim.read_all, else 403), so a count never includes a claim the list would not show. Totals are in the organization’s base currency, exact decimals as strings. Rows are ordered by state key; only states holding at least one claim appear. Requires expenses.claim.read. parameters: - name: scope in: query required: false schema: { type: string, enum: [mine, all, to_act], default: mine } - name: month in: query required: false schema: { type: string, pattern: ‘^\d{4}-(0[1-9]|1[0-2])$’ } description: YYYY-MM, on created_at, in the organization’s timezone. responses: ‘200’: description: The summary content: application/json: schema: { $ref: ‘#/components/schemas/ExpenseClaimSummary’ } ‘401’: { $ref: ‘#/components/responses/Unauthenticated’ } ‘403’: description: scope=all without expenses.claim.read_all, or the caller lacks expenses.claim.read. content: application/problem+json: schema: { $ref: ‘#/components/schemas/Problem’ } ‘422’: description: scope is not one of the three, or month is not YYYY-MM. content: application/problem+json: schema: { $ref: ‘#/components/schemas/Problem’ }

    - Append at the end of the file:
    ```yaml
    ExpenseClaimSummaryRow:
    type: object
    required: [state, semantic, count, total]
    properties:
    state: { type: string }
    semantic: { $ref: '#/components/schemas/WorkflowSemantic' }
    count: { type: integer, minimum: 1 }
    total: { $ref: '#/components/schemas/Money' }
    ExpenseClaimKpi:
    type: object
    required: [count, total]
    properties:
    count: { type: integer, minimum: 0 }
    total: { $ref: '#/components/schemas/Money' }
    ExpenseClaimKpis:
    type: object
    description: >
    The four KPI cards, bucketed by semantic and computed by the server
    (a client never adds amounts). pending = pending + custom;
    approved = accrual; paid = payout + acknowledged; submitted = every
    semantic except draft. An empty card is count 0, total "0.0000".
    required: [pending, approved, paid, submitted]
    properties:
    pending: { $ref: '#/components/schemas/ExpenseClaimKpi' }
    approved: { $ref: '#/components/schemas/ExpenseClaimKpi' }
    paid: { $ref: '#/components/schemas/ExpenseClaimKpi' }
    submitted: { $ref: '#/components/schemas/ExpenseClaimKpi' }
    ExpenseClaimSummary:
    type: object
    required: [currency, rows, kpis]
    properties:
    currency: { type: string, minLength: 3, maxLength: 3 }
    rows: { type: array, items: { $ref: '#/components/schemas/ExpenseClaimSummaryRow' } }
    kpis: { $ref: '#/components/schemas/ExpenseClaimKpis' }

    Run make generate.

  • Step 10: Run everything this task touched.

go test ./internal/platform/expenses/ ./internal/modules/expenses/ -count=1
go test ./internal/app/ -run 'TestEveryRouteIsAuthorized|TestTheOpenAPIDocumentSaysWhatTheRouterEnforces|TestEveryModuleContributesRoutesAndPermissions' -count=1
go test ./internal/gen/ ./internal/kernel/layering/ -count=1

Expected: PASS. Also run go test ./internal/modules/pettycash/ ./internal/modules/advances/ -count=1, because both suites drive claims through internal/platform/expenses (the pettycash e2e files and settles claims).

  • Step 11: Commit.
Terminal window
git add internal/platform/expenses/filter.go internal/platform/expenses/extras.go \
internal/platform/expenses/summary.go internal/platform/expenses/claim.go \
internal/modules/expenses/claim_handlers.go internal/modules/expenses/summary_handlers.go \
internal/modules/expenses/routes.go internal/modules/expenses/routes_test.go \
internal/modules/expenses/claim_filters_test.go internal/modules/expenses/summary_handlers_test.go \
api/openapi.yaml internal/gen/api/api.gen.go
git commit -m "$(cat <<'EOF'
Filter and summarize the claims list (Plan 10 S3, S4)
GET /expenses/claims takes state, q (reference prefix or title, LIKE-
escaped) and month (the organization's calendar month on created_at).
They narrow every scope, compose with the keyset cursor, and go into
to_act's candidate query. ExpenseClaim gains created_at, submitted_at
(first decision out of draft, from the decision log) and category_ids.
GET /expenses/claims/summary counts and totals per state for the same
scopes, with four KPI cards bucketed by semantic, every total a NUMERIC
sum in SQL.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"

Task 4: Seams — scope=to_act on petty-cash spends and top-ups (S5), GET /v1/advances/summary (S6)

Section titled “Task 4: Seams — scope=to_act on petty-cash spends and top-ups (S5), GET /v1/advances/summary (S6)”

Group A. Go only. Shares api/openapi.yaml (append-only) with Tasks 1–3.

Facts this task rests on (read, not assumed):

  • The queues it mirrors. expenses.ToAct and advances.ToAct both work the same way. First they pick candidates in SQL: not the caller’s own record; state semantic not draft/acknowledged; and there EXISTS an exit edge that is either not reporter-gated or is opened to others by an all_states gate grant. The candidates are ordered entered_state_at ASC and capped at toActCandidates (1000). Then each candidate is resolved per instance with workflow.AvailableTransitions, keeping those with any Allowed exit, up to 200. This task copies that shape exactly.

  • Petty cash today. listSpends and listTopUps (internal/modules/pettycash/spend_handlers.go, topup_handlers.go) accept scope=mine|all and an optional float_id. They read ListSpends/ListTopUps (every row of the org, newest first), then filter each row through CanReadSpend/CanReadTopUp. No cursor exists, so none is added. to_act keeps the float_id narrowing.

  • Reporters. A spend’s reporter is its holder (sp.holder_member_id). A top-up’s reporter is its requester (t.requested_by, Ruling PC19). Both are excluded, as claims exclude the claimant.

  • Why no separate read check. Every to_act row is one the caller may transition right now. actsOn (instance.go) treats “an edge they may currently take” as readable, so CanReadSpend/CanReadTopUp would admit every such row. The handler does not re-check.

  • Why reviews are covered. A spend review (POST /spends/:id/review) is not a transition. But mayReview requires an allowed edge into a rejected-semantic state, so every member who may review a spend also has an allowed transition on it and appears in the queue.

  • KPI buckets (design spec §5.2) are computed in SQL with FILTER aggregates, exactly as in Task 3, over the advance’s own semantic:

    • pending = pending + custom
    • approved = accrual (for an advance, acquitted)
    • paid = payout + acknowledged (paid_out, received, acquittal_confirmed)
    • submitted = everything but draft

    The to_act queue’s ids are handed back to the same SQL, so no total is added up in Go.

  • Advances. advanceSelect joins the advance’s own instance only. ToAct also considers the variance legs (mayActOnAny). The summary is keyed on the advance’s own state (i.state_key) in every scope. An advance is in the to_act queue because of its own instance or a variance leg, and it is counted under its own state either way. Total sums base_amount, the advance in the organization’s base currency at its own rate, so rows in different currencies add up. The currency is company.Get(...).BaseCurrency.

Files:

  • Create: internal/platform/pettycash/toact.go (ToActSpends, ToActTopUps)
  • Modify: internal/modules/pettycash/spend_handlers.go (listSpends), internal/modules/pettycash/topup_handlers.go (listTopUps)
  • Test: internal/modules/pettycash/toact_handlers_test.go (new). It reuses newEnv, do, decode, spendListResp and the pettycashtest helpers.
  • Create: internal/platform/advances/summary.go (Summary, SummaryRow, SummaryFilter, Summarize, ScopeToAct)
  • Create: internal/modules/advances/summary_handlers.go
  • Modify: internal/modules/advances/routes.go (one route), internal/modules/advances/routes_test.go (one want entry)
  • Test: internal/modules/advances/summary_handlers_test.go (new). It reuses newEnv, fileAdvance, do and decode.
  • Modify: api/openapi.yaml (two scope enums in place; one path and four schemas appended), then make generate

Interfaces:

  • Consumes: spendSelect/scanSpend, topUpSelect/scanTopUp (internal/platform/pettycash); workflow.AvailableTransitions; advances.ToAct, advanceSelect-shaped joins; company.Get; module helpers h.caller(ctx, tx) (pettycash) and mustActor/mapErr/member.Can/PermissionAdvanceReadAll (advances).

  • Produces:

    package pettycash // internal/platform/pettycash
    func ToActSpends(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID, floatID *uuid.UUID) ([]Spend, error)
    func ToActTopUps(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID, floatID *uuid.UUID) ([]TopUp, error)
    package advances // internal/platform/advances
    const ScopeToAct = "to_act"
    type SummaryRow struct { State string; Semantic posting.Semantic; Count int; Total money.Amount }
    type KPI struct { Count int; Total money.Amount }
    type KPIs struct { Pending, Approved, Paid, Submitted KPI }
    type Summary struct { Currency string; Rows []SummaryRow; KPIs KPIs }
    type SummaryFilter struct { Scope string; Member uuid.UUID }
    func Summarize(ctx context.Context, tx pgx.Tx, c clock.Clock, f SummaryFilter) (Summary, error)

    OpenAPI changes:

    • The scope enum on listPettyCashSpends and listPettyCashTopUps gains to_act.
    • AdvanceSummary = {currency, rows: AdvanceSummaryRow[], kpis: AdvanceKpis}. AdvanceSummaryRow, AdvanceKpi and AdvanceKpis are shaped exactly like Task 3’s ExpenseClaimSummaryRow, ExpenseClaimKpi and ExpenseClaimKpis. They are declared separately so the two parallel tasks share no schema.
    • summarizeAdvances at GET /advances/summary, param scope, permission advances.advance.read.
  • Step 1: Write the failing petty-cash tests. internal/modules/pettycash/toact_handlers_test.go:

package pettycash_test
// Plan 10 S5: the petty-cash approval queue. It mirrors
// /expenses/claims?scope=to_act: only records the caller may transition
// right now, never their own.
import (
"context"
"net/http"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money"
platformpettycash "github.com/mcom/erp/internal/platform/pettycash"
)
type topUpListIDsResp struct {
TopUps []struct {
ID uuid.UUID `json:"id"`
} `json:"topups"`
}
func (e *env) spendIDs(t *testing.T, tok, query string) []uuid.UUID {
t.Helper()
w := e.do(t, http.MethodGet, "/v1/pettycash/spends"+query, tok, nil)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
ids := []uuid.UUID{}
for _, sp := range decode[spendListResp](t, w).Spends {
ids = append(ids, sp.ID)
}
return ids
}
func (e *env) topUpIDs(t *testing.T, tok, query string) []uuid.UUID {
t.Helper()
w := e.do(t, http.MethodGet, "/v1/pettycash/topups"+query, tok, nil)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
ids := []uuid.UUID{}
for _, tu := range decode[topUpListIDsResp](t, w).TopUps {
ids = append(ids, tu.ID)
}
return ids
}
// requestTopUp files a top-up for f's holder and leaves it at its first
// state, where the holder's superior (Owner) reviews it.
func (e *env) requestTopUp(t *testing.T, f platformpettycash.Float, amount string) platformpettycash.TopUp {
t.Helper()
var tu platformpettycash.TopUp
e.ex.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
tu, err = platformpettycash.CreateTopUp(ctx, tx, e.ex.Clock, platformpettycash.TopUpInput{
FloatID: f.ID, Actor: f.HolderMemberID, Amount: money.MustParse(amount), Reason: "Weekly float",
})
return err
})
return tu
}
// TestToActListsTheSpendsICanDecide: PRESENCE — the Owner, the holder's
// superior, sees the recorded spend; ABSENCE — not the draft nobody else
// can move, and nothing for the holder (their own) or the outsider (no
// authority).
func TestToActListsTheSpendsICanDecide(t *testing.T) {
e := newEnv(t)
f := e.ex.Float(t, e.holder)
e.ex.Fund(t, f, "100.00")
recorded := e.ex.RecordedSpend(t, f, "10.00")
draft := e.ex.DraftSpend(t, f, "20.00")
require.ElementsMatch(t, []uuid.UUID{recorded.ID, draft.ID}, e.spendIDs(t, e.adminTok, "?scope=all"),
"both exist and the Owner can see both")
owner := e.spendIDs(t, e.adminTok, "?scope=to_act")
require.Contains(t, owner, recorded.ID)
require.NotContains(t, owner, draft.ID, "a draft is its holder's to move")
require.Empty(t, e.spendIDs(t, e.holderTok, "?scope=to_act"), "nobody decides their own spend")
require.Empty(t, e.spendIDs(t, e.outsiderTok, "?scope=to_act"))
require.Contains(t, e.spendIDs(t, e.adminTok, "?scope=to_act&float_id="+f.ID.String()), recorded.ID)
require.Empty(t, e.spendIDs(t, e.adminTok, "?scope=to_act&float_id="+uuid.NewString()))
w := e.do(t, http.MethodGet, "/v1/pettycash/spends?scope=everything", e.adminTok, nil)
require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String())
}
func TestToActListsTheTopUpsICanDecide(t *testing.T) {
e := newEnv(t)
f := e.ex.Float(t, e.holder)
requested := e.requestTopUp(t, f, "40.00")
require.Contains(t, e.topUpIDs(t, e.holderTok, ""), requested.ID, "the holder's own request exists")
require.Contains(t, e.topUpIDs(t, e.adminTok, "?scope=to_act"), requested.ID)
require.NotContains(t, e.topUpIDs(t, e.holderTok, "?scope=to_act"), requested.ID,
"nobody decides their own request")
require.Empty(t, e.topUpIDs(t, e.outsiderTok, "?scope=to_act"))
}

Verify: CreateTopUp leaves the top-up in requested, and the Owner may take requested → reviewed. pettycashtest.Env.Fund walks exactly that edge as Owner right after creating one. If the first state turns out to be draft-semantic, add the holder’s submit transition to requestTopUp, in the shape Fund uses. Do not weaken the assertions.

  • Step 2: Run and watch fail. go test ./internal/modules/pettycash/ -run 'TestToAct' -count=1 fails: scope=to_act answers 422 (scope must be "mine" or "all").

  • Step 3: Implement the petty-cash queue. internal/platform/pettycash/toact.go:

// The petty-cash approval queue (Plan 10 S5): the spends and top-ups a
// member may decide on right now. It is expenses.ToAct's shape, for the
// same reason. Authority is resolved per instance, per actor, on the day,
// so it cannot be indexed: SQL narrows to records somebody other than
// their reporter could ever move, and the engine is asked about each
// candidate.
package pettycash
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/tenancy"
"github.com/mcom/erp/internal/platform/workflow"
)
const toActCeiling = 200
// toActCandidates bounds the rows considered, because every candidate
// costs one AvailableTransitions call. A var only so a test can lower it.
var toActCandidates = 1000
// actableByOthers keeps out records only their reporter can move: a draft,
// an acknowledged one, or one whose only exits are reporter-gated with no
// all_states grant. Those would otherwise fill the candidate window,
// oldest first, and starve the queue. It is expenses.ToAct's predicate,
// unchanged; it expects the instance aliased i and its state s.
const actableByOthers = `
AND s.semantic NOT IN ('draft','acknowledged')
AND EXISTS (
SELECT 1 FROM workflow_edge e
JOIN workflow_state ts ON ts.org_id = e.org_id AND ts.definition_id = e.definition_id
AND ts.state_key = e.to_key
WHERE e.org_id = $1 AND e.definition_id = i.definition_id AND e.from_key = i.state_key
AND (e.gate <> 'reporter'
OR (ts.semantic <> 'acknowledged' AND EXISTS (
SELECT 1 FROM workflow_gate_grant g
WHERE g.org_id = $1 AND g.definition_id = e.definition_id AND g.all_states))))`
// ToActSpends is the spends member may transition right now, not their
// own, oldest in state first, at most 200. floatID narrows to one float.
func ToActSpends(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID, floatID *uuid.UUID) ([]Spend, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return nil, fmt.Errorf("pettycash: spends to act on: %w", tenancy.ErrNoOrg)
}
rows, err := tx.Query(ctx, spendSelect+`sp.holder_member_id <> $2
AND ($3::uuid IS NULL OR sp.float_id = $3)`+actableByOthers+`
ORDER BY i.entered_state_at ASC, sp.id ASC
LIMIT $4`, orgID, member, floatID, toActCandidates)
if err != nil {
return nil, fmt.Errorf("pettycash: spends to act on: %w", err)
}
var candidates []Spend
for rows.Next() {
sp, err := scanSpend(rows)
if err != nil {
rows.Close()
return nil, fmt.Errorf("pettycash: spends to act on: %w", err)
}
candidates = append(candidates, sp)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("pettycash: spends to act on: %w", err)
}
// Read to completion first: AvailableTransitions queries the same
// transaction, and pgx allows one query at a time.
out := []Spend{}
for _, sp := range candidates {
may, err := mayActNow(ctx, tx, c, sp.InstanceID, member)
if err != nil {
return nil, err
}
if may {
out = append(out, sp)
if len(out) == toActCeiling {
break
}
}
}
return out, nil
}
// ToActTopUps is ToActSpends for top-ups and replenishments. The requester
// is the reporter.
func ToActTopUps(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID, floatID *uuid.UUID) ([]TopUp, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return nil, fmt.Errorf("pettycash: top-ups to act on: %w", tenancy.ErrNoOrg)
}
rows, err := tx.Query(ctx, topUpSelect+`t.requested_by <> $2
AND ($3::uuid IS NULL OR t.float_id = $3)`+actableByOthers+`
ORDER BY i.entered_state_at ASC, t.id ASC
LIMIT $4`, orgID, member, floatID, toActCandidates)
if err != nil {
return nil, fmt.Errorf("pettycash: top-ups to act on: %w", err)
}
var candidates []TopUp
for rows.Next() {
tu, err := scanTopUp(rows)
if err != nil {
rows.Close()
return nil, fmt.Errorf("pettycash: top-ups to act on: %w", err)
}
candidates = append(candidates, tu)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("pettycash: top-ups to act on: %w", err)
}
out := []TopUp{}
for _, tu := range candidates {
may, err := mayActNow(ctx, tx, c, tu.InstanceID, member)
if err != nil {
return nil, err
}
if may {
out = append(out, tu)
if len(out) == toActCeiling {
break
}
}
}
return out, nil
}
// mayActNow is whether member has any allowed exit on the instance right
// now. It is expenses' mayAct. Unlike mayTake it does not count an
// unroutable submission as allowed, because a queue lists decisions to
// make, not drafts to fix.
func mayActNow(ctx context.Context, tx pgx.Tx, c clock.Clock, instanceID, member uuid.UUID) (bool, error) {
available, err := workflow.AvailableTransitions(ctx, tx, c, instanceID, member)
if err != nil {
return false, fmt.Errorf("pettycash: %w", err)
}
for _, a := range available {
if a.Allowed {
return true, nil
}
}
return false, nil
}

Verify: toActCeiling and toActCandidates are not already declared in internal/platform/pettycash (grep -n "toAct" internal/platform/pettycash/*.go). If they are, rename these two pcToActCeiling/pcToActCandidates.

In internal/modules/pettycash/spend_handlers.go, listSpends: change the scope check to

scope := c.DefaultQuery("scope", "mine")
if scope != "mine" && scope != "all" && scope != "to_act" {
_ = c.Error(apperr.New(apperr.KindValidation, `scope must be "mine", "all" or "to_act"`))
return
}

and, inside the transaction right after who, err := h.caller(ctx, tx) and its error check, add:

if scope == "to_act" {
// The approval queue (Plan 10 S5). Every row is one the caller
// may transition now, which CanReadSpend's actsOn already
// admits, so there is no second read check.
spends, err := platformpettycash.ToActSpends(ctx, tx, h.deps.Clock, who.memberID, floatID)
if err != nil {
return err
}
for _, sp := range spends {
out = append(out, toSpendDTO(sp))
}
return nil
}

Make the same change in internal/modules/pettycash/topup_handlers.go listTopUps, calling platformpettycash.ToActTopUps and toTopUpDTO.

  • Step 4: Run and pass. Run go test ./internal/modules/pettycash/ ./internal/platform/pettycash/ -count=1, then go test ./internal/kernel/layering/ -count=1. Expected: PASS. The layering run shows the tenant-predicate guard resolves actableByOthers, a same-package string constant spliced with +, which the guard documents it supports. Watch it fail once. Remove sp.holder_member_id <> $2 AND from ToActSpends, keeping $2 bound by changing the predicate to $2::uuid IS NOT NULL. TestToActListsTheSpendsICanDecide must still pass: the engine refuses the holder anyway, and knowing that is the point of the check. Then remove the if may filter so every candidate is kept. The test must fail on the outsider’s queue. Restore both.

  • Step 5: Write the failing advances test. internal/modules/advances/summary_handlers_test.go:

package advances_test
import (
"context"
"net/http"
"testing"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
platformadvances "github.com/mcom/erp/internal/platform/advances"
)
type advanceKPIResp struct {
Count int `json:"count"`
Total string `json:"total"`
}
type advanceSummaryResp struct {
Currency string `json:"currency"`
KPIs struct {
Pending advanceKPIResp `json:"pending"`
Approved advanceKPIResp `json:"approved"`
Paid advanceKPIResp `json:"paid"`
Submitted advanceKPIResp `json:"submitted"`
} `json:"kpis"`
Rows []struct {
State string `json:"state"`
Semantic string `json:"semantic"`
Count int `json:"count"`
Total string `json:"total"`
} `json:"rows"`
}
func (e *env) advanceSummary(t *testing.T, tok, query string) (advanceSummaryResp, map[string]int, map[string]string) {
t.Helper()
w := e.do(t, http.MethodGet, "/v1/advances/summary"+query, tok, nil)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
s := decode[advanceSummaryResp](t, w)
require.NotNil(t, s.Rows, "rows is [], never null")
counts, totals := map[string]int{}, map[string]string{}
for _, r := range s.Rows {
counts[r.State], totals[r.State] = r.Count, r.Total
}
return s, counts, totals
}
// TestTheAdvanceSummaryCountsAndTotalsPerState: each scope's PRESENCE is
// shown before another scope is shown to exclude the same advances.
func TestTheAdvanceSummaryCountsAndTotalsPerState(t *testing.T) {
e := newEnv(t)
e.fileAdvance(t, e.staffTok, "Site visit", "2026-10-15")
sub, _ := e.fileAdvance(t, e.staffTok, "Needs a review", "2026-10-15")
e.ax.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, _, err := platformadvances.Transition(ctx, tx, e.ax.Clock, platformadvances.TransitionInput{
AdvanceID: sub.Advance.ID, From: "draft", To: "pending_review", Actor: e.staff,
})
return err
})
s, counts, totals := e.advanceSummary(t, e.staffTok, "")
require.Equal(t, "MYR", s.Currency)
require.Equal(t, 1, counts["draft"])
require.Equal(t, 1, counts["pending_review"])
require.Equal(t, "1000.0000", totals["pending_review"], "base_amount, summed in NUMERIC, as a string")
_, all, _ := e.advanceSummary(t, e.ownerTok, "?scope=all")
require.Equal(t, 2, all["draft"]+all["pending_review"])
own, _, _ := e.advanceSummary(t, e.ownerTok, "")
require.Empty(t, own.Rows, "the Owner filed nothing")
require.Equal(t, http.StatusForbidden,
e.do(t, http.MethodGet, "/v1/advances/summary?scope=all", e.staffTok, nil).Code)
_, queue, queueTotals := e.advanceSummary(t, e.ownerTok, "?scope=to_act")
require.Equal(t, 1, queue["pending_review"])
require.Equal(t, "1000.0000", queueTotals["pending_review"])
mine, _, _ := e.advanceSummary(t, e.staffTok, "?scope=to_act")
require.Empty(t, mine.Rows, "nobody decides their own advance")
require.Equal(t, http.StatusUnprocessableEntity,
e.do(t, http.MethodGet, "/v1/advances/summary?scope=everyone", e.staffTok, nil).Code)
}
// TestTheAdvanceSummaryBucketsKPIsBySemantic. PRESENCE: the rows show a
// draft (1000.00), a pending advance (1000.00) and a received one (500.00).
// The cards then hold exactly their semantics. ABSENCE: "submitted" leaves
// out the draft, whose amount would change the total.
func TestTheAdvanceSummaryBucketsKPIsBySemantic(t *testing.T) {
e := newEnv(t)
e.fileAdvance(t, e.staffTok, "Still a draft", "2026-10-15")
sub, _ := e.fileAdvance(t, e.staffTok, "Needs a review", "2026-10-15")
e.ax.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, _, err := platformadvances.Transition(ctx, tx, e.ax.Clock, platformadvances.TransitionInput{
AdvanceID: sub.Advance.ID, From: "draft", To: "pending_review", Actor: e.staff,
})
return err
})
receivedDueOn(t, e, e.staff, "500.00", time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC))
s, counts, totals := e.advanceSummary(t, e.staffTok, "")
require.Equal(t, "1000.0000", totals["draft"], "PRESENCE: the draft is in scope, with money on it")
require.Equal(t, 1, counts["pending_review"])
require.Equal(t, 1, counts["received"])
require.Equal(t, advanceKPIResp{Count: 1, Total: "1000.0000"}, s.KPIs.Pending)
require.Equal(t, advanceKPIResp{Count: 1, Total: "500.0000"}, s.KPIs.Paid, "received is acknowledged")
require.Equal(t, advanceKPIResp{Count: 0, Total: "0.0000"}, s.KPIs.Approved,
"nothing is acquitted; an empty card is zero, not null")
require.Equal(t, advanceKPIResp{Count: 2, Total: "1500.0000"}, s.KPIs.Submitted,
"ABSENCE: the 1000.00 draft is not submitted")
}

Add "time" to this file’s imports. receivedDueOn is the existing helper in advance_handlers_test.go, and takes a due date not before the env’s clock. Verify: 2026-10-15 is not before advancestest’s clock (the other tests in this package file advances due that day). If it is, use the clock’s date plus 30 days.

In internal/modules/advances/routes_test.go, add this line to want after "GET /v1/advances/reminders": readAll,:

"GET /v1/advances/summary": read,
  • Step 6: Run and watch fail. go test ./internal/modules/advances/ -run 'TestTheAdvanceSummary|TestEveryRouteRequires' -count=1 fails. The summary test gets a 404 problem, because summary is read as an advance id and is not a UUID. The routes test fails on the missing entry.

  • Step 7: Implement the advances summary. internal/platform/advances/summary.go:

package advances
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/money"
"github.com/mcom/erp/internal/kernel/tenancy"
"github.com/mcom/erp/internal/platform/company"
"github.com/mcom/erp/internal/platform/posting"
)
// ScopeToAct is the approval queue's scope name, as the list uses it.
const ScopeToAct = "to_act"
// SummaryRow is one state's count and total (Plan 10 S6), keyed on the
// advance's own instance state. Total is the sum of base_amount: each
// advance in the organization's base currency at its own rate, so advances
// in different currencies can be added.
type SummaryRow struct {
State string
Semantic posting.Semantic
Count int
Total money.Amount
}
// KPI is one card: how many advances and their base-currency total.
type KPI struct {
Count int
Total money.Amount
}
// KPIs are the design's four cards (Plan 10 spec §5.2), bucketed by the
// advance's own semantic: Pending = pending + custom; Approved = accrual
// (for an advance, acquitted); Paid = payout + acknowledged; Submitted =
// everything but draft. Computed in SQL: the browser never adds money.
type KPIs struct {
Pending, Approved, Paid, Submitted KPI
}
// Summary is the rows, the four cards and the base currency every total is
// in. Rows are ordered by state key.
type Summary struct {
Currency string
Rows []SummaryRow
KPIs KPIs
}
// SummaryFilter is the list's scopes. Whether the caller may ask for
// ScopeAll is the handler's question.
type SummaryFilter struct {
Scope string
Member uuid.UUID
}
// summaryFrom is the one scope both summary queries read. $2 is the
// claimant (mine) and $3 the to_act queue's ids, each guarded by its own
// NULL test.
const summaryFrom = `
FROM advance a
JOIN workflow_instance i ON i.org_id = a.org_id AND i.id = a.instance_id
JOIN workflow_state s ON s.org_id = i.org_id AND s.definition_id = i.definition_id
AND s.state_key = i.state_key
WHERE a.org_id = $1
AND ($2::uuid IS NULL OR a.claimant_member_id = $2)
AND ($3::uuid[] IS NULL OR a.id = ANY($3::uuid[]))`
// Summarize counts and totals the advances in f's scope, per state and per
// KPI card. Every total is a NUMERIC sum in SQL. For to_act, the queue is
// resolved per instance in Go (ToAct, legs included), and its ids are then
// handed back to the same SQL.
func Summarize(ctx context.Context, tx pgx.Tx, c clock.Clock, f SummaryFilter) (Summary, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return Summary{}, fmt.Errorf("advances: summarize: %w", tenancy.ErrNoOrg)
}
prof, err := company.Get(ctx, tx)
if err != nil {
return Summary{}, fmt.Errorf("advances: summarize: %w", err)
}
var (
member *uuid.UUID
queue any // nil: no id filter; a []uuid.UUID (possibly empty) for to_act
)
switch f.Scope {
case ScopeMine:
m := f.Member
member = &m
case ScopeAll:
case ScopeToAct:
advs, err := ToAct(ctx, tx, c, f.Member)
if err != nil {
return Summary{}, err
}
ids := make([]uuid.UUID, 0, len(advs))
for _, a := range advs {
ids = append(ids, a.ID)
}
queue = ids
default:
return Summary{}, fmt.Errorf("advances: unknown scope %q", f.Scope)
}
args := []any{orgID, member, queue}
out := Summary{Currency: prof.BaseCurrency, Rows: []SummaryRow{}}
rows, err := tx.Query(ctx, `
SELECT i.state_key, s.semantic, count(*), sum(a.base_amount)`+summaryFrom+`
GROUP BY i.state_key, s.semantic
ORDER BY i.state_key, s.semantic`, args...)
if err != nil {
return Summary{}, fmt.Errorf("advances: summarize: %w", err)
}
for rows.Next() {
var (
r SummaryRow
semantic string
)
if err := rows.Scan(&r.State, &semantic, &r.Count, &r.Total); err != nil {
rows.Close()
return Summary{}, fmt.Errorf("advances: summarize scan: %w", err)
}
r.Semantic = posting.Semantic(semantic)
out.Rows = append(out.Rows, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return Summary{}, fmt.Errorf("advances: summarize: %w", err)
}
k := &out.KPIs
if err := tx.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE s.semantic IN ('pending','custom')),
COALESCE(sum(a.base_amount) FILTER (WHERE s.semantic IN ('pending','custom')), 0),
count(*) FILTER (WHERE s.semantic = 'accrual'),
COALESCE(sum(a.base_amount) FILTER (WHERE s.semantic = 'accrual'), 0),
count(*) FILTER (WHERE s.semantic IN ('payout','acknowledged')),
COALESCE(sum(a.base_amount) FILTER (WHERE s.semantic IN ('payout','acknowledged')), 0),
count(*) FILTER (WHERE s.semantic <> 'draft'),
COALESCE(sum(a.base_amount) FILTER (WHERE s.semantic <> 'draft'), 0)`+summaryFrom, args...).
Scan(&k.Pending.Count, &k.Pending.Total, &k.Approved.Count, &k.Approved.Total,
&k.Paid.Count, &k.Paid.Total, &k.Submitted.Count, &k.Submitted.Total); err != nil {
return Summary{}, fmt.Errorf("advances: summarize kpis: %w", err)
}
return out, nil
}

The handler maps the unknown-scope error before it reaches the platform, so the platform’s plain error is only a backstop. Verify: no summaryFrom, KPI, KPIs or ScopeToAct is already declared in internal/platform/advances (grep -n "summaryFrom\|type KPI\|ScopeToAct" internal/platform/advances/*.go). If one is, rename these.

internal/modules/advances/summary_handlers.go:

package advances
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5"
apperr "github.com/mcom/erp/internal/kernel/errors"
"github.com/mcom/erp/internal/kernel/money"
platformadvances "github.com/mcom/erp/internal/platform/advances"
"github.com/mcom/erp/internal/platform/identity/member"
"github.com/mcom/erp/internal/platform/posting"
)
// GET /v1/advances/summary (Plan 10 S6): per state, how many advances the
// caller's scope holds and their base-currency total. Same scopes and the
// same read_all rule as the list.
type summaryRowDTO struct {
State string `json:"state"`
Semantic posting.Semantic `json:"semantic"`
Count int `json:"count"`
Total money.Amount `json:"total"`
}
type kpiDTO struct {
Count int `json:"count"`
Total money.Amount `json:"total"`
}
type kpisDTO struct {
Pending kpiDTO `json:"pending"`
Approved kpiDTO `json:"approved"`
Paid kpiDTO `json:"paid"`
Submitted kpiDTO `json:"submitted"`
}
type summaryDTO struct {
Currency string `json:"currency"`
Rows []summaryRowDTO `json:"rows"`
KPIs kpisDTO `json:"kpis"`
}
func toKPI(k platformadvances.KPI) kpiDTO { return kpiDTO{Count: k.Count, Total: k.Total} }
func (h handlers) summarizeAdvances(c *gin.Context) {
who, err := mustActor(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
}
scope := c.DefaultQuery("scope", scopeMine)
switch scope {
case scopeMine, scopeAll, scopeToAct:
default:
_ = c.Error(apperr.New(apperr.KindValidation, "scope must be mine, all or to_act"))
return
}
var s platformadvances.Summary
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
if scope == scopeAll {
ok, err := member.Can(ctx, tx, who, PermissionAdvanceReadAll)
if err != nil {
return err
}
if !ok {
return apperr.New(apperr.KindForbidden,
"summarizing every advance of the organization needs "+PermissionAdvanceReadAll)
}
}
var err error
s, err = platformadvances.Summarize(ctx, tx, h.deps.Clock,
platformadvances.SummaryFilter{Scope: scope, Member: who})
return err
}); err != nil {
_ = c.Error(mapErr(err))
return
}
out := summaryDTO{Currency: s.Currency, Rows: make([]summaryRowDTO, 0, len(s.Rows)), KPIs: kpisDTO{
Pending: toKPI(s.KPIs.Pending), Approved: toKPI(s.KPIs.Approved),
Paid: toKPI(s.KPIs.Paid), Submitted: toKPI(s.KPIs.Submitted),
}}
for _, r := range s.Rows {
out.Rows = append(out.Rows, summaryRowDTO{State: r.State, Semantic: r.Semantic, Count: r.Count, Total: r.Total})
}
c.JSON(http.StatusOK, out)
}

Verify: the module’s scope constants are named scopeMine, scopeAll and scopeToAct, as listAdvances uses them (grep -n "scopeMine\|scopeToAct" internal/modules/advances/*.go), and their values equal the platform’s ScopeMine, ScopeAll and new ScopeToAct. If the module’s DTO names clash with an existing summaryRowDTO/summaryDTO, prefix them advanceSummary….

In internal/modules/advances/routes.go, directly after the /advances/reminders line:

// The KPI counts (Plan 10 S6). Also before /advances/:id, like
// reminders.
httpx.Protected(g, p, "GET", "/advances/summary", read, h.summarizeAdvances)
  • Step 8: Run and pass. Run go test ./internal/platform/advances/ ./internal/modules/advances/ -count=1, then go test ./internal/kernel/layering/ -count=1. Expected: PASS.

  • Step 9: The contract. In api/openapi.yaml:

    • listPettyCashSpends: change its scope parameter’s schema to { type: string, enum: [mine, all, to_act], default: mine }, and append to the operation’s description: “scope=to_act is the approval queue: the spends the caller may transition right now, never their own, oldest in state first, at most 200. It needs no permission beyond pettycash.read, because authority comes from the workflow’s grants. float_id narrows it too.”
    • listPettyCashTopUps: the same enum change, and append: “scope=to_act is the approval queue: the top-ups and replenishments the caller may transition right now, never their own request, oldest in state first, at most 200.”
    • Insert above the top-level components: line:

    /advances/summary: get: operationId: summarizeAdvances summary: Per state, how many advances and their base-currency total, for the caller’s scope description: > The same scopes as listAdvances, and the same rule for scope=all (advances.advance.read_all, else 403). State is the advance’s own workflow state. total sums each advance’s base_amount, in the organization’s base currency. Rows are ordered by state key; only states holding at least one advance appear. Requires advances.advance.read. parameters: - name: scope in: query required: false schema: { type: string, enum: [mine, all, to_act], default: mine } responses: ‘200’: description: The summary content: application/json: schema: { $ref: ‘#/components/schemas/AdvanceSummary’ } ‘401’: { $ref: ‘#/components/responses/Unauthenticated’ } ‘403’: { $ref: ‘#/components/responses/AdvancesForbidden’ } ‘422’: description: scope is not one of the three. content: application/problem+json: schema: { $ref: ‘#/components/schemas/Problem’ }

    - Append at the end of the file:
    ```yaml
    AdvanceSummaryRow:
    type: object
    required: [state, semantic, count, total]
    properties:
    state: { type: string }
    semantic: { $ref: '#/components/schemas/WorkflowSemantic' }
    count: { type: integer, minimum: 1 }
    total: { $ref: '#/components/schemas/Money' }
    AdvanceKpi:
    type: object
    required: [count, total]
    properties:
    count: { type: integer, minimum: 0 }
    total: { $ref: '#/components/schemas/Money' }
    AdvanceKpis:
    type: object
    description: >
    The four KPI cards, bucketed by the advance's own semantic and
    computed by the server (a client never adds amounts). pending =
    pending + custom; approved = accrual; paid = payout + acknowledged;
    submitted = every semantic except draft. Totals are base_amount, in
    the base currency. An empty card is count 0, total "0.0000".
    required: [pending, approved, paid, submitted]
    properties:
    pending: { $ref: '#/components/schemas/AdvanceKpi' }
    approved: { $ref: '#/components/schemas/AdvanceKpi' }
    paid: { $ref: '#/components/schemas/AdvanceKpi' }
    submitted: { $ref: '#/components/schemas/AdvanceKpi' }
    AdvanceSummary:
    type: object
    required: [currency, rows, kpis]
    properties:
    currency: { type: string, minLength: 3, maxLength: 3 }
    rows: { type: array, items: { $ref: '#/components/schemas/AdvanceSummaryRow' } }
    kpis: { $ref: '#/components/schemas/AdvanceKpis' }

    Run make generate.

  • Step 10: Run everything this task touched.

go test ./internal/platform/pettycash/ ./internal/modules/pettycash/ ./internal/platform/advances/ ./internal/modules/advances/ -count=1
go test ./internal/app/ -run 'TestEveryRouteIsAuthorized|TestTheOpenAPIDocumentSaysWhatTheRouterEnforces|TestEveryModuleContributesRoutesAndPermissions' -count=1
go test ./internal/gen/ ./internal/kernel/layering/ -count=1

Expected: PASS.

  • Step 11: Commit.
Terminal window
git add internal/platform/pettycash/toact.go internal/modules/pettycash/spend_handlers.go \
internal/modules/pettycash/topup_handlers.go internal/modules/pettycash/toact_handlers_test.go \
internal/platform/advances/summary.go internal/modules/advances/summary_handlers.go \
internal/modules/advances/routes.go internal/modules/advances/routes_test.go \
internal/modules/advances/summary_handlers_test.go api/openapi.yaml internal/gen/api/api.gen.go
git commit -m "$(cat <<'EOF'
Petty-cash approval queue and advances summary (Plan 10 S5, S6)
GET /pettycash/spends and /pettycash/topups take scope=to_act: the
records the caller may transition now, never their own, resolved per
instance exactly as the claims and advances queues are. GET
/advances/summary counts and totals advances per state and in four
KPI cards bucketed by semantic (base_amount, base currency, all summed
in SQL) for mine, all and to_act.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"

Task 4b: Seam gaps found by the screen tasks

Section titled “Task 4b: Seam gaps found by the screen tasks”

Group A. Go only. Runs after Tasks 1–4 have merged, or beside them. It shares api/openapi.yaml (append-only, plus two in-place property additions) and internal/modules/pettycash/routes_test.go with Task 4. Four independent items; each has its own commit.

Facts this task rests on (read, not assumed):

  • (a) Close-outs have no list route. internal/modules/pettycash/routes.go registers only GET /pettycash/closeouts/:id and its transitions. The platform has ListCloseouts(ctx, tx, floatID) (per float, used by binder_float.go). A holder who reloads after requesting a return cannot find the close-out again unless they kept its id. A close-out’s requester is always its float’s holder (Ruling PC21, CloseoutInput). The single read is gated by CanReadCloseout (requester, read_all, or actsOn). The existing petty-cash lists gate scope=all on pettycash.read_all or pettycash.float.manage (listSpends, listTopUps), and this route follows them. See Part A notes.
  • (b) The chart holder has no assignment id. orgstructure.chart builds holderDTO{MemberID, Name} from orgstruct.HoldersOf, which returns bare member ids. DELETE /org/members/:id/positions/:pid needs the member_position id. The chart is the only read that shows who sits where, so the UI cannot end an assignment. HoldersOf is also used for approval resolution and must not change shape, so a sibling reader is added. It uses the same currentAsOf predicate constant, so “in force today” keeps one definition.
  • (c) The payout channel read shape has no names, which was verified absent. channelDTO (internal/modules/payout/channel_handlers.go) carries name (the record-locale name) and label (the request locale’s translation), but not the per-locale map that CreatePayoutChannelRequest/UpdatePayoutChannelRequest take as names (PayoutNames). The editor therefore cannot show or round-trip what it saved. The names are translation rows (entity_kind = 'payout_channel', field = 'name') written by payout.writeNames. payout.loadNames reads one entity’s rows but is unexported and per-id, so a batched exported reader is added.
  • (d) Problem.code. Task 2, Step 12 already adds code to the Problem schema. This item is a verification only; its snippet is below in case Task 2’s change did not land.

Files:

  • (a) Create: internal/platform/pettycash/closeout_list.go (CloseoutFilter, FindCloseouts, ToActCloseouts). Modify: internal/modules/pettycash/closeout_handlers.go (listCloseouts), internal/modules/pettycash/routes.go (one route), internal/modules/pettycash/routes_test.go (one want entry). Test: internal/modules/pettycash/closeout_list_test.go (new).
    • ToActCloseouts reuses Task 4’s actableByOthers, mayActNow, toActCeiling and toActCandidates from internal/platform/pettycash/toact.go. If Task 4 has not merged, this item waits for it.
  • (b) Modify: internal/platform/orgstruct/assignment.go (add Holding, HoldingsOf), internal/modules/orgstructure/handlers.go (holderDTO, chart). Test: internal/platform/orgstruct/assignment_test.go (append one test), internal/modules/orgstructure/chart_assignment_test.go (new).
  • (c) Modify: internal/platform/payout/names.go (add ChannelNames), internal/modules/payout/channel_handlers.go (channelDTO, toChannelDTO and its three callers). Test: internal/modules/payout/channel_names_test.go (new).
  • (d) Verify only: api/openapi.yaml Problem.
  • api/openapi.yaml: one path, one schema, and two in-place property additions. Then make generate.

Interfaces:

  • Consumes: closeoutSelect, scanCloseout, CanReadCloseout (pettycash); Task 4’s actableByOthers, mayActNow, toActCeiling and toActCandidates; h.caller (pettycash module); currentAsOf (orgstruct); payout.kindChannel; i18n translation rows.
  • Produces:
    package pettycash
    type CloseoutFilter struct{ Holder, FloatID *uuid.UUID } // Holder: floats this member holds
    func FindCloseouts(ctx context.Context, tx pgx.Tx, f CloseoutFilter) ([]Closeout, error)
    func ToActCloseouts(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID, floatID *uuid.UUID) ([]Closeout, error)
    package orgstruct
    type Holding struct{ AssignmentID, MemberID uuid.UUID }
    func HoldingsOf(ctx context.Context, tx pgx.Tx, positionID uuid.UUID, on time.Time) ([]Holding, error)
    package payout
    func ChannelNames(ctx context.Context, tx pgx.Tx, ids []uuid.UUID) (map[uuid.UUID]Names, error)
    OpenAPI changes:
    • GET /pettycash/closeouts: operationId listPettyCashCloseouts, params scope (mine|all|to_act) and float_id, returning PettyCashCloseoutList {closeouts: PettyCashCloseout[]}, permission pettycash.read.
    • OrgChartHolder gains required assignment_id: uuid.
    • PayoutChannel gains required names: PayoutNames.
  • Step 1: Write the failing test. internal/modules/pettycash/closeout_list_test.go:
package pettycash_test
// Task 4b (a): the close-out list. A return a holder requested must still
// be findable after a reload.
import (
"context"
"net/http"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
platformpettycash "github.com/mcom/erp/internal/platform/pettycash"
)
type closeoutListResp struct {
Closeouts []closeoutResp `json:"closeouts"`
}
func (e *env) closeoutIDs(t *testing.T, tok, query string) []uuid.UUID {
t.Helper()
w := e.do(t, http.MethodGet, "/v1/pettycash/closeouts"+query, tok, nil)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
ids := []uuid.UUID{}
for _, co := range decode[closeoutListResp](t, w).Closeouts {
ids = append(ids, co.ID)
}
return ids
}
// requestCloseout banks f's whole balance against a fresh proof from its
// holder, through the platform: the outsider in this env holds no
// pettycash.float.disburse, so the HTTP route would refuse them.
func (e *env) requestCloseout(t *testing.T, f platformpettycash.Float) platformpettycash.Closeout {
t.Helper()
proof := e.ex.Receipt(t, f.HolderMemberID)
var co platformpettycash.Closeout
e.ex.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
co, err = platformpettycash.RequestCloseout(ctx, tx, e.ex.Clock, platformpettycash.CloseoutInput{
FloatID: f.ID, Actor: f.HolderMemberID, ProofDocumentID: proof,
})
return err
})
return co
}
// TestCloseoutsAreListedByScope: every scope shows its PRESENCE before a
// narrower one is shown to exclude the same close-out.
func TestCloseoutsAreListedByScope(t *testing.T) {
e := newEnv(t)
mineFloat := e.ex.Float(t, e.holder)
e.ex.Fund(t, mineFloat, "100.00")
theirs := e.ex.Float(t, e.outsider) // Cashier reports to OwnerSeat, as Fund needs
e.ex.Fund(t, theirs, "50.00")
mine := e.requestCloseout(t, mineFloat)
other := e.requestCloseout(t, theirs)
// all: both exist, and the Owner (read_all) sees both.
require.ElementsMatch(t, []uuid.UUID{mine.ID, other.ID}, e.closeoutIDs(t, e.adminTok, "?scope=all"))
w := e.do(t, http.MethodGet, "/v1/pettycash/closeouts?scope=all", e.holderTok, nil)
require.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
// mine (the default): the floats I hold, and nobody else's.
require.Equal(t, []uuid.UUID{mine.ID}, e.closeoutIDs(t, e.holderTok, ""))
require.Equal(t, []uuid.UUID{other.ID}, e.closeoutIDs(t, e.outsiderTok, "?scope=mine"))
// to_act: the Owner confirms both; nobody confirms their own.
require.ElementsMatch(t, []uuid.UUID{mine.ID, other.ID}, e.closeoutIDs(t, e.adminTok, "?scope=to_act"))
require.Empty(t, e.closeoutIDs(t, e.holderTok, "?scope=to_act"))
// float_id narrows every scope.
require.Equal(t, []uuid.UUID{other.ID},
e.closeoutIDs(t, e.adminTok, "?scope=all&float_id="+theirs.ID.String()))
require.Empty(t, e.closeoutIDs(t, e.adminTok, "?scope=to_act&float_id="+uuid.NewString()))
for _, q := range []string{"?scope=everything", "?float_id=not-a-uuid"} {
w := e.do(t, http.MethodGet, "/v1/pettycash/closeouts"+q, e.adminTok, nil)
require.Equal(t, http.StatusUnprocessableEntity, w.Code, q+": "+w.Body.String())
}
}

Add "GET /v1/pettycash/closeouts": read, to want in internal/modules/pettycash/routes_test.go, above "GET /v1/pettycash/closeouts/:id".

Verify: pettycashtest.Env.Fund works for a float held by Cashier. Fund needs “f’s holder must report to Owner”, and pettycashtest.New seats Cashier under OwnerSeat. If Fund is refused anyway, open the outsider’s float and fund it with the holder’s world instead: e.ex.Member(t, "Second holder", &e.ex.OwnerSeat), ex.Grant of pettycash.read, and a minted token. Keep every assertion.

  • Step 2: Run and watch fail. go test ./internal/modules/pettycash/ -run 'TestCloseoutsAreListedByScope|TestEveryRouteRequires' -count=1 fails. GET /v1/pettycash/closeouts is 404 (no route), and the routes map lacks the entry.

  • Step 3: Implement. internal/platform/pettycash/closeout_list.go:

package pettycash
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/tenancy"
)
// CloseoutFilter narrows FindCloseouts. Holder is "the floats this member
// holds", which is what "my returns" means: a close-out's requester is its
// float's holder (Ruling PC21), and reading through the float keeps a
// close-out findable by whoever holds that float today.
type CloseoutFilter struct {
Holder *uuid.UUID
FloatID *uuid.UUID
}
// FindCloseouts is the organization's close-outs matching f, newest first.
// It is not the visibility rule; the handler applies CanReadCloseout.
func FindCloseouts(ctx context.Context, tx pgx.Tx, f CloseoutFilter) ([]Closeout, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return nil, fmt.Errorf("pettycash: find close-outs: %w", tenancy.ErrNoOrg)
}
rows, err := tx.Query(ctx, closeoutSelect+`
($2::uuid IS NULL OR EXISTS (
SELECT 1 FROM petty_cash_float f
WHERE f.org_id = $1 AND f.id = co.float_id AND f.holder_member_id = $2))
AND ($3::uuid IS NULL OR co.float_id = $3)
ORDER BY co.created_at DESC, co.id DESC`, orgID, f.Holder, f.FloatID)
if err != nil {
return nil, fmt.Errorf("pettycash: find close-outs: %w", err)
}
defer rows.Close()
out := []Closeout{}
for rows.Next() {
co, err := scanCloseout(rows)
if err != nil {
return nil, fmt.Errorf("pettycash: find close-outs: %w", err)
}
out = append(out, co)
}
return out, rows.Err()
}
// ToActCloseouts is the close-outs member may transition right now, never
// their own request, oldest in state first, at most 200. It is
// ToActSpends's shape, with the same candidate predicate (actableByOthers)
// and the same per-instance resolution (mayActNow).
func ToActCloseouts(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID, floatID *uuid.UUID) ([]Closeout, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return nil, fmt.Errorf("pettycash: close-outs to act on: %w", tenancy.ErrNoOrg)
}
rows, err := tx.Query(ctx, closeoutSelect+`co.requested_by <> $2
AND ($3::uuid IS NULL OR co.float_id = $3)`+actableByOthers+`
ORDER BY i.entered_state_at ASC, co.id ASC
LIMIT $4`, orgID, member, floatID, toActCandidates)
if err != nil {
return nil, fmt.Errorf("pettycash: close-outs to act on: %w", err)
}
var candidates []Closeout
for rows.Next() {
co, err := scanCloseout(rows)
if err != nil {
rows.Close()
return nil, fmt.Errorf("pettycash: close-outs to act on: %w", err)
}
candidates = append(candidates, co)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("pettycash: close-outs to act on: %w", err)
}
out := []Closeout{}
for _, co := range candidates {
may, err := mayActNow(ctx, tx, c, co.InstanceID, member)
if err != nil {
return nil, err
}
if may {
out = append(out, co)
if len(out) == toActCeiling {
break
}
}
}
return out, nil
}

In internal/modules/pettycash/closeout_handlers.go:

// listCloseouts serves GET /v1/pettycash/closeouts (Task 4b). scope=mine
// (the default) is the close-outs of floats the caller holds; all needs
// pettycash.read_all or pettycash.float.manage, as the spend and top-up
// lists do; to_act is the approval queue. mine and all rows also pass
// CanReadCloseout (Ruling PC24); to_act rows are ones the caller may act
// on, which actsOn already admits.
func (h handlers) listCloseouts(c *gin.Context) {
scope := c.DefaultQuery("scope", "mine")
if scope != "mine" && scope != "all" && scope != "to_act" {
_ = c.Error(apperr.New(apperr.KindValidation, `scope must be "mine", "all" or "to_act"`))
return
}
var floatID *uuid.UUID
if raw := c.Query("float_id"); raw != "" {
id, err := uuid.Parse(raw)
if err != nil {
_ = c.Error(apperr.New(apperr.KindValidation, "float_id must be a UUID"))
return
}
floatID = &id
}
out := []closeoutDTO{}
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
who, err := h.caller(ctx, tx)
if err != nil {
return err
}
if scope == "to_act" {
cos, err := platformpettycash.ToActCloseouts(ctx, tx, h.deps.Clock, who.memberID, floatID)
if err != nil {
return err
}
for _, co := range cos {
out = append(out, toCloseoutDTO(co))
}
return nil
}
f := platformpettycash.CloseoutFilter{FloatID: floatID}
if scope == "all" {
if !who.readAll && !who.manage {
return apperr.New(apperr.KindForbidden,
"scope=all requires pettycash.read_all or pettycash.float.manage")
}
} else {
f.Holder = &who.memberID
}
cos, err := platformpettycash.FindCloseouts(ctx, tx, f)
if err != nil {
return err
}
for _, co := range cos {
ok, err := platformpettycash.CanReadCloseout(ctx, tx, h.deps.Clock, co, who.memberID, who.readAll)
if err != nil {
return err
}
if ok {
out = append(out, toCloseoutDTO(co))
}
}
return nil
}); err != nil {
_ = c.Error(mapErr(err))
return
}
c.JSON(http.StatusOK, gin.H{"closeouts": out})
}

Add the imports github.com/google/uuid and apperr "github.com/mcom/erp/internal/kernel/errors" if the file lacks them.

In internal/modules/pettycash/routes.go, above the GET /pettycash/closeouts/:id line:

httpx.Protected(g, p, "GET", "/pettycash/closeouts", PermissionRead, h.listCloseouts)
  • Step 4: Run and pass. Run go test ./internal/modules/pettycash/ ./internal/platform/pettycash/ ./internal/kernel/layering/ -count=1. Expected: PASS. Watch it fail once. In FindCloseouts, replace the holder predicate with ($2::uuid IS NULL OR true). The mine assertions must fail with both ids. Restore it.

  • Step 5: The contract. Insert above the top-level components: line in api/openapi.yaml:

/pettycash/closeouts:
get:
operationId: listPettyCashCloseouts
summary: List petty-cash close-outs (float returns)
description: >
scope=mine (the default) is the close-outs of the floats the caller
holds. scope=all needs pettycash.read_all or pettycash.float.manage,
else 403. scope=to_act is the approval queue: the close-outs the
caller may transition right now, never their own request, oldest in
state first, at most 200. float_id narrows every scope. mine and all
rows are also filtered through CanReadCloseout (Ruling PC24). Newest
first, unpaged. Requires pettycash.read.
parameters:
- name: scope
in: query
schema: { type: string, enum: [mine, all, to_act], default: mine }
- name: float_id
in: query
schema: { type: string, format: uuid }
responses:
'200':
description: The close-outs
content:
application/json:
schema: { $ref: '#/components/schemas/PettyCashCloseoutList' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/PettyCashForbidden' }
'422':
description: scope or float_id is not valid.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }

Append at the end of the file:

PettyCashCloseoutList:
type: object
required: [closeouts]
properties:
closeouts: { type: array, items: { $ref: '#/components/schemas/PettyCashCloseout' } }

Run make generate. Then run go test ./internal/app/ -run 'TestEveryRouteIsAuthorized|TestTheOpenAPIDocumentSaysWhatTheRouterEnforces' -count=1 && go test ./internal/gen/ -count=1. Expected: PASS.

  • Step 6: Commit.
Terminal window
git add internal/platform/pettycash/closeout_list.go internal/modules/pettycash/closeout_handlers.go \
internal/modules/pettycash/routes.go internal/modules/pettycash/routes_test.go \
internal/modules/pettycash/closeout_list_test.go api/openapi.yaml internal/gen/api/api.gen.go
git commit -m "$(cat <<'EOF'
List petty-cash close-outs by scope (Plan 10, Task 4b)
GET /pettycash/closeouts: mine (floats I hold), all (read_all or
float.manage) and to_act (the same per-instance queue as spends and
top-ups), each narrowed by float_id. A return a holder requested is
findable again after a reload.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"
  • Step 7: Write the failing tests. Append to internal/platform/orgstruct/assignment_test.go, which already has the setup/newMember/newPosition/mustAssign fixture, the jan1/jul1 dates, testClock and the holdersOf helper:
// TestHoldingsOfNamesEachHoldersAssignment: HoldingsOf answers exactly
// HoldersOf's members, because they share one currentAsOf predicate. Each
// member comes with the member_position id a close needs. PRESENCE: both
// holders before a close. ABSENCE: the closed one after it, from both
// readers alike.
func TestHoldingsOfNamesEachHoldersAssignment(t *testing.T) {
f := setup(t)
seat := f.newPosition(t, "P-H", "Holder")
aisyah := f.newMember(t, "Aisyah")
bala := f.newMember(t, "Bala")
a := f.mustAssign(t, orgstruct.AssignmentInput{MemberID: aisyah, PositionID: seat, IsPrimary: true, ValidFrom: jan1})
b := f.mustAssign(t, orgstruct.AssignmentInput{MemberID: bala, PositionID: seat, IsPrimary: false, ValidFrom: jan1})
holdings := func(on time.Time) []orgstruct.Holding {
t.Helper()
var got []orgstruct.Holding
require.NoError(t, f.d.InTx(f.ctx, func(txCtx context.Context, tx pgx.Tx) error {
var err error
got, err = orgstruct.HoldingsOf(txCtx, tx, seat, on)
return err
}))
return got
}
members := func(hs []orgstruct.Holding) []uuid.UUID {
out := []uuid.UUID{}
for _, h := range hs {
out = append(out, h.MemberID)
}
return out
}
got := holdings(jul1)
require.ElementsMatch(t, []orgstruct.Holding{
{AssignmentID: a.ID, MemberID: aisyah}, {AssignmentID: b.ID, MemberID: bala},
}, got)
require.Equal(t, holdersOf(t, f, seat, jul1), members(got), "same members, same order as HoldersOf")
require.NoError(t, f.d.InTx(f.ctx, func(txCtx context.Context, tx pgx.Tx) error {
return orgstruct.CloseAssignment(txCtx, tx, testClock, b.ID, jul1)
}))
got = holdings(jul1)
require.Equal(t, []orgstruct.Holding{{AssignmentID: a.ID, MemberID: aisyah}}, got)
require.Equal(t, holdersOf(t, f, seat, jul1), members(got))
}

internal/modules/orgstructure/chart_assignment_test.go:

package orgstructure_test
import (
"fmt"
"net/http"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
type chartHoldersResp struct {
Positions []struct {
ID uuid.UUID `json:"id"`
Holders []struct {
AssignmentID uuid.UUID `json:"assignment_id"`
MemberID uuid.UUID `json:"member_id"`
} `json:"holders"`
} `json:"positions"`
}
func (e *env) holdersOf(t *testing.T, tok string, pos uuid.UUID) map[uuid.UUID]uuid.UUID {
t.Helper()
w := e.do(t, "GET", "/v1/org/chart", tok, nil)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
out := map[uuid.UUID]uuid.UUID{} // member -> assignment
for _, p := range decode[chartHoldersResp](t, w).Positions {
if p.ID == pos {
for _, h := range p.Holders {
out[h.MemberID] = h.AssignmentID
}
}
}
return out
}
// TestTheChartNamesEachHoldersAssignment: PRESENCE — the holder carries the
// id the assign call returned; that id ends the assignment through the
// existing DELETE route; ABSENCE — the chart no longer shows the holder.
func TestTheChartNamesEachHoldersAssignment(t *testing.T) {
e := newEnv(t)
a := e.tenant(t, "Alpha")
hq := e.createUnit(t, a.managerTok, map[string]any{"code": "HQ", "name": "Head Office"})
cfo := e.createPosition(t, a.managerTok, map[string]any{"org_unit_id": hq.ID, "code": "CFO", "title": "CFO"})
as := e.assign(t, a.managerTok, a.reader, map[string]any{
"position_id": cfo.ID, "is_primary": true, "valid_from": "2026-01-01"})
require.Equal(t, as.ID, e.holdersOf(t, a.readTok, cfo.ID)[a.reader])
w := e.do(t, "DELETE", fmt.Sprintf("/v1/org/members/%s/positions/%s", a.reader, as.ID), a.managerTok, nil)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.NotContains(t, e.holdersOf(t, a.readTok, cfo.ID), a.reader,
"closed today: valid_to = today is no longer in force (currentAsOf)")
}
  • Step 8: Run and watch fail. Run go test ./internal/platform/orgstruct/ -run TestHoldingsOf -count=1 and it fails to compile (undefined: orgstruct.HoldingsOf). Run go test ./internal/modules/orgstructure/ -run TestTheChartNames -count=1 and it fails because assignment_id decodes as the zero uuid.

  • Step 9: Implement. In internal/platform/orgstruct/assignment.go, directly after HoldersOf:

// Holding is one current holder of a seat and the member_position row that
// seats them, which is the row a close or withdrawal names.
type Holding struct {
AssignmentID uuid.UUID
MemberID uuid.UUID
}
// HoldingsOf is HoldersOf with each holder's assignment id, for a reader
// that must let somebody end the assignment (the org chart). The members
// and their order are HoldersOf's exactly: the same currentAsOf, the same
// ORDER BY. HoldersOf keeps its shape because approval resolution wants
// members, not rows.
func HoldingsOf(ctx context.Context, tx pgx.Tx, positionID uuid.UUID, on time.Time) ([]Holding, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return nil, fmt.Errorf("orgstruct: %w", tenancy.ErrNoOrg)
}
rows, err := tx.Query(ctx, `
SELECT id, member_id
FROM member_position
WHERE org_id = $1 AND position_id = $2 AND `+currentAsOf+`
ORDER BY valid_from, member_id`, orgID, positionID, on)
if err != nil {
return nil, fmt.Errorf("orgstruct: holdings of: %w", err)
}
defer rows.Close()
out := []Holding{}
for rows.Next() {
var h Holding
if err := rows.Scan(&h.AssignmentID, &h.MemberID); err != nil {
return nil, fmt.Errorf("orgstruct: holdings of: %w", err)
}
out = append(out, h)
}
return out, rows.Err()
}

In internal/modules/orgstructure/handlers.go:

  • holderDTO gains AssignmentID uuid.UUID json:“assignment_id”``, placed first.

  • In chart, change holders = map[uuid.UUID][]uuid.UUID{} to holders = map[uuid.UUID][]orgstruct.Holding{}. Replace the HoldersOf loop body with:

    hs, err := orgstruct.HoldingsOf(ctx, tx, p.ID, today)
    if err != nil {
    return err
    }
    holders[p.ID] = hs
    for _, h := range hs {
    all = append(all, h.MemberID)
    }

    Then change the DTO loop to:

    for _, h := range holders[p.ID] {
    hd := holderDTO{AssignmentID: h.AssignmentID, MemberID: h.MemberID}
    if n, ok := names[h.MemberID]; ok {
    hd.Name = &n
    }
    cp.Holders = append(cp.Holders, hd)
    }
  • Update the chart doc comment’s sentence “Holders come from orgstruct.HoldersOf” to “Holders come from orgstruct.HoldingsOf (HoldersOf plus each assignment id, same predicate)”.

  • Step 10: Run and pass. Run go test ./internal/platform/orgstruct/ ./internal/modules/orgstructure/ ./internal/kernel/layering/ -count=1. Expected: PASS, including the existing TestTheChartShowsWhoSitsInEachSeatToday.

  • Step 11: The contract. In api/openapi.yaml, OrgChartHolder: change required: [member_id, name] to required: [assignment_id, member_id, name], and add as its first property:

assignment_id:
type: string
format: uuid
description: >
The member_position row that seats this holder. Send it as {pid}
to DELETE /org/members/{id}/positions/{pid} to end or withdraw
the assignment.

Run make generate and go test ./internal/gen/ -count=1.

  • Step 12: Commit.
Terminal window
git add internal/platform/orgstruct/assignment.go internal/platform/orgstruct/assignment_test.go \
internal/modules/orgstructure/handlers.go internal/modules/orgstructure/chart_assignment_test.go \
api/openapi.yaml internal/gen/api/api.gen.go
git commit -m "$(cat <<'EOF'
Name each org chart holder's assignment (Plan 10, Task 4b)
OrgChartHolder gains assignment_id, read by orgstruct.HoldingsOf with
HoldersOf's own currentAsOf predicate, so the UI can end an assignment
through the existing DELETE route.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"
  • Step 13: Write the failing test. internal/modules/payout/channel_names_test.go:
package payout_test
import (
"encoding/json"
"net/http"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
type channelNamesResp struct {
ID uuid.UUID `json:"id"`
Key string `json:"key"`
Names map[string]string `json:"names"`
}
// TestAChannelReadsBackItsNames. PRESENCE: the names the editor saved (with
// zh) come back on create and on list. ABSENCE: after a PATCH without zh,
// zh is gone from the read. The read is the stored set, not a fallback.
func TestAChannelReadsBackItsNames(t *testing.T) {
e := newEnv(t)
body := map[string]any{
"key": "wise_myr", "names": map[string]string{"en": "Wise", "ms": "Wise", "zh": "Wise 转账"},
"settlement_behaviour": "external", "credit_account_id": e.accountID(t, "1520"),
"field_schema": []any{}, "allowed_currencies": []string{"MYR"},
}
w := e.do(t, http.MethodPost, "/v1/payout/channels", e.adminTok, body)
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
var created channelNamesResp
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created))
require.Equal(t, map[string]string{"en": "Wise", "ms": "Wise", "zh": "Wise 转账"}, created.Names)
list := func() map[string]string {
t.Helper()
w := e.do(t, http.MethodGet, "/v1/payout/channels", e.adminTok, nil)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var got struct {
Channels []channelNamesResp `json:"channels"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
for _, ch := range got.Channels {
require.NotNil(t, ch.Names, "every channel carries names, never null: %s", ch.Key)
if ch.ID == created.ID {
return ch.Names
}
}
t.Fatalf("created channel %s not listed", created.ID)
return nil
}
require.Equal(t, "Wise 转账", list()["zh"])
w = e.do(t, http.MethodPatch, "/v1/payout/channels/"+created.ID.String(), e.adminTok,
map[string]any{"names": map[string]string{"en": "Wise", "ms": "Wise"}})
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.NotContains(t, list(), "zh")
}

Verify: the PATCH route takes a names-only body. updateChannel decodes optional[platformpayout.Names], and the existing TestUpdateChannelCannotChangeKeyOrBehaviour patches single fields. If it needs If-Match or another field, add exactly what TestUpdateChannelMaxAmountNullClearsIt sends.

  • Step 14: Run and watch fail. go test ./internal/modules/payout/ -run TestAChannelReadsBackItsNames -count=1 fails: created.Names is nil.

  • Step 15: Implement. Append to internal/platform/payout/names.go:

// ChannelNames reads every locale's name of each channel in ids, in one
// query: the editor's read shape (Plan 10, Task 4b). A channel with no
// translation row maps to an empty Names, never nil.
func ChannelNames(ctx context.Context, tx pgx.Tx, ids []uuid.UUID) (map[uuid.UUID]Names, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return nil, fmt.Errorf("payout: channel names: %w", tenancy.ErrNoOrg)
}
out := make(map[uuid.UUID]Names, len(ids))
for _, id := range ids {
out[id] = Names{}
}
if len(ids) == 0 {
return out, nil
}
rows, err := tx.Query(ctx, `
SELECT entity_id, locale, value FROM translation
WHERE org_id = $1 AND entity_kind = $2 AND field = 'name' AND entity_id = ANY($3::uuid[])`,
orgID, kindChannel, ids)
if err != nil {
return nil, fmt.Errorf("payout: channel names: %w", err)
}
defer rows.Close()
for rows.Next() {
var (
id uuid.UUID
loc, v string
)
if err := rows.Scan(&id, &loc, &v); err != nil {
return nil, fmt.Errorf("payout: channel names: %w", err)
}
out[id][loc] = v
}
return out, rows.Err()
}

Verify: names.go already imports tenancy. If not, add "github.com/mcom/erp/internal/kernel/tenancy". kindChannel is the constant in channel.go.

In internal/modules/payout/channel_handlers.go:

  • channelDTO gains Names platformpayout.Names json:“names”``, after Label.

  • toChannelDTO(d platformpayout.Definition, l map[uuid.UUID]string, n map[uuid.UUID]platformpayout.Names) channelDTO sets Names: n[d.ID]. Guard it: if n[d.ID] == nil, use platformpayout.Names{}.

  • In listChannels, createChannel and updateChannel, right after each labels(ctx, tx, kindChannel, …) call, load names, err = platformpayout.ChannelNames(ctx, tx, <the same ids>) in the same transaction. Declare names map[uuid.UUID]platformpayout.Names beside l, and pass it to toChannelDTO.

  • Step 16: Run and pass. Run go test ./internal/modules/payout/ ./internal/platform/payout/ ./internal/kernel/layering/ -count=1. Expected: PASS.

  • Step 17: The contract. In api/openapi.yaml, PayoutChannel: append names to its required: list and add:

names:
allOf: [{ $ref: '#/components/schemas/PayoutNames' }]
description: >
Every locale's name as saved, the same map create and update
take. name is the record-locale one and label the request
locale's, both derived from this.

Verify: PayoutNames requires en and ms. Seeded channels carry both, as TestListChannelsLabelsInTheRequestLocale shows for bank_transfer. A channel inserted by raw SQL without translation rows (for example otherOrgChannel in the tests) would read {}, which violates that schema. If any production path creates a channel without writeNames, mark names as not required instead. grep -n "INSERT INTO payout_channel" internal/platform should show only define.go and the country-pack seeding, which also writes names. Run make generate and go test ./internal/gen/ -count=1.

  • Step 18: Commit.
Terminal window
git add internal/platform/payout/names.go internal/modules/payout/channel_handlers.go \
internal/modules/payout/channel_names_test.go api/openapi.yaml internal/gen/api/api.gen.go
git commit -m "$(cat <<'EOF'
Return each payout channel's names (Plan 10, Task 4b)
PayoutChannel gains names, the per-locale map create and update take,
read from its translation rows in one batched query, so the channel
editor can show and round-trip what it saved.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"
  • Step 19: Verify. Run grep -n -A8 "^ Problem:" api/openapi.yaml.
    • If code: is among its properties, Task 2 landed it and there is nothing to do.
    • If not (Task 2 not merged, or the property was lost in a merge), add after instance and run make generate:
code:
type: string
description: >
A stable refusal code the client branches on, where the status
alone is not enough: owner_role_manage_required and self_lockout
on the role editor's 409s.

Then commit api/openapi.yaml and internal/gen/api/api.gen.go as “Declare Problem.code (Plan 10, Task 4b)”, with the Co-Authored-By trailer.


Task 5: Web scaffold, generated client, CI, drift tests (float, hex)

Section titled “Task 5: Web scaffold, generated client, CI, drift tests (float, hex)”

Files:

  • Create: web/package.json, web/pnpm-lock.yaml (by pnpm), web/tsconfig.json, web/vite.config.ts, web/index.html, web/eslint.config.js, web/.prettierrc.json, web/.prettierignore
  • Create: web/scripts/gen-schema.ts
  • Create: web/src/main.tsx, web/src/app/App.tsx (placeholder, replaced by Task 8), web/src/vite-env.d.ts
  • Create: web/src/styles/tokens.css, web/src/styles/globals.css
  • Create (generated): web/src/lib/api/schema.d.ts; create: web/src/lib/api/types.ts
  • Create: web/src/test/setup.ts, web/src/test/msw/server.ts
  • Create: web/src/drift/scan.ts, web/src/drift/nofloat.test.ts, web/src/drift/nohex.test.ts, web/src/drift/schema.test.ts, web/src/drift/fixtures/float-violations.txt, web/src/drift/fixtures/hex-violations.txt
  • Modify: Makefile, .github/workflows/ci.yml, .gitignore
  • Create: .claude/launch.json

Interfaces:

  • Consumes: api/openapi.yaml (with Group A’s additions merged), packages/contracts/vocab.ts (PERMISSIONS).
  • Produces: web/src/lib/api/schema.d.ts (paths, components), web/src/lib/api/types.ts (the registry’s initial aliases: Schemas, Me, Money, ExpenseClaim, ExpenseClaimView, WorkflowDefinition, WorkflowState, WorkflowSemantic, AvailableTransition, Labels, Permission), the Tailwind theme tokens every later task styles with, the @/ and @contracts/ import aliases, src/test/setup.ts + src/test/msw/server.ts (server), src/drift/scan.ts (listSourceFiles, scanFiles), the web launch configuration, the CI web job.

Decision recorded here — where the schema drift test lives. Spec §3.1 says the Go drift test in internal/gen/drift_test.go is extended to cover schema.d.ts. It is not, for this reason: that test regenerates by running the generator as a child process, and openapi-typescript is a Node program. The Go CI job has no Node, so a Go test would either have to install Node and web/node_modules into the Go job, or skip when pnpm is missing — and a drift test that skips is the “guard you are guessing about” AGENTS.md forbids. Instead, generation lives in one function (web/scripts/gen-schema.ts: generateSchema()) that both make generate and a Vitest test call, and the test byte-compares its output against the committed file. It runs in the CI web job, which fails the build exactly as the Go one does. The Go test file’s header comment is left unchanged; this task adds one sentence to it pointing at the web test so nobody goes looking.

Tailwind token names (binding on Tasks 6–28; use only these):

Utility stem Meaning
bg, bg-subtle, surface, surface-raised page, subtle panel, card, popover
fg, fg-muted text, muted text (text-fg, text-fg-muted)
border, border-subtle outline #d3d2dc, light outline #efeef2 (border-border, border-border-subtle)
primary, primary-hover, primary-fg, primary-soft dark purple, its hover, text on it, light purple
sidebar-from, sidebar-to, sidebar-fg, sidebar-muted, sidebar-active sidebar gradient and text
overlay, focus modal scrim, focus ring
tone-<t>-soft, tone-<t>-accent, tone-<t>-fg for t in neutral amber green green-solid teal purple red muted blue status and category tints
text-caption 12/14, text-body 14/16, text-subtitle 16/20, tracking-ui 0.5px Figma type ramp
rounded-chip 6px, rounded-control 8px, rounded-card 12px, shadow-card, shadow-drawer shape
  • Step 1: Create the package with pnpm and install dependencies (latest versions; the lockfile records them)
Terminal window
mkdir -p web && cd web
pnpm init
# runtime
pnpm add react react-dom openapi-fetch @tanstack/react-query @tanstack/react-router @tanstack/react-table \
radix-ui lucide-react clsx tailwind-merge class-variance-authority zustand zod \
i18next react-i18next react-hook-form @hookform/resolvers sonner \
@fontsource/roboto @fontsource/noto-sans-sc
# tooling
pnpm add -D typescript vite @vitejs/plugin-react tailwindcss @tailwindcss/vite \
vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom msw \
openapi-typescript @types/react @types/react-dom @types/node \
eslint @eslint/js typescript-eslint eslint-plugin-react-hooks eslint-plugin-react-refresh \
eslint-config-prettier globals prettier tsx

Then edit only the non-dependency fields of web/package.json so it reads (keep whatever dependencies / devDependencies pnpm wrote; the packageManager value is the output of pnpm -v on the dev machine):

{
"name": "mcom-web",
"private": true,
"type": "module",
"packageManager": "pnpm@11.18.0",
"engines": { "node": ">=24" },
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json",
"lint": "eslint . && prettier --check .",
"format": "prettier --write .",
"test": "vitest run",
"test:watch": "vitest",
"generate": "node scripts/gen-schema.ts"
}
}

Verify: pnpm 10+ blocks dependency build scripts by default. If pnpm add printed “Ignored build scripts” for esbuild or @tailwindcss/oxide, run pnpm approve-builds and approve exactly those two (this writes the allowlist into pnpm-workspace.yaml / package.json depending on the pnpm version); commit that file. Nothing else should need build scripts.

  • Step 2: TypeScript, Vite, HTML entry

web/tsconfig.json:

{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"types": ["vite/client", "node"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@contracts/*": ["../packages/contracts/*"]
}
},
"include": ["src", "scripts", "e2e", "vite.config.ts", "../packages/contracts/vocab.ts"]
}

web/vite.config.ts:

import { fileURLToPath, URL } from 'node:url';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@contracts': fileURLToPath(new URL('../packages/contracts', import.meta.url)),
},
},
server: {
port: 5173,
strictPort: true,
// Same-origin with /v1 (spec §8): the refresh cookie is scoped to /v1/auth and there is
// no CORS, so the browser must see the API on this origin.
proxy: { '/v1': { target: 'http://localhost:3000', changeOrigin: false } },
// packages/contracts lives outside web/.
fs: { allow: ['..'] },
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
restoreMocks: true,
},
});

web/index.html:

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>mcom</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

web/src/vite-env.d.ts:

/// <reference types="vite/client" />
  • Step 3: ESLint (flat) and Prettier

web/eslint.config.js:

import js from '@eslint/js';
import { defineConfig, globalIgnores } from 'eslint/config';
import prettier from 'eslint-config-prettier';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default defineConfig([
globalIgnores(['dist', 'coverage', 'playwright-report', 'test-results', 'src/lib/api/schema.d.ts']),
{
files: ['**/*.{ts,tsx}'],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: { ecmaVersion: 2023, globals: { ...globals.browser, ...globals.node } },
// Plugins are wired rule by rule rather than through their preset objects, whose
// export names have changed between major versions.
plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh },
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'no-restricted-globals': ['error', { name: 'sessionStorage', message: 'UI state uses ui.* localStorage keys only (Global Constraints).' }],
},
},
prettier,
]);

web/.prettierrc.json:

{ "singleQuote": true, "printWidth": 100, "trailingComma": "all", "semi": true }

web/.prettierignore:

dist
coverage
pnpm-lock.yaml
src/lib/api/schema.d.ts
playwright-report
test-results
  • Step 4: Schema generation in one function, and the first generation

web/scripts/gen-schema.ts (Node 24 runs TypeScript directly by stripping types; this file uses no enums or parameter properties so it stays strippable):

// The one place web/src/lib/api/schema.d.ts is generated from. `make generate` runs this file
// as a script, and src/drift/schema.test.ts calls generateSchema() and byte-compares the result
// with the committed file. Keeping both on this function is what makes the comparison honest:
// a test that re-implemented the generation could agree with itself and not with `make generate`.
import { writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import openapiTS, { astToString } from 'openapi-typescript';
export const SPEC_URL = new URL('../../api/openapi.yaml', import.meta.url);
export const OUT_URL = new URL('../src/lib/api/schema.d.ts', import.meta.url);
const HEADER =
'// Code generated by web/scripts/gen-schema.ts from api/openapi.yaml. DO NOT EDIT.\n' +
'// Regenerate with `make generate`.\n\n';
export async function generateSchema(): Promise<string> {
const ast = await openapiTS(SPEC_URL);
return HEADER + astToString(ast);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await writeFile(OUT_URL, await generateSchema());
}

Run it:

Terminal window
pnpm -C web run generate
head -3 web/src/lib/api/schema.d.ts
grep -c '"/me"' web/src/lib/api/schema.d.ts # Task 1's path must be present: expect 1

Expected: the header lines, then export interface paths {; the grep prints 1. If it prints 0, Group A has not merged into this branch — stop and merge it first.

  • Step 5: Makefile, .gitignore

In Makefile, extend .PHONY and generate, and add a web target (append-only below the existing targets):

.PHONY: test lint build migrate generate web
generate:
go tool sqlc generate
go tool oapi-codegen -config oapi-codegen.yaml api/openapi.yaml
go run ./tools/vocabgen
pnpm -C web run generate
web:
pnpm -C web install --frozen-lockfile
pnpm -C web typecheck
pnpm -C web lint
pnpm -C web test
pnpm -C web build

Append to .gitignore:

web/node_modules/
web/dist/
web/coverage/
web/playwright-report/
web/test-results/
.dev-stack/

In internal/gen/drift_test.go, append one sentence to the package comment (no code change): // web/src/lib/api/schema.d.ts has its own regenerate-and-compare test, web/src/drift/schema.test.ts, because its generator is a Node program and this job has no Node.

  • Step 6: Tokens and global CSS

web/src/styles/tokens.css — the only file in web/src allowed to contain a colour literal:

/*
* Design tokens from Figma file Uz1j7iSQYOYdCLKTm6bRsT (variables read with get_variable_defs):
* Dark purple #6a4b8d, light purple #f1e9f1, outline #d3d2dc, light outline #efeef2,
* Color #8e8e93 (muted text), White, black. Type: Roboto; Caption 12/14, Body copy 14/16,
* Sub bold 14/16 w700, Sub title 16/20 w500, tracking 0.5.
* Status tints are keyed on workflow SEMANTIC (spec §3.4, F9), never on a state key.
* This is the ONLY file under web/src that may contain a colour literal (src/drift/nohex.test.ts).
*/
:root {
color-scheme: light;
--bg: #ffffff;
--bg-subtle: #f8f6fa;
--surface: #ffffff;
--surface-raised: #ffffff;
--fg: #000000;
--fg-muted: #8e8e93;
--border: #d3d2dc;
--border-subtle: #efeef2;
--primary: #6a4b8d;
--primary-hover: #5a3e79;
--primary-fg: #ffffff;
--primary-soft: #f1e9f1;
--sidebar-from: #8a6aaa;
--sidebar-to: #6a4b8d;
--sidebar-fg: #ffffff;
--sidebar-muted: rgb(255 255 255 / 0.72);
--sidebar-active: rgb(255 255 255 / 0.18);
--overlay: rgb(20 16 40 / 0.45);
--focus: #6a4b8d;
/* tone-<t>-soft: tinted background; -accent: border and icon; -fg: text (AA on white and on soft) */
--tone-neutral-soft: #f4f4f6;
--tone-neutral-accent: #d3d2dc;
--tone-neutral-fg: #3c3c43;
--tone-amber-soft: #fff5dc;
--tone-amber-accent: #f5a623;
--tone-amber-fg: #9a5b00;
--tone-green-soft: #e6f7ee;
--tone-green-accent: #1aa260;
--tone-green-fg: #0f7a45;
--tone-green-solid-soft: #1aa260;
--tone-green-solid-accent: #1aa260;
--tone-green-solid-fg: #ffffff;
--tone-teal-soft: #e3f6f5;
--tone-teal-accent: #14a39a;
--tone-teal-fg: #0b716b;
--tone-purple-soft: #f1e9f1;
--tone-purple-accent: #6a4b8d;
--tone-purple-fg: #6a4b8d;
--tone-red-soft: #fdecec;
--tone-red-accent: #e5484d;
--tone-red-fg: #b4232a;
--tone-muted-soft: #f4f4f6;
--tone-muted-accent: #efeef2;
--tone-muted-fg: #8e8e93;
--tone-blue-soft: #eaf2fd;
--tone-blue-accent: #3b82f6;
--tone-blue-fg: #1d5fc9;
}
/* Dark mode follows frame 477:16079. data-theme is set on <html> by app/theme.ts (Task 8). */
[data-theme='dark'] {
color-scheme: dark;
--bg: #0b0930;
--bg-subtle: #120f3a;
--surface: #16133f;
--surface-raised: #1d1a4a;
--fg: #ffffff;
--fg-muted: #a3a1b8;
--border: #2e2b5c;
--border-subtle: #221f4d;
--primary: #7c5ca3;
--primary-hover: #8e6db7;
--primary-fg: #ffffff;
--primary-soft: #2a2152;
--sidebar-from: #5a3f7a;
--sidebar-to: #3d2a57;
--sidebar-fg: #ffffff;
--sidebar-muted: rgb(255 255 255 / 0.66);
--sidebar-active: rgb(255 255 255 / 0.14);
--overlay: rgb(0 0 0 / 0.6);
--focus: #b89ad9;
--tone-neutral-soft: #24214f;
--tone-neutral-accent: #4a4775;
--tone-neutral-fg: #e4e3ef;
--tone-amber-soft: #2b2110;
--tone-amber-accent: #f5a623;
--tone-amber-fg: #ffc15e;
--tone-green-soft: #0f2a22;
--tone-green-accent: #1aa260;
--tone-green-fg: #5fd69a;
--tone-green-solid-soft: #1aa260;
--tone-green-solid-accent: #1aa260;
--tone-green-solid-fg: #ffffff;
--tone-teal-soft: #0d2b2e;
--tone-teal-accent: #14a39a;
--tone-teal-fg: #5dd9cf;
--tone-purple-soft: #2a2152;
--tone-purple-accent: #a27fcb;
--tone-purple-fg: #d2b8f0;
--tone-red-soft: #2e1420;
--tone-red-accent: #e5484d;
--tone-red-fg: #ff8a8e;
--tone-muted-soft: #1d1a4a;
--tone-muted-accent: #2e2b5c;
--tone-muted-fg: #a3a1b8;
--tone-blue-soft: #0f1f45;
--tone-blue-accent: #3b82f6;
--tone-blue-fg: #8ab6ff;
}
@theme inline {
--color-bg: var(--bg);
--color-bg-subtle: var(--bg-subtle);
--color-surface: var(--surface);
--color-surface-raised: var(--surface-raised);
--color-fg: var(--fg);
--color-fg-muted: var(--fg-muted);
--color-border: var(--border);
--color-border-subtle: var(--border-subtle);
--color-primary: var(--primary);
--color-primary-hover: var(--primary-hover);
--color-primary-fg: var(--primary-fg);
--color-primary-soft: var(--primary-soft);
--color-sidebar-from: var(--sidebar-from);
--color-sidebar-to: var(--sidebar-to);
--color-sidebar-fg: var(--sidebar-fg);
--color-sidebar-muted: var(--sidebar-muted);
--color-sidebar-active: var(--sidebar-active);
--color-overlay: var(--overlay);
--color-focus: var(--focus);
--color-tone-neutral-soft: var(--tone-neutral-soft);
--color-tone-neutral-accent: var(--tone-neutral-accent);
--color-tone-neutral-fg: var(--tone-neutral-fg);
--color-tone-amber-soft: var(--tone-amber-soft);
--color-tone-amber-accent: var(--tone-amber-accent);
--color-tone-amber-fg: var(--tone-amber-fg);
--color-tone-green-soft: var(--tone-green-soft);
--color-tone-green-accent: var(--tone-green-accent);
--color-tone-green-fg: var(--tone-green-fg);
--color-tone-green-solid-soft: var(--tone-green-solid-soft);
--color-tone-green-solid-accent: var(--tone-green-solid-accent);
--color-tone-green-solid-fg: var(--tone-green-solid-fg);
--color-tone-teal-soft: var(--tone-teal-soft);
--color-tone-teal-accent: var(--tone-teal-accent);
--color-tone-teal-fg: var(--tone-teal-fg);
--color-tone-purple-soft: var(--tone-purple-soft);
--color-tone-purple-accent: var(--tone-purple-accent);
--color-tone-purple-fg: var(--tone-purple-fg);
--color-tone-red-soft: var(--tone-red-soft);
--color-tone-red-accent: var(--tone-red-accent);
--color-tone-red-fg: var(--tone-red-fg);
--color-tone-muted-soft: var(--tone-muted-soft);
--color-tone-muted-accent: var(--tone-muted-accent);
--color-tone-muted-fg: var(--tone-muted-fg);
--color-tone-blue-soft: var(--tone-blue-soft);
--color-tone-blue-accent: var(--tone-blue-accent);
--color-tone-blue-fg: var(--tone-blue-fg);
--font-sans: 'Roboto', 'Noto Sans SC', system-ui, sans-serif;
--text-caption: 0.75rem;
--text-caption--line-height: 0.875rem;
--text-body: 0.875rem;
--text-body--line-height: 1rem;
--text-subtitle: 1rem;
--text-subtitle--line-height: 1.25rem;
--tracking-ui: 0.5px;
--radius-chip: 6px;
--radius-control: 8px;
--radius-card: 12px;
--shadow-card: 0 1px 2px rgb(20 16 40 / 0.06);
--shadow-drawer: -8px 0 24px rgb(20 16 40 / 0.12);
}

web/src/styles/globals.css:

@import 'tailwindcss';
@import './tokens.css';
@import '@fontsource/roboto/400.css';
@import '@fontsource/roboto/500.css';
@import '@fontsource/roboto/700.css';
@import '@fontsource/noto-sans-sc/400.css';
@import '@fontsource/noto-sans-sc/500.css';
@import '@fontsource/noto-sans-sc/700.css';
@layer base {
html {
font-family: var(--font-sans);
background: var(--bg);
color: var(--fg);
}
:lang(zh) {
font-family: 'Noto Sans SC', 'Roboto', system-ui, sans-serif;
}
body {
@apply text-body tracking-ui antialiased;
}
:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
}
  • Step 7: Entry point and placeholder app

web/src/app/App.tsx (Task 8 replaces the body with the router):

export function App() {
return <div className="min-h-dvh bg-bg" />;
}

web/src/main.tsx:

import { StrictMode, Suspense, lazy, type ComponentType } from 'react';
import { createRoot } from 'react-dom/client';
import './styles/globals.css';
import { App } from './app/App';
// Dev-only pages under src/dev/<name>.tsx are served at /__<name> (Task 7's /__ui gallery).
// The glob is empty until a task adds a page, and `import.meta.env.DEV` is false in a
// production build, so none of them ship.
const devPages = import.meta.env.DEV
? import.meta.glob<{ default: ComponentType }>('./dev/*.tsx')
: {};
function Root() {
const m = /^\/__(\w+)$/.exec(window.location.pathname);
const load = m ? devPages[`./dev/${m[1]}.tsx`] : undefined;
if (load) {
const Page = lazy(load);
return (
<Suspense fallback={null}>
<Page />
</Suspense>
);
}
return <App />;
}
const el = document.getElementById('root');
if (!el) throw new Error('#root is missing from index.html');
createRoot(el).render(
<StrictMode>
<Root />
</StrictMode>,
);
  • Step 8: Type aliases

web/src/lib/api/types.ts:

// Aliases only, all from the generated schema (Global Constraints: no hand-written API types).
// Append-only: a task adds the aliases it first needs at the bottom.
import type { PERMISSIONS } from '@contracts/vocab';
import type { components } from './schema';
export type Schemas = components['schemas'];
export type Me = Schemas['Me'];
export type Money = string;
export type ExpenseClaim = Schemas['ExpenseClaim'];
export type ExpenseClaimView = Schemas['ExpenseClaimView'];
export type WorkflowDefinition = Schemas['WorkflowDefinition'];
export type WorkflowState = Schemas['WorkflowState'];
export type WorkflowSemantic = Schemas['WorkflowSemantic'];
export type AvailableTransition = Schemas['WorkflowAvailableTransition'];
export type Labels = Schemas['WorkflowLabels']; // Record<string, string>
// vocab.ts lists only the permissions in constants.yaml; modules register more (activity.read,
// org.members.invite, …). `string & {}` keeps editor completion for the known ones while
// accepting the rest — the same set as the registry's `| string`.
export type Permission = (typeof PERMISSIONS)[number] | 'org.roles.manage' | (string & {});

Verify: pnpm -C web typecheck passes. If Schemas['Me'] is an error, Task 1’s component is named differently in the merged openapi.yaml; fix the alias to the real name and amend the registry in this commit.

  • Step 9: Test setup and MSW server

web/src/test/msw/server.ts:

import { setupServer } from 'msw/node';
// Handlers are added per test with server.use(...). Task 6 adds defaults in handlers.ts.
export const server = setupServer();

web/src/test/setup.ts:

import '@testing-library/jest-dom/vitest';
import { webcrypto } from 'node:crypto';
import { cleanup } from '@testing-library/react';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './msw/server';
// jsdom lacks these; Radix primitives and our upload hashing need them.
if (!globalThis.crypto?.subtle) {
Object.defineProperty(globalThis, 'crypto', { value: webcrypto, configurable: true });
}
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver ??= ResizeObserverStub as unknown as typeof ResizeObserver;
Element.prototype.scrollIntoView ??= function scrollIntoView() {};
Element.prototype.hasPointerCapture ??= () => false;
Element.prototype.releasePointerCapture ??= () => {};
window.matchMedia ??= (query: string) =>
({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}) as MediaQueryList;
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
window.localStorage.clear();
});
afterAll(() => server.close());
  • Step 10: The shared scanner and the planted fixtures

web/src/drift/scan.ts:

import { readdirSync, readFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
export const WEB_ROOT = fileURLToPath(new URL('../..', import.meta.url));
export const SRC_ROOT = join(WEB_ROOT, 'src');
/** Every file under web/src with one of `exts`, as a web/-relative POSIX path. */
export function listSourceFiles(exts: string[]): string[] {
const out: string[] = [];
const walk = (dir: string) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (exts.some((x) => e.name.endsWith(x))) out.push(relative(WEB_ROOT, p).split(sep).join('/'));
}
};
walk(SRC_ROOT);
return out.sort();
}
export interface Rule {
name: string;
pattern: RegExp; // must carry the g flag
}
export interface Hit {
file: string;
line: number;
rule: string;
text: string;
}
export function scanText(file: string, text: string, rules: Rule[]): Hit[] {
const hits: Hit[] = [];
text.split('\n').forEach((lineText, i) => {
for (const r of rules) {
r.pattern.lastIndex = 0;
if (r.pattern.test(lineText)) hits.push({ file, line: i + 1, rule: r.name, text: lineText.trim() });
}
});
return hits;
}
export function scanFiles(files: string[], rules: Rule[]): Hit[] {
return files.flatMap((f) => scanText(f, readFileSync(join(WEB_ROOT, f), 'utf8'), rules));
}
export const formatHits = (hits: Hit[]) => hits.map((h) => `${h.file}:${h.line} [${h.rule}] ${h.text}`).join('\n');

web/src/drift/fixtures/float-violations.txt (one line per rule; .txt so the real scan never reads it):

const a = parseFloat(claim.total);
const b = Number(line.amount);
const c = total.toFixed(2);
const d = +claim.amount;
const e = Math.round(balance * 100);
const f = parseInt(amountText, 10);

web/src/drift/fixtures/hex-violations.txt:

<div className="bg-[#6a4b8d]" />
const c = '#FFF';
color: #f1e9f1cc;
style={{ color: 'rgb(1, 2, 3)' }}
background: hsl(270 30% 40%);
  • Step 11: Write the float drift test

web/src/drift/nofloat.test.ts:

import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { formatHits, listSourceFiles, scanFiles, scanText, WEB_ROOT, type Rule } from './scan';
// A float never touches money (AGENTS.md; spec §3.2). Money-named = amount, total, balance,
// money, price, cost, variance, committed, available. The heuristic is deliberately broad on
// the four outright bans and narrow on Math/parseInt/unary plus, which are only banned next to
// a money-named identifier.
const MONEY = '(?:amount|total|balance|money|price|cost|variance|committed|available)';
export const FLOAT_RULES: Rule[] = [
{ name: 'parseFloat', pattern: /\bparseFloat\s*\(/g },
{ name: 'Number()', pattern: /\bNumber\s*\(/g },
{ name: 'toFixed', pattern: /\.toFixed\s*\(/g },
{ name: 'unary plus on money', pattern: new RegExp(`(?:[=(,:?\\[{;]|\\breturn)\\s*\\+\\s*[\\w$.]*${MONEY}`, 'gi') },
{ name: 'Math on money', pattern: new RegExp(`\\bMath\\.\\w+\\([^)]*${MONEY}`, 'gi') },
{ name: 'parseInt on money', pattern: new RegExp(`\\bparseInt\\s*\\([^)]*${MONEY}`, 'gi') },
];
// Explicit allowlist (spec §3.2): files that may use these for NON-money numbers. Append-only;
// each entry says why. A stale entry fails the test below.
export const FLOAT_ALLOWLIST: Record<string, string> = {
// Task 6 adds, in the commit that creates the file:
// 'src/lib/date.ts': 'date arithmetic (seconds between instants), never money',
};
// The drift tests and their fixtures quote the patterns; the generated schema quotes the spec.
const EXCLUDED = [/^src\/drift\//, /^src\/lib\/api\/schema\.d\.ts$/];
describe('no float on money', () => {
it('finds every planted violation (presence before absence)', () => {
const text = readFileSync(join(WEB_ROOT, 'src/drift/fixtures/float-violations.txt'), 'utf8');
const hits = scanText('fixture', text, FLOAT_RULES);
expect(new Set(hits.map((h) => h.rule))).toEqual(new Set(FLOAT_RULES.map((r) => r.name)));
});
it('allowlist entries all exist', () => {
for (const f of Object.keys(FLOAT_ALLOWLIST)) expect(existsSync(join(WEB_ROOT, f)), f).toBe(true);
});
it('web/src has none outside the allowlist', () => {
const files = listSourceFiles(['.ts', '.tsx']).filter((f) => !EXCLUDED.some((x) => x.test(f)));
expect(files.length, 'scanned no files; the absence below would be vacuous').toBeGreaterThan(0);
expect(files).toContain('src/main.tsx');
const hits = scanFiles(files, FLOAT_RULES).filter((h) => !(h.file in FLOAT_ALLOWLIST));
expect(hits, formatHits(hits)).toEqual([]);
});
});

The allowlist starts empty because lib/date.ts does not exist yet, and an entry naming a missing file fails the “entries all exist” test by design.

  • Step 12: Write the hex drift test

web/src/drift/nohex.test.ts:

import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { formatHits, listSourceFiles, scanFiles, scanText, WEB_ROOT, type Rule } from './scan';
// No colour literal outside src/styles/tokens.css (spec §3.4). Components use theme tokens.
const HEX_RULES: Rule[] = [
{ name: 'hex colour', pattern: /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b/g },
{ name: 'rgb()/hsl()/oklch()', pattern: /\b(?:rgba?|hsla?|oklch|oklab)\s*\(/g },
];
const ALLOWED = new Set(['src/styles/tokens.css']);
const EXCLUDED = [/^src\/drift\//, /^src\/lib\/api\/schema\.d\.ts$/];
describe('no hex outside tokens.css', () => {
it('finds every planted violation (presence before absence)', () => {
const text = readFileSync(join(WEB_ROOT, 'src/drift/fixtures/hex-violations.txt'), 'utf8');
const hits = scanText('fixture', text, HEX_RULES);
expect(hits.map((h) => h.line)).toEqual([1, 2, 3, 4, 5]);
});
it('tokens.css itself is scanned and does contain colours', () => {
const hits = scanFiles(['src/styles/tokens.css'], HEX_RULES);
expect(hits.length).toBeGreaterThan(10);
});
it('web/src has none outside tokens.css', () => {
const files = listSourceFiles(['.ts', '.tsx', '.css']).filter(
(f) => !ALLOWED.has(f) && !EXCLUDED.some((x) => x.test(f)),
);
expect(files.length, 'scanned no files; the absence below would be vacuous').toBeGreaterThan(0);
expect(files).toContain('src/styles/globals.css');
const hits = scanFiles(files, HEX_RULES);
expect(hits, formatHits(hits)).toEqual([]);
});
});
  • Step 13: Write the schema drift test

web/src/drift/schema.test.ts:

import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { generateSchema, OUT_URL } from '../../scripts/gen-schema';
describe('generated client matches api/openapi.yaml', () => {
it('schema.d.ts is byte-identical to a fresh generation', async () => {
const committed = readFileSync(fileURLToPath(OUT_URL), 'utf8');
expect(committed.length, 'committed schema.d.ts is empty').toBeGreaterThan(1000);
const fresh = await generateSchema();
// Compared as a boolean first so a failure prints one line, not two 10 000-line strings.
const same = committed === fresh;
expect(same, 'api/openapi.yaml changed without regenerating; run `make generate`').toBe(true);
}, 30_000);
});
  • Step 14: Run the drift tests; they pass

Run: pnpm -C web test -- src/drift Expected: 3 files, all tests PASS (with the empty float allowlist from Step 11).

Verify: confirm the path filter worked — the summary lists only the three src/drift/*.test.ts files. If pnpm forwarded the literal -- and Vitest ran nothing or everything, use pnpm -C web exec vitest run src/drift for this and every later “run” step, and say so in the task report.

  • Step 15: Watch each guard fail once, then restore
  1. Float: add export const probe = Number('1'); to the end of web/src/app/App.tsx. Run pnpm -C web test -- src/drift/nofloat. Expected FAIL, listing src/app/App.tsx:<n> [Number()] export const probe = Number('1');. Remove the line.
  2. Hex: change bg-bg in App.tsx to bg-[#ffffff]. Run pnpm -C web test -- src/drift/nohex. Expected FAIL naming src/app/App.tsx and [hex colour]. Restore.
  3. Schema: echo '// hand edit' >> web/src/lib/api/schema.d.ts. Run pnpm -C web test -- src/drift/schema. Expected FAIL with “api/openapi.yaml changed without regenerating; run make generate”. Restore with pnpm -C web run generate and confirm git diff --stat web/src/lib/api/schema.d.ts is empty.

Record the three failure messages in the task report.

  • Step 16: Full web checks

Run: pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test && pnpm -C web build Expected: all succeed. If prettier --check reports files, run pnpm -C web format once and re-run.

  • Step 17: CI job and launch configuration

Append to .github/workflows/ci.yml under jobs::

web:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
package_json_file: web/package.json
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm test
- run: pnpm build

Create .claude/launch.json (Task 8 appends the api entry):

{
"version": "0.0.1",
"configurations": [
{
"name": "web",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["-C", "web", "dev"],
"port": 5173
}
]
}

Runtime check: preview_start with name: "web"; the page loads blank with no console errors (read_console_messages with onlyErrors).

  • Step 18: Commit
Terminal window
git add web .github/workflows/ci.yml Makefile .gitignore .claude/launch.json internal/gen/drift_test.go
git commit -m "$(cat <<'EOF'
Plan 10 Task 5: web scaffold, generated client, drift tests, CI job
Vite + React 19 + TS strict + Tailwind v4 tokens from Figma; schema.d.ts
generated from api/openapi.yaml with a byte-compare drift test; no-float
and no-hex guards with planted fixtures, each watched failing once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"

Task 6: Core libs — api client + auth refresh, problem, query, money, date, i18n + parity, money components

Section titled “Task 6: Core libs — api client + auth refresh, problem, query, money, date, i18n + parity, money components”

Files:

  • Create: web/src/lib/api/client.ts, web/src/lib/api/client.test.ts
  • Create: web/src/lib/api/problem.ts, web/src/lib/api/problem.test.ts
  • Create: web/src/lib/api/query.ts
  • Create: web/src/lib/money.ts, web/src/lib/money.test.ts
  • Create: web/src/lib/date.ts, web/src/lib/date.test.ts
  • Create: web/src/lib/cn.ts
  • Create: web/src/lib/i18n/index.ts, web/src/lib/i18n/pickLabel.ts, web/src/lib/i18n/i18n.test.ts
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/common.json
  • Create: web/src/drift/i18nparity.test.ts, web/src/drift/fixtures/i18n/{en,ms,zh}/demo.json
  • Create: web/src/components/money/MoneyText.tsx, web/src/components/money/MoneyInput.tsx, web/src/components/money/money.test.tsx
  • Create: web/src/test/msw/handlers.ts, web/src/test/msw/fixtures.ts, web/src/test/render.tsx
  • Create: web/src/app/providers.tsx
  • Modify: web/src/test/msw/server.ts, web/src/test/setup.ts, web/src/main.tsx, web/src/drift/nofloat.test.ts (one allowlist entry), web/src/lib/api/types.ts (append)

Interfaces:

  • Consumes: Task 5’s schema.d.ts, types.ts, test setup, drift scanner.
  • Produces (registry, exactly): api, setAccessToken, getAccessToken, refreshSession, onSessionExpired, newIdempotencyKey (@/lib/api/client); ProblemError, unwrap, applyProblemToForm, problemMessage (@/lib/api/problem); queryClient (@/lib/api/query); minorUnits, formatMoney, isMoneyInput, normalizeMoneyInput, subtractMoney, compareMoney, isZeroMoney (@/lib/money); formatDate, formatDateTime, todayISO, monthKey, relativeTime (@/lib/date); SUPPORTED_LOCALES, UiLocale, initI18n, useLocale, pickLabel, RECORD_LOCALES (@/lib/i18n); cn (@/lib/cn); MoneyText, MoneyInput (@/components/money/MoneyText, @/components/money/MoneyInput).
  • Produces (additions; see Part B notes): applyTokenResponse(t) in client.ts; ProblemError.retryAfter?: number and getter kind; toProblemError(body, response); formatMonth(key, locale) and TIME_ZONE in date.ts; isUiLocale, detectLocale, buildResources, LOCALE_STORAGE_KEY in lib/i18n; @/test/render → renderWithQuery(ui), createTestQueryClient(); @/test/msw/fixtures → ORG_ID, ORG2_ID, MEMBER_ID, meFixture, tokenFixture(); @/test/msw/handlers → apiPath(p), defaultHandlers; @/app/providers → Providers.

Facts this task relies on (checked in api/openapi.yaml and internal/kernel/errors/problem.go):

  • Problem is { type, title, status, detail?, instance? }. Extension members exist only through Problem.With, and no handler today sets code or errors. So ProblemError.code / .errors are read when present (Task 2’s lockout refusals add code), and applyProblemToForm returns false for every current 422, after which the caller toasts problemMessage.

  • X-Request-Id is on every response (internal/kernel/httpx/middleware.go).

  • POST /auth/refresh takes an optional { org_id }. Without it the server mints for the default organization (first by name), so the client remembers the organization of the last token it applied. After a full page reload that memory is gone and the user lands in the default organization (see Part B notes).

  • Money on the wire is a string with 4 decimals (money.Amount.String() is StringFixed(4)).

  • Step 1: Write the failing money tests

web/src/lib/money.test.ts:

import { describe, expect, it } from 'vitest';
import {
compareMoney,
formatMoney,
isMoneyInput,
isZeroMoney,
minorUnits,
normalizeMoneyInput,
subtractMoney,
} from './money';
describe('minorUnits', () => {
it('knows the common exceptions and defaults to 2', () => {
expect(minorUnits('MYR')).toBe(2);
expect(minorUnits('JPY')).toBe(0);
expect(minorUnits('KWD')).toBe(3);
expect(minorUnits('XYZ')).toBe(2);
});
});
describe('formatMoney', () => {
it.each([
['1234.5', 'RM 1,234.50'],
['1234.5600', 'RM 1,234.56'],
['0', 'RM 0.00'],
['0.0000', 'RM 0.00'],
['68.4', 'RM 68.40'],
['1000000', 'RM 1,000,000.00'],
['1.005', 'RM 1.01'], // half-up on the magnitude
['1.0049', 'RM 1.00'],
['999.995', 'RM 1,000.00'], // carry across the point and a group
['-1.005', '-RM 1.01'],
['-0.004', 'RM 0.00'], // no negative zero
['0007.10', 'RM 7.10'],
])('MYR %s → %s', (amount, want) => {
expect(formatMoney(amount, 'MYR', 'en')).toBe(want);
});
it('is exact beyond 2^53, where Number would round', () => {
// 2^53 + 1 = 9007199254740993, and Number('9007199254740993') === 9007199254740992.
expect(formatMoney('9007199254740993.4567', 'MYR', 'en')).toBe('RM 9,007,199,254,740,993.46');
expect(formatMoney('-9007199254740993.0050', 'MYR', 'en')).toBe('-RM 9,007,199,254,740,993.01');
expect(formatMoney('123456789012345678901234.5', 'MYR', 'en')).toBe(
'RM 123,456,789,012,345,678,901,234.50',
);
});
it('uses the currency code for non-MYR, and on request', () => {
expect(formatMoney('333', 'CNY', 'zh')).toBe('CNY 333.00');
expect(formatMoney('1234.5', 'MYR', 'en', { code: true })).toBe('MYR 1,234.50');
expect(formatMoney('1234.5', 'JPY', 'en')).toBe('JPY 1,235');
});
it('shows a plus sign only when asked, and never on zero', () => {
expect(formatMoney('12', 'MYR', 'en', { sign: 'always' })).toBe('+RM 12.00');
expect(formatMoney('-12', 'MYR', 'en', { sign: 'always' })).toBe('-RM 12.00');
expect(formatMoney('0', 'MYR', 'en', { sign: 'always' })).toBe('RM 0.00');
});
it('refuses anything that is not a plain decimal string', () => {
for (const bad of ['', '1e5', '12,34', 'abc', '1.2.3', ' 1', 'NaN']) {
expect(() => formatMoney(bad, 'MYR', 'en'), bad).toThrow(RangeError);
}
});
});
describe('isMoneyInput', () => {
it.each(['1', '1.', '1.5', '0.0001', '123456789012345', '123456789012345.1234'])('accepts %s', (s) =>
expect(isMoneyInput(s)).toBe(true),
);
it.each(['', '.5', '1.23456', '-1', '1,000', '1234567890123456', ' 1', '1e3'])('rejects %j', (s) =>
expect(isMoneyInput(s)).toBe(false),
);
});
describe('normalizeMoneyInput', () => {
it.each([
['1.5', '1.5'],
['', ''],
[' 1,234.50 ', '1234.50'],
['007.5', '7.5'],
['00', '0'],
['0.5', '0.5'],
['1.', '1.'],
])('%j → %j', (s, want) => expect(normalizeMoneyInput(s)).toBe(want));
});
describe('subtractMoney (F16: the one place the UI does arithmetic on money)', () => {
it.each([
['100.00', '0.01', '99.9900'],
['0', '0.0001', '-0.0001'],
['-5', '-7.5', '2.5000'],
['1234.5600', '1234.5600', '0.0000'],
['9007199254740993.0000', '1', '9007199254740992.0000'],
['99999999999999999999.9999', '-0.0001', '100000000000000000000.0000'],
])('%s − %s = %s', (a, b, want) => expect(subtractMoney(a, b)).toBe(want));
it('refuses more than 4 decimals, which the wire never carries', () => {
expect(() => subtractMoney('1.00001', '0')).toThrow(RangeError);
});
});
describe('compareMoney / isZeroMoney', () => {
it('compares exactly', () => {
expect(compareMoney('10', '9.9999')).toBe(1);
expect(compareMoney('1.10', '1.1')).toBe(0);
expect(compareMoney('-1', '0')).toBe(-1);
expect(compareMoney('9007199254740993', '9007199254740992')).toBe(1);
});
it('knows zero in every spelling', () => {
expect(isZeroMoney('0.0000')).toBe(true);
expect(isZeroMoney('-0')).toBe(true);
expect(isZeroMoney('0.0001')).toBe(false);
});
});
  • Step 2: Run it; it fails

Run: pnpm -C web test -- src/lib/money.test.ts Expected: FAIL, Failed to resolve import "./money".

  • Step 3: Implement lib/money.ts
// Money is a decimal STRING end to end (AGENTS.md: a float never touches money).
// Everything here is string manipulation or BigInt; nothing converts an amount to a Number.
// subtractMoney is the single place the UI does arithmetic on money (spec F16).
const MINOR_UNITS: Record<string, number> = {
JPY: 0,
KRW: 0,
VND: 0,
CLP: 0,
ISK: 0,
UGX: 0,
BHD: 3,
KWD: 3,
OMR: 3,
JOD: 3,
TND: 3,
IQD: 3,
LYD: 3,
};
const SYMBOLS: Record<string, string> = { MYR: 'RM' };
const DECIMAL = /^([+-])?(\d+)(?:\.(\d*))?$/;
const WIRE_SCALE = 4;
const WIRE_FACTOR = 10n ** BigInt(WIRE_SCALE);
export function minorUnits(currency: string): number {
return MINOR_UNITS[currency.toUpperCase()] ?? 2;
}
interface Parsed {
negative: boolean;
int: string; // no leading zeros; at least "0"
frac: string; // as written; may be ""
}
function parse(s: string): Parsed {
const m = DECIMAL.exec(s);
if (!m) throw new RangeError(`not a decimal amount: ${JSON.stringify(s)}`);
return { negative: m[1] === '-', int: (m[2] ?? '0').replace(/^0+(?=\d)/, ''), frac: m[3] ?? '' };
}
/** |int.frac| rounded half-up to `digits` places, as a scaled integer. */
function roundScaled(int: string, frac: string, digits: number): bigint {
const kept = frac.slice(0, digits).padEnd(digits, '0');
const next = frac.charAt(digits);
const scaled = BigInt(int + kept);
return next !== '' && next >= '5' ? scaled + 1n : scaled;
}
function splitScaled(scaled: bigint, digits: number): [string, string] {
const s = scaled.toString().padStart(digits + 1, '0');
return digits === 0 ? [s, ''] : [s.slice(0, -digits), s.slice(-digits)];
}
/** "1234.5" → "RM 1,234.50". Rounds half-up to the currency's minor units, for display only. */
export function formatMoney(
amount: string,
currency: string,
_locale: string,
opts: { sign?: 'auto' | 'always'; code?: boolean } = {},
): string {
const { negative, int, frac } = parse(amount);
const digits = minorUnits(currency);
const scaled = roundScaled(int, frac, digits);
const [i, f] = splitScaled(scaled, digits);
const grouped = i.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
const body = digits === 0 ? grouped : `${grouped}.${f}`;
const code = currency.toUpperCase();
const symbol = opts.code ? code : (SYMBOLS[code] ?? code);
const zero = scaled === 0n;
const prefix = zero ? '' : negative ? '-' : opts.sign === 'always' ? '+' : '';
return `${prefix}${symbol} ${body}`;
}
export function isMoneyInput(s: string): boolean {
return /^\d{1,15}(\.\d{0,4})?$/.test(s);
}
/** Tidies what a person typed or pasted. It does not validate; pair it with isMoneyInput. */
export function normalizeMoneyInput(s: string): string {
const t = s.trim().replace(/,/g, '');
return t === '' ? '' : t.replace(/^0+(?=\d)/, '');
}
function toWireUnits(s: string): bigint {
const { negative, int, frac } = parse(s);
if (frac.length > WIRE_SCALE) throw new RangeError(`more than ${WIRE_SCALE} decimals: ${s}`);
const units = BigInt(int) * WIRE_FACTOR + BigInt(frac.padEnd(WIRE_SCALE, '0'));
return negative ? -units : units;
}
function fromWireUnits(u: bigint): string {
const negative = u < 0n;
const abs = negative ? -u : u;
const int = (abs / WIRE_FACTOR).toString();
const frac = (abs % WIRE_FACTOR).toString().padStart(WIRE_SCALE, '0');
return `${negative ? '-' : ''}${int}.${frac}`;
}
/** a − b, exact, in the wire's 4-decimal form. */
export function subtractMoney(a: string, b: string): string {
return fromWireUnits(toWireUnits(a) - toWireUnits(b));
}
export function compareMoney(a: string, b: string): -1 | 0 | 1 {
const x = toWireUnits(a);
const y = toWireUnits(b);
return x === y ? 0 : x > y ? 1 : -1;
}
export function isZeroMoney(a: string): boolean {
return toWireUnits(a) === 0n;
}

Run: pnpm -C web test -- src/lib/money.test.ts — Expected: PASS.

  • Step 4: Write the failing date tests

web/src/lib/date.test.ts:

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { formatDate, formatDateTime, formatMonth, monthKey, relativeTime, todayISO } from './date';
describe('dates display in Asia/Kuala_Lumpur (UTC+8)', () => {
// 17:00 UTC on 28 Aug is 01:00 on 29 Aug in Kuala Lumpur.
beforeEach(() => vi.useFakeTimers({ now: new Date('2026-08-28T17:00:00Z') }));
afterEach(() => vi.useRealTimers());
it('formatDate', () => {
expect(formatDate('2026-08-28T06:15:00Z', 'en')).toBe('28 Aug 2026');
expect(formatDate('2026-08-28T06:15:00Z', 'zh')).toBe('2026年8月28日');
expect(formatDate('2026-08-28T06:15:00Z', 'ms')).toBe('28 Ogo 2026');
expect(formatDate('2026-08-28T20:00:00Z', 'en')).toBe('29 Aug 2026'); // 04:00 next day in KL
expect(formatDate('2026-08-28', 'en')).toBe('28 Aug 2026'); // a calendar date never shifts
});
it('formatDateTime', () => {
expect(formatDateTime('2026-08-28T06:15:00Z', 'en')).toBe('28 Aug 2026, 14:15');
expect(formatDateTime('2026-08-28T06:15:00Z', 'zh')).toBe('2026年8月28日 14:15');
});
it('todayISO and monthKey use the KL calendar', () => {
expect(todayISO()).toBe('2026-08-29');
expect(monthKey()).toBe('2026-08'); // still August at 01:00 on the 29th
expect(monthKey(new Date('2026-08-31T16:30:00Z'))).toBe('2026-09');
});
it('formatMonth', () => {
expect(formatMonth('2026-08', 'en')).toBe('Aug 2026');
expect(formatMonth('2026-08', 'zh')).toBe('2026年8月');
});
it('relativeTime', () => {
expect(relativeTime('2026-08-28T16:55:00Z', 'en')).toBe('5 minutes ago');
expect(relativeTime('2026-08-27T17:00:00Z', 'en')).toBe('yesterday');
expect(relativeTime('2026-08-28T16:55:00Z', 'zh')).toBe('5分钟前');
});
});

Run: pnpm -C web test -- src/lib/date.test.ts — Expected: FAIL (./date does not resolve).

  • Step 5: Implement lib/date.ts
// Display in Asia/Kuala_Lumpur; the wire stays ISO 8601 (spec §3.3). Allowlisted in the
// no-float drift test: the arithmetic here is on instants and seconds, never on money.
import type { UiLocale } from './i18n';
export const TIME_ZONE = 'Asia/Kuala_Lumpur';
const INTL_LOCALE: Record<UiLocale, string> = { en: 'en-GB', ms: 'ms-MY', zh: 'zh-CN' };
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
function intl(locale: string): string {
return INTL_LOCALE[locale as UiLocale] ?? 'en-GB';
}
/** A date-only string is a calendar date: format it in UTC so it never moves a day. */
function instant(iso: string): { date: Date; timeZone: string } {
return DATE_ONLY.test(iso)
? { date: new Date(`${iso}T00:00:00Z`), timeZone: 'UTC' }
: { date: new Date(iso), timeZone: TIME_ZONE };
}
export function formatDate(iso: string, locale: string): string {
const { date, timeZone } = instant(iso);
const month = locale === 'zh' ? 'long' : 'short';
return new Intl.DateTimeFormat(intl(locale), { timeZone, year: 'numeric', month, day: 'numeric' }).format(date);
}
export function formatDateTime(iso: string, locale: string): string {
const time = new Intl.DateTimeFormat(intl(locale), {
timeZone: TIME_ZONE,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(new Date(iso));
return `${formatDate(iso, locale)}${locale === 'zh' ? ' ' : ', '}${time}`;
}
function klParts(d: Date): { year: string; month: string; day: string } {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(d);
const get = (t: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === t)?.value ?? '';
return { year: get('year'), month: get('month'), day: get('day') };
}
/** Today's date in Kuala Lumpur, YYYY-MM-DD. */
export function todayISO(): string {
const { year, month, day } = klParts(new Date());
return `${year}-${month}-${day}`;
}
/** The Kuala Lumpur month of `d`, YYYY-MM. */
export function monthKey(d: Date = new Date()): string {
const { year, month } = klParts(d);
return `${year}-${month}`;
}
/** "2026-08" → "Aug 2026" / "2026年8月" / "Ogo 2026". */
export function formatMonth(key: string, locale: string): string {
const month = locale === 'zh' ? 'long' : 'short';
return new Intl.DateTimeFormat(intl(locale), { timeZone: 'UTC', year: 'numeric', month }).format(
new Date(`${key}-01T00:00:00Z`),
);
}
const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
['year', 365 * 24 * 3600],
['month', 30 * 24 * 3600],
['week', 7 * 24 * 3600],
['day', 24 * 3600],
['hour', 3600],
['minute', 60],
['second', 1],
];
export function relativeTime(iso: string, locale: string): string {
const seconds = (new Date(iso).getTime() - Date.now()) / 1000;
const rtf = new Intl.RelativeTimeFormat(intl(locale), { numeric: 'auto' });
const [unit, size] = UNITS.find(([, s]) => Math.abs(seconds) >= s) ?? ['second', 1];
return rtf.format(Math.round(seconds / size), unit);
}

In web/src/drift/nofloat.test.ts, turn the commented allowlist line into the real entry:

export const FLOAT_ALLOWLIST: Record<string, string> = {
'src/lib/date.ts': 'date arithmetic (seconds between instants), never money',
};

Run: pnpm -C web test -- src/lib/date.test.ts src/drift/nofloat.test.ts — Expected: PASS.

Verify: the expected strings assume Node 24’s bundled full ICU. If one differs only in whitespace or punctuation (a narrow no-break space before the time, say), print the actual value, confirm it reads correctly, and update the expectation. Do not change the format options to force the old string. ms month abbreviations come from CLDR (Ogo is August).

  • Step 6: i18n — failing tests first

web/src/lib/i18n/i18n.test.ts:

import { describe, expect, it } from 'vitest';
import { buildResources, detectLocale, pickLabel } from '.';
describe('pickLabel', () => {
it('prefers the UI locale, then en, then any label, then empty', () => {
expect(pickLabel({ en: 'Pending', zh: '待审核' }, 'zh')).toBe('待审核');
expect(pickLabel({ en: 'Pending', zh: '待审核' }, 'ms')).toBe('Pending');
expect(pickLabel({ ms: 'Menunggu' }, 'zh')).toBe('Menunggu');
expect(pickLabel({ en: '', ms: 'Menunggu' }, 'en')).toBe('Menunggu');
expect(pickLabel(undefined, 'en')).toBe('');
expect(pickLabel(null, 'en')).toBe('');
});
});
describe('detectLocale', () => {
it('uses the stored choice, then the browser, then en', () => {
expect(detectLocale('zh', ['en-US'])).toBe('zh');
expect(detectLocale(null, ['ms-MY', 'en'])).toBe('ms');
expect(detectLocale(null, ['zh-TW'])).toBe('zh');
expect(detectLocale('fr', ['de-DE'])).toBe('en');
});
});
describe('buildResources', () => {
it('groups glob modules by locale and namespace', () => {
const res = buildResources({
'./locales/en/common.json': { default: { a: 'A' } },
'./locales/zh/common.json': { default: { a: '甲' } },
});
expect(res).toEqual({ en: { common: { a: 'A' } }, zh: { common: { a: '甲' } } });
});
});

Run: pnpm -C web test -- src/lib/i18n — Expected: FAIL (module not found).

  • Step 7: Implement i18n

web/src/lib/i18n/pickLabel.ts:

import type { UiLocale } from '.';
/** Server-supplied labels (workflow states, categories, channels): locale → en → first → ''. */
export function pickLabel(labels: Record<string, string> | undefined | null, locale: UiLocale): string {
if (!labels) return '';
return labels[locale] || labels.en || Object.values(labels).find((v) => v) || '';
}

web/src/lib/i18n/index.ts:

import i18n, { type Resource } from 'i18next';
import { useCallback } from 'react';
import { initReactI18next, useTranslation } from 'react-i18next';
export { pickLabel } from './pickLabel';
export const SUPPORTED_LOCALES = ['en', 'ms', 'zh'] as const;
export type UiLocale = (typeof SUPPORTED_LOCALES)[number];
/** Record text (titles, reasons, names) must be in one of these (spec §11A.3). */
export const RECORD_LOCALES = ['en', 'ms'] as const;
export const LOCALE_STORAGE_KEY = 'ui.locale';
export function isUiLocale(v: unknown): v is UiLocale {
return typeof v === 'string' && (SUPPORTED_LOCALES as readonly string[]).includes(v);
}
type JsonModule = { default: Record<string, unknown> };
// One JSON file per feature namespace per locale, so parallel tasks never edit one file.
const modules = import.meta.glob<JsonModule>('./locales/*/*.json', { eager: true });
export function buildResources(mods: Record<string, JsonModule> = modules): Resource {
const res: Resource = {};
for (const [path, mod] of Object.entries(mods)) {
const m = /\/locales\/([a-z]+)\/([\w-]+)\.json$/.exec(path);
if (!m?.[1] || !m[2]) continue;
(res[m[1]] ??= {})[m[2]] = mod.default;
}
return res;
}
function readStored(): string | null {
try {
return window.localStorage.getItem(LOCALE_STORAGE_KEY);
} catch {
return null;
}
}
/** The user's stored choice, then the browser's languages, then en (spec §3.3). */
export function detectLocale(
stored: string | null = readStored(),
languages: readonly string[] = navigator.languages,
): UiLocale {
if (isUiLocale(stored)) return stored;
for (const l of languages) {
const base = l.toLowerCase().split('-')[0];
if (isUiLocale(base)) return base;
}
return 'en';
}
const HTML_LANG: Record<UiLocale, string> = { en: 'en', ms: 'ms', zh: 'zh-CN' };
export async function initI18n(): Promise<void> {
if (i18n.isInitialized) return;
const resources = buildResources();
const lng = detectLocale();
await i18n.use(initReactI18next).init({
resources,
lng,
fallbackLng: 'en',
supportedLngs: [...SUPPORTED_LOCALES],
ns: Object.keys(resources.en ?? {}),
defaultNS: 'common',
interpolation: { escapeValue: false }, // React escapes
returnNull: false,
});
document.documentElement.lang = HTML_LANG[lng];
i18n.on('languageChanged', (l) => {
if (isUiLocale(l)) document.documentElement.lang = HTML_LANG[l];
});
}
export function useLocale(): [UiLocale, (l: UiLocale) => void] {
const { i18n: inst } = useTranslation();
const current = isUiLocale(inst.resolvedLanguage) ? inst.resolvedLanguage : 'en';
const set = useCallback(
(l: UiLocale) => {
try {
window.localStorage.setItem(LOCALE_STORAGE_KEY, l);
} catch {
// Private mode: the choice lasts for this page only.
}
void inst.changeLanguage(l);
},
[inst],
);
return [current, set];
}

web/src/lib/i18n/locales/en/common.json:

{
"actions": {
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm",
"close": "Close",
"retry": "Try again",
"reload": "Reload",
"loadMore": "Load more",
"back": "Back",
"next": "Next",
"submit": "Submit",
"edit": "Edit",
"search": "Search",
"clear": "Clear",
"download": "Download",
"open": "Open",
"copy": "Copy",
"copied": "Copied"
},
"states": { "loading": "Loading…", "empty": "Nothing here yet" },
"errors": {
"sessionExpired": "Your session has ended. Please sign in again.",
"forbidden": "You don't have permission to do that.",
"notFound": "Not found, or not visible to you.",
"conflict": "This changed while you were editing. Reload to see the latest version.",
"validation": "Some details need correcting.",
"rateLimited": "Too many attempts. Try again in {{seconds}} seconds.",
"rateLimitedSoon": "Too many attempts. Try again shortly.",
"server": "Something went wrong on our side. Request ID: {{requestId}}",
"serverNoId": "Something went wrong on our side.",
"network": "Can't reach the server. Check your connection and try again.",
"unknown": "Something went wrong."
},
"hints": {
"recordLocale": "Records are kept in English or Bahasa Malaysia. Please type this in one of them."
},
"money": { "invalid": "Enter an amount such as 1234.50" }
}

web/src/lib/i18n/locales/ms/common.json:

{
"actions": {
"save": "Simpan",
"cancel": "Batal",
"confirm": "Sahkan",
"close": "Tutup",
"retry": "Cuba lagi",
"reload": "Muat semula",
"loadMore": "Muat lagi",
"back": "Kembali",
"next": "Seterusnya",
"submit": "Hantar",
"edit": "Sunting",
"search": "Cari",
"clear": "Kosongkan",
"download": "Muat turun",
"open": "Buka",
"copy": "Salin",
"copied": "Disalin"
},
"states": { "loading": "Memuatkan…", "empty": "Belum ada apa-apa di sini" },
"errors": {
"sessionExpired": "Sesi anda telah tamat. Sila log masuk semula.",
"forbidden": "Anda tiada kebenaran untuk melakukan tindakan ini.",
"notFound": "Tidak ditemui, atau tidak boleh dilihat oleh anda.",
"conflict": "Rekod ini berubah semasa anda menyunting. Muat semula untuk melihat versi terkini.",
"validation": "Beberapa butiran perlu dibetulkan.",
"rateLimited": "Terlalu banyak cubaan. Cuba lagi dalam {{seconds}} saat.",
"rateLimitedSoon": "Terlalu banyak cubaan. Cuba lagi sebentar nanti.",
"server": "Berlaku ralat di pihak kami. ID permintaan: {{requestId}}",
"serverNoId": "Berlaku ralat di pihak kami.",
"network": "Tidak dapat menghubungi pelayan. Semak sambungan anda dan cuba lagi.",
"unknown": "Berlaku ralat."
},
"hints": {
"recordLocale": "Rekod disimpan dalam bahasa Inggeris atau Bahasa Malaysia. Sila taip dalam salah satu daripadanya."
},
"money": { "invalid": "Masukkan amaun seperti 1234.50" }
}

web/src/lib/i18n/locales/zh/common.json:

{
"actions": {
"save": "保存",
"cancel": "取消",
"confirm": "确认",
"close": "关闭",
"retry": "重试",
"reload": "重新加载",
"loadMore": "加载更多",
"back": "返回",
"next": "下一步",
"submit": "提交",
"edit": "编辑",
"search": "搜索",
"clear": "清除",
"download": "下载",
"open": "打开",
"copy": "复制",
"copied": "已复制"
},
"states": { "loading": "加载中…", "empty": "暂无内容" },
"errors": {
"sessionExpired": "登录已过期,请重新登录。",
"forbidden": "您没有执行此操作的权限。",
"notFound": "未找到,或您无权查看。",
"conflict": "您编辑期间此记录已被更改,请重新加载查看最新版本。",
"validation": "部分信息需要更正。",
"rateLimited": "尝试次数过多,请在 {{seconds}} 秒后重试。",
"rateLimitedSoon": "尝试次数过多,请稍后重试。",
"server": "服务器出错。请求编号:{{requestId}}",
"serverNoId": "服务器出错。",
"network": "无法连接服务器,请检查网络后重试。",
"unknown": "出现错误。"
},
"hints": { "recordLocale": "记录须以英文或马来文保存,请使用其中一种语言填写。" },
"money": { "invalid": "请输入金额,例如 1234.50" }
}

Run: pnpm -C web test -- src/lib/i18n — Expected: PASS.

  • Step 8: i18n parity drift test, presence first

web/src/drift/fixtures/i18n/en/demo.json and web/src/drift/fixtures/i18n/ms/demo.json (identical):

{ "a": "A", "nested": { "b": "B {{n}}" } }

web/src/drift/fixtures/i18n/zh/demo.json (planted: an empty value and a missing key):

{ "a": "" }

web/src/drift/i18nparity.test.ts:

import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { WEB_ROOT } from './scan';
const LOCALES = ['en', 'ms', 'zh'] as const;
type Flat = Record<string, string>;
type Bundles = Record<string, Partial<Record<string, Flat>>>; // namespace → locale → key → text
function flatten(obj: unknown, prefix = '', out: Flat = {}): Flat {
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object') flatten(v, key, out);
else out[key] = String(v);
}
return out;
}
function load(dir: string): Bundles {
const bundles: Bundles = {};
for (const locale of readdirSync(dir)) {
if (!statSync(join(dir, locale)).isDirectory()) continue;
for (const file of readdirSync(join(dir, locale)).filter((f) => f.endsWith('.json'))) {
const ns = file.slice(0, -'.json'.length);
(bundles[ns] ??= {})[locale] = flatten(JSON.parse(readFileSync(join(dir, locale, file), 'utf8')));
}
}
return bundles;
}
const placeholders = (s: string) =>
[...s.matchAll(/\{\{\s*(\w+)\s*\}\}/g)].map((m) => m[1]).sort().join(',');
function problems(bundles: Bundles): string[] {
const out: string[] = [];
for (const [ns, byLocale] of Object.entries(bundles)) {
const keys = new Set(LOCALES.flatMap((l) => Object.keys(byLocale[l] ?? {})));
for (const l of LOCALES) {
const flat = byLocale[l];
if (!flat) {
out.push(`${l}/${ns}.json is missing`);
continue;
}
for (const key of keys) {
const v = flat[key];
const en = byLocale.en?.[key];
if (v === undefined) out.push(`${l}/${ns}.json lacks ${key}`);
else if (v.trim() === '') out.push(`${l}/${ns}.json has an empty ${key}`);
else if (en !== undefined && placeholders(v) !== placeholders(en))
out.push(`${l}/${ns}.json ${key}: placeholders {${placeholders(v)}} differ from en {${placeholders(en)}}`);
}
}
}
return out.sort();
}
describe('i18n key parity across en / ms / zh', () => {
it('reports the planted missing key and empty value (presence before absence)', () => {
expect(problems(load(join(WEB_ROOT, 'src/drift/fixtures/i18n')))).toEqual([
'zh/demo.json has an empty a',
'zh/demo.json lacks nested.b',
]);
});
it('every namespace has every key, non-empty, with the same placeholders, in every locale', () => {
const bundles = load(join(WEB_ROOT, 'src/lib/i18n/locales'));
const namespaces = Object.keys(bundles);
expect(namespaces, 'no bundles found; the absence below would be vacuous').toContain('common');
const keyCount = namespaces.reduce((n, ns) => n + Object.keys(bundles[ns]?.en ?? {}).length, 0);
expect(keyCount).toBeGreaterThan(10);
expect(problems(bundles)).toEqual([]);
});
});

Run: pnpm -C web test -- src/drift/i18nparity.test.ts — Expected: PASS. Watch it fail once: delete "copied" from zh/common.json and re-run. Expected FAIL listing zh/common.json lacks actions.copied. Restore it and re-run to green.

  • Step 9: Problem mapping — failing tests

web/src/lib/api/problem.test.ts:

import i18n from 'i18next';
import { describe, expect, it, vi } from 'vitest';
import { applyProblemToForm, ProblemError, problemMessage, unwrap } from './problem';
const t = i18n.t.bind(i18n);
const problemResponse = (body: unknown, status: number, headers: Record<string, string> = {}) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/problem+json', ...headers },
});
describe('unwrap', () => {
it('returns data on success', () => {
expect(unwrap({ data: { a: 1 }, response: new Response('{}', { status: 200 }) })).toEqual({ a: 1 });
});
it('throws a typed ProblemError with request id, code and field errors', () => {
expect.assertions(6);
const body = {
type: 'https://errors.mcom.erp/conflict',
title: 'Conflict',
status: 409,
detail: 'the Owner role must keep org.roles.manage',
code: 'owner_role_manage_required',
errors: [{ pointer: '/permissions', detail: 'required' }],
};
try {
unwrap({ error: body, response: problemResponse(body, 409, { 'X-Request-Id': 'req-123' }) });
} catch (e) {
expect(e).toBeInstanceOf(ProblemError);
const p = e as ProblemError;
expect(p.status).toBe(409);
expect(p.code).toBe('owner_role_manage_required');
expect(p.requestId).toBe('req-123');
expect(p.errors).toEqual([{ pointer: '/permissions', detail: 'required' }]);
expect(p.kind).toBe('conflict');
}
});
it('builds a ProblemError from a non-problem failure body', () => {
const response = new Response('oops', { status: 502, statusText: 'Bad Gateway' });
expect(() => unwrap({ error: 'oops', response })).toThrow(ProblemError);
});
it('reads Retry-After on 429', () => {
expect.assertions(1);
const body = { type: 'https://errors.mcom.erp/rate-limited', title: 'Too Many Requests', status: 429 };
try {
unwrap({ error: body, response: problemResponse(body, 429, { 'Retry-After': '30' }) });
} catch (e) {
expect((e as ProblemError).retryAfter).toBe(30);
}
});
});
describe('problemMessage', () => {
const p = (status: number, extra: { detail?: string; retryAfter?: number; requestId?: string } = {}) =>
new ProblemError({ status, type: 'x', title: 'T', ...extra });
it('maps statuses to friendly text', () => {
expect(problemMessage(p(403), t)).toBe("You don't have permission to do that.");
expect(problemMessage(p(404), t)).toBe('Not found, or not visible to you.');
expect(problemMessage(p(422, { detail: 'title is required' }), t)).toBe('title is required');
expect(problemMessage(p(429, { retryAfter: 12 }), t)).toBe('Too many attempts. Try again in 12 seconds.');
expect(problemMessage(p(500, { requestId: 'abc' }), t)).toBe(
'Something went wrong on our side. Request ID: abc',
);
expect(problemMessage(new TypeError('Failed to fetch'), t)).toMatch(/Can't reach the server/);
});
});
describe('applyProblemToForm', () => {
it('maps JSON pointers onto form fields and reports whether it did', () => {
const setError = vi.fn();
const form = { setError } as never;
const e = new ProblemError({
status: 422,
type: 'x',
title: 'T',
errors: [{ pointer: '/lines/0/amount', detail: 'must be positive' }],
});
expect(applyProblemToForm(form, e)).toBe(true);
expect(setError).toHaveBeenCalledWith('lines.0.amount', { type: 'server', message: 'must be positive' });
expect(applyProblemToForm(form, new ProblemError({ status: 422, type: 'x', title: 'T' }))).toBe(false);
expect(applyProblemToForm(form, new Error('x'))).toBe(false);
});
});

Run: pnpm -C web test -- src/lib/api/problem.test.ts — Expected: FAIL (module not found).

  • Step 10: Implement lib/api/problem.ts
import type { TFunction } from 'i18next';
import type { FieldValues, Path, UseFormReturn } from 'react-hook-form';
export interface ProblemInit {
status: number;
type: string;
title: string;
detail?: string;
code?: string;
errors?: { pointer: string; detail: string }[];
requestId?: string;
retryAfter?: number;
}
/** An application/problem+json answer (RFC 7807), typed. */
export class ProblemError extends Error {
status: number;
type: string;
title: string;
detail?: string;
code?: string;
errors?: { pointer: string; detail: string }[];
requestId?: string;
retryAfter?: number;
constructor(init: ProblemInit) {
super(init.detail ?? init.title);
this.name = 'ProblemError';
this.status = init.status;
this.type = init.type;
this.title = init.title;
this.detail = init.detail;
this.code = init.code;
this.errors = init.errors;
this.requestId = init.requestId;
this.retryAfter = init.retryAfter;
}
/** The last segment of `type`: "conflict", "validation", "totp-required", … */
get kind(): string {
return this.type.split('/').pop() ?? '';
}
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null;
}
function isFieldError(e: unknown): e is { pointer: string; detail: string } {
return isRecord(e) && typeof e.pointer === 'string' && typeof e.detail === 'string';
}
export function toProblemError(body: unknown, response: Response): ProblemError {
const requestId = response.headers.get('X-Request-Id') ?? undefined;
const ra = response.headers.get('Retry-After');
const retryAfter = ra && /^\d+$/.test(ra) ? Number.parseInt(ra, 10) : undefined;
if (isRecord(body) && typeof body.type === 'string' && typeof body.title === 'string') {
return new ProblemError({
status: typeof body.status === 'number' ? body.status : response.status,
type: body.type,
title: body.title,
detail: typeof body.detail === 'string' ? body.detail : undefined,
code: typeof body.code === 'string' ? body.code : undefined,
errors: Array.isArray(body.errors) ? body.errors.filter(isFieldError) : undefined,
requestId,
retryAfter,
});
}
return new ProblemError({
status: response.status,
type: 'about:blank',
title: response.statusText || 'Error',
requestId,
retryAfter,
});
}
/** openapi-fetch result → data, or throw ProblemError. */
export function unwrap<T>(r: { data?: T; error?: unknown; response: Response }): T {
if (r.response.ok && r.error === undefined) return r.data as T;
throw toProblemError(r.error, r.response);
}
/** Maps `errors[].pointer` ("/lines/0/amount") onto react-hook-form fields. True if any mapped. */
export function applyProblemToForm<T extends FieldValues>(form: UseFormReturn<T>, e: unknown): boolean {
if (!(e instanceof ProblemError) || !e.errors?.length) return false;
let mapped = false;
for (const err of e.errors) {
const name = err.pointer.replace(/^\//, '').split('/').join('.');
if (!name) continue;
form.setError(name as Path<T>, { type: 'server', message: err.detail });
mapped = true;
}
return mapped;
}
/** Toast text for any error (spec §6). */
export function problemMessage(e: unknown, t: TFunction): string {
if (e instanceof ProblemError) {
if (e.status === 401) return t('common:errors.sessionExpired');
if (e.status === 403) return t('common:errors.forbidden');
if (e.status === 404) return t('common:errors.notFound');
if (e.status === 409) return e.detail ?? t('common:errors.conflict');
if (e.status === 400 || e.status === 422) return e.detail ?? t('common:errors.validation');
if (e.status === 429)
return e.retryAfter !== undefined
? t('common:errors.rateLimited', { seconds: e.retryAfter })
: t('common:errors.rateLimitedSoon');
if (e.status >= 500)
return e.requestId
? t('common:errors.server', { requestId: e.requestId })
: t('common:errors.serverNoId');
return e.detail ?? e.title;
}
if (e instanceof TypeError) return t('common:errors.network');
return t('common:errors.unknown');
}

The test uses the real i18next instance, so it passes only after Step 13 initialises i18n in the test setup.

  • Step 11: API client — failing tests

web/src/test/msw/fixtures.ts:

import type { Me } from '@/lib/api/types';
export const ORG_ID = '0190a000-0000-7000-8000-000000000001';
export const ORG2_ID = '0190a000-0000-7000-8000-000000000002';
export const MEMBER_ID = '0190a000-0000-7000-8000-0000000000a1';
export const meFixture: Me = {
user_id: '0190a000-0000-7000-8000-0000000000f1',
email: 'employee@demo.test',
name: 'Tan Wei Ming',
member_id: MEMBER_ID,
organization: { id: ORG_ID, name: 'Demo Sdn Bhd', base_currency: 'MYR', country: 'MY' },
organizations: [
{ id: ORG_ID, name: 'Demo Sdn Bhd' },
{ id: ORG2_ID, name: 'Second Demo Bhd' },
],
permissions: ['expenses.claim.create', 'expenses.claim.read', 'activity.read'],
};
export const tokenFixture = (access_token = 'token-1', organization_id = ORG_ID) => ({
access_token,
token_type: 'Bearer' as const,
expires_in: 900,
organization_id,
organizations: meFixture.organizations,
});

web/src/test/msw/handlers.ts:

import { http, HttpResponse } from 'msw';
import { meFixture } from './fixtures';
/** MSW path for an API route: apiPath('/me') matches <any origin>/v1/me. */
export const apiPath = (p: string) => `*/v1${p}`;
/** Every test gets these; a test overrides with server.use(...). */
export const defaultHandlers = [
http.get(apiPath('/me'), () => HttpResponse.json(meFixture)),
// No refresh cookie in tests unless a test says otherwise.
http.post(apiPath('/auth/refresh'), () =>
HttpResponse.json(
{ type: 'https://errors.mcom.erp/unauthorized', title: 'Unauthorized', status: 401 },
{ status: 401 },
),
),
];

Replace web/src/test/msw/server.ts with:

import { setupServer } from 'msw/node';
import { defaultHandlers } from './handlers';
export const server = setupServer(...defaultHandlers);

web/src/lib/api/client.test.ts:

import { http, HttpResponse } from 'msw';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ORG2_ID, tokenFixture } from '@/test/msw/fixtures';
import { apiPath } from '@/test/msw/handlers';
import { server } from '@/test/msw/server';
import {
api,
applyTokenResponse,
getAccessToken,
onSessionExpired,
refreshSession,
setAccessToken,
} from './client';
afterEach(() => setAccessToken(null));
describe('bearer token', () => {
it('sends the in-memory token and never writes storage', async () => {
let auth: string | null = null;
server.use(
http.get(apiPath('/me'), ({ request }) => {
auth = request.headers.get('Authorization');
return HttpResponse.json({});
}),
);
const spy = vi.spyOn(Storage.prototype, 'setItem');
setAccessToken('abc');
await api.GET('/me');
expect(auth).toBe('Bearer abc');
expect(spy).not.toHaveBeenCalled();
});
});
describe('401 → one refresh → one retry', () => {
it('ten concurrent 401s cause exactly one refresh, and each request is retried once', async () => {
let refreshes = 0;
const seen: string[] = [];
server.use(
http.post(apiPath('/auth/refresh'), async () => {
refreshes++;
await new Promise((r) => setTimeout(r, 20));
return HttpResponse.json(tokenFixture('new'));
}),
http.get(apiPath('/me'), ({ request }) => {
const a = request.headers.get('Authorization') ?? '';
seen.push(a);
return a === 'Bearer new' ? HttpResponse.json({ ok: true }) : new HttpResponse(null, { status: 401 });
}),
);
setAccessToken('old');
const results = await Promise.all(Array.from({ length: 10 }, () => api.GET('/me')));
expect(refreshes).toBe(1);
expect(results.every((r) => r.response.status === 200)).toBe(true);
expect(seen.filter((a) => a === 'Bearer old')).toHaveLength(10);
expect(seen.filter((a) => a === 'Bearer new')).toHaveLength(10);
expect(getAccessToken()).toBe('new');
});
it('replays the request body on the retry', async () => {
const bodies: unknown[] = [];
server.use(
http.post(apiPath('/auth/refresh'), () => HttpResponse.json(tokenFixture('new'))),
http.post(apiPath('/auth/switch-org'), async ({ request }) => {
bodies.push(await request.json());
return request.headers.get('Authorization') === 'Bearer new'
? HttpResponse.json(tokenFixture('switched', ORG2_ID))
: new HttpResponse(null, { status: 401 });
}),
);
setAccessToken('old');
const r = await api.POST('/auth/switch-org', { body: { org_id: ORG2_ID } });
expect(r.response.status).toBe(200);
expect(bodies).toEqual([{ org_id: ORG2_ID }, { org_id: ORG2_ID }]);
});
it('a failed refresh signals session expiry once and hands back the 401', async () => {
server.use(http.get(apiPath('/me'), () => new HttpResponse(null, { status: 401 })));
const expired = vi.fn();
const off = onSessionExpired(expired);
setAccessToken('old');
const r = await api.GET('/me');
off();
expect(r.response.status).toBe(401);
expect(expired).toHaveBeenCalledTimes(1);
expect(getAccessToken()).toBeNull();
});
it('never refreshes on the auth endpoints themselves', async () => {
let refreshes = 0;
server.use(
http.post(apiPath('/auth/refresh'), () => {
refreshes++;
return new HttpResponse(null, { status: 401 });
}),
http.post(apiPath('/auth/login'), () =>
HttpResponse.json({ type: 'x/unauthorized', title: 'Unauthorized', status: 401 }, { status: 401 }),
),
);
const r = await api.POST('/auth/login', { body: { email: 'a@b.test', password: 'x' } });
expect(r.response.status).toBe(401);
expect(refreshes).toBe(0);
});
});
describe('refreshSession', () => {
it('re-mints for the organization of the last token applied', async () => {
let body: unknown;
server.use(
http.post(apiPath('/auth/refresh'), async ({ request }) => {
body = await request.json();
return HttpResponse.json(tokenFixture('t2', ORG2_ID));
}),
);
applyTokenResponse(tokenFixture('t1', ORG2_ID));
expect(await refreshSession()).toBe(true);
expect(body).toEqual({ org_id: ORG2_ID });
expect(getAccessToken()).toBe('t2');
});
it('returns false and clears the token when there is no session', async () => {
setAccessToken('x');
expect(await refreshSession()).toBe(false);
expect(getAccessToken()).toBeNull();
});
});

Run: pnpm -C web test -- src/lib/api/client.test.ts — Expected: FAIL (module not found).

  • Step 12: Implement lib/api/client.ts
import i18n from 'i18next';
import createClient from 'openapi-fetch';
import type { paths } from './schema';
import type { TokenResponse } from './types';
// The access token lives in memory only (F7). A page load re-mints it through
// POST /auth/refresh, which rides on the HttpOnly refresh cookie.
let accessToken: string | null = null;
let currentOrgId: string | null = null;
let refreshing: Promise<boolean> | null = null;
const expiredListeners = new Set<() => void>();
export function setAccessToken(token: string | null): void {
accessToken = token;
if (token === null) currentOrgId = null;
}
export function getAccessToken(): string | null {
return accessToken;
}
/** Applies a login / refresh / switch-org answer: the token, and which organization it is for. */
export function applyTokenResponse(t: Pick<TokenResponse, 'access_token' | 'organization_id'>): void {
accessToken = t.access_token;
currentOrgId = t.organization_id;
}
export function onSessionExpired(cb: () => void): () => void {
expiredListeners.add(cb);
return () => {
expiredListeners.delete(cb);
};
}
export function newIdempotencyKey(): string {
return crypto.randomUUID();
}
const API_BASE = new URL('/v1', window.location.origin).toString();
/**
* Single-flight: every caller during one refresh shares its promise, so ten concurrent 401s
* cause one refresh. That matters beyond efficiency: refresh tokens are single-use, and a second
* concurrent rotation would look like a replay and revoke the whole session family.
* Resolves false when there is no valid session. Rejects only on a network failure, which is
* not a reason to sign anybody out.
*/
export function refreshSession(): Promise<boolean> {
refreshing ??= (async () => {
try {
const res = await globalThis.fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(currentOrgId ? { org_id: currentOrgId } : {}),
});
if (!res.ok) {
setAccessToken(null);
return false;
}
applyTokenResponse((await res.json()) as TokenResponse);
return true;
} finally {
refreshing = null;
}
})();
return refreshing;
}
// These issue or end credentials: a 401 from them is an answer, not an expired token.
const NO_REFRESH = new Set(['/auth/login', '/auth/refresh', '/auth/logout', '/auth/invitations/accept']);
function authorize(req: Request): void {
if (accessToken) req.headers.set('Authorization', `Bearer ${accessToken}`);
if (!req.headers.has('Accept-Language') && i18n.resolvedLanguage) {
req.headers.set('Accept-Language', i18n.resolvedLanguage);
}
}
function notifyExpired(): void {
setAccessToken(null);
expiredListeners.forEach((cb) => cb());
}
/**
* The fetch openapi-fetch calls. A custom fetch rather than an onResponse middleware because
* the retry must replay the request body, and only a clone taken before the first send has it.
*/
async function authFetch(input: Request): Promise<Response> {
const path = new URL(input.url).pathname.replace(/^\/v1/, '');
const replay = NO_REFRESH.has(path) ? null : input.clone();
authorize(input);
const sentWith = accessToken;
const res = await globalThis.fetch(input);
if (res.status !== 401 || !replay) return res;
// Another request may already have refreshed while this one was in flight.
const fresh = accessToken !== null && accessToken !== sentWith ? true : await refreshSession();
if (!fresh) {
notifyExpired();
return res;
}
authorize(replay);
const retried = await globalThis.fetch(replay);
if (retried.status === 401) notifyExpired();
return retried;
}
export const api = createClient<paths>({ baseUrl: API_BASE, fetch: authFetch });

Append to web/src/lib/api/types.ts:

export type TokenResponse = Schemas['TokenResponse'];
export type ProblemBody = Schemas['Problem'];

Verify: openapi-fetch’s fetch option is typed (input: Request) => Promise<Response> in the installed version (grep -n "fetch?:" web/node_modules/openapi-fetch/dist/index.d.ts). If it takes a second init argument, add _init?: RequestInit to authFetch and ignore it; input already carries method, headers and body.

  • Step 13: Query client, cn, providers, test render helper, i18n in tests

web/src/lib/api/query.ts:

import { QueryClient } from '@tanstack/react-query';
import { ProblemError } from './problem';
// Reads retry twice with backoff, never on a 4xx: those are answers, not glitches.
// Writes never auto-retry (spec §6); a retried write reuses its Idempotency-Key explicitly.
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: (failureCount, error) =>
!(error instanceof ProblemError && error.status < 500) && failureCount < 2,
},
mutations: { retry: 0 },
},
});

web/src/lib/cn.ts:

import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...c: ClassValue[]): string {
return twMerge(clsx(c));
}

web/src/app/providers.tsx (Task 8 adds the router and the toaster):

import { QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { queryClient } from '@/lib/api/query';
export function Providers({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

web/src/test/render.tsx:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, type RenderOptions } from '@testing-library/react';
import type { ReactElement } from 'react';
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: Infinity }, mutations: { retry: 0 } },
});
}
/** Renders with a fresh QueryClient (no retries), so tests never share a cache. */
export function renderWithQuery(ui: ReactElement, options?: RenderOptions & { client?: QueryClient }) {
const client = options?.client ?? createTestQueryClient();
return { client, ...render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>, options) };
}

In web/src/test/setup.ts, initialise i18n in English. Add the imports, and replace the existing beforeAll and afterEach with:

import i18n from 'i18next';
import { initI18n } from '@/lib/i18n';
beforeAll(async () => {
server.listen({ onUnhandledRequest: 'error' });
await initI18n(); // jsdom's navigator.languages is ['en-US'] → en
});
afterEach(async () => {
cleanup();
server.resetHandlers();
window.localStorage.clear();
if (i18n.language !== 'en') await i18n.changeLanguage('en');
});

In web/src/main.tsx, initialise i18n before the first render and add the providers. Add at the top:

import { Providers } from './app/providers';
import { initI18n } from './lib/i18n';

and replace the final block with:

const el = document.getElementById('root');
if (!el) throw new Error('#root is missing from index.html');
await initI18n();
createRoot(el).render(
<StrictMode>
<Providers>
<Root />
</Providers>
</StrictMode>,
);

(Top-level await is supported by Vite’s default browser targets.)

Run: pnpm -C web test -- src/lib — Expected: PASS (money, date, i18n, problem, client).

  • Step 14: Money components — failing test

web/src/components/money/money.test.tsx:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { MoneyInput } from './MoneyInput';
import { MoneyText } from './MoneyText';
describe('MoneyText', () => {
it('formats exactly', () => {
render(<MoneyText amount="9007199254740993.0000" currency="MYR" />);
expect(screen.getByText('RM 9,007,199,254,740,993.00')).toBeInTheDocument();
});
});
describe('MoneyInput', () => {
function Harness({ onChange }: { onChange: (v: string) => void }) {
const [v, setV] = useState('');
return (
<MoneyInput
id="amt"
currency="MYR"
value={v}
onChange={(x) => {
setV(x);
onChange(x);
}}
/>
);
}
it('keeps a string and rejects keystrokes that would make it invalid', async () => {
const onChange = vi.fn();
render(<Harness onChange={onChange} />);
const input = screen.getByRole('textbox');
await userEvent.type(input, '12a.34567');
expect(input).toHaveValue('12.3456');
expect(onChange).toHaveBeenLastCalledWith('12.3456');
});
it('accepts a pasted grouped amount', async () => {
const onChange = vi.fn();
render(<Harness onChange={onChange} />);
await userEvent.click(screen.getByRole('textbox'));
await userEvent.paste('1,234.50');
expect(onChange).toHaveBeenLastCalledWith('1234.50');
});
it('shows the currency and marks invalid', () => {
render(<MoneyInput currency="MYR" value="" onChange={() => {}} invalid />);
expect(screen.getByText('RM')).toBeInTheDocument();
expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true');
});
});

Run: pnpm -C web test -- src/components/money — Expected: FAIL (modules not found).

  • Step 15: Implement the money components

web/src/components/money/MoneyText.tsx:

import { cn } from '@/lib/cn';
import { useLocale } from '@/lib/i18n';
import { formatMoney } from '@/lib/money';
export function MoneyText(p: { amount: string; currency: string; sign?: 'auto' | 'always'; className?: string }) {
const [locale] = useLocale();
return (
<span className={cn('whitespace-nowrap tabular-nums', p.className)}>
{formatMoney(p.amount, p.currency, locale, { sign: p.sign })}
</span>
);
}

web/src/components/money/MoneyInput.tsx:

import type { ChangeEvent } from 'react';
import { cn } from '@/lib/cn';
import { isMoneyInput, normalizeMoneyInput } from '@/lib/money';
const SYMBOL: Record<string, string> = { MYR: 'RM' };
/** A string in, a string out, never a Number (spec §3.2). Sent to the API unchanged. */
export function MoneyInput(p: {
value: string;
onChange(v: string): void;
currency: string;
id?: string;
invalid?: boolean;
disabled?: boolean;
}) {
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const next = normalizeMoneyInput(e.target.value);
if (next === '' || isMoneyInput(next)) p.onChange(next);
};
return (
<div
className={cn(
'flex h-10 items-center gap-2 rounded-control border border-border bg-surface px-3',
'focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-focus',
p.invalid && 'border-tone-red-accent',
p.disabled && 'opacity-60',
)}
>
<span className="text-fg-muted">{SYMBOL[p.currency] ?? p.currency}</span>
<input
id={p.id}
type="text"
inputMode="decimal"
autoComplete="off"
className="min-w-0 flex-1 bg-transparent text-right tabular-nums outline-none"
value={p.value}
onChange={onChange}
aria-invalid={p.invalid || undefined}
disabled={p.disabled}
/>
</div>
);
}

Run: pnpm -C web test -- src/components/money — Expected: PASS.

  • Step 16: Full web checks

Run: pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test Expected: PASS, including all four drift tests. Runtime check: preview_start web; the blank page loads with no console errors, and document.documentElement.lang reads en (javascript_tool).

  • Step 17: Commit
Terminal window
git add web
git commit -m "$(cat <<'EOF'
Plan 10 Task 6: api client with single-flight refresh, problem, money, date, i18n
Access token in memory only; one refresh for N concurrent 401s, with the body
replayed on retry. Money is string/BigInt only and exact beyond 2^53. Dates in
Kuala Lumpur time. en/ms/zh namespaced bundles with a parity drift test,
watched failing once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"

Task 7: Design-system primitives and tokens from the Figma Components page

Section titled “Task 7: Design-system primitives and tokens from the Figma Components page”

Files:

  • Create, one component per file under web/src/components/ui/: Button.tsx, IconButton.tsx, Spinner.tsx, Input.tsx, Textarea.tsx, Label.tsx, Field.tsx, Select.tsx, Combobox.tsx, Checkbox.tsx, RadioCards.tsx, DatePicker.tsx, MonthPicker.tsx, Tabs.tsx, Dialog.tsx, Drawer.tsx, ConfirmDialog.tsx, Tooltip.tsx, Tag.tsx, CountBadge.tsx, StatCard.tsx, Card.tsx, DataTable.tsx, EmptyState.tsx, Skeleton.tsx, LoadMore.tsx, Toaster.tsx, ProgressBar.tsx, Kbd.tsx, PageHeader.tsx, Breadcrumb.tsx
  • Create tests: web/src/components/ui/Button.test.tsx, Tag.test.tsx, StatCard.test.tsx, DataTable.test.tsx, Dialog.test.tsx, Combobox.test.tsx
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/ui.json
  • Create: web/src/dev/ui.tsx (the /__ui gallery; dev builds only, through Task 5’s import.meta.glob)
  • Modify (only if the Figma pass needs a colour Task 5 lacks): web/src/styles/tokens.css

Interfaces:

  • Consumes: cn (@/lib/cn), useLocale, i18next namespaces (Task 6), formatMonth, monthKey (@/lib/date), the Tailwind tokens (Task 5).
  • Produces (registry): every component the registry lists for components/ui/, as a named export of the same name from @/components/ui/<Name>. Toaster.tsx exports both Toaster and toast (toast.success | error | info(msg)).
  • Produces (additions; see Part B notes): buttonVariants (Button.tsx, for styling a router Link as a button without asChild); type TagTone and a variant?: 'outline' | 'soft' prop (Tag.tsx; status chips are outline, category pills are soft, per Figma); TabPanel (Tabs.tsx); type Option = { value: string; label: string } (Select.tsx, reused by Combobox and MonthPicker); DataTable’s optional renderCard(row) for the <768 px card layout (spec §5.1) and selectedRowId; Spinner’s label?: string | false.

Ordering note: this task uses Task 6’s cn and i18n. It must start after Task 6 has merged, not in parallel with it (see Part B notes).

  • Step 1: Read the Figma Components page before writing JSX

Figma MCP, file Uz1j7iSQYOYdCLKTm6bRsT. The MCP times out on whole pages, so:

  1. get_metadata on node 0:1. From the returned tree, list the component frames: buttons (primary 提交报销 / 通过审批, outline, danger-outline 驳回), status chips (草稿 / 待审核 / 已批准 / 已付款 / 已拒绝), category pills, KPI cards, inputs and selects (所有项目, 2026 年 8 月), tabs (我的报销 / 我的审批 / 所有报销), the table header and row, the drawer header with ‹ n/N ›, radio payout cards, the progress bar.
  2. get_design_context on each of those frame ids, one at a time.
  3. Record, in the task report, each frame’s height, padding, radius, font size/weight and colour variable. Adjust the class strings below to match. Use only the Tailwind token names from Task 5. If Figma uses a colour that has no token, add a token to tokens.css (the one file allowed to contain a colour literal) for both light and dark and map it in @theme. Never put a colour literal in a component.

The class strings below are the starting point measured from the screenshots: buttons 40 px (32 px small) with 8 px radius, chips 24 px with 6 px radius, KPI cards 12 px radius, body 14/16.

  • Step 2: ui namespace strings

web/src/lib/i18n/locales/en/ui.json:

{
"close": "Close",
"loading": "Loading…",
"loadMore": "Load more",
"noMore": "That's everything",
"empty": "Nothing to show",
"breadcrumb": "Breadcrumb",
"select": "Select…",
"search": "Search…",
"noResults": "No matches",
"optional": "Optional",
"allMonths": "All months",
"progress": "Progress"
}

web/src/lib/i18n/locales/ms/ui.json:

{
"close": "Tutup",
"loading": "Memuatkan…",
"loadMore": "Muat lagi",
"noMore": "Itu sahaja",
"empty": "Tiada apa-apa untuk dipaparkan",
"breadcrumb": "Serbuk roti",
"select": "Pilih…",
"search": "Cari…",
"noResults": "Tiada padanan",
"optional": "Pilihan",
"allMonths": "Semua bulan",
"progress": "Kemajuan"
}

web/src/lib/i18n/locales/zh/ui.json:

{
"close": "关闭",
"loading": "加载中…",
"loadMore": "加载更多",
"noMore": "已全部加载",
"empty": "暂无数据",
"breadcrumb": "导航路径",
"select": "请选择…",
"search": "搜索…",
"noResults": "无匹配结果",
"optional": "选填",
"allMonths": "全部月份",
"progress": "进度"
}
  • Step 3: Write the failing component tests

web/src/components/ui/Button.test.tsx:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Button } from './Button';
describe('Button', () => {
it.each([
['primary', 'bg-primary'],
['secondary', 'bg-primary-soft'],
['outline', 'border-border'],
['ghost', 'hover:bg-bg-subtle'],
['danger', 'bg-tone-red-accent'],
['success-outline', 'border-tone-green-accent'],
['danger-outline', 'border-tone-red-accent'],
] as const)('%s variant uses its token classes', (variant, cls) => {
render(<Button variant={variant}>Go</Button>);
expect(screen.getByRole('button', { name: 'Go' })).toHaveClass(cls);
});
it('sizes', () => {
render(<Button size="sm">Small</Button>);
expect(screen.getByRole('button')).toHaveClass('h-8');
});
it('loading disables the button and marks it busy', async () => {
const onClick = vi.fn();
render(<Button loading onClick={onClick}>Save</Button>);
const b = screen.getByRole('button', { name: 'Save' });
expect(b).toBeDisabled();
expect(b).toHaveAttribute('aria-busy', 'true');
await userEvent.click(b);
expect(onClick).not.toHaveBeenCalled();
});
it('defaults to type=button so it never submits a form by accident', () => {
render(<Button>Plain</Button>);
expect(screen.getByRole('button')).toHaveAttribute('type', 'button');
});
});

web/src/components/ui/Tag.test.tsx:

import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Tag, type TagTone } from './Tag';
const TONES: TagTone[] = ['neutral', 'amber', 'green', 'green-solid', 'teal', 'purple', 'red', 'muted', 'blue'];
describe('Tag', () => {
it('each tone has its own classes, and text carries the meaning (not colour alone)', () => {
const seen = new Set<string>();
for (const tone of TONES) {
const { unmount } = render(<Tag tone={tone}>{tone}</Tag>);
const el = screen.getByText(tone);
expect(el.className).toContain(tone === 'green-solid' ? 'bg-tone-green-solid-soft' : `text-tone-${tone}-fg`);
seen.add(el.className);
unmount();
}
expect(seen.size).toBe(TONES.length);
});
it('soft variant fills the background', () => {
render(<Tag tone="amber" variant="soft">Meals</Tag>);
expect(screen.getByText('Meals')).toHaveClass('bg-tone-amber-soft');
});
});

web/src/components/ui/StatCard.test.tsx:

import { render, screen } from '@testing-library/react';
import { Clock } from 'lucide-react';
import { describe, expect, it } from 'vitest';
import { StatCard } from './StatCard';
describe('StatCard', () => {
it('shows label, primary and secondary figures in its tone', () => {
render(<StatCard tone="amber" label="Pending" primary="2" secondary="RM 242.55" icon={Clock} />);
const card = screen.getByRole('group', { name: 'Pending' });
expect(card).toHaveClass('bg-tone-amber-soft');
expect(card).toHaveTextContent('2');
expect(card).toHaveTextContent('RM 242.55');
});
it('is a button when clickable, with pressed state', () => {
render(<StatCard tone="green" label="Paid" primary="1" onClick={() => {}} active />);
expect(screen.getByRole('button', { name: /Paid/ })).toHaveAttribute('aria-pressed', 'true');
});
});

web/src/components/ui/DataTable.test.tsx:

import { createColumnHelper } from '@tanstack/react-table';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { DataTable } from './DataTable';
interface Row {
id: string;
title: string;
}
const col = createColumnHelper<Row>();
const columns = [col.accessor('title', { header: 'Title' })];
const data: Row[] = [
{ id: 'a', title: 'Team Coffee Run' },
{ id: 'b', title: 'Client Lunch' },
];
describe('DataTable', () => {
it('row click and keyboard both open the row', async () => {
const onRowClick = vi.fn();
render(<DataTable columns={columns} data={data} getRowId={(r) => r.id} onRowClick={onRowClick} />);
const rows = screen.getAllByRole('row').slice(1); // skip the header row
await userEvent.click(within(rows[0]!).getByText('Team Coffee Run'));
expect(onRowClick).toHaveBeenLastCalledWith(data[0]);
rows[1]!.focus();
await userEvent.keyboard('{Enter}');
expect(onRowClick).toHaveBeenLastCalledWith(data[1]);
await userEvent.keyboard(' ');
expect(onRowClick).toHaveBeenCalledTimes(3);
});
it('marks the selected row', () => {
render(<DataTable columns={columns} data={data} getRowId={(r) => r.id} selectedRowId="b" onRowClick={() => {}} />);
const rows = screen.getAllByRole('row').slice(1);
expect(rows[0]).toHaveAttribute('aria-selected', 'false');
expect(rows[1]).toHaveAttribute('aria-selected', 'true');
});
it('shows the empty state, and a busy table while loading', () => {
const { rerender } = render(<DataTable columns={columns} data={[]} empty={<p>No claims yet</p>} />);
expect(screen.getByText('No claims yet')).toBeInTheDocument();
rerender(<DataTable columns={columns} data={[]} loading />);
expect(screen.getByRole('table')).toHaveAttribute('aria-busy', 'true');
});
});

web/src/components/ui/Dialog.test.tsx:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Button } from './Button';
import { Dialog } from './Dialog';
describe('Dialog', () => {
it('is named by its title, traps focus, and closes on Esc', async () => {
const onOpenChange = vi.fn();
render(
<>
<button>outside</button>
<Dialog
open
onOpenChange={onOpenChange}
title="Request float"
description="How much do you need?"
footer={<Button>Submit</Button>}
>
<input aria-label="Amount" />
</Dialog>
</>,
);
const dialog = await screen.findByRole('dialog');
expect(dialog).toHaveAccessibleName('Request float');
expect(dialog).toHaveAccessibleDescription('How much do you need?');
for (let i = 0; i < 6; i++) {
await userEvent.tab();
expect(dialog).toContainElement(document.activeElement as HTMLElement);
}
await userEvent.keyboard('{Escape}');
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});

web/src/components/ui/Combobox.test.tsx:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Combobox } from './Combobox';
const options = [
{ value: 'm1', label: 'Aisyah Rahman' },
{ value: 'm2', label: 'Siti Nurhaliza' },
{ value: 'm3', label: 'Tan Wei Ming' },
];
describe('Combobox', () => {
it('filters as you type and picks with the keyboard', async () => {
const onChange = vi.fn();
render(<Combobox aria-label="Delegate" options={options} onChange={onChange} />);
await userEvent.click(screen.getByRole('button', { name: 'Delegate' }));
const input = await screen.findByRole('combobox');
await userEvent.type(input, 'si');
expect(screen.getAllByRole('option')).toHaveLength(1);
await userEvent.keyboard('{ArrowDown}{Enter}');
expect(onChange).toHaveBeenCalledWith('m2');
});
});

Run: pnpm -C web test -- src/components/ui — Expected: FAIL (modules not found).

  • Step 4: Spinner, Button, IconButton

web/src/components/ui/Spinner.tsx:

import { Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/cn';
/** label=false when a parent already announces the busy state (e.g. a loading Button). */
export function Spinner({ size = 'md', label, className }: { size?: 'sm' | 'md' | 'lg'; label?: string | false; className?: string }) {
const { t } = useTranslation('ui');
const px = { sm: 'size-4', md: 'size-5', lg: 'size-8' }[size];
const icon = <Loader2 aria-hidden className={cn('animate-spin text-current', px, className)} />;
if (label === false) return icon;
return (
<span role="status" className="inline-flex items-center">
{icon}
<span className="sr-only">{label ?? t('loading')}</span>
</span>
);
}

web/src/components/ui/Button.tsx:

import { cva, type VariantProps } from 'class-variance-authority';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/cn';
import { Spinner } from './Spinner';
export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-control font-medium tracking-ui transition-colors ' +
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus ' +
'disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-primary text-primary-fg hover:bg-primary-hover',
secondary: 'bg-primary-soft text-primary hover:bg-primary-soft/70',
outline: 'border border-border bg-surface text-fg hover:bg-bg-subtle',
ghost: 'text-fg hover:bg-bg-subtle',
danger: 'bg-tone-red-accent text-primary-fg hover:opacity-90',
'success-outline': 'border border-tone-green-accent bg-surface text-tone-green-fg hover:bg-tone-green-soft',
'danger-outline': 'border border-tone-red-accent bg-surface text-tone-red-fg hover:bg-tone-red-soft',
},
size: { sm: 'h-8 px-3 text-caption', md: 'h-10 px-4 text-body' },
},
defaultVariants: { variant: 'primary', size: 'md' },
},
);
export type ButtonProps = ComponentProps<'button'> & VariantProps<typeof buttonVariants> & { loading?: boolean };
export function Button({ variant, size, loading, disabled, className, children, type = 'button', ...rest }: ButtonProps) {
return (
<button
type={type}
className={cn(buttonVariants({ variant, size }), className)}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...rest}
>
{loading && <Spinner size="sm" label={false} />}
{children}
</button>
);
}

web/src/components/ui/IconButton.tsx:

import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/cn';
import { Button, type ButtonProps } from './Button';
/** An icon-only button. `label` is its accessible name (Accessibility floor). */
export function IconButton({
icon: Icon,
label,
variant = 'ghost',
size = 'md',
className,
...rest
}: Omit<ButtonProps, 'children'> & { icon: LucideIcon; label: string }) {
return (
<Button
variant={variant}
size={size}
aria-label={label}
title={label}
className={cn(size === 'sm' ? 'w-8 px-0' : 'w-10 px-0', className)}
{...rest}
>
<Icon aria-hidden className="size-4" />
</Button>
);
}
  • Step 5: Form controls — Input, Textarea, Label, Field, Checkbox, DatePicker

web/src/components/ui/Input.tsx:

import type { ComponentProps } from 'react';
import { cn } from '@/lib/cn';
export const controlClass =
'h-10 w-full rounded-control border border-border bg-surface px-3 text-body text-fg placeholder:text-fg-muted ' +
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus ' +
'disabled:cursor-not-allowed disabled:opacity-60 aria-invalid:border-tone-red-accent';
export function Input({ className, invalid, ...rest }: ComponentProps<'input'> & { invalid?: boolean }) {
return <input className={cn(controlClass, className)} aria-invalid={invalid || undefined} {...rest} />;
}

web/src/components/ui/Textarea.tsx:

import type { ComponentProps } from 'react';
import { cn } from '@/lib/cn';
import { controlClass } from './Input';
export function Textarea({ className, invalid, rows = 3, ...rest }: ComponentProps<'textarea'> & { invalid?: boolean }) {
return (
<textarea
rows={rows}
className={cn(controlClass, 'h-auto py-2', className)}
aria-invalid={invalid || undefined}
{...rest}
/>
);
}

web/src/components/ui/Label.tsx:

import { Label as LabelPrimitive } from 'radix-ui';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/cn';
export function Label({ className, ...rest }: ComponentProps<typeof LabelPrimitive.Root>) {
return <LabelPrimitive.Root className={cn('text-body font-medium text-fg', className)} {...rest} />;
}

web/src/components/ui/Field.tsx:

import { cloneElement, isValidElement, useId, type ReactElement, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Label } from './Label';
type ControlProps = { id?: string; 'aria-describedby'?: string; 'aria-invalid'?: boolean; invalid?: boolean };
/** Label + control + hint + error. Wires the control's id, aria-describedby and invalid state. */
export function Field({
label,
hint,
error,
optional,
children,
id: idProp,
}: {
label: ReactNode;
hint?: ReactNode;
error?: string;
optional?: boolean;
children: ReactElement<ControlProps>;
id?: string;
}) {
const { t } = useTranslation('ui');
const auto = useId();
const id = idProp ?? children.props.id ?? auto;
const hintId = hint ? `${id}-hint` : undefined;
const errorId = error ? `${id}-error` : undefined;
const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined;
const control = isValidElement(children)
? cloneElement(children, { id, 'aria-describedby': describedBy, ...(error ? { invalid: true } : {}) })
: children;
return (
<div className="flex flex-col gap-1.5">
<Label htmlFor={id}>
{label}
{optional && <span className="ml-1 text-caption text-fg-muted">({t('optional')})</span>}
</Label>
{control}
{hint && (
<p id={hintId} className="text-caption text-fg-muted">
{hint}
</p>
)}
{error && (
<p id={errorId} role="alert" className="text-caption text-tone-red-fg">
{error}
</p>
)}
</div>
);
}

web/src/components/ui/Checkbox.tsx:

import { Check } from 'lucide-react';
import { Checkbox as CheckboxPrimitive } from 'radix-ui';
import { useId, type ReactNode } from 'react';
import { cn } from '@/lib/cn';
export function Checkbox({
checked,
onCheckedChange,
label,
id: idProp,
disabled,
}: {
checked: boolean;
onCheckedChange(v: boolean): void;
label?: ReactNode;
id?: string;
disabled?: boolean;
}) {
const auto = useId();
const id = idProp ?? auto;
return (
<span className="inline-flex items-center gap-2">
<CheckboxPrimitive.Root
id={id}
checked={checked}
disabled={disabled}
onCheckedChange={(v) => onCheckedChange(v === true)}
className={cn(
'flex size-4 items-center justify-center rounded-[4px] border border-border bg-surface',
'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-fg',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus disabled:opacity-50',
)}
>
<CheckboxPrimitive.Indicator>
<Check aria-hidden className="size-3" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
{label && (
<label htmlFor={id} className="text-body text-fg">
{label}
</label>
)}
</span>
);
}

web/src/components/ui/DatePicker.tsx:

import { Input } from './Input';
/** ISO date (YYYY-MM-DD) in and out. The native control is keyboard- and screen-reader-accessible. */
export function DatePicker({
value,
onChange,
id,
min,
max,
invalid,
disabled,
'aria-label': ariaLabel,
}: {
value: string | undefined;
onChange(v: string | undefined): void;
id?: string;
min?: string;
max?: string;
invalid?: boolean;
disabled?: boolean;
'aria-label'?: string;
}) {
return (
<Input
type="date"
id={id}
value={value ?? ''}
min={min}
max={max}
invalid={invalid}
disabled={disabled}
aria-label={ariaLabel}
onChange={(e) => onChange(e.target.value || undefined)}
/>
);
}
  • Step 6: Select, MonthPicker, Combobox, RadioCards, Tabs

web/src/components/ui/Select.tsx:

import { Check, ChevronDown } from 'lucide-react';
import { Select as SelectPrimitive } from 'radix-ui';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/cn';
export interface Option {
value: string;
label: string;
}
export function Select({
value,
onValueChange,
options,
placeholder,
id,
disabled,
invalid,
className,
'aria-label': ariaLabel,
}: {
value: string | undefined;
onValueChange(v: string): void;
options: Option[];
placeholder?: string;
id?: string;
disabled?: boolean;
invalid?: boolean;
className?: string;
'aria-label'?: string;
}) {
const { t } = useTranslation('ui');
return (
<SelectPrimitive.Root value={value} onValueChange={onValueChange} disabled={disabled}>
<SelectPrimitive.Trigger
id={id}
aria-label={ariaLabel}
aria-invalid={invalid || undefined}
className={cn(
'flex h-10 min-w-40 items-center justify-between gap-2 rounded-control border border-border bg-surface px-3 text-body text-fg',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus',
'data-[placeholder]:text-fg-muted aria-invalid:border-tone-red-accent disabled:opacity-60',
className,
)}
>
<SelectPrimitive.Value placeholder={placeholder ?? t('select')} />
<SelectPrimitive.Icon>
<ChevronDown aria-hidden className="size-4 text-fg-muted" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
position="popper"
sideOffset={4}
className="z-50 max-h-72 min-w-[var(--radix-select-trigger-width)] overflow-hidden rounded-control border border-border bg-surface-raised shadow-card"
>
<SelectPrimitive.Viewport className="p-1">
{options.map((o) => (
<SelectPrimitive.Item
key={o.value}
value={o.value}
className="flex cursor-pointer items-center justify-between gap-2 rounded-chip px-2 py-2 text-body text-fg outline-none data-[highlighted]:bg-primary-soft"
>
<SelectPrimitive.ItemText>{o.label}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator>
<Check aria-hidden className="size-4 text-primary" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
);
}

web/src/components/ui/MonthPicker.tsx:

import { useTranslation } from 'react-i18next';
import { formatMonth, monthKey } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import { Select, type Option } from './Select';
const ALL = '__all';
/** The last `months` months (Kuala Lumpur calendar), newest first, as YYYY-MM. */
export function recentMonths(months: number, from: string = monthKey()): string[] {
let y = Number.parseInt(from.slice(0, 4), 10);
let m = Number.parseInt(from.slice(5, 7), 10);
const out: string[] = [];
for (let i = 0; i < months; i++) {
out.push(`${y}-${String(m).padStart(2, '0')}`);
m -= 1;
if (m === 0) {
m = 12;
y -= 1;
}
}
return out;
}
/** A month select ("2026 年 8 月"). `allowAll` adds an "All months" choice that maps to undefined. */
export function MonthPicker({
value,
onChange,
months = 24,
allowAll = false,
id,
'aria-label': ariaLabel,
}: {
value: string | undefined;
onChange(v: string | undefined): void;
months?: number;
allowAll?: boolean;
id?: string;
'aria-label'?: string;
}) {
const { t } = useTranslation('ui');
const [locale] = useLocale();
const options: Option[] = [
...(allowAll ? [{ value: ALL, label: t('allMonths') }] : []),
...recentMonths(months).map((k) => ({ value: k, label: formatMonth(k, locale) })),
];
return (
<Select
id={id}
aria-label={ariaLabel}
value={value ?? (allowAll ? ALL : undefined)}
options={options}
onValueChange={(v) => onChange(v === ALL ? undefined : v)}
/>
);
}

web/src/components/ui/Combobox.tsx:

import { Check, ChevronDown } from 'lucide-react';
import { Popover } from 'radix-ui';
import { useEffect, useId, useState, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/cn';
import type { Option } from './Select';
import { Spinner } from './Spinner';
/**
* Searchable single select. Pass `options` to filter locally, or `loadOptions` to ask the server
* (debounced 200 ms). `selectedLabel` names the current value when it is not in the loaded list.
*/
export function Combobox({
value,
onChange,
options,
loadOptions,
selectedLabel,
placeholder,
id,
disabled,
'aria-label': ariaLabel,
}: {
value?: string;
onChange(v: string | undefined): void;
options?: Option[];
loadOptions?(query: string): Promise<Option[]>;
selectedLabel?: string;
placeholder?: string;
id?: string;
disabled?: boolean;
'aria-label'?: string;
}) {
const { t } = useTranslation('ui');
const listId = useId();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [items, setItems] = useState<Option[]>(options ?? []);
const [active, setActive] = useState(0);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open) return;
if (!loadOptions) {
const q = query.trim().toLowerCase();
setItems((options ?? []).filter((o) => o.label.toLowerCase().includes(q)));
setActive(0);
return;
}
let cancelled = false;
setLoading(true);
const timer = setTimeout(() => {
loadOptions(query.trim())
.then((r) => !cancelled && (setItems(r), setActive(0)))
.finally(() => !cancelled && setLoading(false));
}, 200);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [open, query, options, loadOptions]);
const current = options?.find((o) => o.value === value)?.label ?? selectedLabel;
const pick = (o: Option) => {
onChange(o.value);
setOpen(false);
setQuery('');
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive((i) => Math.min(i + 1, items.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const o = items[active];
if (o) pick(o);
}
};
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger
id={id}
disabled={disabled}
aria-label={ariaLabel}
className={cn(
'flex h-10 w-full items-center justify-between gap-2 rounded-control border border-border bg-surface px-3 text-left text-body',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus disabled:opacity-60',
!current && 'text-fg-muted',
)}
>
<span className="truncate">{current ?? placeholder ?? t('select')}</span>
<ChevronDown aria-hidden className="size-4 text-fg-muted" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="start"
sideOffset={4}
className="z-50 w-[var(--radix-popover-trigger-width)] rounded-control border border-border bg-surface-raised p-1 shadow-card"
>
<input
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={items[active] ? `${listId}-${active}` : undefined}
aria-label={ariaLabel ?? t('search')}
autoFocus
value={query}
placeholder={t('search')}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
className="mb-1 h-9 w-full rounded-chip border border-border-subtle bg-surface px-2 text-body outline-none"
/>
<ul id={listId} role="listbox" className="max-h-64 overflow-auto">
{loading && (
<li className="flex justify-center p-2">
<Spinner size="sm" />
</li>
)}
{!loading && items.length === 0 && <li className="p-2 text-body text-fg-muted">{t('noResults')}</li>}
{!loading &&
items.map((o, i) => (
<li
key={o.value}
id={`${listId}-${i}`}
role="option"
aria-selected={o.value === value}
onMouseEnter={() => setActive(i)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => pick(o)}
className={cn(
'flex cursor-pointer items-center justify-between rounded-chip px-2 py-2 text-body',
i === active && 'bg-primary-soft',
)}
>
{o.label}
{o.value === value && <Check aria-hidden className="size-4 text-primary" />}
</li>
))}
</ul>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

web/src/components/ui/RadioCards.tsx:

import type { LucideIcon } from 'lucide-react';
import { RadioGroup } from 'radix-ui';
import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
export interface RadioCardItem {
value: string;
title: ReactNode;
description?: ReactNode;
icon?: LucideIcon;
disabled?: boolean;
}
/** The payout-method cards of the submit wizard's step 3 (Figma 166:11488). */
export function RadioCards({
value,
onValueChange,
items,
'aria-label': ariaLabel,
className,
}: {
value: string | undefined;
onValueChange(v: string): void;
items: RadioCardItem[];
'aria-label': string;
className?: string;
}) {
return (
<RadioGroup.Root
value={value}
onValueChange={onValueChange}
aria-label={ariaLabel}
className={cn('grid gap-3 sm:grid-cols-2', className)}
>
{items.map(({ value: v, title, description, icon: Icon, disabled }) => (
<RadioGroup.Item
key={v}
value={v}
disabled={disabled}
className={cn(
'flex items-start gap-3 rounded-card border border-border bg-surface p-4 text-left',
'data-[state=checked]:border-primary data-[state=checked]:bg-primary-soft',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus disabled:opacity-50',
)}
>
{Icon && <Icon aria-hidden className="mt-0.5 size-5 text-primary" />}
<span className="flex flex-col gap-1">
<span className="text-body font-bold text-fg">{title}</span>
{description && <span className="text-caption text-fg-muted">{description}</span>}
</span>
</RadioGroup.Item>
))}
</RadioGroup.Root>
);
}

web/src/components/ui/Tabs.tsx:

import type { LucideIcon } from 'lucide-react';
import { Tabs as TabsPrimitive } from 'radix-ui';
import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
export interface TabItem {
value: string;
label: ReactNode;
icon?: LucideIcon;
}
/** Underlined page tabs (我的报销 / 我的审批 / 所有报销). Put TabPanel children inside. */
export function Tabs({
value,
onValueChange,
items,
children,
'aria-label': ariaLabel,
}: {
value: string;
onValueChange(v: string): void;
items: TabItem[];
children?: ReactNode;
'aria-label'?: string;
}) {
return (
<TabsPrimitive.Root value={value} onValueChange={onValueChange}>
<TabsPrimitive.List aria-label={ariaLabel} className="flex gap-6 border-b border-border-subtle">
{items.map(({ value: v, label, icon: Icon }) => (
<TabsPrimitive.Trigger
key={v}
value={v}
className={cn(
'-mb-px flex items-center gap-2 border-b-2 border-transparent px-1 pb-2 text-body text-fg-muted',
'data-[state=active]:border-primary data-[state=active]:font-medium data-[state=active]:text-primary',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus',
)}
>
{Icon && <Icon aria-hidden className="size-4" />}
{label}
</TabsPrimitive.Trigger>
))}
</TabsPrimitive.List>
{children}
</TabsPrimitive.Root>
);
}
export const TabPanel = TabsPrimitive.Content;
  • Step 7: Overlays — Dialog, Drawer, ConfirmDialog, Tooltip

web/src/components/ui/Dialog.tsx:

import { X } from 'lucide-react';
import { Dialog as DialogPrimitive } from 'radix-ui';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/cn';
const SIZES = { sm: 'md:max-w-sm', md: 'md:max-w-lg', lg: 'md:max-w-3xl' } as const;
/** Centred modal; full screen below 768 px (spec §5.1). Radix traps focus and closes on Esc. */
export function Dialog({
open,
onOpenChange,
title,
description,
footer,
children,
size = 'md',
}: {
open: boolean;
onOpenChange(open: boolean): void;
title: ReactNode;
description?: ReactNode;
footer?: ReactNode;
children?: ReactNode;
size?: keyof typeof SIZES;
}) {
const { t } = useTranslation('ui');
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-overlay" />
<DialogPrimitive.Content
{...(description ? {} : { 'aria-describedby': undefined })}
className={cn(
'fixed z-50 flex flex-col bg-surface text-fg shadow-card',
'inset-0 md:inset-auto md:top-1/2 md:left-1/2 md:max-h-[85dvh] md:w-full md:-translate-x-1/2 md:-translate-y-1/2 md:rounded-card',
SIZES[size],
)}
>
<header className="flex items-start justify-between gap-4 border-b border-border-subtle px-6 py-4">
<div className="flex flex-col gap-1">
<DialogPrimitive.Title className="text-subtitle font-medium">{title}</DialogPrimitive.Title>
{description && (
<DialogPrimitive.Description className="text-caption text-fg-muted">{description}</DialogPrimitive.Description>
)}
</div>
<DialogPrimitive.Close
aria-label={t('close')}
className="rounded-control p-1 text-fg-muted hover:bg-bg-subtle focus-visible:outline-2 focus-visible:outline-focus"
>
<X aria-hidden className="size-4" />
</DialogPrimitive.Close>
</header>
<div className="flex-1 overflow-auto px-6 py-4">{children}</div>
{footer && <footer className="flex justify-end gap-3 border-t border-border-subtle px-6 py-4">{footer}</footer>}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}

Radix wires the description automatically when DialogPrimitive.Description renders. When there is none, it warns unless aria-describedby is passed explicitly as undefined, which is what the spread does.

web/src/components/ui/Drawer.tsx:

import { X } from 'lucide-react';
import { Dialog as DialogPrimitive } from 'radix-ui';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
/**
* Right-side drawer over the list (claim detail, Figma 278:24022). The header is sticky and
* holds whatever the caller passes (title, ‹ n/N › pager). `title` is the accessible name.
* Full screen below 768 px.
*/
export function Drawer({
open,
onOpenChange,
title,
description,
header,
footer,
children,
}: {
open: boolean;
onOpenChange(open: boolean): void;
title: string;
description?: string;
header?: ReactNode;
footer?: ReactNode;
children: ReactNode;
}) {
const { t } = useTranslation('ui');
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-overlay" />
<DialogPrimitive.Content
{...(description ? {} : { 'aria-describedby': undefined })}
className="fixed inset-y-0 right-0 z-50 flex w-full flex-col bg-surface text-fg shadow-drawer md:w-[520px]"
>
<header className="sticky top-0 z-10 flex items-start justify-between gap-3 border-b border-border-subtle bg-bg-subtle px-4 py-3">
<div className="min-w-0 flex-1">
<DialogPrimitive.Title className="text-subtitle font-bold">{title}</DialogPrimitive.Title>
{description && (
<DialogPrimitive.Description className="text-caption text-fg-muted">{description}</DialogPrimitive.Description>
)}
</div>
{header}
<DialogPrimitive.Close
aria-label={t('close')}
className="rounded-full p-1 text-fg-muted hover:bg-border-subtle focus-visible:outline-2 focus-visible:outline-focus"
>
<X aria-hidden className="size-4" />
</DialogPrimitive.Close>
</header>
<div className="flex-1 overflow-auto p-4">{children}</div>
{footer && <footer className="sticky bottom-0 border-t border-border-subtle bg-surface p-4">{footer}</footer>}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}

web/src/components/ui/ConfirmDialog.tsx:

import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from './Button';
import { Dialog } from './Dialog';
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel,
cancelLabel,
tone = 'primary',
loading,
onConfirm,
}: {
open: boolean;
onOpenChange(open: boolean): void;
title: ReactNode;
description?: ReactNode;
confirmLabel: string;
cancelLabel?: string;
tone?: 'primary' | 'danger';
loading?: boolean;
onConfirm(): void;
}) {
const { t } = useTranslation('common');
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
size="sm"
title={title}
description={description}
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
{cancelLabel ?? t('actions.cancel')}
</Button>
<Button variant={tone === 'danger' ? 'danger' : 'primary'} loading={loading} onClick={onConfirm}>
{confirmLabel}
</Button>
</>
}
/>
);
}

web/src/components/ui/Tooltip.tsx:

import { Tooltip as TooltipPrimitive } from 'radix-ui';
import type { ReactElement, ReactNode } from 'react';
/** Wraps one focusable child. A disabled button cannot be hovered or focused: wrap it in a span with tabIndex={0}. */
export function Tooltip({
content,
children,
side = 'top',
}: {
content: ReactNode;
children: ReactElement;
side?: 'top' | 'right' | 'bottom' | 'left';
}) {
return (
<TooltipPrimitive.Provider delayDuration={200}>
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
side={side}
sideOffset={6}
className="z-50 max-w-xs rounded-control bg-fg px-3 py-2 text-caption text-bg shadow-card"
>
{content}
<TooltipPrimitive.Arrow className="fill-fg" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
</TooltipPrimitive.Provider>
);
}
  • Step 8: Display — Tag, CountBadge, StatCard, Card, EmptyState, Skeleton, ProgressBar, Kbd, PageHeader, Breadcrumb

web/src/components/ui/Tag.tsx:

import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
export type TagTone = 'neutral' | 'amber' | 'green' | 'green-solid' | 'teal' | 'purple' | 'red' | 'muted' | 'blue';
// Full class strings, spelled out, so Tailwind's scanner sees every one.
const OUTLINE: Record<TagTone, string> = {
neutral: 'border-tone-neutral-accent bg-surface text-tone-neutral-fg',
amber: 'border-tone-amber-accent bg-surface text-tone-amber-fg',
green: 'border-tone-green-accent bg-surface text-tone-green-fg',
'green-solid': 'border-tone-green-solid-accent bg-tone-green-solid-soft text-tone-green-solid-fg',
teal: 'border-tone-teal-accent bg-surface text-tone-teal-fg',
purple: 'border-tone-purple-accent bg-surface text-tone-purple-fg',
red: 'border-tone-red-accent bg-surface text-tone-red-fg',
muted: 'border-tone-muted-accent bg-surface text-tone-muted-fg',
blue: 'border-tone-blue-accent bg-surface text-tone-blue-fg',
};
const SOFT: Record<TagTone, string> = {
neutral: 'border-tone-neutral-accent bg-tone-neutral-soft text-tone-neutral-fg',
amber: 'border-tone-amber-accent bg-tone-amber-soft text-tone-amber-fg',
green: 'border-tone-green-accent bg-tone-green-soft text-tone-green-fg',
'green-solid': 'border-tone-green-solid-accent bg-tone-green-solid-soft text-tone-green-solid-fg',
teal: 'border-tone-teal-accent bg-tone-teal-soft text-tone-teal-fg',
purple: 'border-tone-purple-accent bg-tone-purple-soft text-tone-purple-fg',
red: 'border-tone-red-accent bg-tone-red-soft text-tone-red-fg',
muted: 'border-tone-muted-accent bg-tone-muted-soft text-tone-muted-fg',
blue: 'border-tone-blue-accent bg-tone-blue-soft text-tone-blue-fg',
};
/** Status chip (outline, Figma 草稿/待审核/…) or category pill (soft). Always carries text. */
export function Tag({
tone,
variant = 'outline',
children,
className,
}: {
tone: TagTone;
variant?: 'outline' | 'soft';
children: ReactNode;
className?: string;
}) {
return (
<span
className={cn(
'inline-flex h-6 items-center whitespace-nowrap rounded-chip border px-2 text-caption',
(variant === 'soft' ? SOFT : OUTLINE)[tone],
className,
)}
>
{children}
</span>
);
}

web/src/components/ui/CountBadge.tsx:

import { cn } from '@/lib/cn';
/** A row count beside a filter chip or tab (never money). */
export function CountBadge({ count, active, className }: { count: number; active?: boolean; className?: string }) {
return (
<span
className={cn(
'inline-flex h-5 min-w-5 items-center justify-center rounded-[4px] px-1 text-caption tabular-nums',
active ? 'bg-primary text-primary-fg' : 'bg-primary-soft text-primary',
className,
)}
>
{count}
</span>
);
}

web/src/components/ui/StatCard.tsx:

import type { LucideIcon } from 'lucide-react';
import { useId, type ReactNode } from 'react';
import { cn } from '@/lib/cn';
import type { TagTone } from './Tag';
type StatTone = Exclude<TagTone, 'green-solid' | 'muted'>;
const TONE: Record<StatTone, { card: string; dot: string; label: string; icon: string }> = {
neutral: { card: 'bg-tone-neutral-soft border-tone-neutral-accent/40', dot: 'bg-tone-neutral-accent', label: 'text-tone-neutral-fg', icon: 'text-tone-neutral-fg' },
amber: { card: 'bg-tone-amber-soft border-tone-amber-accent/40', dot: 'bg-tone-amber-accent', label: 'text-tone-amber-fg', icon: 'text-tone-amber-accent' },
green: { card: 'bg-tone-green-soft border-tone-green-accent/40', dot: 'bg-tone-green-accent', label: 'text-tone-green-fg', icon: 'text-tone-green-accent' },
teal: { card: 'bg-tone-teal-soft border-tone-teal-accent/40', dot: 'bg-tone-teal-accent', label: 'text-tone-teal-fg', icon: 'text-tone-teal-accent' },
purple: { card: 'bg-tone-purple-soft border-tone-purple-accent/40', dot: 'bg-tone-purple-accent', label: 'text-tone-purple-fg', icon: 'text-tone-purple-accent' },
red: { card: 'bg-tone-red-soft border-tone-red-accent/40', dot: 'bg-tone-red-accent', label: 'text-tone-red-fg', icon: 'text-tone-red-accent' },
blue: { card: 'bg-tone-blue-soft border-tone-blue-accent/40', dot: 'bg-tone-blue-accent', label: 'text-tone-blue-fg', icon: 'text-tone-blue-accent' },
};
/** A KPI card (待审核 / 已批准 / 已付款 / 总提交). Every figure is passed in; nothing is computed here. */
export function StatCard({
tone,
label,
primary,
secondary,
icon: Icon,
onClick,
active,
className,
}: {
tone: StatTone;
label: string;
primary: ReactNode;
secondary?: ReactNode;
icon?: LucideIcon;
onClick?(): void;
active?: boolean;
className?: string;
}) {
const labelId = useId();
const s = TONE[tone];
const body = (
<>
<div className="flex flex-col gap-2">
<span id={labelId} className={cn('flex items-center gap-2 text-body', s.label)}>
<span aria-hidden className={cn('size-1.5 rounded-full', s.dot)} />
{label}
</span>
<span className="text-subtitle font-bold text-fg">{primary}</span>
{secondary !== undefined && <span className="text-body text-fg">{secondary}</span>}
</div>
{Icon && <Icon aria-hidden className={cn('size-7 shrink-0', s.icon)} />}
</>
);
const classes = cn(
'flex items-start justify-between gap-4 rounded-card border p-4 text-left',
s.card,
active && 'ring-2 ring-focus',
className,
);
return onClick ? (
<button type="button" className={classes} onClick={onClick} aria-pressed={active ?? false}>
{body}
</button>
) : (
<div role="group" aria-labelledby={labelId} className={classes}>
{body}
</div>
);
}

web/src/components/ui/Card.tsx:

import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
export function Card({
title,
actions,
children,
className,
}: {
title?: ReactNode;
actions?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section className={cn('rounded-card border border-border-subtle bg-surface p-4 shadow-card', className)}>
{(title || actions) && (
<header className="mb-3 flex items-center justify-between gap-3">
{title && <h2 className="text-body font-bold text-fg">{title}</h2>}
{actions}
</header>
)}
{children}
</section>
);
}

web/src/components/ui/EmptyState.tsx:

import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
export function EmptyState({
icon: Icon,
title,
description,
action,
}: {
icon?: LucideIcon;
title: ReactNode;
description?: ReactNode;
action?: ReactNode;
}) {
return (
<div className="flex flex-col items-center gap-2 px-4 py-10 text-center">
{Icon && <Icon aria-hidden className="size-8 text-fg-muted" />}
<p className="text-body font-bold text-fg">{title}</p>
{description && <p className="max-w-sm text-caption text-fg-muted">{description}</p>}
{action && <div className="mt-2">{action}</div>}
</div>
);
}

web/src/components/ui/Skeleton.tsx:

import { cn } from '@/lib/cn';
export function Skeleton({ className }: { className?: string }) {
return <span aria-hidden className={cn('block animate-pulse rounded-chip bg-border-subtle', className)} />;
}

web/src/components/ui/ProgressBar.tsx:

import { cn } from '@/lib/cn';
/** `value` is a percentage 0–100 of an upload or a step count — never money. */
export function ProgressBar({ value, label, className }: { value: number; label: string; className?: string }) {
const pct = Math.min(100, Math.max(0, value));
return (
<div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
className={cn('h-1.5 w-full overflow-hidden rounded-full bg-border-subtle', className)}
>
<div className="h-full rounded-full bg-primary transition-[width]" style={{ width: `${pct}%` }} />
</div>
);
}

web/src/components/ui/Kbd.tsx:

import type { ReactNode } from 'react';
export function Kbd({ children }: { children: ReactNode }) {
return (
<kbd className="rounded-[4px] border border-border bg-bg-subtle px-1.5 py-0.5 font-sans text-caption text-fg-muted">
{children}
</kbd>
);
}

web/src/components/ui/PageHeader.tsx:

import type { ReactNode } from 'react';
/** "我的报销" + "本月共 5 笔报销 · 2 笔待审核" + actions (Figma 123:7183). */
export function PageHeader({ title, subtitle, actions }: { title: ReactNode; subtitle?: ReactNode; actions?: ReactNode }) {
return (
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-subtitle font-bold text-fg">{title}</h1>
{subtitle && <p className="mt-0.5 text-caption text-fg-muted">{subtitle}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
);
}

web/src/components/ui/Breadcrumb.tsx:

import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
/** "REQUESTS / Expenses Claims". The last item is the current page. */
export function Breadcrumb({ items }: { items: { label: string }[] }) {
const { t } = useTranslation('ui');
return (
<nav aria-label={t('breadcrumb')}>
<ol className="flex items-center gap-1 text-caption">
{items.map((it, i) => {
const last = i === items.length - 1;
return (
<Fragment key={`${i}-${it.label}`}>
{i > 0 && (
<li aria-hidden className="text-fg-muted">
/
</li>
)}
<li aria-current={last ? 'page' : undefined} className={last ? 'text-fg' : 'uppercase text-fg-muted'}>
{it.label}
</li>
</Fragment>
);
})}
</ol>
</nav>
);
}
  • Step 9: DataTable, LoadMore, Toaster

web/src/components/ui/DataTable.tsx:

import { flexRender, getCoreRowModel, useReactTable, type ColumnDef } from '@tanstack/react-table';
import type { KeyboardEvent, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/cn';
import { EmptyState } from './EmptyState';
import { Skeleton } from './Skeleton';
export interface DataTableProps<T> {
// TanStack column helpers produce ColumnDef<T, string | number | …>; `any` is the library's own idiom here.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
columns: ColumnDef<T, any>[];
data: T[];
getRowId?(row: T): string;
onRowClick?(row: T): void;
loading?: boolean;
empty?: ReactNode;
selectedRowId?: string;
caption?: string;
/** Below 768 px, rows render as cards (Figma 94:27703) when this is given. */
renderCard?(row: T): ReactNode;
}
export function DataTable<T>({
columns,
data,
getRowId,
onRowClick,
loading,
empty,
selectedRowId,
caption,
renderCard,
}: DataTableProps<T>) {
const { t } = useTranslation('ui');
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: getRowId ? (r) => getRowId(r) : undefined,
});
const rows = table.getRowModel().rows;
const onKey = (e: KeyboardEvent, row: T) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onRowClick?.(row);
}
};
const isEmpty = !loading && rows.length === 0;
return (
<div className="overflow-hidden rounded-card border border-border-subtle bg-surface">
<table aria-busy={loading || undefined} className={cn('w-full border-collapse text-body', renderCard && 'max-md:hidden')}>
{caption && <caption className="sr-only">{caption}</caption>}
<thead className="bg-bg-subtle">
{table.getHeaderGroups().map((hg) => (
<tr key={hg.id}>
{hg.headers.map((h) => (
<th key={h.id} scope="col" className="px-4 py-3 text-left text-caption font-medium text-fg-muted">
{h.isPlaceholder ? null : flexRender(h.column.columnDef.header, h.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{loading &&
Array.from({ length: 3 }, (_, i) => (
<tr key={`sk-${i}`} className="border-t border-border-subtle">
{columns.map((_c, j) => (
<td key={j} className="px-4 py-3">
<Skeleton className="h-4 w-3/4" />
</td>
))}
</tr>
))}
{!loading &&
rows.map((r) => (
<tr
key={r.id}
aria-selected={onRowClick ? r.id === selectedRowId : undefined}
tabIndex={onRowClick ? 0 : undefined}
onClick={onRowClick ? () => onRowClick(r.original) : undefined}
onKeyDown={onRowClick ? (e) => onKey(e, r.original) : undefined}
className={cn(
'border-t border-border-subtle',
onRowClick && 'cursor-pointer hover:bg-bg-subtle focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus',
r.id === selectedRowId && 'bg-primary-soft',
)}
>
{r.getVisibleCells().map((c) => (
<td key={c.id} className="px-4 py-3 align-middle">
{flexRender(c.column.columnDef.cell, c.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
{renderCard && !loading && (
<ul className="divide-y divide-border-subtle md:hidden">
{rows.map((r) => (
<li key={r.id}>
<button
type="button"
className="w-full p-4 text-left focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus"
onClick={() => onRowClick?.(r.original)}
aria-current={r.id === selectedRowId || undefined}
>
{renderCard(r.original)}
</button>
</li>
))}
</ul>
)}
{isEmpty && (empty ?? <EmptyState title={t('empty')} />)}
</div>
);
}

web/src/components/ui/LoadMore.tsx:

import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from './Button';
/** Keyset pagination's "Load more" (F10). `auto` also loads when the button scrolls into view. */
export function LoadMore({
hasMore,
loading,
onLoadMore,
auto = false,
}: {
hasMore: boolean;
loading: boolean;
onLoadMore(): void;
auto?: boolean;
}) {
const { t } = useTranslation('ui');
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
const el = ref.current;
if (!auto || !hasMore || loading || !el || typeof IntersectionObserver === 'undefined') return;
const io = new IntersectionObserver((entries) => entries.some((e) => e.isIntersecting) && onLoadMore());
io.observe(el);
return () => io.disconnect();
}, [auto, hasMore, loading, onLoadMore]);
if (!hasMore) return null;
return (
<div className="flex justify-center py-4">
<Button ref={ref} variant="outline" loading={loading} onClick={onLoadMore}>
{t('loadMore')}
</Button>
</div>
);
}

web/src/components/ui/Toaster.tsx:

import { toast as sonner, Toaster as Sonner } from 'sonner';
/** Mounted once in the app providers (Task 8). */
export function Toaster() {
return <Sonner position="top-right" richColors closeButton />;
}
// A narrow facade so call sites never depend on sonner's wider API.
export const toast = {
success: (msg: string) => sonner.success(msg),
error: (msg: string) => sonner.error(msg),
info: (msg: string) => sonner.info(msg),
};

Run: pnpm -C web test -- src/components/ui — Expected: PASS (Button, Tag, StatCard, DataTable, Dialog, Combobox).

Verify:

  • radix-ui exports the namespaces used here (Dialog, Tooltip, Tabs, Select, Checkbox, RadioGroup, Popover, Label): node -e "const r=require('radix-ui');console.log(['Dialog','Tooltip','Tabs','Select','Checkbox','RadioGroup','Popover','Label'].map(k=>k+':'+!!r[k]).join(' '))" run in web/. If a name differs, import it under the name the package uses and keep the component’s exported name unchanged.

  • Radix may log a missing-Description warning in tests when a dialog has no description. That is expected; the aria-describedby spread keeps it quiet in the browser.

  • Step 10: Watch one test fail for the right reason

In Tag.tsx, change the amber outline entry to reuse the neutral classes. Run pnpm -C web test -- src/components/ui/Tag.test.tsx. Expected FAIL: the tone loop’s text-tone-amber-fg assertion (and the distinct-classes count). Restore.

  • Step 11: The /__ui gallery (dev only)

web/src/dev/ui.tsx — a visual check page, not product UI. It is excluded from production builds by Task 5’s import.meta.env.DEV guard, and its sample text is literal on purpose (it demonstrates components, it is not a screen a user sees):

import { createColumnHelper } from '@tanstack/react-table';
import { CheckCircle2, Clock, FileText, Wallet } from 'lucide-react';
import { useState } from 'react';
import { MoneyInput } from '@/components/money/MoneyInput';
import { MoneyText } from '@/components/money/MoneyText';
import { Breadcrumb } from '@/components/ui/Breadcrumb';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Checkbox } from '@/components/ui/Checkbox';
import { Combobox } from '@/components/ui/Combobox';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { CountBadge } from '@/components/ui/CountBadge';
import { DataTable } from '@/components/ui/DataTable';
import { DatePicker } from '@/components/ui/DatePicker';
import { Dialog } from '@/components/ui/Dialog';
import { Drawer } from '@/components/ui/Drawer';
import { EmptyState } from '@/components/ui/EmptyState';
import { Field } from '@/components/ui/Field';
import { IconButton } from '@/components/ui/IconButton';
import { Input } from '@/components/ui/Input';
import { Kbd } from '@/components/ui/Kbd';
import { LoadMore } from '@/components/ui/LoadMore';
import { MonthPicker } from '@/components/ui/MonthPicker';
import { PageHeader } from '@/components/ui/PageHeader';
import { ProgressBar } from '@/components/ui/ProgressBar';
import { RadioCards } from '@/components/ui/RadioCards';
import { Select } from '@/components/ui/Select';
import { Skeleton } from '@/components/ui/Skeleton';
import { Spinner } from '@/components/ui/Spinner';
import { StatCard } from '@/components/ui/StatCard';
import { Tabs } from '@/components/ui/Tabs';
import { Tag, type TagTone } from '@/components/ui/Tag';
import { Textarea } from '@/components/ui/Textarea';
import { toast, Toaster } from '@/components/ui/Toaster';
import { Tooltip } from '@/components/ui/Tooltip';
type Row = { id: string; title: string; supplier: string; status: TagTone; label: string; amount: string };
const rows: Row[] = [
{ id: '1', title: '团队咖啡跑', supplier: 'Zus咖啡', status: 'neutral', label: '草稿', amount: '68.40' },
{ id: '2', title: '客户午餐', supplier: "Nando's", status: 'amber', label: '待审核', amount: '210.55' },
{ id: '3', title: 'Parking', supplier: 'KLCC', status: 'green-solid', label: '已付款', amount: '12' },
{ id: '4', title: 'Office Supplies', supplier: 'Calculator', status: 'red', label: '已拒绝', amount: '45.9' },
];
const col = createColumnHelper<Row>();
const columns = [
col.accessor('title', {
header: 'Title',
cell: (c) => (
<div>
<div>{c.getValue()}</div>
<div className="text-caption text-fg-muted">{c.row.original.supplier}</div>
</div>
),
}),
col.accessor('label', { header: 'Status', cell: (c) => <Tag tone={c.row.original.status}>{c.getValue()}</Tag> }),
col.accessor('amount', { header: 'Amount', cell: (c) => <MoneyText amount={c.getValue()} currency="MYR" className="font-bold" /> }),
];
const TONES: TagTone[] = ['neutral', 'amber', 'green', 'green-solid', 'teal', 'purple', 'red', 'muted', 'blue'];
export default function UiGallery() {
const [dark, setDark] = useState(false);
const [dialog, setDialog] = useState(false);
const [drawer, setDrawer] = useState(false);
const [confirm, setConfirm] = useState(false);
const [tab, setTab] = useState('mine');
const [money, setMoney] = useState('');
const [month, setMonth] = useState<string | undefined>();
const [date, setDate] = useState<string | undefined>();
const [sel, setSel] = useState<string | undefined>();
const [pick, setPick] = useState<string | undefined>();
const [radio, setRadio] = useState<string | undefined>('bank');
const [checked, setChecked] = useState(false);
const [selected, setSelected] = useState<string | undefined>();
return (
<div data-theme={dark ? 'dark' : 'light'} className="min-h-dvh bg-bg p-6 text-fg">
<Toaster />
<div className="mx-auto flex max-w-5xl flex-col gap-8">
<PageHeader
title="UI gallery"
subtitle="Every primitive in components/ui, in the current theme"
actions={<Button variant="outline" onClick={() => setDark((d) => !d)}>Toggle dark</Button>}
/>
<Breadcrumb items={[{ label: 'Requests' }, { label: 'Expenses Claims' }]} />
<Card title="Buttons">
<div className="flex flex-wrap gap-3">
{(['primary', 'secondary', 'outline', 'ghost', 'danger', 'success-outline', 'danger-outline'] as const).map((v) => (
<Button key={v} variant={v}>{v}</Button>
))}
<Button size="sm">small</Button>
<Button loading>loading</Button>
<IconButton icon={FileText} label="Open receipt" variant="outline" />
<Tooltip content="Only the approver can do this">
<span tabIndex={0}><Button disabled>disabled with tooltip</Button></span>
</Tooltip>
<Button variant="secondary" onClick={() => toast.success('Saved')}>toast</Button>
</div>
</Card>
<Card title="Tags and counts">
<div className="flex flex-wrap gap-2">
{TONES.map((t) => <Tag key={t} tone={t}>{t}</Tag>)}
{TONES.map((t) => <Tag key={`s-${t}`} tone={t} variant="soft">{t}</Tag>)}
<CountBadge count={6} active />
<CountBadge count={2} />
</div>
</Card>
<div className="grid gap-4 md:grid-cols-4">
<StatCard tone="amber" label="待审核" primary="2" secondary={<MoneyText amount="242.55" currency="MYR" />} icon={Clock} />
<StatCard tone="green" label="已批准" primary="1" secondary={<MoneyText amount="165.9" currency="MYR" />} icon={CheckCircle2} />
<StatCard tone="green" label="已付款" primary="1" secondary={<MoneyText amount="100" currency="MYR" />} icon={Wallet} />
<StatCard tone="blue" label="总提交" primary="5" secondary={<MoneyText amount="1000.45" currency="MYR" />} icon={FileText} onClick={() => {}} active />
</div>
<Tabs value={tab} onValueChange={setTab} items={[{ value: 'mine', label: '我的报销' }, { value: 'to_act', label: '我的审批' }, { value: 'all', label: '所有报销' }]} />
<DataTable
columns={columns}
data={rows}
getRowId={(r) => r.id}
selectedRowId={selected}
onRowClick={(r) => setSelected(r.id)}
renderCard={(r) => <div className="flex justify-between"><span>{r.title}</span><Tag tone={r.status}>{r.label}</Tag></div>}
/>
<DataTable columns={columns} data={[]} loading />
<DataTable columns={columns} data={[]} empty={<EmptyState icon={FileText} title="No claims yet" description="Your claims will show here." />} />
<LoadMore hasMore loading={false} onLoadMore={() => toast.info('load more')} />
<Card title="Form controls">
<div className="grid gap-4 md:grid-cols-2">
<Field label="Title" hint="English or Bahasa Malaysia"><Input placeholder="Client lunch" /></Field>
<Field label="Amount" error="Enter an amount such as 1234.50"><MoneyInput value={money} onChange={setMoney} currency="MYR" /></Field>
<Field label="Note" optional><Textarea /></Field>
<Field label="Project"><Select value={sel} onValueChange={setSel} options={[{ value: 'a', label: 'Project Alpha' }, { value: 'b', label: 'Project Beta' }]} /></Field>
<Field label="Month"><MonthPicker value={month} onChange={setMonth} allowAll /></Field>
<Field label="Date"><DatePicker value={date} onChange={setDate} /></Field>
<Field label="Delegate"><Combobox value={pick} onChange={setPick} options={[{ value: 'm1', label: 'Aisyah Rahman' }, { value: 'm2', label: 'Siti Nurhaliza' }]} /></Field>
<Checkbox checked={checked} onCheckedChange={setChecked} label="Include archived claims" />
</div>
<RadioCards
className="mt-4"
aria-label="Payout method"
value={radio}
onValueChange={setRadio}
items={[
{ value: 'bank', title: 'Bank transfer', description: 'Maybank ••••1234', icon: Wallet },
{ value: 'cash', title: 'Cash', description: 'From the petty-cash float' },
]}
/>
</Card>
<Card title="Feedback">
<div className="flex flex-col gap-3">
<ProgressBar value={42} label="Uploading receipt_01.jpg" />
<div className="flex items-center gap-3"><Spinner /><Skeleton className="h-4 w-40" /><Kbd>←</Kbd><Kbd>→</Kbd></div>
</div>
</Card>
<div className="flex gap-3">
<Button onClick={() => setDialog(true)}>Dialog</Button>
<Button onClick={() => setDrawer(true)}>Drawer</Button>
<Button variant="danger-outline" onClick={() => setConfirm(true)}>Confirm</Button>
</div>
</div>
<Dialog open={dialog} onOpenChange={setDialog} title="申请备用金" description="Request a float top-up" footer={<Button onClick={() => setDialog(false)}>Submit</Button>}>
<Field label="Amount"><MoneyInput value={money} onChange={setMoney} currency="MYR" /></Field>
</Dialog>
<Drawer open={drawer} onOpenChange={setDrawer} title="报销详情" description="查看报销的详细信息与审批进度" header={<span className="text-caption">‹ 3/7 ›</span>}>
<p>Drawer body</p>
</Drawer>
<ConfirmDialog open={confirm} onOpenChange={setConfirm} title="Withdraw this draft?" confirmLabel="Withdraw" tone="danger" onConfirm={() => setConfirm(false)} />
</div>
);
}
  • Step 12: Runtime check

preview_start web, navigate to http://localhost:5173/__ui. At 1440 wide: screenshot; compare buttons, chips, KPI cards and the table with components.png and claims-list.png from the Figma pass. Toggle dark: compare with frame 477:16079. resize_window to mobile: the table becomes cards; the Dialog and Drawer go full screen. Keyboard: Tab through the page; every control shows the focus ring; open the Dialog, Tab stays inside, Esc closes it. read_console_messages with onlyErrors: none. Record the checks in the task report, then resize_window desktop.

  • Step 13: Full web checks and commit

Run: pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test Expected: PASS, including the hex drift test (no colour literal slipped into a component).

Terminal window
git add web
git commit -m "$(cat <<'EOF'
Plan 10 Task 7: design-system primitives from the Figma Components page
Radix-backed, token-styled components/ui (buttons, chips, KPI cards, table
with card layout below 768px, dialog, drawer, selects, combobox, toasts) and
a dev-only /__ui gallery for visual checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"

Task 8: Auth screens, app shell, nav, org switcher, activity; dev stack and seed

Section titled “Task 8: Auth screens, app shell, nav, org switcher, activity; dev stack and seed”

Files:

  • Create: web/src/features/auth/session.tsx, web/src/features/auth/session.test.tsx
  • Create: web/src/features/auth/LoginPage.tsx, web/src/features/auth/LoginPage.test.tsx
  • Create: web/src/features/auth/AcceptInvitationPage.tsx, web/src/features/auth/AcceptInvitationPage.test.tsx
  • Create: web/src/features/auth/HomePage.tsx, web/src/features/auth/routes.tsx
  • Create: web/src/features/activity/useActivityFeed.ts, web/src/features/activity/ActivityPage.tsx, web/src/features/activity/ActivityPage.test.tsx, web/src/features/activity/routes.tsx
  • Create: web/src/app/layouts.tsx, web/src/app/routes.ts, web/src/app/router.tsx, web/src/app/RootErrorBoundary.tsx, web/src/app/RootErrorBoundary.test.tsx, web/src/app/theme.ts
  • Create: web/src/app/shell/nav.ts, AppShell.tsx, Sidebar.tsx, Sidebar.test.tsx, TopBar.tsx, OrgSwitcher.tsx, OrgSwitcher.test.tsx, UserMenu.tsx (all under web/src/app/shell/)
  • Create: web/src/test/renderWithRouter.tsx
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/{auth,shell,activity}.json
  • Modify: web/src/app/App.tsx, web/src/app/providers.tsx, web/src/main.tsx, web/src/lib/api/types.ts (append)
  • Create: scripts/dev-stack.sh, web/e2e/seed.ts, web/e2e/README.md
  • Modify: .claude/launch.json (append api), Makefile (append dev-stack)

Interfaces:

  • Consumes: api, applyTokenResponse, setAccessToken, getAccessToken, refreshSession, onSessionExpired (@/lib/api/client); unwrap, toProblemError, ProblemError, problemMessage (@/lib/api/problem); queryClient; useLocale, SUPPORTED_LOCALES; formatDateTime, relativeTime; components/ui/* (Task 7); renderWithQuery, createTestQueryClient, meFixture, tokenFixture, apiPath, ORG_ID, ORG2_ID (Task 6). API: POST /auth/login, /auth/refresh, /auth/logout, /auth/switch-org, /auth/invitations/accept, GET /me (Task 1), GET /activity; seed also uses /org/roles, /org/permissions (Task 2), /org/chart, /org/positions, /org/invitations, /org/members/{id}/positions, /expenses/groups, /pettycash/floats.
  • Produces (registry, exactly): useMe, useCan, RequirePermission, useSwitchOrg, useSignOut — all from @/features/auth/session; NavGroup, NavItem, navItems from @/app/shell/nav.
  • Produces (additions; see Part B notes): meQueryOptions(), hasPermission(perms, p), safeNext(next) (@/features/auth/session); userMenuLinks (append-only, @/app/shell/nav); rootRoute, publicRoute, appRoute (@/app/layouts) — the parents every feature’s routes attach to; routes + publicRoutes convention (@/app/routes); router (@/app/router); ThemePref, readThemePref, applyTheme, useThemePref (@/app/theme); renderWithRouter (@/test/renderWithRouter); route staticData.crumb (an i18n key) for breadcrumbs deeper than the nav item.

How a later feature adds a screen (P1, P2):

web/src/features/claims/routes.tsx
import { createRoute } from '@tanstack/react-router';
import { appRoute } from '@/app/layouts';
const claimsRoute = createRoute({ getParentRoute: () => appRoute, path: 'claims', component: ClaimsPage, validateSearch: claimSearchSchema });
export const routes = [claimsRoute];
// web/src/app/routes.ts — one import and one spread per feature
// web/src/app/shell/nav.ts — one navItems entry per feature

Facts this task relies on (checked):

  • POST /auth/login → TokenResponse with organizations[]; 403 with type ending totp-required asks for code, ending no-organization means no active membership; 401 is identical for a wrong password and an unknown address; 429 carries Retry-After.

  • POST /auth/invitations/accept { token, legal_name, password? } → 201 AcceptedInvitation { organization_id, member_id, email, created_login }, and does not sign in. 404 unknown token, 409 already accepted or already a member, 410 expired or revoked, 422 missing field or CJK legal name.

  • POST /org/invitations returns IssuedInvitation { invitation, link, token } — the raw token, once. So the seed can accept every invitation it issues through the public API. orgadmin create-org prints organization <id> and invitation <link> on stdout, and the link is PUBLIC_BASE_URL + /invitations/accept?token=… (invite.AcceptLink). The web route is therefore /invitations/accept.

  • GET /activity pages by keyset (before_occurred_at + before_id, limit ≤ 100) and requires activity.read.

  • Provisioning seeds five roles (Employee, Approver, Finance, Admin, Owner); only Owner has grants. A Malaysian organization gets its expense categories seeded. The expense_claim workflow’s grants are: reviewed → the claimant’s superior, approved → the unit head, and category_verified / completed / disputed → the root OWNER seat.

  • api needs DATABASE_URL, JWT_KEYS, PUBLIC_BASE_URL, PAYOUT_DETAILS_KEY, OBJECT_STORE_*; the health probe is GET /v1/health.

  • Step 1: i18n namespaces for this task

web/src/lib/i18n/locales/en/auth.json:

{
"login": {
"title": "Sign in",
"subtitle": "Claims, advances and petty cash in one place",
"email": "Email",
"password": "Password",
"code": "Authentication code",
"codeHint": "Enter the 6-digit code from your authenticator app.",
"submit": "Sign in",
"invalid": "Email or password is incorrect.",
"noOrganization": "Your account is not an active member of any organization. Ask your administrator for an invitation.",
"pickOrg": "Choose an organization",
"pickOrgHint": "You belong to more than one organization. You can switch later from the sidebar.",
"continue": "Continue"
},
"invite": {
"title": "Join your organization",
"subtitle": "Set up your account to accept the invitation.",
"legalName": "Full legal name",
"legalNameHint": "As on your IC or passport, in English or Bahasa Malaysia.",
"password": "Password",
"passwordHint": "Required for a new account. If you already have an account, your password stays unchanged.",
"submit": "Accept invitation",
"missingToken": "This invitation link is incomplete. Open the full link you were sent.",
"notFound": "This invitation link is not valid. Ask for a new one.",
"alreadyAccepted": "This invitation has already been used. Sign in instead.",
"expired": "This invitation has expired or was withdrawn. Ask for a new one.",
"doneTitle": "You're in",
"doneBody": "Sign in as {{email}} to continue.",
"signIn": "Go to sign in"
},
"forbidden": {
"title": "You don't have access to this page",
"body": "Ask your administrator if you need it."
},
"home": {
"noModulesTitle": "Nothing to show yet",
"noModulesBody": "Your role has no access to any module yet. Ask your administrator to grant one."
}
}

web/src/lib/i18n/locales/ms/auth.json:

{
"login": {
"title": "Log masuk",
"subtitle": "Tuntutan, pendahuluan dan wang runcit di satu tempat",
"email": "E-mel",
"password": "Kata laluan",
"code": "Kod pengesahan",
"codeHint": "Masukkan kod 6 digit daripada aplikasi pengesah anda.",
"submit": "Log masuk",
"invalid": "E-mel atau kata laluan tidak betul.",
"noOrganization": "Akaun anda bukan ahli aktif mana-mana organisasi. Minta jemputan daripada pentadbir anda.",
"pickOrg": "Pilih organisasi",
"pickOrgHint": "Anda ahli lebih daripada satu organisasi. Anda boleh bertukar kemudian dari bar sisi.",
"continue": "Teruskan"
},
"invite": {
"title": "Sertai organisasi anda",
"subtitle": "Sediakan akaun anda untuk menerima jemputan.",
"legalName": "Nama penuh mengikut undang-undang",
"legalNameHint": "Seperti dalam MyKad atau pasport, dalam bahasa Inggeris atau Bahasa Malaysia.",
"password": "Kata laluan",
"passwordHint": "Diperlukan untuk akaun baharu. Jika anda sudah mempunyai akaun, kata laluan anda kekal.",
"submit": "Terima jemputan",
"missingToken": "Pautan jemputan ini tidak lengkap. Buka pautan penuh yang dihantar kepada anda.",
"notFound": "Pautan jemputan ini tidak sah. Minta pautan baharu.",
"alreadyAccepted": "Jemputan ini telah digunakan. Sila log masuk.",
"expired": "Jemputan ini telah tamat tempoh atau ditarik balik. Minta pautan baharu.",
"doneTitle": "Anda telah menyertai",
"doneBody": "Log masuk sebagai {{email}} untuk meneruskan.",
"signIn": "Pergi ke log masuk"
},
"forbidden": {
"title": "Anda tiada akses ke halaman ini",
"body": "Hubungi pentadbir anda jika anda memerlukannya."
},
"home": {
"noModulesTitle": "Belum ada apa-apa untuk dipaparkan",
"noModulesBody": "Peranan anda belum mempunyai akses ke mana-mana modul. Minta pentadbir anda memberikannya."
}
}

web/src/lib/i18n/locales/zh/auth.json:

{
"login": {
"title": "登录",
"subtitle": "报销、预支与备用金,一站管理",
"email": "邮箱",
"password": "密码",
"code": "验证码",
"codeHint": "请输入身份验证器应用中的 6 位验证码。",
"submit": "登录",
"invalid": "邮箱或密码不正确。",
"noOrganization": "您的账户尚未加入任何组织。请联系管理员发送邀请。",
"pickOrg": "选择组织",
"pickOrgHint": "您属于多个组织,之后可在侧边栏切换。",
"continue": "继续"
},
"invite": {
"title": "加入您的组织",
"subtitle": "设置账户以接受邀请。",
"legalName": "法定全名",
"legalNameHint": "与身份证或护照一致,请使用英文或马来文。",
"password": "密码",
"passwordHint": "新账户必填。如您已有账户,密码保持不变。",
"submit": "接受邀请",
"missingToken": "邀请链接不完整,请打开您收到的完整链接。",
"notFound": "邀请链接无效,请索取新的链接。",
"alreadyAccepted": "此邀请已被使用,请直接登录。",
"expired": "此邀请已过期或已被撤回,请索取新的链接。",
"doneTitle": "已成功加入",
"doneBody": "请使用 {{email}} 登录以继续。",
"signIn": "前往登录"
},
"forbidden": {
"title": "您无权访问此页面",
"body": "如有需要,请联系管理员。"
},
"home": {
"noModulesTitle": "暂无可显示的内容",
"noModulesBody": "您的角色尚未获得任何模块的权限,请联系管理员开通。"
}
}

web/src/lib/i18n/locales/en/shell.json:

{
"groups": { "workspace": "Workspace", "requests": "Requests", "approvals": "Approvals", "manage": "Manage" },
"nav": { "activity": "Activity" },
"menu": {
"open": "Open navigation",
"account": "Account menu",
"language": "Language",
"theme": "Theme",
"themeLight": "Light",
"themeDark": "Dark",
"themeSystem": "System",
"signOut": "Sign out"
},
"locales": { "en": "English", "ms": "Bahasa Malaysia", "zh": "中文" },
"org": { "switch": "Switch organization", "switchFailed": "Couldn't switch organization." },
"sidebar": { "collapse": "Collapse sidebar", "expand": "Expand sidebar", "nav": "Main navigation" },
"skip": "Skip to content",
"error": {
"title": "Something went wrong",
"body": "The page couldn't be shown. Reload to try again. If it keeps happening, send the request ID below to support.",
"requestId": "Request ID",
"notFoundTitle": "Page not found",
"notFoundBody": "The page doesn't exist, or you don't have access to it.",
"home": "Go to home"
}
}

web/src/lib/i18n/locales/ms/shell.json:

{
"groups": { "workspace": "Ruang kerja", "requests": "Permohonan", "approvals": "Kelulusan", "manage": "Urus" },
"nav": { "activity": "Aktiviti" },
"menu": {
"open": "Buka navigasi",
"account": "Menu akaun",
"language": "Bahasa",
"theme": "Tema",
"themeLight": "Cerah",
"themeDark": "Gelap",
"themeSystem": "Sistem",
"signOut": "Log keluar"
},
"locales": { "en": "English", "ms": "Bahasa Malaysia", "zh": "中文" },
"org": { "switch": "Tukar organisasi", "switchFailed": "Tidak dapat menukar organisasi." },
"sidebar": { "collapse": "Kecilkan bar sisi", "expand": "Besarkan bar sisi", "nav": "Navigasi utama" },
"skip": "Langkau ke kandungan",
"error": {
"title": "Berlaku ralat",
"body": "Halaman tidak dapat dipaparkan. Muat semula untuk cuba lagi. Jika berulang, hantar ID permintaan di bawah kepada sokongan.",
"requestId": "ID permintaan",
"notFoundTitle": "Halaman tidak ditemui",
"notFoundBody": "Halaman ini tidak wujud, atau anda tiada akses kepadanya.",
"home": "Ke laman utama"
}
}

web/src/lib/i18n/locales/zh/shell.json:

{
"groups": { "workspace": "工作区", "requests": "申请", "approvals": "审批", "manage": "管理" },
"nav": { "activity": "动态" },
"menu": {
"open": "打开导航",
"account": "账户菜单",
"language": "语言",
"theme": "主题",
"themeLight": "浅色",
"themeDark": "深色",
"themeSystem": "跟随系统",
"signOut": "退出登录"
},
"locales": { "en": "English", "ms": "Bahasa Malaysia", "zh": "中文" },
"org": { "switch": "切换组织", "switchFailed": "无法切换组织。" },
"sidebar": { "collapse": "收起侧边栏", "expand": "展开侧边栏", "nav": "主导航" },
"skip": "跳到正文",
"error": {
"title": "出现错误",
"body": "页面无法显示,请重新加载。如问题持续,请将下方的请求编号发送给技术支持。",
"requestId": "请求编号",
"notFoundTitle": "页面不存在",
"notFoundBody": "该页面不存在,或您无权访问。",
"home": "返回首页"
}
}

web/src/lib/i18n/locales/en/activity.json:

{ "title": "Activity", "subtitle": "What happened in this organization, newest first", "empty": "No activity yet", "system": "System" }

web/src/lib/i18n/locales/ms/activity.json:

{ "title": "Aktiviti", "subtitle": "Apa yang berlaku dalam organisasi ini, terkini dahulu", "empty": "Belum ada aktiviti", "system": "Sistem" }

web/src/lib/i18n/locales/zh/activity.json:

{ "title": "动态", "subtitle": "本组织的最新动态", "empty": "暂无动态", "system": "系统" }

Run: pnpm -C web test -- src/drift/i18nparity.test.ts — Expected: PASS.

  • Step 2: Router test helper

web/src/test/renderWithRouter.tsx:

import { QueryClientProvider, type QueryClient } from '@tanstack/react-query';
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
Outlet,
RouterProvider,
type AnyRouter,
} from '@tanstack/react-router';
import { render } from '@testing-library/react';
import type { ComponentType } from 'react';
import { createTestQueryClient } from './render';
/**
* Renders `Component` at `path` inside a real TanStack router (memory history) and a fresh
* QueryClient. Every path matches the same component (a splat route), so a test asserts
* navigation through `router.state.location`.
*/
export function renderWithRouter(Component: ComponentType, { path = '/', client }: { path?: string; client?: QueryClient } = {}) {
const rootRoute = createRootRoute({ component: Outlet });
const splat = createRoute({ getParentRoute: () => rootRoute, path: '$', component: Component });
const index = createRoute({ getParentRoute: () => rootRoute, path: '/', component: Component });
const router: AnyRouter = createRouter({
routeTree: rootRoute.addChildren([index, splat]),
history: createMemoryHistory({ initialEntries: [path] }),
});
const qc = client ?? createTestQueryClient();
const utils = render(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
return { ...utils, router, client: qc };
}
  • Step 3: Session hooks — failing tests

web/src/features/auth/session.test.tsx:

import { useQueryClient } from '@tanstack/react-query';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { getAccessToken, setAccessToken } from '@/lib/api/client';
import { ORG2_ID, tokenFixture } from '@/test/msw/fixtures';
import { apiPath } from '@/test/msw/handlers';
import { server } from '@/test/msw/server';
import { renderWithRouter } from '@/test/renderWithRouter';
import { hasPermission, RequirePermission, safeNext, useCan, useSwitchOrg } from './session';
describe('hasPermission / useCan', () => {
it('is any-of', () => {
expect(hasPermission(['a', 'b'], 'a')).toBe(true);
expect(hasPermission(['a'], ['x', 'a'])).toBe(true);
expect(hasPermission(['a'], ['x', 'y'])).toBe(false);
expect(hasPermission(['a'], undefined)).toBe(true); // an item with no permission is open to all
});
it('reads /me', async () => {
function Probe() {
const yes = useCan('activity.read');
const no = useCan(['accounting.ledger.read', 'reports.read']);
return <p>{`${yes}/${no}`}</p>;
}
renderWithRouter(Probe);
expect(await screen.findByText('true/false')).toBeInTheDocument();
});
});
describe('RequirePermission', () => {
it('shows the children to a holder and a 403 panel to anyone else', async () => {
renderWithRouter(() => (
<>
<RequirePermission permission="activity.read">
<p>feed</p>
</RequirePermission>
<RequirePermission permission="accounting.ledger.read">
<p>ledger</p>
</RequirePermission>
</>
));
expect(await screen.findByText('feed')).toBeInTheDocument();
expect(screen.queryByText('ledger')).not.toBeInTheDocument();
expect(screen.getByText("You don't have access to this page")).toBeInTheDocument();
});
});
describe('useSwitchOrg', () => {
it('mints a token for the other org, clears every cached query, and goes home', async () => {
let body: unknown;
server.use(
http.post(apiPath('/auth/switch-org'), async ({ request }) => {
body = await request.json();
return HttpResponse.json(tokenFixture('org2-token', ORG2_ID));
}),
);
setAccessToken('t');
function Probe() {
const switchOrg = useSwitchOrg();
const qc = useQueryClient();
return (
<button onClick={() => { qc.setQueryData(['claims'], [1]); void switchOrg(ORG2_ID); }}>switch</button>
);
}
const { router, client } = renderWithRouter(Probe, { path: '/claims' });
await userEvent.click(await screen.findByRole('button', { name: 'switch' }));
await waitFor(() => expect(router.state.location.pathname).toBe('/'));
expect(body).toEqual({ org_id: ORG2_ID });
expect(getAccessToken()).toBe('org2-token');
expect(client.getQueryData(['claims'])).toBeUndefined();
});
});
describe('safeNext', () => {
it('only follows same-origin paths', () => {
expect(safeNext('/claims?scope=mine')).toBe('/claims?scope=mine');
expect(safeNext(undefined)).toBe('/');
expect(safeNext('//evil.test/x')).toBe('/');
expect(safeNext('https://evil.test')).toBe('/');
expect(safeNext('/login')).toBe('/');
});
});

Run: pnpm -C web test -- src/features/auth/session.test.tsx — Expected: FAIL (module not found).

  • Step 4: Implement features/auth/session.tsx
import { queryOptions, useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query';
import { useRouter } from '@tanstack/react-router';
import { ShieldAlert } from 'lucide-react';
import { useCallback, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { EmptyState } from '@/components/ui/EmptyState';
import { Spinner } from '@/components/ui/Spinner';
import { api, applyTokenResponse, setAccessToken } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
import type { Me, Permission } from '@/lib/api/types';
export const meQueryOptions = () =>
queryOptions({
queryKey: ['me'],
queryFn: async () => unwrap(await api.GET('/me')),
staleTime: 5 * 60_000,
});
export function useMe(): UseQueryResult<Me> {
return useQuery(meQueryOptions());
}
/** Any-of. An undefined requirement is open to every signed-in member. */
export function hasPermission(perms: readonly string[], p: Permission | Permission[] | undefined): boolean {
if (p === undefined) return true;
return (Array.isArray(p) ? p : [p]).some((x) => perms.includes(x));
}
export function useCan(p: Permission | Permission[]): boolean {
const { data } = useMe();
return data ? hasPermission(data.permissions, p) : false;
}
export function ForbiddenPanel() {
const { t } = useTranslation('auth');
return <EmptyState icon={ShieldAlert} title={t('forbidden.title')} description={t('forbidden.body')} />;
}
/** The nav should already have hidden the way here; this is the backstop (spec §6). */
export function RequirePermission(p: { permission: Permission | Permission[]; children: ReactNode }) {
const { data, isPending } = useMe();
if (isPending) {
return (
<div className="flex justify-center p-10">
<Spinner />
</div>
);
}
return data && hasPermission(data.permissions, p.permission) ? <>{p.children}</> : <ForbiddenPanel />;
}
/** POST /auth/switch-org → new token → drop every cached query (they belong to the old org) → home. */
export function useSwitchOrg(): (orgId: string) => Promise<void> {
const qc = useQueryClient();
const router = useRouter();
return useCallback(
async (orgId: string) => {
const t = unwrap(await api.POST('/auth/switch-org', { body: { org_id: orgId } }));
applyTokenResponse(t);
qc.clear();
// Ids in the current URL belong to the old organization and would 404 in the new one.
await router.navigate({ href: '/' });
},
[qc, router],
);
}
export function useSignOut(): () => Promise<void> {
const qc = useQueryClient();
const router = useRouter();
return useCallback(async () => {
try {
await api.POST('/auth/logout');
} finally {
setAccessToken(null);
qc.clear();
await router.navigate({ href: '/login' });
}
}, [qc, router]);
}
/** `next` from /login?next= — same-origin paths only, never back to /login. */
export function safeNext(next: string | undefined): string {
if (!next || !next.startsWith('/') || next.startsWith('//') || next.startsWith('/login')) return '/';
return next;
}

Run: pnpm -C web test -- src/features/auth/session.test.tsx — Expected: PASS.

  • Step 5: Login page — failing test

web/src/features/auth/LoginPage.test.tsx:

import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { getAccessToken } from '@/lib/api/client';
import { ORG_ID, ORG2_ID, tokenFixture } from '@/test/msw/fixtures';
import { apiPath } from '@/test/msw/handlers';
import { server } from '@/test/msw/server';
import { renderWithRouter } from '@/test/renderWithRouter';
import { LoginPage } from './LoginPage';
const problem = (status: number, kind: string) =>
HttpResponse.json({ type: `https://errors.mcom.erp/${kind}`, title: kind, status }, { status });
async function fill() {
await userEvent.type(await screen.findByLabelText('Email'), 'employee@demo.test');
await userEvent.type(screen.getByLabelText('Password'), 'secret-password');
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }));
}
describe('LoginPage', () => {
it('single organization: signs in and follows ?next=', async () => {
server.use(
http.post(apiPath('/auth/login'), () =>
HttpResponse.json({ ...tokenFixture('t1'), organizations: [{ id: ORG_ID, name: 'Demo Sdn Bhd' }] }),
),
);
const { router } = renderWithRouter(LoginPage, { path: '/login?next=%2Factivity' });
await fill();
await waitFor(() => expect(router.state.location.pathname).toBe('/activity'));
expect(getAccessToken()).toBe('t1');
});
it('several organizations: asks which, and switches when another is picked', async () => {
let switched: unknown;
server.use(
http.post(apiPath('/auth/login'), () => HttpResponse.json(tokenFixture('t1', ORG_ID))),
http.post(apiPath('/auth/switch-org'), async ({ request }) => {
switched = await request.json();
return HttpResponse.json(tokenFixture('t2', ORG2_ID));
}),
);
const { router } = renderWithRouter(LoginPage, { path: '/login' });
await fill();
await userEvent.click(await screen.findByRole('radio', { name: /Second Demo Bhd/ }));
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => expect(router.state.location.pathname).toBe('/'));
expect(switched).toEqual({ org_id: ORG2_ID });
expect(getAccessToken()).toBe('t2');
});
it('asks for the TOTP code when the server requires one', async () => {
const bodies: Record<string, unknown>[] = [];
server.use(
http.post(apiPath('/auth/login'), async ({ request }) => {
const b = (await request.json()) as Record<string, unknown>;
bodies.push(b);
return b.code ? HttpResponse.json({ ...tokenFixture('t1'), organizations: [] }) : problem(403, 'totp-required');
}),
);
renderWithRouter(LoginPage, { path: '/login' });
await fill();
await userEvent.type(await screen.findByLabelText('Authentication code'), '123456');
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }));
await waitFor(() => expect(bodies.at(-1)).toMatchObject({ code: '123456' }));
});
it('says the credentials are wrong without saying which', async () => {
server.use(http.post(apiPath('/auth/login'), () => problem(401, 'unauthorized')));
renderWithRouter(LoginPage, { path: '/login' });
await fill();
expect(await screen.findByRole('alert')).toHaveTextContent('Email or password is incorrect.');
});
});

Run: pnpm -C web test -- src/features/auth/LoginPage.test.tsx — Expected: FAIL (module not found).

  • Step 6: Implement the login page

web/src/features/auth/LoginPage.tsx:

import { zodResolver } from '@hookform/resolvers/zod';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter, useSearch } from '@tanstack/react-router';
import { Building2 } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { Button } from '@/components/ui/Button';
import { Field } from '@/components/ui/Field';
import { Input } from '@/components/ui/Input';
import { RadioCards } from '@/components/ui/RadioCards';
import { api, applyTokenResponse } from '@/lib/api/client';
import { problemMessage, toProblemError, unwrap } from '@/lib/api/problem';
import type { Schemas } from '@/lib/api/types';
import { safeNext } from './session';
const schema = z.object({
email: z.string().email(),
password: z.string().min(1),
code: z.string().optional(),
});
type Form = z.infer<typeof schema>;
export function AuthCard({ title, subtitle, children }: { title: string; subtitle?: string; children: ReactNode }) {
return (
<main className="flex min-h-dvh items-center justify-center bg-bg-subtle p-4">
<section className="w-full max-w-sm rounded-card border border-border-subtle bg-surface p-6 shadow-card">
<h1 className="text-subtitle font-bold text-fg">{title}</h1>
{subtitle && <p className="mt-1 text-caption text-fg-muted">{subtitle}</p>}
<div className="mt-6">{children}</div>
</section>
</main>
);
}
export function LoginPage() {
const { t } = useTranslation('auth');
const router = useRouter();
const qc = useQueryClient();
const { next } = useSearch({ strict: false }) as { next?: string };
const [needCode, setNeedCode] = useState(false);
const [error, setError] = useState<string>();
const [choice, setChoice] = useState<{ orgs: Schemas['OrgSummary'][]; current: string; picked: string }>();
const [switching, setSwitching] = useState(false);
const form = useForm<Form>({ resolver: zodResolver(schema), defaultValues: { email: '', password: '', code: '' } });
const finish = () => router.navigate({ href: safeNext(next) });
const onSubmit = form.handleSubmit(async (v) => {
setError(undefined);
const r = await api.POST('/auth/login', {
body: { email: v.email, password: v.password, ...(needCode && v.code ? { code: v.code } : {}) },
});
if (!r.response.ok || !r.data) {
const e = toProblemError(r.error, r.response);
if (e.status === 403 && e.kind === 'totp-required') return setNeedCode(true);
if (e.status === 403 && e.kind === 'no-organization') return setError(t('login.noOrganization'));
if (e.status === 401) return setError(t('login.invalid'));
return setError(problemMessage(e, t));
}
applyTokenResponse(r.data);
qc.clear();
const orgs = r.data.organizations ?? [];
if (orgs.length > 1) {
setChoice({ orgs, current: r.data.organization_id, picked: r.data.organization_id });
return;
}
await finish();
});
const onContinue = async () => {
if (!choice) return;
setSwitching(true);
try {
if (choice.picked !== choice.current) {
applyTokenResponse(unwrap(await api.POST('/auth/switch-org', { body: { org_id: choice.picked } })));
qc.clear();
}
await finish();
} catch (e) {
setError(problemMessage(e, t));
} finally {
setSwitching(false);
}
};
if (choice) {
return (
<AuthCard title={t('login.pickOrg')} subtitle={t('login.pickOrgHint')}>
<RadioCards
aria-label={t('login.pickOrg')}
className="sm:grid-cols-1"
value={choice.picked}
onValueChange={(picked) => setChoice({ ...choice, picked })}
items={choice.orgs.map((o) => ({ value: o.id, title: o.name, icon: Building2 }))}
/>
{error && <p role="alert" className="mt-3 text-caption text-tone-red-fg">{error}</p>}
<Button className="mt-6 w-full" loading={switching} onClick={onContinue}>
{t('login.continue')}
</Button>
</AuthCard>
);
}
const errs = form.formState.errors;
return (
<AuthCard title={t('login.title')} subtitle={t('login.subtitle')}>
<form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
<Field label={t('login.email')} error={errs.email?.message}>
<Input type="email" autoComplete="username" {...form.register('email')} />
</Field>
<Field label={t('login.password')} error={errs.password?.message}>
<Input type="password" autoComplete="current-password" {...form.register('password')} />
</Field>
{needCode && (
<Field label={t('login.code')} hint={t('login.codeHint')}>
<Input inputMode="numeric" autoComplete="one-time-code" autoFocus {...form.register('code')} />
</Field>
)}
{error && <p role="alert" className="text-caption text-tone-red-fg">{error}</p>}
<Button type="submit" loading={form.formState.isSubmitting} className="w-full">
{t('login.submit')}
</Button>
</form>
</AuthCard>
);
}

Field-level zod messages are zod’s English defaults; the server is the authority on credentials, and the two client checks (an email shape, a non-empty password) only save a round trip. Verify: if pnpm -C web typecheck rejects form.register('email') spread into Input (ref typing), it is the React 19 ref-as-prop change in @types/react; Input already accepts ref through ComponentProps<'input'>, so check that the installed react-hook-form is v7.54+.

Append to web/src/lib/api/types.ts:

export type OrgSummary = Schemas['OrgSummary'];
export type ActivityItem = Schemas['ActivityItem'];

Run: pnpm -C web test -- src/features/auth/LoginPage.test.tsx — Expected: PASS.

  • Step 7: Accept invitation — failing test, then implementation

web/src/features/auth/AcceptInvitationPage.test.tsx:

import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { ORG_ID } from '@/test/msw/fixtures';
import { apiPath } from '@/test/msw/handlers';
import { server } from '@/test/msw/server';
import { renderWithRouter } from '@/test/renderWithRouter';
import { AcceptInvitationPage } from './AcceptInvitationPage';
async function submit() {
await userEvent.type(await screen.findByLabelText('Full legal name'), 'Tan Wei Ming');
await userEvent.type(screen.getByLabelText('Password'), 'a-long-password-2026');
await userEvent.click(screen.getByRole('button', { name: 'Accept invitation' }));
}
describe('AcceptInvitationPage', () => {
it('sends the token from the link and then offers sign-in', async () => {
let body: unknown;
server.use(
http.post(apiPath('/auth/invitations/accept'), async ({ request }) => {
body = await request.json();
return HttpResponse.json(
{ organization_id: ORG_ID, member_id: 'm1', email: 'employee@demo.test', created_login: true },
{ status: 201 },
);
}),
);
renderWithRouter(AcceptInvitationPage, { path: '/invitations/accept?token=tok-123' });
await submit();
expect(await screen.findByText('Sign in as employee@demo.test to continue.')).toBeInTheDocument();
expect(body).toEqual({ token: 'tok-123', legal_name: 'Tan Wei Ming', password: 'a-long-password-2026' });
});
it('explains an expired invitation', async () => {
server.use(
http.post(apiPath('/auth/invitations/accept'), () =>
HttpResponse.json({ type: 'x/gone', title: 'Gone', status: 410 }, { status: 410 }),
),
);
renderWithRouter(AcceptInvitationPage, { path: '/invitations/accept?token=tok-123' });
await submit();
expect(await screen.findByRole('alert')).toHaveTextContent('expired or was withdrawn');
});
it('refuses to render a form without a token', async () => {
renderWithRouter(AcceptInvitationPage, { path: '/invitations/accept' });
expect(await screen.findByText(/link is incomplete/)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Accept invitation' })).not.toBeInTheDocument();
});
});

Run: pnpm -C web test -- src/features/auth/AcceptInvitationPage.test.tsx — Expected: FAIL.

web/src/features/auth/AcceptInvitationPage.tsx:

import { zodResolver } from '@hookform/resolvers/zod';
import { Link, useSearch } from '@tanstack/react-router';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { Button, buttonVariants } from '@/components/ui/Button';
import { Field } from '@/components/ui/Field';
import { Input } from '@/components/ui/Input';
import { api } from '@/lib/api/client';
import { problemMessage, toProblemError } from '@/lib/api/problem';
import { useLocale } from '@/lib/i18n';
import { AuthCard } from './LoginPage';
const schema = z.object({ legal_name: z.string().trim().min(1), password: z.string() });
type Form = z.infer<typeof schema>;
export function AcceptInvitationPage() {
const { t } = useTranslation(['auth', 'common']);
const [locale] = useLocale();
const { token } = useSearch({ strict: false }) as { token?: string };
const [error, setError] = useState<string>();
const [doneEmail, setDoneEmail] = useState<string>();
const form = useForm<Form>({ resolver: zodResolver(schema), defaultValues: { legal_name: '', password: '' } });
if (!token) {
return (
<AuthCard title={t('invite.title')}>
<p role="alert" className="text-body text-tone-red-fg">{t('invite.missingToken')}</p>
</AuthCard>
);
}
if (doneEmail) {
return (
<AuthCard title={t('invite.doneTitle')} subtitle={t('invite.doneBody', { email: doneEmail })}>
<Link to="/login" className={buttonVariants({ className: 'w-full' })}>
{t('invite.signIn')}
</Link>
</AuthCard>
);
}
const onSubmit = form.handleSubmit(async (v) => {
setError(undefined);
const r = await api.POST('/auth/invitations/accept', {
body: { token, legal_name: v.legal_name, ...(v.password ? { password: v.password } : {}) },
});
if (r.response.ok && r.data) return setDoneEmail(r.data.email);
const e = toProblemError(r.error, r.response);
const byStatus: Record<number, string> = {
404: t('invite.notFound'),
409: t('invite.alreadyAccepted'),
410: t('invite.expired'),
};
setError(byStatus[e.status] ?? problemMessage(e, t));
});
return (
<AuthCard title={t('invite.title')} subtitle={t('invite.subtitle')}>
<form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
<Field
label={t('invite.legalName')}
hint={locale === 'zh' ? t('common:hints.recordLocale') : t('invite.legalNameHint')}
error={form.formState.errors.legal_name?.message}
>
<Input autoComplete="name" {...form.register('legal_name')} />
</Field>
<Field label={t('invite.password')} hint={t('invite.passwordHint')}>
<Input type="password" autoComplete="new-password" {...form.register('password')} />
</Field>
{error && <p role="alert" className="text-caption text-tone-red-fg">{error}</p>}
<Button type="submit" className="w-full" loading={form.formState.isSubmitting}>
{t('invite.submit')}
</Button>
</form>
</AuthCard>
);
}

The legal name is record text; the server’s 422 for CJK script is the authority (F8), so the page only shows the hint in zh.

Run: pnpm -C web test -- src/features/auth — Expected: PASS.

  • Step 8: Theme, nav config, layouts

web/src/app/theme.ts:

import { useCallback, useEffect, useState } from 'react';
export type ThemePref = 'light' | 'dark' | 'system';
const KEY = 'ui.theme';
const DARK = '(prefers-color-scheme: dark)';
export function readThemePref(): ThemePref {
try {
const v = window.localStorage.getItem(KEY);
return v === 'light' || v === 'dark' ? v : 'system';
} catch {
return 'system';
}
}
/** Sets <html data-theme> (tokens.css keys dark mode on it). Call before first render to avoid a flash. */
export function applyTheme(pref: ThemePref): void {
const dark = pref === 'dark' || (pref === 'system' && window.matchMedia(DARK).matches);
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
}
export function useThemePref(): [ThemePref, (p: ThemePref) => void] {
const [pref, setPref] = useState<ThemePref>(readThemePref);
useEffect(() => {
applyTheme(pref);
if (pref !== 'system') return;
const mq = window.matchMedia(DARK);
const on = () => applyTheme('system');
mq.addEventListener('change', on);
return () => mq.removeEventListener('change', on);
}, [pref]);
const set = useCallback((p: ThemePref) => {
try {
window.localStorage.setItem(KEY, p);
} catch {
// Private mode: lasts for this page.
}
setPref(p);
}, []);
return [pref, set];
}

web/src/app/shell/nav.ts:

import { Activity, type LucideIcon } from 'lucide-react';
import type { Permission } from '@/lib/api/types';
export type NavGroup = 'workspace' | 'requests' | 'approvals' | 'manage';
export const NAV_GROUPS: NavGroup[] = ['workspace', 'requests', 'approvals', 'manage'];
export interface NavItem {
group: NavGroup;
to: string;
labelKey: string;
icon: LucideIcon;
permission?: Permission | Permission[];
badge?: () => number | undefined;
}
// APPEND-ONLY (Global Constraints): one entry per feature, added by the task that builds it.
// Items are shown only for modules the API serves (F4) and only to members who can use them.
export const navItems: NavItem[] = [
{ group: 'workspace', to: '/activity', labelKey: 'shell:nav.activity', icon: Activity, permission: 'activity.read' },
];
// APPEND-ONLY: links in the user menu (my payout details, sessions — Task 14).
export const userMenuLinks: { to: string; labelKey: string; icon: LucideIcon }[] = [];

web/src/app/layouts.tsx:

import type { QueryClient } from '@tanstack/react-query';
import { createRootRouteWithContext, createRoute, Outlet, redirect } from '@tanstack/react-router';
import { meQueryOptions } from '@/features/auth/session';
import { getAccessToken, refreshSession } from '@/lib/api/client';
import { NotFound, RootErrorBoundary } from './RootErrorBoundary';
import { AppShell } from './shell/AppShell';
declare module '@tanstack/react-router' {
interface StaticDataRouteOption {
/** i18n key of a breadcrumb deeper than the nav item (e.g. 'claims:detail.crumb'). */
crumb?: string;
}
}
export interface RouterContext {
queryClient: QueryClient;
}
export const rootRoute = createRootRouteWithContext<RouterContext>()({
component: Outlet,
errorComponent: RootErrorBoundary,
notFoundComponent: NotFound,
});
/** Pages reachable signed out: login, accept invitation. */
export const publicRoute = createRoute({ getParentRoute: () => rootRoute, id: 'public', component: Outlet });
/**
* Every signed-in page. On a fresh load the token is re-minted from the refresh cookie (F7);
* with no session the user goes to /login?next=<here>. /me is loaded before any page renders,
* so nav and permission checks never flash.
*/
export const appRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'app',
beforeLoad: async ({ context, location }) => {
if (!getAccessToken() && !(await refreshSession())) {
throw redirect({ href: `/login?next=${encodeURIComponent(location.href)}` });
}
await context.queryClient.ensureQueryData(meQueryOptions());
},
component: AppShell,
});
  • Step 9: Feature routes, route list, router, error boundary

web/src/features/auth/HomePage.tsx:

import { Navigate } from '@tanstack/react-router';
import { Compass } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { EmptyState } from '@/components/ui/EmptyState';
import { NAV_GROUPS, navItems } from '@/app/shell/nav';
import { hasPermission, useMe } from './session';
/** "/" goes to the first nav item this member can use, or explains that there is none. */
export function HomePage() {
const { t } = useTranslation('auth');
const { data: me } = useMe();
const first = NAV_GROUPS.flatMap((g) => navItems.filter((i) => i.group === g)).find((i) =>
hasPermission(me?.permissions ?? [], i.permission),
);
if (first) return <Navigate to={first.to} replace />;
return <EmptyState icon={Compass} title={t('home.noModulesTitle')} description={t('home.noModulesBody')} />;
}

web/src/features/auth/routes.tsx:

import { createRoute } from '@tanstack/react-router';
import { z } from 'zod';
import { appRoute, publicRoute } from '@/app/layouts';
import { AcceptInvitationPage } from './AcceptInvitationPage';
import { HomePage } from './HomePage';
import { LoginPage } from './LoginPage';
const loginRoute = createRoute({
getParentRoute: () => publicRoute,
path: 'login',
validateSearch: z.object({ next: z.string().optional() }),
component: LoginPage,
});
// Matches invite.AcceptPath in internal/platform/identity/invite: the link the API issues.
const acceptRoute = createRoute({
getParentRoute: () => publicRoute,
path: 'invitations/accept',
validateSearch: z.object({ token: z.string().optional() }),
component: AcceptInvitationPage,
});
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
export const publicRoutes = [loginRoute, acceptRoute];
export const routes = [homeRoute];

web/src/features/activity/routes.tsx is written in Step 11. web/src/app/routes.ts:

import type { AnyRoute } from '@tanstack/react-router';
import { routes as activityRoutes } from '@/features/activity/routes';
import { publicRoutes as authPublicRoutes, routes as authRoutes } from '@/features/auth/routes';
// APPEND-ONLY (P1): one import and one spread per feature.
export const publicRoutes: AnyRoute[] = [...authPublicRoutes];
export const appRoutes: AnyRoute[] = [...authRoutes, ...activityRoutes];

web/src/app/router.tsx:

import { createRouter } from '@tanstack/react-router';
import { onSessionExpired } from '@/lib/api/client';
import { queryClient } from '@/lib/api/query';
import { appRoute, publicRoute, rootRoute } from './layouts';
import { NotFound, RootErrorBoundary } from './RootErrorBoundary';
import { appRoutes, publicRoutes } from './routes';
const routeTree = rootRoute.addChildren([publicRoute.addChildren(publicRoutes), appRoute.addChildren(appRoutes)]);
export const router = createRouter({
routeTree,
context: { queryClient },
defaultPreload: 'intent',
defaultErrorComponent: RootErrorBoundary,
defaultNotFoundComponent: NotFound,
scrollRestoration: true,
});
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
// A refresh that failed mid-session: drop the old session's data and ask to sign in again,
// coming back here afterwards.
onSessionExpired(() => {
queryClient.clear();
const { pathname, search } = window.location;
if (!pathname.startsWith('/login')) void router.navigate({ href: `/login?next=${encodeURIComponent(pathname + search)}` });
});

Verify: with the children typed as AnyRoute[], Link to accepts any string and each page reads its own typed search through its route object (claimsRoute.useSearch()), which is what P7 needs. If pnpm -C web typecheck reports addChildren rejecting AnyRoute[], type the arrays as readonly AnyRoute[] and spread into a tuple (addChildren([...appRoutes])); do not drop to any.

web/src/app/RootErrorBoundary.tsx:

import type { ErrorComponentProps } from '@tanstack/react-router';
import { AlertTriangle, SearchX } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { ProblemError, problemMessage } from '@/lib/api/problem';
/** A friendly panel with reload and the request id, so users can report it (spec §6). */
export function RootErrorBoundary({ error, reset }: Pick<ErrorComponentProps, 'error' | 'reset'>) {
const { t } = useTranslation(['shell', 'common']);
const requestId = error instanceof ProblemError ? error.requestId : undefined;
return (
<div role="alert" className="p-6">
<EmptyState
icon={AlertTriangle}
title={t('error.title')}
description={
<>
{problemMessage(error, t)} {t('error.body')}
</>
}
action={
<div className="flex flex-col items-center gap-3">
{requestId && (
<p className="text-caption text-fg-muted">
{t('error.requestId')}: <code className="select-all">{requestId}</code>
</p>
)}
<div className="flex gap-2">
<Button variant="outline" onClick={reset}>
{t('common:actions.retry')}
</Button>
<Button onClick={() => window.location.reload()}>{t('common:actions.reload')}</Button>
</div>
</div>
}
/>
</div>
);
}
export function NotFound() {
const { t } = useTranslation('shell');
return (
<EmptyState
icon={SearchX}
title={t('error.notFoundTitle')}
description={t('error.notFoundBody')}
action={<a className="text-primary underline" href="/">{t('error.home')}</a>}
/>
);
}

web/src/app/RootErrorBoundary.test.tsx:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { ProblemError } from '@/lib/api/problem';
import { RootErrorBoundary } from './RootErrorBoundary';
describe('RootErrorBoundary', () => {
it('shows the request id of a server error and offers retry', async () => {
const reset = vi.fn();
render(
<RootErrorBoundary
error={new ProblemError({ status: 500, type: 'x/internal', title: 'Internal error', requestId: 'req-42' })}
reset={reset}
/>,
);
expect(screen.getByRole('alert')).toHaveTextContent('req-42');
await userEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(reset).toHaveBeenCalled();
});
it('has no request id line for a client-side error', () => {
render(<RootErrorBoundary error={new Error('boom')} reset={() => {}} />);
expect(screen.queryByText(/Request ID/)).not.toBeInTheDocument();
});
});
  • Step 10: Shell — failing tests for the sidebar and org switcher

web/src/app/shell/Sidebar.test.tsx:

import { screen } from '@testing-library/react';
import { Activity, BookOpen, Receipt } from 'lucide-react';
import { describe, expect, it } from 'vitest';
import { renderWithRouter } from '@/test/renderWithRouter';
import type { NavItem } from './nav';
import { SidebarNav } from './Sidebar';
const items: NavItem[] = [
{ group: 'workspace', to: '/activity', labelKey: 'shell:nav.activity', icon: Activity, permission: 'activity.read' },
{ group: 'requests', to: '/claims', labelKey: 'shell:nav.activity', icon: Receipt, permission: ['expenses.claim.read', 'x'] },
{ group: 'manage', to: '/accounting', labelKey: 'shell:nav.activity', icon: BookOpen, permission: 'accounting.ledger.read' },
];
describe('SidebarNav', () => {
it('shows items the member may use and hides a group with none (presence before absence)', async () => {
renderWithRouter(() => <SidebarNav items={items} collapsed={false} />, { path: '/activity' });
// presence: groups with a permitted item render
expect(await screen.findByText('Workspace')).toBeInTheDocument();
expect(screen.getByText('Requests')).toBeInTheDocument();
expect(screen.getAllByRole('link')).toHaveLength(2);
// absence: Manage's only item needs accounting.ledger.read, which meFixture lacks
expect(screen.queryByText('Manage')).not.toBeInTheDocument();
});
it('marks the current page', async () => {
renderWithRouter(() => <SidebarNav items={items} collapsed={false} />, { path: '/activity' });
const links = await screen.findAllByRole('link');
expect(links[0]).toHaveAttribute('aria-current', 'page');
});
it('keeps accessible names when collapsed to icons', async () => {
renderWithRouter(() => <SidebarNav items={items} collapsed />, { path: '/' });
expect(await screen.findAllByRole('link', { name: 'Activity' })).not.toHaveLength(0);
});
});

web/src/app/shell/OrgSwitcher.test.tsx:

import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { ORG2_ID, tokenFixture } from '@/test/msw/fixtures';
import { apiPath } from '@/test/msw/handlers';
import { server } from '@/test/msw/server';
import { renderWithRouter } from '@/test/renderWithRouter';
import { OrgSwitcher } from './OrgSwitcher';
describe('OrgSwitcher', () => {
it('lists my organizations and switches', async () => {
let body: unknown;
server.use(
http.post(apiPath('/auth/switch-org'), async ({ request }) => {
body = await request.json();
return HttpResponse.json(tokenFixture('t2', ORG2_ID));
}),
);
const { router } = renderWithRouter(() => <OrgSwitcher collapsed={false} />, { path: '/activity' });
await userEvent.click(await screen.findByRole('button', { name: /Demo Sdn Bhd/ }));
await userEvent.click(await screen.findByRole('menuitemradio', { name: 'Second Demo Bhd' }));
await waitFor(() => expect(body).toEqual({ org_id: ORG2_ID }));
await waitFor(() => expect(router.state.location.pathname).toBe('/'));
});
});

Run: pnpm -C web test -- src/app — Expected: FAIL (Sidebar, OrgSwitcher not found; the error boundary test passes).

  • Step 11: Implement the shell

web/src/app/shell/Sidebar.tsx:

import { Link } from '@tanstack/react-router';
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CountBadge } from '@/components/ui/CountBadge';
import { hasPermission, useMe } from '@/features/auth/session';
import { cn } from '@/lib/cn';
import { NAV_GROUPS, navItems, type NavItem } from './nav';
import { OrgSwitcher } from './OrgSwitcher';
const SIDEBAR_KEY = 'ui.sidebar';
export function useSidebarCollapsed(): [boolean, (v: boolean) => void] {
const [collapsed, setCollapsed] = useState(() => {
try {
return window.localStorage.getItem(SIDEBAR_KEY) === 'collapsed';
} catch {
return false;
}
});
const set = (v: boolean) => {
try {
window.localStorage.setItem(SIDEBAR_KEY, v ? 'collapsed' : 'expanded');
} catch {
// Private mode.
}
setCollapsed(v);
};
return [collapsed, set];
}
/**
* The grouped nav. `collapsed` is the ≥1280 px user choice; between 768 and 1280 px the labels
* are always visually hidden (icon mode, spec §5.1) but stay as accessible names.
*/
export function SidebarNav({
items = navItems,
collapsed,
onNavigate,
forceLabels = false,
}: {
items?: NavItem[];
collapsed: boolean;
onNavigate?(): void;
forceLabels?: boolean;
}) {
const { t } = useTranslation('shell');
const { data: me } = useMe();
const perms = me?.permissions ?? [];
const label = forceLabels ? '' : cn('sr-only', !collapsed && 'xl:not-sr-only');
return (
<nav aria-label={t('sidebar.nav')} className="flex flex-col gap-4">
{NAV_GROUPS.map((g) => {
const visible = items.filter((i) => i.group === g && hasPermission(perms, i.permission));
if (visible.length === 0) return null;
return (
<div key={g} className="flex flex-col gap-1">
<h2 className={cn('px-3 text-caption font-bold uppercase text-sidebar-muted', label)}>{t(`groups.${g}`)}</h2>
<ul className="flex flex-col gap-1">
{visible.map((i) => {
const n = i.badge?.();
return (
<li key={i.to}>
<Link
to={i.to}
onClick={onNavigate}
className="flex h-9 items-center gap-3 rounded-control px-3 text-body text-sidebar-fg hover:bg-sidebar-active focus-visible:outline-2 focus-visible:outline-sidebar-fg"
activeProps={{ className: 'bg-sidebar-active font-medium', 'aria-current': 'page' }}
>
<i.icon aria-hidden className="size-4 shrink-0" />
<span className={cn('flex-1 truncate', label)}>{t(i.labelKey)}</span>
{n !== undefined && n > 0 && <CountBadge count={n} active className={label} />}
</Link>
</li>
);
})}
</ul>
</div>
);
})}
</nav>
);
}
export function Sidebar() {
const { t } = useTranslation('shell');
const [collapsed, setCollapsed] = useSidebarCollapsed();
return (
<aside
className={cn(
'sticky top-0 hidden h-dvh shrink-0 flex-col gap-6 overflow-y-auto bg-linear-to-b from-sidebar-from to-sidebar-to p-3 md:flex',
'md:w-16',
!collapsed && 'xl:w-60',
)}
>
<OrgSwitcher collapsed={collapsed} />
<SidebarNav collapsed={collapsed} />
<button
type="button"
onClick={() => setCollapsed(!collapsed)}
aria-label={collapsed ? t('sidebar.expand') : t('sidebar.collapse')}
className="mt-auto hidden self-start rounded-control p-2 text-sidebar-fg hover:bg-sidebar-active xl:block"
>
{collapsed ? <PanelLeftOpen aria-hidden className="size-4" /> : <PanelLeftClose aria-hidden className="size-4" />}
</button>
</aside>
);
}

web/src/app/shell/OrgSwitcher.tsx:

import { Building2, Check, ChevronDown } from 'lucide-react';
import { DropdownMenu } from 'radix-ui';
import { useTranslation } from 'react-i18next';
import { toast } from '@/components/ui/Toaster';
import { useMe, useSwitchOrg } from '@/features/auth/session';
import { problemMessage } from '@/lib/api/problem';
import { cn } from '@/lib/cn';
export function OrgSwitcher({ collapsed, forceLabel = false }: { collapsed: boolean; forceLabel?: boolean }) {
const { t } = useTranslation(['shell', 'common']);
const { data: me } = useMe();
const switchOrg = useSwitchOrg();
if (!me) return null;
// Same rule as SidebarNav: icon mode between 768 and 1280 px; the mobile drawer shows labels.
const label = forceLabel ? '' : cn('sr-only', !collapsed && 'xl:not-sr-only');
const trigger = (
<span className="flex h-10 w-full items-center gap-2 rounded-control bg-sidebar-active px-3 text-body text-sidebar-fg">
<Building2 aria-hidden className="size-4 shrink-0" />
<span className={cn('flex-1 truncate text-left', label)}>{me.organization.name}</span>
{me.organizations.length > 1 && <ChevronDown aria-hidden className={cn('size-4', label)} />}
</span>
);
if (me.organizations.length < 2) return <div title={me.organization.name}>{trigger}</div>;
const onPick = async (id: string) => {
if (id === me.organization.id) return;
try {
await switchOrg(id);
} catch (e) {
toast.error(`${t('org.switchFailed')} ${problemMessage(e, t)}`);
}
};
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger className="w-full rounded-control focus-visible:outline-2 focus-visible:outline-sidebar-fg" aria-label={`${me.organization.name} — ${t('org.switch')}`}>
{trigger}
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content align="start" sideOffset={6} className="z-50 min-w-56 rounded-control border border-border bg-surface-raised p-1 shadow-card">
<DropdownMenu.Label className="px-2 py-1.5 text-caption text-fg-muted">{t('org.switch')}</DropdownMenu.Label>
<DropdownMenu.RadioGroup value={me.organization.id} onValueChange={(v) => void onPick(v)}>
{me.organizations.map((o) => (
<DropdownMenu.RadioItem
key={o.id}
value={o.id}
className="flex cursor-pointer items-center justify-between gap-2 rounded-chip px-2 py-2 text-body text-fg outline-none data-[highlighted]:bg-primary-soft"
>
{o.name}
<DropdownMenu.ItemIndicator>
<Check aria-hidden className="size-4 text-primary" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
))}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

The org switcher’s trigger name includes the org name (“Demo Sdn Bhd — Switch organization”), which is what the test’s /Demo Sdn Bhd/ matches.

web/src/app/shell/UserMenu.tsx:

import { Link } from '@tanstack/react-router';
import { ChevronDown, LogOut } from 'lucide-react';
import { DropdownMenu } from 'radix-ui';
import { useTranslation } from 'react-i18next';
import { useMe, useSignOut } from '@/features/auth/session';
import { SUPPORTED_LOCALES, useLocale, type UiLocale } from '@/lib/i18n';
import { useThemePref, type ThemePref } from '../theme';
import { userMenuLinks } from './nav';
const item =
'flex cursor-pointer items-center gap-2 rounded-chip px-2 py-2 text-body text-fg outline-none data-[highlighted]:bg-primary-soft data-[state=checked]:font-medium';
export function UserMenu() {
const { t } = useTranslation('shell');
const { data: me } = useMe();
const [locale, setLocale] = useLocale();
const [theme, setTheme] = useThemePref();
const signOut = useSignOut();
if (!me) return null;
const name = me.name ?? me.email;
const initials = name
.split(/\s+/)
.map((w) => w[0] ?? '')
.join('')
.slice(0, 2)
.toUpperCase();
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger
aria-label={t('menu.account')}
className="flex items-center gap-2 rounded-full bg-primary-soft py-1 pr-3 pl-1 focus-visible:outline-2 focus-visible:outline-focus"
>
<span aria-hidden className="flex size-8 items-center justify-center rounded-full bg-primary text-caption font-bold text-primary-fg">
{initials}
</span>
<span className="hidden text-left md:block">
<span className="block text-body font-medium text-fg">{name}</span>
<span className="block text-caption text-fg-muted">{me.organization.name}</span>
</span>
<ChevronDown aria-hidden className="size-4 text-fg-muted" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content align="end" sideOffset={6} className="z-50 min-w-60 rounded-control border border-border bg-surface-raised p-1 shadow-card">
<DropdownMenu.Label className="px-2 py-1.5 text-caption text-fg-muted">{t('menu.language')}</DropdownMenu.Label>
<DropdownMenu.RadioGroup value={locale} onValueChange={(v) => setLocale(v as UiLocale)}>
{SUPPORTED_LOCALES.map((l) => (
<DropdownMenu.RadioItem key={l} value={l} className={item}>
{t(`locales.${l}`)}
</DropdownMenu.RadioItem>
))}
</DropdownMenu.RadioGroup>
<DropdownMenu.Separator className="my-1 h-px bg-border-subtle" />
<DropdownMenu.Label className="px-2 py-1.5 text-caption text-fg-muted">{t('menu.theme')}</DropdownMenu.Label>
<DropdownMenu.RadioGroup value={theme} onValueChange={(v) => setTheme(v as ThemePref)}>
<DropdownMenu.RadioItem value="light" className={item}>{t('menu.themeLight')}</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="dark" className={item}>{t('menu.themeDark')}</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="system" className={item}>{t('menu.themeSystem')}</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
{userMenuLinks.length > 0 && <DropdownMenu.Separator className="my-1 h-px bg-border-subtle" />}
{userMenuLinks.map((l) => (
<DropdownMenu.Item key={l.to} asChild className={item}>
<Link to={l.to}>
<l.icon aria-hidden className="size-4" />
{t(l.labelKey)}
</Link>
</DropdownMenu.Item>
))}
<DropdownMenu.Separator className="my-1 h-px bg-border-subtle" />
<DropdownMenu.Item className={item} onSelect={() => void signOut()}>
<LogOut aria-hidden className="size-4" />
{t('menu.signOut')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

web/src/app/shell/TopBar.tsx:

import { useMatches, useRouterState } from '@tanstack/react-router';
import { Menu } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Breadcrumb } from '@/components/ui/Breadcrumb';
import { IconButton } from '@/components/ui/IconButton';
import { navItems } from './nav';
import { UserMenu } from './UserMenu';
/** "REQUESTS / Expenses Claims": the nav group, the nav item, then any deeper route crumb. */
function useCrumbs(): { label: string }[] {
const { t } = useTranslation();
const pathname = useRouterState({ select: (s) => s.location.pathname });
const matches = useMatches();
const item = navItems
.filter((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
.sort((a, b) => b.to.length - a.to.length)[0];
const crumbs = item ? [{ label: t(`shell:groups.${item.group}`) }, { label: t(item.labelKey) }] : [];
const deeper = matches.at(-1)?.staticData?.crumb;
if (deeper) crumbs.push({ label: t(deeper) });
return crumbs;
}
export function TopBar({ onOpenNav }: { onOpenNav(): void }) {
const { t } = useTranslation('shell');
const crumbs = useCrumbs();
return (
<header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-border-subtle bg-bg-subtle px-4 md:px-6">
<IconButton icon={Menu} label={t('menu.open')} className="md:hidden" onClick={onOpenNav} />
<div className="min-w-0 flex-1">{crumbs.length > 0 && <Breadcrumb items={crumbs} />}</div>
<UserMenu />
</header>
);
}

web/src/app/shell/AppShell.tsx:

import { Outlet } from '@tanstack/react-router';
import { Dialog as DialogPrimitive } from 'radix-ui';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { OrgSwitcher } from './OrgSwitcher';
import { Sidebar, SidebarNav } from './Sidebar';
import { TopBar } from './TopBar';
/** ≥1280: full sidebar; 768–1280: icons; <768: a drawer from the top bar (spec §5.1). */
export function AppShell() {
const { t } = useTranslation('shell');
const [navOpen, setNavOpen] = useState(false);
return (
<div className="flex min-h-dvh bg-bg text-fg">
<a href="#main" className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-control focus:bg-surface focus:p-2">
{t('skip')}
</a>
<Sidebar />
<DialogPrimitive.Root open={navOpen} onOpenChange={setNavOpen}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-overlay md:hidden" />
<DialogPrimitive.Content
aria-describedby={undefined}
className="fixed inset-y-0 left-0 z-50 flex w-72 flex-col gap-6 overflow-y-auto bg-linear-to-b from-sidebar-from to-sidebar-to p-3 md:hidden"
>
<DialogPrimitive.Title className="sr-only">{t('sidebar.nav')}</DialogPrimitive.Title>
<OrgSwitcher collapsed={false} forceLabel />
<SidebarNav collapsed={false} forceLabels onNavigate={() => setNavOpen(false)} />
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
<div className="flex min-w-0 flex-1 flex-col">
<TopBar onOpenNav={() => setNavOpen(true)} />
<main id="main" tabIndex={-1} className="flex-1 px-4 py-4 md:px-6">
<Outlet />
</main>
</div>
</div>
);
}

Run: pnpm -C web test -- src/app — Expected: PASS.

  • Step 12: Activity page — failing test, then implementation

web/src/features/activity/ActivityPage.test.tsx:

import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { apiPath } from '@/test/msw/handlers';
import { server } from '@/test/msw/server';
import { renderWithRouter } from '@/test/renderWithRouter';
import { ActivityPage } from './ActivityPage';
const item = (n: number) => ({
id: `0190a000-0000-7000-8000-${String(n).padStart(12, '0')}`,
subject: 'expense_claim',
subject_id: '0190a000-0000-7000-8000-00000000c001',
verb: 'submitted',
actor_name: 'Tan Wei Ming',
summary: `Claim EC-2026-${String(n).padStart(4, '0')} submitted`,
occurred_at: new Date(Date.UTC(2026, 7, 28, 6, 0, 0) - n * 60_000).toISOString(),
});
describe('ActivityPage', () => {
it('pages by keyset with Load more', async () => {
const queries: URLSearchParams[] = [];
server.use(
http.get(apiPath('/activity'), ({ request }) => {
const q = new URL(request.url).searchParams;
queries.push(q);
const page = q.get('before_id') ? [item(51)] : Array.from({ length: 50 }, (_, i) => item(i + 1));
return HttpResponse.json({ items: page });
}),
);
renderWithRouter(ActivityPage, { path: '/activity' });
expect(await screen.findByText('Claim EC-2026-0001 submitted')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Load more' }));
expect(await screen.findByText('Claim EC-2026-0051 submitted')).toBeInTheDocument();
expect(queries[1]?.get('before_id')).toBe(item(50).id);
expect(queries[1]?.get('before_occurred_at')).toBe(item(50).occurred_at);
// A short page is the last one: no more button.
await waitFor(() => expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument());
});
it('shows an empty state', async () => {
server.use(http.get(apiPath('/activity'), () => HttpResponse.json({ items: [] })));
renderWithRouter(ActivityPage, { path: '/activity' });
expect(await screen.findByText('No activity yet')).toBeInTheDocument();
});
});

web/src/features/activity/useActivityFeed.ts:

import { useInfiniteQuery } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
import type { ActivityItem } from '@/lib/api/types';
const PAGE = 50;
type Cursor = { at: string; id: string };
export function useActivityFeed() {
return useInfiniteQuery({
queryKey: ['activity', {}],
initialPageParam: undefined as Cursor | undefined,
queryFn: async ({ pageParam }): Promise<ActivityItem[]> =>
unwrap(
await api.GET('/activity', {
params: { query: { limit: PAGE, before_occurred_at: pageParam?.at, before_id: pageParam?.id } },
}),
).items,
getNextPageParam: (last): Cursor | undefined => {
const tail = last[last.length - 1];
return last.length < PAGE || !tail ? undefined : { at: tail.occurred_at, id: tail.id };
},
});
}

web/src/features/activity/ActivityPage.tsx:

import { History } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { EmptyState } from '@/components/ui/EmptyState';
import { LoadMore } from '@/components/ui/LoadMore';
import { PageHeader } from '@/components/ui/PageHeader';
import { Skeleton } from '@/components/ui/Skeleton';
import { Tooltip } from '@/components/ui/Tooltip';
import { RequirePermission } from '@/features/auth/session';
import { formatDateTime, relativeTime } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import { useActivityFeed } from './useActivityFeed';
function Feed() {
const { t } = useTranslation('activity');
const [locale] = useLocale();
const q = useActivityFeed();
const items = q.data?.pages.flat() ?? [];
if (q.isPending) {
return (
<Card>
<div className="flex flex-col gap-3">
{Array.from({ length: 5 }, (_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
</Card>
);
}
if (q.isError) throw q.error; // the route's error boundary shows it with the request id
if (items.length === 0) return <EmptyState icon={History} title={t('empty')} />;
return (
<Card>
<ol className="divide-y divide-border-subtle">
{items.map((it) => (
<li key={it.id} className="flex items-start justify-between gap-4 py-3">
<div className="min-w-0">
{/* summary is server record text (English/Malay), shown as written */}
<p className="text-body text-fg">{it.summary}</p>
<p className="text-caption text-fg-muted">{it.actor_name ?? t('system')}</p>
</div>
<Tooltip content={formatDateTime(it.occurred_at, locale)}>
<time tabIndex={0} dateTime={it.occurred_at} className="shrink-0 text-caption text-fg-muted">
{relativeTime(it.occurred_at, locale)}
</time>
</Tooltip>
</li>
))}
</ol>
<LoadMore hasMore={q.hasNextPage} loading={q.isFetchingNextPage} onLoadMore={() => void q.fetchNextPage()} />
</Card>
);
}
export function ActivityPage() {
const { t } = useTranslation('activity');
return (
<RequirePermission permission="activity.read">
<PageHeader title={t('title')} subtitle={t('subtitle')} />
<Feed />
</RequirePermission>
);
}

web/src/features/activity/routes.tsx:

import { createRoute } from '@tanstack/react-router';
import { appRoute } from '@/app/layouts';
import { ActivityPage } from './ActivityPage';
const activityRoute = createRoute({ getParentRoute: () => appRoute, path: 'activity', component: ActivityPage });
export const routes = [activityRoute];

Run: pnpm -C web test -- src/features/activity — Expected: PASS.

  • Step 13: Wire the app

web/src/app/App.tsx:

import { RouterProvider } from '@tanstack/react-router';
import { router } from './router';
export function App() {
return <RouterProvider router={router} />;
}

web/src/app/providers.tsx:

import { QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { Toaster } from '@/components/ui/Toaster';
import { queryClient } from '@/lib/api/query';
export function Providers({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
<Toaster />
</QueryClientProvider>
);
}

In web/src/main.tsx, apply the stored theme before the first render (no light flash in dark mode): add import { applyTheme, readThemePref } from './app/theme'; and call applyTheme(readThemePref()); immediately before await initI18n();. The dev-page glob stays as it is.

Run: pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test — Expected: PASS.

  • Step 14: Dev stack script

scripts/dev-stack.sh:

#!/usr/bin/env bash
# Local stack for the web app (Plan 10, P6): Postgres 17 + MinIO in Docker, migrations, a demo
# organization created by `orgadmin create-org` and seeded THROUGH THE PUBLIC API by
# web/e2e/seed.ts, then cmd/api and cmd/worker in the foreground. Ctrl-C stops api and worker;
# the containers keep running (`scripts/dev-stack.sh down` removes them and the state).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
STATE="$ROOT/.dev-stack"
PG=erp-dev-pg
MINIO=erp-dev-minio
PG_PORT=55432
MINIO_PORT=59000
if [[ "${1:-}" == "down" ]]; then
docker rm -f "$PG" "$MINIO" >/dev/null 2>&1 || true
rm -rf "$STATE"
echo "dev stack removed"
exit 0
fi
mkdir -p "$STATE"
cd "$ROOT"
# Secrets are generated once per checkout and kept in .dev-stack/ (gitignored).
if [[ ! -f "$STATE/env" ]]; then
cat >"$STATE/env" <<ENV
export DATABASE_URL=postgres://erp_app:erp@localhost:$PG_PORT/erp?sslmode=disable
export DATABASE_MIGRATE_URL=postgres://postgres:postgres@localhost:$PG_PORT/erp?sslmode=disable
export PORT=3000
export PUBLIC_BASE_URL=http://localhost:5173
export JWT_KEYS=k1:$(openssl rand -base64 32)
export PAYOUT_DETAILS_KEY=p1:$(openssl rand -base64 32)
export OBJECT_STORE_ENDPOINT=http://localhost:$MINIO_PORT
export OBJECT_STORE_REGION=us-east-1
export OBJECT_STORE_BUCKET=erp-documents
export OBJECT_STORE_ACCESS_KEY=erp
export OBJECT_STORE_SECRET_KEY=$(openssl rand -hex 16)
export OBJECT_STORE_PRESIGN_TTL=5m
export REDIS_URL=
export TOTP_ENCRYPTION_KEY=
export BNM_RATES_URL=
export OCR_PROVIDER=
export TRUSTED_PROXIES=
ENV
fi
# shellcheck disable=SC1091
source "$STATE/env"
running() { [[ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null)" == "true" ]]; }
if ! running "$PG"; then
docker rm -f "$PG" >/dev/null 2>&1 || true
docker run -d --name "$PG" -p "$PG_PORT:5432" \
-e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=erp postgres:17 >/dev/null
fi
if ! running "$MINIO"; then
docker rm -f "$MINIO" >/dev/null 2>&1 || true
# The browser PUTs receipts straight to MinIO with a presigned URL, cross-origin from :5173.
docker run -d --name "$MINIO" -p "$MINIO_PORT:9000" \
-e MINIO_ROOT_USER="$OBJECT_STORE_ACCESS_KEY" -e MINIO_ROOT_PASSWORD="$OBJECT_STORE_SECRET_KEY" \
-e MINIO_API_CORS_ALLOW_ORIGIN=http://localhost:5173 \
minio/minio server /data >/dev/null
fi
echo -n "waiting for postgres"
until docker exec "$PG" pg_isready -U postgres -d erp >/dev/null 2>&1; do echo -n .; sleep 1; done
echo
go run ./cmd/migrate up
API_PID=""
WORKER_PID=""
cleanup() { [[ -n "$API_PID" ]] && kill "$API_PID" 2>/dev/null; [[ -n "$WORKER_PID" ]] && kill "$WORKER_PID" 2>/dev/null; true; }
trap cleanup EXIT INT TERM
go build -o "$STATE/api" ./cmd/api
go build -o "$STATE/worker" ./cmd/worker
"$STATE/api" &
API_PID=$!
echo -n "waiting for api"
until curl -sf http://localhost:3000/v1/health >/dev/null; do echo -n .; sleep 1; done
echo
if [[ ! -f "$STATE/seeded" ]]; then
out="$(go run ./cmd/orgadmin create-org -name "Demo Sdn Bhd" -country MY -currency MYR \
-fiscal-year-start 2026-01-01 -owner-email owner@demo.test -owner-name "Aisyah binti Rahman")"
link="$(printf '%s\n' "$out" | awk '$1 == "invitation" { print $2 }')"
[[ -n "$link" ]] || { echo "orgadmin printed no invitation link" >&2; exit 1; }
API_BASE=http://localhost:3000/v1 pnpm -C web exec tsx e2e/seed.ts "$link"
touch "$STATE/seeded"
fi
"$STATE/worker" &
WORKER_PID=$!
echo "api :3000 and worker running. Start the web app with the 'web' launch config (vite :5173)."
wait "$API_PID"

chmod +x scripts/dev-stack.sh. Append to Makefile (and add dev-stack to .PHONY):

dev-stack:
scripts/dev-stack.sh

Append the api entry to .claude/launch.json’s configurations:

{
"name": "api",
"runtimeExecutable": "bash",
"runtimeArgs": ["scripts/dev-stack.sh"],
"port": 3000
}
  • Step 15: Seed script, through the public API

web/e2e/seed.ts:

// Seeds the demo organization THROUGH THE PUBLIC API (P6), so it exercises the paths the UI uses.
// Input: the owner's invitation link printed by `orgadmin create-org`. Run by scripts/dev-stack.sh.
import createClient from 'openapi-fetch';
import type { paths } from '../src/lib/api/schema';
const BASE = process.env.API_BASE ?? 'http://localhost:3000/v1';
export const DEMO_PASSWORD = process.env.DEMO_PASSWORD ?? 'demo-password-2026';
type Result<T> = { data?: T; error?: unknown; response: Response };
async function must<T>(label: string, p: Promise<Result<T>>): Promise<T> {
const r = await p;
if (!r.response.ok) throw new Error(`${label}: HTTP ${r.response.status} ${JSON.stringify(r.error)}`);
return r.data as T;
}
const anon = createClient<paths>({ baseUrl: BASE });
function as(token: string) {
return createClient<paths>({ baseUrl: BASE, headers: { Authorization: `Bearer ${token}` } });
}
async function login(email: string): Promise<string> {
const t = await must(`login ${email}`, anon.POST('/auth/login', { body: { email, password: DEMO_PASSWORD } }));
return t.access_token;
}
const EMPLOYEE = [
'expenses.claim.create', 'expenses.claim.read', 'expenses.claim.transition',
'advances.advance.create', 'advances.advance.read', 'advances.advance.transition',
'documents.document.upload', 'pettycash.read', 'pettycash.transition',
'workflow.definition.read', 'workflow.decision.read', 'workflow.delegation.create', 'activity.read',
];
const APPROVER = [...EMPLOYEE, 'expenses.claim.approve', 'pettycash.spend.review'];
const FINANCE = [
...APPROVER,
'expenses.claim.read_all', 'expenses.claim.verify', 'advances.advance.read_all',
'pettycash.read_all', 'pettycash.float.disburse', 'pettycash.float.manage',
'accounting.ledger.read', 'accounting.journal.post', 'reports.read', 'reports.export',
];
const PEOPLE = [
{ email: 'employee@demo.test', name: 'Tan Wei Ming', role: 'Employee', position: 'EXEC' },
{ email: 'approver@demo.test', name: 'Siti Nurhaliza binti Ahmad', role: 'Approver', position: 'MGR' },
{ email: 'finance@demo.test', name: 'Rajesh a/l Kumar', role: 'Finance', position: 'FIN' },
] as const;
function todayKL(): string {
return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Kuala_Lumpur' }).format(new Date());
}
async function main() {
const link = process.argv[2];
const token = link ? new URL(link).searchParams.get('token') : null;
if (!token) throw new Error('usage: tsx e2e/seed.ts <owner invitation link>');
// 1. The owner activates their account (sets the first password) and signs in.
await must('activate owner', anon.POST('/auth/invitations/accept', {
body: { token, legal_name: 'Aisyah binti Rahman', password: DEMO_PASSWORD },
}));
const owner = as(await login('owner@demo.test'));
// 2. Grant the seeded roles a working permission set (only Owner has grants at provisioning).
const catalogue = new Set((await must('permissions', owner.GET('/org/permissions'))).permissions.map((p) => p.key));
const { roles } = await must('roles', owner.GET('/org/roles'));
const roleId = (name: string) => {
const r = roles.find((x) => x.name === name);
if (!r) throw new Error(`role ${name} not seeded`);
return r.id;
};
for (const [name, perms] of [['Employee', EMPLOYEE], ['Approver', APPROVER], ['Finance', FINANCE]] as const) {
const missing = perms.filter((p) => !catalogue.has(p));
if (missing.length) console.warn(`seed: ${name}: not in the catalogue, skipped: ${missing.join(', ')}`);
await must(`permissions ${name}`, owner.PUT('/org/roles/{id}/permissions', {
params: { path: { id: roleId(name) } },
body: { permissions: perms.filter((p) => catalogue.has(p)) },
}));
}
// 3. Positions under the OWNER seat, so claims route: EXEC → MGR (superior) → unit head → OWNER.
const chart = await must('chart', owner.GET('/org/chart'));
const rootUnit = chart.units.find((u) => u.parent_id === null && u.active);
const ownerSeat = chart.positions.find((p) => p.reports_to === null && p.active && p.code === 'OWNER');
if (!rootUnit || !ownerSeat) throw new Error('provisioning left no root unit or OWNER seat');
const mkPos = (code: string, title: string, reportsTo: string) =>
must(`position ${code}`, owner.POST('/org/positions', {
body: { org_unit_id: rootUnit.id, code, title, reports_to: reportsTo },
}));
const mgr = await mkPos('MGR', 'Operations Manager', ownerSeat.id);
const positions = {
MGR: mgr,
EXEC: await mkPos('EXEC', 'Executive', mgr.id),
FIN: await mkPos('FIN', 'Finance Executive', ownerSeat.id),
};
// 4. Invite, accept (the issue response carries the raw token once) and seat each person.
const members: Record<string, string> = {};
for (const p of PEOPLE) {
const issued = await must(`invite ${p.email}`, owner.POST('/org/invitations', {
body: { email: p.email, role_id: roleId(p.role) },
}));
const accepted = await must(`accept ${p.email}`, anon.POST('/auth/invitations/accept', {
body: { token: issued.token, legal_name: p.name, password: DEMO_PASSWORD },
}));
members[p.email] = accepted.member_id;
await must(`seat ${p.email}`, owner.POST('/org/members/{id}/positions', {
params: { path: { id: accepted.member_id } },
body: { position_id: positions[p.position].id, is_primary: true, valid_from: todayKL() },
}));
}
// 5. A claim group and the employee's petty-cash float. Categories are seeded for MY orgs.
await must('group', owner.POST('/expenses/groups', { body: { name: 'Client Visit Penang', kind: 'trip' } }));
await must('float', owner.POST('/pettycash/floats', { body: { holder_member_id: members['employee@demo.test']! } }));
console.log(`seeded: owner@demo.test + ${PEOPLE.map((p) => p.email).join(', ')} (password ${DEMO_PASSWORD})`);
}
main().catch((e: unknown) => {
console.error(e);
process.exit(1);
});

Verify (these shapes come from the registry or the spec, not from running code):

  • GET /org/roles → { roles: [{ id, name, … }] } and GET /org/permissions → { permissions: [{ key, module }] } are Task 2’s registry shapes. If Task 2 shipped different names, change the two reads; the typecheck (pnpm -C web typecheck covers e2e/) fails first.
  • POST /org/positions returns OrgPosition (checked); GET /org/chart → { units, positions } (checked).
  • The workflow’s approved step resolves to the unit head. If the root unit’s head_position_id is not the OWNER seat after provisioning, the demo flow stalls at “reviewed → approved”; read GET /org/readiness after seeding and, if it warns, set the root unit’s head to OWNER with PATCH /org/units/{id} in the seed.
  • MINIO_API_CORS_ALLOW_ORIGIN is MinIO’s server-wide CORS setting. If a browser upload fails with a CORS error in the console, check docker logs erp-dev-minio and the preflight response headers first.
  • The refresh cookie is Secure. Chromium accepts Secure cookies on http://localhost. If a different browser drops it, sign-in works but a page reload signs you out; use Chromium for the runtime checks.

web/e2e/README.md:

# Local dev stack and demo data
`make dev-stack` (or the `api` launch configuration) starts Postgres 17 and MinIO in Docker,
runs migrations, and on the first run creates **Demo Sdn Bhd** with `orgadmin create-org` and
seeds it through the public API (`seed.ts`). Then it runs `cmd/api` (:3000) and `cmd/worker`.
Start the web app with the `web` launch configuration (Vite on :5173, proxying `/v1`).
| Email | Role | Position | Password |
|---|---|---|---|
| owner@demo.test | Owner (every permission) | OWNER (root) | demo-password-2026 |
| employee@demo.test | Employee | EXEC → reports to MGR | demo-password-2026 |
| approver@demo.test | Approver | MGR → reports to OWNER | demo-password-2026 |
| finance@demo.test | Finance | FIN → reports to OWNER | demo-password-2026 |
Demo only. These credentials exist in no other environment. Override the password with
`DEMO_PASSWORD` before the first run.
A claim filed by the employee goes: submit (employee) → mark reviewed (approver, the claimant's
superior) → approve (the unit head: the owner) → verify category and mark paid (the owner seat)
→ confirm receipt (employee).
`scripts/dev-stack.sh down` removes the containers and `.dev-stack/` (secrets and the seeded
marker), so the next run starts from an empty database.
  • Step 16: Runtime check against the dev stack
  1. preview_start api (runs the dev stack; first run seeds), then preview_start web.
  2. navigate to http://localhost:5173/activity → redirected to /login?next=%2Factivity.
  3. Sign in as employee@demo.test → lands on /activity, which lists the seeding events.
  4. Reload the page → still signed in (refresh cookie → new token → /me).
  5. Sidebar shows only Workspace → Activity for the employee; sign in as the owner and confirm the same (other items arrive with later tasks). Open the user menu: switch to 中文 — sidebar group reads 工作区, breadcrumb 工作区 / 动态; switch theme to Dark and compare with frame 477:16079.
  6. resize_window 1280×800: full sidebar; 1100×800: icons only, labels announced (read_page shows link names); mobile: hamburger opens the drawer; Esc closes it.
  7. Sign out → /login; navigate back to /activity → redirected to login.
  8. navigate to /invitations/accept with no token: the incomplete-link message shows and there is no form. (The full accept flow is exercised by the seed and, in Task 28, by Playwright.)
  9. read_console_messages onlyErrors: none. resize_window desktop.

Record each result in the task report.

  • Step 17: Commit
Terminal window
git add web scripts/dev-stack.sh Makefile .claude/launch.json
git commit -m "$(cat <<'EOF'
Plan 10 Task 8: auth, app shell, org switcher, activity; dev stack and seed
Login with org picker and TOTP, invitation acceptance, session restore from
the refresh cookie, permission-gated grouped sidebar (icons 768-1280, drawer
below), top bar with locale/theme menu, root error boundary with request id,
activity feed. scripts/dev-stack.sh seeds a demo org through the public API.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"

Task 9: Workflow components and member names

Section titled “Task 9: Workflow components and member names”

Files:

  • Create under web/src/components/workflow/: useWorkflowDefinition.ts, semanticTone.ts, StatusChip.tsx, StateFilterChips.tsx, TransitionBar.tsx, ReasonDialog.tsx, DecisionTimeline.tsx, useNeighbours.ts, __fixtures__/expenseClaim.ts
  • Create tests: web/src/components/workflow/semanticTone.test.ts, StatusChip.test.tsx, StateFilterChips.test.tsx, TransitionBar.test.tsx, DecisionTimeline.test.tsx, useNeighbours.test.ts
  • Create: web/src/features/members/useMemberNames.ts, web/src/features/members/useMemberNames.test.tsx
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/workflow.json
  • Modify: web/src/lib/api/types.ts (append)

Interfaces:

  • Consumes: api, unwrap, problemMessage, pickLabel, useLocale, renderWithQuery, apiPath, server (Task 6); Tag/TagTone, Button, CountBadge, Dialog, Field, Textarea, Tooltip, Skeleton, toast (Task 7). API: GET /workflow/definitions/{kind}, GET /workflow/definitions/{kind}/versions/{version} (both need workflow.definition.read), GET /org/members (Task 1, no permission).
  • Produces (registry): useWorkflowDefinition, semanticTone, StatusChip, StateFilterChips, TransitionBar, ReasonDialog, DecisionTimeline, useNeighbours — each from @/components/workflow/<same name>; useMemberNames, useMemberDirectory from @/features/members/useMemberNames.
  • Produces (additions and two widenings; see Part B notes):
    • TransitionBar gains optional kind?: string; version?: number. Without them it cannot know an edge’s target semantic, so it cannot give reject/cancel edges the danger style. Every caller should pass kind.
    • DecisionTimeline’s version becomes optional (version?: number, latest when absent), and each decision’s action_key becomes optional. ExpenseClaim carries no workflow version, and ExpenseClaimDecision has no action_key (checked in api/openapi.yaml). The registry’s required forms could not be satisfied by a claim.
    • workflowDefinitionQuery(kind, version?) (query options), mainPath(def), timelineProgress(def, decisions, currentState), type TimelineDecision, expenseClaimDefinition (test fixture).

Rules this task implements (spec §3.4, §4):

  • Tone is keyed on semantic, never on a state key: draft→neutral, pending→amber, accrual→green, payout→green-solid, acknowledged→teal, dispute→purple, rejected→red, cancelled→muted, custom→blue.
  • Labels come from the definition through pickLabel. The UI never translates a state key.
  • Buttons come from available_transitions, in the server’s order. Primary = the first allowed transition whose target semantic is not rejected, cancelled or dispute. Danger outline = target semantic rejected or cancelled. A disallowed transition is shown disabled, with refusal.detail as its accessible description and its tooltip. requires_reason opens ReasonDialog first. No gate or threshold is evaluated here.
  • Progress (n/m): m = the main path, meaning the definition’s states in display_order minus the off-path semantics rejected, cancelled and dispute. n = the distinct main-path states reached: the first decision’s from (or the current state when there are none), every decision’s to, and the current state. This excludes dispute too, where the spec said only reject/cancel. The seeded graph’s disputed sits at display order 9 and is not a step every claim passes through, so counting it would show a paid-and-received claim as 7/8.

The seeded graph used as the fixture (internal/platform/workflow/seed.go, DefaultExpenseClaim): states draft(draft,1) pending_review(pending,2) reviewed(pending,3) approved(pending,4) category_verified(accrual,5) completed(payout,6) received(acknowledged,7, terminal) rejected(rejected,8) disputed(dispute,9). Main path: 7 states.

  • Step 1: workflow namespace strings

web/src/lib/i18n/locales/en/workflow.json:

{
"filterLabel": "Status",
"all": "All",
"progress": "Progress ({{done}}/{{total}})",
"current": "In progress",
"notStarted": "Not started",
"reason": "Reason: {{reason}}",
"notAllowed": "You can't take this action right now.",
"reasonDialog": {
"label": "Reason",
"hint": "This is kept on the record and shown to the people involved.",
"confirm": "Confirm"
}
}

web/src/lib/i18n/locales/ms/workflow.json:

{
"filterLabel": "Status",
"all": "Semua",
"progress": "Kemajuan ({{done}}/{{total}})",
"current": "Sedang diproses",
"notStarted": "Belum bermula",
"reason": "Sebab: {{reason}}",
"notAllowed": "Anda tidak boleh mengambil tindakan ini sekarang.",
"reasonDialog": {
"label": "Sebab",
"hint": "Ini disimpan dalam rekod dan dipaparkan kepada pihak yang terlibat.",
"confirm": "Sahkan"
}
}

web/src/lib/i18n/locales/zh/workflow.json:

{
"filterLabel": "主状态",
"all": "全部",
"progress": "审批进度({{done}}/{{total}})",
"current": "当前处理中",
"notStarted": "未开始",
"reason": "原因:{{reason}}",
"notAllowed": "您目前无法执行此操作。",
"reasonDialog": {
"label": "原因",
"hint": "原因将记录在案,并向相关人员显示。",
"confirm": "确认"
}
}
  • Step 2: The fixture, from the seeded graph

Append to web/src/lib/api/types.ts:

export type WorkflowEdge = Schemas['WorkflowEdge'];
export type MemberDirectoryEntry = Schemas['MemberDirectoryEntry'];

web/src/components/workflow/__fixtures__/expenseClaim.ts:

import type { AvailableTransition, WorkflowDefinition, WorkflowEdge, WorkflowSemantic, WorkflowState } from '@/lib/api/types';
// Mirrors DefaultExpenseClaim in internal/platform/workflow/seed.go (v1 of every organization).
const ls = (en: string, zh: string, ms: string) => ({ en, zh, ms });
const st = (display_order: number, key: string, semantic: WorkflowSemantic, is_terminal: boolean, labels: Record<string, string>): WorkflowState => ({
key,
semantic,
display_order,
is_terminal,
labels,
...(semantic === 'accrual' ? { control_kind: 'ap' as const } : {}),
});
const ed = (
display_order: number,
from: string,
to: string,
gate: WorkflowEdge['gate'],
action_key: string,
labels: Record<string, string>,
requires_reason = false,
reverses_accrual = false,
): WorkflowEdge => ({ from, to, gate, action_key, requires_reason, reverses_accrual, display_order, labels });
const reject = ls('Reject', '拒绝', 'Tolak');
export const expenseClaimDefinition: WorkflowDefinition = {
id: '0190a000-0000-7000-8000-00000000d001',
kind: 'expense_claim',
version: 1,
published_at: '2026-01-01T00:00:00Z',
published_by: null,
graph: {
kind: 'expense_claim',
posting_order: 'accrual_first',
states: [
st(1, 'draft', 'draft', false, ls('Draft', '草稿', 'Draf')),
st(2, 'pending_review', 'pending', false, ls('Pending review', '待审核', 'Menunggu semakan')),
st(3, 'reviewed', 'pending', false, ls('Reviewed', '已审核', 'Disemak')),
st(4, 'approved', 'pending', false, ls('Approved', '已批准', 'Diluluskan')),
st(5, 'category_verified', 'accrual', false, ls('Category verified', '类别已核实', 'Kategori disahkan')),
st(6, 'completed', 'payout', false, ls('Paid', '已付款', 'Telah dibayar')),
st(7, 'received', 'acknowledged', true, ls('Received', '已收款', 'Telah diterima')),
st(8, 'rejected', 'rejected', false, ls('Rejected', '已拒绝', 'Ditolak')),
st(9, 'disputed', 'dispute', false, ls('Disputed', '有争议', 'Dipertikaikan')),
],
edges: [
ed(1, 'draft', 'pending_review', 'reporter', 'submit', ls('Submit', '提交', 'Hantar')),
ed(2, 'pending_review', 'reviewed', 'target', 'review', ls('Mark reviewed', '审核通过', 'Tandakan disemak')),
ed(3, 'reviewed', 'approved', 'target', 'approve', ls('Approve', '批准', 'Luluskan')),
ed(4, 'approved', 'category_verified', 'target', 'verify_category', ls('Verify category', '核实类别', 'Sahkan kategori')),
ed(5, 'category_verified', 'completed', 'target', 'pay', ls('Mark paid', '标记已付款', 'Tandakan telah dibayar')),
ed(6, 'completed', 'received', 'reporter', 'acknowledge', ls('Confirm receipt', '确认收款', 'Sahkan penerimaan')),
ed(7, 'pending_review', 'rejected', 'advance', 'reject', reject, true),
ed(8, 'reviewed', 'rejected', 'advance', 'reject', reject, true),
ed(9, 'approved', 'rejected', 'advance', 'reject', reject, true),
ed(10, 'category_verified', 'rejected', 'advance', 'reject', reject, true, true),
ed(11, 'rejected', 'draft', 'reporter', 'revise', ls('Revise', '修改', 'Semak semula')),
ed(12, 'completed', 'disputed', 'reporter', 'dispute', ls('Raise dispute', '提出争议', 'Ajukan pertikaian'), true),
ed(13, 'received', 'disputed', 'reporter', 'dispute', ls('Raise dispute', '提出争议', 'Ajukan pertikaian'), true),
ed(14, 'disputed', 'received', 'source', 'dismiss', ls('Dismiss dispute', '驳回争议', 'Ketepikan pertikaian')),
ed(15, 'disputed', 'rejected', 'source', 'uphold', ls('Uphold dispute', '支持争议', 'Terima pertikaian'), true, true),
],
// Grants do not affect rendering; the seeded ones name positions this fixture has no ids for.
grants: [],
},
};
/** The exits of pending_review as an approver sees them (review allowed, reject allowed with reason). */
export const pendingReviewTransitions: AvailableTransition[] = [
{ to: 'reviewed', action_key: 'review', gate: 'target', requires_reason: false, allowed: true, labels: ls('Mark reviewed', '审核通过', 'Tandakan disemak') },
{ to: 'rejected', action_key: 'reject', gate: 'advance', requires_reason: true, allowed: true, labels: reject },
];
export const MEMBERS = {
employee: '0190a000-0000-7000-8000-0000000000a1',
approver: '0190a000-0000-7000-8000-0000000000a2',
owner: '0190a000-0000-7000-8000-0000000000a3',
unknown: '0190a000-0000-7000-8000-00000000beef',
};
export const memberDirectory = {
members: [
{ member_id: MEMBERS.employee, name: 'Tan Wei Ming', active: true },
{ member_id: MEMBERS.approver, name: 'Siti Nurhaliza binti Ahmad', active: true },
{ member_id: MEMBERS.owner, name: null, active: true },
],
};

Verify: pnpm -C web typecheck. If WorkflowDefinition rejects published_by: null or the grant array, the generated types say what the spec says; follow them. If MemberDirectoryEntry is missing, Task 1 named it differently; use its name and amend the registry.

  • Step 3: Failing tests — tone, names, neighbours

web/src/components/workflow/semanticTone.test.ts:

import { describe, expect, it } from 'vitest';
import { SEMANTICS } from '@contracts/vocab';
import { semanticTone } from './semanticTone';
describe('semanticTone (spec §3.4, F9)', () => {
it('maps every semantic in the vocabulary', () => {
expect(SEMANTICS.length).toBeGreaterThan(0);
const table = Object.fromEntries(SEMANTICS.map((s) => [s, semanticTone(s)]));
expect(table).toEqual({
draft: 'neutral',
pending: 'amber',
accrual: 'green',
payout: 'green-solid',
acknowledged: 'teal',
dispute: 'purple',
rejected: 'red',
cancelled: 'muted',
custom: 'blue',
});
});
});

web/src/components/workflow/useNeighbours.test.ts:

import { renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useNeighbours } from './useNeighbours';
const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
describe('useNeighbours', () => {
it('finds prev and next in the loaded list', () => {
expect(renderHook(() => useNeighbours(items, 'b')).result.current).toEqual({ index: 1, total: 3, prev: 'a', next: 'c' });
expect(renderHook(() => useNeighbours(items, 'a')).result.current).toEqual({ index: 0, total: 3, prev: undefined, next: 'b' });
expect(renderHook(() => useNeighbours(items, 'c')).result.current).toEqual({ index: 2, total: 3, prev: 'b', next: undefined });
});
it('has no neighbours for an id not in the list (opened from a link)', () => {
expect(renderHook(() => useNeighbours(items, 'z')).result.current).toEqual({ index: -1, total: 3, prev: undefined, next: undefined });
});
});

web/src/features/members/useMemberNames.test.tsx:

import { renderHook, waitFor } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import type { ReactNode } from 'react';
import { describe, expect, it } from 'vitest';
import { MEMBERS, memberDirectory } from '@/components/workflow/__fixtures__/expenseClaim';
import { apiPath } from '@/test/msw/handlers';
import { server } from '@/test/msw/server';
import { createTestQueryClient } from '@/test/render';
import { useMemberNames } from './useMemberNames';
describe('useMemberNames', () => {
it('names members from the directory, and falls back to …last4', async () => {
server.use(http.get(apiPath('/org/members'), () => HttpResponse.json(memberDirectory)));
const client = createTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider>;
const { result } = renderHook(() => useMemberNames(), { wrapper });
await waitFor(() => expect(result.current(MEMBERS.employee)).toBe('Tan Wei Ming'));
expect(result.current(MEMBERS.owner)).toBe('…00a3'); // in the directory, but no name
expect(result.current(MEMBERS.unknown)).toBe('…beef');
expect(result.current(null)).toBe('');
});
});

Run: pnpm -C web test -- src/components/workflow src/features/members — Expected: FAIL (modules not found).

  • Step 4: Implement tone, definition hook, neighbours, member names

web/src/components/workflow/semanticTone.ts:

import type { TagTone } from '@/components/ui/Tag';
import type { WorkflowSemantic } from '@/lib/api/types';
// Keyed on SEMANTIC, never on a state key (F9): a tenant's custom step gets a correct blue chip
// with no release (spec §13.4).
const TONE: Record<WorkflowSemantic, TagTone> = {
draft: 'neutral',
pending: 'amber',
accrual: 'green',
payout: 'green-solid',
acknowledged: 'teal',
dispute: 'purple',
rejected: 'red',
cancelled: 'muted',
custom: 'blue',
};
export function semanticTone(s: WorkflowSemantic): TagTone {
return TONE[s];
}

web/src/components/workflow/useWorkflowDefinition.ts:

import { queryOptions, useQuery, type UseQueryResult } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
import type { WorkflowDefinition } from '@/lib/api/types';
/**
* A published version never changes, so it is cached for the session (staleTime Infinity).
* "Latest" can change when an administrator publishes; Task 20 invalidates ['workflow-definition'].
*/
export function workflowDefinitionQuery(kind: string, version?: number) {
return queryOptions({
queryKey: ['workflow-definition', { kind, version: version ?? 'latest' }],
queryFn: async (): Promise<WorkflowDefinition> =>
version === undefined
? unwrap(await api.GET('/workflow/definitions/{kind}', { params: { path: { kind } } }))
: unwrap(await api.GET('/workflow/definitions/{kind}/versions/{version}', { params: { path: { kind, version } } })),
staleTime: version === undefined ? 5 * 60_000 : Infinity,
});
}
export function useWorkflowDefinition(kind: string, version?: number): UseQueryResult<WorkflowDefinition> {
return useQuery(workflowDefinitionQuery(kind, version));
}

web/src/components/workflow/useNeighbours.ts:

import { useMemo } from 'react';
/** The drawers' ‹ n/N › pager over the list already loaded (P5): it never fetches. */
export function useNeighbours<T extends { id: string }>(
items: T[],
id: string,
): { index: number; total: number; prev?: string; next?: string } {
return useMemo(() => {
const index = items.findIndex((i) => i.id === id);
return {
index,
total: items.length,
prev: index > 0 ? items[index - 1]?.id : undefined,
next: index >= 0 ? items[index + 1]?.id : undefined,
};
}, [items, id]);
}

web/src/features/members/useMemberNames.ts:

import { useQuery, type UseQueryResult } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { api } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
import type { MemberDirectoryEntry } from '@/lib/api/types';
/** GET /org/members (S2): names and ids of the caller's own organization. */
export function useMemberDirectory(): UseQueryResult<MemberDirectoryEntry[]> {
return useQuery({
queryKey: ['members', 'directory'],
queryFn: async () => unwrap(await api.GET('/org/members')).members,
staleTime: 5 * 60_000,
});
}
/** member id → name; "…" + the last 4 characters of the id when there is no name to show. */
export function useMemberNames(): (memberId: string | null | undefined) => string {
const { data } = useMemberDirectory();
const names = useMemo(() => new Map((data ?? []).map((m) => [m.member_id, m.name])), [data]);
return useCallback(
(memberId) => (memberId ? names.get(memberId) || `…${memberId.slice(-4)}` : ''),
[names],
);
}

Run: pnpm -C web test -- src/components/workflow/semanticTone.test.ts src/components/workflow/useNeighbours.test.ts src/features/members — Expected: PASS.

  • Step 5: StatusChip and StateFilterChips — failing tests

web/src/components/workflow/StatusChip.test.tsx:

import { screen } from '@testing-library/react';
import i18n from 'i18next';
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { apiPath } from '@/test/msw/handlers';
import { server } from '@/test/msw/server';
import { renderWithQuery } from '@/test/render';
import { expenseClaimDefinition } from './__fixtures__/expenseClaim';
import { StatusChip } from './StatusChip';
const serveDefinition = () =>
server.use(http.get(apiPath('/workflow/definitions/expense_claim'), () => HttpResponse.json(expenseClaimDefinition)));
describe('StatusChip', () => {
it('labels from the definition and tones by semantic', async () => {
serveDefinition();
renderWithQuery(<StatusChip kind="expense_claim" state="pending_review" />);
const chip = await screen.findByText('Pending review');
expect(chip).toHaveClass('text-tone-amber-fg');
});
it('uses the UI locale', async () => {
serveDefinition();
await i18n.changeLanguage('zh');
renderWithQuery(<StatusChip kind="expense_claim" state="completed" />);
expect(await screen.findByText('已付款')).toHaveClass('text-tone-green-solid-fg');
});
it('a pinned version is fetched by version', async () => {
server.use(
http.get(apiPath('/workflow/definitions/expense_claim/versions/1'), () => HttpResponse.json(expenseClaimDefinition)),
);
renderWithQuery(<StatusChip kind="expense_claim" version={1} state="rejected" />);
expect(await screen.findByText('Rejected')).toHaveClass('text-tone-red-fg');
});
it('without workflow.definition.read it still shows the state, toned by the semantic the record carries', async () => {
server.use(
http.get(apiPath('/workflow/definitions/expense_claim'), () =>
HttpResponse.json({ type: 'x/forbidden', title: 'Forbidden', status: 403 }, { status: 403 }),
),
);
renderWithQuery(<StatusChip kind="expense_claim" state="pending_review" semantic="pending" />);
expect(await screen.findByText('pending_review')).toHaveClass('text-tone-amber-fg');
});
});

web/src/components/workflow/StateFilterChips.test.tsx:

import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { apiPath } from '@/test/msw/handlers';
import { server } from '@/test/msw/server';
import { renderWithQuery } from '@/test/render';
import { expenseClaimDefinition } from './__fixtures__/expenseClaim';
import { StateFilterChips } from './StateFilterChips';
beforeEach(() =>
server.use(http.get(apiPath('/workflow/definitions/expense_claim'), () => HttpResponse.json(expenseClaimDefinition))),
);
describe('StateFilterChips', () => {
it('All first, then every state in display order, zero counts included', async () => {
renderWithQuery(
<StateFilterChips kind="expense_claim" counts={{ draft: 1, pending_review: 2 }} total={6} value={undefined} onChange={() => {}} />,
);
const group = await screen.findByRole('group', { name: 'Status' });
const buttons = await within(group).findAllByRole('button');
expect(buttons.map((b) => b.textContent)).toEqual([
'All6',
'Draft1',
'Pending review2',
'Reviewed0',
'Approved0',
'Category verified0',
'Paid0',
'Received0',
'Rejected0',
'Disputed0',
]);
expect(buttons[0]).toHaveAttribute('aria-pressed', 'true');
});
it('selecting a state, and All clearing it', async () => {
const onChange = vi.fn();
renderWithQuery(<StateFilterChips kind="expense_claim" counts={{}} value="draft" onChange={onChange} />);
expect(await screen.findByRole('button', { name: /Draft/ })).toHaveAttribute('aria-pressed', 'true');
await userEvent.click(screen.getByRole('button', { name: /Rejected/ }));
expect(onChange).toHaveBeenLastCalledWith('rejected');
await userEvent.click(screen.getByRole('button', { name: /All/ }));
expect(onChange).toHaveBeenLastCalledWith(undefined);
});
});

Run: pnpm -C web test -- src/components/workflow/StatusChip.test.tsx src/components/workflow/StateFilterChips.test.tsx — Expected: FAIL (modules not found).

  • Step 6: Implement StatusChip and StateFilterChips

web/src/components/workflow/StatusChip.tsx:

import { Skeleton } from '@/components/ui/Skeleton';
import { Tag } from '@/components/ui/Tag';
import { pickLabel, useLocale } from '@/lib/i18n';
import type { WorkflowSemantic } from '@/lib/api/types';
import { semanticTone } from './semanticTone';
import { useWorkflowDefinition } from './useWorkflowDefinition';
/**
* The one place a workflow state is rendered as a chip. `semantic` is what the record itself
* carries (ExpenseClaim.semantic); it wins over the definition's, and it keeps the tone right
* for a member who cannot read definitions (workflow.definition.read), who then sees the key.
*/
export function StatusChip(p: { kind: string; version?: number; state: string; semantic?: WorkflowSemantic }) {
const [locale] = useLocale();
const def = useWorkflowDefinition(p.kind, p.version);
if (def.isPending) return <Skeleton className="inline-block h-6 w-16" />;
const st = def.data?.graph.states.find((s) => s.key === p.state);
const tone = semanticTone(p.semantic ?? st?.semantic ?? 'custom');
return <Tag tone={tone}>{(st && pickLabel(st.labels, locale)) || p.state}</Tag>;
}

web/src/components/workflow/StateFilterChips.tsx:

import { useTranslation } from 'react-i18next';
import { CountBadge } from '@/components/ui/CountBadge';
import { Skeleton } from '@/components/ui/Skeleton';
import { cn } from '@/lib/cn';
import { pickLabel, useLocale } from '@/lib/i18n';
import { useWorkflowDefinition } from './useWorkflowDefinition';
function Chip({ label, count, pressed, onClick }: { label: string; count?: number; pressed: boolean; onClick(): void }) {
return (
<button
type="button"
aria-pressed={pressed}
onClick={onClick}
className={cn(
'inline-flex h-8 items-center gap-1.5 rounded-control px-2 text-body',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus',
pressed ? 'bg-primary-soft font-medium text-fg' : 'text-fg-muted hover:bg-bg-subtle',
)}
>
{label}
{count !== undefined && <CountBadge count={count} active={pressed} />}
</button>
);
}
/**
* "主状态 全部 6 草稿 待审核 2 …" (Figma 123:7183): All first, then the definition's states in
* display_order. Counts come from the S4/S6 summary; a state with no rows shows 0 so the row
* never jumps. While counts load, no badges are shown.
*/
export function StateFilterChips(p: {
kind: string;
counts: Record<string, number> | undefined;
value: string | undefined;
onChange(state: string | undefined): void;
total?: number;
}) {
const { t } = useTranslation('workflow');
const [locale] = useLocale();
const def = useWorkflowDefinition(p.kind);
const states = [...(def.data?.graph.states ?? [])].sort((a, b) => a.display_order - b.display_order);
const total = p.total ?? (p.counts ? Object.values(p.counts).reduce((a, b) => a + b, 0) : undefined);
return (
<div role="group" aria-label={t('filterLabel')} className="flex flex-wrap items-center gap-1">
<span aria-hidden className="mr-2 text-body text-fg-muted">
{t('filterLabel')}
</span>
<Chip label={t('all')} count={total} pressed={p.value === undefined} onClick={() => p.onChange(undefined)} />
{def.isPending && <Skeleton className="h-8 w-64" />}
{states.map((s) => (
<Chip
key={s.key}
label={pickLabel(s.labels, locale) || s.key}
count={p.counts ? (p.counts[s.key] ?? 0) : undefined}
pressed={p.value === s.key}
onClick={() => p.onChange(s.key)}
/>
))}
</div>
);
}

Run: pnpm -C web test -- src/components/workflow/StatusChip.test.tsx src/components/workflow/StateFilterChips.test.tsx — Expected: PASS.

  • Step 7: TransitionBar and ReasonDialog — failing test

web/src/components/workflow/TransitionBar.test.tsx:

import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AvailableTransition } from '@/lib/api/types';
import { apiPath } from '@/test/msw/handlers';
import { server } from '@/test/msw/server';
import { renderWithQuery } from '@/test/render';
import { expenseClaimDefinition, pendingReviewTransitions } from './__fixtures__/expenseClaim';
import { TransitionBar } from './TransitionBar';
beforeEach(() =>
server.use(http.get(apiPath('/workflow/definitions/expense_claim'), () => HttpResponse.json(expenseClaimDefinition))),
);
describe('TransitionBar', () => {
it('one button per exit; primary for the forward edge; danger outline for an edge into a rejected state', async () => {
renderWithQuery(<TransitionBar kind="expense_claim" transitions={pendingReviewTransitions} onTake={vi.fn()} />);
const review = await screen.findByRole('button', { name: 'Mark reviewed' });
const reject = screen.getByRole('button', { name: 'Reject' });
expect(review).toHaveClass('bg-primary');
await waitFor(() => expect(reject).toHaveClass('border-tone-red-accent'));
// bar layout puts danger first, primary last (Figma: 驳回 left, 通过审批 right)
const order = screen.getAllByRole('button').map((b) => b.textContent);
expect(order).toEqual(['Reject', 'Mark reviewed']);
});
it('takes a plain transition directly', async () => {
const onTake = vi.fn().mockResolvedValue(undefined);
renderWithQuery(<TransitionBar kind="expense_claim" transitions={pendingReviewTransitions} onTake={onTake} />);
await userEvent.click(await screen.findByRole('button', { name: 'Mark reviewed' }));
expect(onTake).toHaveBeenCalledWith(pendingReviewTransitions[0], undefined);
});
it('asks for a reason first when the edge requires one', async () => {
const onTake = vi.fn().mockResolvedValue(undefined);
renderWithQuery(<TransitionBar kind="expense_claim" transitions={pendingReviewTransitions} onTake={onTake} />);
await userEvent.click(await screen.findByRole('button', { name: 'Reject' }));
const dialog = await screen.findByRole('dialog', { name: 'Reject' });
const confirm = screen.getByRole('button', { name: 'Confirm' });
expect(confirm).toBeDisabled(); // no empty reasons
expect(onTake).not.toHaveBeenCalled();
await userEvent.type(screen.getByLabelText('Reason'), 'Receipt is unreadable');
await userEvent.click(confirm);
expect(onTake).toHaveBeenCalledWith(pendingReviewTransitions[1], 'Receipt is unreadable');
await waitFor(() => expect(dialog).not.toBeInTheDocument());
});
it('shows a refused exit disabled, with the server’s reason as its description', async () => {
const refused: AvailableTransition = {
...pendingReviewTransitions[0]!,
allowed: false,
refusal: { code: 'not_eligible', detail: 'Only the claimant’s superior can review this claim.' },
};
renderWithQuery(<TransitionBar kind="expense_claim" transitions={[refused]} onTake={vi.fn()} />);
const b = await screen.findByRole('button', { name: 'Mark reviewed' });
expect(b).toBeDisabled();
expect(b).toHaveAccessibleDescription('Only the claimant’s superior can review this claim.');
});
it('without a kind it still renders every button, with no danger styling', async () => {
renderWithQuery(<TransitionBar transitions={pendingReviewTransitions} onTake={vi.fn()} />);
expect(await screen.findByRole('button', { name: 'Reject' })).not.toHaveClass('border-tone-red-accent');
});
});

Run: pnpm -C web test -- src/components/workflow/TransitionBar.test.tsx — Expected: FAIL (module not found).

  • Step 8: Implement ReasonDialog and TransitionBar

web/src/components/workflow/ReasonDialog.tsx:

import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Dialog } from '@/components/ui/Dialog';
import { Field } from '@/components/ui/Field';
import { Textarea } from '@/components/ui/Textarea';
import { useLocale } from '@/lib/i18n';
/** A reason is record text: in zh the record-language hint shows, and the server's 422 decides (F8). */
export function ReasonDialog(p: { open: boolean; title: string; onConfirm(reason: string): void; onCancel(): void }) {
const { t } = useTranslation(['workflow', 'common']);
const [locale] = useLocale();
const [reason, setReason] = useState('');
useEffect(() => {
if (p.open) setReason('');
}, [p.open]);
const trimmed = reason.trim();
return (
<Dialog
open={p.open}
onOpenChange={(o) => !o && p.onCancel()}
title={p.title}
footer={
<>
<Button variant="outline" onClick={p.onCancel}>
{t('common:actions.cancel')}
</Button>
<Button disabled={!trimmed} onClick={() => p.onConfirm(trimmed)}>
{t('reasonDialog.confirm')}
</Button>
</>
}
>
<Field
label={t('reasonDialog.label')}
hint={locale === 'zh' ? `${t('reasonDialog.hint')} ${t('common:hints.recordLocale')}` : t('reasonDialog.hint')}
>
<Textarea value={reason} onChange={(e) => setReason(e.target.value)} rows={4} autoFocus />
</Field>
</Dialog>
);
}

web/src/components/workflow/TransitionBar.tsx:

import { useQuery } from '@tanstack/react-query';
import { useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { toast } from '@/components/ui/Toaster';
import { Tooltip } from '@/components/ui/Tooltip';
import { problemMessage } from '@/lib/api/problem';
import type { AvailableTransition, WorkflowSemantic } from '@/lib/api/types';
import { cn } from '@/lib/cn';
import { pickLabel, useLocale } from '@/lib/i18n';
import { ReasonDialog } from './ReasonDialog';
import { workflowDefinitionQuery } from './useWorkflowDefinition';
const DANGER: WorkflowSemantic[] = ['rejected', 'cancelled'];
const NOT_PRIMARY: WorkflowSemantic[] = ['rejected', 'cancelled', 'dispute'];
const keyOf = (t: AvailableTransition) => `${t.action_key}:${t.to}`;
/**
* One button per exit of the current state, from available_transitions (spec §4). The frontend
* never evaluates a gate: `allowed` and `refusal` are the server's. Failures are reported here
* as a toast, so callers should not toast them again.
*/
export function TransitionBar(p: {
transitions: AvailableTransition[];
onTake(t: AvailableTransition, reason?: string): Promise<unknown>;
layout?: 'bar' | 'inline';
/** Needed to style edges by their TARGET's semantic (danger for rejected/cancelled). */
kind?: string;
version?: number;
}) {
const { t } = useTranslation(['workflow', 'common']);
const [locale] = useLocale();
const baseId = useId();
const def = useQuery({ ...workflowDefinitionQuery(p.kind ?? '', p.version), enabled: !!p.kind });
const [busy, setBusy] = useState<string>();
const [askReason, setAskReason] = useState<AvailableTransition>();
const target = (tr: AvailableTransition) => def.data?.graph.states.find((s) => s.key === tr.to)?.semantic;
const isDanger = (tr: AvailableTransition) => {
const s = target(tr);
return s !== undefined && DANGER.includes(s);
};
const primary = p.transitions.find((tr) => {
const s = target(tr);
return tr.allowed && (s === undefined || !NOT_PRIMARY.includes(s));
});
const label = (tr: AvailableTransition) => pickLabel(tr.labels, locale) || tr.action_key;
const take = async (tr: AvailableTransition, reason?: string) => {
setBusy(keyOf(tr));
try {
await p.onTake(tr, reason);
} catch (e) {
toast.error(problemMessage(e, t));
} finally {
setBusy(undefined);
}
};
const ordered =
(p.layout ?? 'bar') === 'bar'
? [
...p.transitions.filter((tr) => tr !== primary && isDanger(tr)),
...p.transitions.filter((tr) => tr !== primary && !isDanger(tr)),
...(primary ? [primary] : []),
]
: p.transitions;
return (
<>
<div className={cn('flex gap-3', (p.layout ?? 'bar') === 'bar' ? 'w-full [&>*]:flex-1' : 'flex-wrap')}>
{ordered.map((tr, i) => {
const k = keyOf(tr);
const variant = tr === primary ? 'primary' : isDanger(tr) ? 'danger-outline' : 'outline';
const button = (
<Button
key={k}
variant={variant}
disabled={!tr.allowed || (busy !== undefined && busy !== k)}
loading={busy === k}
aria-describedby={tr.allowed ? undefined : `${baseId}-${i}`}
onClick={() => (tr.requires_reason ? setAskReason(tr) : void take(tr))}
className="w-full"
>
{label(tr)}
</Button>
);
if (tr.allowed) return button;
const why = tr.refusal?.detail || t('notAllowed');
// A disabled button can be neither hovered nor focused, so the wrapper carries the tooltip.
return (
<Tooltip key={k} content={why}>
<span tabIndex={0} className="inline-flex">
{button}
<span id={`${baseId}-${i}`} className="sr-only">
{why}
</span>
</span>
</Tooltip>
);
})}
</div>
<ReasonDialog
open={askReason !== undefined}
title={askReason ? label(askReason) : ''}
onCancel={() => setAskReason(undefined)}
onConfirm={(reason) => {
const tr = askReason;
setAskReason(undefined);
if (tr) void take(tr, reason);
}}
/>
</>
);
}

Run: pnpm -C web test -- src/components/workflow/TransitionBar.test.tsx — Expected: PASS.

Watch it fail once: in TransitionBar.tsx, change DANGER to ['cancelled']. The first test fails on border-tone-red-accent. Restore.

  • Step 9: DecisionTimeline — failing test

web/src/components/workflow/DecisionTimeline.test.tsx:

import { screen, within } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';
import { apiPath } from '@/test/msw/handlers';
import { server } from '@/test/msw/server';
import { renderWithQuery } from '@/test/render';
import { expenseClaimDefinition, MEMBERS, memberDirectory } from './__fixtures__/expenseClaim';
import { DecisionTimeline, mainPath, timelineProgress, type TimelineDecision } from './DecisionTimeline';
const submitted: TimelineDecision = { from: 'draft', to: 'pending_review', actor_member_id: MEMBERS.employee, decided_at: '2026-08-28T06:15:00Z' };
const reviewed: TimelineDecision = { from: 'pending_review', to: 'reviewed', actor_member_id: MEMBERS.approver, decided_at: '2026-08-28T08:20:00Z' };
const rejected: TimelineDecision = {
from: 'reviewed',
to: 'rejected',
actor_member_id: MEMBERS.approver,
decided_at: '2026-08-28T09:00:00Z',
reason: 'Receipt is unreadable',
};
beforeEach(() =>
server.use(
http.get(apiPath('/workflow/definitions/expense_claim'), () => HttpResponse.json(expenseClaimDefinition)),
http.get(apiPath('/org/members'), () => HttpResponse.json(memberDirectory)),
),
);
describe('progress rule', () => {
it('the main path excludes off-path semantics', () => {
expect(mainPath(expenseClaimDefinition).map((s) => s.key)).toEqual([
'draft', 'pending_review', 'reviewed', 'approved', 'category_verified', 'completed', 'received',
]);
});
it('counts distinct main-path states reached', () => {
expect(timelineProgress(expenseClaimDefinition, [], 'draft')).toEqual({ done: 1, total: 7 });
expect(timelineProgress(expenseClaimDefinition, [submitted, reviewed], 'reviewed')).toEqual({ done: 3, total: 7 });
// Rejected is off the path: the count stays at what was reached.
expect(timelineProgress(expenseClaimDefinition, [submitted, reviewed, rejected], 'rejected')).toEqual({ done: 3, total: 7 });
});
});
describe('DecisionTimeline', () => {
it('decided steps with actors, the current one marked, then the remaining main path', async () => {
server.use(
http.get(apiPath('/workflow/definitions/expense_claim/versions/1'), () => HttpResponse.json(expenseClaimDefinition)),
);
renderWithQuery(
<DecisionTimeline kind="expense_claim" version={1} currentState="reviewed" decisions={[submitted, reviewed]} />,
);
expect(await screen.findByRole('heading', { name: 'Progress (3/7)' })).toBeInTheDocument();
const items = screen.getAllByRole('listitem');
expect(items.map((li) => within(li).getByTestId('state').textContent)).toEqual([
'Pending review', 'Reviewed', 'Approved', 'Category verified', 'Paid', 'Received',
]);
expect(await within(items[0]!).findByText('Tan Wei Ming')).toBeInTheDocument();
expect(items[1]).toHaveAttribute('aria-current', 'step');
expect(within(items[2]!).getByText('Not started')).toBeInTheDocument();
});
it('a rejection shows its reason and no pending steps after it', async () => {
renderWithQuery(
<DecisionTimeline kind="expense_claim" currentState="rejected" decisions={[submitted, reviewed, rejected]} />,
);
expect(await screen.findByText('Reason: Receipt is unreadable')).toBeInTheDocument();
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(3);
expect(items[2]).toHaveAttribute('aria-current', 'step');
});
it('a draft with no decisions shows only its current state', async () => {
renderWithQuery(<DecisionTimeline kind="expense_claim" currentState="draft" decisions={[]} />);
expect(await screen.findByRole('heading', { name: 'Progress (1/7)' })).toBeInTheDocument();
expect(within(screen.getAllByRole('listitem')[0]!).getByTestId('state')).toHaveTextContent('Draft');
});
});

Run: pnpm -C web test -- src/components/workflow/DecisionTimeline.test.tsx — Expected: FAIL (module not found).

  • Step 10: Implement DecisionTimeline

web/src/components/workflow/DecisionTimeline.tsx:

import { CheckCircle2, Circle, CircleDot } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Skeleton } from '@/components/ui/Skeleton';
import { useMemberNames } from '@/features/members/useMemberNames';
import type { WorkflowDefinition, WorkflowSemantic, WorkflowState } from '@/lib/api/types';
import { cn } from '@/lib/cn';
import { formatDateTime } from '@/lib/date';
import { pickLabel, useLocale } from '@/lib/i18n';
import { useWorkflowDefinition } from './useWorkflowDefinition';
export interface TimelineDecision {
from: string;
to: string;
actor_member_id: string;
decided_at: string;
reason?: string;
action_key?: string;
}
// States a claim does not pass through on its way to the end: they are not steps of the path.
const OFF_PATH: WorkflowSemantic[] = ['rejected', 'cancelled', 'dispute'];
export function mainPath(def: WorkflowDefinition): WorkflowState[] {
return [...def.graph.states]
.filter((s) => !OFF_PATH.includes(s.semantic))
.sort((a, b) => a.display_order - b.display_order);
}
/** Figma's "报销进度 (n/m)": distinct main-path states reached over the main path's length. */
export function timelineProgress(def: WorkflowDefinition, decisions: TimelineDecision[], currentState: string) {
const reached = new Set([decisions[0]?.from ?? currentState, ...decisions.map((d) => d.to), currentState]);
const path = mainPath(def);
return { done: path.filter((s) => reached.has(s.key)).length, total: path.length };
}
export function DecisionTimeline(p: {
kind: string;
version?: number;
currentState: string;
decisions: TimelineDecision[];
}) {
const { t } = useTranslation('workflow');
const [locale] = useLocale();
const nameOf = useMemberNames();
const def = useWorkflowDefinition(p.kind, p.version);
if (!def.data) return <Skeleton className="h-40 w-full" />;
const states = def.data.graph.states;
const labelOf = (key: string) => pickLabel(states.find((s) => s.key === key)?.labels, locale) || key;
const { done, total } = timelineProgress(def.data, p.decisions, p.currentState);
const decided = p.decisions.length
? p.decisions.map((d, i) => ({ key: `${i}-${d.to}`, state: d.to, decision: d, current: i === p.decisions.length - 1 }))
: [{ key: `0-${p.currentState}`, state: p.currentState, decision: undefined, current: true }];
const path = mainPath(def.data);
const here = path.find((s) => s.key === p.currentState);
const remaining = here ? path.filter((s) => s.display_order > here.display_order) : [];
return (
<section>
<h3 className="mb-3 text-body font-bold text-fg">{t('progress', { done, total })}</h3>
<ol className="flex flex-col">
{decided.map((row, i) => (
<li key={row.key} aria-current={row.current ? 'step' : undefined} className="relative flex gap-3 pb-4">
<Rail last={i === decided.length - 1 && remaining.length === 0} done />
{row.current ? (
<CircleDot aria-hidden className="relative z-10 size-5 shrink-0 text-primary" />
) : (
<CheckCircle2 aria-hidden className="relative z-10 size-5 shrink-0 text-tone-green-accent" />
)}
<div className="flex min-w-0 flex-1 justify-between gap-3">
<div className="min-w-0">
<p data-testid="state" className="text-body text-fg">{labelOf(row.state)}</p>
{row.decision && <p className="text-caption text-fg-muted">{nameOf(row.decision.actor_member_id)}</p>}
{row.current && <p className="text-caption text-primary">{t('current')}</p>}
{row.decision?.reason && (
<p className="mt-1 text-caption text-fg">{t('reason', { reason: row.decision.reason })}</p>
)}
</div>
{row.decision && (
<time dateTime={row.decision.decided_at} className="shrink-0 text-caption text-fg-muted">
{formatDateTime(row.decision.decided_at, locale)}
</time>
)}
</div>
</li>
))}
{remaining.map((s, i) => (
<li key={s.key} className="relative flex gap-3 pb-4">
<Rail last={i === remaining.length - 1} done={false} />
<Circle aria-hidden className="relative z-10 size-5 shrink-0 bg-surface text-border" />
<div>
<p data-testid="state" className="text-body text-fg-muted">{pickLabel(s.labels, locale) || s.key}</p>
<p className="text-caption text-fg-muted">{t('notStarted')}</p>
</div>
</li>
))}
</ol>
</section>
);
}
function Rail({ last, done }: { last: boolean; done: boolean }) {
if (last) return null;
return (
<span
aria-hidden
className={cn('absolute top-5 bottom-0 left-[9px] w-0.5', done ? 'bg-tone-green-accent' : 'bg-border')}
/>
);
}

Run: pnpm -C web test -- src/components/workflow — Expected: PASS.

  • Step 11: Full web checks

Run: pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test Expected: PASS. No screen uses these components yet, so the runtime check is the gallery: add a “Workflow” section to web/src/dev/ui.tsx rendering StatusChip for each fixture state, StateFilterChips, TransitionBar (with one refused transition) and DecisionTimeline for a claim at reviewed, against the dev stack’s real expense_claim definition (sign in first so the queries are authorised). Check them at 1440 and 390 wide, light and dark, en and zh (/__ui renders outside the shell, so switch locale by setting localStorage['ui.locale'] with javascript_tool and reloading). Record the checks.

  • Step 12: Commit
Terminal window
git add web
git commit -m "$(cat <<'EOF'
Plan 10 Task 9: workflow components and member names
StatusChip and filter chips from the definition, toned by semantic;
TransitionBar from available_transitions with refusal reasons on disabled
exits and a reason dialog; DecisionTimeline with the main-path progress count;
member directory names; the drawer pager's neighbour hook.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"

Files:

  • Create under web/src/components/upload/: uploadDocument.ts, uploadDocument.test.ts, useDocument.ts, formatBytes.ts, DocumentUploader.tsx, DocumentUploader.test.tsx, DocumentThumb.tsx, DocumentViewer.tsx, DocumentThumb.test.tsx, DocumentViewer.test.tsx
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/documents.json
  • Modify: web/src/lib/api/types.ts (append)

Interfaces:

  • Consumes: api, unwrap, ProblemError, problemMessage (Task 6); Button, IconButton, Dialog, ProgressBar, Tag, Skeleton (Task 7); renderWithQuery, apiPath, server (Task 6). API: POST /documents, POST /documents/{id}/complete, GET /documents/{id}.
  • Produces (registry): uploadDocument (@/components/upload/uploadDocument), DocumentUploader, DocumentThumb, DocumentViewer (each from @/components/upload/<same name>).
  • Produces (additions; see Part B notes): type UploadedDoc = { id: string; filename: string; size: number; content_type: string } and class UploadError (uploadDocument.ts); ACCEPTED_TYPES, DEFAULT_MAX_BYTES; useDocument(id) (useDocument.ts); formatBytes(n, locale); type ApiDocument in types.ts.

The API, as checked in api/openapi.yaml:

  • POST /documents takes CreateDocumentRequest { filename (1–255), content_type (image/jpeg | image/png | application/pdf), byte_size (1..20 MiB), sha256 (64 lowercase hex) } and answers 201 CreateDocumentResponse { document: Document, upload: UploadTicket | null }. upload is null when the same uploader already proved these exact bytes: the document returned is the verified one, and nothing is sent. It requires documents.document.upload.
  • UploadTicket { url, method: 'PUT', headers: Record<string,string>, expires_at }. The headers are signed into the URL and must be sent unchanged, so the declared type and digest bind at the store.
  • POST /documents/{id}/complete → 200 Document, now verified or rejected (with reject_reason one of size | sha256 | content_type | duplicate). A rejection is a 200, not an error status. 409 means nothing was uploaded yet. Only the uploader may complete.
  • GET /documents/{id} → Document { id, uploaded_by, filename, content_type, byte_size, sha256, status, reject_reason, created_at, verified_at, download_url, thumbnail_url, ocr_suggestions[] }. download_url is a short-lived presigned link, empty until verified; thumbnail_url is presigned and null for a PDF or an image too large to decode. The default presign TTL is 5 minutes (OBJECT_STORE_PRESIGN_TTL), so the query’s staleTime is 4 minutes, and a broken image refetches once.

The PUT goes cross-origin, straight to the object store. The store must allow the web origin through CORS (the dev stack sets it, Task 8), and Task 27’s CSP needs connect-src and img-src for the store’s public origin (see Part B notes).

Why XHR for the PUT: fetch has no upload progress. The progress bar is the only reason; everything else goes through api.

  • Step 1: documents namespace strings

web/src/lib/i18n/locales/en/documents.json:

{
"drop": "Drag receipts here, or",
"browse": "browse files",
"hint": "JPG, PNG or PDF, up to {{size}} each",
"uploading": "Uploading {{name}}",
"remove": "Remove {{name}}",
"dismiss": "Dismiss",
"checking": "Checking…",
"rejectedTag": "Rejected",
"errors": {
"tooBig": "{{name}} is larger than {{size}}.",
"badType": "{{name}} isn't a JPG, PNG or PDF.",
"failed": "{{name}} couldn't be uploaded. Try again.",
"rejected": {
"size": "{{name}} arrived incomplete. Try uploading it again.",
"sha256": "{{name}} changed while uploading. Try uploading it again.",
"content_type": "{{name}} isn't really a JPG, PNG or PDF.",
"duplicate": "You've already uploaded {{name}}."
}
},
"viewer": {
"prev": "Previous document",
"next": "Next document",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"download": "Download",
"counter": "{{n}} of {{total}}",
"pdf": "This PDF can't be previewed here.",
"openPdf": "Open the PDF",
"notReady": "This document is still being checked."
}
}

web/src/lib/i18n/locales/ms/documents.json:

{
"drop": "Seret resit ke sini, atau",
"browse": "semak imbas fail",
"hint": "JPG, PNG atau PDF, sehingga {{size}} setiap satu",
"uploading": "Memuat naik {{name}}",
"remove": "Buang {{name}}",
"dismiss": "Tutup",
"checking": "Menyemak…",
"rejectedTag": "Ditolak",
"errors": {
"tooBig": "{{name}} melebihi {{size}}.",
"badType": "{{name}} bukan fail JPG, PNG atau PDF.",
"failed": "{{name}} tidak dapat dimuat naik. Cuba lagi.",
"rejected": {
"size": "{{name}} tidak diterima dengan lengkap. Cuba muat naik semula.",
"sha256": "{{name}} berubah semasa dimuat naik. Cuba muat naik semula.",
"content_type": "{{name}} bukan fail JPG, PNG atau PDF yang sebenar.",
"duplicate": "Anda telah memuat naik {{name}} sebelum ini."
}
},
"viewer": {
"prev": "Dokumen sebelumnya",
"next": "Dokumen seterusnya",
"zoomIn": "Zum masuk",
"zoomOut": "Zum keluar",
"download": "Muat turun",
"counter": "{{n}} daripada {{total}}",
"pdf": "PDF ini tidak dapat dipratonton di sini.",
"openPdf": "Buka PDF",
"notReady": "Dokumen ini masih disemak."
}
}

web/src/lib/i18n/locales/zh/documents.json:

{
"drop": "将收据拖到此处,或",
"browse": "选择文件",
"hint": "JPG、PNG 或 PDF,每个不超过 {{size}}",
"uploading": "正在上传 {{name}}",
"remove": "移除 {{name}}",
"dismiss": "关闭",
"checking": "校验中…",
"rejectedTag": "已拒收",
"errors": {
"tooBig": "{{name}} 超过 {{size}}。",
"badType": "{{name}} 不是 JPG、PNG 或 PDF 文件。",
"failed": "{{name}} 上传失败,请重试。",
"rejected": {
"size": "{{name}} 未完整上传,请重新上传。",
"sha256": "{{name}} 在上传过程中发生变化,请重新上传。",
"content_type": "{{name}} 并非真正的 JPG、PNG 或 PDF 文件。",
"duplicate": "您已上传过 {{name}}。"
}
},
"viewer": {
"prev": "上一个文件",
"next": "下一个文件",
"zoomIn": "放大",
"zoomOut": "缩小",
"download": "下载",
"counter": "第 {{n}} / {{total}} 个",
"pdf": "此 PDF 无法在此预览。",
"openPdf": "打开 PDF",
"notReady": "此文件仍在校验中。"
}
}

Append to web/src/lib/api/types.ts (named ApiDocument because Document is the DOM’s global type):

export type ApiDocument = Schemas['Document'];
export type UploadTicket = Schemas['UploadTicket'];
  • Step 2: uploadDocument — failing test

web/src/components/upload/uploadDocument.test.ts:

import { createHash } from 'node:crypto';
import { http, HttpResponse } from 'msw';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ApiDocument } from '@/lib/api/types';
import { apiPath } from '@/test/msw/handlers';
import { server } from '@/test/msw/server';
import { uploadDocument, UploadError } from './uploadDocument';
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4]);
const file = () => new File([bytes], 'receipt_01.png', { type: 'image/png' });
const sha = createHash('sha256').update(bytes).digest('hex');
const doc = (over: Partial<ApiDocument> = {}): ApiDocument => ({
id: '0190a000-0000-7000-8000-00000000f001',
uploaded_by: '0190a000-0000-7000-8000-0000000000a1',
filename: 'receipt_01.png',
content_type: 'image/png',
byte_size: bytes.length,
sha256: sha,
status: 'pending',
reject_reason: null,
created_at: '2026-08-28T06:00:00Z',
verified_at: null,
download_url: '',
thumbnail_url: null,
ocr_suggestions: [],
...over,
});
/** Stands in for the browser's XHR: records the PUT and reports progress 50% then 100%. */
class FakeXHR {
static instances: FakeXHR[] = [];
method = '';
url = '';
headers: Record<string, string> = {};
body: unknown;
status = 0;
upload: { onprogress: ((e: { lengthComputable: boolean; loaded: number; total: number }) => void) | null } = { onprogress: null };
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
onabort: (() => void) | null = null;
constructor() {
FakeXHR.instances.push(this);
}
open(method: string, url: string) {
this.method = method;
this.url = url;
}
setRequestHeader(k: string, v: string) {
this.headers[k] = v;
}
send(body: unknown) {
this.body = body;
queueMicrotask(() => {
this.upload.onprogress?.({ lengthComputable: true, loaded: 4, total: 8 });
this.upload.onprogress?.({ lengthComputable: true, loaded: 8, total: 8 });
this.status = 200;
this.onload?.();
});
}
abort() {
this.onabort?.();
}
}
beforeEach(() => {
FakeXHR.instances = [];
vi.stubGlobal('XMLHttpRequest', FakeXHR);
});
afterEach(() => vi.unstubAllGlobals());
describe('uploadDocument', () => {
it('declares with a SHA-256, PUTs with the signed headers and progress, then completes', async () => {
let declared: Record<string, unknown> | undefined;
let completed = false;
server.use(
http.post(apiPath('/documents'), async ({ request }) => {
declared = (await request.json()) as Record<string, unknown>;
return HttpResponse.json(
{
document: doc(),
upload: {
url: 'http://store.test/erp-documents/abc?X-Amz-Signature=s',
method: 'PUT',
headers: { 'Content-Type': 'image/png', 'x-amz-checksum-sha256': 'b64', 'Content-Length': '8' },
expires_at: '2026-08-28T06:05:00Z',
},
},
{ status: 201 },
);
}),
http.post(apiPath('/documents/:id/complete'), () => {
completed = true;
return HttpResponse.json(doc({ status: 'verified', verified_at: '2026-08-28T06:00:05Z' }));
}),
);
const progress: number[] = [];
const out = await uploadDocument(file(), (p) => progress.push(p));
expect(declared).toEqual({ filename: 'receipt_01.png', content_type: 'image/png', byte_size: 8, sha256: sha });
const xhr = FakeXHR.instances[0]!;
expect(xhr.method).toBe('PUT');
expect(xhr.url).toBe('http://store.test/erp-documents/abc?X-Amz-Signature=s');
// Signed headers are sent unchanged; Content-Length is the browser's to set.
expect(xhr.headers).toEqual({ 'Content-Type': 'image/png', 'x-amz-checksum-sha256': 'b64' });
expect(progress).toEqual([50, 100]);
expect(completed).toBe(true);
expect(out).toEqual({ id: doc().id, filename: 'receipt_01.png', size: 8, content_type: 'image/png' });
});
it('sends nothing when the server already has these bytes from this uploader', async () => {
server.use(
http.post(apiPath('/documents'), () =>
HttpResponse.json({ document: doc({ status: 'verified' }), upload: null }, { status: 201 }),
),
);
const progress: number[] = [];
const out = await uploadDocument(file(), (p) => progress.push(p));
expect(FakeXHR.instances).toHaveLength(0);
expect(progress).toEqual([100]);
expect(out.id).toBe(doc().id);
});
it('a rejected completion is an error naming the reason', async () => {
server.use(
http.post(apiPath('/documents'), () =>
HttpResponse.json(
{ document: doc(), upload: { url: 'http://store.test/x', method: 'PUT', headers: {}, expires_at: '2026-08-28T06:05:00Z' } },
{ status: 201 },
),
),
http.post(apiPath('/documents/:id/complete'), () =>
HttpResponse.json(doc({ status: 'rejected', reject_reason: 'sha256' })),
),
);
await expect(uploadDocument(file())).rejects.toMatchObject({ reason: 'rejected', detail: 'sha256' });
});
it('refuses a type the server would refuse, before any request', async () => {
const txt = new File(['hello'], 'notes.txt', { type: 'text/plain' });
await expect(uploadDocument(txt)).rejects.toBeInstanceOf(UploadError);
await expect(uploadDocument(txt)).rejects.toMatchObject({ reason: 'type' });
});
});

(onUnhandledRequest: 'error' in the test setup makes the last test fail loudly if any request is sent.)

Run: pnpm -C web test -- src/components/upload/uploadDocument.test.ts — Expected: FAIL (module not found).

  • Step 3: Implement uploadDocument.ts
import { api } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
import type { ApiDocument } from '@/lib/api/types';
export const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'application/pdf'] as const;
type AcceptedType = (typeof ACCEPTED_TYPES)[number];
/** The server's ceiling (CreateDocumentRequest.byte_size ≤ 20 MiB). The uploader defaults lower. */
export const SERVER_MAX_BYTES = 20 * 1024 * 1024;
export const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
export type UploadedDoc = { id: string; filename: string; size: number; content_type: string };
export class UploadError extends Error {
constructor(
readonly reason: 'type' | 'size' | 'rejected' | 'transfer' | 'aborted',
readonly detail?: string,
) {
super(detail ? `${reason}: ${detail}` : reason);
this.name = 'UploadError';
}
}
function readAsArrayBuffer(file: File): Promise<ArrayBuffer> {
// FileReader rather than file.arrayBuffer(): identical in browsers, and it exists in jsdom.
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(r.result as ArrayBuffer);
r.onerror = () => reject(r.error ?? new Error('could not read the file'));
r.readAsArrayBuffer(file);
});
}
async function sha256Hex(file: File): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', await readAsArrayBuffer(file));
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
}
// The browser sets these itself; setting them from script is refused.
const BROWSER_OWNED = new Set(['content-length', 'host']);
function putWithProgress(
url: string,
file: File,
headers: Record<string, string>,
onProgress?: (pct: number) => void,
signal?: AbortSignal,
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', url);
for (const [k, v] of Object.entries(headers)) {
if (!BROWSER_OWNED.has(k.toLowerCase())) xhr.setRequestHeader(k, v);
}
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) return;
const { loaded, total: size } = e;
onProgress?.(Math.round((loaded / size) * 100));
};
xhr.onload = () =>
xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new UploadError('transfer', `HTTP ${xhr.status}`));
xhr.onerror = () => reject(new UploadError('transfer'));
xhr.onabort = () => reject(new UploadError('aborted'));
signal?.addEventListener('abort', () => xhr.abort(), { once: true });
xhr.send(file);
});
}
/**
* Declare (POST /documents, with the file's SHA-256) → PUT the bytes to the presigned URL →
* POST /documents/{id}/complete, which checks size, digest and magic bytes. A rejected document
* is a 200 from the server and an UploadError('rejected', reason) here.
*/
export async function uploadDocument(
file: File,
onProgress?: (pct: number) => void,
signal?: AbortSignal,
): Promise<UploadedDoc> {
if (!(ACCEPTED_TYPES as readonly string[]).includes(file.type)) throw new UploadError('type', file.type || 'unknown');
if (file.size < 1 || file.size > SERVER_MAX_BYTES) throw new UploadError('size', String(file.size));
const sha256 = await sha256Hex(file);
signal?.throwIfAborted();
const { document, upload } = unwrap(
await api.POST('/documents', {
body: { filename: file.name, content_type: file.type as AcceptedType, byte_size: file.size, sha256 },
}),
);
let final: ApiDocument = document;
if (upload) {
await putWithProgress(upload.url, file, upload.headers, onProgress, signal);
signal?.throwIfAborted();
final = unwrap(await api.POST('/documents/{id}/complete', { params: { path: { id: document.id } } }));
} else {
onProgress?.(100); // the server already holds these exact bytes from this uploader
}
if (final.status === 'rejected') throw new UploadError('rejected', final.reject_reason ?? undefined);
return { id: final.id, filename: final.filename, size: final.byte_size, content_type: final.content_type };
}

The AbortSignal is honoured by the XHR and checked between steps. It is deliberately not passed into fetch: in jsdom, jsdom’s AbortSignal is not the one Node’s fetch accepts, and a declared-but-unsent document costs nothing.

Run: pnpm -C web test -- src/components/upload/uploadDocument.test.ts — Expected: PASS.

Verify: if the first test fails inside crypto.subtle.digest with “argument must be BufferSource”, jsdom’s FileReader returned an ArrayBuffer from jsdom’s realm, which Node’s WebCrypto rejects. Fix it in sha256Hex by copying into the current realm: crypto.subtle.digest('SHA-256', Uint8Array.from(new Uint8Array(await readAsArrayBuffer(file)))). It costs one copy of the file and is harmless in browsers.

Then confirm the no-float drift test still passes. The progress line uses total only in a destructuring rename, so the “Math on money” rule does not see a money-named identifier inside Math.round(...): pnpm -C web test -- src/drift/nofloat.test.ts.

  • Step 4: Helpers — formatBytes, useDocument

web/src/components/upload/formatBytes.ts:

import type { UiLocale } from '@/lib/i18n';
/** 94 KB / 1.2 MB. File sizes are integers from the API, never money. */
export function formatBytes(n: number, locale: UiLocale): string {
const fmt = new Intl.NumberFormat(locale === 'zh' ? 'zh-CN' : locale === 'ms' ? 'ms-MY' : 'en-GB', {
maximumFractionDigits: 1,
});
if (n < 1024) return `${fmt.format(n)} B`;
if (n < 1024 * 1024) return `${fmt.format(Math.round(n / 1024))} KB`;
return `${fmt.format(n / (1024 * 1024))} MB`;
}

web/src/components/upload/useDocument.ts:

import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
/** GET /documents/{id}. Presigned links live 5 minutes by default, so refetch before that. */
export function useDocument(id: string | undefined) {
return useQuery({
queryKey: ['documents', 'detail', id],
queryFn: async () => unwrap(await api.GET('/documents/{id}', { params: { path: { id: id! } } })),
enabled: !!id,
staleTime: 4 * 60_000,
refetchInterval: (q) => (q.state.data?.status === 'pending' ? 3_000 : false),
});
}

A pending document is polled every 3 s until it is verified or rejected, so a thumbnail appears on its own once the check finishes.

  • Step 5: DocumentUploader — failing test

web/src/components/upload/DocumentUploader.test.tsx:

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { DocumentUploader } from './DocumentUploader';
import { UploadError, type UploadedDoc } from './uploadDocument';
const upload = vi.hoisted(() => vi.fn());
vi.mock('./uploadDocument', async (importOriginal) => ({
...(await importOriginal<typeof import('./uploadDocument')>()),
uploadDocument: upload,
}));
function Harness({ onUploaded = vi.fn(), onRemoved = vi.fn() }: { onUploaded?: (d: UploadedDoc) => void; onRemoved?: (id: string) => void }) {
const [docs, setDocs] = useState<UploadedDoc[]>([]);
return (
<DocumentUploader
multiple
value={docs}
onUploaded={(d) => {
setDocs((x) => [...x, d]);
onUploaded(d);
}}
onRemoved={(id) => {
setDocs((x) => x.filter((d) => d.id !== id));
onRemoved(id);
}}
/>
);
}
describe('DocumentUploader', () => {
it('uploads with a progress bar, lists the result, and removes it', async () => {
let finish: (d: UploadedDoc) => void = () => {};
upload.mockImplementation((file: File, onProgress?: (p: number) => void) => {
onProgress?.(40);
return new Promise<UploadedDoc>((r) => (finish = r));
});
const onUploaded = vi.fn();
const onRemoved = vi.fn();
render(<Harness onUploaded={onUploaded} onRemoved={onRemoved} />);
const f = new File(['x'], 'receipt_01.jpg', { type: 'image/jpeg' });
await userEvent.upload(screen.getByLabelText(/browse files/), f);
expect(await screen.findByRole('progressbar', { name: 'Uploading receipt_01.jpg' })).toHaveAttribute('aria-valuenow', '40');
finish({ id: 'd1', filename: 'receipt_01.jpg', size: 96_256, content_type: 'image/jpeg' });
expect(await screen.findByText('receipt_01.jpg')).toBeInTheDocument();
expect(screen.getByText('94 KB')).toBeInTheDocument();
expect(onUploaded).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
await userEvent.click(screen.getByRole('button', { name: 'Remove receipt_01.jpg' }));
expect(onRemoved).toHaveBeenCalledWith('d1');
});
it('refuses an oversized file before uploading', async () => {
render(<Harness />);
const big = new File([new Uint8Array(11 * 1024 * 1024)], 'scan.pdf', { type: 'application/pdf' });
await userEvent.upload(screen.getByLabelText(/browse files/), big);
expect(await screen.findByRole('alert')).toHaveTextContent('scan.pdf is larger than 10 MB.');
expect(upload).not.toHaveBeenCalledWith(big, expect.anything(), expect.anything());
});
it('explains a server-side rejection', async () => {
upload.mockRejectedValueOnce(new UploadError('rejected', 'duplicate'));
render(<Harness />);
await userEvent.upload(screen.getByLabelText(/browse files/), new File(['x'], 'a.png', { type: 'image/png' }));
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent("You've already uploaded a.png."));
});
});

userEvent.upload respects the input’s accept, so a disallowed type never reaches onChange in this test; the type check in the component covers drag-and-drop, which bypasses accept.

Run: pnpm -C web test -- src/components/upload/DocumentUploader.test.tsx — Expected: FAIL (module not found).

  • Step 6: Implement DocumentUploader

web/src/components/upload/DocumentUploader.tsx:

import { FileText, Upload, X } from 'lucide-react';
import { useId, useState, type DragEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { IconButton } from '@/components/ui/IconButton';
import { ProgressBar } from '@/components/ui/ProgressBar';
import { cn } from '@/lib/cn';
import { useLocale } from '@/lib/i18n';
import { formatBytes } from './formatBytes';
import { ACCEPTED_TYPES, DEFAULT_MAX_BYTES, uploadDocument, UploadError, type UploadedDoc } from './uploadDocument';
type Pending = { key: string; name: string; progress: number; error?: string };
export function DocumentUploader(p: {
accept?: string;
maxBytes?: number;
multiple?: boolean;
onUploaded(d: UploadedDoc): void;
onRemoved?(id: string): void;
value?: UploadedDoc[];
}) {
const { t } = useTranslation('documents');
const [locale] = useLocale();
const inputId = useId();
const accept = p.accept ?? ACCEPTED_TYPES.join(',');
const maxBytes = p.maxBytes ?? DEFAULT_MAX_BYTES;
const [pending, setPending] = useState<Pending[]>([]);
const [dragging, setDragging] = useState(false);
const patch = (key: string, next: Partial<Pending>) =>
setPending((xs) => xs.map((x) => (x.key === key ? { ...x, ...next } : x)));
const messageFor = (e: unknown, name: string) => {
if (e instanceof UploadError && e.reason === 'rejected' && e.detail) {
return t(`errors.rejected.${e.detail}`, { name, defaultValue: t('errors.failed', { name }) });
}
if (e instanceof UploadError && e.reason === 'type') return t('errors.badType', { name });
return t('errors.failed', { name });
};
const start = (files: File[]) => {
const allowed = accept.split(',').map((s) => s.trim());
for (const file of p.multiple ? files : files.slice(0, 1)) {
const key = crypto.randomUUID();
const size = formatBytes(maxBytes, locale);
if (!allowed.includes(file.type)) {
setPending((xs) => [...xs, { key, name: file.name, progress: 0, error: t('errors.badType', { name: file.name }) }]);
continue;
}
if (file.size > maxBytes) {
setPending((xs) => [...xs, { key, name: file.name, progress: 0, error: t('errors.tooBig', { name: file.name, size }) }]);
continue;
}
setPending((xs) => [...xs, { key, name: file.name, progress: 0 }]);
uploadDocument(file, (progress) => patch(key, { progress }))
.then((d) => {
setPending((xs) => xs.filter((x) => x.key !== key));
p.onUploaded(d);
})
.catch((e: unknown) => patch(key, { error: messageFor(e, file.name) }));
}
};
const onDrop = (e: DragEvent) => {
e.preventDefault();
setDragging(false);
start(Array.from(e.dataTransfer.files));
};
return (
<div className="flex flex-col gap-3">
<div
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
className={cn(
'flex flex-col items-center gap-2 rounded-card border border-dashed border-border px-4 py-6 text-center',
dragging && 'border-primary bg-primary-soft',
)}
>
<Upload aria-hidden className="size-6 text-fg-muted" />
<p className="text-body text-fg">
{t('drop')}{' '}
<label htmlFor={inputId} className="cursor-pointer font-medium text-primary underline">
{t('browse')}
</label>
</p>
<p className="text-caption text-fg-muted">{t('hint', { size: formatBytes(maxBytes, locale) })}</p>
<input
id={inputId}
type="file"
accept={accept}
multiple={p.multiple}
className="sr-only"
onChange={(e) => {
start(Array.from(e.target.files ?? []));
e.target.value = ''; // choosing the same file again should upload again
}}
/>
</div>
<ul className="flex flex-col gap-2">
{(p.value ?? []).map((d) => (
<li key={d.id} className="flex items-center gap-3 rounded-control border border-border-subtle p-2">
<FileText aria-hidden className="size-5 shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1">
<span className="block truncate text-body text-fg">{d.filename}</span>
<span className="block text-caption text-fg-muted">{formatBytes(d.size, locale)}</span>
</span>
{p.onRemoved && (
<IconButton icon={X} size="sm" label={t('remove', { name: d.filename })} onClick={() => p.onRemoved?.(d.id)} />
)}
</li>
))}
{pending.map((x) => (
<li key={x.key} className="flex items-center gap-3 rounded-control border border-border-subtle p-2">
<FileText aria-hidden className="size-5 shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1">
<span className="block truncate text-body text-fg">{x.name}</span>
{x.error ? (
<span role="alert" className="block text-caption text-tone-red-fg">
{x.error}
</span>
) : (
<ProgressBar className="mt-1" value={x.progress} label={t('uploading', { name: x.name })} />
)}
</span>
{x.error && (
<IconButton
icon={X}
size="sm"
label={t('dismiss')}
onClick={() => setPending((xs) => xs.filter((y) => y.key !== x.key))}
/>
)}
</li>
))}
</ul>
</div>
);
}

formatBytes(10 MiB) renders 10 MB, which is what the oversize test expects; 96 256 bytes renders 94 KB.

Run: pnpm -C web test -- src/components/upload/DocumentUploader.test.tsx — Expected: PASS.

  • Step 7: Thumb and viewer — failing tests

web/src/components/upload/DocumentThumb.test.tsx:

import { fireEvent, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { describe, expect, it, vi } from 'vitest';
import { apiPath } from '@/test/msw/handlers';
import { server } from '@/test/msw/server';
import { renderWithQuery } from '@/test/render';
import { DocumentThumb } from './DocumentThumb';
const base = {
uploaded_by: 'u',
byte_size: 96_256,
sha256: 'a'.repeat(64),
reject_reason: null,
created_at: '2026-08-28T06:00:00Z',
verified_at: '2026-08-28T06:00:05Z',
ocr_suggestions: [],
};
describe('DocumentThumb', () => {
it('shows the presigned thumbnail and opens on click', async () => {
server.use(
http.get(apiPath('/documents/d1'), () =>
HttpResponse.json({ ...base, id: 'd1', filename: 'receipt_01.jpg', content_type: 'image/jpeg', status: 'verified', download_url: 'http://store.test/d1', thumbnail_url: 'http://store.test/d1-thumb' }),
),
);
const onOpen = vi.fn();
renderWithQuery(<DocumentThumb documentId="d1" onOpen={onOpen} />);
const img = await screen.findByRole('img', { name: 'receipt_01.jpg' });
expect(img).toHaveAttribute('src', 'http://store.test/d1-thumb');
expect(screen.getByText('94 KB')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /receipt_01.jpg/ }));
expect(onOpen).toHaveBeenCalled();
});
it('a PDF has no thumbnail and shows an icon instead', async () => {
server.use(
http.get(apiPath('/documents/d2'), () =>
HttpResponse.json({ ...base, id: 'd2', filename: 'invoice.pdf', content_type: 'application/pdf', status: 'verified', download_url: 'http://store.test/d2', thumbnail_url: null }),
),
);
renderWithQuery(<DocumentThumb documentId="d2" />);
expect(await screen.findByText('invoice.pdf')).toBeInTheDocument();
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
it('refetches once when an expired link fails to load', async () => {
let calls = 0;
server.use(
http.get(apiPath('/documents/d1'), () => {
calls++;
return HttpResponse.json({ ...base, id: 'd1', filename: 'r.jpg', content_type: 'image/jpeg', status: 'verified', download_url: 'u', thumbnail_url: `http://store.test/t${calls}` });
}),
);
renderWithQuery(<DocumentThumb documentId="d1" />);
fireEvent.error(await screen.findByRole('img'));
await waitFor(() => expect(calls).toBe(2));
fireEvent.error(await screen.findByRole('img'));
await new Promise((r) => setTimeout(r, 50));
expect(calls).toBe(2);
});
});

web/src/components/upload/DocumentViewer.test.tsx:

import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { apiPath } from '@/test/msw/handlers';
import { server } from '@/test/msw/server';
import { renderWithQuery } from '@/test/render';
import { DocumentViewer } from './DocumentViewer';
const doc = (id: string, filename: string, content_type: string) => ({
id, filename, content_type,
uploaded_by: 'u', byte_size: 1, sha256: 'a'.repeat(64), status: 'verified', reject_reason: null,
created_at: '2026-08-28T06:00:00Z', verified_at: '2026-08-28T06:00:05Z',
download_url: `http://store.test/${id}`, thumbnail_url: null, ocr_suggestions: [],
});
describe('DocumentViewer', () => {
it('steps through documents with buttons and arrow keys, with a download link', async () => {
server.use(
http.get(apiPath('/documents/d1'), () => HttpResponse.json(doc('d1', 'receipt_01.jpg', 'image/jpeg'))),
http.get(apiPath('/documents/d2'), () => HttpResponse.json(doc('d2', 'invoice.pdf', 'application/pdf'))),
);
renderWithQuery(<DocumentViewer documentIds={['d1', 'd2']} index={0} open onOpenChange={() => {}} />);
expect(await screen.findByRole('dialog', { name: 'receipt_01.jpg' })).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'receipt_01.jpg' })).toHaveAttribute('src', 'http://store.test/d1');
expect(screen.getByText('1 of 2')).toBeInTheDocument();
await userEvent.keyboard('{ArrowRight}');
expect(await screen.findByRole('dialog', { name: 'invoice.pdf' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Download' })).toHaveAttribute('href', 'http://store.test/d2');
expect(screen.getByRole('button', { name: 'Next document' })).toBeDisabled();
await userEvent.click(screen.getByRole('button', { name: 'Previous document' }));
expect(await screen.findByRole('dialog', { name: 'receipt_01.jpg' })).toBeInTheDocument();
});
});

Run: pnpm -C web test -- src/components/upload/DocumentThumb.test.tsx src/components/upload/DocumentViewer.test.tsx — Expected: FAIL (modules not found).

  • Step 8: Implement DocumentThumb and DocumentViewer

web/src/components/upload/DocumentThumb.tsx:

import { FileText, ZoomIn } from 'lucide-react';
import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Skeleton } from '@/components/ui/Skeleton';
import { Tag } from '@/components/ui/Tag';
import { useLocale } from '@/lib/i18n';
import { formatBytes } from './formatBytes';
import { useDocument } from './useDocument';
/** A receipt tile (Figma 278:24022 收据): thumbnail or file icon, filename, size. */
export function DocumentThumb(p: { documentId: string; onOpen?(): void }) {
const { t } = useTranslation('documents');
const [locale] = useLocale();
const q = useDocument(p.documentId);
const retried = useRef(false);
if (!q.data) return <Skeleton className="size-24 rounded-control" />;
const d = q.data;
// A presigned link that expired while the page sat open: fetch a fresh one, once.
const onImgError = () => {
if (retried.current) return;
retried.current = true;
void q.refetch();
};
const tile = (
<span className="relative flex size-24 items-center justify-center overflow-hidden rounded-control border border-border-subtle bg-bg-subtle">
{d.status === 'verified' && d.thumbnail_url ? (
<img src={d.thumbnail_url} alt={d.filename} className="size-full object-cover" onError={onImgError} />
) : (
<FileText aria-hidden className="size-8 text-fg-muted" />
)}
{p.onOpen && d.status === 'verified' && (
<ZoomIn aria-hidden className="absolute bottom-1 left-1 size-4 rounded-full bg-surface p-0.5 text-fg" />
)}
</span>
);
return (
<figure className="flex w-24 flex-col gap-1">
{p.onOpen && d.status === 'verified' ? (
<button type="button" onClick={p.onOpen} className="rounded-control focus-visible:outline-2 focus-visible:outline-focus">
{tile}
</button>
) : (
tile
)}
<figcaption className="min-w-0">
<span className="block truncate text-caption text-fg">{d.filename}</span>
<span className="block text-caption text-fg-muted">
{d.status === 'pending' ? t('checking') : formatBytes(d.byte_size, locale)}
</span>
{d.status === 'rejected' && <Tag tone="red">{t('rejectedTag')}</Tag>}
</figcaption>
</figure>
);
}

web/src/components/upload/DocumentViewer.tsx:

import { ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { buttonVariants } from '@/components/ui/Button';
import { Dialog } from '@/components/ui/Dialog';
import { IconButton } from '@/components/ui/IconButton';
import { Skeleton } from '@/components/ui/Skeleton';
import { cn } from '@/lib/cn';
import { useDocument } from './useDocument';
/** Full-size receipts with ‹ › and ←/→, zoom for images, and a download link. */
export function DocumentViewer(p: { documentIds: string[]; index: number; open: boolean; onOpenChange(o: boolean): void }) {
const { t } = useTranslation('documents');
const [i, setI] = useState(p.index);
const [zoom, setZoom] = useState(false);
useEffect(() => {
if (p.open) setI(p.index);
}, [p.open, p.index]);
useEffect(() => setZoom(false), [i]);
// ←/→ anywhere in the open dialog. Radix puts focus on a footer button or the close button,
// so a handler on the content area alone would miss the keys.
useEffect(() => {
if (!p.open) return;
const on = (e: globalThis.KeyboardEvent) => {
if (e.key === 'ArrowLeft') setI((x) => Math.max(0, x - 1));
if (e.key === 'ArrowRight') setI((x) => Math.min(p.documentIds.length - 1, x + 1));
};
document.addEventListener('keydown', on);
return () => document.removeEventListener('keydown', on);
}, [p.open, p.documentIds.length]);
const q = useDocument(p.open ? p.documentIds[i] : undefined);
const d = q.data;
const hasPrev = i > 0;
const hasNext = i < p.documentIds.length - 1;
const isImage = d?.content_type === 'image/jpeg' || d?.content_type === 'image/png';
return (
<Dialog
open={p.open}
onOpenChange={p.onOpenChange}
size="lg"
title={d?.filename ?? '…'}
footer={
<div className="flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-2">
<IconButton icon={ChevronLeft} label={t('viewer.prev')} variant="outline" disabled={!hasPrev} onClick={() => setI(i - 1)} />
<span className="text-caption text-fg-muted">{t('viewer.counter', { n: i + 1, total: p.documentIds.length })}</span>
<IconButton icon={ChevronRight} label={t('viewer.next')} variant="outline" disabled={!hasNext} onClick={() => setI(i + 1)} />
</div>
<div className="flex items-center gap-2">
{isImage && (
<IconButton
icon={zoom ? ZoomOut : ZoomIn}
label={zoom ? t('viewer.zoomOut') : t('viewer.zoomIn')}
variant="outline"
onClick={() => setZoom((z) => !z)}
/>
)}
{d?.download_url && (
<a href={d.download_url} target="_blank" rel="noopener noreferrer" className={buttonVariants({ variant: 'outline' })}>
<Download aria-hidden className="size-4" />
{t('viewer.download')}
</a>
)}
</div>
</div>
}
>
<div className="flex min-h-80 items-center justify-center overflow-auto">
{!d ? (
<Skeleton className="h-80 w-full" />
) : !d.download_url ? (
<p className="text-body text-fg-muted">{t('viewer.notReady')}</p>
) : isImage ? (
<img
src={d.download_url}
alt={d.filename}
className={cn('max-h-[70dvh] object-contain transition-transform', zoom ? 'max-w-none scale-200 cursor-zoom-out' : 'max-w-full cursor-zoom-in')}
onClick={() => setZoom((z) => !z)}
/>
) : (
<object data={d.download_url} type="application/pdf" aria-label={d.filename} className="h-[70dvh] w-full">
<p className="text-body text-fg">
{t('viewer.pdf')}{' '}
<a href={d.download_url} target="_blank" rel="noopener noreferrer" className="text-primary underline">
{t('viewer.openPdf')}
</a>
</p>
</object>
)}
</div>
</Dialog>
);
}

Run: pnpm -C web test -- src/components/upload — Expected: PASS.

  • Step 9: Full checks and runtime check

Run: pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test — Expected: PASS (including the no-float and no-hex drift tests).

Runtime: no screen uses these components until Tasks 12 and 13, so add an “Upload” section to web/src/dev/ui.tsx that renders DocumentUploader (controlled list), a DocumentThumb per uploaded id, and a DocumentViewer over them. With the dev stack running (Task 8), sign in as employee@demo.test at /login first, then open /__ui:

  1. Upload a JPG and a PDF: the progress bar moves, both land in the list, and the thumbs show “Checking…” and then a thumbnail (JPG) or an icon (PDF).
  2. Upload the same JPG again: it returns at once with no PUT (dedup; see the Network panel via read_network_requests), or shows the “already uploaded” message if the server rejected it as a duplicate.
  3. Pick a 12 MB file: refused client-side with the size message.
  4. Open the viewer: ←/→ steps, zoom toggles, Download opens the presigned link.
  5. Check at 1440 and 390 wide, light and dark, en and zh. read_console_messages onlyErrors: none. If the PUT fails with a CORS error, see Task 8’s MinIO note.

Record the checks in the task report.

  • Step 10: Commit
Terminal window
git add web
git commit -m "$(cat <<'EOF'
Plan 10 Task 10: document upload and viewer
Declare with SHA-256, presigned PUT with upload progress, complete and surface
server-side rejections; dedup skips the transfer. Thumbnails from presigned
links with one refetch on expiry, and a viewer with arrows, zoom and download.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"

Part C conventions (read once; they bind Tasks 11–19)

Section titled “Part C conventions (read once; they bind Tasks 11–19)”

C0. Execution order inside the groups. Tasks 11, 12 and 13 all live in web/src/features/claims/, and the list page’s buttons navigate to routes that 12 and 13 create. So they run in sequence, 11 → 12 → 13. Task 14 runs in parallel with them. Tasks 12 and 13 make the small, exactly-listed edits to Task 11’s files that their task bodies name (the childRoutes.ts append, and one prop each in claimsRoute.tsx). Nothing else crosses a task boundary. Group F (15–19) is fully parallel: Task 15 imports only from group E (ClaimDrawer, the claims hooks), never from 16 or 17.

C1. Import paths. The registry fixes export names, not file paths. Part C assumes:

Import From
api, newIdempotencyKey @/lib/api/client
unwrap, ProblemError, applyProblemToForm, problemMessage @/lib/api/problem
Schemas, ExpenseClaim, ExpenseClaimView, WorkflowDefinition, AvailableTransition, Me @/lib/api/types (type-only)
formatMoney, isMoneyInput, compareMoney, isZeroMoney @/lib/money
formatDate, formatDateTime, todayISO, monthKey, relativeTime @/lib/date
useLocale, pickLabel @/lib/i18n
cn @/lib/cn
MoneyText, MoneyInput @/components/money
each components/ui primitive @/components/ui/<Name> (e.g. @/components/ui/Button); toast from @/components/ui/Toaster
StatusChip, StateFilterChips, TransitionBar, ReasonDialog, DecisionTimeline, useWorkflowDefinition, useNeighbours, semanticTone @/components/workflow
DocumentUploader, DocumentThumb, DocumentViewer, uploadDocument, type UploadedDoc @/components/upload
useMe, useCan, RequirePermission, useSignOut @/features/auth
useMemberNames, useMemberDirectory @/features/members/useMemberNames
server @/test/msw/server
renderWithApp @/test/renderWithApp (registry change R1, see Part C notes)
appRoute @/app/router (registry change R2)

Every task below starts with a Step 0 (Verify imports): run ls web/src/lib web/src/lib/api web/src/components/* web/src/features/auth web/src/test web/src/test/msw and grep -n "export" web/src/components/ui/*.tsx | head -200. Where a path or a prop name differs from the table above or from the props this task uses, change the import specifier or prop name in this task’s code, not the other task’s file. The export names are binding; the paths and the props of components/ui are not in the registry, so they are the likeliest thing to differ. The components/ui props Part C assumes are: Button {variant,size,loading,onClick,type,disabled}, IconButton {aria-label,onClick}, Field {label,hint,error,htmlFor,required,children}, Select {id,value,onChange(v),options,placeholder,disabled}, Combobox {id,value,onChange(v),options,placeholder}, Tabs {value,onChange(v),items:{value,label,icon?}[]}, Dialog {open,onOpenChange,title,description,footer,children}, Drawer {open,onOpenChange,header,footer,children}, ConfirmDialog {open,onOpenChange,title,description,confirmLabel,tone,onConfirm}, DataTable {columns,data,onRowClick,loading,empty,getRowId}, StatCard {tone,label,primary,secondary,icon}, Tag {tone,children}, CountBadge {count}, EmptyState {title,description,action}, LoadMore {hasMore,loading,onLoad}, MonthPicker {value?:'YYYY-MM',onChange(v|undefined),allowClear}, DatePicker {id,value?:'YYYY-MM-DD',onChange(v|undefined)}, RadioCards {value,onChange(v),options:{value,label,description?,disabled?}[]}, PageHeader {title,subtitle,actions}, Tooltip {content,children}, Checkbox {id,checked,onCheckedChange}.

C2. Colour and type classes. Part C writes Tailwind classes with shadcn-style token names (bg-card, text-muted-foreground, border-border, text-primary, bg-accent). Task 7 defines the real token names in tokens.css; replace them in Step 0 if they differ. No hex, ever (the Task 5 drift test fails the build).

C3. Tests. Every screen test renders through renderWithApp (R1), which builds the real route tree in a memory history, a fresh QueryClient, i18n in en, and MSW handlers for GET /v1/me (with the given permissions) and GET /v1/org/members. It renders without the AppShell unless shell: true, so a page test does not have to mock the shell’s polling. MSW paths are written */v1/... so they match whatever origin jsdom uses. Tests assert presence before absence (AGENTS.md): every “hidden without the permission” assertion is preceded, in the same it, by the same render with the permission showing the thing.

C4. Query keys (P5 [resource, params], mutations invalidate by prefix). Registry change R6 fixes the keys other tasks share: ['claims', …] (Task 11), ['approvals', 'claims'|'advances'|'spends'|'topups'] (Task 15, invalidated by every transition in 11, 16, 17), ['advances', …] (Task 17), ['pettycash', …] (Task 16), ['payout','me','channels'] and ['payout','me','profiles'] (Tasks 12, 13, 14), ['expenses','categories'], ['expenses','groups', {active}] (Tasks 11, 13, 16, 17).

C5. Money. MoneyText renders every figure. MoneyInput holds every entered amount, and the string goes on the wire unchanged. No task below adds, subtracts, divides or compares two amounts; where Figma shows a sum or a ratio the API does not return, the task renders the parts and the gap is listed in the Part C notes.

C6. Record-language hint (F8). Every free-text input that lands on a record (claim title, line description, reasons, advance purpose, spend description, top-up reason, delegation reason, org names) renders <RecordLanguageHint /> from its own feature when the UI locale is zh. The zh placeholders are English examples (“例如:Client lunch”), because the server refuses a Chinese-script title with 422 (record_language).


Figma: frame 123:7183 (list, zh), 477:16079 (dark, en), 94:27703 (390px mobile). Pull get_design_context and get_screenshot for each of the three node ids before writing JSX.

What the frame shows, top to bottom (1440px): breadcrumb REQUESTS / Expenses Claims; page title 我的报销 with the subline 本月共 5 笔报销 · 2 笔待审核 and a purple + 提交报销 button at the right; four KPI cards in one row — 待审核 (amber fill, clock icon), 已批准 (green, check-circle), 已付款 (green, receipt-dollar), 总提交 (blue, document) — each with a count on the first line and an amount (RM 242.55) on the second; three underline tabs with icons 我的报销 · 我的审批 · 所有报销; a white card holding: the label 主状态 then state chips (全部 6, 草稿, 待审核 2, 已审核 1, 已批准 1, 已付款 1, 已结案 1, 已拒绝 1 — count in a small purple badge, the active chip on a light-purple fill), at the right 筛选 and 导出; a search box 搜索报销单号/供应商…, then at the right a project select 所有项目 and a month select 2026 年 8 月; the table with a light-purple header row: 标题 (title, bold, with a grey subline), 类别 (an outlined pill), 状态 (outlined status chip), 金额 (bold; two lines when foreign: CNY 333.00 / RM 210.55), 提交时间 (date, then time on a second line), 报销单号, 操作 (⋮). Under the card: Showing 6 of 6 claims. At 390px (frame 94:27703) the KPI cards wrap two per row, search and the two selects stack, the tabs scroll horizontally, and each claim is a card: title + supplier at the left, the category pill at the right; the amount large, the status chip at the right; a footer line 11 Aug 2026 · CLM-2026-0034 and ⋮.

Departures, each for a reason recorded in the Part C notes: no 筛选 button (the filters are already inline), no 导出 (no claims export endpoint; F4), no supplier subline and no foreign-currency second amount (the list row does not carry them, gap G3), and the KPI totals come from the server’s kpis buckets (registry change R5, gap G2).

Files:

  • Create: web/src/features/claims/types.ts
  • Create: web/src/features/claims/api.ts
  • Create: web/src/features/claims/search.ts
  • Create: web/src/features/claims/claimsRoute.tsx
  • Create: web/src/features/claims/childRoutes.ts (append-only for Tasks 12, 13)
  • Create: web/src/features/claims/routes.tsx
  • Create: web/src/features/claims/ClaimsListPage.tsx
  • Create: web/src/features/claims/ClaimsTable.tsx
  • Create: web/src/features/claims/ClaimCards.tsx
  • Create: web/src/features/claims/ClaimRowMenu.tsx
  • Create: web/src/features/claims/CategoryPills.tsx
  • Create: web/src/features/claims/RecordLanguageHint.tsx
  • Create: web/src/features/claims/__tests__/fixtures.ts
  • Create: web/src/features/claims/__tests__/ClaimsListPage.test.tsx
  • Create: web/src/lib/i18n/locales/en/claims.json, web/src/lib/i18n/locales/ms/claims.json, web/src/lib/i18n/locales/zh/claims.json
  • Modify (append one import + one entry): web/src/app/routes.ts
  • Modify (append one item): web/src/app/shell/nav.ts

Interfaces:

  • Consumes: api, newIdempotencyKey, unwrap, ProblemError, Schemas, ExpenseClaim, ExpenseClaimView, MoneyText, formatDateTime, monthKey, useLocale, pickLabel, StatusChip, StateFilterChips, useMe, useCan, appRoute (R2), renderWithApp (R1), and from components/ui: PageHeader, Button, IconButton, StatCard, Tabs, Input, Select, MonthPicker, DataTable, Tag, LoadMore, EmptyState, Skeleton, toast.

  • Produces (registry, “Web: claims”): claimKeys, Scope, ClaimListSearch, useClaimList, useClaim, useClaimTransition (with the variables of registry change R3). Also: useClaimSummary(s), useCategories(), useCategoryLabel(), useClaimGroups(active: boolean), claimsRoute, claimChildRoutes, ClaimSearch type, RecordLanguageHint.

  • Step 0: Verify imports as described in C1. Also run grep -n "summary\|kpis" web/src/lib/api/schema.d.ts | head and confirm Task 3 shipped ExpenseClaimSummary.kpis (R5). If it did not, stop: do not sum rows[].total in the browser (F13). Amend Task 3 to add kpis (see R5) and regenerate, then continue.

  • Step 1: Write the fixtures

web/src/features/claims/__tests__/fixtures.ts
import type { Schemas } from '@/lib/api/types';
export const ME_ID = '00000000-0000-4000-8000-000000000001';
export const APPROVER_ID = '00000000-0000-4000-8000-000000000002';
export const CAT_MEALS = '00000000-0000-4000-8000-00000000c001';
export const CAT_TRAVEL = '00000000-0000-4000-8000-00000000c002';
export const GROUP_ALPHA = '00000000-0000-4000-8000-00000000a001';
const l = (en: string, zh: string, ms: string) => ({ en, zh, ms });
// The seeded expense_claim graph (internal/platform/workflow/seed.go), labels verbatim.
export const claimDefinition: Schemas['WorkflowDefinition'] = {
id: '00000000-0000-4000-8000-00000000d001',
kind: 'expense_claim',
version: 1,
published_at: '2026-08-01T00:00:00Z',
graph: {
kind: 'expense_claim',
posting_order: 'accrual_first',
grants: [],
states: [
{ key: 'draft', semantic: 'draft', display_order: 1, is_terminal: false, labels: l('Draft', '草稿', 'Draf') },
{ key: 'pending_review', semantic: 'pending', display_order: 2, is_terminal: false, labels: l('Pending review', '待审核', 'Menunggu semakan') },
{ key: 'reviewed', semantic: 'pending', display_order: 3, is_terminal: false, labels: l('Reviewed', '已审核', 'Disemak') },
{ key: 'approved', semantic: 'pending', display_order: 4, is_terminal: false, labels: l('Approved', '已批准', 'Diluluskan') },
{ key: 'category_verified', semantic: 'accrual', display_order: 5, is_terminal: false, control_kind: 'ap', labels: l('Category verified', '类别已核实', 'Kategori disahkan') },
{ key: 'completed', semantic: 'payout', display_order: 6, is_terminal: false, labels: l('Paid', '已付款', 'Telah dibayar') },
{ key: 'received', semantic: 'acknowledged', display_order: 7, is_terminal: true, labels: l('Received', '已收款', 'Telah diterima') },
{ key: 'rejected', semantic: 'rejected', display_order: 8, is_terminal: false, labels: l('Rejected', '已拒绝', 'Ditolak') },
{ key: 'disputed', semantic: 'dispute', display_order: 9, is_terminal: false, labels: l('Disputed', '有争议', 'Dipertikaikan') },
],
edges: [
{ from: 'draft', to: 'pending_review', gate: 'reporter', action_key: 'submit', requires_reason: false, reverses_accrual: false, display_order: 1, labels: l('Submit', '提交', 'Hantar') },
{ from: 'pending_review', to: 'reviewed', gate: 'target', action_key: 'review', requires_reason: false, reverses_accrual: false, display_order: 2, labels: l('Mark reviewed', '审核通过', 'Tandakan disemak') },
{ from: 'category_verified', to: 'completed', gate: 'target', action_key: 'pay', requires_reason: false, reverses_accrual: false, display_order: 5, labels: l('Mark paid', '标记已付款', 'Tandakan telah dibayar') },
{ from: 'pending_review', to: 'rejected', gate: 'advance', action_key: 'reject', requires_reason: true, reverses_accrual: false, display_order: 7, labels: l('Reject', '驳回', 'Tolak') },
],
},
};
export function makeClaim(over: Partial<Schemas['ExpenseClaim']> = {}): Schemas['ExpenseClaim'] {
return {
id: over.id ?? crypto.randomUUID(),
reference: 'EC-2026-0034',
claimant_member_id: ME_ID,
title: 'Team coffee run',
currency: 'MYR',
total: '68.4000',
state: 'pending_review',
semantic: 'pending',
group_id: null,
payout_channel_id: null,
revision: 2,
created_at: '2026-08-11T02:10:00Z',
submitted_at: '2026-08-11T02:10:00Z',
category_ids: [CAT_MEALS],
...over,
};
}
export const categories: Schemas['Category'][] = [
{ id: CAT_MEALS, key: 'meals', name: 'Meals', label: 'Meals', names: l('Meals', '餐饮', 'Makan'), account_id: '00000000-0000-4000-8000-0000000ac001', employee_named_document_allowed: false, active: true, sort_order: 1 },
{ id: CAT_TRAVEL, key: 'travel', name: 'Transport', label: 'Transport', names: l('Transport', '交通', 'Pengangkutan'), account_id: '00000000-0000-4000-8000-0000000ac002', employee_named_document_allowed: false, active: true, sort_order: 2 },
];
export const groups: Schemas['ExpenseClaimGroup'][] = [
{ id: GROUP_ALPHA, reference: 'EG-2026-0001', name: 'Project Alpha', kind: 'trip', owner_member_id: ME_ID, starts_on: null, ends_on: null, active: true, created_at: '2026-08-01T00:00:00Z' },
];
// Registry change R5: the server buckets the KPI totals; the browser never sums them.
export const summary: Schemas['ExpenseClaimSummary'] = {
currency: 'MYR',
rows: [
{ state: 'draft', semantic: 'draft', count: 1, total: '68.4000' },
{ state: 'pending_review', semantic: 'pending', count: 2, total: '242.5500' },
{ state: 'reviewed', semantic: 'pending', count: 1, total: '10.0000' },
{ state: 'category_verified', semantic: 'accrual', count: 1, total: '165.9000' },
{ state: 'completed', semantic: 'payout', count: 1, total: '100.0000' },
],
kpis: {
pending: { count: 3, total: '252.5500' },
approved: { count: 1, total: '165.9000' },
paid: { count: 1, total: '100.0000' },
submitted: { count: 5, total: '1000.4500' },
},
};
  • Step 2: Write the failing test
web/src/features/claims/__tests__/ClaimsListPage.test.tsx
import { describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/msw/server';
import { renderWithApp } from '@/test/renderWithApp';
import { categories, claimDefinition, groups, makeClaim, summary } from './fixtures';
const READ = ['expenses.claim.read'];
function mockApi(opts: { onList?: (url: URL) => void; pages?: Record<string, { claims: ReturnType<typeof makeClaim>[]; next_cursor: string | null }> } = {}) {
const pages = opts.pages ?? {
first: { claims: [makeClaim({ title: 'Team coffee run' }), makeClaim({ title: 'Client lunch', reference: 'EC-2026-0035', category_ids: [ '00000000-0000-4000-8000-00000000c001', '00000000-0000-4000-8000-00000000c002' ] })], next_cursor: null },
};
server.use(
http.get('*/v1/workflow/definitions/expense_claim', () => HttpResponse.json(claimDefinition)),
http.get('*/v1/expenses/categories', () => HttpResponse.json({ categories })),
http.get('*/v1/expenses/groups', () => HttpResponse.json({ groups })),
http.get('*/v1/expenses/claims/summary', () => HttpResponse.json(summary)),
http.get('*/v1/expenses/claims', ({ request }) => {
const url = new URL(request.url);
opts.onList?.(url);
return HttpResponse.json(pages[url.searchParams.get('cursor') ?? 'first']);
}),
);
}
describe('ClaimsListPage', () => {
it('shows the server KPI buckets and the this-month subline, never a client sum', async () => {
mockApi();
await renderWithApp({ path: '/claims', permissions: READ });
const pending = await screen.findByRole('group', { name: 'Pending' });
expect(within(pending).getByText('3')).toBeInTheDocument();
expect(within(pending).getByText('RM 252.55')).toBeInTheDocument();
expect(within(screen.getByRole('group', { name: 'Total submitted' })).getByText('RM 1,000.45')).toBeInTheDocument();
// 6 = 1+2+1+1+1 rows of the current-month summary (integers, not money).
expect(screen.getByText('6 claims this month · 3 pending')).toBeInTheDocument();
});
it('shows the All claims tab only with expenses.claim.read_all (presence first)', async () => {
mockApi();
const withPerm = await renderWithApp({ path: '/claims', permissions: [...READ, 'expenses.claim.read_all'] });
expect(await screen.findByRole('tab', { name: 'All claims' })).toBeInTheDocument();
withPerm.unmount();
mockApi();
await renderWithApp({ path: '/claims', permissions: READ });
expect(await screen.findByRole('tab', { name: 'My approvals' })).toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'All claims' })).not.toBeInTheDocument();
});
it('puts the state chip in the URL and in the list request', async () => {
const seen: URL[] = [];
mockApi({ onList: (u) => seen.push(u) });
const { user, router } = await renderWithApp({ path: '/claims', permissions: READ });
await screen.findByText('Team coffee run');
expect(seen[0].searchParams.get('state')).toBeNull();
await user.click(screen.getByRole('button', { name: /Pending review/ }));
await waitFor(() => expect(router.state.location.search).toMatchObject({ state: 'pending_review' }));
await waitFor(() => expect(seen.at(-1)!.searchParams.get('state')).toBe('pending_review'));
expect(seen.at(-1)!.searchParams.get('limit')).toBe('10');
});
it('renders the category pill with +N and the status chip from the definition', async () => {
mockApi();
await renderWithApp({ path: '/claims', permissions: READ });
const row = (await screen.findByText('Client lunch')).closest('tr')!;
expect(within(row).getByText('Meals')).toBeInTheDocument();
expect(within(row).getByText('+1')).toBeInTheDocument();
expect(within(row).getByText('Pending review')).toBeInTheDocument();
expect(within(row).getByText('RM 68.40')).toBeInTheDocument();
});
it('loads the next page with the cursor the server returned', async () => {
const seen: URL[] = [];
mockApi({
onList: (u) => seen.push(u),
pages: {
first: { claims: [makeClaim({ title: 'First page claim' })], next_cursor: 'c2' },
c2: { claims: [makeClaim({ title: 'Second page claim' })], next_cursor: null },
},
});
const { user } = await renderWithApp({ path: '/claims', permissions: READ });
await screen.findByText('First page claim');
expect(screen.queryByText('Second page claim')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Load more' }));
expect(await screen.findByText('Second page claim')).toBeInTheDocument();
expect(seen.at(-1)!.searchParams.get('cursor')).toBe('c2');
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
});
it('debounces the search box into ?q=', async () => {
const seen: URL[] = [];
mockApi({ onList: (u) => seen.push(u) });
const { user, router } = await renderWithApp({ path: '/claims', permissions: READ });
await screen.findByText('Team coffee run');
await user.type(screen.getByRole('searchbox'), 'EC-2026');
await waitFor(() => expect(router.state.location.search).toMatchObject({ q: 'EC-2026' }));
await waitFor(() => expect(seen.at(-1)!.searchParams.get('q')).toBe('EC-2026'));
});
});
  • Step 3: Run it and watch it fail

Run: pnpm -C web test -- src/features/claims/__tests__/ClaimsListPage.test.tsx Expected: FAIL — the route /claims is not registered (renderWithApp reports “Not Found”), and ./fixtures type-checks only once ExpenseClaimSummary.kpis exists (R5).

  • Step 4: Types, API hooks and search schema
web/src/features/claims/types.ts
import type { Schemas } from '@/lib/api/types';
export type ExpenseClaimList = Schemas['ExpenseClaimList'];
export type ExpenseClaimSummary = Schemas['ExpenseClaimSummary'];
export type Category = Schemas['Category'];
export type ClaimGroup = Schemas['ExpenseClaimGroup'];
export type ClaimTransitionInput = Schemas['ExpenseClaimTransitionInput'];
web/src/features/claims/api.ts
import { useCallback } from 'react';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api, newIdempotencyKey } from '@/lib/api/client';
import { unwrap, type ProblemError } from '@/lib/api/problem';
import type { ExpenseClaimView } from '@/lib/api/types';
import { pickLabel, useLocale } from '@/lib/i18n';
export type Scope = 'mine' | 'to_act' | 'all';
export interface ClaimListSearch { scope: Scope; state?: string; q?: string; month?: string; group?: string }
export const PAGE_SIZE = 10; // F10
export const claimKeys = {
all: ['claims'] as const,
list: (s: ClaimListSearch) => ['claims', 'list', s] as const,
summary: (s: { scope: Scope; month?: string }) => ['claims', 'summary', s] as const,
detail: (id: string) => ['claims', 'detail', id] as const,
};
/** Only the filter fields: a search object carrying other keys must not split the cache. */
export function listSearch(s: ClaimListSearch): ClaimListSearch {
return { scope: s.scope, state: s.state, q: s.q, month: s.month, group: s.group };
}
export function useClaimList(s: ClaimListSearch) {
const search = listSearch(s);
return useInfiniteQuery({
queryKey: claimKeys.list(search),
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam, signal }) =>
unwrap(
await api.GET('/expenses/claims', {
params: {
query: {
scope: search.scope,
state: search.state,
q: search.q,
month: search.month,
group_id: search.group,
limit: PAGE_SIZE,
cursor: pageParam,
},
},
signal,
}),
),
getNextPageParam: (last) => last.next_cursor ?? undefined,
select: (data) => data.pages.flatMap((p) => p.claims),
});
}
export function useClaimSummary(s: { scope: Scope; month?: string }) {
return useQuery({
queryKey: claimKeys.summary(s),
queryFn: async ({ signal }) =>
unwrap(await api.GET('/expenses/claims/summary', { params: { query: { scope: s.scope, month: s.month } }, signal })),
});
}
export function useClaim(id: string) {
return useQuery({
queryKey: claimKeys.detail(id),
queryFn: async ({ signal }) => unwrap(await api.GET('/expenses/claims/{id}', { params: { path: { id } }, signal })),
});
}
/** Registry change R3: the transitions endpoint takes `from` + Idempotency-Key, not If-Match. */
export interface ClaimTransitionVars {
from: string;
to: string;
reason?: string;
payout_channel_id?: string | null;
proof_document_id?: string | null;
/** One per user action. Pass the same key to retry that action; omitted, a fresh one is minted. */
idempotencyKey?: string;
}
export function useClaimTransition(id: string) {
const qc = useQueryClient();
return useMutation<ExpenseClaimView, ProblemError, ClaimTransitionVars>({
mutationFn: async ({ idempotencyKey, ...body }) =>
unwrap(
await api.POST('/expenses/claims/{id}/transitions', {
params: { path: { id }, header: { 'Idempotency-Key': idempotencyKey ?? newIdempotencyKey() } },
body,
}),
),
onSuccess: (view) => {
qc.setQueryData(claimKeys.detail(id), view);
void qc.invalidateQueries({ queryKey: ['claims', 'list'] });
void qc.invalidateQueries({ queryKey: ['claims', 'summary'] });
void qc.invalidateQueries({ queryKey: ['approvals'] });
},
});
}
export function useCategories() {
return useQuery({
queryKey: ['expenses', 'categories'],
staleTime: 5 * 60_000,
queryFn: async ({ signal }) => unwrap(await api.GET('/expenses/categories', { signal })),
select: (d) => d.categories,
});
}
/** Category name in the UI locale. Record names go through pickLabel (never the UI's own translation). */
export function useCategoryLabel() {
const [locale] = useLocale();
const { data } = useCategories();
return useCallback(
(id: string | null | undefined) => {
const c = data?.find((x) => x.id === id);
return c ? pickLabel(c.names, locale) || c.name : '…';
},
[data, locale],
);
}
export function useClaimGroups(active: boolean) {
return useQuery({
queryKey: ['expenses', 'groups', { active }],
staleTime: 5 * 60_000,
queryFn: async ({ signal }) =>
unwrap(await api.GET('/expenses/groups', { params: { query: active ? { active: 'true' } : {} }, signal })),
select: (d) => d.groups,
});
}
web/src/features/claims/search.ts
import { z } from 'zod';
export const claimSearchSchema = z.object({
scope: z.enum(['mine', 'to_act', 'all']).catch('mine'),
state: z.string().regex(/^[a-z][a-z0-9_]{0,62}$/).optional().catch(undefined),
q: z.string().max(100).optional().catch(undefined),
month: z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/).optional().catch(undefined),
group: z.string().uuid().optional().catch(undefined),
});
export type ClaimSearch = z.infer<typeof claimSearchSchema>;
  • Step 5: The page, table, cards, menu and pills
web/src/features/claims/CategoryPills.tsx
import { useTranslation } from 'react-i18next';
import { Tag } from '@/components/ui/Tag';
import { useCategoryLabel } from './api';
export function CategoryPills({ ids }: { ids: string[] }) {
const { t } = useTranslation('claims');
const label = useCategoryLabel();
if (ids.length === 0) return <span className="text-muted-foreground">—</span>;
return (
<span className="inline-flex items-center gap-1">
<Tag tone="purple">{label(ids[0])}</Tag>
{ids.length > 1 && <Tag tone="muted">{t('moreCategories', { count: ids.length - 1 })}</Tag>}
</span>
);
}
web/src/features/claims/ClaimRowMenu.tsx
// components/ui has no dropdown menu (registry change R7); this is the Radix primitive, token-styled.
import * as Menu from '@radix-ui/react-dropdown-menu';
import { MoreVertical } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { IconButton } from '@/components/ui/IconButton';
import { toast } from '@/components/ui/Toaster';
import type { ExpenseClaim } from '@/lib/api/types';
const item = 'cursor-pointer rounded px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent';
export function ClaimRowMenu(p: { claim: ExpenseClaim; onOpen?: (id: string) => void; onEdit?: (id: string) => void; canEdit: boolean }) {
const { t } = useTranslation('claims');
return (
<Menu.Root>
<Menu.Trigger asChild>
<IconButton aria-label={t('menu.label', { reference: p.claim.reference })} onClick={(e) => e.stopPropagation()}>
<MoreVertical className="size-4" />
</IconButton>
</Menu.Trigger>
<Menu.Portal>
<Menu.Content align="end" className="z-50 min-w-40 rounded-md border border-border bg-popover p-1 shadow-md" onClick={(e) => e.stopPropagation()}>
{p.onOpen && <Menu.Item className={item} onSelect={() => p.onOpen?.(p.claim.id)}>{t('menu.open')}</Menu.Item>}
<Menu.Item
className={item}
onSelect={() => {
void navigator.clipboard.writeText(p.claim.reference).then(() => toast.success(t('menu.copied')));
}}
>
{t('menu.copyReference')}
</Menu.Item>
{p.canEdit && p.onEdit && <Menu.Item className={item} onSelect={() => p.onEdit?.(p.claim.id)}>{t('menu.editDraft')}</Menu.Item>}
</Menu.Content>
</Menu.Portal>
</Menu.Root>
);
}
web/src/features/claims/ClaimsTable.tsx
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { useTranslation } from 'react-i18next';
import { DataTable } from '@/components/ui/DataTable';
import { MoneyText } from '@/components/money';
import { StatusChip } from '@/components/workflow';
import { useMe } from '@/features/auth';
import type { ExpenseClaim } from '@/lib/api/types';
import { formatDateTime } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import { CategoryPills } from './CategoryPills';
import { ClaimRowMenu } from './ClaimRowMenu';
export interface ClaimsViewProps {
claims: ExpenseClaim[];
loading: boolean;
onOpen?: (id: string) => void;
onEdit?: (id: string) => void;
empty: React.ReactNode;
}
export function ClaimsTable({ claims, loading, onOpen, onEdit, empty }: ClaimsViewProps) {
const { t } = useTranslation('claims');
const [locale] = useLocale();
const { data: me } = useMe();
const columns = useMemo<ColumnDef<ExpenseClaim>[]>(
() => [
{ id: 'title', header: t('columns.title'), cell: ({ row }) => <span className="font-medium">{row.original.title}</span> },
{ id: 'category', header: t('columns.category'), cell: ({ row }) => <CategoryPills ids={row.original.category_ids} /> },
{
id: 'status',
header: t('columns.status'),
cell: ({ row }) => <StatusChip kind="expense_claim" state={row.original.state} semantic={row.original.semantic} />,
},
{
id: 'amount',
header: t('columns.amount'),
cell: ({ row }) => <MoneyText className="font-bold" amount={row.original.total} currency={row.original.currency} />,
},
{
id: 'submitted',
header: t('columns.submitted'),
cell: ({ row }) =>
row.original.submitted_at ? (
<span className="whitespace-pre-line text-sm">{formatDateTime(row.original.submitted_at, locale)}</span>
) : (
<span className="text-muted-foreground">{t('notSubmitted')}</span>
),
},
{ id: 'reference', header: t('columns.reference'), cell: ({ row }) => <span className="font-mono text-sm">{row.original.reference}</span> },
{
id: 'actions',
header: t('columns.actions'),
cell: ({ row }) => (
<ClaimRowMenu
claim={row.original}
onOpen={onOpen}
onEdit={onEdit}
canEdit={row.original.semantic === 'draft' && row.original.claimant_member_id === me?.member_id}
/>
),
},
],
[t, locale, me?.member_id, onOpen, onEdit],
);
return (
<DataTable
columns={columns}
data={claims}
loading={loading}
getRowId={(c: ExpenseClaim) => c.id}
onRowClick={onOpen ? (c: ExpenseClaim) => onOpen(c.id) : undefined}
empty={empty}
/>
);
}
// web/src/features/claims/ClaimCards.tsx — frame 94:27703, below 768px
import { useTranslation } from 'react-i18next';
import { MoneyText } from '@/components/money';
import { StatusChip } from '@/components/workflow';
import { useMe } from '@/features/auth';
import { formatDate } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import { CategoryPills } from './CategoryPills';
import { ClaimRowMenu } from './ClaimRowMenu';
import type { ClaimsViewProps } from './ClaimsTable';
export function ClaimCards({ claims, onOpen, onEdit, empty, loading }: ClaimsViewProps) {
const { t } = useTranslation('claims');
const [locale] = useLocale();
const { data: me } = useMe();
if (!loading && claims.length === 0) return <>{empty}</>;
return (
<ul className="flex flex-col gap-3" aria-label={t('title')}>
{claims.map((c) => (
<li key={c.id} className="rounded-xl border border-border bg-card p-4">
<button type="button" className="w-full text-left" onClick={() => onOpen?.(c.id)} disabled={!onOpen}>
<div className="flex items-start justify-between gap-2">
<span className="font-medium">{c.title}</span>
<CategoryPills ids={c.category_ids} />
</div>
<div className="mt-2 flex items-center justify-between">
<MoneyText className="text-lg font-bold" amount={c.total} currency={c.currency} />
<StatusChip kind="expense_claim" state={c.state} semantic={c.semantic} />
</div>
</button>
<div className="mt-3 flex items-center justify-between border-t border-border pt-2 text-xs text-muted-foreground">
<span>
{c.submitted_at ? formatDate(c.submitted_at, locale) : t('notSubmitted')} · {c.reference}
</span>
<ClaimRowMenu claim={c} onOpen={onOpen} onEdit={onEdit} canEdit={c.semantic === 'draft' && c.claimant_member_id === me?.member_id} />
</div>
</li>
))}
</ul>
);
}
// web/src/features/claims/RecordLanguageHint.tsx — F8, spec §3.3
import { useTranslation } from 'react-i18next';
import { useLocale } from '@/lib/i18n';
export function RecordLanguageHint() {
const { t } = useTranslation('claims');
const [locale] = useLocale();
if (locale !== 'zh') return null;
return <p className="text-xs text-muted-foreground">{t('recordLanguageHint')}</p>;
}
web/src/features/claims/ClaimsListPage.tsx
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { BadgeDollarSign, CircleCheck, Clock, FileText, Plus, Search, User, UserCheck, Users } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { Input } from '@/components/ui/Input';
import { LoadMore } from '@/components/ui/LoadMore';
import { MonthPicker } from '@/components/ui/MonthPicker';
import { PageHeader } from '@/components/ui/PageHeader';
import { Select } from '@/components/ui/Select';
import { StatCard } from '@/components/ui/StatCard';
import { Tabs } from '@/components/ui/Tabs';
import { MoneyText } from '@/components/money';
import { StateFilterChips } from '@/components/workflow';
import { useCan } from '@/features/auth';
import { monthKey } from '@/lib/date';
import { useClaimGroups, useClaimList, useClaimSummary, type Scope } from './api';
import { ClaimCards } from './ClaimCards';
import { ClaimsTable } from './ClaimsTable';
import type { ClaimSearch } from './search';
export interface ClaimsListPageProps {
search: ClaimSearch;
onSearch: (next: Partial<ClaimSearch>) => void;
onOpen?: (id: string) => void; // wired by Task 12
onNew?: () => void; // wired by Task 13
onEdit?: (id: string) => void; // wired by Task 13
children?: ReactNode; // the child route outlet (drawer, wizard)
}
export function ClaimsListPage({ search, onSearch, onOpen, onNew, onEdit, children }: ClaimsListPageProps) {
const { t } = useTranslation('claims');
const canReadAll = useCan('expenses.claim.read_all');
const canCreate = useCan('expenses.claim.create');
const list = useClaimList(search);
const summary = useClaimSummary({ scope: search.scope, month: search.month });
const thisMonth = useClaimSummary({ scope: 'mine', month: monthKey() });
const groups = useClaimGroups(false);
const [q, setQ] = useState(search.q ?? '');
useEffect(() => setQ(search.q ?? ''), [search.q]);
useEffect(() => {
const h = setTimeout(() => {
if ((search.q ?? '') !== q) onSearch({ q: q.trim() || undefined });
}, 300);
return () => clearTimeout(h);
}, [q, search.q, onSearch]);
// Counts are integers; the chips and "Showing n of N" may add them. Amounts are never added.
const counts = useMemo(
() => (summary.data ? Object.fromEntries(summary.data.rows.map((r) => [r.state, r.count])) : undefined),
[summary.data],
);
const total = summary.data?.rows.reduce((n, r) => n + r.count, 0);
const shownTotal = search.state ? (counts?.[search.state] ?? 0) : total;
const monthCount = thisMonth.data?.rows.reduce((n, r) => n + r.count, 0);
const k = summary.data?.kpis;
const cur = summary.data?.currency ?? 'MYR';
const rows = list.data ?? [];
const tabs: { value: Scope; label: string; icon: typeof User }[] = [
{ value: 'mine', label: t('tabs.mine'), icon: User },
{ value: 'to_act', label: t('tabs.to_act'), icon: UserCheck },
...(canReadAll ? [{ value: 'all' as const, label: t('tabs.all'), icon: Users }] : []),
];
const empty = (
<EmptyState
title={t('empty.title')}
description={t('empty.description')}
action={onNew && canCreate ? <Button onClick={onNew}>{t('newClaim')}</Button> : undefined}
/>
);
return (
<div className="flex flex-col gap-4">
<PageHeader
title={t('title')}
subtitle={monthCount === undefined ? undefined : t('subline', { count: monthCount, pending: thisMonth.data?.kpis.pending.count ?? 0 })}
actions={
onNew && canCreate ? (
<Button onClick={onNew}>
<Plus className="size-4" aria-hidden /> {t('newClaim')}
</Button>
) : undefined
}
/>
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4" aria-label={t('kpi.label')}>
{(
[
['pending', 'amber', Clock],
['approved', 'green', CircleCheck],
['paid', 'green-solid', BadgeDollarSign],
['submitted', 'blue', FileText],
] as const
).map(([key, tone, icon]) => (
// role=group + aria-label names the card for screen readers (and the test).
<div key={key} role="group" aria-label={t(`kpi.${key}`)}>
<StatCard
tone={tone}
icon={icon}
label={t(`kpi.${key}`)}
primary={k ? String(k[key].count) : '–'}
secondary={k ? <MoneyText amount={k[key].total} currency={cur} /> : undefined}
/>
</div>
))}
</section>
<Tabs value={search.scope} onChange={(v: string) => onSearch({ scope: v as Scope, state: undefined })} items={tabs} />
<section className="rounded-xl border border-border bg-card">
<div className="flex flex-wrap items-center gap-2 px-4 pt-4">
<span className="text-sm text-muted-foreground">{t('filters.status')}</span>
<StateFilterChips kind="expense_claim" counts={counts} total={total} value={search.state} onChange={(state) => onSearch({ state })} />
</div>
<div className="flex flex-col gap-2 p-4 md:flex-row md:items-center">
<label className="relative md:w-64">
<span className="sr-only">{t('filters.search')}</span>
<Search className="absolute left-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input type="search" className="pl-8" placeholder={t('filters.search')} value={q} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQ(e.target.value)} />
</label>
<div className="flex gap-2 md:ml-auto">
<Select
aria-label={t('filters.group')}
value={search.group ?? ''}
onChange={(v: string) => onSearch({ group: v || undefined })}
options={[{ value: '', label: t('filters.allGroups') }, ...(groups.data ?? []).map((g) => ({ value: g.id, label: g.name }))]}
/>
<MonthPicker aria-label={t('filters.month')} value={search.month} onChange={(v?: string) => onSearch({ month: v })} allowClear />
</div>
</div>
<div className="hidden md:block">
<ClaimsTable claims={rows} loading={list.isLoading} onOpen={onOpen} onEdit={onEdit} empty={empty} />
</div>
<div className="p-4 md:hidden">
<ClaimCards claims={rows} loading={list.isLoading} onOpen={onOpen} onEdit={onEdit} empty={empty} />
</div>
</section>
<div className="flex items-center justify-between text-sm text-muted-foreground">
{shownTotal !== undefined && <span>{t('showing', { shown: rows.length, total: shownTotal })}</span>}
<LoadMore hasMore={Boolean(list.hasNextPage)} loading={list.isFetchingNextPage} onLoad={() => void list.fetchNextPage()} />
</div>
{children}
</div>
);
}
  • Step 6: Routes and nav
web/src/features/claims/claimsRoute.tsx
import { createRoute, Outlet } from '@tanstack/react-router';
import { useCallback } from 'react';
import { appRoute } from '@/app/layouts';
import { RequirePermission } from '@/features/auth';
import { ClaimsListPage } from './ClaimsListPage';
import { claimSearchSchema, type ClaimSearch } from './search';
export const claimsRoute = createRoute({
getParentRoute: () => appRoute,
path: 'claims',
validateSearch: (s: Record<string, unknown>) => claimSearchSchema.parse(s),
component: function ClaimsRoute() {
const search = claimsRoute.useSearch();
const navigate = claimsRoute.useNavigate();
const onSearch = useCallback(
(next: Partial<ClaimSearch>) => void navigate({ search: (prev: ClaimSearch) => ({ ...prev, ...next }), replace: true }),
[navigate],
);
return (
<RequirePermission permission="expenses.claim.read">
{/* Task 12 adds onOpen; Task 13 adds onNew and onEdit (C0). */}
<ClaimsListPage search={search} onSearch={onSearch}>
<Outlet />
</ClaimsListPage>
</RequirePermission>
);
},
});
// web/src/features/claims/childRoutes.ts — append-only: Task 12 and Task 13 add one entry each (C0).
export const claimChildRoutes = [] as const;
web/src/features/claims/routes.tsx
import { claimChildRoutes } from './childRoutes';
import { claimsRoute } from './claimsRoute';
export const routes = [claimsRoute.addChildren([...claimChildRoutes])] as const;

Append to web/src/app/routes.ts (one import, one entry, following the file’s existing pattern):

import { routes as claimsRoutes } from '@/features/claims/routes';
// …in the array:
...claimsRoutes,

Append to web/src/app/shell/nav.ts:

import { ReceiptText } from 'lucide-react';
// …in navItems:
{ group: 'requests', to: '/claims', labelKey: 'claims:nav', icon: ReceiptText, permission: 'expenses.claim.read' },

Verify: grep -n "labelKey" web/src/app/shell/nav.ts — if Task 8’s items use a key without a namespace prefix, follow its form.

  • Step 7: i18n bundles

web/src/lib/i18n/locales/en/claims.json

{
"nav": "Expense claims",
"title": "My claims",
"subline": "{{count}} claims this month · {{pending}} pending",
"newClaim": "New claim",
"recordLanguageHint": "Claim text is kept in English or Malay. Please type the title and description in English or Malay.",
"kpi": { "label": "Claim summary", "pending": "Pending", "approved": "Approved", "paid": "Paid", "submitted": "Total submitted" },
"tabs": { "mine": "My claims", "to_act": "My approvals", "all": "All claims" },
"filters": { "status": "Status", "search": "Search claim ID or title…", "group": "Project", "allGroups": "All projects", "month": "Month" },
"columns": { "title": "Title", "category": "Category", "status": "Status", "amount": "Amount", "submitted": "Submitted", "reference": "Claim ID", "actions": "Actions" },
"notSubmitted": "Not submitted",
"moreCategories": "+{{count}}",
"menu": { "label": "Actions for {{reference}}", "open": "Open", "copyReference": "Copy claim ID", "editDraft": "Edit draft", "copied": "Claim ID copied" },
"showing": "Showing {{shown}} of {{total}} claims",
"empty": { "title": "No claims here yet", "description": "Claims you file will appear here." }
}

web/src/lib/i18n/locales/ms/claims.json

{
"nav": "Tuntutan perbelanjaan",
"title": "Tuntutan saya",
"subline": "{{count}} tuntutan bulan ini · {{pending}} menunggu",
"newClaim": "Tuntutan baharu",
"recordLanguageHint": "Teks tuntutan disimpan dalam bahasa Inggeris atau Melayu. Sila taip tajuk dan keterangan dalam bahasa Inggeris atau Melayu.",
"kpi": { "label": "Ringkasan tuntutan", "pending": "Menunggu", "approved": "Diluluskan", "paid": "Dibayar", "submitted": "Jumlah dihantar" },
"tabs": { "mine": "Tuntutan saya", "to_act": "Kelulusan saya", "all": "Semua tuntutan" },
"filters": { "status": "Status", "search": "Cari ID tuntutan atau tajuk…", "group": "Projek", "allGroups": "Semua projek", "month": "Bulan" },
"columns": { "title": "Tajuk", "category": "Kategori", "status": "Status", "amount": "Amaun", "submitted": "Dihantar", "reference": "ID tuntutan", "actions": "Tindakan" },
"notSubmitted": "Belum dihantar",
"moreCategories": "+{{count}}",
"menu": { "label": "Tindakan untuk {{reference}}", "open": "Buka", "copyReference": "Salin ID tuntutan", "editDraft": "Sunting draf", "copied": "ID tuntutan disalin" },
"showing": "Menunjukkan {{shown}} daripada {{total}} tuntutan",
"empty": { "title": "Belum ada tuntutan", "description": "Tuntutan yang anda failkan akan dipaparkan di sini." }
}

web/src/lib/i18n/locales/zh/claims.json

{
"nav": "费用报销",
"title": "我的报销",
"subline": "本月共 {{count}} 笔报销 · {{pending}} 笔待审核",
"newClaim": "提交报销",
"recordLanguageHint": "报销记录须使用英文或马来文,请用英文或马来文填写标题和说明。",
"kpi": { "label": "报销概览", "pending": "待审核", "approved": "已批准", "paid": "已付款", "submitted": "总提交" },
"tabs": { "mine": "我的报销", "to_act": "我的审批", "all": "所有报销" },
"filters": { "status": "主状态", "search": "搜索报销单号/标题...", "group": "项目", "allGroups": "所有项目", "month": "月份" },
"columns": { "title": "标题", "category": "类别", "status": "状态", "amount": "金额", "submitted": "提交时间", "reference": "报销单号", "actions": "操作" },
"notSubmitted": "未提交",
"moreCategories": "+{{count}}",
"menu": { "label": "{{reference}} 的操作", "open": "打开", "copyReference": "复制报销单号", "editDraft": "编辑草稿", "copied": "已复制报销单号" },
"showing": "显示 {{shown}} / {{total}} 笔报销",
"empty": { "title": "暂无报销", "description": "您提交的报销会显示在这里。" }
}

Verify: grep -rn "import.meta.glob\|addResourceBundle" web/src/lib/i18n/index.ts — a new namespace file must be picked up without editing index.ts. If Task 6 lists namespaces by hand, that list is a shared file this task cannot edit: raise it as a registry change instead of editing it.

  • Step 8: Run the test and the i18n parity test

Run: pnpm -C web test -- src/features/claims/__tests__/ClaimsListPage.test.tsx src/drift/i18nparity.test.ts Expected: PASS (6 tests + parity). Then pnpm -C web typecheck && pnpm -C web lint. Expected: no errors.

  • Step 9: Runtime check in the in-app browser

Start the dev stack (Task 8, scripts/dev-stack.sh) and preview_start with the web config. Sign in as the seeded claimant. At 1440px, light, en:

  1. Open /claims. The sidebar shows Expense claims under Requests, highlighted. The header reads My claims with the “N claims this month · M pending” subline.
  2. The four KPI cards show counts and RM amounts identical to curl -s -H "Authorization: Bearer $T" localhost:3000/v1/expenses/claims/summary?scope=mine | jq .kpis.
  3. The chips list every state of the seeded graph in display order, All first, with a 0 badge on empty states. Click Pending review: the URL gains ?state=pending_review, the table narrows, reload keeps it.
  4. Type a reference prefix in search: after ~300 ms the URL gains ?q=.
  5. With more than 10 seeded claims, Load more appends the next 10 and disappears on the last page. “Showing n of N” matches.
  6. The ⋮ menu opens with Copy claim ID; choosing it toasts “Claim ID copied”.
  7. Sign in as a member without expenses.claim.read_all: the All claims tab is absent. As the Owner it is present. Then 390px: the table is replaced by cards, KPI cards wrap two per row, nothing scrolls horizontally except the tabs. Then dark (user menu → theme): chips, pills and KPI cards follow frame 477:16079. Then zh: title 我的报销, tabs 我的报销 · 我的审批 · 所有报销, KPI 待审核 · 已批准 · 已付款 · 总提交, status chips in Chinese from the definition’s zh labels. Record each check in the task report.
  • Step 10: Commit
Terminal window
git add web/src/features/claims web/src/lib/i18n/locales/*/claims.json web/src/app/routes.ts web/src/app/shell/nav.ts
git commit -m "$(cat <<'MSG'
Plan 10 Task 11: claims list (KPIs, scope tabs, state chips, filters, keyset paging)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MSG
)"

Figma: 286:9929 (pending, the approver’s view), 278:24022 (closed). Pull get_design_context for both, and for the design note 297:15745 (the pager).

What the frames show: a right-side drawer, ~330px wide at 1440 (full-screen below 768px), over the dimmed list. Sticky header: 报销详情 with the grey subtitle 查看报销的详细信息与审批进度 (closed frame: …与报销进度), at the right a pager ‹ 3 / 7 › and a round ✕. Then the claim title (客户午餐, 20px bold) with the status chip at the right; under it Claim ID: CLM-2026-0042 at the left and 提交于 2026年8月28日 14:15 at the right. Pending frame: an amber-outlined banner with 当前待办 (amber), the current step name (报销审核, bold), the hint *请确认报销资料、收据与金额, and at the right the amount RM 165.90 with the payout method (现金支付) under it; then two full-width buttons side by side, 驳回 (red outline) and 通过审批 (purple solid). Closed frame: a green banner with the amount and 8月9日已付款 · 9月1日已结案. Then cards: 收据 (thumbnails 72px with a zoom badge, filename and size under each, and a 下载 link); 基本信息 (collapsible: 项目, 费用类别, 标题, 报销日期 as label/value rows); 审批进度 (5/5) — a vertical timeline, each step a circle (green check done, purple ring current, grey empty pending), the step label, the actor name under it, the timestamp at the right, and 当前由您处理 under the current step when it is the caller’s; 评论 (comments). The closed frame’s footer has 下载收据 (outline) and 关闭 (purple).

Departures: no comments (F3 — the decision reasons show in the timeline instead); the buttons come from available_transitions through TransitionBar, so their labels are the workflow edge labels (the seed’s reject edge is 驳回/“Reject”; its review edge is “审核通过”); a lines table is added (F11); a settlements card is added; the footer is a single Close (the viewer already downloads each receipt). A transition into a payout-semantic state opens a pay dialog first (proof upload and, for payout.channel.manage holders, a channel override), because the endpoint reads payout_channel_id and proof_document_id only on that move.

Files:

  • Create: web/src/features/claims/detail/ClaimDrawer.tsx
  • Create: web/src/features/claims/detail/ClaimFacts.tsx
  • Create: web/src/features/claims/detail/ClaimLines.tsx
  • Create: web/src/features/claims/detail/ClaimReceipts.tsx
  • Create: web/src/features/claims/detail/ClaimSettlements.tsx
  • Create: web/src/features/claims/detail/PayDialog.tsx
  • Create: web/src/features/claims/detail/usePayoutChannelLabel.ts
  • Create: web/src/features/claims/detail/detailRoute.tsx
  • Create: web/src/features/claims/detail/__tests__/ClaimDrawer.test.tsx
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/claimDetail.json
  • Modify (append one entry): web/src/features/claims/childRoutes.ts
  • Modify (one prop): web/src/features/claims/claimsRoute.tsx — pass onOpen

Interfaces:

  • Consumes: useClaim, useClaimTransition (R3 vars), useClaimList, useCategoryLabel, useClaimGroups, claimsRoute, ClaimSearch (Task 11); useWorkflowDefinition, StatusChip, TransitionBar, DecisionTimeline, useNeighbours (Task 9); useMemberNames; DocumentThumb, DocumentViewer, DocumentUploader, UploadedDoc (Task 10); useCan; Drawer, Dialog, Button, IconButton, Select, Tag, Skeleton, EmptyState, toast; problemMessage, ProblemError.

  • Produces: ClaimDrawer(p: { id: string; items: { id: string }[]; onNavigate(id: string): void; onClose(): void }) — registry change R6, consumed by Task 15. usePayoutChannelLabel() (local).

  • Step 0: Verify imports (C1). Also read web/src/components/workflow/TransitionBar.tsx to learn the accessible names of ReasonDialog’s textbox and confirm button, and adjust the test’s getByRole names below if they differ.

  • Step 1: Write the failing test

web/src/features/claims/detail/__tests__/ClaimDrawer.test.tsx
import { describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/msw/server';
import { renderWithApp } from '@/test/renderWithApp';
import type { Schemas } from '@/lib/api/types';
import { APPROVER_ID, categories, claimDefinition, groups, makeClaim, ME_ID, summary } from '../../__tests__/fixtures';
const A = '00000000-0000-4000-8000-0000000000a1';
const B = '00000000-0000-4000-8000-0000000000b2';
const C = '00000000-0000-4000-8000-0000000000c3';
const DOC = '00000000-0000-4000-8000-0000000d0c01';
const t = (to: string, en: string, zh: string, extra: Partial<Schemas['WorkflowAvailableTransition']> = {}): Schemas['WorkflowAvailableTransition'] => ({
to, action_key: to, gate: 'target', requires_reason: false, allowed: true, labels: { en, zh, ms: en }, ...extra,
});
function line(over: Partial<Schemas['ExpenseClaimLine']> = {}): Schemas['ExpenseClaimLine'] {
return {
id: crypto.randomUUID(), line_no: 1, type: 'receipt', expense_date: '2026-08-28', description: 'Lunch with client',
category_id: categories[0].id, currency: 'MYR', amount: '165.9000', fx_rate: '1', fx_rate_source: 'base', base_amount: '165.9000',
tax_code_id: null, tax_amount: '0.0000', quantity: null, rate_id: null, band: null, supplier_name: "Nando's", supplier_tin: null,
einvoice_uuid: null, einvoice_validated_at: null, document_basis: 'relaxation_receipt', relaxation_reason: 'Small merchant',
shares: [], withdrawn: false, documents: [DOC], possible_duplicate: false, duplicate_claim_references: [], ...over,
};
}
function view(id: string, over: Partial<Schemas['ExpenseClaimView']> = {}, claim: Partial<Schemas['ExpenseClaim']> = {}): Schemas['ExpenseClaimView'] {
return {
claim: makeClaim({ id, title: 'Client lunch', reference: 'EC-2026-0042', total: '165.9000', claimant_member_id: ME_ID, ...claim }),
lines: [line()],
available_transitions: [t('reviewed', 'Mark reviewed', '审核通过'), t('rejected', 'Reject', '驳回', { requires_reason: true, gate: 'advance' })],
decisions: [{ from: 'draft', to: 'pending_review', actor_member_id: ME_ID, authority_member_id: ME_ID, via: 'reporter', reason: '', decided_at: '2026-08-28T06:15:00Z' }],
settlements: [],
...over,
};
}
function mockApi(views: Record<string, Schemas['ExpenseClaimView']>, onTransition?: (body: unknown, headers: Headers) => Schemas['ExpenseClaimView']) {
server.use(
http.get('*/v1/workflow/definitions/expense_claim', () => HttpResponse.json(claimDefinition)),
http.get('*/v1/expenses/categories', () => HttpResponse.json({ categories })),
http.get('*/v1/expenses/groups', () => HttpResponse.json({ groups })),
http.get('*/v1/expenses/claims/summary', () => HttpResponse.json(summary)),
http.get('*/v1/payout/me/channels', () => HttpResponse.json({ channels: [] })),
http.get('*/v1/documents/:id', ({ params }) =>
HttpResponse.json({ id: params.id, uploaded_by: ME_ID, filename: 'receipt_01.jpg', content_type: 'image/jpeg', byte_size: 96256, sha256: 'a'.repeat(64), status: 'verified', reject_reason: null, created_at: '2026-08-28T06:00:00Z', verified_at: '2026-08-28T06:00:01Z', download_url: 'http://store/x', thumbnail_url: null, ocr_suggestions: [] }),
),
http.get('*/v1/expenses/claims', () => HttpResponse.json({ claims: [A, B, C].map((id) => views[id]?.claim ?? makeClaim({ id })), next_cursor: null })),
http.get('*/v1/expenses/claims/:id', ({ params }) =>
views[params.id as string] ? HttpResponse.json(views[params.id as string]) : HttpResponse.json({ type: 'about:blank/not-found', title: 'Not found', status: 404 }, { status: 404 }),
),
http.post('*/v1/expenses/claims/:id/transitions', async ({ request }) => HttpResponse.json(onTransition!(await request.json(), request.headers))),
);
}
const PERMS = ['expenses.claim.read', 'expenses.claim.transition'];
describe('ClaimDrawer', () => {
it('shows the your-turn banner and one button per available transition', async () => {
mockApi({ [B]: view(B) });
await renderWithApp({ path: `/claims/${B}`, permissions: PERMS });
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByRole('heading', { name: 'Client lunch' })).toBeInTheDocument();
expect(within(dialog).getByText('EC-2026-0042', { exact: false })).toBeInTheDocument();
const banner = within(dialog).getByRole('status');
expect(within(banner).getByText('Your turn')).toBeInTheDocument();
expect(within(banner).getByText('Pending review')).toBeInTheDocument();
expect(within(banner).getByText('RM 165.90')).toBeInTheDocument();
expect(within(dialog).getByRole('button', { name: 'Mark reviewed' })).toBeEnabled();
expect(within(dialog).getByRole('button', { name: 'Reject' })).toBeEnabled();
});
it('shows no your-turn banner when nothing is allowed for the caller (presence first)', async () => {
mockApi({ [B]: view(B) });
const first = await renderWithApp({ path: `/claims/${B}`, permissions: PERMS });
expect(await screen.findByText('Your turn')).toBeInTheDocument();
first.unmount();
const refused = { allowed: false, refusal: { code: 'not_granted', detail: 'You are not an approver of this step.' } };
mockApi({ [B]: view(B, { available_transitions: [t('reviewed', 'Mark reviewed', '审核通过', refused)] }) });
await renderWithApp({ path: `/claims/${B}`, permissions: PERMS });
expect(await screen.findByRole('button', { name: 'Mark reviewed' })).toBeDisabled();
expect(screen.queryByText('Your turn')).not.toBeInTheDocument();
});
it('posts from, to and an Idempotency-Key when a transition is taken', async () => {
let body: unknown;
let key: string | null = null;
mockApi({ [B]: view(B) }, (b, h) => {
body = b;
key = h.get('Idempotency-Key');
return view(B, { available_transitions: [] }, { state: 'reviewed' });
});
const { user } = await renderWithApp({ path: `/claims/${B}`, permissions: PERMS });
await user.click(await screen.findByRole('button', { name: 'Mark reviewed' }));
await waitFor(() => expect(body).toEqual({ from: 'pending_review', to: 'reviewed' }));
expect(key).toMatch(/^[0-9a-f-]{36}$/);
expect(await screen.findByText('Moved to Reviewed')).toBeInTheDocument();
});
it('opens the pay dialog for a payout-semantic target instead of posting at once', async () => {
const posted: unknown[] = [];
const payable = view(B, { available_transitions: [t('completed', 'Mark paid', '标记已付款')] }, { state: 'category_verified', semantic: 'accrual' });
mockApi({ [B]: payable }, (b) => {
posted.push(b);
return view(B, { available_transitions: [] }, { state: 'completed', semantic: 'payout' });
});
const { user } = await renderWithApp({ path: `/claims/${B}`, permissions: PERMS });
await user.click(await screen.findByRole('button', { name: 'Mark paid' }));
const pay = await screen.findByRole('dialog', { name: 'Mark paid' });
expect(within(pay).getByText('RM 165.90')).toBeInTheDocument();
expect(posted).toHaveLength(0);
await user.click(within(pay).getByRole('button', { name: 'Confirm payment' }));
await waitFor(() => expect(posted).toEqual([{ from: 'category_verified', to: 'completed' }]));
});
it('steps to the neighbour with the pager and with the arrow keys', async () => {
mockApi({ [A]: view(A, {}, { title: 'Claim A' }), [B]: view(B), [C]: view(C, {}, { title: 'Claim C' }) });
const { user, router } = await renderWithApp({ path: `/claims/${B}`, permissions: PERMS });
expect(await screen.findByText('2 / 3')).toBeInTheDocument();
await user.keyboard('{ArrowRight}');
await waitFor(() => expect(router.state.location.pathname).toBe(`/claims/${C}`));
expect(await screen.findByRole('heading', { name: 'Claim C' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Previous claim' }));
await waitFor(() => expect(router.state.location.pathname).toBe(`/claims/${B}`));
});
it('flags a possible duplicate with the references the caller may read', async () => {
mockApi({ [B]: view(B, { lines: [line({ possible_duplicate: true, duplicate_claim_references: ['EC-2026-0007'] })] }) });
await renderWithApp({ path: `/claims/${B}`, permissions: PERMS });
expect(await screen.findByText('Possible duplicate receipt')).toBeInTheDocument();
expect(screen.getByText('EC-2026-0007')).toBeInTheDocument();
});
it('says "not found or not visible" for a 404, the same wording for both', async () => {
mockApi({});
await renderWithApp({ path: `/claims/${B}`, permissions: PERMS });
expect(await screen.findByText('This claim was not found, or you cannot see it.')).toBeInTheDocument();
});
it('names the approver in the timeline from the member directory', async () => {
mockApi({
[B]: view(B, {
decisions: [
{ from: 'draft', to: 'pending_review', actor_member_id: ME_ID, authority_member_id: ME_ID, via: 'reporter', reason: '', decided_at: '2026-08-28T06:15:00Z' },
{ from: 'pending_review', to: 'reviewed', actor_member_id: APPROVER_ID, authority_member_id: APPROVER_ID, via: 'grant', reason: '', decided_at: '2026-08-28T08:20:00Z' },
],
}, { state: 'reviewed' }),
});
await renderWithApp({
path: `/claims/${B}`,
permissions: PERMS,
members: [{ member_id: ME_ID, name: 'Zi Yang', active: true }, { member_id: APPROVER_ID, name: 'Alex Tan', active: true }],
});
expect(await screen.findByText('Alex Tan')).toBeInTheDocument();
});
});
  • Step 2: Run it and watch it fail

Run: pnpm -C web test -- src/features/claims/detail/__tests__/ClaimDrawer.test.tsx Expected: FAIL — /claims/<id> matches no route (“Not Found”).

  • Step 3: Payout channel labels and the pay dialog
web/src/features/claims/detail/usePayoutChannelLabel.ts
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
/** GET /payout/me/channels needs no permission; its `label` is already in the request locale. Key fixed by R6. */
export function usePayoutChannelLabel() {
const { data } = useQuery({
queryKey: ['payout', 'me', 'channels'],
staleTime: 5 * 60_000,
queryFn: async ({ signal }) => unwrap(await api.GET('/payout/me/channels', { signal })),
});
return useCallback((id: string | null | undefined) => (id ? (data?.channels.find((c) => c.id === id)?.label ?? '…') : '—'), [data]);
}
web/src/features/claims/detail/PayDialog.tsx
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Dialog } from '@/components/ui/Dialog';
import { Field } from '@/components/ui/Field';
import { Select } from '@/components/ui/Select';
import { MoneyText } from '@/components/money';
import { DocumentUploader, type UploadedDoc } from '@/components/upload';
import { useCan } from '@/features/auth';
import { api } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
import type { AvailableTransition, ExpenseClaim } from '@/lib/api/types';
import { pickLabel, useLocale } from '@/lib/i18n';
import { usePayoutChannelLabel } from './usePayoutChannelLabel';
export function PayDialog(p: {
claim: ExpenseClaim;
transition: AvailableTransition;
busy: boolean;
onCancel(): void;
onConfirm(v: { payout_channel_id?: string; proof_document_id?: string }): void;
}) {
const { t } = useTranslation('claimDetail');
const [locale] = useLocale();
const channelLabel = usePayoutChannelLabel();
const canPickChannel = useCan('payout.channel.manage');
const channels = useQuery({
queryKey: ['payout', 'channels'],
enabled: canPickChannel,
queryFn: async ({ signal }) => unwrap(await api.GET('/payout/channels', { signal })),
select: (d) => d.channels.filter((c) => c.active),
});
const [override, setOverride] = useState('');
const [proof, setProof] = useState<UploadedDoc[]>([]);
const title = pickLabel(p.transition.labels, locale);
return (
<Dialog
open
onOpenChange={(o: boolean) => !o && p.onCancel()}
title={title}
description={t('pay.description')}
footer={
<>
<Button variant="outline" onClick={p.onCancel}>{t('pay.cancel')}</Button>
<Button
loading={p.busy}
onClick={() => p.onConfirm({ payout_channel_id: override || undefined, proof_document_id: proof[0]?.id })}
>
{t('pay.confirm')}
</Button>
</>
}
>
<dl className="grid grid-cols-[8rem_1fr] gap-y-2 text-sm">
<dt className="text-muted-foreground">{t('pay.amount')}</dt>
<dd><MoneyText className="font-bold" amount={p.claim.total} currency={p.claim.currency} /></dd>
<dt className="text-muted-foreground">{t('pay.preferred')}</dt>
<dd>{channelLabel(p.claim.payout_channel_id)}</dd>
</dl>
{canPickChannel && (
<Field label={t('pay.override')} hint={t('pay.overrideHint')} htmlFor="pay-channel">
<Select
id="pay-channel"
value={override}
onChange={setOverride}
options={[{ value: '', label: t('pay.useClaimant') }, ...(channels.data ?? []).map((c) => ({ value: c.id, label: c.label }))]}
/>
</Field>
)}
<Field label={t('pay.proof')} hint={t('pay.proofHint')}>
<DocumentUploader
multiple={false}
value={proof}
onUploaded={(d) => setProof([d])}
onRemoved={() => setProof([])}
/>
</Field>
</Dialog>
);
}
  • Step 4: The drawer and its sections
web/src/features/claims/detail/ClaimReceipts.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DocumentThumb, DocumentViewer } from '@/components/upload';
export function ClaimReceipts({ documentIds }: { documentIds: string[] }) {
const { t } = useTranslation('claimDetail');
const [open, setOpen] = useState<number | null>(null);
return (
<section className="rounded-xl border border-border bg-card p-4" aria-labelledby="claim-receipts">
<h3 id="claim-receipts" className="mb-3 font-bold">{t('receipts.title')}</h3>
{documentIds.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('receipts.none')}</p>
) : (
<ul className="flex flex-wrap gap-3">
{documentIds.map((id, i) => (
<li key={id}><DocumentThumb documentId={id} onOpen={() => setOpen(i)} /></li>
))}
</ul>
)}
<DocumentViewer documentIds={documentIds} index={open ?? 0} open={open !== null} onOpenChange={(o) => !o && setOpen(null)} />
</section>
);
}
// web/src/features/claims/detail/ClaimFacts.tsx — the 基本信息 card
import { ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { ExpenseClaimView } from '@/lib/api/types';
import { formatDate } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import { useCategoryLabel, useClaimGroups } from '../api';
import { usePayoutChannelLabel } from './usePayoutChannelLabel';
export function ClaimFacts({ view }: { view: ExpenseClaimView }) {
const { t } = useTranslation('claimDetail');
const [locale] = useLocale();
const groups = useClaimGroups(false);
const category = useCategoryLabel();
const channel = usePayoutChannelLabel();
const active = view.lines.filter((l) => !l.withdrawn);
const dates = [...new Set(active.map((l) => l.expense_date))].map((d) => formatDate(d, locale)).join(', ');
const rows: [string, string][] = [
[t('facts.group'), groups.data?.find((g) => g.id === view.claim.group_id)?.name ?? '—'],
[t('facts.categories'), view.claim.category_ids.map(category).join(', ') || '—'],
[t('facts.title'), view.claim.title],
[t('facts.dates'), dates || '—'],
[t('facts.payout'), channel(view.claim.payout_channel_id)],
];
return (
<details open className="group rounded-xl border border-border bg-card p-4">
<summary className="flex cursor-pointer list-none items-center justify-between font-bold">
{t('facts.heading')}
<ChevronDown className="size-4 transition-transform group-open:rotate-180" aria-hidden />
</summary>
<dl className="mt-3 divide-y divide-border text-sm">
{rows.map(([k, v]) => (
<div key={k} className="grid grid-cols-[6rem_1fr] py-2">
<dt className="text-muted-foreground">{k}</dt>
<dd>{v}</dd>
</div>
))}
</dl>
</details>
);
}
// web/src/features/claims/detail/ClaimLines.tsx — F11
import { useTranslation } from 'react-i18next';
import { Tag } from '@/components/ui/Tag';
import { MoneyText } from '@/components/money';
import type { ExpenseClaimView } from '@/lib/api/types';
import { formatDate } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import { useCategoryLabel } from '../api';
export function ClaimLines({ view }: { view: ExpenseClaimView }) {
const { t } = useTranslation('claimDetail');
const [locale] = useLocale();
const category = useCategoryLabel();
const base = view.claim.currency;
return (
<section className="rounded-xl border border-border bg-card p-4" aria-labelledby="claim-lines">
<h3 id="claim-lines" className="mb-3 font-bold">{t('lines.title')}</h3>
<ul className="flex flex-col divide-y divide-border">
{view.lines.map((l) => (
<li key={l.id} className={l.withdrawn ? 'py-2 text-muted-foreground line-through' : 'py-2'}>
<div className="flex justify-between gap-2 text-sm">
<span>{l.line_no}. {l.description}</span>
<span className="text-right">
<MoneyText amount={l.amount} currency={l.currency} />
{l.currency !== base && (
<span className="block text-xs text-muted-foreground"><MoneyText amount={l.base_amount} currency={base} /></span>
)}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{formatDate(l.expense_date, locale)}</span>
<span>· {category(l.category_id)}</span>
{l.supplier_name && <span>· {l.supplier_name}</span>}
<span>· {t('lines.receipts', { count: l.documents.length })}</span>
{l.withdrawn && <Tag tone="muted">{t('lines.withdrawn')}</Tag>}
</div>
{l.possible_duplicate && (
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs" role="note">
<Tag tone="red">{t('lines.duplicate')}</Tag>
{l.duplicate_claim_references.length > 0 ? (
l.duplicate_claim_references.map((r) => <span key={r} className="font-mono">{r}</span>)
) : (
<span>{t('lines.duplicateHidden')}</span>
)}
</div>
)}
</li>
))}
</ul>
</section>
);
}
web/src/features/claims/detail/ClaimSettlements.tsx
import { useTranslation } from 'react-i18next';
import { MoneyText } from '@/components/money';
import { DocumentThumb } from '@/components/upload';
import { useMemberNames } from '@/features/members/useMemberNames';
import type { ExpenseClaimView } from '@/lib/api/types';
import { formatDateTime } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
export function ClaimSettlements({ view }: { view: ExpenseClaimView }) {
const { t } = useTranslation('claimDetail');
const [locale] = useLocale();
const name = useMemberNames();
if (view.settlements.length === 0) return null;
return (
<section className="rounded-xl border border-border bg-card p-4" aria-labelledby="claim-settlements">
<h3 id="claim-settlements" className="mb-3 font-bold">{t('settlements.title')}</h3>
<ul className="flex flex-col gap-3 text-sm">
{view.settlements.map((s) => (
<li key={s.id} className="flex items-start justify-between gap-3">
<div>
<p className="font-medium">
{s.channel_name}
{s.account_tail && <span className="ml-1 font-mono text-muted-foreground">•••• {s.account_tail}</span>}
</p>
<p className="text-xs text-muted-foreground">{t('settlements.by', { name: name(s.settled_by), at: formatDateTime(s.settled_at, locale) })}</p>
</div>
<div className="flex items-center gap-2">
<MoneyText className="font-bold" amount={s.amount} currency={s.currency} />
{s.proof_document_id && <DocumentThumb documentId={s.proof_document_id} />}
</div>
</li>
))}
</ul>
</section>
);
}
web/src/features/claims/detail/ClaimDrawer.tsx
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Drawer } from '@/components/ui/Drawer';
import { EmptyState } from '@/components/ui/EmptyState';
import { IconButton } from '@/components/ui/IconButton';
import { Skeleton } from '@/components/ui/Skeleton';
import { toast } from '@/components/ui/Toaster';
import { MoneyText } from '@/components/money';
import { DecisionTimeline, StatusChip, TransitionBar, useNeighbours, useWorkflowDefinition } from '@/components/workflow';
import { ProblemError, problemMessage } from '@/lib/api/problem';
import type { AvailableTransition, ExpenseClaimView } from '@/lib/api/types';
import { formatDate, formatDateTime } from '@/lib/date';
import { pickLabel, useLocale } from '@/lib/i18n';
import { useClaim, useClaimTransition, type ClaimTransitionVars } from '../api';
import { ClaimFacts } from './ClaimFacts';
import { ClaimLines } from './ClaimLines';
import { ClaimReceipts } from './ClaimReceipts';
import { ClaimSettlements } from './ClaimSettlements';
import { PayDialog } from './PayDialog';
import { usePayoutChannelLabel } from './usePayoutChannelLabel';
export interface ClaimDrawerProps {
id: string;
items: { id: string }[];
onNavigate(id: string): void;
onClose(): void;
}
export function ClaimDrawer({ id, items, onNavigate, onClose }: ClaimDrawerProps) {
const { t } = useTranslation('claimDetail');
const { t: tc } = useTranslation();
const [locale] = useLocale();
const claim = useClaim(id);
const def = useWorkflowDefinition('expense_claim');
const nav = useNeighbours(items, id);
const transition = useClaimTransition(id);
const [payTarget, setPayTarget] = useState<AvailableTransition | null>(null);
const stateOf = useCallback((key: string) => def.data?.graph.states.find((s) => s.key === key), [def.data]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const el = e.target as HTMLElement | null;
if (el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName))) return;
if (e.key === 'ArrowLeft' && nav.prev) onNavigate(nav.prev);
if (e.key === 'ArrowRight' && nav.next) onNavigate(nav.next);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [nav.prev, nav.next, onNavigate]);
async function run(vars: ClaimTransitionVars): Promise<boolean> {
try {
const v = await transition.mutateAsync(vars);
toast.success(t('moved', { state: pickLabel(stateOf(v.claim.state)?.labels, locale) || v.claim.state }));
return true;
} catch (e) {
toast.error(problemMessage(e, tc));
if (e instanceof ProblemError && e.status === 409) void claim.refetch();
return false;
}
}
async function take(tr: AvailableTransition, reason?: string) {
const current = claim.data?.claim;
if (!current) return;
// The endpoint reads payout_channel_id and proof_document_id only on a move INTO a payout-semantic state.
if (stateOf(tr.to)?.semantic === 'payout') {
setPayTarget(tr);
return;
}
await run({ from: current.state, to: tr.to, reason });
}
const header = (
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-lg font-bold">{t('title')}</h2>
<p className="text-xs text-muted-foreground">{t('subtitle')}</p>
</div>
{nav.index >= 0 && nav.total > 1 && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<IconButton aria-label={t('prev')} disabled={!nav.prev} onClick={() => nav.prev && onNavigate(nav.prev)}>
<ChevronLeft className="size-4" />
</IconButton>
<span>{nav.index + 1} / {nav.total}</span>
<IconButton aria-label={t('next')} disabled={!nav.next} onClick={() => nav.next && onNavigate(nav.next)}>
<ChevronRight className="size-4" />
</IconButton>
</div>
)}
</div>
);
return (
<Drawer
open
onOpenChange={(o: boolean) => !o && onClose()}
header={header}
footer={<Button className="w-full" onClick={onClose}>{t('close')}</Button>}
>
{claim.isLoading && <Skeleton className="h-96 w-full" />}
{claim.error && (
<EmptyState
title={claim.error instanceof ProblemError && claim.error.status === 404 ? t('notFound') : problemMessage(claim.error, tc)}
/>
)}
{claim.data && (
<ClaimBody
view={claim.data}
stateLabel={(k) => pickLabel(stateOf(k)?.labels, locale) || k}
version={def.data?.version}
onTake={take}
/>
)}
{payTarget && claim.data && (
<PayDialog
claim={claim.data.claim}
transition={payTarget}
busy={transition.isPending}
onCancel={() => setPayTarget(null)}
onConfirm={async (v) => {
const ok = await run({ from: claim.data!.claim.state, to: payTarget.to, ...v });
if (ok) setPayTarget(null);
}}
/>
)}
</Drawer>
);
}
function ClaimBody(p: { view: ExpenseClaimView; stateLabel(k: string): string; version?: number; onTake(t: AvailableTransition, reason?: string): Promise<unknown> }) {
const { t } = useTranslation('claimDetail');
const [locale] = useLocale();
const channel = usePayoutChannelLabel();
const { claim, available_transitions, settlements, decisions, lines } = p.view;
const canAct = available_transitions.some((a) => a.allowed);
const documentIds = useMemo(() => [...new Set(lines.filter((l) => !l.withdrawn).flatMap((l) => l.documents))], [lines]);
return (
<div className="flex flex-col gap-4">
<div>
<div className="flex items-start justify-between gap-2">
<h3 className="text-xl font-bold">{claim.title}</h3>
<StatusChip kind="expense_claim" version={p.version} state={claim.state} semantic={claim.semantic} />
</div>
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>{t('reference', { reference: claim.reference })}</span>
{claim.submitted_at && <span>{t('submittedAt', { at: formatDateTime(claim.submitted_at, locale) })}</span>}
</div>
</div>
{canAct ? (
<div role="status" className="flex items-center justify-between rounded-xl border border-warning bg-warning/10 p-4">
<div>
<p className="text-sm font-medium text-warning">{t('banner.yourTurn')}</p>
<p className="font-bold">{p.stateLabel(claim.state)}</p>
<p className="text-xs text-muted-foreground">{t('banner.checkHint')}</p>
</div>
<div className="text-right">
<MoneyText className="text-xl font-bold" amount={claim.total} currency={claim.currency} />
<p className="text-xs text-muted-foreground">{channel(claim.payout_channel_id)}</p>
</div>
</div>
) : (
<div className={settlements.length ? 'rounded-xl bg-success/10 p-4' : 'rounded-xl bg-muted p-4'}>
<MoneyText className="text-xl font-bold" amount={claim.total} currency={claim.currency} />
{settlements[0] && <p className="text-xs">{t('banner.paidOn', { date: formatDate(settlements[0].settled_at, locale) })}</p>}
</div>
)}
<TransitionBar transitions={available_transitions} onTake={p.onTake} layout="bar" />
<ClaimReceipts documentIds={documentIds} />
<ClaimFacts view={p.view} />
<ClaimLines view={p.view} />
{p.version !== undefined && (
<section className="rounded-xl border border-border bg-card p-4" aria-label={t('timeline')}>
<DecisionTimeline
kind="expense_claim"
version={p.version}
currentState={claim.state}
// R4: claim decisions carry no action_key; the timeline does not need one.
decisions={decisions.map((d) => ({ ...d, action_key: '' }))}
/>
</section>
)}
<ClaimSettlements view={p.view} />
</div>
);
}
web/src/features/claims/detail/detailRoute.tsx
import { createRoute } from '@tanstack/react-router';
import { useClaimList } from '../api';
import { claimsRoute } from '../claimsRoute';
import type { ClaimSearch } from '../search';
import { ClaimDrawer } from './ClaimDrawer';
export const claimDetailRoute = createRoute({
getParentRoute: () => claimsRoute,
path: '$id',
component: function ClaimDetailRoute() {
const { id } = claimDetailRoute.useParams();
const search = claimsRoute.useSearch();
const navigate = claimDetailRoute.useNavigate();
// Same key as the list above it: the pager reads the cached pages, never refetches (P5).
const list = useClaimList(search);
return (
<ClaimDrawer
id={id}
items={list.data ?? []}
onNavigate={(next) => void navigate({ to: '/claims/$id', params: { id: next }, search: (p: ClaimSearch) => p })}
onClose={() => void navigate({ to: '/claims', search: (p: ClaimSearch) => p })}
/>
);
},
});

Append to web/src/features/claims/childRoutes.ts (replace the empty tuple with the first entry; Task 13 appends after it):

import { claimDetailRoute } from './detail/detailRoute';
export const claimChildRoutes = [claimDetailRoute] as const;

In web/src/features/claims/claimsRoute.tsx, add exactly this prop to <ClaimsListPage …>:

onOpen={(id: string) => void navigate({ to: '/claims/$id', params: { id }, search: (p: ClaimSearch) => p })}
  • Step 5: i18n bundles

en/claimDetail.json

{
"title": "Claim details",
"subtitle": "The claim's details and its approval progress",
"prev": "Previous claim",
"next": "Next claim",
"close": "Close",
"reference": "Claim ID: {{reference}}",
"submittedAt": "Submitted {{at}}",
"notFound": "This claim was not found, or you cannot see it.",
"moved": "Moved to {{state}}",
"timeline": "Approval progress",
"banner": { "yourTurn": "Your turn", "checkHint": "Please check the claim, its receipts and the amount", "paidOn": "Paid {{date}}" },
"receipts": { "title": "Receipts", "none": "No receipts attached" },
"facts": { "heading": "Basic information", "group": "Project", "categories": "Category", "title": "Title", "dates": "Expense date", "payout": "Payout method" },
"lines": { "title": "Lines", "receipts_one": "{{count}} receipt", "receipts_other": "{{count}} receipts", "withdrawn": "Withdrawn", "duplicate": "Possible duplicate receipt", "duplicateHidden": "The matching claim is not visible to you" },
"settlements": { "title": "Payouts", "by": "Paid by {{name}} · {{at}}" },
"pay": { "description": "Record the payout. The claimant's preferred method is used unless you choose another.", "amount": "Amount", "preferred": "Claimant's method", "override": "Pay with", "overrideHint": "Leave as is to use the claimant's method", "useClaimant": "Claimant's method", "proof": "Proof of payment", "proofHint": "Some payout methods require proof", "cancel": "Cancel", "confirm": "Confirm payment" }
}

ms/claimDetail.json

{
"title": "Butiran tuntutan",
"subtitle": "Butiran tuntutan dan kemajuan kelulusannya",
"prev": "Tuntutan sebelumnya",
"next": "Tuntutan seterusnya",
"close": "Tutup",
"reference": "ID tuntutan: {{reference}}",
"submittedAt": "Dihantar {{at}}",
"notFound": "Tuntutan ini tidak ditemui, atau anda tidak boleh melihatnya.",
"moved": "Dipindahkan ke {{state}}",
"timeline": "Kemajuan kelulusan",
"banner": { "yourTurn": "Giliran anda", "checkHint": "Sila semak tuntutan, resit dan amaunnya", "paidOn": "Dibayar {{date}}" },
"receipts": { "title": "Resit", "none": "Tiada resit dilampirkan" },
"facts": { "heading": "Maklumat asas", "group": "Projek", "categories": "Kategori", "title": "Tajuk", "dates": "Tarikh perbelanjaan", "payout": "Kaedah pembayaran" },
"lines": { "title": "Baris", "receipts_one": "{{count}} resit", "receipts_other": "{{count}} resit", "withdrawn": "Ditarik balik", "duplicate": "Kemungkinan resit berganda", "duplicateHidden": "Tuntutan yang sepadan tidak kelihatan kepada anda" },
"settlements": { "title": "Pembayaran", "by": "Dibayar oleh {{name}} · {{at}}" },
"pay": { "description": "Rekodkan pembayaran. Kaedah pilihan penuntut digunakan melainkan anda memilih yang lain.", "amount": "Amaun", "preferred": "Kaedah penuntut", "override": "Bayar dengan", "overrideHint": "Biarkan untuk menggunakan kaedah penuntut", "useClaimant": "Kaedah penuntut", "proof": "Bukti pembayaran", "proofHint": "Sesetengah kaedah pembayaran memerlukan bukti", "cancel": "Batal", "confirm": "Sahkan pembayaran" }
}

zh/claimDetail.json

{
"title": "报销详情",
"subtitle": "查看报销的详细信息与审批进度",
"prev": "上一笔报销",
"next": "下一笔报销",
"close": "关闭",
"reference": "Claim ID: {{reference}}",
"submittedAt": "提交于 {{at}}",
"notFound": "找不到这笔报销,或您无权查看。",
"moved": "已转为{{state}}",
"timeline": "报销进度",
"banner": { "yourTurn": "当前待办", "checkHint": "*请确认报销资料、收据与金额", "paidOn": "{{date}}已付款" },
"receipts": { "title": "收据", "none": "未附收据" },
"facts": { "heading": "基本信息", "group": "项目", "categories": "费用类别", "title": "标题", "dates": "报销日期", "payout": "收款方式" },
"lines": { "title": "报销明细", "receipts_one": "{{count}} 张收据", "receipts_other": "{{count}} 张收据", "withdrawn": "已撤回", "duplicate": "疑似重复收据", "duplicateHidden": "相符的报销您无权查看" },
"settlements": { "title": "付款记录", "by": "{{name}} 付款 · {{at}}" },
"pay": { "description": "登记付款。默认使用报销人的收款方式,也可另选。", "amount": "金额", "preferred": "报销人的收款方式", "override": "付款方式", "overrideHint": "不修改则使用报销人的收款方式", "useClaimant": "报销人的收款方式", "proof": "付款凭证", "proofHint": "部分收款方式需要凭证", "cancel": "取消", "confirm": "确认付款" }
}

Verify: the parity test must accept _one/_other plural pairs in all three locales (zh has no plural distinction, but the keys must still exist for parity). If Task 6’s parity test treats plural suffixes differently, follow it.

  • Step 6: Run the tests

Run: pnpm -C web test -- src/features/claims src/drift/i18nparity.test.ts Expected: PASS (Task 11’s 6, this task’s 8, parity). Then typecheck and lint: no errors.

  • Step 7: Runtime check

Dev stack and preview as in Task 11. Seed state needed: a claim of the claimant’s at pending_review, visible to the seeded approver. At 1440, light, en:

  1. As the claimant, click a row on /claims: the drawer slides in from the right, the URL is /claims/<id>, the list stays visible and dimmed behind it. Focus is inside the drawer; Esc closes it and the URL returns to /claims with the filters intact.
  2. The header shows Claim details, the pager n / N; → and ← step through the loaded rows without closing; the pager buttons do the same.
  3. No “Your turn” banner for the claimant (no allowed exit); the Mark reviewed button is disabled and its tooltip shows the refusal detail (TransitionBar).
  4. Receipts show thumbnails; clicking one opens the viewer (zoom, download).
  5. Sign in as the approver, open My approvals, open the claim: the amber “Your turn · Pending review” banner with the amount; Reject opens the reason dialog; cancel. Mark reviewed: toast “Moved to Reviewed”, chip updates, list row updates, the timeline gains a green step with the approver’s name.
  6. Take the claim to category_verified (seed or UI), then Mark paid: the pay dialog shows the amount and the claimant’s method; upload a proof, confirm; the green banner “Paid ” and a Payouts card appear.
  7. Open /claims/<random uuid>: “This claim was not found, or you cannot see it.” Then 390px (drawer full-screen, pager still reachable), dark, zh (报销详情, 当前待办, 收据, 基本信息, 报销进度 labels; chip text from the definition’s zh labels).
  • Step 8: Commit
Terminal window
git add web/src/features/claims web/src/lib/i18n/locales/*/claimDetail.json
git commit -m "$(cat <<'MSG'
Plan 10 Task 12: claim detail drawer (pager, transitions, pay dialog, lines, timeline)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MSG
)"

Task 13: Submit-claim wizard and draft editing

Section titled “Task 13: Submit-claim wizard and draft editing”

Figma: 233:13631 (step 1), 166:12082 (step 2), 166:11488 (step 3). Pull get_design_context for each.

What the frames show: a centred dialog, 700px wide, white, rounded 16px. Header: 提交报销 (24px bold) with a grey subtitle that changes per step — 所有信息均需填写 / 填写金额与上传收据 / 确认收款方式与报销资料无误后再提交 — and a round ✕. Under it a stepper: ① 报销信息 —— ② 金额与收据 —— ③ 确认提交; done and current steps have a purple filled circle and purple text, later ones a grey outline. Step 1: 项目 (select, placeholder 选择项目, full half-width); then two columns 报销类型 (select, value 费用报销) and 费用类别 (select, placeholder 选择类别); 标题 with the grey aside (这笔报销在列表中显示的名称), placeholder 例如:客户午餐; 用途说明 (textarea, placeholder 简单说明这笔费用的用途). Footer: 保存草稿 (outline, left), 下一步 › (purple, right). Step 2: 上传收据 with the aside (支持 JPG、PNG、PDF,单个文件不超过 10MB), a dashed drop zone 点击上传或拖拽文件到此处, then “2个文件已上传” and one row per file (PDF icon, name, size, 已上传 ✓ or 上传中 ◌, a bin icon); 费用日期 (aside (请选择收据上的日期), a date field with a calendar icon); two columns 总金额 (aside (请填写所有上传收据的合计金额), an RM prefix box then the amount) and 币种 (select, MYR); 备注 (选填) textarea. Footer: 保存草稿, ‹ 上一步 (outline), 下一步 ›. Step 3: 收款方式 — three radio cards in a row (银行转账 / “Maybank •••• 4821”, 现金 / “到财务部领取”, Touch ’n Go 电子钱包 / “•••• 8890”), the selected one with a purple border; under them ☑ 设为默认收款方式 at the left and the underlined link 管理收款资料 at the right; 报销资料确认 — a bordered two-column list (项目, 报销类型, 费用类别, 标题, 说明 | 上传收据 “2个文件已上传”, 费用日期, 总金额, 币种, 收款方式, 备注). Footer: 保存草稿, ‹ 上一步, 提交报销 (purple).

Departures (reasons in the Part C notes): the line type select offers receipt only (mileage and per-diem need rate bands the wizard does not model yet); 备注 is replaced by an optional supplier field, because a line has no note field and the list’s subline wants the supplier (G12); a receipt evidence select (document_basis) is added, because submission refuses a receipt line without one (document_basis rule=required), with e-Invoice UUID or reason there is no e-Invoice shown for the matching basis; 设为默认收款方式 is not rendered (no API, G11); the confirm list shows the line amount in its currency and “Total to reimburse” — the server’s claim.total in the base currency — never a client sum. Submit takes the first available transition whose target state’s semantic is not draft, cancelled or rejected (spec §5.2), never a hard-coded key. The zh placeholder for the title is an English example (C6).

Mapping to the API (F12): Next on step 1 creates the claim (POST /expenses/claims, {title, group_id}) or PATCHes its header. Next on step 2 creates or replaces the one line (POST|PATCH …/lines, whole line) and attaches or detaches documents so the line holds exactly the uploaded set. Step 3 PATCHes payout_channel_id, then takes the submit transition with a fresh Idempotency-Key. Every write sends If-Match: "<revision>" from the last response, because each write moves the revision. Save draft runs the same writes as Next for the current step and closes. Save and add another line (step 2, secondary) saves the line and clears the line fields for a new one; the other active lines are listed above the form with a withdraw button.

Files:

  • Create: web/src/features/claims/wizard/ClaimWizard.tsx
  • Create: web/src/features/claims/wizard/StepInfo.tsx
  • Create: web/src/features/claims/wizard/StepAmount.tsx
  • Create: web/src/features/claims/wizard/StepConfirm.tsx
  • Create: web/src/features/claims/wizard/Stepper.tsx
  • Create: web/src/features/claims/wizard/wizardSchema.ts
  • Create: web/src/features/claims/wizard/useClaimDraft.ts
  • Create: web/src/features/claims/wizard/usePayoutOptions.ts
  • Create: web/src/features/claims/wizard/useDocumentsMeta.ts
  • Create: web/src/features/claims/wizard/pickSubmitTransition.ts
  • Create: web/src/features/claims/wizard/lineRefusal.ts
  • Create: web/src/features/claims/wizard/wizardRoutes.tsx
  • Create: web/src/features/claims/wizard/__tests__/pickSubmitTransition.test.ts
  • Create: web/src/features/claims/wizard/__tests__/lineRefusal.test.ts
  • Create: web/src/features/claims/wizard/__tests__/ClaimWizard.test.tsx
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/claimWizard.json
  • Modify (append two entries): web/src/features/claims/childRoutes.ts
  • Modify (two props): web/src/features/claims/claimsRoute.tsx — pass onNew, onEdit

Interfaces:

  • Consumes: claimKeys, useClaim, useCategories, useClaimGroups, claimsRoute, ClaimSearch, RecordLanguageHint (Task 11); useWorkflowDefinition (Task 9); DocumentUploader, UploadedDoc (Task 10); MoneyInput; useMe; api, newIdempotencyKey, unwrap, ProblemError, applyProblemToForm, problemMessage; todayISO, formatDate; pickLabel, useLocale; Dialog, Button, Field, Input, Textarea, Select, DatePicker, RadioCards, Skeleton, toast.

  • Produces: ClaimWizard(p: { claimId?: string; onClose(): void; onDone(claimId: string): void }), pickSubmitTransition(view, definition) (also re-implemented in Task 17 — no cross-feature import), routes /claims/new and /claims/$id/edit.

  • Step 0: Verify imports (C1). Also check how Select and DatePicker behave in jsdom: grep -n "hasPointerCapture\|scrollIntoView" web/src/test/setup.ts. Radix Select needs both polyfilled; if Task 7 used a native <select>, replace the pick helper below with user.selectOptions.

  • Step 1: Write the failing unit tests

web/src/features/claims/wizard/__tests__/pickSubmitTransition.test.ts
import { describe, expect, it } from 'vitest';
import type { Schemas } from '@/lib/api/types';
import { pickSubmitTransition } from '../pickSubmitTransition';
const tr = (to: string, allowed = true): Schemas['WorkflowAvailableTransition'] => ({
to, action_key: `go_${to}`, gate: 'reporter', requires_reason: false, allowed, labels: { en: to },
});
const def = (states: [string, Schemas['WorkflowSemantic']][]): Schemas['WorkflowDefinition'] => ({
id: 'd', kind: 'expense_claim', version: 3, published_at: '2026-01-01T00:00:00Z',
graph: {
kind: 'expense_claim', posting_order: 'accrual_first', edges: [], grants: [],
states: states.map(([key, semantic], i) => ({ key, semantic, display_order: i, is_terminal: false, labels: { en: key } })),
},
});
describe('pickSubmitTransition', () => {
const d = def([['draft', 'draft'], ['withdrawn', 'cancelled'], ['awaiting_manager', 'pending'], ['refused', 'rejected']]);
it('picks the exit into a non-draft, non-cancel, non-reject state, whatever its key or position', () => {
const got = pickSubmitTransition({ available_transitions: [tr('withdrawn'), tr('refused'), tr('awaiting_manager')] }, d);
expect(got?.to).toBe('awaiting_manager');
});
it('returns the transition even when it is not allowed, so the refusal can be shown', () => {
expect(pickSubmitTransition({ available_transitions: [tr('awaiting_manager', false)] }, d)?.allowed).toBe(false);
});
it('returns undefined when the only exits cancel or reject, or the definition is not loaded', () => {
expect(pickSubmitTransition({ available_transitions: [tr('withdrawn')] }, d)).toBeUndefined();
expect(pickSubmitTransition({ available_transitions: [tr('withdrawn'), tr('refused')] }, d)).toBeUndefined();
expect(pickSubmitTransition({ available_transitions: [tr('awaiting_manager')] }, undefined)).toBeUndefined();
});
it('accepts a custom-semantic step as a submit target (a tenant-added first step)', () => {
const custom = def([['draft', 'draft'], ['triage', 'custom']]);
expect(pickSubmitTransition({ available_transitions: [tr('triage')] }, custom)?.to).toBe('triage');
});
});
web/src/features/claims/wizard/__tests__/lineRefusal.test.ts
import { describe, expect, it } from 'vitest';
import { parseLineRefusal } from '../lineRefusal';
describe('parseLineRefusal (Ruling R43 detail format)', () => {
it('reads several problems, with and without a line id', () => {
expect(
parseLineRefusal('line=3f1c field=amount rule=positive: an amount is greater than zero; field=lines rule=required: a claim needs at least one line'),
).toEqual([
{ line: '3f1c', field: 'amount', rule: 'positive', message: 'an amount is greater than zero' },
{ line: undefined, field: 'lines', rule: 'required', message: 'a claim needs at least one line' },
]);
});
it('returns nothing for a detail in another format', () => {
expect(parseLineRefusal('The claim is no longer in the state sent as from')).toEqual([]);
});
});
  • Step 2: Write the failing component test
web/src/features/claims/wizard/__tests__/ClaimWizard.test.tsx
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import type { UserEvent } from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/msw/server';
import { renderWithApp } from '@/test/renderWithApp';
import type { Schemas } from '@/lib/api/types';
import { categories, claimDefinition, groups, makeClaim, ME_ID, summary } from '../../__tests__/fixtures';
const DOC_ID = '00000000-0000-4000-8000-0000000d0c77';
vi.mock('@/components/upload', async (orig) => ({
...(await orig<typeof import('@/components/upload')>()),
DocumentUploader: (p: { onUploaded(d: { id: string; filename: string; size: number; content_type: string }): void }) => (
<button type="button" onClick={() => p.onUploaded({ id: DOC_ID, filename: 'grab.pdf', size: 96256, content_type: 'application/pdf' })}>
Fake upload
</button>
),
}));
const CLAIM_ID = '00000000-0000-4000-8000-0000000c1a11';
const LINE_ID = '00000000-0000-4000-8000-00000000113e';
const BANK = '00000000-0000-4000-8000-00000000ba01';
// A tenant-edited graph: the submit edge is NOT called "submit", and a withdraw exit is listed first.
const tenantDef: Schemas['WorkflowDefinition'] = {
...claimDefinition,
graph: {
...claimDefinition.graph,
states: [
...claimDefinition.graph.states,
{ key: 'awaiting_manager', semantic: 'pending', display_order: 10, is_terminal: false, labels: { en: 'Awaiting manager' } },
{ key: 'withdrawn', semantic: 'cancelled', display_order: 11, is_terminal: true, labels: { en: 'Withdrawn' } },
],
},
};
const exits: Schemas['WorkflowAvailableTransition'][] = [
{ to: 'withdrawn', action_key: 'withdraw', gate: 'reporter', requires_reason: true, allowed: true, labels: { en: 'Withdraw' } },
{ to: 'awaiting_manager', action_key: 'send_for_review', gate: 'reporter', requires_reason: false, allowed: true, labels: { en: 'Send for review' } },
];
type Call = { method: string; path: string; body: unknown; ifMatch: string | null; idem: string | null };
function viewAt(revision: number, over: Partial<Schemas['ExpenseClaimView']> = {}, claim: Partial<Schemas['ExpenseClaim']> = {}): Schemas['ExpenseClaimView'] {
return {
claim: makeClaim({ id: CLAIM_ID, title: 'Grab to client', state: 'draft', semantic: 'draft', revision, submitted_at: null, total: '0.0000', ...claim }),
lines: [], available_transitions: exits, decisions: [], settlements: [], ...over,
};
}
function lineAt(documents: string[]): Schemas['ExpenseClaimLine'] {
return {
id: LINE_ID, line_no: 1, type: 'receipt', expense_date: '2026-09-03', description: 'Ride to client meeting', category_id: categories[1].id,
currency: 'MYR', amount: '30.0000', fx_rate: '1', fx_rate_source: 'base', base_amount: '30.0000', tax_code_id: null, tax_amount: '0.0000',
quantity: null, rate_id: null, band: null, supplier_name: 'Grab', supplier_tin: null, einvoice_uuid: null, einvoice_validated_at: null,
document_basis: 'relaxation_receipt', relaxation_reason: 'Ride-hailing receipt', shares: [], withdrawn: false, documents,
possible_duplicate: false, duplicate_claim_references: [],
};
}
let calls: Call[];
function record(method: string, request: Request, body: unknown) {
calls.push({ method, path: new URL(request.url).pathname, body, ifMatch: request.headers.get('If-Match'), idem: request.headers.get('Idempotency-Key') });
}
function mockApi(opts: { existing?: Schemas['ExpenseClaimView']; submitRefusal?: Schemas['Problem'] } = {}) {
let rev = opts.existing?.claim.revision ?? 0;
let lines: Schemas['ExpenseClaimLine'][] = opts.existing?.lines ?? [];
let claim: Partial<Schemas['ExpenseClaim']> = opts.existing?.claim ?? {};
const current = () => viewAt(rev, { lines }, { ...claim, total: lines.length ? '30.0000' : '0.0000' });
server.use(
http.get('*/v1/workflow/definitions/expense_claim', () => HttpResponse.json(tenantDef)),
http.get('*/v1/expenses/categories', () => HttpResponse.json({ categories })),
http.get('*/v1/expenses/groups', () => HttpResponse.json({ groups })),
http.get('*/v1/expenses/claims/summary', () => HttpResponse.json(summary)),
http.get('*/v1/expenses/claims', () => HttpResponse.json({ claims: [], next_cursor: null })),
http.get('*/v1/payout/me/channels', () =>
HttpResponse.json({ channels: [{ id: BANK, key: 'bank', label: 'Bank transfer', settlement_behaviour: 'external', field_schema: [{ key: 'account_no', type: 'account_no', required: true, sensitivity: 'masked', label: { en: 'Account', ms: 'Akaun' } }], allowed_currencies: ['MYR'], allowed_claim_types: [], max_amount: null }] }),
),
http.get('*/v1/payout/me/profiles', () =>
HttpResponse.json({ profiles: [{ id: 'p1', channel_id: BANK, channel_key: 'bank', fields: [{ key: 'account_no', type: 'account_no', label: { en: 'Account', ms: 'Akaun' }, sensitivity: 'masked', value: null, display: '•••• 4821' }], valid: true, problems: [], updated_at: '2026-08-01T00:00:00Z' }] }),
),
http.get('*/v1/documents/:id', ({ params }) =>
HttpResponse.json({ id: params.id, uploaded_by: ME_ID, filename: 'grab.pdf', content_type: 'application/pdf', byte_size: 96256, sha256: 'b'.repeat(64), status: 'verified', reject_reason: null, created_at: '2026-09-03T00:00:00Z', verified_at: '2026-09-03T00:00:00Z', download_url: 'x', thumbnail_url: null, ocr_suggestions: [] }),
),
http.get('*/v1/expenses/claims/:id', () => HttpResponse.json(current())),
http.post('*/v1/expenses/claims', async ({ request }) => {
const body = (await request.json()) as { title: string };
record('POST', request, body);
rev = 1;
claim = { title: body.title };
return HttpResponse.json(current(), { status: 201, headers: { ETag: '"1"' } });
}),
http.patch('*/v1/expenses/claims/:id', async ({ request }) => {
const body = (await request.json()) as Partial<Schemas['ExpenseClaim']>;
record('PATCH', request, body);
rev += 1;
claim = { ...claim, ...body };
return HttpResponse.json(current());
}),
http.post('*/v1/expenses/claims/:id/lines', async ({ request }) => {
record('POST', request, await request.json());
rev += 1;
lines = [lineAt([])];
return HttpResponse.json(current(), { status: 201 });
}),
http.patch('*/v1/expenses/claims/:id/lines/:lineId', async ({ request }) => {
record('PATCH', request, await request.json());
rev += 1;
return HttpResponse.json(current());
}),
http.post('*/v1/expenses/claims/:id/lines/:lineId/documents', async ({ request }) => {
const body = (await request.json()) as { document_id: string };
record('POST', request, body);
rev += 1;
lines = [lineAt([body.document_id])];
return HttpResponse.json(current());
}),
http.post('*/v1/expenses/claims/:id/transitions', async ({ request }) => {
record('POST', request, await request.json());
if (opts.submitRefusal) return HttpResponse.json(opts.submitRefusal, { status: opts.submitRefusal.status });
return HttpResponse.json(viewAt(rev + 1, { lines, available_transitions: [] }, { ...claim, state: 'awaiting_manager', semantic: 'pending' }));
}),
);
}
const PERMS = ['expenses.claim.read', 'expenses.claim.create', 'expenses.claim.transition', 'documents.document.upload'];
async function pick(user: UserEvent, label: string, option: string) {
await user.click(screen.getByRole('combobox', { name: label }));
await user.click(await screen.findByRole('option', { name: option }));
}
async function fillStep1(user: UserEvent) {
await pick(user, 'Project', 'Project Alpha');
await pick(user, 'Expense category', 'Transport');
await user.type(screen.getByRole('textbox', { name: /Title/ }), 'Grab to client');
await user.type(screen.getByRole('textbox', { name: 'Purpose' }), 'Ride to client meeting');
}
async function fillStep2(user: UserEvent) {
await user.click(screen.getByRole('button', { name: 'Fake upload' }));
await user.clear(screen.getByLabelText(/Total amount/));
await user.type(screen.getByLabelText(/Total amount/), '30.00');
await pick(user, 'Receipt evidence', 'Receipt without e-Invoice');
await user.type(screen.getByRole('textbox', { name: 'Why there is no e-Invoice' }), 'Ride-hailing receipt');
}
beforeEach(() => {
calls = [];
});
describe('ClaimWizard', () => {
it('gates step 1 on its required fields, then creates the claim on Next', async () => {
mockApi();
const { user } = await renderWithApp({ path: '/claims/new', permissions: PERMS });
const dialog = await screen.findByRole('dialog', { name: 'New claim' });
await user.click(within(dialog).getByRole('button', { name: 'Next' }));
expect(await within(dialog).findByText('Enter a title')).toBeInTheDocument();
expect(within(dialog).getByText('Choose a category')).toBeInTheDocument();
expect(calls).toHaveLength(0);
await fillStep1(user);
await user.click(within(dialog).getByRole('button', { name: 'Next' }));
await waitFor(() => expect(calls[0]).toMatchObject({ method: 'POST', path: '/v1/expenses/claims', body: { title: 'Grab to client', group_id: groups[0].id } }));
expect(await within(dialog).findByRole('heading', { name: 'Amount & receipts' })).toBeInTheDocument();
});
it('writes the line with the amount string unchanged and threads the revision through each write', async () => {
mockApi();
const { user } = await renderWithApp({ path: '/claims/new', permissions: PERMS });
await screen.findByRole('dialog', { name: 'New claim' });
await fillStep1(user);
await user.click(screen.getByRole('button', { name: 'Next' }));
await screen.findByRole('heading', { name: 'Amount & receipts' });
await fillStep2(user);
await user.click(screen.getByRole('button', { name: 'Next' }));
await waitFor(() => expect(calls.map((c) => `${c.method} ${c.path}`)).toEqual([
'POST /v1/expenses/claims',
`POST /v1/expenses/claims/${CLAIM_ID}/lines`,
`POST /v1/expenses/claims/${CLAIM_ID}/lines/${LINE_ID}/documents`,
]));
expect(calls[1].ifMatch).toBe('"1"');
expect(calls[1].body).toMatchObject({ type: 'receipt', amount: '30.00', currency: 'MYR', category_id: categories[1].id, document_basis: 'relaxation_receipt', relaxation_reason: 'Ride-hailing receipt' });
expect(typeof (calls[1].body as { amount: unknown }).amount).toBe('string');
expect(calls[2]).toMatchObject({ ifMatch: '"2"', body: { document_id: DOC_ID } });
expect(await screen.findByRole('heading', { name: 'Confirm & submit' })).toBeInTheDocument();
});
it('submits through the draft-leaving transition from available_transitions, not a hard-coded key', async () => {
mockApi();
const { user, router } = await renderWithApp({ path: '/claims/new', permissions: PERMS });
await screen.findByRole('dialog', { name: 'New claim' });
await fillStep1(user);
await user.click(screen.getByRole('button', { name: 'Next' }));
await screen.findByRole('heading', { name: 'Amount & receipts' });
await fillStep2(user);
await user.click(screen.getByRole('button', { name: 'Next' }));
await screen.findByRole('heading', { name: 'Confirm & submit' });
expect(screen.getByText('RM 30.00')).toBeInTheDocument(); // claim.total from the server
await user.click(screen.getByRole('radio', { name: /Bank transfer/ }));
await user.click(screen.getByRole('button', { name: 'Submit claim' }));
await waitFor(() => expect(calls.at(-1)).toMatchObject({ method: 'POST', path: `/v1/expenses/claims/${CLAIM_ID}/transitions`, body: { from: 'draft', to: 'awaiting_manager' } }));
expect(calls.at(-1)!.idem).toMatch(/^[0-9a-f-]{36}$/);
const patch = calls.find((c) => c.method === 'PATCH' && c.path === `/v1/expenses/claims/${CLAIM_ID}`);
expect(patch?.body).toEqual({ payout_channel_id: BANK });
await waitFor(() => expect(router.state.location.pathname).toBe(`/claims/${CLAIM_ID}`));
});
it('sends a line refusal back to the step and field it names', async () => {
mockApi({
submitRefusal: { type: 'about:blank/validation', title: 'Validation failed', status: 422, detail: `line=${LINE_ID} field=amount rule=positive: an amount is greater than zero` },
});
const { user } = await renderWithApp({ path: '/claims/new', permissions: PERMS });
await screen.findByRole('dialog', { name: 'New claim' });
await fillStep1(user);
await user.click(screen.getByRole('button', { name: 'Next' }));
await screen.findByRole('heading', { name: 'Amount & receipts' });
await fillStep2(user);
await user.click(screen.getByRole('button', { name: 'Next' }));
await screen.findByRole('heading', { name: 'Confirm & submit' });
await user.click(screen.getByRole('radio', { name: /Bank transfer/ }));
await user.click(screen.getByRole('button', { name: 'Submit claim' }));
expect(await screen.findByRole('heading', { name: 'Amount & receipts' })).toBeInTheDocument();
expect(screen.getByText('an amount is greater than zero')).toBeInTheDocument();
});
it('reopens a draft at step 1 with its values, and PATCHes only what changed', async () => {
mockApi({ existing: viewAt(4, { lines: [lineAt([DOC_ID])] }, { title: 'Grab to client', group_id: null }) });
const { user } = await renderWithApp({ path: `/claims/${CLAIM_ID}/edit`, permissions: PERMS });
const dialog = await screen.findByRole('dialog', { name: 'Edit draft' });
expect(within(dialog).getByRole('textbox', { name: /Title/ })).toHaveValue('Grab to client');
expect(within(dialog).getByRole('textbox', { name: 'Purpose' })).toHaveValue('Ride to client meeting');
await user.clear(within(dialog).getByRole('textbox', { name: /Title/ }));
await user.type(within(dialog).getByRole('textbox', { name: /Title/ }), 'Grab to KLCC');
await user.click(within(dialog).getByRole('button', { name: 'Next' }));
await waitFor(() => expect(calls[0]).toMatchObject({ method: 'PATCH', path: `/v1/expenses/claims/${CLAIM_ID}`, ifMatch: '"4"', body: { title: 'Grab to KLCC' } }));
});
it('refuses to edit a claim that has left draft (presence of the form first)', async () => {
mockApi({ existing: viewAt(4, { lines: [lineAt([DOC_ID])] }) });
const first = await renderWithApp({ path: `/claims/${CLAIM_ID}/edit`, permissions: PERMS });
expect(await screen.findByRole('textbox', { name: /Title/ })).toBeInTheDocument();
first.unmount();
mockApi({ existing: viewAt(5, { lines: [lineAt([DOC_ID])] }, { state: 'pending_review', semantic: 'pending' }) });
await renderWithApp({ path: `/claims/${CLAIM_ID}/edit`, permissions: PERMS });
expect(await screen.findByText('Only your own drafts can be edited.')).toBeInTheDocument();
expect(screen.queryByRole('textbox', { name: /Title/ })).not.toBeInTheDocument();
});
});
  • Step 3: Run them and watch them fail

Run: pnpm -C web test -- src/features/claims/wizard Expected: FAIL — modules ../pickSubmitTransition and ../lineRefusal do not exist, and /claims/new matches no route.

  • Step 4: Pure helpers
web/src/features/claims/wizard/pickSubmitTransition.ts
import type { AvailableTransition, WorkflowDefinition } from '@/lib/api/types';
const NOT_SUBMIT = new Set(['draft', 'cancelled', 'rejected']);
/**
* The submit action is whichever exit of the draft leaves for a state that is not itself a
* draft, a cancellation or a rejection (spec §5.2). It is chosen by the target's SEMANTIC,
* never by an action or state key, so a tenant who renames the edge or adds a first step
* keeps a working Submit button. It may come back not allowed; the caller shows its refusal.
*/
export function pickSubmitTransition(
view: { available_transitions: AvailableTransition[] },
definition: WorkflowDefinition | undefined,
): AvailableTransition | undefined {
if (!definition) return undefined;
const semantic = new Map(definition.graph.states.map((s) => [s.key, s.semantic]));
return view.available_transitions.find((t) => {
const s = semantic.get(t.to);
return s !== undefined && !NOT_SUBMIT.has(s);
});
}
web/src/features/claims/wizard/lineRefusal.ts
import type { FieldPath, UseFormReturn } from 'react-hook-form';
import { ProblemError } from '@/lib/api/problem';
import type { WizardValues } from './wizardSchema';
import { STEP_FIELDS, type Step } from './wizardSchema';
export interface LineProblem { line?: string; field: string; rule: string; message: string }
/** Ruling R43: `[line=<id> ]field=<f> rule=<r>: <sentence>`, several joined by "; ". */
export function parseLineRefusal(detail: string | undefined): LineProblem[] {
if (!detail) return [];
const out: LineProblem[] = [];
for (const part of detail.split('; ')) {
const m = /^(?:line=(\S+) )?field=(\S+) rule=(\S+): (.+)$/.exec(part.trim());
if (m) out.push({ line: m[1], field: m[2], rule: m[3], message: m[4] });
}
return out;
}
const FIELD: Record<string, FieldPath<WizardValues>> = {
title: 'title',
group_id: 'group_id',
description: 'description',
category_id: 'category_id',
expense_date: 'expense_date',
amount: 'amount',
currency: 'currency',
document_basis: 'document_basis',
relaxation_reason: 'relaxation_reason',
einvoice_uuid: 'einvoice_uuid',
supplier_name: 'supplier_name',
documents: 'document_ids',
payout_channel_id: 'payout_channel_id',
};
/** Puts each named problem on its field. Returns the earliest step that holds one, if any. */
export function applyLineRefusal(form: UseFormReturn<WizardValues>, e: unknown): Step | undefined {
if (!(e instanceof ProblemError) || e.status !== 422) return undefined;
let step: Step | undefined;
for (const p of parseLineRefusal(e.detail)) {
const name = FIELD[p.field];
if (!name) continue;
form.setError(name, { type: 'server', message: p.message });
const s = ([1, 2, 3] as const).find((n) => (STEP_FIELDS[n] as readonly string[]).includes(name));
if (s && (step === undefined || s < step)) step = s;
}
return step;
}
// web/src/features/claims/wizard/wizardSchema.ts — shape only (P8); the server owns the rules.
import { z } from 'zod';
import { isMoneyInput } from '@/lib/money';
export const DOCUMENT_BASES = ['einvoice_company', 'einvoice_employee', 'foreign_supplier', 'relaxation_receipt'] as const;
export const wizardSchema = z.object({
group_id: z.string().uuid().nullable(),
category_id: z.string({ required_error: 'category' }).uuid('category'),
title: z.string().trim().min(1, 'title').max(200, 'titleTooLong'),
description: z.string().trim().min(1, 'description').max(500, 'descriptionTooLong'),
document_ids: z.array(z.string().uuid()).min(1, 'documents'),
expense_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date'),
amount: z.string().refine((s) => isMoneyInput(s) && !/^0*(\.0*)?$/.test(s), 'amount'),
currency: z.string().regex(/^[A-Z]{3}$/, 'currency'),
document_basis: z.enum(DOCUMENT_BASES, { errorMap: () => ({ message: 'basis' }) }),
supplier_name: z.string().max(200),
einvoice_uuid: z.string().max(100),
relaxation_reason: z.string().max(500),
payout_channel_id: z.string().uuid().nullable(),
});
export type WizardValues = z.infer<typeof wizardSchema>;
export type Step = 1 | 2 | 3;
export const STEP_FIELDS = {
1: ['group_id', 'category_id', 'title', 'description'],
2: ['document_ids', 'expense_date', 'amount', 'currency', 'document_basis', 'supplier_name', 'einvoice_uuid', 'relaxation_reason'],
3: ['payout_channel_id'],
} as const satisfies Record<Step, readonly (keyof WizardValues)[]>;

The zod messages are i18n key suffixes under claimWizard:errors.*; Field shows t(\errors.${error.message}`, { defaultValue: error.message }), so a server message (which is a sentence, not a key) shows as sent. The amount refinement is a shape check (a positive decimal string), done by regex on the string — no number is formed. Verify the zod major version (grep ‘“zod”’ web/package.json): with zod 4 replace required_error/errorMapwith{ error: ‘category’ }/{ error: ‘basis’ }`.

  • Step 5: Draft writes, payout options and document metadata
web/src/features/claims/wizard/useClaimDraft.ts
import { useCallback, useRef, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { api, newIdempotencyKey } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
import type { AvailableTransition, ExpenseClaimView, Schemas } from '@/lib/api/types';
import { claimKeys } from '../api';
export type LineInput = Schemas['ExpenseClaimLineInput'];
const etag = (v: ExpenseClaimView) => `"${v.claim.revision}"`;
/**
* Every write answers with the whole claim, and each moves the revision, so the NEXT write
* must send the revision of the LAST answer (Ruling R29). The ref holds that answer.
*/
export function useClaimDraft(initial?: ExpenseClaimView) {
const qc = useQueryClient();
const [view, setView] = useState<ExpenseClaimView | undefined>(initial);
const ref = useRef<ExpenseClaimView | undefined>(initial);
const accept = useCallback(
(v: ExpenseClaimView) => {
ref.current = v;
setView(v);
qc.setQueryData(claimKeys.detail(v.claim.id), v);
return v;
},
[qc],
);
async function saveHeader(h: { title: string; group_id: string | null; payout_channel_id?: string | null }) {
const cur = ref.current;
if (!cur) {
return accept(unwrap(await api.POST('/expenses/claims', { body: { title: h.title, group_id: h.group_id } })));
}
const patch: Schemas['ExpenseClaimPatch'] = {};
if (h.title !== cur.claim.title) patch.title = h.title;
if (h.group_id !== cur.claim.group_id) patch.group_id = h.group_id;
if (h.payout_channel_id !== undefined && h.payout_channel_id !== cur.claim.payout_channel_id) patch.payout_channel_id = h.payout_channel_id;
if (Object.keys(patch).length === 0) return cur; // an empty PATCH is a 422 ("changes nothing")
return accept(
unwrap(await api.PATCH('/expenses/claims/{id}', { params: { path: { id: cur.claim.id }, header: { 'If-Match': etag(cur) } }, body: patch })),
);
}
async function saveLine(lineId: string | null, input: LineInput): Promise<{ view: ExpenseClaimView; lineId: string }> {
const cur = ref.current!;
if (lineId) {
const v = accept(
unwrap(await api.PATCH('/expenses/claims/{id}/lines/{lineId}', { params: { path: { id: cur.claim.id, lineId }, header: { 'If-Match': etag(cur) } }, body: input })),
);
return { view: v, lineId };
}
const before = new Set(cur.lines.map((l) => l.id));
const v = accept(
unwrap(await api.POST('/expenses/claims/{id}/lines', { params: { path: { id: cur.claim.id }, header: { 'If-Match': etag(cur) } }, body: input })),
);
const added = v.lines.find((l) => !before.has(l.id));
if (!added) throw new Error('The server did not return the new line');
return { view: v, lineId: added.id };
}
/** Makes the line hold exactly `documentIds`: attach the new, detach the removed, one write each. */
async function syncDocuments(lineId: string, documentIds: string[]) {
let cur = ref.current!;
const held = cur.lines.find((l) => l.id === lineId)?.documents ?? [];
for (const documentId of documentIds.filter((d) => !held.includes(d))) {
cur = accept(
unwrap(
await api.POST('/expenses/claims/{id}/lines/{lineId}/documents', {
params: { path: { id: cur.claim.id, lineId }, header: { 'If-Match': etag(cur) } },
body: { document_id: documentId },
}),
),
);
}
for (const documentId of held.filter((d) => !documentIds.includes(d))) {
cur = accept(
unwrap(
await api.DELETE('/expenses/claims/{id}/lines/{lineId}/documents/{documentId}', {
params: { path: { id: cur.claim.id, lineId, documentId }, header: { 'If-Match': etag(cur) } },
}),
),
);
}
return cur;
}
async function withdrawLine(lineId: string) {
const cur = ref.current!;
return accept(
unwrap(await api.DELETE('/expenses/claims/{id}/lines/{lineId}', { params: { path: { id: cur.claim.id, lineId }, header: { 'If-Match': etag(cur) } } })),
);
}
async function submit(t: AvailableTransition) {
const cur = ref.current!;
return accept(
unwrap(
await api.POST('/expenses/claims/{id}/transitions', {
params: { path: { id: cur.claim.id }, header: { 'Idempotency-Key': newIdempotencyKey() } },
body: { from: cur.claim.state, to: t.to },
}),
),
);
}
function invalidateLists() {
void qc.invalidateQueries({ queryKey: ['claims', 'list'] });
void qc.invalidateQueries({ queryKey: ['claims', 'summary'] });
}
return { view, current: () => ref.current, saveHeader, saveLine, syncDocuments, withdrawLine, submit, invalidateLists };
}
web/src/features/claims/wizard/usePayoutOptions.ts
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { api } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
export interface PayoutOption { value: string; label: string; description?: string; disabled?: boolean }
/** Keys fixed by R6 so Settings (Task 14) invalidates the same cache. */
export function usePayoutOptions(): { options: PayoutOption[]; loading: boolean } {
const { t } = useTranslation('claimWizard');
const channels = useQuery({ queryKey: ['payout', 'me', 'channels'], queryFn: async ({ signal }) => unwrap(await api.GET('/payout/me/channels', { signal })) });
const profiles = useQuery({ queryKey: ['payout', 'me', 'profiles'], queryFn: async ({ signal }) => unwrap(await api.GET('/payout/me/profiles', { signal })) });
const options = (channels.data?.channels ?? []).map((c) => {
const profile = profiles.data?.profiles.find((p) => p.channel_id === c.id);
const needsDetails = c.field_schema.some((f) => f.required);
const usable = !needsDetails || profile?.valid === true;
const masked = profile?.fields.find((f) => f.display)?.display;
return {
value: c.id,
label: c.label,
description: usable ? masked : profile && !profile.valid ? t('payout.invalid') : t('payout.addDetails'),
disabled: !usable,
};
});
return { options, loading: channels.isLoading || profiles.isLoading };
}
web/src/features/claims/wizard/useDocumentsMeta.ts
import { useQueries } from '@tanstack/react-query';
import type { UploadedDoc } from '@/components/upload';
import { api } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
/** A draft reopened for editing knows its documents by id only; the uploader shows names and sizes. */
export function useDocumentsMeta(ids: string[]): UploadedDoc[] | undefined {
const results = useQueries({
queries: ids.map((id) => ({
queryKey: ['documents', id],
staleTime: 60_000,
queryFn: async ({ signal }: { signal: AbortSignal }) => unwrap(await api.GET('/documents/{id}', { params: { path: { id } }, signal })),
})),
});
if (results.some((r) => r.isLoading)) return undefined;
return results.flatMap((r) => (r.data ? [{ id: r.data.id, filename: r.data.filename, size: r.data.byte_size, content_type: r.data.content_type }] : []));
}
  • Step 6: Stepper and the three steps
web/src/features/claims/wizard/Stepper.tsx
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/cn';
import type { Step } from './wizardSchema';
export function Stepper({ step }: { step: Step }) {
const { t } = useTranslation('claimWizard');
const labels = [t('steps.info'), t('steps.amount'), t('steps.confirm')];
return (
<ol className="flex items-center gap-2 text-sm">
{labels.map((label, i) => {
const n = (i + 1) as Step;
const reached = n <= step;
return (
<li key={label} className="flex flex-1 items-center gap-2" aria-current={n === step ? 'step' : undefined}>
<span className={cn('flex size-6 items-center justify-center rounded-full border text-xs', reached ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground')}>
{n}
</span>
<span className={cn('whitespace-nowrap', reached ? 'text-primary' : 'text-muted-foreground')}>{label}</span>
{n < 3 && <span className="h-px flex-1 bg-border" aria-hidden />}
</li>
);
})}
</ol>
);
}
web/src/features/claims/wizard/StepInfo.tsx
import { Controller, type UseFormReturn } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Field } from '@/components/ui/Field';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Textarea } from '@/components/ui/Textarea';
import { pickLabel, useLocale } from '@/lib/i18n';
import { useCategories, useClaimGroups } from '../api';
import { RecordLanguageHint } from '../RecordLanguageHint';
import type { WizardValues } from './wizardSchema';
export function StepInfo({ form }: { form: UseFormReturn<WizardValues> }) {
const { t } = useTranslation('claimWizard');
const [locale] = useLocale();
const groups = useClaimGroups(true);
const categories = useCategories();
const err = (k: keyof WizardValues) => {
const m = form.formState.errors[k]?.message;
return m ? t(`errors.${m}`, { defaultValue: m }) : undefined;
};
return (
<div className="grid gap-4 md:grid-cols-2">
<Field label={t('fields.group')} htmlFor="wz-group" error={err('group_id')}>
<Controller
control={form.control}
name="group_id"
render={({ field }) => (
<Select
id="wz-group"
aria-label={t('fields.group')}
placeholder={t('placeholders.group')}
value={field.value ?? ''}
onChange={(v: string) => field.onChange(v || null)}
options={[{ value: '', label: t('placeholders.noGroup') }, ...(groups.data ?? []).map((g) => ({ value: g.id, label: g.name }))]}
/>
)}
/>
</Field>
<div className="hidden md:block" />
<Field label={t('fields.lineType')} htmlFor="wz-type">
<Select id="wz-type" aria-label={t('fields.lineType')} value="receipt" onChange={() => undefined} options={[{ value: 'receipt', label: t('lineTypes.receipt') }]} />
</Field>
<Field label={t('fields.category')} htmlFor="wz-category" error={err('category_id')} required>
<Controller
control={form.control}
name="category_id"
render={({ field }) => (
<Select
id="wz-category"
aria-label={t('fields.category')}
placeholder={t('placeholders.category')}
value={field.value ?? ''}
onChange={field.onChange}
options={(categories.data ?? []).filter((c) => c.active).map((c) => ({ value: c.id, label: pickLabel(c.names, locale) || c.name }))}
/>
)}
/>
</Field>
<div className="md:col-span-2">
<Field label={t('fields.title')} hint={t('hints.title')} htmlFor="wz-title" error={err('title')} required>
<Input id="wz-title" placeholder={t('placeholders.title')} maxLength={200} {...form.register('title')} />
</Field>
<RecordLanguageHint />
</div>
<div className="md:col-span-2">
<Field label={t('fields.description')} htmlFor="wz-description" error={err('description')} required>
<Textarea id="wz-description" rows={3} maxLength={500} placeholder={t('placeholders.description')} {...form.register('description')} />
</Field>
</div>
</div>
);
}
web/src/features/claims/wizard/StepAmount.tsx
import { Controller, type UseFormReturn } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Trash2 } from 'lucide-react';
import { DatePicker } from '@/components/ui/DatePicker';
import { Field } from '@/components/ui/Field';
import { IconButton } from '@/components/ui/IconButton';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Textarea } from '@/components/ui/Textarea';
import { MoneyInput, MoneyText } from '@/components/money';
import { DocumentUploader, type UploadedDoc } from '@/components/upload';
import type { ExpenseClaimView } from '@/lib/api/types';
import { DOCUMENT_BASES, type WizardValues } from './wizardSchema';
export function StepAmount(p: {
form: UseFormReturn<WizardValues>;
docs: UploadedDoc[];
onDocsChange(d: UploadedDoc[]): void;
otherLines: ExpenseClaimView['lines'];
onWithdrawLine(id: string): void;
}) {
const { t } = useTranslation('claimWizard');
const { form } = p;
const basis = form.watch('document_basis');
const err = (k: keyof WizardValues) => {
const m = form.formState.errors[k]?.message;
return m ? t(`errors.${m}`, { defaultValue: m }) : undefined;
};
const setDocs = (d: UploadedDoc[]) => {
p.onDocsChange(d);
form.setValue('document_ids', d.map((x) => x.id), { shouldValidate: form.formState.isSubmitted });
};
return (
<div className="grid gap-4 md:grid-cols-2">
{p.otherLines.length > 0 && (
<ul className="md:col-span-2 flex flex-col gap-1 rounded-lg border border-border p-2 text-sm" aria-label={t('otherLines')}>
{p.otherLines.map((l) => (
<li key={l.id} className="flex items-center justify-between gap-2">
<span>{l.line_no}. {l.description}</span>
<span className="flex items-center gap-2">
<MoneyText amount={l.amount} currency={l.currency} />
<IconButton aria-label={t('withdrawLine', { n: l.line_no })} onClick={() => p.onWithdrawLine(l.id)}>
<Trash2 className="size-4" />
</IconButton>
</span>
</li>
))}
</ul>
)}
<div className="md:col-span-2">
<Field label={t('fields.receipts')} hint={t('hints.receipts')} error={err('document_ids')} required>
<DocumentUploader
multiple
value={p.docs}
onUploaded={(d) => setDocs([...p.docs, d])}
onRemoved={(id) => setDocs(p.docs.filter((x) => x.id !== id))}
/>
</Field>
</div>
<Field label={t('fields.date')} hint={t('hints.date')} htmlFor="wz-date" error={err('expense_date')} required>
<Controller control={form.control} name="expense_date" render={({ field }) => <DatePicker id="wz-date" value={field.value} onChange={(v?: string) => field.onChange(v ?? '')} />} />
</Field>
<div className="hidden md:block" />
<Field label={t('fields.amount')} hint={t('hints.amount')} htmlFor="wz-amount" error={err('amount')} required>
<Controller
control={form.control}
name="amount"
render={({ field }) => <MoneyInput id="wz-amount" value={field.value} onChange={field.onChange} currency={form.watch('currency')} invalid={Boolean(err('amount'))} />}
/>
</Field>
<Field label={t('fields.currency')} hint={t('hints.currency')} htmlFor="wz-currency" error={err('currency')} required>
<Input id="wz-currency" maxLength={3} className="uppercase" {...form.register('currency', { setValueAs: (v: string) => v.toUpperCase() })} />
</Field>
<Field label={t('fields.basis')} htmlFor="wz-basis" error={err('document_basis')} required>
<Controller
control={form.control}
name="document_basis"
render={({ field }) => (
<Select
id="wz-basis"
aria-label={t('fields.basis')}
placeholder={t('placeholders.basis')}
value={field.value ?? ''}
onChange={field.onChange}
options={DOCUMENT_BASES.map((b) => ({ value: b, label: t(`basis.${b}`) }))}
/>
)}
/>
</Field>
<Field label={t('fields.supplier')} htmlFor="wz-supplier" error={err('supplier_name')}>
<Input id="wz-supplier" maxLength={200} placeholder={t('placeholders.supplier')} {...form.register('supplier_name')} />
</Field>
{(basis === 'einvoice_company' || basis === 'einvoice_employee') && (
<Field label={t('fields.einvoice')} htmlFor="wz-einvoice" error={err('einvoice_uuid')}>
<Input id="wz-einvoice" {...form.register('einvoice_uuid')} />
</Field>
)}
{basis === 'relaxation_receipt' && (
<div className="md:col-span-2">
<Field label={t('fields.relaxation')} htmlFor="wz-relax" error={err('relaxation_reason')}>
<Textarea id="wz-relax" rows={2} maxLength={500} {...form.register('relaxation_reason')} />
</Field>
</div>
)}
</div>
);
}
web/src/features/claims/wizard/StepConfirm.tsx
import { Link } from '@tanstack/react-router';
import { Controller, type UseFormReturn } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Field } from '@/components/ui/Field';
import { RadioCards } from '@/components/ui/RadioCards';
import { MoneyText } from '@/components/money';
import type { ExpenseClaimView } from '@/lib/api/types';
import { formatDate } from '@/lib/date';
import { pickLabel, useLocale } from '@/lib/i18n';
import { useCategories, useClaimGroups } from '../api';
import { usePayoutOptions } from './usePayoutOptions';
import type { WizardValues } from './wizardSchema';
export function StepConfirm({ form, view }: { form: UseFormReturn<WizardValues>; view: ExpenseClaimView }) {
const { t } = useTranslation('claimWizard');
const [locale] = useLocale();
const { options } = usePayoutOptions();
const groups = useClaimGroups(false);
const categories = useCategories();
const v = form.watch();
const cat = categories.data?.find((c) => c.id === v.category_id);
const left: [string, React.ReactNode][] = [
[t('fields.group'), groups.data?.find((g) => g.id === v.group_id)?.name ?? '—'],
[t('fields.lineType'), t('lineTypes.receipt')],
[t('fields.category'), cat ? pickLabel(cat.names, locale) || cat.name : '—'],
[t('fields.title'), v.title || '—'],
[t('fields.description'), v.description || '—'],
];
const right: [string, React.ReactNode][] = [
[t('fields.receipts'), t('receiptsCount', { count: v.document_ids.length })],
[t('fields.date'), v.expense_date ? formatDate(v.expense_date, locale) : '—'],
[t('fields.amount'), v.amount ? <MoneyText amount={v.amount} currency={v.currency} /> : '—'],
[t('totalToReimburse'), <MoneyText key="total" className="font-bold" amount={view.claim.total} currency={view.claim.currency} />],
[t('fields.payout'), options.find((o) => o.value === v.payout_channel_id)?.label ?? '—'],
];
return (
<div className="flex flex-col gap-4">
<Field label={t('fields.payout')} error={form.formState.errors.payout_channel_id?.message}>
<Controller
control={form.control}
name="payout_channel_id"
render={({ field }) => <RadioCards value={field.value ?? ''} onChange={(x: string) => field.onChange(x || null)} options={options} />}
/>
</Field>
<div className="flex justify-end">
<Link to="/settings/payout" className="text-sm text-primary underline">{t('managePayout')}</Link>
</div>
<section aria-labelledby="wz-summary">
<h4 id="wz-summary" className="mb-2 font-bold">{t('summary')}</h4>
<div className="grid gap-x-6 rounded-xl border border-border p-4 text-sm md:grid-cols-2">
{[left, right].map((col, i) => (
<dl key={i} className="divide-y divide-border">
{col.map(([k, val]) => (
<div key={k} className="grid grid-cols-[7rem_1fr] py-2">
<dt className="text-muted-foreground">{k}</dt>
<dd>{val}</dd>
</div>
))}
</dl>
))}
</div>
</section>
</div>
);
}

The /settings/payout link targets Task 14’s route. Task 14 runs in parallel with 13 (C0); if it has not merged when Task 13 typechecks, the typed to fails. Merge Task 14 before Task 13, or — if 13 lands first — type the link as <a href="/settings/payout"> and switch it to <Link> in Task 14’s commit.

  • Step 7: The wizard and its routes
web/src/features/claims/wizard/ClaimWizard.tsx
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Dialog } from '@/components/ui/Dialog';
import { Skeleton } from '@/components/ui/Skeleton';
import { toast } from '@/components/ui/Toaster';
import { useWorkflowDefinition } from '@/components/workflow';
import type { UploadedDoc } from '@/components/upload';
import { useMe } from '@/features/auth';
import { applyProblemToForm, ProblemError, problemMessage } from '@/lib/api/problem';
import type { ExpenseClaimView } from '@/lib/api/types';
import { todayISO } from '@/lib/date';
import { useClaim } from '../api';
import { applyLineRefusal } from './lineRefusal';
import { pickSubmitTransition } from './pickSubmitTransition';
import { StepAmount } from './StepAmount';
import { StepConfirm } from './StepConfirm';
import { StepInfo } from './StepInfo';
import { Stepper } from './Stepper';
import { useClaimDraft, type LineInput } from './useClaimDraft';
import { useDocumentsMeta } from './useDocumentsMeta';
import { STEP_FIELDS, wizardSchema, type Step, type WizardValues } from './wizardSchema';
export interface ClaimWizardProps { claimId?: string; onClose(): void; onDone(claimId: string): void }
export function ClaimWizard(p: ClaimWizardProps) {
return p.claimId ? <EditWizard {...p} claimId={p.claimId} /> : <WizardForm onClose={p.onClose} onDone={p.onDone} />;
}
function EditWizard(p: ClaimWizardProps & { claimId: string }) {
const { t } = useTranslation('claimWizard');
const { t: tc } = useTranslation();
const q = useClaim(p.claimId);
const { data: me } = useMe();
const firstLine = q.data?.lines.find((l) => !l.withdrawn);
const docs = useDocumentsMeta(firstLine?.documents ?? []);
const shell = (body: React.ReactNode) => (
<Dialog open onOpenChange={(o: boolean) => !o && p.onClose()} title={t('editTitle')}>{body}</Dialog>
);
if (q.isLoading || (q.data && docs === undefined)) return shell(<Skeleton className="h-72 w-full" />);
if (q.error) return shell(<p>{problemMessage(q.error, tc)}</p>);
const view = q.data!;
if (view.claim.semantic !== 'draft' || view.claim.claimant_member_id !== me?.member_id) return shell(<p>{t('notEditable')}</p>);
return <WizardForm initial={view} initialDocs={docs ?? []} onClose={p.onClose} onDone={p.onDone} />;
}
function valuesFrom(view: ExpenseClaimView | undefined, base: string): WizardValues {
const l = view?.lines.find((x) => !x.withdrawn);
return {
group_id: view?.claim.group_id ?? null,
category_id: l?.category_id ?? '',
title: view?.claim.title ?? '',
description: l?.description ?? '',
document_ids: l?.documents ?? [],
expense_date: l?.expense_date ?? todayISO(),
amount: l?.amount ?? '',
currency: l?.currency ?? base,
document_basis: (l?.document_basis ?? undefined) as WizardValues['document_basis'],
supplier_name: l?.supplier_name ?? '',
einvoice_uuid: l?.einvoice_uuid ?? '',
relaxation_reason: l?.relaxation_reason ?? '',
payout_channel_id: view?.claim.payout_channel_id ?? null,
};
}
function toLine(v: WizardValues): LineInput {
return {
type: 'receipt',
expense_date: v.expense_date,
description: v.description.trim(),
category_id: v.category_id,
currency: v.currency,
amount: v.amount, // the string as typed (C5)
document_basis: v.document_basis,
supplier_name: v.supplier_name.trim() || null,
einvoice_uuid: v.document_basis.startsWith('einvoice') ? v.einvoice_uuid.trim() || null : null,
relaxation_reason: v.document_basis === 'relaxation_receipt' ? v.relaxation_reason.trim() || null : null,
};
}
function WizardForm(p: { initial?: ExpenseClaimView; initialDocs?: UploadedDoc[]; onClose(): void; onDone(id: string): void }) {
const { t } = useTranslation('claimWizard');
const { t: tc } = useTranslation();
const { data: me } = useMe();
const def = useWorkflowDefinition('expense_claim');
const draft = useClaimDraft(p.initial);
const base = me?.organization.base_currency ?? 'MYR';
const form = useForm<WizardValues>({ resolver: zodResolver(wizardSchema), defaultValues: valuesFrom(p.initial, base) });
const [step, setStep] = useState<Step>(1);
const [lineId, setLineId] = useState<string | null>(p.initial?.lines.find((l) => !l.withdrawn)?.id ?? null);
const [docs, setDocs] = useState<UploadedDoc[]>(p.initialDocs ?? []);
const [busy, setBusy] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
useEffect(() => {
if (!p.initial) form.setValue('currency', base);
}, [base, p.initial, form]);
const otherLines = useMemo(() => (draft.view?.lines ?? []).filter((l) => !l.withdrawn && l.id !== lineId), [draft.view, lineId]);
/** Everything up to and including `upTo` is written; each write returns the whole claim. */
async function persist(upTo: Step): Promise<ExpenseClaimView> {
const v = form.getValues();
let view = await draft.saveHeader({ title: v.title.trim(), group_id: v.group_id, ...(upTo >= 3 ? { payout_channel_id: v.payout_channel_id } : {}) });
if (upTo >= 2 || lineId) {
const saved = await draft.saveLine(lineId, toLine(v));
setLineId(saved.lineId);
view = await draft.syncDocuments(saved.lineId, v.document_ids);
}
return view;
}
async function guarded(fn: () => Promise<boolean>): Promise<boolean> {
setBusy(true);
setFormError(null);
try {
return await fn();
} catch (e) {
const at = applyLineRefusal(form, e);
if (at) setStep(at);
else if (!applyProblemToForm(form, e)) setFormError(problemMessage(e, tc));
if (e instanceof ProblemError && e.status === 409) setFormError(t('conflict'));
return false;
} finally {
setBusy(false);
}
}
const fieldsThrough = (s: Step) => ([1, 2, 3] as const).filter((n) => n <= s).flatMap((n) => [...STEP_FIELDS[n]]);
async function next() {
if (!(await form.trigger(fieldsThrough(step)))) return;
if (await guarded(async () => (await persist(step), true))) setStep((s) => (s < 3 ? ((s + 1) as Step) : s));
}
async function saveDraft() {
if (!(await form.trigger(fieldsThrough(step)))) return;
const ok = await guarded(async () => (await persist(step), true));
if (ok) {
draft.invalidateLists();
toast.success(t('draftSaved'));
p.onClose();
}
}
async function addAnotherLine() {
if (!(await form.trigger(fieldsThrough(2)))) return;
const ok = await guarded(async () => (await persist(2), true));
if (!ok) return;
setLineId(null);
setDocs([]);
form.reset({ ...form.getValues(), category_id: form.getValues('category_id'), description: '', document_ids: [], amount: '', supplier_name: '', einvoice_uuid: '', relaxation_reason: '' });
}
async function withdrawLine(id: string) {
await guarded(async () => (await draft.withdrawLine(id), true));
}
async function submit() {
if (!(await form.trigger())) return;
await guarded(async () => {
const view = await persist(3);
const tr = pickSubmitTransition(view, def.data);
if (!tr) {
setFormError(t('noSubmit'));
return false;
}
if (!tr.allowed) {
setFormError(tr.refusal?.detail ?? t('noSubmit'));
return false;
}
const after = await draft.submit(tr);
draft.invalidateLists();
toast.success(t('submitted', { reference: after.claim.reference }));
p.onDone(after.claim.id);
return true;
});
}
const subtitle = [t('subtitles.info'), t('subtitles.amount'), t('subtitles.confirm')][step - 1];
return (
<Dialog
open
onOpenChange={(o: boolean) => !o && p.onClose()}
title={p.initial ? t('editTitle') : t('title')}
description={subtitle}
footer={
<div className="flex w-full flex-wrap items-center gap-2">
<Button variant="outline" onClick={saveDraft} disabled={busy}>{t('saveDraft')}</Button>
<div className="ml-auto flex gap-2">
{step === 2 && <Button variant="ghost" onClick={addAnotherLine} disabled={busy}>{t('addLine')}</Button>}
{step > 1 && (
<Button variant="outline" onClick={() => setStep((s) => (s - 1) as Step)} disabled={busy}>
<ChevronLeft className="size-4" aria-hidden /> {t('back')}
</Button>
)}
{step < 3 ? (
<Button onClick={next} loading={busy}>
{t('next')} <ChevronRight className="size-4" aria-hidden />
</Button>
) : (
<Button onClick={submit} loading={busy}>{t('submit')}</Button>
)}
</div>
</div>
}
>
<Stepper step={step} />
<h3 className="mt-4 text-base font-medium">{[t('steps.info'), t('steps.amount'), t('steps.confirm')][step - 1]}</h3>
<form className="mt-4" onSubmit={(e) => e.preventDefault()} noValidate>
{step === 1 && <StepInfo form={form} />}
{step === 2 && <StepAmount form={form} docs={docs} onDocsChange={setDocs} otherLines={otherLines} onWithdrawLine={withdrawLine} />}
{step === 3 && draft.view && <StepConfirm form={form} view={draft.view} />}
</form>
{formError && <p role="alert" className="mt-3 text-sm text-destructive">{formError}</p>}
</Dialog>
);
}
web/src/features/claims/wizard/wizardRoutes.tsx
import { createRoute } from '@tanstack/react-router';
import { RequirePermission } from '@/features/auth';
import { claimsRoute } from '../claimsRoute';
import type { ClaimSearch } from '../search';
import { ClaimWizard } from './ClaimWizard';
export const claimNewRoute = createRoute({
getParentRoute: () => claimsRoute,
path: 'new',
component: function ClaimNewRoute() {
const navigate = claimNewRoute.useNavigate();
return (
<RequirePermission permission="expenses.claim.create">
<ClaimWizard
onClose={() => void navigate({ to: '/claims', search: (p: ClaimSearch) => p })}
onDone={(id) => void navigate({ to: '/claims/$id', params: { id }, search: (p: ClaimSearch) => p })}
/>
</RequirePermission>
);
},
});
export const claimEditRoute = createRoute({
getParentRoute: () => claimsRoute,
path: '$id/edit',
component: function ClaimEditRoute() {
const { id } = claimEditRoute.useParams();
const navigate = claimEditRoute.useNavigate();
return (
<RequirePermission permission="expenses.claim.create">
<ClaimWizard
claimId={id}
onClose={() => void navigate({ to: '/claims', search: (p: ClaimSearch) => p })}
onDone={(next) => void navigate({ to: '/claims/$id', params: { id: next }, search: (p: ClaimSearch) => p })}
/>
</RequirePermission>
);
},
});

web/src/features/claims/childRoutes.ts becomes:

import { claimDetailRoute } from './detail/detailRoute';
import { claimEditRoute, claimNewRoute } from './wizard/wizardRoutes';
export const claimChildRoutes = [claimDetailRoute, claimNewRoute, claimEditRoute] as const;

In claimsRoute.tsx add exactly these two props to <ClaimsListPage …>:

onNew={() => void navigate({ to: '/claims/new', search: (p: ClaimSearch) => p })}
onEdit={(id: string) => void navigate({ to: '/claims/$id/edit', params: { id }, search: (p: ClaimSearch) => p })}
  • Step 8: i18n bundles

en/claimWizard.json

{
"title": "New claim",
"editTitle": "Edit draft",
"subtitles": { "info": "Every field is required", "amount": "Enter the amount and upload the receipts", "confirm": "Check the payout method and the claim, then submit" },
"steps": { "info": "Claim details", "amount": "Amount & receipts", "confirm": "Confirm & submit" },
"fields": { "group": "Project", "lineType": "Claim type", "category": "Expense category", "title": "Title", "description": "Purpose", "receipts": "Receipts", "date": "Expense date", "amount": "Total amount", "currency": "Currency", "basis": "Receipt evidence", "supplier": "Supplier", "einvoice": "e-Invoice UUID", "relaxation": "Why there is no e-Invoice", "payout": "Payout method" },
"hints": { "title": "(the name shown in the claims list)", "receipts": "(JPG, PNG or PDF, up to 10 MB each)", "date": "(the date on the receipt)", "amount": "(the total of the receipts uploaded)", "currency": "(three letters, e.g. MYR)" },
"placeholders": { "group": "Choose a project", "noGroup": "No project", "category": "Choose a category", "title": "e.g. Client lunch", "description": "Briefly say what this expense was for", "basis": "Choose the evidence", "supplier": "e.g. Grab" },
"lineTypes": { "receipt": "Expense claim" },
"basis": { "einvoice_company": "e-Invoice to the company", "einvoice_employee": "e-Invoice in my name", "foreign_supplier": "Foreign supplier invoice", "relaxation_receipt": "Receipt without e-Invoice" },
"payout": { "addDetails": "Add your details first", "invalid": "Details need updating" },
"managePayout": "Manage payout details",
"summary": "Check the claim",
"totalToReimburse": "Total to reimburse",
"receiptsCount_one": "{{count}} file uploaded",
"receiptsCount_other": "{{count}} files uploaded",
"otherLines": "Other lines on this claim",
"withdrawLine": "Withdraw line {{n}}",
"saveDraft": "Save draft",
"addLine": "Save and add another line",
"back": "Back",
"next": "Next",
"submit": "Submit claim",
"draftSaved": "Draft saved",
"submitted": "Claim {{reference}} submitted",
"noSubmit": "This claim cannot be submitted right now.",
"notEditable": "Only your own drafts can be edited.",
"conflict": "This claim changed while you were editing. Close and reopen it to see the latest version.",
"errors": { "category": "Choose a category", "title": "Enter a title", "titleTooLong": "A title is at most 200 characters", "description": "Say what this expense was for", "descriptionTooLong": "At most 500 characters", "documents": "Upload at least one receipt", "date": "Choose the expense date", "amount": "Enter an amount greater than zero, e.g. 30.00", "currency": "Use a three-letter currency code", "basis": "Choose the receipt evidence" }
}

ms/claimWizard.json

{
"title": "Tuntutan baharu",
"editTitle": "Sunting draf",
"subtitles": { "info": "Semua medan wajib diisi", "amount": "Masukkan amaun dan muat naik resit", "confirm": "Semak kaedah pembayaran dan tuntutan, kemudian hantar" },
"steps": { "info": "Butiran tuntutan", "amount": "Amaun & resit", "confirm": "Sahkan & hantar" },
"fields": { "group": "Projek", "lineType": "Jenis tuntutan", "category": "Kategori perbelanjaan", "title": "Tajuk", "description": "Tujuan", "receipts": "Resit", "date": "Tarikh perbelanjaan", "amount": "Jumlah amaun", "currency": "Mata wang", "basis": "Bukti resit", "supplier": "Pembekal", "einvoice": "UUID e-Invois", "relaxation": "Sebab tiada e-Invois", "payout": "Kaedah pembayaran" },
"hints": { "title": "(nama yang dipaparkan dalam senarai tuntutan)", "receipts": "(JPG, PNG atau PDF, maksimum 10 MB setiap satu)", "date": "(tarikh pada resit)", "amount": "(jumlah resit yang dimuat naik)", "currency": "(tiga huruf, cth. MYR)" },
"placeholders": { "group": "Pilih projek", "noGroup": "Tiada projek", "category": "Pilih kategori", "title": "cth. Makan tengah hari pelanggan", "description": "Nyatakan secara ringkas tujuan perbelanjaan ini", "basis": "Pilih bukti", "supplier": "cth. Grab" },
"lineTypes": { "receipt": "Tuntutan perbelanjaan" },
"basis": { "einvoice_company": "e-Invois kepada syarikat", "einvoice_employee": "e-Invois atas nama saya", "foreign_supplier": "Invois pembekal asing", "relaxation_receipt": "Resit tanpa e-Invois" },
"payout": { "addDetails": "Tambah butiran anda dahulu", "invalid": "Butiran perlu dikemas kini" },
"managePayout": "Urus butiran pembayaran",
"summary": "Semak tuntutan",
"totalToReimburse": "Jumlah untuk dibayar balik",
"receiptsCount_one": "{{count}} fail dimuat naik",
"receiptsCount_other": "{{count}} fail dimuat naik",
"otherLines": "Baris lain dalam tuntutan ini",
"withdrawLine": "Tarik balik baris {{n}}",
"saveDraft": "Simpan draf",
"addLine": "Simpan dan tambah baris lain",
"back": "Kembali",
"next": "Seterusnya",
"submit": "Hantar tuntutan",
"draftSaved": "Draf disimpan",
"submitted": "Tuntutan {{reference}} dihantar",
"noSubmit": "Tuntutan ini tidak boleh dihantar sekarang.",
"notEditable": "Hanya draf anda sendiri boleh disunting.",
"conflict": "Tuntutan ini berubah semasa anda menyunting. Tutup dan buka semula untuk melihat versi terkini.",
"errors": { "category": "Pilih kategori", "title": "Masukkan tajuk", "titleTooLong": "Tajuk maksimum 200 aksara", "description": "Nyatakan tujuan perbelanjaan ini", "descriptionTooLong": "Maksimum 500 aksara", "documents": "Muat naik sekurang-kurangnya satu resit", "date": "Pilih tarikh perbelanjaan", "amount": "Masukkan amaun lebih daripada sifar, cth. 30.00", "currency": "Gunakan kod mata wang tiga huruf", "basis": "Pilih bukti resit" }
}

zh/claimWizard.json

{
"title": "提交报销",
"editTitle": "编辑草稿",
"subtitles": { "info": "所有信息均需填写", "amount": "填写金额与上传收据", "confirm": "确认收款方式与报销资料无误后再提交" },
"steps": { "info": "报销信息", "amount": "金额与收据", "confirm": "确认提交" },
"fields": { "group": "项目", "lineType": "报销类型", "category": "费用类别", "title": "标题", "description": "用途说明", "receipts": "上传收据", "date": "费用日期", "amount": "总金额", "currency": "币种", "basis": "收据类型", "supplier": "供应商", "einvoice": "电子发票 UUID", "relaxation": "无电子发票的原因", "payout": "收款方式" },
"hints": { "title": "(这笔报销在列表中显示的名称)", "receipts": "(支持 JPG、PNG、PDF,单个文件不超过 10MB)", "date": "(请选择收据上的日期)", "amount": "(请填写所有上传收据的合计金额)", "currency": "(三个字母,例如 MYR)" },
"placeholders": { "group": "选择项目", "noGroup": "不关联项目", "category": "选择类别", "title": "例如:Client lunch", "description": "简单说明这笔费用的用途(请用英文或马来文)", "basis": "选择收据类型", "supplier": "例如:Grab" },
"lineTypes": { "receipt": "费用报销" },
"basis": { "einvoice_company": "开给公司的电子发票", "einvoice_employee": "开给本人的电子发票", "foreign_supplier": "境外供应商发票", "relaxation_receipt": "无电子发票的收据" },
"payout": { "addDetails": "请先填写收款资料", "invalid": "收款资料需要更新" },
"managePayout": "管理收款资料",
"summary": "报销资料确认",
"totalToReimburse": "应报销总额",
"receiptsCount_one": "{{count}}个文件已上传",
"receiptsCount_other": "{{count}}个文件已上传",
"otherLines": "这笔报销的其他明细",
"withdrawLine": "撤回第 {{n}} 条明细",
"saveDraft": "保存草稿",
"addLine": "保存并添加另一条明细",
"back": "上一步",
"next": "下一步",
"submit": "提交报销",
"draftSaved": "草稿已保存",
"submitted": "报销 {{reference}} 已提交",
"noSubmit": "这笔报销目前无法提交。",
"notEditable": "只能编辑您自己的草稿。",
"conflict": "这笔报销在您编辑期间已被更改。请关闭后重新打开以查看最新版本。",
"errors": { "category": "请选择类别", "title": "请填写标题", "titleTooLong": "标题最多 200 个字符", "description": "请说明这笔费用的用途", "descriptionTooLong": "最多 500 个字符", "documents": "请至少上传一张收据", "date": "请选择费用日期", "amount": "请输入大于零的金额,例如 30.00", "currency": "请使用三个字母的币种代码", "basis": "请选择收据类型" }
}
  • Step 9: Run the tests

Run: pnpm -C web test -- src/features/claims src/drift Expected: PASS (all claims tests, parity, no-float, no-hex). Then typecheck and lint.

  • Step 10: Runtime check

Dev stack and preview. The seeded claimant must have a bank payout profile (Task 8 seed) and the seed’s categories. At 1440, light, en:

  1. /claims → New claim: the dialog opens at /claims/new, stepper at 1. Press Next empty: “Enter a title” and “Choose a category” appear; the network tab shows no request.
  2. Fill project, category, title “Grab to client”, purpose; Next: one POST /v1/expenses/claims (201, ETag: "1"), step 2 shows.
  3. Upload a real PDF: progress, then the row shows “uploaded ✓”. Enter 30.00, pick Receipt without e-Invoice, type a reason; Next: POST …/lines with If-Match: "1", then POST …/documents with If-Match: "2"; step 3 shows.
  4. Step 3: the bank card shows the masked tail; a channel without saved details is disabled with “Add your details first”. “Total to reimburse” shows RM 30.00 (from the server response). Manage payout details goes to /settings/payout.
  5. Submit claim: POST …/transitions with body {from:"draft", to:"pending_review"} and an Idempotency-Key; the drawer opens on the new claim, chip “Pending review”.
  6. Create a second claim, Save draft on step 2, reopen it via ⋮ → Edit draft: title, purpose, amount and the uploaded file are back; change the title and Next: one PATCH with only title.
  7. Type a Chinese title while in zh: the record-language hint shows; submitting returns the server’s 422 under the title field. Then 390px (dialog full-width, the two-column fields stack, the three payout cards stack), dark, zh (提交报销, 报销信息 / 金额与收据 / 确认提交, 保存草稿, 下一步, 上一步, 收款方式, 管理收款资料, 报销资料确认).
  • Step 11: Commit
Terminal window
git add web/src/features/claims web/src/lib/i18n/locales/*/claimWizard.json
git commit -m "$(cat <<'MSG'
Plan 10 Task 13: submit-claim wizard and draft editing (semantic submit, revision threading)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MSG
)"

Task 14: Settings — my payout details, sessions, delegations

Section titled “Task 14: Settings — my payout details, sessions, delegations”

No Figma frame (C, spec §5.6); built from the Components page (0:1) — pull its get_design_context for the card, radio-card, form-field and table styles. Step 3 of the wizard (166:11488) links here with 管理收款资料, so the payout page uses the same card look as the wizard’s payout cards: one card per channel, label bold, masked detail in grey under it.

Layout: /settings is a page with the header Settings and three underline tabs Payout details · Sessions · Delegations (the third only with workflow.delegation.create). /settings redirects to /settings/payout.

  • Payout details (/settings/payout): a grid of channel cards (3 per row at 1440, 1 at 390). Each card: channel label; a status tag (Saved green, Needs update amber when valid is false, Not set muted); the saved fields as label: display rows (masked values arrive masked and stay masked); buttons Edit / Add details and, when saved, Remove (confirm dialog). When valid is false the problems list under it (key — reason). Edit opens a dialog rendering the channel’s field_schema with FieldSchemaForm. A masked field that already has a saved value starts empty with the hint “Saved value is hidden — type it again”; because PUT is a full replace, the form refuses to save while such a field is blank (a shape rule: the value would otherwise be silently cleared).
  • Sessions (/settings/sessions): a table — device (device label or user agent), IP, signed in (created_at), last active (relative), and a This device tag on the current one; Sign out on every other row, and Sign out all other devices above the table. The current row has no revoke button; the user menu’s sign-out covers it.
  • Delegations (/settings/delegations): New delegation button; a table of the delegations I gave and received — direction tag (Given / Received), the other person’s name, kind (All workflows or the kind), window (from – until, with the note that the end date is the first day it no longer applies), reason, status (Active, Scheduled, Ended, Revoked, derived from the ISO dates by string comparison with todayISO()), and Revoke on my own non-revoked, non-ended rows. The dialog: delegate (searchable member combobox, active members except me), kind (a select of the workflow kinds when the caller holds workflow.definition.read, otherwise only “All workflows”), from (date, default today), until (optional), reason (textarea, required, record-language hint).

Files:

  • Create: web/src/features/settings/routes.tsx
  • Create: web/src/features/settings/SettingsLayout.tsx
  • Create: web/src/features/settings/RecordLanguageHint.tsx
  • Create: web/src/features/settings/payout/api.ts
  • Create: web/src/features/settings/payout/FieldSchemaForm.tsx
  • Create: web/src/features/settings/payout/PayoutProfileDialog.tsx
  • Create: web/src/features/settings/payout/MyPayoutPage.tsx
  • Create: web/src/features/settings/sessions/SessionsPage.tsx
  • Create: web/src/features/settings/delegations/DelegationsPage.tsx
  • Create: web/src/features/settings/delegations/DelegationDialog.tsx
  • Create: web/src/features/settings/delegations/delegationStatus.ts
  • Create: web/src/features/settings/__tests__/FieldSchemaForm.test.tsx
  • Create: web/src/features/settings/__tests__/MyPayoutPage.test.tsx
  • Create: web/src/features/settings/__tests__/SessionsPage.test.tsx
  • Create: web/src/features/settings/__tests__/DelegationsPage.test.tsx
  • Create: web/src/features/settings/__tests__/delegationStatus.test.ts
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/settings.json
  • Modify (append): web/src/app/routes.ts, web/src/app/shell/nav.ts

Interfaces:

  • Consumes: api, unwrap, ProblemError, problemMessage, Schemas; useMe, useCan, useSignOut; useMemberNames, useMemberDirectory; DocumentUploader, UploadedDoc; pickLabel, useLocale; formatDate, formatDateTime, relativeTime, todayISO; appRoute; ui PageHeader, Tabs, Card, Tag, Button, Dialog, ConfirmDialog, Field, Input, Textarea, Select, Combobox, DatePicker, DataTable, EmptyState, Skeleton, toast.

  • Produces: FieldSchemaForm(p: { fields: FieldSchemaField[]; values: Record<string, string>; onChange(key: string, value: string): void; errors?: Record<string, string>; hiddenSaved?: Set<string>; disabled?: boolean; idPrefix?: string }) where FieldSchemaField = Schemas['PayoutField'] & { choices?: { key: string; label: string }[] } — registry change R6, consumed by Task 21’s live preview. payoutMeKeys = { channels: ['payout','me','channels'], profiles: ['payout','me','profiles'] }.

  • Step 0: Verify imports (C1). Also grep -n "file\b\|registry_ref" internal/platform/payout/*.go | grep -v _test | head to confirm how a file field’s value is validated (Part C assumes the value is a document id). If it is something else, change FieldSchemaForm’s file branch accordingly.

  • Step 1: Write the failing tests

web/src/features/settings/__tests__/delegationStatus.test.ts
import { describe, expect, it } from 'vitest';
import { delegationStatus } from '../delegations/delegationStatus';
const base = { valid_from: '2026-09-10', valid_to: '2026-09-20' as string | null, revoked_at: null as string | null };
describe('delegationStatus (half-open [from, to))', () => {
it('is scheduled before, active from valid_from, ended ON valid_to', () => {
expect(delegationStatus(base, '2026-09-09')).toBe('scheduled');
expect(delegationStatus(base, '2026-09-10')).toBe('active');
expect(delegationStatus(base, '2026-09-19')).toBe('active');
expect(delegationStatus(base, '2026-09-20')).toBe('ended');
});
it('is active forever when open-ended, and revoked wins over everything', () => {
expect(delegationStatus({ ...base, valid_to: null }, '2030-01-01')).toBe('active');
expect(delegationStatus({ ...base, revoked_at: '2026-09-12T00:00:00Z' }, '2026-09-15')).toBe('revoked');
});
});
web/src/features/settings/__tests__/FieldSchemaForm.test.tsx
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FieldSchemaForm, type FieldSchemaField } from '../payout/FieldSchemaForm';
vi.mock('@/lib/i18n', async (orig) => ({ ...(await orig<typeof import('@/lib/i18n')>()), useLocale: () => ['ms', () => undefined] }));
const fields: FieldSchemaField[] = [
{ key: 'account_name', type: 'text', required: true, max: 100, label: { en: 'Account name', ms: 'Nama akaun' } },
{ key: 'account_no', type: 'account_no', required: true, sensitivity: 'masked', label: { en: 'Account number', ms: 'Nombor akaun' }, help: { en: 'Digits only', ms: 'Digit sahaja' } },
{ key: 'kind', type: 'select', label: { en: 'Account type', ms: 'Jenis akaun' }, options: [{ key: 'savings', label: { en: 'Savings', ms: 'Simpanan' } }, { key: 'current', label: { en: 'Current', ms: 'Semasa' } }] },
{ key: 'bank', type: 'registry_ref', required: true, registry: 'banks', label: { en: 'Bank', ms: 'Bank' }, choices: [{ key: 'maybank', label: 'Maybank' }, { key: 'cimb', label: 'CIMB' }] },
];
describe('FieldSchemaForm', () => {
it('renders each field type with its label in the UI locale, required marks and help', () => {
render(<FieldSchemaForm fields={fields} values={{}} onChange={() => undefined} />);
expect(screen.getByRole('textbox', { name: /Nama akaun/ })).toHaveAttribute('maxlength', '100');
expect(screen.getByRole('textbox', { name: /Nombor akaun/ })).toHaveAttribute('autocomplete', 'off');
expect(screen.getByText('Digit sahaja')).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /Jenis akaun/ })).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /Bank/ })).toBeInTheDocument();
});
it('reports typed values by key, as strings', async () => {
const onChange = vi.fn();
render(<FieldSchemaForm fields={fields} values={{}} onChange={onChange} />);
await userEvent.type(screen.getByRole('textbox', { name: /Nombor akaun/ }), '7');
expect(onChange).toHaveBeenLastCalledWith('account_no', '7');
});
it('shows the hidden-saved hint only on masked fields that have a saved value (presence first)', () => {
const { rerender } = render(<FieldSchemaForm fields={fields} values={{}} onChange={() => undefined} hiddenSaved={new Set(['account_no'])} />);
expect(screen.getByText('Saved value is hidden — type it again')).toBeInTheDocument();
rerender(<FieldSchemaForm fields={fields} values={{}} onChange={() => undefined} hiddenSaved={new Set()} />);
expect(screen.queryByText('Saved value is hidden — type it again')).not.toBeInTheDocument();
});
it('puts a server or shape error under its field', () => {
render(<FieldSchemaForm fields={fields} values={{}} onChange={() => undefined} errors={{ bank: 'Choose a bank' }} />);
expect(screen.getByText('Choose a bank')).toBeInTheDocument();
});
});

This test renders FieldSchemaForm without the router, so it relies on the test setup (Task 5/6) initialising i18n in en globally. Verify: grep -n "initI18n" web/src/test/setup.ts.

web/src/features/settings/__tests__/MyPayoutPage.test.tsx
import { describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/msw/server';
import { renderWithApp } from '@/test/renderWithApp';
const BANK = '00000000-0000-4000-8000-00000000ba01';
const CASH = '00000000-0000-4000-8000-00000000ca51';
const channels = {
channels: [
{ id: BANK, key: 'bank', label: 'Bank transfer', settlement_behaviour: 'external', allowed_currencies: ['MYR'], allowed_claim_types: [], max_amount: null,
field_schema: [
{ key: 'account_name', type: 'text', required: true, label: { en: 'Account name', ms: 'Nama akaun' } },
{ key: 'account_no', type: 'account_no', required: true, sensitivity: 'masked', label: { en: 'Account number', ms: 'Nombor akaun' } },
] },
{ id: CASH, key: 'cash', label: 'Cash', settlement_behaviour: 'external', allowed_currencies: ['MYR'], allowed_claim_types: [], max_amount: null, field_schema: [] },
],
};
const saved = {
profiles: [{ id: 'p1', channel_id: BANK, channel_key: 'bank', valid: true, problems: [], updated_at: '2026-08-01T00:00:00Z',
fields: [
{ key: 'account_name', type: 'text', label: { en: 'Account name', ms: 'Nama akaun' }, value: 'ZI YANG', display: 'ZI YANG' },
{ key: 'account_no', type: 'account_no', label: { en: 'Account number', ms: 'Nombor akaun' }, sensitivity: 'masked', value: null, display: '•••• 4821' },
] }],
};
function mockApi(onPut?: (body: unknown) => void) {
server.use(
http.get('*/v1/payout/me/channels', () => HttpResponse.json(channels)),
http.get('*/v1/payout/me/profiles', () => HttpResponse.json(saved)),
http.put('*/v1/payout/me/profiles/:channelId', async ({ request }) => {
const body = await request.json();
onPut?.(body);
return HttpResponse.json(saved.profiles[0]);
}),
);
}
describe('MyPayoutPage', () => {
it('shows saved details masked as the server sent them, and channels with nothing saved', async () => {
mockApi();
await renderWithApp({ path: '/settings/payout', permissions: [] });
const bank = await screen.findByRole('region', { name: 'Bank transfer' });
expect(within(bank).getByText('•••• 4821')).toBeInTheDocument();
expect(within(bank).getByText('Saved')).toBeInTheDocument();
expect(within(screen.getByRole('region', { name: 'Cash' })).getByText('Not set')).toBeInTheDocument();
});
it('prefills plain fields, leaves masked ones blank, and refuses to save a blank masked field', async () => {
const puts: unknown[] = [];
mockApi((b) => puts.push(b));
const { user } = await renderWithApp({ path: '/settings/payout', permissions: [] });
await user.click(within(await screen.findByRole('region', { name: 'Bank transfer' })).getByRole('button', { name: 'Edit' }));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByRole('textbox', { name: /Account name/ })).toHaveValue('ZI YANG');
expect(within(dialog).getByRole('textbox', { name: /Account number/ })).toHaveValue('');
await user.click(within(dialog).getByRole('button', { name: 'Save' }));
expect(await within(dialog).findByText('Type the account number again to keep it')).toBeInTheDocument();
expect(puts).toHaveLength(0);
await user.type(within(dialog).getByRole('textbox', { name: /Account number/ }), '5140123454821');
await user.click(within(dialog).getByRole('button', { name: 'Save' }));
await waitFor(() => expect(puts).toEqual([{ details: { account_name: 'ZI YANG', account_no: '5140123454821' } }]));
});
});
web/src/features/settings/__tests__/SessionsPage.test.tsx
import { describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/msw/server';
import { renderWithApp } from '@/test/renderWithApp';
const CUR = '00000000-0000-4000-8000-00000005e551';
const OLD = '00000000-0000-4000-8000-00000005e552';
describe('SessionsPage', () => {
it('offers Sign out on other sessions and not on this device (presence first)', async () => {
let sessions = [
{ id: CUR, device_label: 'MacBook', user_agent: 'Chrome', ip: '10.0.0.2', created_at: '2026-09-20T01:00:00Z', last_used_at: '2026-09-23T01:00:00Z', current: true },
{ id: OLD, device_label: 'iPhone', user_agent: 'Safari', ip: '10.0.0.9', created_at: '2026-09-01T01:00:00Z', last_used_at: '2026-09-02T01:00:00Z', current: false },
];
const deleted: string[] = [];
server.use(
http.get('*/v1/auth/sessions', () => HttpResponse.json(sessions)),
http.delete('*/v1/auth/sessions/:id', ({ params }) => {
deleted.push(params.id as string);
sessions = sessions.filter((s) => s.id !== params.id);
return new HttpResponse(null, { status: 204 });
}),
);
const { user } = await renderWithApp({ path: '/settings/sessions', permissions: [] });
const other = (await screen.findByText('iPhone')).closest('tr')!;
expect(within(other).getByRole('button', { name: 'Sign out' })).toBeInTheDocument();
const mine = screen.getByText('MacBook').closest('tr')!;
expect(within(mine).getByText('This device')).toBeInTheDocument();
expect(within(mine).queryByRole('button', { name: 'Sign out' })).not.toBeInTheDocument();
await user.click(within(other).getByRole('button', { name: 'Sign out' }));
await waitFor(() => expect(deleted).toEqual([OLD]));
await waitFor(() => expect(screen.queryByText('iPhone')).not.toBeInTheDocument());
});
});
web/src/features/settings/__tests__/DelegationsPage.test.tsx
import { describe, expect, it } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/msw/server';
import { renderWithApp } from '@/test/renderWithApp';
const ME = '00000000-0000-4000-8000-000000000001';
const ALEX = '00000000-0000-4000-8000-000000000002';
const members = [{ member_id: ME, name: 'Zi Yang', active: true }, { member_id: ALEX, name: 'Alex Tan', active: true }];
function mockApi(onPost?: (b: unknown) => void) {
server.use(
http.get('*/v1/payout/me/channels', () => HttpResponse.json({ channels: [] })),
http.get('*/v1/payout/me/profiles', () => HttpResponse.json({ profiles: [] })),
http.get('*/v1/workflow/delegations', () =>
HttpResponse.json({ delegations: [{ id: 'd1', delegator_member_id: ME, delegate_member_id: ALEX, kind: null, valid_from: '2026-09-01', valid_to: null, reason: 'Annual leave', created_at: '2026-08-30T00:00:00Z', revoked_at: null }] }),
),
http.post('*/v1/workflow/delegations', async ({ request }) => {
const b = await request.json();
onPost?.(b);
return HttpResponse.json({ id: 'd2', delegator_member_id: ME, ...(b as object), created_at: '2026-09-23T00:00:00Z', revoked_at: null }, { status: 201 });
}),
);
}
describe('Delegations tab', () => {
it('is a tab only with workflow.delegation.create (presence first)', async () => {
mockApi();
const first = await renderWithApp({ path: '/settings/payout', permissions: ['workflow.delegation.create'], members });
expect(await screen.findByRole('tab', { name: 'Delegations' })).toBeInTheDocument();
first.unmount();
mockApi();
await renderWithApp({ path: '/settings/payout', permissions: [], members });
expect(await screen.findByRole('tab', { name: 'Payout details' })).toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'Delegations' })).not.toBeInTheDocument();
});
it('lists a given delegation by the delegate name and creates a new one', async () => {
const posts: unknown[] = [];
mockApi((b) => posts.push(b));
const { user } = await renderWithApp({ path: '/settings/delegations', permissions: ['workflow.delegation.create'], members });
expect(await screen.findByText('Alex Tan')).toBeInTheDocument();
expect(screen.getByText('All workflows')).toBeInTheDocument();
expect(screen.getByText('Active')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'New delegation' }));
await user.click(screen.getByRole('combobox', { name: 'Delegate' }));
await user.click(await screen.findByRole('option', { name: 'Alex Tan' }));
await user.type(screen.getByRole('textbox', { name: 'Reason' }), 'Conference trip');
await user.click(screen.getByRole('button', { name: 'Create delegation' }));
await waitFor(() => expect(posts[0]).toMatchObject({ delegate_member_id: ALEX, reason: 'Conference trip', kind: null, valid_to: null }));
expect((posts[0] as { valid_from: string }).valid_from).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
  • Step 2: Run them and watch them fail

Run: pnpm -C web test -- src/features/settings Expected: FAIL — the modules and the /settings/* routes do not exist.

  • Step 3: Payout API hooks and FieldSchemaForm
web/src/features/settings/payout/api.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
export const payoutMeKeys = { channels: ['payout', 'me', 'channels'] as const, profiles: ['payout', 'me', 'profiles'] as const };
export function useMyPayoutChannels() {
return useQuery({ queryKey: payoutMeKeys.channels, queryFn: async ({ signal }) => unwrap(await api.GET('/payout/me/channels', { signal })), select: (d) => d.channels });
}
export function useMyPayoutProfiles() {
return useQuery({ queryKey: payoutMeKeys.profiles, queryFn: async ({ signal }) => unwrap(await api.GET('/payout/me/profiles', { signal })), select: (d) => d.profiles });
}
export function useSavePayoutProfile() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (v: { channelId: string; details: Record<string, string> }) =>
unwrap(await api.PUT('/payout/me/profiles/{channel_id}', { params: { path: { channel_id: v.channelId } }, body: { details: v.details } })),
onSuccess: () => void qc.invalidateQueries({ queryKey: payoutMeKeys.profiles }),
});
}
export function useDeletePayoutProfile() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (channelId: string) => {
const r = await api.DELETE('/payout/me/profiles/{channel_id}', { params: { path: { channel_id: channelId } } });
if (r.error) unwrap(r);
},
onSuccess: () => void qc.invalidateQueries({ queryKey: payoutMeKeys.profiles }),
});
}

Verify: unwrap on a 204 — if Task 6’s unwrap already returns undefined for an empty success body, call it directly instead of the if (r.error) guard (used here and in every DELETE below).

web/src/features/settings/payout/FieldSchemaForm.tsx
import { useTranslation } from 'react-i18next';
import { Field } from '@/components/ui/Field';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Textarea } from '@/components/ui/Textarea';
import { DocumentUploader } from '@/components/upload';
import type { Schemas } from '@/lib/api/types';
import { pickLabel, useLocale } from '@/lib/i18n';
/** A channel's field as the admin schema (Task 21 preview) or the member's view (with registry choices) carries it. */
export type FieldSchemaField = Schemas['PayoutField'] & { choices?: { key: string; label: string }[] };
export interface FieldSchemaFormProps {
fields: FieldSchemaField[];
values: Record<string, string>;
onChange(key: string, value: string): void;
errors?: Record<string, string>;
/** Masked fields that hold a saved value the server will never send back. */
hiddenSaved?: Set<string>;
disabled?: boolean;
idPrefix?: string;
}
export function FieldSchemaForm({ fields, values, onChange, errors, hiddenSaved, disabled, idPrefix = 'pf' }: FieldSchemaFormProps) {
const { t } = useTranslation('settings');
const [locale] = useLocale();
return (
<div className="flex flex-col gap-4">
{fields.map((f) => {
const id = `${idPrefix}-${f.key}`;
const label = pickLabel(f.label, locale) || f.key;
const help = f.help ? pickLabel(f.help, locale) : undefined;
const hint = hiddenSaved?.has(f.key) ? [help, t('payout.hiddenSaved')].filter(Boolean).join(' · ') : help;
const value = values[f.key] ?? '';
const set = (v: string) => onChange(f.key, v);
let control: React.ReactNode;
switch (f.type) {
case 'select':
control = (
<Select id={id} aria-label={label} disabled={disabled} value={value} onChange={set} placeholder={t('payout.choose')}
options={(f.options ?? []).map((o) => ({ value: o.key, label: pickLabel(o.label, locale) || o.key }))} />
);
break;
case 'registry_ref':
control = (
<Select id={id} aria-label={label} disabled={disabled} value={value} onChange={set} placeholder={t('payout.choose')}
options={(f.choices ?? []).map((c) => ({ value: c.key, label: c.label }))} />
);
break;
case 'address':
control = <Textarea id={id} rows={3} maxLength={f.max} disabled={disabled} value={value} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => set(e.target.value)} />;
break;
case 'file':
control = (
<DocumentUploader
multiple={false}
value={value ? [{ id: value, filename: t('payout.fileUploaded'), size: 0, content_type: 'application/pdf' }] : []}
onUploaded={(d) => set(d.id)}
onRemoved={() => set('')}
/>
);
break;
default: // text, phone, account_no
control = (
<Input
id={id}
type="text"
inputMode={f.type === 'phone' ? 'tel' : f.type === 'account_no' ? 'numeric' : undefined}
autoComplete={f.sensitivity === 'masked' ? 'off' : undefined}
maxLength={f.max}
disabled={disabled}
value={value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => set(e.target.value)}
/>
);
}
return (
<Field key={f.key} label={label} htmlFor={id} hint={hint} error={errors?.[f.key]} required={f.required}>
{control}
</Field>
);
})}
</div>
);
}
  • Step 4: Payout page and dialog
web/src/features/settings/payout/PayoutProfileDialog.tsx
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Dialog } from '@/components/ui/Dialog';
import { toast } from '@/components/ui/Toaster';
import { ProblemError, problemMessage } from '@/lib/api/problem';
import type { Schemas } from '@/lib/api/types';
import { pickLabel, useLocale } from '@/lib/i18n';
import { useSavePayoutProfile } from './api';
import { FieldSchemaForm } from './FieldSchemaForm';
export function PayoutProfileDialog(p: { channel: Schemas['MyPayoutChannel']; profile?: Schemas['PayoutProfile']; onClose(): void }) {
const { t } = useTranslation('settings');
const { t: tc } = useTranslation();
const [locale] = useLocale();
const save = useSavePayoutProfile();
const hiddenSaved = useMemo(
() => new Set((p.profile?.fields ?? []).filter((f) => f.sensitivity === 'masked' && f.display).map((f) => f.key)),
[p.profile],
);
const [values, setValues] = useState<Record<string, string>>(() =>
Object.fromEntries(p.channel.field_schema.map((f) => [f.key, p.profile?.fields.find((x) => x.key === f.key)?.value ?? ''])),
);
const [errors, setErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
async function onSave() {
// Shape only (P8): a required field is filled; a masked field with a saved value is typed again,
// because PUT is a full replace and the saved value is never read back.
const e: Record<string, string> = {};
for (const f of p.channel.field_schema) {
if (values[f.key]) continue;
const label = pickLabel(f.label, locale) || f.key;
if (hiddenSaved.has(f.key)) e[f.key] = t('payout.retype', { field: label.toLowerCase() });
else if (f.required) e[f.key] = t('payout.required', { field: label });
}
setErrors(e);
if (Object.keys(e).length) return;
setFormError(null);
try {
await save.mutateAsync({ channelId: p.channel.id, details: values });
toast.success(t('payout.saved'));
p.onClose();
} catch (err) {
// The 422 detail names each field and what is wrong with it; it is shown as sent.
setFormError(err instanceof ProblemError && err.detail ? err.detail : problemMessage(err, tc));
}
}
return (
<Dialog
open
onOpenChange={(o: boolean) => !o && p.onClose()}
title={t('payout.dialogTitle', { channel: p.channel.label })}
description={t('payout.dialogDescription')}
footer={
<>
<Button variant="outline" onClick={p.onClose}>{t('cancel')}</Button>
<Button onClick={onSave} loading={save.isPending}>{t('save')}</Button>
</>
}
>
<FieldSchemaForm fields={p.channel.field_schema} values={values} onChange={(k, v) => setValues((s) => ({ ...s, [k]: v }))} errors={errors} hiddenSaved={hiddenSaved} />
{formError && <p role="alert" className="mt-3 text-sm text-destructive">{formError}</p>}
</Dialog>
);
}
web/src/features/settings/payout/MyPayoutPage.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { EmptyState } from '@/components/ui/EmptyState';
import { Skeleton } from '@/components/ui/Skeleton';
import { Tag } from '@/components/ui/Tag';
import { toast } from '@/components/ui/Toaster';
import { problemMessage } from '@/lib/api/problem';
import type { Schemas } from '@/lib/api/types';
import { pickLabel, useLocale } from '@/lib/i18n';
import { useDeletePayoutProfile, useMyPayoutChannels, useMyPayoutProfiles } from './api';
import { PayoutProfileDialog } from './PayoutProfileDialog';
export function MyPayoutPage() {
const { t } = useTranslation('settings');
const { t: tc } = useTranslation();
const [locale] = useLocale();
const channels = useMyPayoutChannels();
const profiles = useMyPayoutProfiles();
const remove = useDeletePayoutProfile();
const [editing, setEditing] = useState<Schemas['MyPayoutChannel'] | null>(null);
const [removing, setRemoving] = useState<Schemas['MyPayoutChannel'] | null>(null);
if (channels.isLoading || profiles.isLoading) return <Skeleton className="h-48 w-full" />;
if (!channels.data?.length) return <EmptyState title={t('payout.noChannels')} description={t('payout.noChannelsHint')} />;
return (
<>
<p className="mb-4 text-sm text-muted-foreground">{t('payout.intro')}</p>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{channels.data.map((c) => {
const prof = profiles.data?.find((p) => p.channel_id === c.id);
const headingId = `payout-${c.id}`;
return (
<section key={c.id} aria-labelledby={headingId} className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between">
<h3 id={headingId} className="font-bold">{c.label}</h3>
{!prof ? <Tag tone="muted">{t('payout.notSet')}</Tag> : prof.valid ? <Tag tone="green">{t('payout.savedTag')}</Tag> : <Tag tone="amber">{t('payout.needsUpdate')}</Tag>}
</div>
{prof && (
<dl className="text-sm">
{prof.fields.map((f) => (
<div key={f.key} className="flex justify-between gap-2 py-0.5">
<dt className="text-muted-foreground">{pickLabel(f.label, locale) || f.key}</dt>
<dd className="font-mono">{f.display}</dd>
</div>
))}
</dl>
)}
{prof && !prof.valid && (
<ul className="text-xs text-warning">
{prof.problems.map((pr) => <li key={pr.key}>{pr.key} — {pr.reason}</li>)}
</ul>
)}
{c.field_schema.length === 0 && <p className="text-xs text-muted-foreground">{t('payout.noDetailsNeeded')}</p>}
<div className="mt-auto flex gap-2">
{c.field_schema.length > 0 && (
<Button size="sm" variant={prof ? 'outline' : 'primary'} onClick={() => setEditing(c)}>
{prof ? t('payout.edit') : t('payout.add')}
</Button>
)}
{prof && <Button size="sm" variant="danger-outline" onClick={() => setRemoving(c)}>{t('payout.remove')}</Button>}
</div>
</section>
);
})}
</div>
{editing && <PayoutProfileDialog channel={editing} profile={profiles.data?.find((p) => p.channel_id === editing.id)} onClose={() => setEditing(null)} />}
<ConfirmDialog
open={removing !== null}
onOpenChange={(o: boolean) => !o && setRemoving(null)}
title={t('payout.removeTitle', { channel: removing?.label })}
description={t('payout.removeDescription')}
confirmLabel={t('payout.remove')}
tone="danger"
onConfirm={async () => {
try {
await remove.mutateAsync(removing!.id);
toast.success(t('payout.removed'));
} catch (e) {
toast.error(problemMessage(e, tc));
}
setRemoving(null);
}}
/>
</>
);
}

<section aria-labelledby> gives each card the region role the test queries by name.

  • Step 5: Sessions
web/src/features/settings/sessions/SessionsPage.tsx
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { DataTable } from '@/components/ui/DataTable';
import { Tag } from '@/components/ui/Tag';
import { toast } from '@/components/ui/Toaster';
import { api } from '@/lib/api/client';
import { problemMessage, unwrap } from '@/lib/api/problem';
import type { Schemas } from '@/lib/api/types';
import { formatDateTime, relativeTime } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
type Session = Schemas['SessionSummary'];
export function SessionsPage() {
const { t } = useTranslation('settings');
const { t: tc } = useTranslation();
const [locale] = useLocale();
const qc = useQueryClient();
const sessions = useQuery({ queryKey: ['auth', 'sessions'], queryFn: async ({ signal }) => unwrap(await api.GET('/auth/sessions', { signal })) });
const revoke = useMutation({
mutationFn: async (id: string) => {
const r = await api.DELETE('/auth/sessions/{id}', { params: { path: { id } } });
if (r.error) unwrap(r);
},
onSettled: () => void qc.invalidateQueries({ queryKey: ['auth', 'sessions'] }),
});
const others = (sessions.data ?? []).filter((s) => !s.current);
async function revokeOne(id: string) {
try {
await revoke.mutateAsync(id);
toast.success(t('sessions.revoked'));
} catch (e) {
toast.error(problemMessage(e, tc));
}
}
async function revokeOthers() {
for (const s of others) await revokeOne(s.id); // sequential: one audited revoke each
}
const columns = useMemo<ColumnDef<Session>[]>(
() => [
{ id: 'device', header: t('sessions.device'), cell: ({ row }) => (
<span className="flex items-center gap-2">
{row.original.device_label || row.original.user_agent || t('sessions.unknownDevice')}
{row.original.current && <Tag tone="teal">{t('sessions.thisDevice')}</Tag>}
</span>
) },
{ id: 'ip', header: t('sessions.ip'), cell: ({ row }) => <span className="font-mono text-sm">{row.original.ip ?? '—'}</span> },
{ id: 'created', header: t('sessions.signedIn'), cell: ({ row }) => formatDateTime(row.original.created_at, locale) },
{ id: 'last', header: t('sessions.lastActive'), cell: ({ row }) => relativeTime(row.original.last_used_at, locale) },
{ id: 'actions', header: '', cell: ({ row }) =>
row.original.current ? null : (
<Button size="sm" variant="danger-outline" onClick={() => void revokeOne(row.original.id)}>{t('sessions.signOut')}</Button>
) },
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, locale],
);
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{t('sessions.intro')}</p>
{others.length > 0 && <Button variant="outline" onClick={() => void revokeOthers()} loading={revoke.isPending}>{t('sessions.signOutOthers')}</Button>}
</div>
<DataTable columns={columns} data={sessions.data ?? []} loading={sessions.isLoading} getRowId={(s: Session) => s.id} empty={t('sessions.none')} />
</div>
);
}
  • Step 6: Delegations
web/src/features/settings/delegations/delegationStatus.ts
export type DelegationStatus = 'scheduled' | 'active' | 'ended' | 'revoked';
/** [valid_from, valid_to) — half-open, ISO dates compared as strings (YYYY-MM-DD sorts correctly). */
export function delegationStatus(d: { valid_from: string; valid_to?: string | null; revoked_at?: string | null }, today: string): DelegationStatus {
if (d.revoked_at) return 'revoked';
if (today < d.valid_from) return 'scheduled';
if (d.valid_to && today >= d.valid_to) return 'ended';
return 'active';
}
web/src/features/settings/delegations/DelegationDialog.tsx
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Combobox } from '@/components/ui/Combobox';
import { DatePicker } from '@/components/ui/DatePicker';
import { Dialog } from '@/components/ui/Dialog';
import { Field } from '@/components/ui/Field';
import { Select } from '@/components/ui/Select';
import { Textarea } from '@/components/ui/Textarea';
import { toast } from '@/components/ui/Toaster';
import { useCan, useMe } from '@/features/auth';
import { useMemberDirectory } from '@/features/members/useMemberNames';
import { api } from '@/lib/api/client';
import { ProblemError, problemMessage, unwrap } from '@/lib/api/problem';
import { todayISO } from '@/lib/date';
import { RecordLanguageHint } from '../RecordLanguageHint';
export function DelegationDialog({ onClose }: { onClose(): void }) {
const { t } = useTranslation('settings');
const { t: tc } = useTranslation();
const qc = useQueryClient();
const { data: me } = useMe();
const directory = useMemberDirectory();
const canReadKinds = useCan('workflow.definition.read');
const kinds = useQuery({
queryKey: ['workflow', 'definitions'],
enabled: canReadKinds,
queryFn: async ({ signal }) => unwrap(await api.GET('/workflow/definitions', { signal })),
select: (d) => d.kinds.filter((k) => k.latest_version > 0).map((k) => k.kind),
});
const [delegate, setDelegate] = useState('');
const [kind, setKind] = useState('');
const [from, setFrom] = useState(todayISO());
const [until, setUntil] = useState<string | undefined>();
const [reason, setReason] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
const create = useMutation({
mutationFn: async () =>
unwrap(await api.POST('/workflow/delegations', { body: { delegate_member_id: delegate, kind: kind || null, valid_from: from, valid_to: until ?? null, reason: reason.trim() } })),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['workflow', 'delegations'] }),
});
async function onSubmit() {
const e: Record<string, string> = {};
if (!delegate) e.delegate = t('delegations.errors.delegate');
if (!reason.trim()) e.reason = t('delegations.errors.reason');
setErrors(e);
if (Object.keys(e).length) return;
try {
await create.mutateAsync();
toast.success(t('delegations.created'));
onClose();
} catch (err) {
setFormError(err instanceof ProblemError && err.status === 422 && err.detail ? err.detail : problemMessage(err, tc));
}
}
const people = (directory.data ?? []).filter((m) => m.active && m.member_id !== me?.member_id).map((m) => ({ value: m.member_id, label: m.name ?? `…${m.member_id.slice(-4)}` }));
return (
<Dialog
open
onOpenChange={(o: boolean) => !o && onClose()}
title={t('delegations.newTitle')}
description={t('delegations.newDescription')}
footer={
<>
<Button variant="outline" onClick={onClose}>{t('cancel')}</Button>
<Button onClick={onSubmit} loading={create.isPending}>{t('delegations.create')}</Button>
</>
}
>
<div className="grid gap-4 md:grid-cols-2">
<Field label={t('delegations.delegate')} htmlFor="dl-delegate" error={errors.delegate} required>
<Combobox id="dl-delegate" aria-label={t('delegations.delegate')} value={delegate} onChange={setDelegate} options={people} placeholder={t('delegations.pickPerson')} />
</Field>
<Field label={t('delegations.kind')} htmlFor="dl-kind">
<Select id="dl-kind" aria-label={t('delegations.kind')} value={kind} onChange={setKind}
options={[{ value: '', label: t('delegations.allKinds') }, ...(kinds.data ?? []).map((k) => ({ value: k, label: t(`kinds.${k}`, { defaultValue: k }) }))]} />
</Field>
<Field label={t('delegations.from')} htmlFor="dl-from" required>
<DatePicker id="dl-from" value={from} onChange={(v?: string) => setFrom(v ?? todayISO())} />
</Field>
<Field label={t('delegations.until')} hint={t('delegations.untilHint')} htmlFor="dl-until">
<DatePicker id="dl-until" value={until} onChange={setUntil} />
</Field>
<div className="md:col-span-2">
<Field label={t('delegations.reason')} htmlFor="dl-reason" error={errors.reason} required>
<Textarea id="dl-reason" rows={2} value={reason} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setReason(e.target.value)} />
</Field>
<RecordLanguageHint />
</div>
</div>
{formError && <p role="alert" className="mt-3 text-sm text-destructive">{formError}</p>}
</Dialog>
);
}
web/src/features/settings/delegations/DelegationsPage.tsx
import { useMemo, useState } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { DataTable } from '@/components/ui/DataTable';
import { Tag } from '@/components/ui/Tag';
import { toast } from '@/components/ui/Toaster';
import { useMe } from '@/features/auth';
import { useMemberNames } from '@/features/members/useMemberNames';
import { api } from '@/lib/api/client';
import { problemMessage, unwrap } from '@/lib/api/problem';
import type { Schemas } from '@/lib/api/types';
import { formatDate, todayISO } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import { DelegationDialog } from './DelegationDialog';
import { delegationStatus } from './delegationStatus';
type Delegation = Schemas['WorkflowDelegation'];
const tone = { active: 'green', scheduled: 'blue', ended: 'muted', revoked: 'neutral' } as const;
export function DelegationsPage() {
const { t } = useTranslation('settings');
const { t: tc } = useTranslation();
const [locale] = useLocale();
const { data: me } = useMe();
const name = useMemberNames();
const qc = useQueryClient();
const [creating, setCreating] = useState(false);
const list = useQuery({ queryKey: ['workflow', 'delegations'], queryFn: async ({ signal }) => unwrap(await api.GET('/workflow/delegations', { signal })), select: (d) => d.delegations });
const revoke = useMutation({
mutationFn: async (id: string) => {
const r = await api.DELETE('/workflow/delegations/{id}', { params: { path: { id } } });
if (r.error) unwrap(r);
},
onSettled: () => void qc.invalidateQueries({ queryKey: ['workflow', 'delegations'] }),
});
const today = todayISO();
const columns = useMemo<ColumnDef<Delegation>[]>(() => [
{ id: 'dir', header: t('delegations.direction'), cell: ({ row }) => row.original.delegator_member_id === me?.member_id ? <Tag tone="purple">{t('delegations.given')}</Tag> : <Tag tone="blue">{t('delegations.received')}</Tag> },
{ id: 'who', header: t('delegations.person'), cell: ({ row }) => name(row.original.delegator_member_id === me?.member_id ? row.original.delegate_member_id : row.original.delegator_member_id) },
{ id: 'kind', header: t('delegations.kind'), cell: ({ row }) => (row.original.kind ? t(`kinds.${row.original.kind}`, { defaultValue: row.original.kind }) : t('delegations.allKinds')) },
{ id: 'window', header: t('delegations.window'), cell: ({ row }) => row.original.valid_to
? t('delegations.windowBounded', { from: formatDate(row.original.valid_from, locale), until: formatDate(row.original.valid_to, locale) })
: t('delegations.windowOpen', { from: formatDate(row.original.valid_from, locale) }) },
{ id: 'reason', header: t('delegations.reason'), cell: ({ row }) => row.original.reason },
{ id: 'status', header: t('delegations.status'), cell: ({ row }) => { const s = delegationStatus(row.original, today); return <Tag tone={tone[s]}>{t(`delegations.statuses.${s}`)}</Tag>; } },
{ id: 'actions', header: '', cell: ({ row }) => {
const s = delegationStatus(row.original, today);
if (row.original.delegator_member_id !== me?.member_id || s === 'revoked' || s === 'ended') return null;
return (
<Button size="sm" variant="danger-outline" onClick={async () => {
try { await revoke.mutateAsync(row.original.id); toast.success(t('delegations.revoked')); } catch (e) { toast.error(problemMessage(e, tc)); }
}}>{t('delegations.revoke')}</Button>
);
} },
// eslint-disable-next-line react-hooks/exhaustive-deps
], [t, locale, me?.member_id, name, today]);
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{t('delegations.intro')}</p>
<Button onClick={() => setCreating(true)}>{t('delegations.new')}</Button>
</div>
<DataTable columns={columns} data={list.data ?? []} loading={list.isLoading} getRowId={(d: Delegation) => d.id} empty={t('delegations.none')} />
{creating && <DelegationDialog onClose={() => setCreating(false)} />}
</div>
);
}
// web/src/features/settings/RecordLanguageHint.tsx — F8 (C6)
import { useTranslation } from 'react-i18next';
import { useLocale } from '@/lib/i18n';
export function RecordLanguageHint() {
const { t } = useTranslation('settings');
const [locale] = useLocale();
return locale === 'zh' ? <p className="text-xs text-muted-foreground">{t('recordLanguageHint')}</p> : null;
}
  • Step 7: Layout, routes, nav
web/src/features/settings/SettingsLayout.tsx
import { Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { KeyRound, MonitorSmartphone, Wallet } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Tabs } from '@/components/ui/Tabs';
import { useCan } from '@/features/auth';
export function SettingsLayout() {
const { t } = useTranslation('settings');
const canDelegate = useCan('workflow.delegation.create');
const pathname = useRouterState({ select: (s) => s.location.pathname });
const navigate = useNavigate();
const items = [
{ value: '/settings/payout', label: t('tabs.payout'), icon: Wallet },
{ value: '/settings/sessions', label: t('tabs.sessions'), icon: MonitorSmartphone },
...(canDelegate ? [{ value: '/settings/delegations', label: t('tabs.delegations'), icon: KeyRound }] : []),
];
return (
<div className="flex flex-col gap-4">
<PageHeader title={t('title')} subtitle={t('subtitle')} />
<Tabs value={pathname} onChange={(v: string) => void navigate({ to: v })} items={items} />
<Outlet />
</div>
);
}
web/src/features/settings/routes.tsx
import { createRoute, redirect } from '@tanstack/react-router';
import { appRoute } from '@/app/layouts';
import { RequirePermission } from '@/features/auth';
import { DelegationsPage } from './delegations/DelegationsPage';
import { MyPayoutPage } from './payout/MyPayoutPage';
import { SessionsPage } from './sessions/SessionsPage';
import { SettingsLayout } from './SettingsLayout';
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: 'settings', component: SettingsLayout });
const settingsIndexRoute = createRoute({ getParentRoute: () => settingsRoute, path: '/', beforeLoad: () => { throw redirect({ to: '/settings/payout' }); } });
const payoutRoute = createRoute({ getParentRoute: () => settingsRoute, path: 'payout', component: MyPayoutPage });
const sessionsRoute = createRoute({ getParentRoute: () => settingsRoute, path: 'sessions', component: SessionsPage });
const delegationsRoute = createRoute({
getParentRoute: () => settingsRoute,
path: 'delegations',
component: () => (
<RequirePermission permission="workflow.delegation.create">
<DelegationsPage />
</RequirePermission>
),
});
export const routes = [settingsRoute.addChildren([settingsIndexRoute, payoutRoute, sessionsRoute, delegationsRoute])] as const;

Append to web/src/app/routes.ts: import { routes as settingsRoutes } from '@/features/settings/routes'; and ...settingsRoutes,. Append to web/src/app/shell/nav.ts (no permission: every member has payout details and sessions):

import { Settings } from 'lucide-react';
{ group: 'manage', to: '/settings', labelKey: 'settings:nav', icon: Settings },

Verify with Task 8’s UserMenu.tsx: if its “My payout details” / “Sessions” entries link to other paths, point them at /settings/payout and /settings/sessions (that is Task 8’s file — report the mismatch rather than edit it, unless Task 8 has merged and left a TODO).

  • Step 8: i18n bundles

en/settings.json

{
"nav": "Settings",
"title": "Settings",
"subtitle": "Your payout details, devices and approval delegations",
"save": "Save",
"cancel": "Cancel",
"recordLanguageHint": "Records are kept in English or Malay. Please type the reason in English or Malay.",
"tabs": { "payout": "Payout details", "sessions": "Sessions", "delegations": "Delegations" },
"kinds": { "expense_claim": "Expense claims", "advance": "Advances", "advance_topup": "Advance top-ups", "advance_refund": "Advance refunds", "petty_cash_spend": "Petty cash spends", "float_topup": "Petty cash top-ups", "float_closeout": "Petty cash returns" },
"payout": {
"intro": "Claims and advances are paid to the details you save here. Hidden values are never shown again after saving.",
"noChannels": "No payout methods yet", "noChannelsHint": "An administrator has not set up any payout method.",
"notSet": "Not set", "savedTag": "Saved", "needsUpdate": "Needs update", "noDetailsNeeded": "No details needed for this method.",
"add": "Add details", "edit": "Edit", "remove": "Remove", "removed": "Payout details removed", "saved": "Payout details saved",
"removeTitle": "Remove your {{channel}} details?", "removeDescription": "Payouts already made keep the details they were paid with.",
"dialogTitle": "{{channel}} details", "dialogDescription": "Saving replaces every field of this method.",
"hiddenSaved": "Saved value is hidden — type it again", "retype": "Type the {{field}} again to keep it", "required": "{{field}} is required",
"choose": "Choose…", "fileUploaded": "Uploaded file"
},
"sessions": {
"intro": "Devices signed in to your account.", "device": "Device", "ip": "IP address", "signedIn": "Signed in", "lastActive": "Last active",
"thisDevice": "This device", "unknownDevice": "Unknown device", "signOut": "Sign out", "signOutOthers": "Sign out all other devices",
"revoked": "Signed out that device", "none": "No other sessions"
},
"delegations": {
"intro": "Let someone approve on your behalf while you are away.", "new": "New delegation", "newTitle": "New delegation",
"newDescription": "The delegate approves with your limits. Every decision names you both.", "create": "Create delegation", "created": "Delegation created",
"direction": "Direction", "given": "Given", "received": "Received", "person": "Person", "delegate": "Delegate", "pickPerson": "Search people…",
"kind": "Workflow", "allKinds": "All workflows", "window": "Period", "from": "From", "until": "Until", "untilHint": "The first day it no longer applies. Leave empty for no end.",
"windowBounded": "{{from}} – before {{until}}", "windowOpen": "From {{from}}, no end",
"reason": "Reason", "status": "Status", "revoke": "Revoke", "revoked": "Delegation revoked", "none": "No delegations",
"statuses": { "active": "Active", "scheduled": "Scheduled", "ended": "Ended", "revoked": "Revoked" },
"errors": { "delegate": "Choose who approves for you", "reason": "Give a reason" }
}
}

ms/settings.json

{
"nav": "Tetapan",
"title": "Tetapan",
"subtitle": "Butiran pembayaran, peranti dan delegasi kelulusan anda",
"save": "Simpan",
"cancel": "Batal",
"recordLanguageHint": "Rekod disimpan dalam bahasa Inggeris atau Melayu. Sila taip sebab dalam bahasa Inggeris atau Melayu.",
"tabs": { "payout": "Butiran pembayaran", "sessions": "Sesi", "delegations": "Delegasi" },
"kinds": { "expense_claim": "Tuntutan perbelanjaan", "advance": "Pendahuluan", "advance_topup": "Tambahan pendahuluan", "advance_refund": "Bayaran balik pendahuluan", "petty_cash_spend": "Perbelanjaan wang runcit", "float_topup": "Tambahan wang runcit", "float_closeout": "Pemulangan wang runcit" },
"payout": {
"intro": "Tuntutan dan pendahuluan dibayar kepada butiran yang anda simpan di sini. Nilai tersembunyi tidak dipaparkan lagi selepas disimpan.",
"noChannels": "Belum ada kaedah pembayaran", "noChannelsHint": "Pentadbir belum menyediakan sebarang kaedah pembayaran.",
"notSet": "Belum ditetapkan", "savedTag": "Disimpan", "needsUpdate": "Perlu dikemas kini", "noDetailsNeeded": "Kaedah ini tidak memerlukan butiran.",
"add": "Tambah butiran", "edit": "Sunting", "remove": "Buang", "removed": "Butiran pembayaran dibuang", "saved": "Butiran pembayaran disimpan",
"removeTitle": "Buang butiran {{channel}} anda?", "removeDescription": "Pembayaran yang telah dibuat mengekalkan butiran yang digunakan.",
"dialogTitle": "Butiran {{channel}}", "dialogDescription": "Menyimpan akan menggantikan semua medan kaedah ini.",
"hiddenSaved": "Nilai tersimpan disembunyikan — taip semula", "retype": "Taip semula {{field}} untuk mengekalkannya", "required": "{{field}} diperlukan",
"choose": "Pilih…", "fileUploaded": "Fail dimuat naik"
},
"sessions": {
"intro": "Peranti yang log masuk ke akaun anda.", "device": "Peranti", "ip": "Alamat IP", "signedIn": "Log masuk", "lastActive": "Aktif terakhir",
"thisDevice": "Peranti ini", "unknownDevice": "Peranti tidak diketahui", "signOut": "Log keluar", "signOutOthers": "Log keluar semua peranti lain",
"revoked": "Peranti itu telah dilog keluar", "none": "Tiada sesi lain"
},
"delegations": {
"intro": "Benarkan seseorang meluluskan bagi pihak anda semasa anda tiada.", "new": "Delegasi baharu", "newTitle": "Delegasi baharu",
"newDescription": "Wakil meluluskan dengan had anda. Setiap keputusan menamakan anda berdua.", "create": "Cipta delegasi", "created": "Delegasi dicipta",
"direction": "Arah", "given": "Diberi", "received": "Diterima", "person": "Orang", "delegate": "Wakil", "pickPerson": "Cari orang…",
"kind": "Aliran kerja", "allKinds": "Semua aliran kerja", "window": "Tempoh", "from": "Dari", "until": "Hingga", "untilHint": "Hari pertama ia tidak lagi terpakai. Biarkan kosong jika tiada tarikh akhir.",
"windowBounded": "{{from}} – sebelum {{until}}", "windowOpen": "Dari {{from}}, tiada tarikh akhir",
"reason": "Sebab", "status": "Status", "revoke": "Batalkan", "revoked": "Delegasi dibatalkan", "none": "Tiada delegasi",
"statuses": { "active": "Aktif", "scheduled": "Dijadualkan", "ended": "Tamat", "revoked": "Dibatalkan" },
"errors": { "delegate": "Pilih siapa yang meluluskan untuk anda", "reason": "Berikan sebab" }
}
}

zh/settings.json

{
"nav": "设置",
"title": "设置",
"subtitle": "收款资料、登录设备与审批委托",
"save": "保存",
"cancel": "取消",
"recordLanguageHint": "记录须使用英文或马来文,请用英文或马来文填写原因。",
"tabs": { "payout": "收款资料", "sessions": "登录设备", "delegations": "审批委托" },
"kinds": { "expense_claim": "费用报销", "advance": "预支款", "advance_topup": "预支补付", "advance_refund": "预支退款", "petty_cash_spend": "备用金支出", "float_topup": "申请备用金", "float_closeout": "归还备用金" },
"payout": {
"intro": "报销与预支款将支付到您在此保存的收款资料。隐藏的信息保存后不会再显示。",
"noChannels": "暂无收款方式", "noChannelsHint": "管理员尚未设置任何收款方式。",
"notSet": "未设置", "savedTag": "已保存", "needsUpdate": "需要更新", "noDetailsNeeded": "此收款方式无需填写资料。",
"add": "填写资料", "edit": "编辑", "remove": "删除", "removed": "已删除收款资料", "saved": "收款资料已保存",
"removeTitle": "删除您的{{channel}}收款资料?", "removeDescription": "已完成的付款仍保留当时使用的资料。",
"dialogTitle": "{{channel}}收款资料", "dialogDescription": "保存将替换此收款方式的所有字段。",
"hiddenSaved": "已保存的信息已隐藏,请重新输入", "retype": "请重新输入{{field}}以保留", "required": "请填写{{field}}",
"choose": "请选择…", "fileUploaded": "已上传文件"
},
"sessions": {
"intro": "已登录您账户的设备。", "device": "设备", "ip": "IP 地址", "signedIn": "登录时间", "lastActive": "最近活动",
"thisDevice": "本设备", "unknownDevice": "未知设备", "signOut": "退出登录", "signOutOthers": "退出所有其他设备",
"revoked": "已退出该设备", "none": "没有其他登录设备"
},
"delegations": {
"intro": "外出期间,可让他人代您审批。", "new": "新建委托", "newTitle": "新建委托",
"newDescription": "受托人按您的审批额度审批,每项决定都会记录双方。", "create": "创建委托", "created": "委托已创建",
"direction": "方向", "given": "我委托的", "received": "委托给我的", "person": "人员", "delegate": "受托人", "pickPerson": "搜索人员…",
"kind": "流程", "allKinds": "所有流程", "window": "期间", "from": "开始", "until": "结束", "untilHint": "不再生效的第一天。留空表示无结束日期。",
"windowBounded": "{{from}} – {{until}}之前", "windowOpen": "自 {{from}} 起,无结束日期",
"reason": "原因", "status": "状态", "revoke": "撤销", "revoked": "委托已撤销", "none": "暂无委托",
"statuses": { "active": "生效中", "scheduled": "未开始", "ended": "已结束", "revoked": "已撤销" },
"errors": { "delegate": "请选择代您审批的人", "reason": "请填写原因" }
}
}
  • Step 9: Run the tests

Run: pnpm -C web test -- src/features/settings src/drift Expected: PASS. Then typecheck and lint.

  • Step 10: Runtime check

Dev stack and preview. At 1440, light, en, signed in as the seeded claimant:

  1. Sidebar Settings → /settings/payout. One card per active channel; the seeded bank profile shows its masked account (•••• nnnn) — compare with curl …/v1/payout/me/profiles; no clear-text value appears anywhere in the DOM (search the Elements panel for the full number you seeded).
  2. Edit bank: name prefilled, account blank with the hidden hint; Save blank → inline “Type the account number again…”, no request in the network tab; type it, Save → one PUT with every field; the card updates.
  3. Enter an invalid value (e.g. letters in an account number if the schema refuses them): the server’s 422 detail shows in the dialog.
  4. Sessions: this browser tagged This device, no button on it. Sign in from a private window, reload: a second row with Sign out; click it; the private window’s next request lands on /login.
  5. As the approver (holds workflow.delegation.create): Delegations tab present; create one to another member for tomorrow → status Scheduled; revoke it → Revoked. As a member without the permission: no Delegations tab, and /settings/delegations shows the 403 panel. Then 390px (cards one column, table scrolls inside its card), dark, zh (收款资料, 登录设备, 审批委托, 本设备).
  • Step 11: Commit
Terminal window
git add web/src/features/settings web/src/lib/i18n/locales/*/settings.json web/src/app/routes.ts web/src/app/shell/nav.ts
git commit -m "$(cat <<'MSG'
Plan 10 Task 14: settings (payout details with field_schema renderer, sessions, delegations)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MSG
)"

No Figma frame (C, spec §5.3). Built from the claims list’s table and chip styles (frame 123:7183) and the drawer of 286:9929; pull both through get_design_context for spacing and the Components page (0:1) for tags.

Layout: /approvals — header Approvals (审批) with the subtitle “Everything waiting for your decision”; a chip row All n · Claims n · Advances n · Petty cash spends n · Petty cash top-ups n (counts are the queue lengths — integers); one table, columns Type (tag), Reference, Description (claim title / advance purpose / spend description / top-up reason), Requested by (member name), Amount, Status (chip from that record’s own workflow). Rows open a drawer at /approvals/$kind/$id with ‹ › through the same kind’s queue; a claim opens Task 12’s ClaimDrawer, the others a record drawer built here: header facts, StatusChip, TransitionBar, the decision timeline and, per kind, the receipt and review control (spend), the legs (advance), or the replenishment line count (top-up). Below 768px the table becomes cards. The sidebar item Approvals carries a count badge — the same four queues, polled every 60 s and on window focus.

Close-outs are not in the queue: there is no list endpoint for them (gap G7).

Files:

  • Create: web/src/features/approvals/types.ts
  • Create: web/src/features/approvals/api.ts
  • Create: web/src/features/approvals/useApprovalsCount.ts
  • Create: web/src/features/approvals/search.ts
  • Create: web/src/features/approvals/ApprovalsPage.tsx
  • Create: web/src/features/approvals/RecordDrawer.tsx
  • Create: web/src/features/approvals/ProofDialog.tsx
  • Create: web/src/features/approvals/routes.tsx
  • Create: web/src/features/approvals/__tests__/fixtures.ts
  • Create: web/src/features/approvals/__tests__/ApprovalsPage.test.tsx
  • Create: web/src/features/approvals/__tests__/RecordDrawer.test.tsx
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/approvals.json
  • Modify (append): web/src/app/routes.ts, web/src/app/shell/nav.ts

Interfaces:

  • Consumes: ClaimDrawer (Task 12, R6); claimKeys (Task 11); useWorkflowDefinition, StatusChip, TransitionBar, DecisionTimeline, useNeighbours; useMemberNames; DocumentThumb, DocumentUploader, UploadedDoc; useMe, useCan; api, newIdempotencyKey, unwrap, ProblemError, problemMessage; ui PageHeader, DataTable, Tag, CountBadge, Drawer, Dialog, Button, IconButton, Select, Field, EmptyState, Skeleton, toast.

  • Produces: useApprovalsCount(): number | undefined (nav badge; R6), query keys ['approvals', 'claims'|'advances'|'spends'|'topups'], ['advances','detail',id], ['pettycash','spend',id], ['pettycash','topup',id] (R6 — Tasks 16 and 17 use the same keys, so a decision here refreshes their pages and vice versa).

  • Step 0: Verify imports (C1). Read web/src/app/shell/Sidebar.tsx: confirm it calls item.badge?.() inside a per-item component (so the hook inside useApprovalsCount runs in a stable component, not in a loop over a filtered array) and how it renders the number. If the Sidebar calls badge in a .map inside one component, that is a hook-order bug in Task 8 — report it; do not work around it here.

  • Step 1: Fixtures

web/src/features/approvals/__tests__/fixtures.ts
import type { Schemas } from '@/lib/api/types';
import { claimDefinition, makeClaim } from '@/features/claims/__tests__/fixtures';
export { claimDefinition, makeClaim };
export const ME = '00000000-0000-4000-8000-000000000001';
export const HOLDER = '00000000-0000-4000-8000-000000000003';
export const members = [
{ member_id: ME, name: 'Alex Tan', active: true },
{ member_id: HOLDER, name: 'Siti Aminah', active: true },
];
const l = (en: string, zh: string) => ({ en, zh, ms: en });
const st = (key: string, semantic: Schemas['WorkflowSemantic'], order: number, en: string, zh: string, is_terminal = false): Schemas['WorkflowState'] =>
({ key, semantic, display_order: order, is_terminal, labels: l(en, zh) });
const def = (kind: string, states: Schemas['WorkflowState'][]): Schemas['WorkflowDefinition'] =>
({ id: `def-${kind}`, kind, version: 1, published_at: '2026-08-01T00:00:00Z', graph: { kind, posting_order: 'accrual_first', states, edges: [], grants: [] } });
export const spendDefinition = def('petty_cash_spend', [
st('draft', 'draft', 1, 'Draft', '草稿'), st('recorded', 'accrual', 2, 'Recorded', '已记录'),
st('spend_paid', 'payout', 3, 'Paid from float', '已从备用金支付', true), st('not_reimbursable', 'rejected', 6, 'Not reimbursable', '不予报销', true),
]);
export const topupDefinition = def('float_topup', [
st('requested', 'draft', 1, 'Requested', '已申请'), st('reviewed', 'pending', 2, 'Reviewed', '已审核'), st('approved', 'pending', 3, 'Approved', '已批准'),
st('paid', 'payout', 4, 'Paid', '已支付', true), st('rejected', 'rejected', 7, 'Rejected', '已拒绝', true),
]);
export const advanceDefinition = def('advance', [
st('draft', 'draft', 1, 'Draft', '草稿'), st('pending_review', 'pending', 2, 'Pending review', '待审核'), st('reviewed', 'pending', 3, 'Reviewed', '已审核'),
st('approved', 'pending', 4, 'Approved', '已批准'), st('paid_out', 'payout', 5, 'Paid out', '已支付'), st('received', 'acknowledged', 6, 'Received', '已收款'),
st('acquitted', 'accrual', 7, 'Acquitted', '已核销', true), st('rejected', 'rejected', 9, 'Rejected', '已拒绝'),
]);
export const tr = (to: string, en: string, extra: Partial<Schemas['WorkflowAvailableTransition']> = {}): Schemas['WorkflowAvailableTransition'] =>
({ to, action_key: to, gate: 'target', requires_reason: false, allowed: true, labels: { en }, ...extra });
export function spend(over: Partial<Schemas['PettyCashSpend']> = {}): Schemas['PettyCashSpend'] {
return {
id: '00000000-0000-4000-8000-00000005be01', reference: 'PS-2026-0004', float_id: 'f1', holder_member_id: HOLDER, expense_date: '2026-08-10',
description: 'Cleaning supplies', amount: '11.0000', currency: 'MYR', expense_account_id: 'acc', tax_code_id: null, supplier_name: null,
supplier_tin: null, einvoice_uuid: null, einvoice_validated_at: null, document_basis: 'relaxation_receipt', relaxation_reason: 'Market stall',
receipt_document_id: '00000000-0000-4000-8000-0000000d0c02', outcome: null, reviewed_by: null, reviewed_at: null,
state: 'recorded', semantic: 'accrual', revision: 3, ...over,
};
}
export function topup(over: Partial<Schemas['PettyCashTopUp']> = {}): Schemas['PettyCashTopUp'] {
return { id: '00000000-0000-4000-8000-0000000070a1', reference: 'PT-2026-0002', float_id: 'f1', requested_by: HOLDER, purpose: 'topup', amount: '500.0000', currency: 'MYR', reason: 'Monthly float', state: 'requested', semantic: 'draft', revision: 1, ...over };
}
export function advance(over: Partial<Schemas['Advance']> = {}): Schemas['Advance'] {
return {
id: '00000000-0000-4000-8000-0000000ad001', reference: 'AD-2026-0003', claimant_member_id: HOLDER, holder_party_id: 'party', purpose: 'Site visit to Penang',
currency: 'MYR', amount: '800.0000', fx_rate: '1', fx_rate_source: 'base', base_amount: '800.0000', due_on: '2026-09-30', payout_channel_id: null,
revision: 2, state: 'pending_review', semantic: 'pending', ...over,
};
}
  • Step 2: Write the failing tests
web/src/features/approvals/__tests__/ApprovalsPage.test.tsx
import { describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/msw/server';
import { renderWithApp } from '@/test/renderWithApp';
import { advance, advanceDefinition, claimDefinition, makeClaim, members, spend, spendDefinition, topup, topupDefinition } from './fixtures';
const ALL = ['expenses.claim.read', 'expenses.claim.transition', 'advances.advance.read', 'advances.advance.transition', 'pettycash.read', 'pettycash.transition'];
function mockApi(seen: string[] = []) {
server.use(
http.get('*/v1/workflow/definitions/:kind', ({ params }) =>
HttpResponse.json({ expense_claim: claimDefinition, advance: advanceDefinition, petty_cash_spend: spendDefinition, float_topup: topupDefinition }[params.kind as string])),
http.get('*/v1/expenses/claims', ({ request }) => { seen.push(`claims?${new URL(request.url).search}`); return HttpResponse.json({ claims: [makeClaim({ title: 'Client lunch' })], next_cursor: null }); }),
http.get('*/v1/advances', ({ request }) => { seen.push(`advances?${new URL(request.url).search}`); return HttpResponse.json({ advances: [advance()], next_cursor: null }); }),
http.get('*/v1/pettycash/spends', ({ request }) => { seen.push(`spends?${new URL(request.url).search}`); return HttpResponse.json({ spends: [spend()] }); }),
http.get('*/v1/pettycash/topups', ({ request }) => { seen.push(`topups?${new URL(request.url).search}`); return HttpResponse.json({ topups: [topup()] }); }),
);
}
describe('ApprovalsPage', () => {
it('merges the four to_act queues into one table with a type, requester and status per row', async () => {
const seen: string[] = [];
mockApi(seen);
await renderWithApp({ path: '/approvals', permissions: ALL, members });
const row = (await screen.findByText('Cleaning supplies')).closest('tr')!;
expect(within(row).getByText('Petty cash spend')).toBeInTheDocument();
expect(within(row).getByText('Siti Aminah')).toBeInTheDocument();
expect(within(row).getByText('RM 11.00')).toBeInTheDocument();
expect(within(row).getByText('Recorded')).toBeInTheDocument();
expect(screen.getByText('Client lunch')).toBeInTheDocument();
expect(screen.getByText('Site visit to Penang')).toBeInTheDocument();
expect(screen.getByText('Monthly float')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /All\s*4/ })).toBeInTheDocument();
expect(seen.every((s) => s.includes('scope=to_act'))).toBe(true);
});
it('filters by kind through ?kind=', async () => {
mockApi();
const { user, router } = await renderWithApp({ path: '/approvals', permissions: ALL, members });
await screen.findByText('Cleaning supplies');
await user.click(screen.getByRole('button', { name: /Advances\s*1/ }));
await waitFor(() => expect(router.state.location.search).toMatchObject({ kind: 'advance' }));
expect(screen.getByText('Site visit to Penang')).toBeInTheDocument();
expect(screen.queryByText('Cleaning supplies')).not.toBeInTheDocument();
});
it('asks only for the queues the caller may read (presence first)', async () => {
const withPc: string[] = [];
mockApi(withPc);
const first = await renderWithApp({ path: '/approvals', permissions: ALL, members });
await screen.findByText('Cleaning supplies');
expect(withPc.some((s) => s.startsWith('spends'))).toBe(true);
first.unmount();
const without: string[] = [];
mockApi(without);
await renderWithApp({ path: '/approvals', permissions: ['expenses.claim.read', 'expenses.claim.transition'], members });
await screen.findByText('Client lunch');
expect(without.some((s) => s.startsWith('claims'))).toBe(true);
expect(without.some((s) => s.startsWith('spends') || s.startsWith('topups') || s.startsWith('advances'))).toBe(false);
expect(screen.queryByText('Cleaning supplies')).not.toBeInTheDocument();
});
it('shows the queue length as the sidebar badge', async () => {
mockApi();
await renderWithApp({ path: '/approvals', permissions: ALL, members, shell: true });
const link = await screen.findByRole('link', { name: /Approvals/ });
await waitFor(() => expect(within(link).getByText('4')).toBeInTheDocument());
});
});
web/src/features/approvals/__tests__/RecordDrawer.test.tsx
import { describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/msw/server';
import { renderWithApp } from '@/test/renderWithApp';
import type { Schemas } from '@/lib/api/types';
import { advance, advanceDefinition, claimDefinition, members, spend, spendDefinition, topupDefinition, tr } from './fixtures';
const PERMS = ['pettycash.read', 'pettycash.transition', 'pettycash.spend.review', 'advances.advance.read', 'advances.advance.transition'];
type Captured = { path: string; body: unknown; ifMatch: string | null; idem: string | null };
function mockApi(opts: { spendView?: Schemas['PettyCashSpendView']; advanceView?: Schemas['AdvanceView'] }, calls: Captured[]) {
const cap = async (request: Request) => calls.push({ path: new URL(request.url).pathname, body: await request.json(), ifMatch: request.headers.get('If-Match'), idem: request.headers.get('Idempotency-Key') });
server.use(
http.get('*/v1/workflow/definitions/:kind', ({ params }) =>
HttpResponse.json({ expense_claim: claimDefinition, advance: advanceDefinition, petty_cash_spend: spendDefinition, float_topup: topupDefinition }[params.kind as string] ?? advanceDefinition)),
http.get('*/v1/expenses/claims', () => HttpResponse.json({ claims: [], next_cursor: null })),
http.get('*/v1/advances', () => HttpResponse.json({ advances: opts.advanceView ? [opts.advanceView.advance] : [], next_cursor: null })),
http.get('*/v1/pettycash/spends', () => HttpResponse.json({ spends: opts.spendView ? [opts.spendView.spend] : [] })),
http.get('*/v1/pettycash/topups', () => HttpResponse.json({ topups: [] })),
http.get('*/v1/documents/:id', ({ params }) => HttpResponse.json({ id: params.id, uploaded_by: 'x', filename: 'r.jpg', content_type: 'image/jpeg', byte_size: 1, sha256: 'c'.repeat(64), status: 'verified', reject_reason: null, created_at: '2026-08-10T00:00:00Z', verified_at: null, download_url: 'x', thumbnail_url: null, ocr_suggestions: [] })),
http.get('*/v1/pettycash/spends/:id', () => HttpResponse.json(opts.spendView)),
http.get('*/v1/advances/:id', () => HttpResponse.json(opts.advanceView)),
http.post('*/v1/pettycash/spends/:id/review', async ({ request }) => { await cap(request); return HttpResponse.json({ ...opts.spendView!, spend: { ...opts.spendView!.spend, outcome: 'verified', revision: 4 } }); }),
http.post('*/v1/pettycash/spends/:id/transitions', async ({ request }) => { await cap(request); return HttpResponse.json({ ...opts.spendView!, spend: { ...opts.spendView!.spend, state: 'spend_paid', semantic: 'payout' }, available_transitions: [] }); }),
http.post('*/v1/advances/:id/transitions', async ({ request }) => { await cap(request); return HttpResponse.json({ ...opts.advanceView!, legs: opts.advanceView!.legs.map((l) => ({ ...l, available_transitions: [] })) }); }),
);
}
const spendView = (over: Partial<Schemas['PettyCashSpend']> = {}): Schemas['PettyCashSpendView'] => ({
spend: spend(over),
available_transitions: [tr('spend_paid', 'Pay from float', { gate: 'reporter', allowed: false, refusal: { code: 'outcome', detail: 'The spend has not been verified.' } }), tr('not_reimbursable', 'Not reimbursable', { requires_reason: true })],
decisions: [],
});
describe('RecordDrawer', () => {
it('records a reviewer outcome with the spend revision in If-Match', async () => {
const calls: Captured[] = [];
const v = spendView();
mockApi({ spendView: v }, calls);
const { user } = await renderWithApp({ path: `/approvals/spend/${v.spend.id}`, permissions: PERMS, members });
const drawer = await screen.findByRole('dialog');
expect(within(drawer).getByText('Cleaning supplies')).toBeInTheDocument();
expect(within(drawer).getByRole('button', { name: 'Pay from float' })).toBeDisabled();
await user.click(within(drawer).getByRole('combobox', { name: 'Review outcome' }));
await user.click(await screen.findByRole('option', { name: 'Verified' }));
await user.click(within(drawer).getByRole('button', { name: 'Record review' }));
await waitFor(() => expect(calls[0]).toMatchObject({ path: `/v1/pettycash/spends/${v.spend.id}/review`, body: { outcome: 'verified' }, ifMatch: '"3"' }));
});
it('hides the review control from the spend holder (presence first)', async () => {
const v = spendView();
mockApi({ spendView: v }, []);
const first = await renderWithApp({ path: `/approvals/spend/${v.spend.id}`, permissions: PERMS, members });
expect(await screen.findByRole('combobox', { name: 'Review outcome' })).toBeInTheDocument();
first.unmount();
const own = spendView({ holder_member_id: '00000000-0000-4000-8000-000000000001' }); // renderWithApp's default member id
mockApi({ spendView: own }, []);
await renderWithApp({ path: `/approvals/spend/${own.spend.id}`, permissions: PERMS, members });
expect(await screen.findByText('Cleaning supplies')).toBeInTheDocument();
expect(screen.queryByRole('combobox', { name: 'Review outcome' })).not.toBeInTheDocument();
});
it('moves the right leg of an advance: kind, from and an Idempotency-Key', async () => {
const calls: Captured[] = [];
const a = advance();
const view: Schemas['AdvanceView'] = {
advance: a, acquittal: null, settlements: [], overdue: null,
legs: [{ kind: 'advance', instance_id: 'i1', state: 'pending_review', semantic: 'pending', amount: '800.0000', decisions: [], available_transitions: [tr('reviewed', 'Mark reviewed')] }],
};
mockApi({ advanceView: view }, calls);
const { user } = await renderWithApp({ path: `/approvals/advance/${a.id}`, permissions: PERMS, members });
await user.click(await screen.findByRole('button', { name: 'Mark reviewed' }));
await waitFor(() => expect(calls[0]).toMatchObject({ path: `/v1/advances/${a.id}/transitions`, body: { kind: 'advance', from: 'pending_review', to: 'reviewed' } }));
expect(calls[0].idem).toMatch(/^[0-9a-f-]{36}$/);
});
});

Verify: renderWithApp’s /me fixture uses member id 00000000-0000-4000-8000-000000000001 (R1 fixes it); the second test depends on it.

  • Step 3: Run them and watch them fail

Run: pnpm -C web test -- src/features/approvals Expected: FAIL — no /approvals route.

  • Step 4: Types, queue API, count
web/src/features/approvals/types.ts
import type { Schemas } from '@/lib/api/types';
export type QueueKind = 'claim' | 'advance' | 'spend' | 'topup';
export const WF_KIND: Record<QueueKind, string> = { claim: 'expense_claim', advance: 'advance', spend: 'petty_cash_spend', topup: 'float_topup' };
export interface QueueItem {
kind: QueueKind; id: string; reference: string; description: string; requester: string;
amount: string; currency: string; state: string; semantic: Schemas['WorkflowSemantic'] | string;
}
export type AdvanceView = Schemas['AdvanceView'];
export type SpendView = Schemas['PettyCashSpendView'];
export type TopUpView = Schemas['PettyCashTopUpView'];
web/src/features/approvals/api.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCan } from '@/features/auth';
import { api, newIdempotencyKey } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
import type { Schemas } from '@/lib/api/types';
import type { AdvanceView, QueueItem, QueueKind, SpendView, TopUpView } from './types';
const POLL = { refetchInterval: 60_000, refetchOnWindowFocus: true } as const;
/** The four to_act queues, bounded and cursorless. Shared by the page and the nav badge (same keys). */
export function useApprovalQueues(poll = false) {
const canClaims = useCan('expenses.claim.read');
const canAdvances = useCan('advances.advance.read');
const canPc = useCan('pettycash.read');
const o = poll ? POLL : {};
const claims = useQuery({ queryKey: ['approvals', 'claims'], enabled: canClaims, ...o,
queryFn: async ({ signal }) => unwrap(await api.GET('/expenses/claims', { params: { query: { scope: 'to_act' } }, signal })).claims });
const advances = useQuery({ queryKey: ['approvals', 'advances'], enabled: canAdvances, ...o,
queryFn: async ({ signal }) => unwrap(await api.GET('/advances', { params: { query: { scope: 'to_act' } }, signal })).advances });
const spends = useQuery({ queryKey: ['approvals', 'spends'], enabled: canPc, ...o,
queryFn: async ({ signal }) => unwrap(await api.GET('/pettycash/spends', { params: { query: { scope: 'to_act' } }, signal })).spends });
const topups = useQuery({ queryKey: ['approvals', 'topups'], enabled: canPc, ...o,
queryFn: async ({ signal }) => unwrap(await api.GET('/pettycash/topups', { params: { query: { scope: 'to_act' } }, signal })).topups });
return { claims, advances, spends, topups };
}
export function toQueueItems(q: ReturnType<typeof useApprovalQueues>): QueueItem[] {
return [
...(q.claims.data ?? []).map((c): QueueItem => ({ kind: 'claim', id: c.id, reference: c.reference, description: c.title, requester: c.claimant_member_id, amount: c.total, currency: c.currency, state: c.state, semantic: c.semantic })),
...(q.advances.data ?? []).map((a): QueueItem => ({ kind: 'advance', id: a.id, reference: a.reference, description: a.purpose, requester: a.claimant_member_id, amount: a.amount, currency: a.currency, state: a.state, semantic: a.semantic })),
...(q.spends.data ?? []).map((s): QueueItem => ({ kind: 'spend', id: s.id, reference: s.reference, description: s.description, requester: s.holder_member_id, amount: s.amount, currency: s.currency, state: s.state, semantic: s.semantic })),
...(q.topups.data ?? []).map((t): QueueItem => ({ kind: 'topup', id: t.id, reference: t.reference, description: t.reason, requester: t.requested_by, amount: t.amount, currency: t.currency, state: t.state, semantic: t.semantic })),
];
}
function invalidateAfterDecision(qc: ReturnType<typeof useQueryClient>, resource: 'advances' | 'pettycash') {
void qc.invalidateQueries({ queryKey: ['approvals'] });
void qc.invalidateQueries({ queryKey: [resource] });
}
export function useAdvanceView(id: string, enabled = true) {
return useQuery({ queryKey: ['advances', 'detail', id], enabled, queryFn: async ({ signal }) => unwrap(await api.GET('/advances/{id}', { params: { path: { id } }, signal })) });
}
export function useSpendView(id: string, enabled = true) {
return useQuery({ queryKey: ['pettycash', 'spend', id], enabled, queryFn: async ({ signal }) => unwrap(await api.GET('/pettycash/spends/{id}', { params: { path: { id } }, signal })) });
}
export function useTopUpView(id: string, enabled = true) {
return useQuery({ queryKey: ['pettycash', 'topup', id], enabled, queryFn: async ({ signal }) => unwrap(await api.GET('/pettycash/topups/{id}', { params: { path: { id } }, signal })) });
}
export function useAdvanceTransition(id: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: Schemas['AdvanceTransition']): Promise<AdvanceView> =>
unwrap(await api.POST('/advances/{id}/transitions', { params: { path: { id }, header: { 'Idempotency-Key': newIdempotencyKey() } }, body })),
onSuccess: (v) => { qc.setQueryData(['advances', 'detail', id], v); invalidateAfterDecision(qc, 'advances'); },
});
}
export function useSpendTransition(id: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: Schemas['PettyCashTransitionInput']): Promise<SpendView> =>
unwrap(await api.POST('/pettycash/spends/{id}/transitions', { params: { path: { id }, header: { 'Idempotency-Key': newIdempotencyKey() } }, body })),
onSuccess: (v) => { qc.setQueryData(['pettycash', 'spend', id], v); invalidateAfterDecision(qc, 'pettycash'); },
});
}
export function useTopUpTransition(id: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: Schemas['PettyCashTransitionInput']): Promise<TopUpView> =>
unwrap(await api.POST('/pettycash/topups/{id}/transitions', { params: { path: { id }, header: { 'Idempotency-Key': newIdempotencyKey() } }, body })),
onSuccess: (v) => { qc.setQueryData(['pettycash', 'topup', id], v); invalidateAfterDecision(qc, 'pettycash'); },
});
}
export function useSpendReview(id: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (v: { outcome: NonNullable<Schemas['ReviewerOutcome']>; revision: number }): Promise<SpendView> =>
unwrap(await api.POST('/pettycash/spends/{id}/review', { params: { path: { id }, header: { 'If-Match': `"${v.revision}"` } }, body: { outcome: v.outcome } })),
onSuccess: (v) => { qc.setQueryData(['pettycash', 'spend', id], v); invalidateAfterDecision(qc, 'pettycash'); },
});
}
export const QUEUE_KINDS: QueueKind[] = ['claim', 'advance', 'spend', 'topup'];
web/src/features/approvals/useApprovalsCount.ts
import { toQueueItems, useApprovalQueues } from './api';
/** The sidebar badge: how many records wait for me. Polled every 60 s and on focus (spec §5.3). */
export function useApprovalsCount(): number | undefined {
const q = useApprovalQueues(true);
const any = [q.claims, q.advances, q.spends, q.topups].some((x) => x.data !== undefined);
return any ? toQueueItems(q).length : undefined;
}
web/src/features/approvals/search.ts
import { z } from 'zod';
export const approvalsSearchSchema = z.object({ kind: z.enum(['claim', 'advance', 'spend', 'topup']).optional().catch(undefined) });
export type ApprovalsSearch = z.infer<typeof approvalsSearchSchema>;
  • Step 5: Page
web/src/features/approvals/ApprovalsPage.tsx
import { useMemo, type ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { useTranslation } from 'react-i18next';
import { CountBadge } from '@/components/ui/CountBadge';
import { DataTable } from '@/components/ui/DataTable';
import { EmptyState } from '@/components/ui/EmptyState';
import { PageHeader } from '@/components/ui/PageHeader';
import { Tag } from '@/components/ui/Tag';
import { MoneyText } from '@/components/money';
import { StatusChip } from '@/components/workflow';
import { useMemberNames } from '@/features/members/useMemberNames';
import { cn } from '@/lib/cn';
import { QUEUE_KINDS, toQueueItems, useApprovalQueues } from './api';
import type { ApprovalsSearch } from './search';
import { WF_KIND, type QueueItem, type QueueKind } from './types';
const KIND_TONE = { claim: 'purple', advance: 'blue', spend: 'amber', topup: 'teal' } as const;
export function ApprovalsPage(p: { search: ApprovalsSearch; onKind(k?: QueueKind): void; onOpen(item: QueueItem): void; children?: ReactNode }) {
const { t } = useTranslation('approvals');
const name = useMemberNames();
const queues = useApprovalQueues();
const all = toQueueItems(queues);
const rows = p.search.kind ? all.filter((i) => i.kind === p.search.kind) : all;
const loading = [queues.claims, queues.advances, queues.spends, queues.topups].some((q) => q.isLoading);
const columns = useMemo<ColumnDef<QueueItem>[]>(() => [
{ id: 'type', header: t('columns.type'), cell: ({ row }) => <Tag tone={KIND_TONE[row.original.kind]}>{t(`kinds.${row.original.kind}`)}</Tag> },
{ id: 'reference', header: t('columns.reference'), cell: ({ row }) => <span className="font-mono text-sm">{row.original.reference}</span> },
{ id: 'description', header: t('columns.description'), cell: ({ row }) => row.original.description },
{ id: 'requester', header: t('columns.requester'), cell: ({ row }) => name(row.original.requester) },
{ id: 'amount', header: t('columns.amount'), cell: ({ row }) => <MoneyText className="font-bold" amount={row.original.amount} currency={row.original.currency} /> },
{ id: 'status', header: t('columns.status'), cell: ({ row }) => <StatusChip kind={WF_KIND[row.original.kind]} state={row.original.state} /> },
], [t, name]);
const chip = (k: QueueKind | undefined, count: number) => (
<button
key={k ?? 'all'}
type="button"
aria-pressed={p.search.kind === k}
onClick={() => p.onKind(k)}
className={cn('inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm', p.search.kind === k ? 'bg-accent text-primary' : 'text-muted-foreground')}
>
{k ? t(`chips.${k}`) : t('chips.all')} <CountBadge count={count} />
</button>
);
return (
<div className="flex flex-col gap-4">
<PageHeader title={t('title')} subtitle={t('subtitle')} />
<div className="flex flex-wrap gap-1">
{chip(undefined, all.length)}
{QUEUE_KINDS.map((k) => chip(k, all.filter((i) => i.kind === k).length))}
</div>
<div className="hidden rounded-xl border border-border bg-card md:block">
<DataTable columns={columns} data={rows} loading={loading} getRowId={(i: QueueItem) => `${i.kind}:${i.id}`} onRowClick={p.onOpen}
empty={<EmptyState title={t('empty.title')} description={t('empty.description')} />} />
</div>
<ul className="flex flex-col gap-3 md:hidden">
{rows.map((i) => (
<li key={`${i.kind}:${i.id}`}>
<button type="button" onClick={() => p.onOpen(i)} className="w-full rounded-xl border border-border bg-card p-4 text-left">
<div className="flex items-center justify-between"><Tag tone={KIND_TONE[i.kind]}>{t(`kinds.${i.kind}`)}</Tag><StatusChip kind={WF_KIND[i.kind]} state={i.state} /></div>
<p className="mt-2 font-medium">{i.description}</p>
<div className="mt-1 flex justify-between text-sm"><span className="text-muted-foreground">{name(i.requester)} · {i.reference}</span><MoneyText className="font-bold" amount={i.amount} currency={i.currency} /></div>
</button>
</li>
))}
{!loading && rows.length === 0 && <EmptyState title={t('empty.title')} description={t('empty.description')} />}
</ul>
{p.children}
</div>
);
}
  • Step 6: Record drawer and proof dialog
// web/src/features/approvals/ProofDialog.tsx — for a move into a payout-semantic advance state
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Dialog } from '@/components/ui/Dialog';
import { Field } from '@/components/ui/Field';
import { DocumentUploader, type UploadedDoc } from '@/components/upload';
export function ProofDialog(p: { title: string; busy: boolean; onCancel(): void; onConfirm(proofId?: string): void }) {
const { t } = useTranslation('approvals');
const [doc, setDoc] = useState<UploadedDoc[]>([]);
return (
<Dialog open onOpenChange={(o: boolean) => !o && p.onCancel()} title={p.title} description={t('proof.description')}
footer={<><Button variant="outline" onClick={p.onCancel}>{t('cancel')}</Button><Button loading={p.busy} onClick={() => p.onConfirm(doc[0]?.id)}>{t('proof.confirm')}</Button></>}>
<Field label={t('proof.label')} hint={t('proof.hint')}>
<DocumentUploader multiple={false} value={doc} onUploaded={(d) => setDoc([d])} onRemoved={() => setDoc([])} />
</Field>
</Dialog>
);
}
web/src/features/approvals/RecordDrawer.tsx
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Drawer } from '@/components/ui/Drawer';
import { EmptyState } from '@/components/ui/EmptyState';
import { Field } from '@/components/ui/Field';
import { IconButton } from '@/components/ui/IconButton';
import { Select } from '@/components/ui/Select';
import { Skeleton } from '@/components/ui/Skeleton';
import { toast } from '@/components/ui/Toaster';
import { MoneyText } from '@/components/money';
import { DecisionTimeline, StatusChip, TransitionBar, useNeighbours, useWorkflowDefinition } from '@/components/workflow';
import { DocumentThumb } from '@/components/upload';
import { useCan, useMe } from '@/features/auth';
import { useMemberNames } from '@/features/members/useMemberNames';
import { ProblemError, problemMessage } from '@/lib/api/problem';
import type { AvailableTransition, Schemas } from '@/lib/api/types';
import { formatDate } from '@/lib/date';
import { pickLabel, useLocale } from '@/lib/i18n';
import { useAdvanceTransition, useAdvanceView, useSpendReview, useSpendTransition, useSpendView, useTopUpTransition, useTopUpView } from './api';
import { ProofDialog } from './ProofDialog';
import type { QueueKind } from './types';
type Decision = { from: string; to: string; actor_member_id: string; decided_at: string; reason?: string };
const toTimeline = (d: Decision[]) => d.map((x) => ({ ...x, action_key: '' })); // R4
export function RecordDrawer(p: { kind: Exclude<QueueKind, 'claim'>; id: string; items: { id: string }[]; onNavigate(id: string): void; onClose(): void }) {
const { t } = useTranslation('approvals');
const nav = useNeighbours(p.items, p.id);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const el = e.target as HTMLElement | null;
if (el && ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName)) return;
if (e.key === 'ArrowLeft' && nav.prev) p.onNavigate(nav.prev);
if (e.key === 'ArrowRight' && nav.next) p.onNavigate(nav.next);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [nav.prev, nav.next, p]);
const header = (
<div className="flex items-start justify-between gap-3">
<h2 className="text-lg font-bold">{t(`drawer.${p.kind}`)}</h2>
{nav.index >= 0 && nav.total > 1 && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<IconButton aria-label={t('prev')} disabled={!nav.prev} onClick={() => nav.prev && p.onNavigate(nav.prev)}><ChevronLeft className="size-4" /></IconButton>
<span>{nav.index + 1} / {nav.total}</span>
<IconButton aria-label={t('next')} disabled={!nav.next} onClick={() => nav.next && p.onNavigate(nav.next)}><ChevronRight className="size-4" /></IconButton>
</div>
)}
</div>
);
return (
<Drawer open onOpenChange={(o: boolean) => !o && p.onClose()} header={header} footer={<Button className="w-full" onClick={p.onClose}>{t('close')}</Button>}>
{p.kind === 'spend' && <SpendBody id={p.id} />}
{p.kind === 'topup' && <TopUpBody id={p.id} />}
{p.kind === 'advance' && <AdvanceBody id={p.id} />}
</Drawer>
);
}
function useDecide() {
const { t } = useTranslation('approvals');
const { t: tc } = useTranslation();
return async (fn: () => Promise<unknown>, refetch: () => void) => {
try {
await fn();
toast.success(t('done'));
} catch (e) {
toast.error(problemMessage(e, tc));
if (e instanceof ProblemError && e.status === 409) refetch();
}
};
}
function Loading({ q }: { q: { isLoading: boolean; error: unknown } }) {
const { t } = useTranslation('approvals');
const { t: tc } = useTranslation();
if (q.isLoading) return <Skeleton className="h-72 w-full" />;
if (q.error) return <EmptyState title={q.error instanceof ProblemError && q.error.status === 404 ? t('notFound') : problemMessage(q.error, tc)} />;
return null;
}
function Facts({ rows }: { rows: [string, React.ReactNode][] }) {
return (
<dl className="divide-y divide-border rounded-xl border border-border bg-card p-4 text-sm">
{rows.map(([k, v]) => <div key={k} className="grid grid-cols-[8rem_1fr] py-2"><dt className="text-muted-foreground">{k}</dt><dd>{v}</dd></div>)}
</dl>
);
}
function SpendBody({ id }: { id: string }) {
const { t } = useTranslation('approvals');
const [locale] = useLocale();
const name = useMemberNames();
const { data: me } = useMe();
const canReview = useCan('pettycash.spend.review');
const q = useSpendView(id);
const def = useWorkflowDefinition('petty_cash_spend');
const move = useSpendTransition(id);
const review = useSpendReview(id);
const decide = useDecide();
const [outcome, setOutcome] = useState('');
if (!q.data) return <Loading q={q} />;
const s = q.data.spend;
// Nobody reviews their own spend (PC7); the review is open while the spend is recorded (accrual semantic).
const showReview = canReview && s.holder_member_id !== me?.member_id && s.semantic === 'accrual';
return (
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between"><h3 className="text-xl font-bold">{s.description}</h3><StatusChip kind="petty_cash_spend" version={def.data?.version} state={s.state} /></div>
<MoneyText className="text-2xl font-bold" amount={s.amount} currency={s.currency} />
<TransitionBar transitions={q.data.available_transitions} layout="bar"
onTake={(tr: AvailableTransition, reason?: string) => decide(() => move.mutateAsync({ from: s.state, to: tr.to, reason }), () => void q.refetch())} />
<Facts rows={[
[t('facts.reference'), s.reference], [t('facts.holder'), name(s.holder_member_id)], [t('facts.date'), formatDate(s.expense_date, locale)],
[t('facts.outcome'), s.outcome ? t(`outcomes.${s.outcome}`) : t('outcomes.none')],
]} />
{s.receipt_document_id && <DocumentThumb documentId={s.receipt_document_id} />}
{showReview && (
<div className="flex items-end gap-2">
<Field label={t('review.label')} htmlFor="rv-outcome">
<Select id="rv-outcome" aria-label={t('review.label')} value={outcome} onChange={setOutcome} placeholder={t('review.placeholder')}
options={(['queried', 'verified', 'not_reimbursable'] as const).map((o) => ({ value: o, label: t(`outcomes.${o}`) }))} />
</Field>
<Button disabled={!outcome} loading={review.isPending}
onClick={() => decide(() => review.mutateAsync({ outcome: outcome as 'verified', revision: s.revision }), () => void q.refetch())}>
{t('review.submit')}
</Button>
</div>
)}
{def.data && <DecisionTimeline kind="petty_cash_spend" version={def.data.version} currentState={s.state} decisions={toTimeline(q.data.decisions)} />}
</div>
);
}
function TopUpBody({ id }: { id: string }) {
const { t } = useTranslation('approvals');
const name = useMemberNames();
const q = useTopUpView(id);
const def = useWorkflowDefinition('float_topup');
const move = useTopUpTransition(id);
const decide = useDecide();
if (!q.data) return <Loading q={q} />;
const u = q.data.topup;
return (
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between"><h3 className="text-xl font-bold">{u.reason}</h3><StatusChip kind="float_topup" version={def.data?.version} state={u.state} /></div>
<MoneyText className="text-2xl font-bold" amount={u.amount} currency={u.currency} />
<TransitionBar transitions={q.data.available_transitions} layout="bar"
onTake={(tr: AvailableTransition, reason?: string) => decide(() => move.mutateAsync({ from: u.state, to: tr.to, reason }), () => void q.refetch())} />
<Facts rows={[
[t('facts.reference'), u.reference], [t('facts.requester'), name(u.requested_by)], [t('facts.purpose'), t(`purposes.${u.purpose}`)],
...(u.purpose === 'replenishment' ? [[t('facts.lines'), String(q.data.replenishment_lines.length)] as [string, string]] : []),
]} />
{def.data && <DecisionTimeline kind="float_topup" version={def.data.version} currentState={u.state} decisions={toTimeline(q.data.decisions)} />}
</div>
);
}
function AdvanceBody({ id }: { id: string }) {
const { t } = useTranslation('approvals');
const [locale] = useLocale();
const name = useMemberNames();
const q = useAdvanceView(id);
if (!q.data) return <Loading q={q} />;
const a = q.data.advance;
return (
<div className="flex flex-col gap-4">
<h3 className="text-xl font-bold">{a.purpose}</h3>
<MoneyText className="text-2xl font-bold" amount={a.amount} currency={a.currency} />
<Facts rows={[[t('facts.reference'), a.reference], [t('facts.requester'), name(a.claimant_member_id)], [t('facts.due'), formatDate(a.due_on, locale)]]} />
{q.data.legs.map((leg) => <LegPanel key={leg.kind} advanceId={id} leg={leg} refetch={() => void q.refetch()} />)}
</div>
);
}
function LegPanel({ advanceId, leg, refetch }: { advanceId: string; leg: Schemas['AdvanceLeg']; refetch(): void }) {
const { t } = useTranslation('approvals');
const [locale] = useLocale();
const def = useWorkflowDefinition(leg.kind);
const move = useAdvanceTransition(advanceId);
const decide = useDecide();
const [payTarget, setPayTarget] = useState<AvailableTransition | null>(null);
const take = async (tr: AvailableTransition, reason?: string) => {
if (def.data?.graph.states.find((s) => s.key === tr.to)?.semantic === 'payout') return setPayTarget(tr);
await decide(() => move.mutateAsync({ kind: leg.kind, from: leg.state, to: tr.to, reason }), refetch);
};
return (
<section className="flex flex-col gap-3 rounded-xl border border-border p-4" aria-label={t(`legs.${leg.kind}`)}>
<div className="flex items-center justify-between"><h4 className="font-medium">{t(`legs.${leg.kind}`)}</h4><StatusChip kind={leg.kind} version={def.data?.version} state={leg.state} semantic={leg.semantic} /></div>
<TransitionBar transitions={leg.available_transitions} onTake={take} layout="bar" />
{def.data && <DecisionTimeline kind={leg.kind} version={def.data.version} currentState={leg.state} decisions={toTimeline(leg.decisions)} />}
{payTarget && (
<ProofDialog title={pickLabel(payTarget.labels, locale)} busy={move.isPending} onCancel={() => setPayTarget(null)}
onConfirm={async (proof) => { await decide(() => move.mutateAsync({ kind: leg.kind, from: leg.state, to: payTarget.to, proof_document_id: proof ?? null }), refetch); setPayTarget(null); }} />
)}
</section>
);
}
  • Step 7: Routes and nav
web/src/features/approvals/routes.tsx
import { createRoute, Outlet } from '@tanstack/react-router';
import { appRoute } from '@/app/layouts';
import { ClaimDrawer } from '@/features/claims/detail/ClaimDrawer';
import { ApprovalsPage } from './ApprovalsPage';
import { toQueueItems, useApprovalQueues } from './api';
import { RecordDrawer } from './RecordDrawer';
import { approvalsSearchSchema, type ApprovalsSearch } from './search';
import type { QueueKind } from './types';
const approvalsRoute = createRoute({
getParentRoute: () => appRoute,
path: 'approvals',
validateSearch: (s: Record<string, unknown>) => approvalsSearchSchema.parse(s),
component: function ApprovalsRoute() {
const search = approvalsRoute.useSearch();
const navigate = approvalsRoute.useNavigate();
return (
<ApprovalsPage
search={search}
onKind={(kind) => void navigate({ search: { kind }, replace: true })}
onOpen={(i) => void navigate({ to: '/approvals/$kind/$id', params: { kind: i.kind, id: i.id }, search: (p: ApprovalsSearch) => p })}
>
<Outlet />
</ApprovalsPage>
);
},
});
const approvalDetailRoute = createRoute({
getParentRoute: () => approvalsRoute,
path: '$kind/$id',
component: function ApprovalDetailRoute() {
const { kind, id } = approvalDetailRoute.useParams() as { kind: QueueKind; id: string };
const navigate = approvalDetailRoute.useNavigate();
const items = toQueueItems(useApprovalQueues()).filter((i) => i.kind === kind);
const go = (next: string) => void navigate({ to: '/approvals/$kind/$id', params: { kind, id: next }, search: (p: ApprovalsSearch) => p });
const close = () => void navigate({ to: '/approvals', search: (p: ApprovalsSearch) => p });
if (kind === 'claim') return <ClaimDrawer id={id} items={items} onNavigate={go} onClose={close} />;
if (kind === 'advance' || kind === 'spend' || kind === 'topup') return <RecordDrawer kind={kind} id={id} items={items} onNavigate={go} onClose={close} />;
return null;
},
});
export const routes = [approvalsRoute.addChildren([approvalDetailRoute])] as const;

Append to web/src/app/routes.ts: import { routes as approvalsRoutes } from '@/features/approvals/routes'; / ...approvalsRoutes,. Append to web/src/app/shell/nav.ts:

import { ListChecks } from 'lucide-react';
import { useApprovalsCount } from '@/features/approvals/useApprovalsCount';
{ group: 'approvals', to: '/approvals', labelKey: 'approvals:nav', icon: ListChecks,
permission: ['expenses.claim.transition', 'advances.advance.transition', 'pettycash.transition'], badge: useApprovalsCount },
  • Step 8: i18n bundles

en/approvals.json

{
"nav": "Approvals",
"title": "Approvals",
"subtitle": "Everything waiting for your decision",
"chips": { "all": "All", "claim": "Claims", "advance": "Advances", "spend": "Petty cash spends", "topup": "Petty cash top-ups" },
"kinds": { "claim": "Claim", "advance": "Advance", "spend": "Petty cash spend", "topup": "Petty cash top-up" },
"columns": { "type": "Type", "reference": "Reference", "description": "Description", "requester": "Requested by", "amount": "Amount", "status": "Status" },
"empty": { "title": "Nothing to approve", "description": "Requests waiting for you appear here." },
"drawer": { "advance": "Advance", "spend": "Petty cash spend", "topup": "Petty cash top-up" },
"prev": "Previous", "next": "Next", "close": "Close", "cancel": "Cancel", "done": "Done",
"notFound": "This record was not found, or you cannot see it.",
"facts": { "reference": "Reference", "holder": "Float holder", "requester": "Requested by", "date": "Expense date", "outcome": "Review", "purpose": "Type", "lines": "Outflows restored", "due": "Due" },
"outcomes": { "none": "Not reviewed yet", "queried": "Queried", "verified": "Verified", "not_reimbursable": "Not reimbursable" },
"purposes": { "topup": "Top-up", "replenishment": "Replenishment" },
"review": { "label": "Review outcome", "placeholder": "Choose an outcome", "submit": "Record review" },
"legs": { "advance": "Advance", "advance_topup": "Top-up of the excess", "advance_refund": "Refund of the unspent" },
"proof": { "description": "Attach proof of payment if this payout method needs one.", "label": "Proof of payment", "hint": "JPG, PNG or PDF", "confirm": "Confirm payment" }
}

ms/approvals.json

{
"nav": "Kelulusan",
"title": "Kelulusan",
"subtitle": "Semua yang menunggu keputusan anda",
"chips": { "all": "Semua", "claim": "Tuntutan", "advance": "Pendahuluan", "spend": "Perbelanjaan wang runcit", "topup": "Tambahan wang runcit" },
"kinds": { "claim": "Tuntutan", "advance": "Pendahuluan", "spend": "Perbelanjaan wang runcit", "topup": "Tambahan wang runcit" },
"columns": { "type": "Jenis", "reference": "Rujukan", "description": "Keterangan", "requester": "Dipohon oleh", "amount": "Amaun", "status": "Status" },
"empty": { "title": "Tiada untuk diluluskan", "description": "Permohonan yang menunggu anda dipaparkan di sini." },
"drawer": { "advance": "Pendahuluan", "spend": "Perbelanjaan wang runcit", "topup": "Tambahan wang runcit" },
"prev": "Sebelumnya", "next": "Seterusnya", "close": "Tutup", "cancel": "Batal", "done": "Selesai",
"notFound": "Rekod ini tidak ditemui, atau anda tidak boleh melihatnya.",
"facts": { "reference": "Rujukan", "holder": "Pemegang wang runcit", "requester": "Dipohon oleh", "date": "Tarikh perbelanjaan", "outcome": "Semakan", "purpose": "Jenis", "lines": "Aliran keluar dipulihkan", "due": "Tarikh akhir" },
"outcomes": { "none": "Belum disemak", "queried": "Dipersoalkan", "verified": "Disahkan", "not_reimbursable": "Tidak boleh dituntut" },
"purposes": { "topup": "Tambahan", "replenishment": "Penambahan semula" },
"review": { "label": "Keputusan semakan", "placeholder": "Pilih keputusan", "submit": "Rekod semakan" },
"legs": { "advance": "Pendahuluan", "advance_topup": "Tambahan lebihan", "advance_refund": "Bayaran balik baki" },
"proof": { "description": "Lampirkan bukti pembayaran jika kaedah ini memerlukannya.", "label": "Bukti pembayaran", "hint": "JPG, PNG atau PDF", "confirm": "Sahkan pembayaran" }
}

zh/approvals.json

{
"nav": "我的审批",
"title": "我的审批",
"subtitle": "所有等待您处理的申请",
"chips": { "all": "全部", "claim": "报销", "advance": "预支款", "spend": "备用金支出", "topup": "申请备用金" },
"kinds": { "claim": "报销", "advance": "预支款", "spend": "备用金支出", "topup": "申请备用金" },
"columns": { "type": "类型", "reference": "编号", "description": "说明", "requester": "申请人", "amount": "金额", "status": "状态" },
"empty": { "title": "暂无待审批", "description": "等待您处理的申请会显示在这里。" },
"drawer": { "advance": "预支款详情", "spend": "备用金支出详情", "topup": "备用金申请详情" },
"prev": "上一条", "next": "下一条", "close": "关闭", "cancel": "取消", "done": "已完成",
"notFound": "找不到这条记录,或您无权查看。",
"facts": { "reference": "编号", "holder": "备用金持有人", "requester": "申请人", "date": "费用日期", "outcome": "审核", "purpose": "类型", "lines": "补回的支出笔数", "due": "到期日" },
"outcomes": { "none": "尚未审核", "queried": "有疑问", "verified": "已核实", "not_reimbursable": "不予报销" },
"purposes": { "topup": "申请备用金", "replenishment": "补充备用金" },
"review": { "label": "审核结果", "placeholder": "选择结果", "submit": "提交审核" },
"legs": { "advance": "预支款", "advance_topup": "超支补付", "advance_refund": "余款退还" },
"proof": { "description": "如该收款方式需要,请附上付款凭证。", "label": "付款凭证", "hint": "JPG、PNG 或 PDF", "confirm": "确认付款" }
}
  • Step 9: Run the tests

Run: pnpm -C web test -- src/features/approvals src/drift Expected: PASS. Then typecheck and lint.

  • Step 10: Runtime check

Dev stack and preview; the seed must leave one of each record waiting for the seeded approver (a pending claim, a pending advance, a recorded spend, a requested top-up). At 1440, light, en, as the approver:

  1. The sidebar’s Approvals item shows 4. Take one decision in another tab; within 60 s, or on refocusing this tab, the badge drops.
  2. /approvals: four rows with their type tags, requester names (no raw uuids), amounts and chips. Click Advances: URL ?kind=advance, one row.
  3. Open the claim row: Task 12’s drawer; ← / → stay within claims. Close returns to the queue with the filter intact.
  4. Open the spend: Pay from float disabled with the “not verified” tooltip; choose Verified, Record review; the button becomes enabled (the refreshed available_transitions), click it; the row leaves the queue.
  5. Open the top-up: Mark reviewed; the row updates.
  6. Open the advance: the Advance leg’s buttons; a move into Paid out opens the proof dialog.
  7. As a member holding only claim permissions: no advances/petty cash requests in the network tab. Then 390px (cards, drawer full-screen), dark, zh (我的审批, 报销 / 预支款 / 备用金支出 / 申请备用金).
  • Step 11: Commit
Terminal window
git add web/src/features/approvals web/src/lib/i18n/locales/*/approvals.json web/src/app/routes.ts web/src/app/shell/nav.ts
git commit -m "$(cat <<'MSG'
Plan 10 Task 15: approvals queue (claims, advances, petty cash to_act; drawers; nav badge)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MSG
)"

Task 16: Petty cash — page, request float, return float, record spend

Section titled “Task 16: Petty cash — page, request float, return float, record spend”

Figma: 407:17221 (page), 426:18791 (申请备用金 dialog), 446:20155 (归还备用金 dialog). Pull get_design_context for each.

What the frames show. Page: breadcrumb 申请 / 备用金; title 备用金 with the subtitle 申请和管理备用金,并归还未使用的现金。; at the right 归还备用金 (outline) and + 申请备用金 (purple). Three KPI cards: 待审核 (amber, “3 笔” and “RM 31.00”), 授权项目备用金总额度 (lilac, “RM 28,000.00”, “共3个授权项目”), 可用余额 (light blue, “RM 15,800.00”, a progress bar and “可用 56.4%”). A two-card row: 待审批 (“以下是待您审批的 备用金申请。”) — a table 日期 · 申请金额 · 用途说明 · 操作 with 批准 (green outline) and 驳回 (red outline) per row; and 按项目查看备用金使用 — per project: 项目名称, 已使用/额度, 使用率 with a bar, 可用余额. Then 备用金流水记录: chips 全部 6 · 待审核 3 · 已审核 3 · 已批准 3 · 已出款 3 · 已拒绝 1 · 归还 1, a search box 搜索申请编号,说明…, a month select, 筛选, 导出; a table 日期 (date + time) · 类型 (pill: 申请备用金 purple, 归还备用金 blue, 已支出 red) · 说明 · 金额 (green + RM 68.40, red - RM 650.00) · 状态 (chip) · 申请编号 · 操作 ⋮. 申请备用金 dialog (540px): 申请金额 (RM prefix), 项目, 预计使用日期, 代申请员工, 用途说明, 补充资料 (upload); footer 保存草稿 / 提交申请. 归还备用金 dialog: 关联备用金申请 (select “CLM-2026-0012 | 2026-08-06”), 项目, 退还金额 (RM prefix with 全部退还 link), 退还日期, 退还方式 (three radio cards as in the wizard), 设为默认退还方式, 管理收款资料, 上传凭证 (upload), 备注; footer 保存草稿 / 提交申请.

Departures, per spec §5.4 and the API (gaps in the Part C notes): floats are per holder, not per project, so the lilac “project total” card and the per-project table become the selected float’s Cash balance (with committed under it) and, for custodians, a Floats table (code, holder, balance, committed, available, status). The progress bar and percentage are not drawn — they would be a ratio of two amounts (G6). The amber card is In progress: the count of my requests on this float in a non-terminal state (no total — that would be a client sum, G6). The “to approve” table shows spends and top-ups that wait for me (scope=to_act, S5), with the buttons from each record’s available_transitions inline. The bottom area has two tabs: Requests (spends and top-ups on this float, with status chips — the Figma chips live here, by state) and Cash movements (the float’s ledger; a movement has no status, so its row shows the status of the request it mirrors when that request is on the page). No export (F4). Request float has only amount and purpose: a top-up is filed straight into review (its first state is requested; there is no draft to save), and it has no project, date, on-behalf or attachment field. Return float returns the whole cash balance — the amount is fixed by the server — so it shows the float and the amount read-only, and asks only for the refund proof. Record spend (a button next to Request float) is added: a spend needs an expense account, which a holder cannot list, so the form offers the expense categories that are mapped to an account and sends that account (G8).

Files:

  • Create: web/src/features/pettycash/api.ts
  • Create: web/src/features/pettycash/search.ts
  • Create: web/src/features/pettycash/pickRecordTransition.ts
  • Create: web/src/features/pettycash/PettyCashPage.tsx
  • Create: web/src/features/pettycash/FloatKpis.tsx
  • Create: web/src/features/pettycash/ToApproveTable.tsx
  • Create: web/src/features/pettycash/FloatsTable.tsx
  • Create: web/src/features/pettycash/RequestsTable.tsx
  • Create: web/src/features/pettycash/MovementLedger.tsx
  • Create: web/src/features/pettycash/RequestFloatDialog.tsx
  • Create: web/src/features/pettycash/ReturnFloatDialog.tsx
  • Create: web/src/features/pettycash/RecordSpendDialog.tsx
  • Create: web/src/features/pettycash/SpendDrawer.tsx
  • Create: web/src/features/pettycash/TopUpDrawer.tsx
  • Create: web/src/features/pettycash/RecordLanguageHint.tsx
  • Create: web/src/features/pettycash/routes.tsx
  • Create: web/src/features/pettycash/__tests__/fixtures.ts
  • Create: web/src/features/pettycash/__tests__/PettyCashPage.test.tsx
  • Create: web/src/features/pettycash/__tests__/dialogs.test.tsx
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/pettycash.json
  • Modify (append): web/src/app/routes.ts, web/src/app/shell/nav.ts

Interfaces:

  • Consumes: api, newIdempotencyKey, unwrap, ProblemError, problemMessage, Schemas; useWorkflowDefinition, StatusChip, TransitionBar, DecisionTimeline; useMemberNames; DocumentUploader, DocumentThumb, UploadedDoc; MoneyText, MoneyInput; isMoneyInput; useMe, useCan; pickLabel, useLocale; formatDate, formatDateTime, todayISO; appRoute; ui as in Tasks 11–15.

  • Produces: pettyCashKeys (R6): { all: ['pettycash'], floats: (scope) => ['pettycash','floats',scope], movements: (floatId) => ['pettycash','movements',floatId], spends: (p) => ['pettycash','spends',p], topups: (p) => ['pettycash','topups',p], spend: (id) => ['pettycash','spend',id], topup: (id) => ['pettycash','topup',id] }. Task 26 (float register) consumes them. Routes /petty-cash, /petty-cash/spends/$id, /petty-cash/topups/$id.

  • Step 0: Verify imports (C1) and that Task 4 shipped scope=to_act on listPettyCashSpends / listPettyCashTopUps: grep -n "to_act" web/src/lib/api/schema.d.ts | head.

  • Step 1: Fixtures

web/src/features/pettycash/__tests__/fixtures.ts
import type { Schemas } from '@/lib/api/types';
export { spend, topup, spendDefinition, topupDefinition, tr } from '@/features/approvals/__tests__/fixtures';
export const ME = '00000000-0000-4000-8000-000000000001';
export const FLOAT_ID = '00000000-0000-4000-8000-0000000f1a01';
export const OTHER_FLOAT = '00000000-0000-4000-8000-0000000f1a02';
export const CAT_OFFICE = '00000000-0000-4000-8000-00000000c010';
export const ACC_OFFICE = '00000000-0000-4000-8000-0000000ac010';
export function float(over: Partial<Schemas['PettyCashFloat']> = {}): Schemas['PettyCashFloat'] {
return {
id: FLOAT_ID, code: 'PF-2026-0001', holder_member_id: ME, holder_name: 'Alex Tan', account_id: 'a', currency: 'MYR',
cash_balance: '15800.0000', committed: '31.0000', available: '15769.0000', status: 'open', refund_proof_document_id: null,
opened_on: '2026-01-02', closed_on: null, ...over,
};
}
export const closeoutDefinition: Schemas['WorkflowDefinition'] = {
id: 'def-closeout', kind: 'float_closeout', version: 1, published_at: '2026-08-01T00:00:00Z',
graph: { kind: 'float_closeout', posting_order: 'cash_only', edges: [], grants: [], states: [
{ key: 'requested', semantic: 'draft', display_order: 1, is_terminal: false, labels: { en: 'Requested' } },
{ key: 'confirmed', semantic: 'payout', display_order: 2, is_terminal: true, labels: { en: 'Confirmed' } },
] },
};
export const officeCategory: Schemas['Category'] = {
id: CAT_OFFICE, key: 'office', name: 'Office supplies', label: 'Office supplies', names: { en: 'Office supplies', ms: 'Bekalan pejabat', zh: '办公用品' },
account_id: ACC_OFFICE, employee_named_document_allowed: false, active: true, sort_order: 1,
};

The fixtures import Task 15’s; both are group F. If Task 16 is built before Task 15 has merged, copy those five exports into this file instead of re-exporting them.

  • Step 2: Write the failing tests
web/src/features/pettycash/__tests__/PettyCashPage.test.tsx
import { describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/msw/server';
import { renderWithApp } from '@/test/renderWithApp';
import { closeoutDefinition, float, FLOAT_ID, officeCategory, OTHER_FLOAT, spend, spendDefinition, topup, topupDefinition, tr } from './fixtures';
const HOLDER = ['pettycash.read', 'pettycash.float.disburse', 'pettycash.transition'];
function mockApi(seen: string[] = []) {
server.use(
http.get('*/v1/workflow/definitions/:kind', ({ params }) =>
HttpResponse.json({ petty_cash_spend: spendDefinition, float_topup: topupDefinition, float_closeout: closeoutDefinition }[params.kind as string])),
http.get('*/v1/expenses/categories', () => HttpResponse.json({ categories: [officeCategory] })),
http.get('*/v1/pettycash/floats', ({ request }) => {
const scope = new URL(request.url).searchParams.get('scope') ?? 'mine';
seen.push(`floats:${scope}`);
return HttpResponse.json({ floats: scope === 'all' ? [float(), float({ id: OTHER_FLOAT, code: 'PF-2026-0002', holder_name: 'Siti Aminah', holder_member_id: 'x' })] : [float()] });
}),
http.get('*/v1/pettycash/floats/:id/movements', () =>
HttpResponse.json({ movements: [
{ id: 'm1', occurred_at: '2026-08-10T02:10:00Z', amount: '500.0000', kind: 'topup', source_type: 'float_topup', source_id: topup().id, journal_entry_id: 'j1', reverses_movement_id: null },
{ id: 'm2', occurred_at: '2026-08-06T02:10:00Z', amount: '-650.0000', kind: 'spend_payout', source_type: 'petty_cash_spend', source_id: spend().id, journal_entry_id: 'j2', reverses_movement_id: null },
] })),
http.get('*/v1/pettycash/spends', ({ request }) => {
const scope = new URL(request.url).searchParams.get('scope');
return HttpResponse.json({ spends: scope === 'to_act' ? [spend({ id: 'to-act-spend', reference: 'PS-2026-0009', description: 'Buy a mouse' })] : [spend({ state: 'spend_paid', semantic: 'payout', amount: '650.0000', description: 'IT equipment' })] });
}),
http.get('*/v1/pettycash/topups', ({ request }) => {
const scope = new URL(request.url).searchParams.get('scope');
return HttpResponse.json({ topups: scope === 'to_act' ? [] : [topup({ state: 'paid', semantic: 'payout' }), topup({ id: 'open-topup', reference: 'PT-2026-0003', state: 'requested', semantic: 'draft' })] });
}),
http.get('*/v1/pettycash/spends/:id', ({ params }) =>
HttpResponse.json({ spend: spend({ id: params.id as string, description: 'Buy a mouse', holder_member_id: 'x' }), available_transitions: [tr('spend_paid', 'Pay from float', { gate: 'reporter' }), tr('not_reimbursable', 'Not reimbursable', { requires_reason: true })], decisions: [] })),
);
}
describe('PettyCashPage', () => {
it('shows the float figures exactly as the server sent them, and a count (not a total) of open requests', async () => {
mockApi();
await renderWithApp({ path: '/petty-cash', permissions: HOLDER });
const cash = await screen.findByRole('group', { name: 'Cash balance' });
expect(within(cash).getByText('RM 15,800.00')).toBeInTheDocument();
expect(cash).toHaveTextContent('Committed RM 31.00');
expect(within(screen.getByRole('group', { name: 'Available' })).getByText('RM 15,769.00')).toBeInTheDocument();
const open = screen.getByRole('group', { name: 'In progress' });
await waitFor(() => expect(within(open).getByText('1')).toBeInTheDocument()); // only the requested top-up is non-terminal
});
it('shows the float picker and floats table only to a custodian (presence first)', async () => {
const seen: string[] = [];
mockApi(seen);
const first = await renderWithApp({ path: '/petty-cash', permissions: [...HOLDER, 'pettycash.read_all'] });
expect(await screen.findByRole('combobox', { name: 'Float' })).toBeInTheDocument();
expect(await screen.findByText('Siti Aminah')).toBeInTheDocument();
expect(seen).toContain('floats:all');
first.unmount();
const seen2: string[] = [];
mockApi(seen2);
await renderWithApp({ path: '/petty-cash', permissions: HOLDER });
await screen.findByRole('group', { name: 'Cash balance' });
expect(screen.queryByRole('combobox', { name: 'Float' })).not.toBeInTheDocument();
expect(seen2).not.toContain('floats:all');
});
it('lists what waits for me with the buttons from available_transitions', async () => {
mockApi();
await renderWithApp({ path: '/petty-cash', permissions: HOLDER });
const table = await screen.findByRole('region', { name: 'To approve' });
const row = (await within(table).findByText('Buy a mouse')).closest('tr')!;
expect(await within(row).findByRole('button', { name: 'Pay from float' })).toBeInTheDocument();
expect(within(row).getByRole('button', { name: 'Not reimbursable' })).toBeInTheDocument();
});
it('signs movement amounts and names the request each one mirrors', async () => {
mockApi();
const { user } = await renderWithApp({ path: '/petty-cash', permissions: HOLDER });
await user.click(await screen.findByRole('tab', { name: 'Cash movements' }));
const out = (await screen.findByText('IT equipment')).closest('tr')!;
expect(within(out).getByText('-RM 650.00')).toBeInTheDocument();
expect(within(out).getByText('Spend paid')).toBeInTheDocument(); // the movement-kind tag
expect(within(out).getByText('Paid from float')).toBeInTheDocument(); // the mirrored spend's status chip
const into = screen.getByText('PT-2026-0002').closest('tr')!;
expect(within(into).getByText('+RM 500.00')).toBeInTheDocument();
});
});

formatMoney(…, { sign: 'always' }) decides the exact sign glyph and spacing; if Task 6 renders + RM 500.00 or RM +500.00, change the two strings above to match — the assertion is that a sign is shown, not its typography.

web/src/features/pettycash/__tests__/dialogs.test.tsx
import { describe, expect, it, vi } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/msw/server';
import { renderWithApp } from '@/test/renderWithApp';
import type { Schemas } from '@/lib/api/types';
import { ACC_OFFICE, closeoutDefinition, float, FLOAT_ID, officeCategory, spend, spendDefinition, topup, topupDefinition, tr } from './fixtures';
const DOC = '00000000-0000-4000-8000-0000000d0c55';
vi.mock('@/components/upload', async (orig) => ({
...(await orig<typeof import('@/components/upload')>()),
DocumentUploader: (p: { onUploaded(d: { id: string; filename: string; size: number; content_type: string }): void }) => (
<button type="button" onClick={() => p.onUploaded({ id: DOC, filename: 'proof.jpg', size: 1000, content_type: 'image/jpeg' })}>Fake upload</button>
),
}));
type Call = { method: string; path: string; body: unknown; ifMatch: string | null; idem: string | null };
const HOLDER = ['pettycash.read', 'pettycash.float.disburse', 'pettycash.transition'];
function mockApi(calls: Call[]) {
const cap = async (method: string, request: Request) => {
const text = await request.text();
calls.push({ method, path: new URL(request.url).pathname, body: text ? JSON.parse(text) : undefined, ifMatch: request.headers.get('If-Match'), idem: request.headers.get('Idempotency-Key') });
};
const draft = spend({ state: 'draft', semantic: 'draft', revision: 1, receipt_document_id: null, holder_member_id: '00000000-0000-4000-8000-000000000001' });
const recordEdge: Schemas['WorkflowAvailableTransition'] = tr('recorded', 'Record', { gate: 'reporter' });
server.use(
http.get('*/v1/workflow/definitions/:kind', ({ params }) =>
HttpResponse.json({ petty_cash_spend: spendDefinition, float_topup: topupDefinition, float_closeout: closeoutDefinition }[params.kind as string])),
http.get('*/v1/expenses/categories', () => HttpResponse.json({ categories: [officeCategory] })),
http.get('*/v1/pettycash/floats', () => HttpResponse.json({ floats: [float()] })),
http.get('*/v1/pettycash/floats/:id/movements', () => HttpResponse.json({ movements: [] })),
http.get('*/v1/pettycash/spends', () => HttpResponse.json({ spends: [] })),
http.get('*/v1/pettycash/topups', () => HttpResponse.json({ topups: [] })),
http.post('*/v1/pettycash/topups', async ({ request }) => { await cap('POST', request); return HttpResponse.json({ topup: topup(), replenishment_lines: [], available_transitions: [], decisions: [] }, { status: 201 }); }),
http.post('*/v1/pettycash/floats/:id/closeouts', async ({ request }) => {
await cap('POST', request);
return HttpResponse.json({ closeout: { id: 'c1', reference: 'PX-2026-0001', float_id: FLOAT_ID, requested_by: 'me', amount: '15800.0000', currency: 'MYR', proof_document_id: DOC, state: 'requested', semantic: 'draft' }, available_transitions: [], decisions: [] }, { status: 201 });
}),
http.post('*/v1/pettycash/spends', async ({ request }) => { await cap('POST', request); return HttpResponse.json({ spend: draft, available_transitions: [recordEdge], decisions: [] }, { status: 201 }); }),
http.put('*/v1/pettycash/spends/:id/receipt', async ({ request }) => { await cap('PUT', request); return HttpResponse.json({ spend: { ...draft, receipt_document_id: DOC, revision: 2 }, available_transitions: [recordEdge], decisions: [] }); }),
http.post('*/v1/pettycash/spends/:id/transitions', async ({ request }) => { await cap('POST', request); return HttpResponse.json({ spend: { ...draft, state: 'recorded', semantic: 'accrual', revision: 3 }, available_transitions: [], decisions: [] }); }),
);
}
describe('petty cash dialogs', () => {
it('requests a top-up into my float with the amount string as typed', async () => {
const calls: Call[] = [];
mockApi(calls);
const { user } = await renderWithApp({ path: '/petty-cash', permissions: HOLDER });
await user.click(await screen.findByRole('button', { name: 'Request float' }));
const dialog = await screen.findByRole('dialog', { name: 'Request float' });
await user.type(within(dialog).getByLabelText(/Amount/), '150.00');
await user.type(within(dialog).getByRole('textbox', { name: 'Purpose' }), 'Cleaning supplies for August');
await user.click(within(dialog).getByRole('button', { name: 'Submit request' }));
await waitFor(() => expect(calls[0]).toMatchObject({ method: 'POST', path: '/v1/pettycash/topups', body: { float_id: FLOAT_ID, amount: '150.00', reason: 'Cleaning supplies for August' } }));
});
it('returns the float only once a refund proof is attached', async () => {
const calls: Call[] = [];
mockApi(calls);
const { user } = await renderWithApp({ path: '/petty-cash', permissions: HOLDER });
await user.click(await screen.findByRole('button', { name: 'Return float' }));
const dialog = await screen.findByRole('dialog', { name: 'Return float' });
expect(within(dialog).getByText('RM 15,800.00')).toBeInTheDocument();
const submit = within(dialog).getByRole('button', { name: 'Submit return' });
expect(submit).toBeDisabled();
await user.click(within(dialog).getByRole('button', { name: 'Fake upload' }));
expect(submit).toBeEnabled();
await user.click(submit);
await waitFor(() => expect(calls[0]).toMatchObject({ path: `/v1/pettycash/floats/${FLOAT_ID}/closeouts`, body: { proof_document_id: DOC } }));
});
it('records a spend: create, bind the receipt at revision 1, then take the draft-leaving transition', async () => {
const calls: Call[] = [];
mockApi(calls);
const { user } = await renderWithApp({ path: '/petty-cash', permissions: HOLDER });
await user.click(await screen.findByRole('button', { name: 'Record spend' }));
const dialog = await screen.findByRole('dialog', { name: 'Record spend' });
await user.type(within(dialog).getByRole('textbox', { name: 'Description' }), 'Printer paper');
await user.type(within(dialog).getByLabelText(/Amount/), '45.90');
await user.click(within(dialog).getByRole('combobox', { name: 'Category' }));
await user.click(await screen.findByRole('option', { name: 'Office supplies' }));
await user.click(within(dialog).getByRole('combobox', { name: 'Receipt evidence' }));
await user.click(await screen.findByRole('option', { name: 'Receipt without e-Invoice' }));
await user.type(within(dialog).getByRole('textbox', { name: 'Why there is no e-Invoice' }), 'Stationery shop');
await user.click(within(dialog).getByRole('button', { name: 'Fake upload' }));
await user.click(within(dialog).getByRole('button', { name: 'Record spend' }));
await waitFor(() => expect(calls.map((c) => `${c.method} ${c.path.replace(/[0-9a-f-]{36}/, ':id')}`)).toEqual([
'POST /v1/pettycash/spends', 'PUT /v1/pettycash/spends/:id/receipt', 'POST /v1/pettycash/spends/:id/transitions',
]));
expect(calls[0].body).toMatchObject({ float_id: FLOAT_ID, amount: '45.90', expense_account_id: ACC_OFFICE, description: 'Printer paper', document_basis: 'relaxation_receipt' });
expect(calls[1]).toMatchObject({ ifMatch: '"1"', body: { document_id: DOC } });
expect(calls[2]).toMatchObject({ body: { from: 'draft', to: 'recorded' } });
expect(calls[2].idem).toMatch(/^[0-9a-f-]{36}$/);
});
});
  • Step 3: Run them and watch them fail

Run: pnpm -C web test -- src/features/pettycash Expected: FAIL — no /petty-cash route.

  • Step 4: API, search, helpers
web/src/features/pettycash/api.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api, newIdempotencyKey } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
import type { Schemas } from '@/lib/api/types';
export type Float = Schemas['PettyCashFloat'];
export type SpendView = Schemas['PettyCashSpendView'];
export type TopUpView = Schemas['PettyCashTopUpView'];
export const pettyCashKeys = {
all: ['pettycash'] as const,
floats: (scope: 'mine' | 'all') => ['pettycash', 'floats', scope] as const,
movements: (floatId: string) => ['pettycash', 'movements', floatId] as const,
spends: (p: { scope: 'mine' | 'all' | 'to_act'; float_id?: string }) => ['pettycash', 'spends', p] as const,
topups: (p: { scope: 'mine' | 'all' | 'to_act'; float_id?: string }) => ['pettycash', 'topups', p] as const,
spend: (id: string) => ['pettycash', 'spend', id] as const,
topup: (id: string) => ['pettycash', 'topup', id] as const,
};
export function useFloats(scope: 'mine' | 'all', enabled = true) {
return useQuery({ queryKey: pettyCashKeys.floats(scope), enabled, queryFn: async ({ signal }) => unwrap(await api.GET('/pettycash/floats', { params: { query: { scope } }, signal })), select: (d) => d.floats });
}
export function useMovements(floatId: string | undefined) {
return useQuery({ queryKey: pettyCashKeys.movements(floatId ?? ''), enabled: Boolean(floatId),
queryFn: async ({ signal }) => unwrap(await api.GET('/pettycash/floats/{id}/movements', { params: { path: { id: floatId! } }, signal })), select: (d) => d.movements });
}
export function useSpends(p: { scope: 'mine' | 'all' | 'to_act'; float_id?: string }, enabled = true) {
return useQuery({ queryKey: pettyCashKeys.spends(p), enabled, queryFn: async ({ signal }) => unwrap(await api.GET('/pettycash/spends', { params: { query: p }, signal })), select: (d) => d.spends });
}
export function useTopUps(p: { scope: 'mine' | 'all' | 'to_act'; float_id?: string }, enabled = true) {
return useQuery({ queryKey: pettyCashKeys.topups(p), enabled, queryFn: async ({ signal }) => unwrap(await api.GET('/pettycash/topups', { params: { query: p }, signal })), select: (d) => d.topups });
}
export function useSpendView(id: string) {
return useQuery({ queryKey: pettyCashKeys.spend(id), queryFn: async ({ signal }) => unwrap(await api.GET('/pettycash/spends/{id}', { params: { path: { id } }, signal })) });
}
export function useTopUpView(id: string) {
return useQuery({ queryKey: pettyCashKeys.topup(id), queryFn: async ({ signal }) => unwrap(await api.GET('/pettycash/topups/{id}', { params: { path: { id } }, signal })) });
}
function useInvalidate() {
const qc = useQueryClient();
return () => {
void qc.invalidateQueries({ queryKey: pettyCashKeys.all });
void qc.invalidateQueries({ queryKey: ['approvals'] });
};
}
export function useSpendTransition(id: string) {
const qc = useQueryClient();
const invalidate = useInvalidate();
return useMutation({
mutationFn: async (body: Schemas['PettyCashTransitionInput']): Promise<SpendView> =>
unwrap(await api.POST('/pettycash/spends/{id}/transitions', { params: { path: { id }, header: { 'Idempotency-Key': newIdempotencyKey() } }, body })),
onSuccess: (v) => { qc.setQueryData(pettyCashKeys.spend(id), v); invalidate(); },
});
}
export function useTopUpTransition(id: string) {
const qc = useQueryClient();
const invalidate = useInvalidate();
return useMutation({
mutationFn: async (body: Schemas['PettyCashTransitionInput']): Promise<TopUpView> =>
unwrap(await api.POST('/pettycash/topups/{id}/transitions', { params: { path: { id }, header: { 'Idempotency-Key': newIdempotencyKey() } }, body })),
onSuccess: (v) => { qc.setQueryData(pettyCashKeys.topup(id), v); invalidate(); },
});
}
export function useSpendReview(id: string) {
const qc = useQueryClient();
const invalidate = useInvalidate();
return useMutation({
mutationFn: async (v: { outcome: NonNullable<Schemas['ReviewerOutcome']>; revision: number }): Promise<SpendView> =>
unwrap(await api.POST('/pettycash/spends/{id}/review', { params: { path: { id }, header: { 'If-Match': `"${v.revision}"` } }, body: { outcome: v.outcome } })),
onSuccess: (v) => { qc.setQueryData(pettyCashKeys.spend(id), v); invalidate(); },
});
}
export function useRequestTopUp() {
const invalidate = useInvalidate();
return useMutation({ mutationFn: async (body: Schemas['PettyCashTopUpInput']) => unwrap(await api.POST('/pettycash/topups', { body })), onSuccess: invalidate });
}
export function useRequestCloseout() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: async (v: { floatId: string; proof_document_id: string }) =>
unwrap(await api.POST('/pettycash/floats/{id}/closeouts', { params: { path: { id: v.floatId } }, body: { proof_document_id: v.proof_document_id } })),
onSuccess: invalidate,
});
}
/** Create → bind receipt (If-Match) → record. Each step's answer carries the next revision. */
export function useSpendWrites() {
const invalidate = useInvalidate();
return {
create: async (body: Schemas['PettyCashSpendInput']): Promise<SpendView> => unwrap(await api.POST('/pettycash/spends', { body })),
bindReceipt: async (v: SpendView, documentId: string): Promise<SpendView> =>
unwrap(await api.PUT('/pettycash/spends/{id}/receipt', { params: { path: { id: v.spend.id }, header: { 'If-Match': `"${v.spend.revision}"` } }, body: { document_id: documentId } })),
transition: async (v: SpendView, to: string): Promise<SpendView> =>
unwrap(await api.POST('/pettycash/spends/{id}/transitions', { params: { path: { id: v.spend.id }, header: { 'Idempotency-Key': newIdempotencyKey() } }, body: { from: v.spend.state, to } })),
invalidate,
};
}
web/src/features/pettycash/pickRecordTransition.ts
import type { AvailableTransition, WorkflowDefinition } from '@/lib/api/types';
const NOT_FORWARD = new Set(['draft', 'cancelled', 'rejected']);
/** The exit that takes a draft spend forward (seeded: draft → recorded), found by the target's semantic. */
export function pickRecordTransition(view: { available_transitions: AvailableTransition[] }, def: WorkflowDefinition | undefined) {
if (!def) return undefined;
const sem = new Map(def.graph.states.map((s) => [s.key, s.semantic]));
return view.available_transitions.find((t) => { const s = sem.get(t.to); return s !== undefined && !NOT_FORWARD.has(s); });
}
web/src/features/pettycash/search.ts
import { z } from 'zod';
export const pettyCashSearchSchema = z.object({
float: z.string().uuid().optional().catch(undefined),
tab: z.enum(['requests', 'movements']).catch('requests'),
});
export type PettyCashSearch = z.infer<typeof pettyCashSearchSchema>;
  • Step 5: KPIs, tables, ledger
web/src/features/pettycash/FloatKpis.tsx
import { useTranslation } from 'react-i18next';
import { Clock, Wallet, Coins } from 'lucide-react';
import { StatCard } from '@/components/ui/StatCard';
import { MoneyText } from '@/components/money';
import type { Float } from './api';
export function FloatKpis({ float, openCount }: { float?: Float; openCount?: number }) {
const { t } = useTranslation('pettycash');
const cards = [
{ key: 'open', tone: 'amber', icon: Clock, primary: openCount === undefined ? '–' : String(openCount), secondary: t('kpi.openHint') },
{ key: 'cash', tone: 'purple', icon: Coins, primary: float ? <MoneyText amount={float.cash_balance} currency={float.currency} /> : '–',
secondary: float ? <span>{t('kpi.committed')} <MoneyText amount={float.committed} currency={float.currency} /></span> : undefined },
{ key: 'available', tone: 'blue', icon: Wallet, primary: float ? <MoneyText amount={float.available} currency={float.currency} /> : '–', secondary: float?.code },
] as const;
return (
<section className="grid gap-3 md:grid-cols-3" aria-label={t('kpi.label')}>
{cards.map((c) => (
<div key={c.key} role="group" aria-label={t(`kpi.${c.key}`)}>
<StatCard tone={c.tone} icon={c.icon} label={t(`kpi.${c.key}`)} primary={c.primary} secondary={c.secondary} />
</div>
))}
</section>
);
}
web/src/features/pettycash/ToApproveTable.tsx
import { useTranslation } from 'react-i18next';
import { toast } from '@/components/ui/Toaster';
import { MoneyText } from '@/components/money';
import { StatusChip, TransitionBar } from '@/components/workflow';
import { useMemberNames } from '@/features/members/useMemberNames';
import { ProblemError, problemMessage } from '@/lib/api/problem';
import type { AvailableTransition, Schemas } from '@/lib/api/types';
import { useSpendTransition, useSpendView, useTopUpTransition, useTopUpView } from './api';
export function ToApproveTable({ spends, topups }: { spends: Schemas['PettyCashSpend'][]; topups: Schemas['PettyCashTopUp'][] }) {
const { t } = useTranslation('pettycash');
if (spends.length + topups.length === 0) return null;
return (
<section aria-labelledby="pc-to-approve" className="rounded-xl border border-border bg-card">
<div className="p-4">
<h2 id="pc-to-approve" className="font-bold">{t('toApprove.title')}</h2>
<p className="text-xs text-muted-foreground">{t('toApprove.subtitle')}</p>
</div>
<table className="w-full text-sm">
<thead className="bg-accent text-left">
<tr><th className="px-4 py-2">{t('columns.reference')}</th><th>{t('columns.requester')}</th><th>{t('columns.amount')}</th><th>{t('columns.description')}</th><th>{t('columns.status')}</th><th>{t('columns.actions')}</th></tr>
</thead>
<tbody>
{spends.map((s) => <SpendRow key={s.id} id={s.id} />)}
{topups.map((u) => <TopUpRow key={u.id} id={u.id} />)}
</tbody>
</table>
</section>
);
}
function useTake() {
const { t: tc } = useTranslation();
return async (fn: () => Promise<unknown>, refetch: () => void) => {
try { await fn(); } catch (e) { toast.error(problemMessage(e, tc)); if (e instanceof ProblemError && e.status === 409) refetch(); }
};
}
function SpendRow({ id }: { id: string }) {
const q = useSpendView(id);
const move = useSpendTransition(id);
const name = useMemberNames();
const take = useTake();
if (!q.data) return null;
const s = q.data.spend;
return (
<tr className="border-t border-border">
<td className="px-4 py-2 font-mono">{s.reference}</td><td>{name(s.holder_member_id)}</td>
<td><MoneyText amount={s.amount} currency={s.currency} /></td><td>{s.description}</td>
<td><StatusChip kind="petty_cash_spend" state={s.state} /></td>
<td className="py-2"><TransitionBar layout="inline" transitions={q.data.available_transitions}
onTake={(tr: AvailableTransition, reason?: string) => take(() => move.mutateAsync({ from: s.state, to: tr.to, reason }), () => void q.refetch())} /></td>
</tr>
);
}
function TopUpRow({ id }: { id: string }) {
const q = useTopUpView(id);
const move = useTopUpTransition(id);
const name = useMemberNames();
const take = useTake();
if (!q.data) return null;
const u = q.data.topup;
return (
<tr className="border-t border-border">
<td className="px-4 py-2 font-mono">{u.reference}</td><td>{name(u.requested_by)}</td>
<td><MoneyText amount={u.amount} currency={u.currency} /></td><td>{u.reason}</td>
<td><StatusChip kind="float_topup" state={u.state} /></td>
<td className="py-2"><TransitionBar layout="inline" transitions={q.data.available_transitions}
onTake={(tr: AvailableTransition, reason?: string) => take(() => move.mutateAsync({ from: u.state, to: tr.to, reason }), () => void q.refetch())} /></td>
</tr>
);
}
// web/src/features/pettycash/FloatsTable.tsx — custodians (pettycash.read_all or float.manage)
import { useTranslation } from 'react-i18next';
import { Tag } from '@/components/ui/Tag';
import { MoneyText } from '@/components/money';
import { cn } from '@/lib/cn';
import type { Float } from './api';
const STATUS_TONE = { open: 'green', closing: 'amber', closed: 'muted' } as const;
export function FloatsTable({ floats, selected, onSelect }: { floats: Float[]; selected?: string; onSelect(id: string): void }) {
const { t } = useTranslation('pettycash');
return (
<section aria-labelledby="pc-floats" className="rounded-xl border border-border bg-card">
<h2 id="pc-floats" className="p-4 font-bold">{t('floats.title')}</h2>
<table className="w-full text-sm">
<thead className="bg-accent text-left"><tr>
<th className="px-4 py-2">{t('floats.code')}</th><th>{t('floats.holder')}</th><th>{t('floats.balance')}</th><th>{t('floats.committed')}</th><th>{t('floats.available')}</th><th>{t('floats.status')}</th>
</tr></thead>
<tbody>
{floats.map((f) => (
<tr key={f.id} aria-selected={f.id === selected} onClick={() => onSelect(f.id)} className={cn('cursor-pointer border-t border-border', f.id === selected && 'bg-accent')}>
<td className="px-4 py-2 font-mono">{f.code}</td><td>{f.holder_name}</td>
<td><MoneyText amount={f.cash_balance} currency={f.currency} /></td><td><MoneyText amount={f.committed} currency={f.currency} /></td>
<td><MoneyText amount={f.available} currency={f.currency} /></td><td><Tag tone={STATUS_TONE[f.status]}>{t(`floatStatus.${f.status}`)}</Tag></td>
</tr>
))}
</tbody>
</table>
</section>
);
}
web/src/features/pettycash/RequestsTable.tsx
import { useTranslation } from 'react-i18next';
import { EmptyState } from '@/components/ui/EmptyState';
import { Tag } from '@/components/ui/Tag';
import { MoneyText } from '@/components/money';
import { StatusChip } from '@/components/workflow';
import type { Schemas } from '@/lib/api/types';
import { formatDate } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
export function RequestsTable(p: { spends: Schemas['PettyCashSpend'][]; topups: Schemas['PettyCashTopUp'][]; onOpen(kind: 'spend' | 'topup', id: string): void }) {
const { t } = useTranslation('pettycash');
const [locale] = useLocale();
if (p.spends.length + p.topups.length === 0) return <EmptyState title={t('requests.empty')} />;
return (
<table className="w-full text-sm">
<thead className="bg-accent text-left"><tr>
<th className="px-4 py-2">{t('columns.type')}</th><th>{t('columns.reference')}</th><th>{t('columns.description')}</th><th>{t('columns.amount')}</th><th>{t('columns.status')}</th>
</tr></thead>
<tbody>
{p.topups.map((u) => (
<tr key={u.id} className="cursor-pointer border-t border-border" onClick={() => p.onOpen('topup', u.id)}>
<td className="px-4 py-2"><Tag tone="purple">{t(`types.${u.purpose}`)}</Tag></td><td className="font-mono">{u.reference}</td><td>{u.reason}</td>
<td><MoneyText amount={u.amount} currency={u.currency} /></td><td><StatusChip kind="float_topup" state={u.state} /></td>
</tr>
))}
{p.spends.map((s) => (
<tr key={s.id} className="cursor-pointer border-t border-border" onClick={() => p.onOpen('spend', s.id)}>
<td className="px-4 py-2"><Tag tone="red">{t('types.spend')}</Tag></td><td className="font-mono">{s.reference}</td>
<td>{s.description}<span className="block text-xs text-muted-foreground">{formatDate(s.expense_date, locale)}</span></td>
<td><MoneyText amount={s.amount} currency={s.currency} /></td><td><StatusChip kind="petty_cash_spend" state={s.state} /></td>
</tr>
))}
</tbody>
</table>
);
}
web/src/features/pettycash/MovementLedger.tsx
import { useTranslation } from 'react-i18next';
import { EmptyState } from '@/components/ui/EmptyState';
import { Tag } from '@/components/ui/Tag';
import { MoneyText } from '@/components/money';
import { StatusChip } from '@/components/workflow';
import type { Schemas } from '@/lib/api/types';
import { formatDateTime } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
const KIND_TONE: Record<Schemas['FloatMovementKind'], 'purple' | 'red' | 'blue' | 'muted' | 'green'> = {
topup: 'purple', topup_reversal: 'muted', spend_payout: 'red', spend_payout_reversal: 'muted',
claim_settlement: 'green', claim_settlement_reversal: 'muted', closeout: 'blue',
};
/**
* A movement carries only its source id (gap G6). The request it mirrors is looked up among the
* spends and top-ups already on the page; a close-out or claim settlement shows its kind only.
*/
export function MovementLedger(p: { movements: Schemas['FloatMovement'][]; currency: string; spends: Schemas['PettyCashSpend'][]; topups: Schemas['PettyCashTopUp'][] }) {
const { t } = useTranslation('pettycash');
const [locale] = useLocale();
if (p.movements.length === 0) return <EmptyState title={t('movements.empty')} />;
return (
<table className="w-full text-sm">
<thead className="bg-accent text-left"><tr>
<th className="px-4 py-2">{t('columns.date')}</th><th>{t('columns.type')}</th><th>{t('columns.description')}</th><th>{t('columns.amount')}</th><th>{t('columns.status')}</th><th>{t('columns.reference')}</th>
</tr></thead>
<tbody>
{p.movements.map((m) => {
const s = p.spends.find((x) => x.id === m.source_id);
const u = p.topups.find((x) => x.id === m.source_id);
const out = m.amount.startsWith('-'); // the sign of the server's string, not arithmetic
return (
<tr key={m.id} className="border-t border-border">
<td className="px-4 py-2 whitespace-pre-line">{formatDateTime(m.occurred_at, locale)}</td>
<td><Tag tone={KIND_TONE[m.kind]}>{t(`movementKinds.${m.kind}`)}</Tag></td>
<td>{s?.description ?? u?.reason ?? '—'}</td>
<td className={out ? 'font-bold text-destructive' : 'font-bold text-success'}><MoneyText amount={m.amount} currency={p.currency} sign="always" /></td>
<td>{s ? <StatusChip kind="petty_cash_spend" state={s.state} /> : u ? <StatusChip kind="float_topup" state={u.state} /> : '—'}</td>
<td className="font-mono">{s?.reference ?? u?.reference ?? '—'}</td>
</tr>
);
})}
</tbody>
</table>
);
}
  • Step 6: Dialogs
// web/src/features/pettycash/RecordLanguageHint.tsx — C6
import { useTranslation } from 'react-i18next';
import { useLocale } from '@/lib/i18n';
export function RecordLanguageHint() {
const { t } = useTranslation('pettycash');
const [locale] = useLocale();
return locale === 'zh' ? <p className="text-xs text-muted-foreground">{t('recordLanguageHint')}</p> : null;
}
web/src/features/pettycash/RequestFloatDialog.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Dialog } from '@/components/ui/Dialog';
import { Field } from '@/components/ui/Field';
import { Textarea } from '@/components/ui/Textarea';
import { toast } from '@/components/ui/Toaster';
import { MoneyInput } from '@/components/money';
import { ProblemError, problemMessage } from '@/lib/api/problem';
import { isMoneyInput } from '@/lib/money';
import { useRequestTopUp, type Float } from './api';
import { RecordLanguageHint } from './RecordLanguageHint';
export function RequestFloatDialog({ float, onClose }: { float: Float; onClose(): void }) {
const { t } = useTranslation('pettycash');
const { t: tc } = useTranslation();
const req = useRequestTopUp();
const [amount, setAmount] = useState('');
const [reason, setReason] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
async function submit() {
const e: Record<string, string> = {};
if (!isMoneyInput(amount) || /^0*(\.0*)?$/.test(amount)) e.amount = t('errors.amount');
if (!reason.trim()) e.reason = t('errors.reason');
setErrors(e);
if (Object.keys(e).length) return;
try {
const v = await req.mutateAsync({ float_id: float.id, amount, reason: reason.trim() });
toast.success(t('request.done', { reference: v.topup.reference }));
onClose();
} catch (err) {
setErrors({ form: err instanceof ProblemError && err.detail ? err.detail : problemMessage(err, tc) });
}
}
return (
<Dialog open onOpenChange={(o: boolean) => !o && onClose()} title={t('request.title')} description={t('request.description')}
footer={<><Button variant="outline" onClick={onClose}>{t('cancel')}</Button><Button onClick={submit} loading={req.isPending}>{t('request.submit')}</Button></>}>
<div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">{t('request.into', { code: float.code })}</p>
<Field label={t('fields.amount')} htmlFor="rq-amount" error={errors.amount} required>
<MoneyInput id="rq-amount" value={amount} onChange={setAmount} currency={float.currency} invalid={Boolean(errors.amount)} />
</Field>
<Field label={t('fields.purpose')} htmlFor="rq-reason" error={errors.reason} required>
<Textarea id="rq-reason" rows={3} placeholder={t('placeholders.purpose')} value={reason} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setReason(e.target.value)} />
</Field>
<RecordLanguageHint />
{errors.form && <p role="alert" className="text-sm text-destructive">{errors.form}</p>}
</div>
</Dialog>
);
}
// web/src/features/pettycash/ReturnFloatDialog.tsx — frame 446:20155, reduced to what a close-out takes
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Dialog } from '@/components/ui/Dialog';
import { Field } from '@/components/ui/Field';
import { toast } from '@/components/ui/Toaster';
import { MoneyText } from '@/components/money';
import { DocumentUploader, type UploadedDoc } from '@/components/upload';
import { ProblemError, problemMessage } from '@/lib/api/problem';
import { useRequestCloseout, type Float } from './api';
export function ReturnFloatDialog({ float, onClose }: { float: Float; onClose(): void }) {
const { t } = useTranslation('pettycash');
const { t: tc } = useTranslation();
const req = useRequestCloseout();
const [proof, setProof] = useState<UploadedDoc[]>([]);
const [error, setError] = useState<string | null>(null);
async function submit() {
setError(null);
try {
const v = await req.mutateAsync({ floatId: float.id, proof_document_id: proof[0].id });
toast.success(t('return.done', { reference: v.closeout.reference }));
onClose();
} catch (e) {
setError(e instanceof ProblemError && e.detail ? e.detail : problemMessage(e, tc));
}
}
return (
<Dialog open onOpenChange={(o: boolean) => !o && onClose()} title={t('return.title')} description={t('return.description')}
footer={<><Button variant="outline" onClick={onClose}>{t('cancel')}</Button><Button onClick={submit} loading={req.isPending} disabled={proof.length === 0}>{t('return.submit')}</Button></>}>
<dl className="grid grid-cols-[9rem_1fr] gap-y-2 text-sm">
<dt className="text-muted-foreground">{t('return.float')}</dt><dd className="font-mono">{float.code}</dd>
<dt className="text-muted-foreground">{t('return.amount')}</dt><dd><MoneyText className="font-bold" amount={float.cash_balance} currency={float.currency} /></dd>
</dl>
<p className="mt-2 text-xs text-muted-foreground">{t('return.amountHint')}</p>
<div className="mt-4">
<Field label={t('return.proof')} hint={t('return.proofHint')} required>
<DocumentUploader multiple={false} value={proof} onUploaded={(d) => setProof([d])} onRemoved={() => setProof([])} />
</Field>
</div>
{error && <p role="alert" className="mt-3 text-sm text-destructive">{error}</p>}
</Dialog>
);
}
web/src/features/pettycash/RecordSpendDialog.tsx
import { useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { DatePicker } from '@/components/ui/DatePicker';
import { Dialog } from '@/components/ui/Dialog';
import { Field } from '@/components/ui/Field';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Textarea } from '@/components/ui/Textarea';
import { toast } from '@/components/ui/Toaster';
import { MoneyInput } from '@/components/money';
import { useWorkflowDefinition } from '@/components/workflow';
import { DocumentUploader, type UploadedDoc } from '@/components/upload';
import { api } from '@/lib/api/client';
import { ProblemError, problemMessage, unwrap } from '@/lib/api/problem';
import type { Schemas } from '@/lib/api/types';
import { todayISO } from '@/lib/date';
import { pickLabel, useLocale } from '@/lib/i18n';
import { isMoneyInput } from '@/lib/money';
import { useSpendWrites, type Float, type SpendView } from './api';
import { pickRecordTransition } from './pickRecordTransition';
import { RecordLanguageHint } from './RecordLanguageHint';
const BASES = ['einvoice_company', 'foreign_supplier', 'relaxation_receipt'] as const; // PettyCashDocumentBasis
export function RecordSpendDialog({ float, onClose }: { float: Float; onClose(): void }) {
const { t } = useTranslation('pettycash');
const { t: tc } = useTranslation();
const [locale] = useLocale();
const def = useWorkflowDefinition('petty_cash_spend');
const writes = useSpendWrites();
// G8: a holder cannot list accounts; a category mapped to an account names the expense account.
const cats = useQuery({ queryKey: ['expenses', 'categories'], queryFn: async ({ signal }) => unwrap(await api.GET('/expenses/categories', { signal })),
select: (d) => d.categories.filter((c) => c.active && c.account_id) });
const [v, setV] = useState({ expense_date: todayISO(), description: '', amount: '', category: '', basis: '', supplier: '', relaxation: '', einvoice: '' });
const [receipt, setReceipt] = useState<UploadedDoc[]>([]);
const [errors, setErrors] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const made = useRef<SpendView | null>(null); // a retry continues the spend already created, never files a second one
const set = (k: keyof typeof v) => (x: string) => setV((s) => ({ ...s, [k]: x }));
async function save(record: boolean) {
const e: Record<string, string> = {};
if (!v.description.trim()) e.description = t('errors.description');
if (!isMoneyInput(v.amount) || /^0*(\.0*)?$/.test(v.amount)) e.amount = t('errors.amount');
if (!v.category) e.category = t('errors.category');
if (record && !v.basis) e.basis = t('errors.basis');
if (record && receipt.length === 0) e.receipt = t('errors.receipt');
setErrors(e);
if (Object.keys(e).length) return;
setBusy(true);
try {
const account = cats.data!.find((c) => c.id === v.category)!.account_id!;
const body: Schemas['PettyCashSpendInput'] = {
float_id: float.id, expense_date: v.expense_date, description: v.description.trim(), amount: v.amount, expense_account_id: account,
document_basis: (v.basis || null) as Schemas['PettyCashSpendInput']['document_basis'], supplier_name: v.supplier.trim() || null,
relaxation_reason: v.basis === 'relaxation_receipt' ? v.relaxation.trim() || null : null,
einvoice_uuid: v.basis === 'einvoice_company' ? v.einvoice.trim() || null : null,
};
let view = made.current ?? (made.current = await writes.create(body));
if (receipt[0] && view.spend.receipt_document_id !== receipt[0].id) view = made.current = await writes.bindReceipt(view, receipt[0].id);
if (record) {
const tr = pickRecordTransition(view, def.data);
if (!tr || !tr.allowed) throw new Error(tr?.refusal?.detail ?? t('errors.cannotRecord'));
view = made.current = await writes.transition(view, tr.to);
}
writes.invalidate();
toast.success(record ? t('spend.recorded', { reference: view.spend.reference }) : t('spend.saved', { reference: view.spend.reference }));
onClose();
} catch (err) {
setErrors({ form: err instanceof ProblemError && err.detail ? err.detail : err instanceof Error && !(err instanceof ProblemError) ? err.message : problemMessage(err, tc) });
} finally {
setBusy(false);
}
}
return (
<Dialog open onOpenChange={(o: boolean) => !o && onClose()} title={t('spend.title')} description={t('spend.description', { code: float.code })}
footer={<><Button variant="outline" onClick={() => save(false)} disabled={busy}>{t('saveDraft')}</Button><Button onClick={() => save(true)} loading={busy}>{t('spend.submit')}</Button></>}>
<div className="grid gap-4 md:grid-cols-2">
<Field label={t('fields.date')} htmlFor="sp-date" required><DatePicker id="sp-date" value={v.expense_date} onChange={(x?: string) => set('expense_date')(x ?? todayISO())} /></Field>
<Field label={t('fields.amount')} htmlFor="sp-amount" error={errors.amount} required><MoneyInput id="sp-amount" value={v.amount} onChange={set('amount')} currency={float.currency} /></Field>
<div className="md:col-span-2">
<Field label={t('fields.description')} htmlFor="sp-desc" error={errors.description} required>
<Input id="sp-desc" value={v.description} onChange={(e: React.ChangeEvent<HTMLInputElement>) => set('description')(e.target.value)} />
</Field>
<RecordLanguageHint />
</div>
<Field label={t('fields.category')} htmlFor="sp-cat" error={errors.category} required>
<Select id="sp-cat" aria-label={t('fields.category')} value={v.category} onChange={set('category')} placeholder={t('placeholders.category')}
options={(cats.data ?? []).map((c: Schemas['Category']) => ({ value: c.id, label: pickLabel(c.names, locale) || c.name }))} />
</Field>
<Field label={t('fields.supplier')} htmlFor="sp-supplier"><Input id="sp-supplier" value={v.supplier} onChange={(e: React.ChangeEvent<HTMLInputElement>) => set('supplier')(e.target.value)} /></Field>
<Field label={t('fields.basis')} htmlFor="sp-basis" error={errors.basis} required>
<Select id="sp-basis" aria-label={t('fields.basis')} value={v.basis} onChange={set('basis')} placeholder={t('placeholders.basis')}
options={BASES.map((b) => ({ value: b, label: t(`basis.${b}`) }))} />
</Field>
{v.basis === 'einvoice_company' && <Field label={t('fields.einvoice')} htmlFor="sp-einv"><Input id="sp-einv" value={v.einvoice} onChange={(e: React.ChangeEvent<HTMLInputElement>) => set('einvoice')(e.target.value)} /></Field>}
{v.basis === 'relaxation_receipt' && (
<div className="md:col-span-2"><Field label={t('fields.relaxation')} htmlFor="sp-relax">
<Textarea id="sp-relax" rows={2} value={v.relaxation} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => set('relaxation')(e.target.value)} />
</Field></div>
)}
<div className="md:col-span-2">
<Field label={t('fields.receipt')} hint={t('hints.receipt')} error={errors.receipt} required>
<DocumentUploader multiple={false} value={receipt} onUploaded={(d) => setReceipt([d])} onRemoved={() => setReceipt([])} />
</Field>
</div>
</div>
{errors.form && <p role="alert" className="mt-3 text-sm text-destructive">{errors.form}</p>}
</Dialog>
);
}
  • Step 7: Drawers
web/src/features/pettycash/SpendDrawer.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Drawer } from '@/components/ui/Drawer';
import { EmptyState } from '@/components/ui/EmptyState';
import { Field } from '@/components/ui/Field';
import { Select } from '@/components/ui/Select';
import { Skeleton } from '@/components/ui/Skeleton';
import { toast } from '@/components/ui/Toaster';
import { MoneyText } from '@/components/money';
import { DecisionTimeline, StatusChip, TransitionBar, useWorkflowDefinition } from '@/components/workflow';
import { DocumentThumb } from '@/components/upload';
import { useCan, useMe } from '@/features/auth';
import { useMemberNames } from '@/features/members/useMemberNames';
import { ProblemError, problemMessage } from '@/lib/api/problem';
import type { AvailableTransition } from '@/lib/api/types';
import { formatDate } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import { useSpendReview, useSpendTransition, useSpendView } from './api';
export function SpendDrawer({ id, onClose }: { id: string; onClose(): void }) {
const { t } = useTranslation('pettycash');
const { t: tc } = useTranslation();
const [locale] = useLocale();
const name = useMemberNames();
const { data: me } = useMe();
const canReview = useCan('pettycash.spend.review');
const q = useSpendView(id);
const def = useWorkflowDefinition('petty_cash_spend');
const move = useSpendTransition(id);
const review = useSpendReview(id);
const [outcome, setOutcome] = useState('');
const run = async (fn: () => Promise<unknown>) => {
try { await fn(); toast.success(t('done')); } catch (e) { toast.error(problemMessage(e, tc)); if (e instanceof ProblemError && e.status === 409) void q.refetch(); }
};
const s = q.data?.spend;
return (
<Drawer open onOpenChange={(o: boolean) => !o && onClose()} header={<h2 className="text-lg font-bold">{t('spend.detailTitle')}</h2>}
footer={<Button className="w-full" onClick={onClose}>{t('close')}</Button>}>
{q.isLoading && <Skeleton className="h-72 w-full" />}
{q.error && <EmptyState title={q.error instanceof ProblemError && q.error.status === 404 ? t('notFound') : problemMessage(q.error, tc)} />}
{s && q.data && (
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between"><h3 className="text-xl font-bold">{s.description}</h3><StatusChip kind="petty_cash_spend" version={def.data?.version} state={s.state} /></div>
<MoneyText className="text-2xl font-bold" amount={s.amount} currency={s.currency} />
<TransitionBar transitions={q.data.available_transitions} layout="bar"
onTake={(tr: AvailableTransition, reason?: string) => run(() => move.mutateAsync({ from: s.state, to: tr.to, reason }))} />
<dl className="grid grid-cols-[8rem_1fr] gap-y-2 text-sm">
<dt className="text-muted-foreground">{t('columns.reference')}</dt><dd className="font-mono">{s.reference}</dd>
<dt className="text-muted-foreground">{t('fields.holder')}</dt><dd>{name(s.holder_member_id)}</dd>
<dt className="text-muted-foreground">{t('fields.date')}</dt><dd>{formatDate(s.expense_date, locale)}</dd>
<dt className="text-muted-foreground">{t('fields.review')}</dt><dd>{s.outcome ? t(`outcomes.${s.outcome}`) : t('outcomes.none')}</dd>
</dl>
{s.receipt_document_id ? <DocumentThumb documentId={s.receipt_document_id} /> : <p className="text-sm text-muted-foreground">{t('spend.noReceipt')}</p>}
{canReview && s.holder_member_id !== me?.member_id && s.semantic === 'accrual' && (
<div className="flex items-end gap-2">
<Field label={t('fields.review')} htmlFor="sd-outcome">
<Select id="sd-outcome" aria-label={t('fields.review')} value={outcome} onChange={setOutcome} placeholder={t('placeholders.outcome')}
options={(['queried', 'verified', 'not_reimbursable'] as const).map((o) => ({ value: o, label: t(`outcomes.${o}`) }))} />
</Field>
<Button disabled={!outcome} loading={review.isPending} onClick={() => run(() => review.mutateAsync({ outcome: outcome as 'verified', revision: s.revision }))}>{t('spend.recordReview')}</Button>
</div>
)}
{def.data && <DecisionTimeline kind="petty_cash_spend" version={def.data.version} currentState={s.state} decisions={q.data.decisions.map((d) => ({ ...d, action_key: '' }))} />}
</div>
)}
</Drawer>
);
}
web/src/features/pettycash/TopUpDrawer.tsx
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Drawer } from '@/components/ui/Drawer';
import { EmptyState } from '@/components/ui/EmptyState';
import { Skeleton } from '@/components/ui/Skeleton';
import { toast } from '@/components/ui/Toaster';
import { MoneyText } from '@/components/money';
import { DecisionTimeline, StatusChip, TransitionBar, useWorkflowDefinition } from '@/components/workflow';
import { useMemberNames } from '@/features/members/useMemberNames';
import { ProblemError, problemMessage } from '@/lib/api/problem';
import type { AvailableTransition } from '@/lib/api/types';
import { useTopUpTransition, useTopUpView } from './api';
export function TopUpDrawer({ id, onClose }: { id: string; onClose(): void }) {
const { t } = useTranslation('pettycash');
const { t: tc } = useTranslation();
const name = useMemberNames();
const q = useTopUpView(id);
const def = useWorkflowDefinition('float_topup');
const move = useTopUpTransition(id);
const u = q.data?.topup;
return (
<Drawer open onOpenChange={(o: boolean) => !o && onClose()} header={<h2 className="text-lg font-bold">{t('request.detailTitle')}</h2>}
footer={<Button className="w-full" onClick={onClose}>{t('close')}</Button>}>
{q.isLoading && <Skeleton className="h-72 w-full" />}
{q.error && <EmptyState title={q.error instanceof ProblemError && q.error.status === 404 ? t('notFound') : problemMessage(q.error, tc)} />}
{u && q.data && (
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between"><h3 className="text-xl font-bold">{u.reason}</h3><StatusChip kind="float_topup" version={def.data?.version} state={u.state} /></div>
<MoneyText className="text-2xl font-bold" amount={u.amount} currency={u.currency} />
<TransitionBar transitions={q.data.available_transitions} layout="bar" onTake={async (tr: AvailableTransition, reason?: string) => {
try { await move.mutateAsync({ from: u.state, to: tr.to, reason }); toast.success(t('done')); }
catch (e) { toast.error(problemMessage(e, tc)); if (e instanceof ProblemError && e.status === 409) void q.refetch(); }
}} />
<dl className="grid grid-cols-[8rem_1fr] gap-y-2 text-sm">
<dt className="text-muted-foreground">{t('columns.reference')}</dt><dd className="font-mono">{u.reference}</dd>
<dt className="text-muted-foreground">{t('columns.requester')}</dt><dd>{name(u.requested_by)}</dd>
<dt className="text-muted-foreground">{t('columns.type')}</dt><dd>{t(`types.${u.purpose}`)}</dd>
</dl>
{def.data && <DecisionTimeline kind="float_topup" version={def.data.version} currentState={u.state} decisions={q.data.decisions.map((d) => ({ ...d, action_key: '' }))} />}
</div>
)}
</Drawer>
);
}
  • Step 8: Page and routes
web/src/features/pettycash/PettyCashPage.tsx
import { useMemo, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { PageHeader } from '@/components/ui/PageHeader';
import { Select } from '@/components/ui/Select';
import { Tabs } from '@/components/ui/Tabs';
import { Tooltip } from '@/components/ui/Tooltip';
import { useWorkflowDefinition } from '@/components/workflow';
import { useCan } from '@/features/auth';
import { useFloats, useMovements, useSpends, useTopUps } from './api';
import { FloatKpis } from './FloatKpis';
import { FloatsTable } from './FloatsTable';
import { MovementLedger } from './MovementLedger';
import { RecordSpendDialog } from './RecordSpendDialog';
import { RequestFloatDialog } from './RequestFloatDialog';
import { RequestsTable } from './RequestsTable';
import { ReturnFloatDialog } from './ReturnFloatDialog';
import type { PettyCashSearch } from './search';
import { ToApproveTable } from './ToApproveTable';
export function PettyCashPage(p: { search: PettyCashSearch; onSearch(n: Partial<PettyCashSearch>): void; onOpen(kind: 'spend' | 'topup', id: string): void; children?: ReactNode }) {
const { t } = useTranslation('pettycash');
const isCustodian = useCan(['pettycash.read_all', 'pettycash.float.manage']);
const canDisburse = useCan('pettycash.float.disburse');
const canTransition = useCan('pettycash.transition');
const mine = useFloats('mine');
const all = useFloats('all', isCustodian);
const myFloat = mine.data?.find((f) => f.status !== 'closed');
const floatId = p.search.float ?? myFloat?.id ?? all.data?.[0]?.id;
const float = [...(all.data ?? []), ...(mine.data ?? [])].find((f) => f.id === floatId);
const isMine = Boolean(float && mine.data?.some((f) => f.id === float.id));
const scope = isCustodian ? 'all' : 'mine';
const spends = useSpends({ scope, float_id: floatId }, Boolean(floatId));
const topups = useTopUps({ scope, float_id: floatId }, Boolean(floatId));
const toActSpends = useSpends({ scope: 'to_act' }, canTransition);
const toActTopUps = useTopUps({ scope: 'to_act' }, canTransition);
const movements = useMovements(floatId);
const spendDef = useWorkflowDefinition('petty_cash_spend');
const topupDef = useWorkflowDefinition('float_topup');
const [dialog, setDialog] = useState<'request' | 'return' | 'spend' | null>(null);
// A count of non-terminal requests (integers only; F13 forbids a client total).
const openCount = useMemo(() => {
if (!spendDef.data || !topupDef.data || !spends.data || !topups.data) return undefined;
const terminal = (d: typeof spendDef.data, s: string) => d!.graph.states.find((x) => x.key === s)?.is_terminal ?? false;
return spends.data.filter((s) => !terminal(spendDef.data, s.state)).length + topups.data.filter((u) => !terminal(topupDef.data, u.state)).length;
}, [spendDef.data, topupDef.data, spends.data, topups.data]);
const canAct = isMine && float?.status === 'open' && canDisburse;
const disabledWhy = !float ? t('noFloat') : !isMine ? t('notYourFloat') : float.status !== 'open' ? t('floatNotOpen') : undefined;
const action = (key: 'request' | 'return' | 'spend', label: string, variant: 'primary' | 'outline' | 'secondary', icon?: ReactNode) => {
const btn = <Button variant={variant} disabled={!canAct} onClick={() => setDialog(key)}>{icon}{label}</Button>;
return canAct || !disabledWhy ? btn : <Tooltip content={disabledWhy}><span>{btn}</span></Tooltip>;
};
if (!mine.isLoading && !all.isLoading && !float) {
return (<><PageHeader title={t('title')} subtitle={t('subtitle')} /><EmptyState title={t('noFloat')} description={t('noFloatHint')} /></>);
}
return (
<div className="flex flex-col gap-4">
<PageHeader title={t('title')} subtitle={t('subtitle')} actions={
<div className="flex flex-wrap gap-2">
{action('return', t('return.button'), 'outline')}
{action('spend', t('spend.button'), 'secondary')}
{action('request', t('request.button'), 'primary', <Plus className="size-4" aria-hidden />)}
</div>
} />
{isCustodian && (
<div className="w-72">
<Select aria-label={t('floatPicker')} value={floatId ?? ''} onChange={(v: string) => p.onSearch({ float: v })}
options={(all.data ?? []).map((f) => ({ value: f.id, label: `${f.code} · ${f.holder_name}` }))} />
</div>
)}
<FloatKpis float={float} openCount={openCount} />
{float?.status === 'closing' && <p role="status" className="rounded-lg bg-warning/10 p-3 text-sm">{t('closingBanner')}</p>}
<ToApproveTable spends={toActSpends.data ?? []} topups={toActTopUps.data ?? []} />
{isCustodian && all.data && <FloatsTable floats={all.data} selected={floatId} onSelect={(id) => p.onSearch({ float: id })} />}
<section className="rounded-xl border border-border bg-card" aria-labelledby="pc-records">
<h2 id="pc-records" className="p-4 font-bold">{t('records')}</h2>
<div className="px-4"><Tabs value={p.search.tab} onChange={(v: string) => p.onSearch({ tab: v as PettyCashSearch['tab'] })}
items={[{ value: 'requests', label: t('tabs.requests') }, { value: 'movements', label: t('tabs.movements') }]} /></div>
{p.search.tab === 'requests'
? <RequestsTable spends={spends.data ?? []} topups={topups.data ?? []} onOpen={p.onOpen} />
: <MovementLedger movements={movements.data ?? []} currency={float?.currency ?? 'MYR'} spends={spends.data ?? []} topups={topups.data ?? []} />}
</section>
{float && dialog === 'request' && <RequestFloatDialog float={float} onClose={() => setDialog(null)} />}
{float && dialog === 'return' && <ReturnFloatDialog float={float} onClose={() => setDialog(null)} />}
{float && dialog === 'spend' && <RecordSpendDialog float={float} onClose={() => setDialog(null)} />}
{p.children}
</div>
);
}
web/src/features/pettycash/routes.tsx
import { createRoute, Outlet } from '@tanstack/react-router';
import { appRoute } from '@/app/layouts';
import { RequirePermission } from '@/features/auth';
import { PettyCashPage } from './PettyCashPage';
import { pettyCashSearchSchema, type PettyCashSearch } from './search';
import { SpendDrawer } from './SpendDrawer';
import { TopUpDrawer } from './TopUpDrawer';
const pettyCashRoute = createRoute({
getParentRoute: () => appRoute,
path: 'petty-cash',
validateSearch: (s: Record<string, unknown>) => pettyCashSearchSchema.parse(s),
component: function PettyCashRoute() {
const search = pettyCashRoute.useSearch();
const navigate = pettyCashRoute.useNavigate();
return (
<RequirePermission permission="pettycash.read">
<PettyCashPage search={search}
onSearch={(n) => void navigate({ search: (prev: PettyCashSearch) => ({ ...prev, ...n }), replace: true })}
onOpen={(kind, id) => void navigate({ to: kind === 'spend' ? '/petty-cash/spends/$id' : '/petty-cash/topups/$id', params: { id }, search: (p: PettyCashSearch) => p })}>
<Outlet />
</PettyCashPage>
</RequirePermission>
);
},
});
const close = (navigate: ReturnType<typeof pettyCashRoute.useNavigate>) => () => void navigate({ to: '/petty-cash', search: (p: PettyCashSearch) => p });
const spendRoute = createRoute({ getParentRoute: () => pettyCashRoute, path: 'spends/$id', component: function R() { const { id } = spendRoute.useParams(); return <SpendDrawer id={id} onClose={close(spendRoute.useNavigate())} />; } });
const topupRoute = createRoute({ getParentRoute: () => pettyCashRoute, path: 'topups/$id', component: function R() { const { id } = topupRoute.useParams(); return <TopUpDrawer id={id} onClose={close(topupRoute.useNavigate())} />; } });
export const routes = [pettyCashRoute.addChildren([spendRoute, topupRoute])] as const;

Append to web/src/app/routes.ts: import { routes as pettyCashRoutes } from '@/features/pettycash/routes'; / ...pettyCashRoutes,. Append to web/src/app/shell/nav.ts:

import { WalletCards } from 'lucide-react';
{ group: 'requests', to: '/petty-cash', labelKey: 'pettycash:nav', icon: WalletCards, permission: 'pettycash.read' },
  • Step 9: i18n bundles

en/pettycash.json

{
"nav": "Petty cash",
"title": "Petty cash",
"subtitle": "Request and manage your float, and return unused cash.",
"recordLanguageHint": "Records are kept in English or Malay. Please type descriptions in English or Malay.",
"floatPicker": "Float",
"noFloat": "You do not hold a petty cash float",
"noFloatHint": "Ask finance to open one for you.",
"notYourFloat": "Only the float's holder can do this",
"floatNotOpen": "This float is not open",
"closingBanner": "This float is being returned. The return is waiting for finance to confirm it.",
"records": "Petty cash records",
"cancel": "Cancel", "close": "Close", "done": "Done", "saveDraft": "Save draft",
"notFound": "This record was not found, or you cannot see it.",
"tabs": { "requests": "Requests", "movements": "Cash movements" },
"kpi": { "label": "Float summary", "open": "In progress", "openHint": "requests not yet finished", "cash": "Cash balance", "committed": "Committed", "available": "Available" },
"toApprove": { "title": "To approve", "subtitle": "Petty cash requests waiting for your decision." },
"floats": { "title": "Floats", "code": "Float", "holder": "Holder", "balance": "Cash balance", "committed": "Committed", "available": "Available", "status": "Status" },
"floatStatus": { "open": "Open", "closing": "Closing", "closed": "Closed" },
"columns": { "date": "Date", "type": "Type", "reference": "Reference", "requester": "Requested by", "description": "Description", "amount": "Amount", "status": "Status", "actions": "Actions" },
"types": { "topup": "Float request", "replenishment": "Replenishment", "spend": "Spend" },
"movementKinds": { "topup": "Top-up", "topup_reversal": "Top-up reversed", "spend_payout": "Spend paid", "spend_payout_reversal": "Spend reversed", "claim_settlement": "Claim paid", "claim_settlement_reversal": "Claim payment reversed", "closeout": "Float returned" },
"requests": { "empty": "No requests on this float yet" },
"movements": { "empty": "No cash has moved yet" },
"fields": { "amount": "Amount", "purpose": "Purpose", "date": "Expense date", "description": "Description", "category": "Category", "supplier": "Supplier", "basis": "Receipt evidence", "einvoice": "e-Invoice UUID", "relaxation": "Why there is no e-Invoice", "receipt": "Receipt", "holder": "Holder", "review": "Review outcome" },
"hints": { "receipt": "JPG, PNG or PDF, up to 10 MB" },
"placeholders": { "purpose": "Briefly say what the cash is for", "category": "Choose a category", "basis": "Choose the evidence", "outcome": "Choose an outcome" },
"basis": { "einvoice_company": "e-Invoice to the company", "foreign_supplier": "Foreign supplier invoice", "relaxation_receipt": "Receipt without e-Invoice" },
"outcomes": { "none": "Not reviewed yet", "queried": "Queried", "verified": "Verified", "not_reimbursable": "Not reimbursable" },
"request": { "button": "Request float", "title": "Request float", "description": "Enter the amount and purpose. The request goes for approval, then the cash is paid out.", "into": "Into float {{code}}", "submit": "Submit request", "done": "Request {{reference}} submitted", "detailTitle": "Float request" },
"return": { "button": "Return float", "title": "Return float", "description": "Return the unused cash to the company. Finance confirms the return.", "float": "Float", "amount": "Amount to return", "amountHint": "The whole cash balance is returned and the float is closed.", "proof": "Proof of return", "proofHint": "A bank slip or receipt, JPG, PNG or PDF", "submit": "Submit return", "done": "Return {{reference}} submitted" },
"spend": { "button": "Record spend", "title": "Record spend", "description": "Paid out of float {{code}}", "submit": "Record spend", "recorded": "Spend {{reference}} recorded", "saved": "Spend {{reference}} saved as a draft", "detailTitle": "Petty cash spend", "noReceipt": "No receipt attached yet", "recordReview": "Record review" },
"errors": { "amount": "Enter an amount greater than zero, e.g. 150.00", "reason": "Say what the cash is for", "description": "Describe the spend", "category": "Choose a category", "basis": "Choose the receipt evidence", "receipt": "Attach the receipt", "cannotRecord": "This spend cannot be recorded right now." }
}

ms/pettycash.json

{
"nav": "Wang runcit",
"title": "Wang runcit",
"subtitle": "Mohon dan urus wang runcit anda, dan pulangkan tunai yang tidak digunakan.",
"recordLanguageHint": "Rekod disimpan dalam bahasa Inggeris atau Melayu. Sila taip keterangan dalam bahasa Inggeris atau Melayu.",
"floatPicker": "Wang runcit",
"noFloat": "Anda tidak memegang wang runcit",
"noFloatHint": "Minta bahagian kewangan membukanya untuk anda.",
"notYourFloat": "Hanya pemegang wang runcit boleh melakukan ini",
"floatNotOpen": "Wang runcit ini tidak dibuka",
"closingBanner": "Wang runcit ini sedang dipulangkan. Pemulangan menunggu pengesahan kewangan.",
"records": "Rekod wang runcit",
"cancel": "Batal", "close": "Tutup", "done": "Selesai", "saveDraft": "Simpan draf",
"notFound": "Rekod ini tidak ditemui, atau anda tidak boleh melihatnya.",
"tabs": { "requests": "Permohonan", "movements": "Pergerakan tunai" },
"kpi": { "label": "Ringkasan wang runcit", "open": "Dalam proses", "openHint": "permohonan belum selesai", "cash": "Baki tunai", "committed": "Komited", "available": "Tersedia" },
"toApprove": { "title": "Untuk diluluskan", "subtitle": "Permohonan wang runcit yang menunggu keputusan anda." },
"floats": { "title": "Wang runcit", "code": "Wang runcit", "holder": "Pemegang", "balance": "Baki tunai", "committed": "Komited", "available": "Tersedia", "status": "Status" },
"floatStatus": { "open": "Dibuka", "closing": "Sedang ditutup", "closed": "Ditutup" },
"columns": { "date": "Tarikh", "type": "Jenis", "reference": "Rujukan", "requester": "Dipohon oleh", "description": "Keterangan", "amount": "Amaun", "status": "Status", "actions": "Tindakan" },
"types": { "topup": "Permohonan wang runcit", "replenishment": "Penambahan semula", "spend": "Perbelanjaan" },
"movementKinds": { "topup": "Tambahan", "topup_reversal": "Tambahan dibatalkan", "spend_payout": "Perbelanjaan dibayar", "spend_payout_reversal": "Perbelanjaan dibatalkan", "claim_settlement": "Tuntutan dibayar", "claim_settlement_reversal": "Bayaran tuntutan dibatalkan", "closeout": "Wang runcit dipulangkan" },
"requests": { "empty": "Belum ada permohonan untuk wang runcit ini" },
"movements": { "empty": "Belum ada pergerakan tunai" },
"fields": { "amount": "Amaun", "purpose": "Tujuan", "date": "Tarikh perbelanjaan", "description": "Keterangan", "category": "Kategori", "supplier": "Pembekal", "basis": "Bukti resit", "einvoice": "UUID e-Invois", "relaxation": "Sebab tiada e-Invois", "receipt": "Resit", "holder": "Pemegang", "review": "Keputusan semakan" },
"hints": { "receipt": "JPG, PNG atau PDF, maksimum 10 MB" },
"placeholders": { "purpose": "Nyatakan secara ringkas tujuan tunai", "category": "Pilih kategori", "basis": "Pilih bukti", "outcome": "Pilih keputusan" },
"basis": { "einvoice_company": "e-Invois kepada syarikat", "foreign_supplier": "Invois pembekal asing", "relaxation_receipt": "Resit tanpa e-Invois" },
"outcomes": { "none": "Belum disemak", "queried": "Dipersoalkan", "verified": "Disahkan", "not_reimbursable": "Tidak boleh dituntut" },
"request": { "button": "Mohon wang runcit", "title": "Mohon wang runcit", "description": "Masukkan amaun dan tujuan. Permohonan akan diluluskan, kemudian tunai dibayar.", "into": "Ke dalam wang runcit {{code}}", "submit": "Hantar permohonan", "done": "Permohonan {{reference}} dihantar", "detailTitle": "Permohonan wang runcit" },
"return": { "button": "Pulangkan wang runcit", "title": "Pulangkan wang runcit", "description": "Pulangkan tunai yang tidak digunakan kepada syarikat. Kewangan mengesahkan pemulangan.", "float": "Wang runcit", "amount": "Amaun dipulangkan", "amountHint": "Seluruh baki tunai dipulangkan dan wang runcit ditutup.", "proof": "Bukti pemulangan", "proofHint": "Slip bank atau resit, JPG, PNG atau PDF", "submit": "Hantar pemulangan", "done": "Pemulangan {{reference}} dihantar" },
"spend": { "button": "Rekod perbelanjaan", "title": "Rekod perbelanjaan", "description": "Dibayar daripada wang runcit {{code}}", "submit": "Rekod perbelanjaan", "recorded": "Perbelanjaan {{reference}} direkodkan", "saved": "Perbelanjaan {{reference}} disimpan sebagai draf", "detailTitle": "Perbelanjaan wang runcit", "noReceipt": "Belum ada resit dilampirkan", "recordReview": "Rekod semakan" },
"errors": { "amount": "Masukkan amaun lebih daripada sifar, cth. 150.00", "reason": "Nyatakan tujuan tunai", "description": "Terangkan perbelanjaan", "category": "Pilih kategori", "basis": "Pilih bukti resit", "receipt": "Lampirkan resit", "cannotRecord": "Perbelanjaan ini tidak boleh direkodkan sekarang." }
}

zh/pettycash.json

{
"nav": "备用金",
"title": "备用金",
"subtitle": "申请和管理备用金,并归还未使用的现金。",
"recordLanguageHint": "记录须使用英文或马来文,请用英文或马来文填写说明。",
"floatPicker": "备用金",
"noFloat": "您没有持有备用金",
"noFloatHint": "请联系财务为您开立。",
"notYourFloat": "只有备用金持有人可以操作",
"floatNotOpen": "此备用金未处于开放状态",
"closingBanner": "此备用金正在归还中,等待财务确认。",
"records": "备用金流水记录",
"cancel": "取消", "close": "关闭", "done": "已完成", "saveDraft": "保存草稿",
"notFound": "找不到这条记录,或您无权查看。",
"tabs": { "requests": "申请记录", "movements": "资金流水" },
"kpi": { "label": "备用金概览", "open": "进行中", "openHint": "笔申请尚未完成", "cash": "现金余额", "committed": "已占用", "available": "可用余额" },
"toApprove": { "title": "待审批", "subtitle": "以下是待您审批的备用金申请。" },
"floats": { "title": "备用金账户", "code": "备用金", "holder": "持有人", "balance": "现金余额", "committed": "已占用", "available": "可用余额", "status": "状态" },
"floatStatus": { "open": "使用中", "closing": "归还中", "closed": "已关闭" },
"columns": { "date": "日期", "type": "类型", "reference": "申请编号", "requester": "申请人", "description": "说明", "amount": "金额", "status": "状态", "actions": "操作" },
"types": { "topup": "申请备用金", "replenishment": "补充备用金", "spend": "已支出" },
"movementKinds": { "topup": "申请备用金", "topup_reversal": "申请已冲销", "spend_payout": "已支出", "spend_payout_reversal": "支出已冲销", "claim_settlement": "报销付款", "claim_settlement_reversal": "报销付款已冲销", "closeout": "归还备用金" },
"requests": { "empty": "此备用金暂无申请" },
"movements": { "empty": "暂无资金流水" },
"fields": { "amount": "申请金额", "purpose": "用途说明", "date": "费用日期", "description": "说明", "category": "类别", "supplier": "供应商", "basis": "收据类型", "einvoice": "电子发票 UUID", "relaxation": "无电子发票的原因", "receipt": "收据", "holder": "持有人", "review": "审核结果" },
"hints": { "receipt": "支持 JPG、PNG、PDF,单个文件不超过 10MB" },
"placeholders": { "purpose": "简单说明这笔现金的用途(请用英文或马来文)", "category": "选择类别", "basis": "选择收据类型", "outcome": "选择结果" },
"basis": { "einvoice_company": "开给公司的电子发票", "foreign_supplier": "境外供应商发票", "relaxation_receipt": "无电子发票的收据" },
"outcomes": { "none": "尚未审核", "queried": "有疑问", "verified": "已核实", "not_reimbursable": "不予报销" },
"request": { "button": "申请备用金", "title": "申请备用金", "description": "填写申请金额与用途,提交后等待审批与出款。", "into": "存入备用金 {{code}}", "submit": "提交申请", "done": "申请 {{reference}} 已提交", "detailTitle": "备用金申请" },
"return": { "button": "归还备用金", "title": "归还备用金", "description": "将未使用的备用金退回公司,提交后等待财务确认。", "float": "备用金", "amount": "退还金额", "amountHint": "将全部现金余额退还并关闭此备用金。", "proof": "上传凭证", "proofHint": "银行水单或收据,支持 JPG、PNG、PDF", "submit": "提交申请", "done": "归还申请 {{reference}} 已提交" },
"spend": { "button": "记录支出", "title": "记录支出", "description": "从备用金 {{code}} 支付", "submit": "记录支出", "recorded": "支出 {{reference}} 已记录", "saved": "支出 {{reference}} 已保存为草稿", "detailTitle": "备用金支出", "noReceipt": "尚未附收据", "recordReview": "提交审核" },
"errors": { "amount": "请输入大于零的金额,例如 150.00", "reason": "请说明这笔现金的用途", "description": "请描述这笔支出", "category": "请选择类别", "basis": "请选择收据类型", "receipt": "请附上收据", "cannotRecord": "这笔支出目前无法记录。" }
}

Note: the return dialog’s submit button is named Submit return in en but 提交申请 in zh, matching Figma; the test runs in en.

  • Step 10: Run the tests

Run: pnpm -C web test -- src/features/pettycash src/drift Expected: PASS. Then typecheck and lint.

  • Step 11: Runtime check

Dev stack and preview; the seed opens a float for the claimant and leaves one recorded spend and one requested top-up waiting for the approver. At 1440, light, en:

  1. As the holder, /petty-cash: KPI cards match curl …/v1/pettycash/floats?scope=mine | jq '.floats[0] | {cash_balance, committed, available}' exactly; no percentage or bar anywhere.
  2. Request float: amount 150.00, purpose → one POST /v1/pettycash/topups whose body amount is the string "150.00"; the Requests tab shows it as Requested.
  3. Record spend with a category, evidence and a real receipt upload: three requests in order (POST, PUT receipt with If-Match: "1", POST transitions with an Idempotency-Key); the spend shows Recorded. Force a failure on the third (stop the API after the PUT), retry: no second spend is created (the dialog continues the first).
  4. As the approver: the To approve card lists both; Verified via the spend drawer, then Pay from float on the row; the Cash movements tab gains a red signed row naming the spend.
  5. As the holder: Return float is enabled only while the float is open; submit is disabled until a proof is uploaded; after submitting, the page shows the closing banner and all three action buttons disable with the tooltip.
  6. As a custodian (pettycash.read_all): the Float picker and the Floats table appear; selecting another float updates KPIs, requests and movements, and the URL gains ?float=. Then 390px, dark, zh (备用金, 申请备用金, 归还备用金, 待审批, 备用金流水记录).
  • Step 12: Commit
Terminal window
git add web/src/features/pettycash web/src/lib/i18n/locales/*/pettycash.json web/src/app/routes.ts web/src/app/shell/nav.ts
git commit -m "$(cat <<'MSG'
Plan 10 Task 16: petty cash page (float KPIs, to-approve, requests, movements; request/return/spend)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MSG
)"

Task 17: Advances — list, detail, acquittal

Section titled “Task 17: Advances — list, detail, acquittal”

No Figma frame (C, spec §5.5 and §13.5 #7). Built from the claims list (123:7183), the claim drawer (286:9929) and the wizard’s form fields (166:12082); pull those and the Components page (0:1).

Layout. List (/advances): header Advances with a one-line subtitle and + New advance; four KPI cards as on the claims list (Pending, Approved, Paid out, Total requested — from the server’s kpis buckets, R5); tabs My advances · My approvals · All advances (the last with advances.advance.read_all); a table Purpose · Amount (in the advance’s currency, and the base amount under it when the currency differs) · Due (the date; the overdue block exists only on the detail view, so the overdue warning is shown in the drawer) · Status · Reference; Load more. There are no state chips: the list endpoint has no state filter (G5). Drawer (/advances/$id): title (purpose) with the status chip of the advance’s own leg, reference and due date; a red overdue banner (n days overdue · reminders now go to your manager for level manager); the amount banner; then one panel per leg (the advance; then its top-up or refund leg), each with its chip, its TransitionBar (moves send that leg’s kind) and its timeline; the Acquittal panel; Payouts. Acquittal panel: when recorded — Advanced (the advance’s base amount), Spent (actual_base), Variance (signed, as the server computed it) and the outcome in words (Exact — nothing to settle, Top-up to you, Refund to the company, Returned in full), plus the receipt, category and tax-record fields. When the advance waits for its acquittal and the caller is the claimant, the Record acquittal form: a Nothing was spent switch (then only a reason), otherwise actual amount and currency, receipt date, receipt upload, category, receipt evidence and the matching tax fields. The UI never computes the variance or the outcome.

“Waits for its acquittal” is read from the workflow, not a state key: the advance’s own leg is in a non-terminal state whose semantic is acknowledged (the seeded received), per the definition. The server still refuses a recording at the wrong moment (409), and the form shows that refusal.

Files:

  • Create: web/src/features/advances/api.ts
  • Create: web/src/features/advances/search.ts
  • Create: web/src/features/advances/pickSubmitTransition.ts
  • Create: web/src/features/advances/awaitingAcquittal.ts
  • Create: web/src/features/advances/AdvancesListPage.tsx
  • Create: web/src/features/advances/NewAdvanceDialog.tsx
  • Create: web/src/features/advances/AdvanceDrawer.tsx
  • Create: web/src/features/advances/LegPanel.tsx
  • Create: web/src/features/advances/AcquittalPanel.tsx
  • Create: web/src/features/advances/AcquittalForm.tsx
  • Create: web/src/features/advances/RecordLanguageHint.tsx
  • Create: web/src/features/advances/routes.tsx
  • Create: web/src/features/advances/__tests__/fixtures.ts
  • Create: web/src/features/advances/__tests__/AdvancesListPage.test.tsx
  • Create: web/src/features/advances/__tests__/AdvanceDrawer.test.tsx
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/advances.json
  • Modify (append): web/src/app/routes.ts, web/src/app/shell/nav.ts

Interfaces:

  • Consumes: as Task 16, plus useNeighbours, useMe, todayISO, monthKey; useCategories is not imported from claims (a different feature) — this task queries ['expenses','categories'] itself with the same key.

  • Produces: advanceKeys (R6) { all: ['advances'], list: (s) => ['advances','list',s], summary: (s) => ['advances','summary',s], detail: (id) => ['advances','detail',id] }; useAdvanceList, useAdvance, useAdvanceTransition (vars { kind?, from, to, reason?, payout_channel_id?, proof_document_id?, idempotencyKey? }). Routes /advances, /advances/new, /advances/$id.

  • Step 0: Verify imports (C1) and that Task 4 shipped summarizeAdvances with the kpis buckets (R5): grep -n "AdvanceSummary" web/src/lib/api/schema.d.ts. If kpis is missing, stop and amend Task 4, as in Task 11 Step 0.

  • Step 1: Fixtures

web/src/features/advances/__tests__/fixtures.ts
import type { Schemas } from '@/lib/api/types';
export const ME = '00000000-0000-4000-8000-000000000001';
export const OTHER = '00000000-0000-4000-8000-000000000003';
export const ADV = '00000000-0000-4000-8000-0000000ad001';
const l = (en: string, zh: string) => ({ en, zh, ms: en });
const st = (key: string, semantic: Schemas['WorkflowSemantic'], order: number, en: string, zh: string, is_terminal = false): Schemas['WorkflowState'] =>
({ key, semantic, display_order: order, is_terminal, labels: l(en, zh) });
const def = (kind: string, states: Schemas['WorkflowState'][]): Schemas['WorkflowDefinition'] =>
({ id: `def-${kind}`, kind, version: 1, published_at: '2026-08-01T00:00:00Z', graph: { kind, posting_order: 'payout_first', states, edges: [], grants: [] } });
// Seeded graphs (internal/platform/advances/graphs.go), the states the tests touch.
export const definitions: Record<string, Schemas['WorkflowDefinition']> = {
advance: def('advance', [
st('draft', 'draft', 1, 'Draft', '草稿'), st('pending_review', 'pending', 2, 'Pending review', '待审核'), st('reviewed', 'pending', 3, 'Reviewed', '已审核'),
st('approved', 'pending', 4, 'Approved', '已批准'), st('paid_out', 'payout', 5, 'Paid out', '已支付'), st('received', 'acknowledged', 6, 'Received', '已收款'),
st('acquitted', 'accrual', 7, 'Acquitted', '已核销', true), st('returned', 'cancelled', 8, 'Returned in full', '已全额退回', true),
st('cancelled', 'cancelled', 10, 'Withdrawn', '已撤回', true), st('acquittal_confirmed', 'acknowledged', 13, 'Acquittal confirmed', '核销已确认', true),
]),
advance_refund: def('advance_refund', [st('draft', 'draft', 1, 'Draft', '草稿'), st('refund_pending', 'pending', 2, 'Refund pending', '退款待处理'), st('refund_received', 'acknowledged', 3, 'Refund received', '已收到退款', true)]),
advance_topup: def('advance_topup', [st('draft', 'draft', 1, 'Draft', '草稿'), st('topup_pending', 'pending', 2, 'Excess awaiting approval', '超支待批准')]),
};
export const tr = (to: string, en: string, extra: Partial<Schemas['WorkflowAvailableTransition']> = {}): Schemas['WorkflowAvailableTransition'] =>
({ to, action_key: to, gate: 'target', requires_reason: false, allowed: true, labels: { en }, ...extra });
export function advance(over: Partial<Schemas['Advance']> = {}): Schemas['Advance'] {
return {
id: ADV, reference: 'AD-2026-0003', claimant_member_id: ME, holder_party_id: 'party', purpose: 'Site visit to Penang', currency: 'MYR',
amount: '800.0000', fx_rate: '1', fx_rate_source: 'base', base_amount: '800.0000', due_on: '2026-09-30', payout_channel_id: null,
revision: 5, state: 'received', semantic: 'acknowledged', ...over,
};
}
export function leg(over: Partial<Schemas['AdvanceLeg']> = {}): Schemas['AdvanceLeg'] {
return { kind: 'advance', instance_id: 'i1', state: 'received', semantic: 'acknowledged', amount: '800.0000', available_transitions: [], decisions: [], ...over };
}
export function view(over: Partial<Schemas['AdvanceView']> = {}, a: Partial<Schemas['Advance']> = {}): Schemas['AdvanceView'] {
return { advance: advance(a), acquittal: null, legs: [leg()], settlements: [], overdue: null, ...over };
}
export const acquittal: Schemas['AdvanceAcquittalView'] = {
actual_currency: 'MYR', actual_amount: '750.0000', actual_fx_rate: '1', actual_fx_rate_source: 'base', actual_base: '750.0000',
receipt_date: '2026-09-05', receipt_document_id: '00000000-0000-4000-8000-0000000d0c09', category_id: null, reason: null,
document_basis: 'relaxation_receipt', supplier_name: 'Hotel Penang', supplier_tin: null, einvoice_uuid: null, relaxation_reason: 'Small hotel',
outcome: 'refund', variance: '-50.0000', leg_amount: '50.0000', expense_account_id: null, recorded_by: ME, acquitted_at: null, acquitted_by: null,
};
export const summary = {
currency: 'MYR',
rows: [{ state: 'pending_review', semantic: 'pending', count: 2, total: '1600.0000' }, { state: 'received', semantic: 'acknowledged', count: 1, total: '800.0000' }],
kpis: { pending: { count: 2, total: '1600.0000' }, approved: { count: 0, total: '0.0000' }, paid: { count: 1, total: '800.0000' }, submitted: { count: 3, total: '2400.0000' } },
} as Schemas['AdvanceSummary'];
  • Step 2: Write the failing tests
web/src/features/advances/__tests__/AdvancesListPage.test.tsx
import { describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/msw/server';
import { renderWithApp } from '@/test/renderWithApp';
import { advance, definitions, summary, tr, view } from './fixtures';
const READ = ['advances.advance.read', 'advances.advance.create', 'advances.advance.transition'];
function mockApi(calls: { path: string; body: unknown; idem: string | null }[] = []) {
server.use(
http.get('*/v1/workflow/definitions/:kind', ({ params }) => HttpResponse.json(definitions[params.kind as string])),
http.get('*/v1/advances/summary', () => HttpResponse.json(summary)),
http.get('*/v1/advances', () => HttpResponse.json({ advances: [advance(), advance({ id: 'usd', reference: 'AD-2026-0004', currency: 'USD', amount: '100.0000', base_amount: '470.0000', purpose: 'Conference fee' })], next_cursor: null })),
http.get('*/v1/payout/me/channels', () => HttpResponse.json({ channels: [] })),
http.get('*/v1/payout/me/profiles', () => HttpResponse.json({ profiles: [] })),
http.post('*/v1/advances', async ({ request }) => {
calls.push({ path: '/v1/advances', body: await request.json(), idem: null });
return HttpResponse.json(view({ legs: [{ kind: 'advance', instance_id: 'i', state: 'draft', semantic: 'draft', amount: '300.0000', decisions: [],
available_transitions: [tr('cancelled', 'Withdraw', { gate: 'reporter', requires_reason: true }), tr('pending_review', 'Submit', { gate: 'reporter' })] }] }, { state: 'draft', semantic: 'draft', revision: 1 }), { status: 201 });
}),
http.post('*/v1/advances/:id/transitions', async ({ request }) => {
calls.push({ path: new URL(request.url).pathname, body: await request.json(), idem: request.headers.get('Idempotency-Key') });
return HttpResponse.json(view({}, { state: 'pending_review', semantic: 'pending' }));
}),
);
}
describe('AdvancesListPage', () => {
it('shows the server KPI buckets and the base amount under a foreign one', async () => {
mockApi();
await renderWithApp({ path: '/advances', permissions: READ });
expect(within(await screen.findByRole('group', { name: 'Pending' })).getByText('RM 1,600.00')).toBeInTheDocument();
const row = (await screen.findByText('Conference fee')).closest('tr')!;
expect(within(row).getByText('USD 100.00')).toBeInTheDocument();
expect(within(row).getByText('RM 470.00')).toBeInTheDocument();
});
it('shows All advances only with advances.advance.read_all (presence first)', async () => {
mockApi();
const first = await renderWithApp({ path: '/advances', permissions: [...READ, 'advances.advance.read_all'] });
expect(await screen.findByRole('tab', { name: 'All advances' })).toBeInTheDocument();
first.unmount();
mockApi();
await renderWithApp({ path: '/advances', permissions: READ });
expect(await screen.findByRole('tab', { name: 'My approvals' })).toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'All advances' })).not.toBeInTheDocument();
});
it('files and submits a new advance through the draft-leaving transition, not the withdraw one', async () => {
const calls: { path: string; body: unknown; idem: string | null }[] = [];
mockApi(calls);
const { user } = await renderWithApp({ path: '/advances', permissions: READ });
await user.click(await screen.findByRole('button', { name: 'New advance' }));
const dialog = await screen.findByRole('dialog', { name: 'New advance' });
await user.type(within(dialog).getByRole('textbox', { name: 'Purpose' }), 'Trade fair in Johor');
await user.type(within(dialog).getByLabelText(/Amount/), '300.00');
await user.click(within(dialog).getByRole('button', { name: 'Submit' }));
await waitFor(() => expect(calls).toHaveLength(2));
expect(calls[0].body).toMatchObject({ purpose: 'Trade fair in Johor', amount: '300.00', currency: 'MYR' });
expect(calls[1]).toMatchObject({ body: { kind: 'advance', from: 'draft', to: 'pending_review' } });
expect(calls[1].idem).toMatch(/^[0-9a-f-]{36}$/);
});
});
web/src/features/advances/__tests__/AdvanceDrawer.test.tsx
import { describe, expect, it, vi } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/msw/server';
import { renderWithApp } from '@/test/renderWithApp';
import type { Schemas } from '@/lib/api/types';
import { acquittal, ADV, advance, definitions, leg, OTHER, summary, tr, view } from './fixtures';
vi.mock('@/components/upload', async (orig) => ({
...(await orig<typeof import('@/components/upload')>()),
DocumentUploader: () => <span>uploader</span>,
DocumentThumb: () => <span>thumb</span>,
}));
const PERMS = ['advances.advance.read', 'advances.advance.create', 'advances.advance.transition'];
type Call = { method: string; path: string; body: unknown; ifMatch: string | null };
function mockApi(v: Schemas['AdvanceView'], calls: Call[] = []) {
server.use(
http.get('*/v1/workflow/definitions/:kind', ({ params }) => HttpResponse.json(definitions[params.kind as string])),
http.get('*/v1/advances/summary', () => HttpResponse.json(summary)),
http.get('*/v1/advances', () => HttpResponse.json({ advances: [v.advance], next_cursor: null })),
http.get('*/v1/expenses/categories', () => HttpResponse.json({ categories: [] })),
http.get('*/v1/advances/:id', () => HttpResponse.json(v)),
http.put('*/v1/advances/:id/acquittal', async ({ request }) => {
calls.push({ method: 'PUT', path: new URL(request.url).pathname, body: await request.json(), ifMatch: request.headers.get('If-Match') });
return HttpResponse.json({ ...v, acquittal: { ...acquittal, actual_amount: '0.0000', actual_base: '0.0000', outcome: 'returned', variance: '-800.0000' } });
}),
http.post('*/v1/advances/:id/transitions', async ({ request }) => {
calls.push({ method: 'POST', path: new URL(request.url).pathname, body: await request.json(), ifMatch: null });
return HttpResponse.json(v);
}),
);
}
describe('AdvanceDrawer', () => {
it('shows advanced, spent, the signed variance and the outcome exactly as the API decided them', async () => {
mockApi(view({ acquittal }));
await renderWithApp({ path: `/advances/${ADV}`, permissions: PERMS });
const panel = await screen.findByRole('region', { name: 'Acquittal' });
expect(within(panel).getByText('RM 800.00')).toBeInTheDocument();
expect(within(panel).getByText('RM 750.00')).toBeInTheDocument();
expect(within(panel).getByText('-RM 50.00')).toBeInTheDocument();
expect(within(panel).getByText('Refund to the company')).toBeInTheDocument();
});
it('offers the acquittal form to the claimant only, while the advance awaits it (presence first)', async () => {
const calls: Call[] = [];
mockApi(view(), calls);
const first = await renderWithApp({ path: `/advances/${ADV}`, permissions: PERMS });
const form = await screen.findByRole('form', { name: 'Record acquittal' });
const { user } = first;
await user.click(within(form).getByRole('switch', { name: 'Nothing was spent' }));
await user.type(within(form).getByRole('textbox', { name: 'Reason' }), 'Trip cancelled');
await user.click(within(form).getByRole('button', { name: 'Save acquittal' }));
await waitFor(() => expect(calls[0]).toMatchObject({ method: 'PUT', path: `/v1/advances/${ADV}/acquittal`, ifMatch: '"5"', body: { actual_amount: '0', reason: 'Trip cancelled' } }));
first.unmount();
mockApi(view({}, { claimant_member_id: OTHER }));
await renderWithApp({ path: `/advances/${ADV}`, permissions: PERMS });
expect(await screen.findByText('Site visit to Penang')).toBeInTheDocument();
expect(screen.queryByRole('form', { name: 'Record acquittal' })).not.toBeInTheDocument();
});
it('moves a variance leg with its own kind', async () => {
const calls: Call[] = [];
mockApi(view({ acquittal, legs: [leg({ state: 'acquitted', semantic: 'accrual' }), leg({ kind: 'advance_refund', instance_id: 'i2', state: 'refund_pending', semantic: 'pending', amount: '50.0000', available_transitions: [tr('refund_received', 'Confirm refund received')] })] }, { state: 'acquitted', semantic: 'accrual' }), calls);
const { user } = await renderWithApp({ path: `/advances/${ADV}`, permissions: PERMS });
await user.click(await screen.findByRole('button', { name: 'Confirm refund received' }));
await waitFor(() => expect(calls[0]).toMatchObject({ method: 'POST', body: { kind: 'advance_refund', from: 'refund_pending', to: 'refund_received' } }));
});
it('shows the overdue banner from the server block', async () => {
mockApi(view({ overdue: { days_overdue: 9, level: 'manager' } }));
await renderWithApp({ path: `/advances/${ADV}`, permissions: PERMS });
expect(await screen.findByText('9 days overdue · your manager is now reminded too')).toBeInTheDocument();
});
});

The acquittal form is a <form aria-label="Record acquittal"> (role form needs an accessible name) and the “nothing spent” control is Switch-like: use Checkbox with role="switch" if Task 7 has no switch.

  • Step 3: Run them and watch them fail

Run: pnpm -C web test -- src/features/advances Expected: FAIL — no /advances route.

  • Step 4: API and helpers
web/src/features/advances/api.ts
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api, newIdempotencyKey } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
import type { Schemas } from '@/lib/api/types';
export type Scope = 'mine' | 'to_act' | 'all';
export type AdvanceView = Schemas['AdvanceView'];
export const PAGE_SIZE = 10;
export const advanceKeys = {
all: ['advances'] as const,
list: (s: { scope: Scope }) => ['advances', 'list', s] as const,
summary: (s: { scope: Scope }) => ['advances', 'summary', s] as const,
detail: (id: string) => ['advances', 'detail', id] as const,
};
export function useAdvanceList(s: { scope: Scope }) {
return useInfiniteQuery({
queryKey: advanceKeys.list({ scope: s.scope }),
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam, signal }) =>
unwrap(await api.GET('/advances', { params: { query: { scope: s.scope, limit: PAGE_SIZE, cursor: pageParam } }, signal })),
getNextPageParam: (last) => last.next_cursor ?? undefined,
select: (d) => d.pages.flatMap((p) => p.advances),
});
}
export function useAdvanceSummary(s: { scope: Scope }) {
return useQuery({ queryKey: advanceKeys.summary({ scope: s.scope }), queryFn: async ({ signal }) => unwrap(await api.GET('/advances/summary', { params: { query: { scope: s.scope } }, signal })) });
}
export function useAdvance(id: string) {
return useQuery({ queryKey: advanceKeys.detail(id), queryFn: async ({ signal }) => unwrap(await api.GET('/advances/{id}', { params: { path: { id } }, signal })) });
}
function useAfterWrite() {
const qc = useQueryClient();
return (v: AdvanceView) => {
qc.setQueryData(advanceKeys.detail(v.advance.id), v);
void qc.invalidateQueries({ queryKey: ['advances', 'list'] });
void qc.invalidateQueries({ queryKey: ['advances', 'summary'] });
void qc.invalidateQueries({ queryKey: ['approvals'] });
};
}
export type AdvanceTransitionVars = Schemas['AdvanceTransition'] & { idempotencyKey?: string };
export function useAdvanceTransition(id: string) {
const after = useAfterWrite();
return useMutation({
mutationFn: async ({ idempotencyKey, ...body }: AdvanceTransitionVars): Promise<AdvanceView> =>
unwrap(await api.POST('/advances/{id}/transitions', { params: { path: { id }, header: { 'Idempotency-Key': idempotencyKey ?? newIdempotencyKey() } }, body })),
onSuccess: after,
});
}
export function useRecordAcquittal(id: string) {
const after = useAfterWrite();
return useMutation({
mutationFn: async (v: { revision: number; body: Schemas['AcquittalInput'] }): Promise<AdvanceView> =>
unwrap(await api.PUT('/advances/{id}/acquittal', { params: { path: { id }, header: { 'If-Match': `"${v.revision}"` } }, body: v.body })),
onSuccess: after,
});
}
export function useCreateAdvance() {
const after = useAfterWrite();
return useMutation({ mutationFn: async (body: Schemas['CreateAdvanceRequest']): Promise<AdvanceView> => unwrap(await api.POST('/advances', { body })), onSuccess: after });
}
export function useCategoriesForAcquittal() {
return useQuery({ queryKey: ['expenses', 'categories'], queryFn: async ({ signal }) => unwrap(await api.GET('/expenses/categories', { signal })), select: (d) => d.categories.filter((c) => c.active) });
}
// web/src/features/advances/pickSubmitTransition.ts — same rule as the claims wizard (spec §5.2), kept per feature
import type { AvailableTransition, WorkflowDefinition } from '@/lib/api/types';
const NOT_SUBMIT = new Set(['draft', 'cancelled', 'rejected']);
export function pickSubmitTransition(view: { available_transitions: AvailableTransition[] }, def: WorkflowDefinition | undefined) {
if (!def) return undefined;
const sem = new Map(def.graph.states.map((s) => [s.key, s.semantic]));
return view.available_transitions.find((t) => { const s = sem.get(t.to); return s !== undefined && !NOT_SUBMIT.has(s); });
}
web/src/features/advances/awaitingAcquittal.ts
import type { Schemas, WorkflowDefinition } from '@/lib/api/types';
/**
* The advance's own leg sits in a non-terminal acknowledged-semantic state (the seeded "received"):
* the money is with the claimant and has not been accounted for. Read from the definition by
* semantic, never by state key; the server still refuses a recording at the wrong moment.
*/
export function awaitingAcquittal(view: Schemas['AdvanceView'], def: WorkflowDefinition | undefined): boolean {
const own = view.legs[0];
const state = def?.graph.states.find((s) => s.key === own?.state);
return Boolean(state && state.semantic === 'acknowledged' && !state.is_terminal && !view.acquittal?.acquitted_at);
}
web/src/features/advances/search.ts
import { z } from 'zod';
export const advanceSearchSchema = z.object({ scope: z.enum(['mine', 'to_act', 'all']).catch('mine') });
export type AdvanceSearch = z.infer<typeof advanceSearchSchema>;
  • Step 5: List page and new-advance dialog
web/src/features/advances/AdvancesListPage.tsx
import { useMemo, type ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { useTranslation } from 'react-i18next';
import { BadgeDollarSign, CircleCheck, Clock, FileText, Plus, User, UserCheck, Users } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { DataTable } from '@/components/ui/DataTable';
import { EmptyState } from '@/components/ui/EmptyState';
import { LoadMore } from '@/components/ui/LoadMore';
import { PageHeader } from '@/components/ui/PageHeader';
import { StatCard } from '@/components/ui/StatCard';
import { Tabs } from '@/components/ui/Tabs';
import { MoneyText } from '@/components/money';
import { StatusChip } from '@/components/workflow';
import { useCan, useMe } from '@/features/auth';
import type { Schemas } from '@/lib/api/types';
import { formatDate } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import { useAdvanceList, useAdvanceSummary, type Scope } from './api';
import type { AdvanceSearch } from './search';
type Advance = Schemas['Advance'];
export function AdvancesListPage(p: { search: AdvanceSearch; onScope(s: Scope): void; onOpen(id: string): void; onNew(): void; children?: ReactNode }) {
const { t } = useTranslation('advances');
const [locale] = useLocale();
const { data: me } = useMe();
const base = me?.organization.base_currency ?? 'MYR';
const canAll = useCan('advances.advance.read_all');
const canCreate = useCan('advances.advance.create');
const list = useAdvanceList(p.search);
const summary = useAdvanceSummary(p.search);
const k = summary.data?.kpis;
const cur = summary.data?.currency ?? base;
const columns = useMemo<ColumnDef<Advance>[]>(() => [
{ id: 'purpose', header: t('columns.purpose'), cell: ({ row }) => <span className="font-medium">{row.original.purpose}</span> },
{ id: 'amount', header: t('columns.amount'), cell: ({ row }) => (
<span className="font-bold">
<MoneyText amount={row.original.amount} currency={row.original.currency} />
{row.original.currency !== base && <span className="block text-xs font-normal text-muted-foreground"><MoneyText amount={row.original.base_amount} currency={base} /></span>}
</span>
) },
{ id: 'due', header: t('columns.due'), cell: ({ row }) => formatDate(row.original.due_on, locale) },
{ id: 'status', header: t('columns.status'), cell: ({ row }) => <StatusChip kind="advance" state={row.original.state} semantic={row.original.semantic} /> },
{ id: 'reference', header: t('columns.reference'), cell: ({ row }) => <span className="font-mono text-sm">{row.original.reference}</span> },
], [t, locale, base]);
return (
<div className="flex flex-col gap-4">
<PageHeader title={t('title')} subtitle={t('subtitle')}
actions={canCreate ? <Button onClick={p.onNew}><Plus className="size-4" aria-hidden /> {t('new')}</Button> : undefined} />
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4" aria-label={t('kpi.label')}>
{([['pending', 'amber', Clock], ['approved', 'green', CircleCheck], ['paid', 'green-solid', BadgeDollarSign], ['submitted', 'blue', FileText]] as const).map(([key, tone, icon]) => (
<div key={key} role="group" aria-label={t(`kpi.${key}`)}>
<StatCard tone={tone} icon={icon} label={t(`kpi.${key}`)} primary={k ? String(k[key].count) : '–'} secondary={k ? <MoneyText amount={k[key].total} currency={cur} /> : undefined} />
</div>
))}
</section>
<Tabs value={p.search.scope} onChange={(v: string) => p.onScope(v as Scope)} items={[
{ value: 'mine', label: t('tabs.mine'), icon: User }, { value: 'to_act', label: t('tabs.to_act'), icon: UserCheck },
...(canAll ? [{ value: 'all', label: t('tabs.all'), icon: Users }] : []),
]} />
<div className="rounded-xl border border-border bg-card">
<DataTable columns={columns} data={list.data ?? []} loading={list.isLoading} getRowId={(a: Advance) => a.id} onRowClick={(a: Advance) => p.onOpen(a.id)}
empty={<EmptyState title={t('empty.title')} description={t('empty.description')} />} />
</div>
<LoadMore hasMore={Boolean(list.hasNextPage)} loading={list.isFetchingNextPage} onLoad={() => void list.fetchNextPage()} />
{p.children}
</div>
);
}

Below 768px DataTable scrolls inside its card; the advances list is a secondary screen and has no card layout of its own (the claims cards are the Figma-specified one).

web/src/features/advances/NewAdvanceDialog.tsx
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { DatePicker } from '@/components/ui/DatePicker';
import { Dialog } from '@/components/ui/Dialog';
import { Field } from '@/components/ui/Field';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Textarea } from '@/components/ui/Textarea';
import { toast } from '@/components/ui/Toaster';
import { MoneyInput } from '@/components/money';
import { useWorkflowDefinition } from '@/components/workflow';
import { useMe } from '@/features/auth';
import { api, newIdempotencyKey } from '@/lib/api/client';
import { ProblemError, problemMessage, unwrap } from '@/lib/api/problem';
import { todayISO } from '@/lib/date';
import { isMoneyInput } from '@/lib/money';
import { useCreateAdvance, type AdvanceView } from './api';
import { pickSubmitTransition } from './pickSubmitTransition';
import { RecordLanguageHint } from './RecordLanguageHint';
export function NewAdvanceDialog({ onClose, onDone }: { onClose(): void; onDone(id: string): void }) {
const { t } = useTranslation('advances');
const { t: tc } = useTranslation();
const { data: me } = useMe();
const def = useWorkflowDefinition('advance');
const create = useCreateAdvance();
const [created, setCreated] = useState<AdvanceView | null>(null); // a retry of Submit reuses the draft already filed
const channels = useQuery({ queryKey: ['payout', 'me', 'channels'], queryFn: async ({ signal }) => unwrap(await api.GET('/payout/me/channels', { signal })) });
const [v, setV] = useState({ purpose: '', currency: me?.organization.base_currency ?? 'MYR', amount: '', due_on: todayISO(), channel: '' });
const [errors, setErrors] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const set = (key: keyof typeof v) => (x: string) => setV((s) => ({ ...s, [key]: x }));
async function save(submit: boolean) {
const e: Record<string, string> = {};
if (!v.purpose.trim()) e.purpose = t('errors.purpose');
if (!isMoneyInput(v.amount) || /^0*(\.0*)?$/.test(v.amount)) e.amount = t('errors.amount');
if (!/^[A-Z]{3}$/.test(v.currency)) e.currency = t('errors.currency');
setErrors(e);
if (Object.keys(e).length) return;
setBusy(true);
try {
const draft = created ?? (await create.mutateAsync({ purpose: v.purpose.trim(), currency: v.currency, amount: v.amount, due_on: v.due_on, payout_channel_id: v.channel || null }));
setCreated(draft);
if (submit) {
const own = draft.legs[0];
const tr = pickSubmitTransition(own, def.data);
if (!tr || !tr.allowed) throw new Error(tr?.refusal?.detail ?? t('errors.cannotSubmit'));
// The draft's id is known only now, so the one submit is posted directly rather than through a hook bound to an id.
await unwrap(await api.POST('/advances/{id}/transitions', {
params: { path: { id: draft.advance.id }, header: { 'Idempotency-Key': newIdempotencyKey() } },
body: { kind: 'advance', from: own.state, to: tr.to },
}));
}
toast.success(submit ? t('submitted', { reference: draft.advance.reference }) : t('draftSaved'));
onDone(draft.advance.id);
} catch (err) {
setErrors({ form: err instanceof ProblemError ? err.detail ?? problemMessage(err, tc) : err instanceof Error ? err.message : problemMessage(err, tc) });
} finally {
setBusy(false);
}
}
return (
<Dialog open onOpenChange={(o: boolean) => !o && onClose()} title={t('newTitle')} description={t('newDescription')}
footer={<><Button variant="outline" onClick={() => save(false)} disabled={busy}>{t('saveDraft')}</Button><Button onClick={() => save(true)} loading={busy}>{t('submit')}</Button></>}>
<div className="grid gap-4 md:grid-cols-2">
<div className="md:col-span-2">
<Field label={t('fields.purpose')} htmlFor="na-purpose" error={errors.purpose} required>
<Textarea id="na-purpose" rows={2} maxLength={500} value={v.purpose} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => set('purpose')(e.target.value)} />
</Field>
<RecordLanguageHint />
</div>
<Field label={t('fields.amount')} htmlFor="na-amount" error={errors.amount} required><MoneyInput id="na-amount" value={v.amount} onChange={set('amount')} currency={v.currency} /></Field>
<Field label={t('fields.currency')} htmlFor="na-currency" error={errors.currency} required>
<Input id="na-currency" maxLength={3} className="uppercase" value={v.currency} onChange={(e: React.ChangeEvent<HTMLInputElement>) => set('currency')(e.target.value.toUpperCase())} />
</Field>
<Field label={t('fields.due')} htmlFor="na-due" required><DatePicker id="na-due" value={v.due_on} onChange={(x?: string) => set('due_on')(x ?? todayISO())} /></Field>
<Field label={t('fields.payout')} htmlFor="na-channel">
<Select id="na-channel" aria-label={t('fields.payout')} value={v.channel} onChange={set('channel')}
options={[{ value: '', label: t('fields.payoutLater') }, ...(channels.data?.channels ?? []).map((c) => ({ value: c.id, label: c.label }))]} />
</Field>
</div>
{errors.form && <p role="alert" className="mt-3 text-sm text-destructive">{errors.form}</p>}
</Dialog>
);
}
  • Step 6: Drawer, legs, acquittal
web/src/features/advances/LegPanel.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Dialog } from '@/components/ui/Dialog';
import { Field } from '@/components/ui/Field';
import { toast } from '@/components/ui/Toaster';
import { MoneyText } from '@/components/money';
import { DecisionTimeline, StatusChip, TransitionBar, useWorkflowDefinition } from '@/components/workflow';
import { DocumentUploader, type UploadedDoc } from '@/components/upload';
import { ProblemError, problemMessage } from '@/lib/api/problem';
import type { AvailableTransition, Schemas } from '@/lib/api/types';
import { pickLabel, useLocale } from '@/lib/i18n';
import { useAdvanceTransition } from './api';
export function LegPanel({ advanceId, leg, currency, refetch }: { advanceId: string; leg: Schemas['AdvanceLeg']; currency: string; refetch(): void }) {
const { t } = useTranslation('advances');
const { t: tc } = useTranslation();
const [locale] = useLocale();
const def = useWorkflowDefinition(leg.kind);
const move = useAdvanceTransition(advanceId);
const [payTarget, setPayTarget] = useState<AvailableTransition | null>(null);
const [proof, setProof] = useState<UploadedDoc[]>([]);
async function run(to: string, reason?: string, proofId?: string) {
try {
await move.mutateAsync({ kind: leg.kind, from: leg.state, to, reason, ...(proofId ? { proof_document_id: proofId } : {}) });
toast.success(t('moved'));
} catch (e) {
toast.error(problemMessage(e, tc));
if (e instanceof ProblemError && e.status === 409) refetch();
}
}
return (
<section className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4" aria-label={t(`legs.${leg.kind}`)}>
<div className="flex items-center justify-between">
<h4 className="font-bold">{t(`legs.${leg.kind}`)}</h4>
<StatusChip kind={leg.kind} version={def.data?.version} state={leg.state} semantic={leg.semantic} />
</div>
<MoneyText amount={leg.amount} currency={currency} />
<TransitionBar transitions={leg.available_transitions} layout="bar" onTake={async (tr: AvailableTransition, reason?: string) => {
if (def.data?.graph.states.find((s) => s.key === tr.to)?.semantic === 'payout') return setPayTarget(tr);
await run(tr.to, reason);
}} />
{def.data && <DecisionTimeline kind={leg.kind} version={def.data.version} currentState={leg.state} decisions={leg.decisions.map((d) => ({ ...d, action_key: '' }))} />}
{payTarget && (
<Dialog open onOpenChange={(o: boolean) => !o && setPayTarget(null)} title={pickLabel(payTarget.labels, locale)} description={t('pay.description')}
footer={<><Button variant="outline" onClick={() => setPayTarget(null)}>{t('cancel')}</Button>
<Button loading={move.isPending} onClick={async () => { await run(payTarget.to, undefined, proof[0]?.id); setPayTarget(null); }}>{t('pay.confirm')}</Button></>}>
<Field label={t('pay.proof')} hint={t('pay.proofHint')}>
<DocumentUploader multiple={false} value={proof} onUploaded={(d) => setProof([d])} onRemoved={() => setProof([])} />
</Field>
</Dialog>
)}
</section>
);
}
// web/src/features/advances/AcquittalPanel.tsx — §13.5 #7; every figure is the server's
import { useTranslation } from 'react-i18next';
import { MoneyText } from '@/components/money';
import { DocumentThumb } from '@/components/upload';
import type { Schemas } from '@/lib/api/types';
import { formatDate } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
export function AcquittalPanel({ view, base }: { view: Schemas['AdvanceView']; base: string }) {
const { t } = useTranslation('advances');
const [locale] = useLocale();
const a = view.acquittal;
if (!a) return null;
return (
<section aria-labelledby="adv-acq" className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4">
<h3 id="adv-acq" className="font-bold">{t('acquittal.title')}</h3>
<dl className="grid grid-cols-3 gap-3 text-center">
<div><dt className="text-xs text-muted-foreground">{t('acquittal.advanced')}</dt><dd className="font-bold"><MoneyText amount={view.advance.base_amount} currency={base} /></dd></div>
<div><dt className="text-xs text-muted-foreground">{t('acquittal.spent')}</dt><dd className="font-bold"><MoneyText amount={a.actual_base} currency={base} /></dd></div>
<div><dt className="text-xs text-muted-foreground">{t('acquittal.variance')}</dt><dd className="font-bold">{a.variance === null ? '—' : <MoneyText amount={a.variance} currency={base} sign="always" />}</dd></div>
</dl>
<p className="rounded-lg bg-accent p-2 text-center text-sm font-medium">{a.outcome ? t(`acquittal.outcomes.${a.outcome}`) : t('acquittal.undecided')}</p>
<dl className="grid grid-cols-[9rem_1fr] gap-y-1 text-sm">
{a.actual_currency !== base && (<><dt className="text-muted-foreground">{t('acquittal.actual')}</dt><dd><MoneyText amount={a.actual_amount} currency={a.actual_currency} /></dd></>)}
{a.receipt_date && (<><dt className="text-muted-foreground">{t('fields.receiptDate')}</dt><dd>{formatDate(a.receipt_date, locale)}</dd></>)}
{a.reason && (<><dt className="text-muted-foreground">{t('fields.reason')}</dt><dd>{a.reason}</dd></>)}
{a.supplier_name && (<><dt className="text-muted-foreground">{t('fields.supplier')}</dt><dd>{a.supplier_name}</dd></>)}
{a.document_basis && (<><dt className="text-muted-foreground">{t('fields.basis')}</dt><dd>{t(`basis.${a.document_basis}`)}</dd></>)}
</dl>
{a.receipt_document_id && <DocumentThumb documentId={a.receipt_document_id} />}
</section>
);
}
web/src/features/advances/AcquittalForm.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Checkbox } from '@/components/ui/Checkbox';
import { DatePicker } from '@/components/ui/DatePicker';
import { Field } from '@/components/ui/Field';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Textarea } from '@/components/ui/Textarea';
import { toast } from '@/components/ui/Toaster';
import { MoneyInput } from '@/components/money';
import { DocumentUploader, type UploadedDoc } from '@/components/upload';
import { ProblemError, problemMessage } from '@/lib/api/problem';
import type { Schemas } from '@/lib/api/types';
import { todayISO } from '@/lib/date';
import { pickLabel, useLocale } from '@/lib/i18n';
import { isMoneyInput } from '@/lib/money';
import { useCategoriesForAcquittal, useRecordAcquittal } from './api';
import { RecordLanguageHint } from './RecordLanguageHint';
const BASES = ['einvoice_company', 'einvoice_employee', 'foreign_supplier', 'relaxation_receipt'] as const;
export function AcquittalForm({ view }: { view: Schemas['AdvanceView'] }) {
const { t } = useTranslation('advances');
const { t: tc } = useTranslation();
const [locale] = useLocale();
const cats = useCategoriesForAcquittal();
const record = useRecordAcquittal(view.advance.id);
const [nothing, setNothing] = useState(false);
const [v, setV] = useState({ amount: '', currency: view.advance.currency, date: todayISO(), category: '', basis: '', supplier: '', tin: '', einvoice: '', relaxation: '', reason: '' });
const [receipt, setReceipt] = useState<UploadedDoc[]>([]);
const [errors, setErrors] = useState<Record<string, string>>({});
const set = (k: keyof typeof v) => (x: string) => setV((s) => ({ ...s, [k]: x }));
async function submit(e: React.FormEvent) {
e.preventDefault();
const err: Record<string, string> = {};
if (nothing && !v.reason.trim()) err.reason = t('errors.reason');
if (!nothing && (!isMoneyInput(v.amount) || /^0*(\.0*)?$/.test(v.amount))) err.amount = t('errors.amount');
if (!nothing && receipt.length === 0) err.receipt = t('errors.receipt');
setErrors(err);
if (Object.keys(err).length) return;
const body: Schemas['AcquittalInput'] = nothing
? { actual_amount: '0', reason: v.reason.trim() }
: {
actual_amount: v.amount, actual_currency: v.currency, receipt_date: v.date, receipt_document_id: receipt[0].id,
category_id: v.category || null, document_basis: (v.basis || null) as Schemas['AcquittalInput']['document_basis'],
supplier_name: v.supplier.trim() || null, supplier_tin: v.tin.trim() || null,
einvoice_uuid: v.basis.startsWith('einvoice') ? v.einvoice.trim() || null : null,
relaxation_reason: v.basis === 'relaxation_receipt' ? v.relaxation.trim() || null : null,
};
try {
await record.mutateAsync({ revision: view.advance.revision, body });
toast.success(t('acquittal.saved'));
} catch (x) {
setErrors({ form: x instanceof ProblemError && x.detail ? x.detail : problemMessage(x, tc) });
}
}
return (
<form aria-label={t('acquittal.formTitle')} onSubmit={submit} className="flex flex-col gap-4 rounded-xl border border-primary bg-card p-4" noValidate>
<h3 className="font-bold">{t('acquittal.formTitle')}</h3>
<p className="text-sm text-muted-foreground">{t('acquittal.formHint')}</p>
<label className="flex items-center gap-2 text-sm">
<Checkbox id="aq-nothing" role="switch" aria-label={t('acquittal.nothing')} checked={nothing} onCheckedChange={(c: boolean) => setNothing(c)} />
{t('acquittal.nothing')}
</label>
{nothing ? (
<Field label={t('fields.reason')} htmlFor="aq-reason" error={errors.reason} required>
<Textarea id="aq-reason" rows={2} value={v.reason} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => set('reason')(e.target.value)} />
</Field>
) : (
<div className="grid gap-4 md:grid-cols-2">
<Field label={t('fields.actualAmount')} htmlFor="aq-amount" error={errors.amount} required><MoneyInput id="aq-amount" value={v.amount} onChange={set('amount')} currency={v.currency} /></Field>
<Field label={t('fields.currency')} htmlFor="aq-currency" required><Input id="aq-currency" maxLength={3} value={v.currency} onChange={(e: React.ChangeEvent<HTMLInputElement>) => set('currency')(e.target.value.toUpperCase())} /></Field>
<Field label={t('fields.receiptDate')} htmlFor="aq-date" required><DatePicker id="aq-date" value={v.date} onChange={(x?: string) => set('date')(x ?? todayISO())} /></Field>
<Field label={t('fields.category')} htmlFor="aq-cat">
<Select id="aq-cat" aria-label={t('fields.category')} value={v.category} onChange={set('category')} placeholder={t('placeholders.category')}
options={(cats.data ?? []).map((c) => ({ value: c.id, label: pickLabel(c.names, locale) || c.name }))} />
</Field>
<Field label={t('fields.basis')} htmlFor="aq-basis">
<Select id="aq-basis" aria-label={t('fields.basis')} value={v.basis} onChange={set('basis')} placeholder={t('placeholders.basis')} options={BASES.map((b) => ({ value: b, label: t(`basis.${b}`) }))} />
</Field>
<Field label={t('fields.supplier')} htmlFor="aq-supplier"><Input id="aq-supplier" value={v.supplier} onChange={(e: React.ChangeEvent<HTMLInputElement>) => set('supplier')(e.target.value)} /></Field>
<Field label={t('fields.tin')} htmlFor="aq-tin"><Input id="aq-tin" value={v.tin} onChange={(e: React.ChangeEvent<HTMLInputElement>) => set('tin')(e.target.value)} /></Field>
{v.basis.startsWith('einvoice') && <Field label={t('fields.einvoice')} htmlFor="aq-einv"><Input id="aq-einv" value={v.einvoice} onChange={(e: React.ChangeEvent<HTMLInputElement>) => set('einvoice')(e.target.value)} /></Field>}
{v.basis === 'relaxation_receipt' && <Field label={t('fields.relaxation')} htmlFor="aq-relax"><Textarea id="aq-relax" rows={2} value={v.relaxation} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => set('relaxation')(e.target.value)} /></Field>}
<div className="md:col-span-2">
<Field label={t('fields.receipt')} error={errors.receipt} required><DocumentUploader multiple={false} value={receipt} onUploaded={(d) => setReceipt([d])} onRemoved={() => setReceipt([])} /></Field>
</div>
</div>
)}
<RecordLanguageHint />
{errors.form && <p role="alert" className="text-sm text-destructive">{errors.form}</p>}
<div className="flex justify-end"><Button type="submit" loading={record.isPending}>{t('acquittal.save')}</Button></div>
</form>
);
}
web/src/features/advances/AdvanceDrawer.tsx
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Drawer } from '@/components/ui/Drawer';
import { EmptyState } from '@/components/ui/EmptyState';
import { Skeleton } from '@/components/ui/Skeleton';
import { MoneyText } from '@/components/money';
import { StatusChip, useWorkflowDefinition } from '@/components/workflow';
import { useMe } from '@/features/auth';
import { useMemberNames } from '@/features/members/useMemberNames';
import { ProblemError, problemMessage } from '@/lib/api/problem';
import { formatDate, formatDateTime } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import { AcquittalForm } from './AcquittalForm';
import { AcquittalPanel } from './AcquittalPanel';
import { useAdvance } from './api';
import { awaitingAcquittal } from './awaitingAcquittal';
import { LegPanel } from './LegPanel';
export function AdvanceDrawer({ id, onClose }: { id: string; onClose(): void }) {
const { t } = useTranslation('advances');
const { t: tc } = useTranslation();
const [locale] = useLocale();
const { data: me } = useMe();
const name = useMemberNames();
const base = me?.organization.base_currency ?? 'MYR';
const q = useAdvance(id);
const def = useWorkflowDefinition('advance');
const v = q.data;
return (
<Drawer open onOpenChange={(o: boolean) => !o && onClose()} header={<h2 className="text-lg font-bold">{t('detailTitle')}</h2>}
footer={<Button className="w-full" onClick={onClose}>{t('close')}</Button>}>
{q.isLoading && <Skeleton className="h-96 w-full" />}
{q.error && <EmptyState title={q.error instanceof ProblemError && q.error.status === 404 ? t('notFound') : problemMessage(q.error, tc)} />}
{v && (
<div className="flex flex-col gap-4">
<div>
<div className="flex items-start justify-between gap-2"><h3 className="text-xl font-bold">{v.advance.purpose}</h3>
<StatusChip kind="advance" version={def.data?.version} state={v.legs[0]?.state ?? v.advance.state} semantic={v.advance.semantic} /></div>
<p className="text-xs text-muted-foreground">{v.advance.reference} · {name(v.advance.claimant_member_id)} · {t('dueOn', { date: formatDate(v.advance.due_on, locale) })}</p>
</div>
{v.overdue && (
<p role="status" className="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm">
{t(`overdue.${v.overdue.level}`, { count: v.overdue.days_overdue })}
</p>
)}
<div className="rounded-xl bg-muted p-4">
<MoneyText className="text-xl font-bold" amount={v.advance.amount} currency={v.advance.currency} />
{v.advance.currency !== base && <p className="text-xs text-muted-foreground"><MoneyText amount={v.advance.base_amount} currency={base} /> · {t('rate', { rate: v.advance.fx_rate })}</p>}
</div>
{v.legs.map((leg) => <LegPanel key={leg.kind} advanceId={id} leg={leg} currency={leg.kind === 'advance' ? v.advance.currency : base} refetch={() => void q.refetch()} />)}
<AcquittalPanel view={v} base={base} />
{v.advance.claimant_member_id === me?.member_id && awaitingAcquittal(v, def.data) && <AcquittalForm view={v} />}
{v.settlements.length > 0 && (
<section aria-labelledby="adv-pay" className="rounded-xl border border-border bg-card p-4">
<h3 id="adv-pay" className="mb-2 font-bold">{t('settlements')}</h3>
<ul className="flex flex-col gap-2 text-sm">
{v.settlements.map((s) => (
<li key={s.id} className="flex justify-between">
<span>{s.channel_name}{s.account_tail && <span className="ml-1 font-mono text-muted-foreground">•••• {s.account_tail}</span>}
<span className="block text-xs text-muted-foreground">{formatDateTime(s.settled_at, locale)}</span></span>
<MoneyText className="font-bold" amount={s.amount} currency={s.currency} />
</li>
))}
</ul>
</section>
)}
</div>
)}
</Drawer>
);
}
// web/src/features/advances/RecordLanguageHint.tsx — C6
import { useTranslation } from 'react-i18next';
import { useLocale } from '@/lib/i18n';
export function RecordLanguageHint() {
const { t } = useTranslation('advances');
const [locale] = useLocale();
return locale === 'zh' ? <p className="text-xs text-muted-foreground">{t('recordLanguageHint')}</p> : null;
}
  • Step 7: Routes and nav
web/src/features/advances/routes.tsx
import { createRoute, Outlet } from '@tanstack/react-router';
import { appRoute } from '@/app/layouts';
import { RequirePermission } from '@/features/auth';
import { AdvanceDrawer } from './AdvanceDrawer';
import { AdvancesListPage } from './AdvancesListPage';
import { NewAdvanceDialog } from './NewAdvanceDialog';
import { advanceSearchSchema, type AdvanceSearch } from './search';
const advancesRoute = createRoute({
getParentRoute: () => appRoute,
path: 'advances',
validateSearch: (s: Record<string, unknown>) => advanceSearchSchema.parse(s),
component: function AdvancesRoute() {
const search = advancesRoute.useSearch();
const navigate = advancesRoute.useNavigate();
return (
<RequirePermission permission="advances.advance.read">
<AdvancesListPage search={search}
onScope={(scope) => void navigate({ search: { scope }, replace: true })}
onOpen={(id) => void navigate({ to: '/advances/$id', params: { id }, search: (p: AdvanceSearch) => p })}
onNew={() => void navigate({ to: '/advances/new', search: (p: AdvanceSearch) => p })}>
<Outlet />
</AdvancesListPage>
</RequirePermission>
);
},
});
const newRoute = createRoute({ getParentRoute: () => advancesRoute, path: 'new', component: function R() {
const navigate = newRoute.useNavigate();
return <NewAdvanceDialog onClose={() => void navigate({ to: '/advances', search: (p: AdvanceSearch) => p })}
onDone={(id) => void navigate({ to: '/advances/$id', params: { id }, search: (p: AdvanceSearch) => p })} />;
} });
const detailRoute = createRoute({ getParentRoute: () => advancesRoute, path: '$id', component: function R() {
const { id } = detailRoute.useParams();
const navigate = detailRoute.useNavigate();
return <AdvanceDrawer id={id} onClose={() => void navigate({ to: '/advances', search: (p: AdvanceSearch) => p })} />;
} });
export const routes = [advancesRoute.addChildren([newRoute, detailRoute])] as const;

The list test clicks New advance, which navigates to /advances/new; the dialog is that route’s component.

Append to web/src/app/routes.ts: import { routes as advancesRoutes } from '@/features/advances/routes'; / ...advancesRoutes,. Append to web/src/app/shell/nav.ts:

import { HandCoins } from 'lucide-react';
{ group: 'requests', to: '/advances', labelKey: 'advances:nav', icon: HandCoins, permission: 'advances.advance.read' },
  • Step 8: i18n bundles

en/advances.json

{
"nav": "Advances",
"title": "Advances",
"subtitle": "Cash paid to you before you spend it, and how you account for it",
"new": "New advance", "newTitle": "New advance", "newDescription": "Say what the advance is for, how much, and when you will account for it.",
"saveDraft": "Save draft", "submit": "Submit", "cancel": "Cancel", "close": "Close", "moved": "Done",
"draftSaved": "Draft saved", "submitted": "Advance {{reference}} submitted",
"detailTitle": "Advance details", "notFound": "This advance was not found, or you cannot see it.",
"dueOn": "due {{date}}", "rate": "rate {{rate}}", "settlements": "Payouts",
"recordLanguageHint": "Records are kept in English or Malay. Please type the purpose and reasons in English or Malay.",
"kpi": { "label": "Advance summary", "pending": "Pending", "approved": "Approved", "paid": "Paid out", "submitted": "Total requested" },
"tabs": { "mine": "My advances", "to_act": "My approvals", "all": "All advances" },
"columns": { "purpose": "Purpose", "amount": "Amount", "due": "Due", "status": "Status", "reference": "Reference" },
"empty": { "title": "No advances here yet", "description": "Advances you request will appear here." },
"overdue": { "claimant_one": "{{count}} day overdue · please record your acquittal", "claimant_other": "{{count}} days overdue · please record your acquittal", "manager_one": "{{count}} day overdue · your manager is now reminded too", "manager_other": "{{count}} days overdue · your manager is now reminded too" },
"legs": { "advance": "Advance", "advance_topup": "Top-up of the excess", "advance_refund": "Refund of the unspent" },
"pay": { "description": "Attach proof of payment if this payout method needs one.", "proof": "Proof of payment", "proofHint": "JPG, PNG or PDF", "confirm": "Confirm payment" },
"acquittal": {
"title": "Acquittal", "advanced": "Advanced", "spent": "Spent", "variance": "Variance", "actual": "Spent (original currency)",
"undecided": "Recorded, waiting for a decision",
"outcomes": { "settled": "Exact — nothing to settle", "topup": "Top-up to you", "refund": "Refund to the company", "returned": "Returned in full" },
"formTitle": "Record acquittal", "formHint": "Tell us what you actually spent. The difference is worked out when your acquittal is accepted.",
"nothing": "Nothing was spent", "save": "Save acquittal", "saved": "Acquittal recorded"
},
"fields": { "purpose": "Purpose", "amount": "Amount", "currency": "Currency", "due": "Account for it by", "payout": "Payout method", "payoutLater": "Choose later", "actualAmount": "Amount spent", "receiptDate": "Receipt date", "category": "Category", "basis": "Receipt evidence", "supplier": "Supplier", "tin": "Supplier TIN", "einvoice": "e-Invoice UUID", "relaxation": "Why there is no e-Invoice", "receipt": "Receipt", "reason": "Reason" },
"placeholders": { "category": "Choose a category", "basis": "Choose the evidence" },
"basis": { "einvoice_company": "e-Invoice to the company", "einvoice_employee": "e-Invoice in my name", "foreign_supplier": "Foreign supplier invoice", "relaxation_receipt": "Receipt without e-Invoice" },
"errors": { "purpose": "Say what the advance is for", "amount": "Enter an amount greater than zero", "currency": "Use a three-letter currency code", "reason": "Say why nothing was spent", "receipt": "Attach the receipt", "cannotSubmit": "This advance cannot be submitted right now." }
}

ms/advances.json

{
"nav": "Pendahuluan",
"title": "Pendahuluan",
"subtitle": "Tunai yang dibayar kepada anda sebelum dibelanjakan, dan cara anda mengakaunkannya",
"new": "Pendahuluan baharu", "newTitle": "Pendahuluan baharu", "newDescription": "Nyatakan tujuan, amaun dan bila anda akan mengakaunkannya.",
"saveDraft": "Simpan draf", "submit": "Hantar", "cancel": "Batal", "close": "Tutup", "moved": "Selesai",
"draftSaved": "Draf disimpan", "submitted": "Pendahuluan {{reference}} dihantar",
"detailTitle": "Butiran pendahuluan", "notFound": "Pendahuluan ini tidak ditemui, atau anda tidak boleh melihatnya.",
"dueOn": "tarikh akhir {{date}}", "rate": "kadar {{rate}}", "settlements": "Pembayaran",
"recordLanguageHint": "Rekod disimpan dalam bahasa Inggeris atau Melayu. Sila taip tujuan dan sebab dalam bahasa Inggeris atau Melayu.",
"kpi": { "label": "Ringkasan pendahuluan", "pending": "Menunggu", "approved": "Diluluskan", "paid": "Dibayar", "submitted": "Jumlah dipohon" },
"tabs": { "mine": "Pendahuluan saya", "to_act": "Kelulusan saya", "all": "Semua pendahuluan" },
"columns": { "purpose": "Tujuan", "amount": "Amaun", "due": "Tarikh akhir", "status": "Status", "reference": "Rujukan" },
"empty": { "title": "Belum ada pendahuluan", "description": "Pendahuluan yang anda mohon dipaparkan di sini." },
"overdue": { "claimant_one": "Lewat {{count}} hari · sila rekodkan penyelesaian anda", "claimant_other": "Lewat {{count}} hari · sila rekodkan penyelesaian anda", "manager_one": "Lewat {{count}} hari · pengurus anda kini turut diingatkan", "manager_other": "Lewat {{count}} hari · pengurus anda kini turut diingatkan" },
"legs": { "advance": "Pendahuluan", "advance_topup": "Tambahan lebihan", "advance_refund": "Bayaran balik baki" },
"pay": { "description": "Lampirkan bukti pembayaran jika kaedah ini memerlukannya.", "proof": "Bukti pembayaran", "proofHint": "JPG, PNG atau PDF", "confirm": "Sahkan pembayaran" },
"acquittal": {
"title": "Penyelesaian", "advanced": "Didahulukan", "spent": "Dibelanjakan", "variance": "Perbezaan", "actual": "Dibelanjakan (mata wang asal)",
"undecided": "Direkodkan, menunggu keputusan",
"outcomes": { "settled": "Tepat — tiada perlu diselesaikan", "topup": "Tambahan kepada anda", "refund": "Bayaran balik kepada syarikat", "returned": "Dipulangkan sepenuhnya" },
"formTitle": "Rekod penyelesaian", "formHint": "Nyatakan amaun sebenar yang dibelanjakan. Perbezaan dikira apabila penyelesaian anda diterima.",
"nothing": "Tiada yang dibelanjakan", "save": "Simpan penyelesaian", "saved": "Penyelesaian direkodkan"
},
"fields": { "purpose": "Tujuan", "amount": "Amaun", "currency": "Mata wang", "due": "Diakaunkan sebelum", "payout": "Kaedah pembayaran", "payoutLater": "Pilih kemudian", "actualAmount": "Amaun dibelanjakan", "receiptDate": "Tarikh resit", "category": "Kategori", "basis": "Bukti resit", "supplier": "Pembekal", "tin": "TIN pembekal", "einvoice": "UUID e-Invois", "relaxation": "Sebab tiada e-Invois", "receipt": "Resit", "reason": "Sebab" },
"placeholders": { "category": "Pilih kategori", "basis": "Pilih bukti" },
"basis": { "einvoice_company": "e-Invois kepada syarikat", "einvoice_employee": "e-Invois atas nama saya", "foreign_supplier": "Invois pembekal asing", "relaxation_receipt": "Resit tanpa e-Invois" },
"errors": { "purpose": "Nyatakan tujuan pendahuluan", "amount": "Masukkan amaun lebih daripada sifar", "currency": "Gunakan kod mata wang tiga huruf", "reason": "Nyatakan mengapa tiada yang dibelanjakan", "receipt": "Lampirkan resit", "cannotSubmit": "Pendahuluan ini tidak boleh dihantar sekarang." }
}

zh/advances.json

{
"nav": "预支款",
"title": "预支款",
"subtitle": "先行支付给您的款项,以及核销情况",
"new": "申请预支", "newTitle": "申请预支", "newDescription": "填写用途、金额以及核销期限。",
"saveDraft": "保存草稿", "submit": "提交", "cancel": "取消", "close": "关闭", "moved": "已完成",
"draftSaved": "草稿已保存", "submitted": "预支款 {{reference}} 已提交",
"detailTitle": "预支款详情", "notFound": "找不到这笔预支款,或您无权查看。",
"dueOn": "{{date}} 前核销", "rate": "汇率 {{rate}}", "settlements": "付款记录",
"recordLanguageHint": "记录须使用英文或马来文,请用英文或马来文填写用途和原因。",
"kpi": { "label": "预支款概览", "pending": "待审核", "approved": "已批准", "paid": "已支付", "submitted": "总申请" },
"tabs": { "mine": "我的预支", "to_act": "我的审批", "all": "所有预支" },
"columns": { "purpose": "用途", "amount": "金额", "due": "核销期限", "status": "状态", "reference": "编号" },
"empty": { "title": "暂无预支款", "description": "您申请的预支款会显示在这里。" },
"overdue": { "claimant_one": "已逾期 {{count}} 天 · 请尽快核销", "claimant_other": "已逾期 {{count}} 天 · 请尽快核销", "manager_one": "已逾期 {{count}} 天 · 已同时提醒您的主管", "manager_other": "已逾期 {{count}} 天 · 已同时提醒您的主管" },
"legs": { "advance": "预支款", "advance_topup": "超支补付", "advance_refund": "余款退还" },
"pay": { "description": "如该收款方式需要,请附上付款凭证。", "proof": "付款凭证", "proofHint": "JPG、PNG 或 PDF", "confirm": "确认付款" },
"acquittal": {
"title": "核销", "advanced": "预支金额", "spent": "实际花费", "variance": "差额", "actual": "实际花费(原币)",
"undecided": "已记录,等待审核",
"outcomes": { "settled": "金额一致,无需结算", "topup": "公司补付给您", "refund": "退还给公司", "returned": "已全额退回" },
"formTitle": "登记核销", "formHint": "请填写实际花费,差额将在核销通过时计算。",
"nothing": "没有任何花费", "save": "保存核销", "saved": "核销已登记"
},
"fields": { "purpose": "用途", "amount": "金额", "currency": "币种", "due": "核销期限", "payout": "收款方式", "payoutLater": "稍后选择", "actualAmount": "实际花费", "receiptDate": "收据日期", "category": "费用类别", "basis": "收据类型", "supplier": "供应商", "tin": "供应商税号", "einvoice": "电子发票 UUID", "relaxation": "无电子发票的原因", "receipt": "收据", "reason": "原因" },
"placeholders": { "category": "选择类别", "basis": "选择收据类型" },
"basis": { "einvoice_company": "开给公司的电子发票", "einvoice_employee": "开给本人的电子发票", "foreign_supplier": "境外供应商发票", "relaxation_receipt": "无电子发票的收据" },
"errors": { "purpose": "请说明预支用途", "amount": "请输入大于零的金额", "currency": "请使用三个字母的币种代码", "reason": "请说明为何没有花费", "receipt": "请附上收据", "cannotSubmit": "这笔预支款目前无法提交。" }
}
  • Step 9: Run the tests

Run: pnpm -C web test -- src/features/advances src/drift Expected: PASS. Then typecheck and lint.

  • Step 10: Runtime check

Dev stack and preview; the seed has one advance at received for the claimant, one pending for the approver, and one with a recorded acquittal showing a refund. At 1440, light, en:

  1. /advances as the claimant: KPI cards equal curl …/v1/advances/summary?scope=mine | jq .kpis; a USD advance shows its base amount under it.
  2. New advance → Submit: POST /v1/advances then POST …/transitions with {kind:"advance", from:"draft", to:"pending_review"} and an Idempotency-Key; the drawer opens at Pending review.
  3. Open the received advance: the Record acquittal form shows (the approver opening the same advance does not see it). Record a receipt of less than the advance: the panel then shows Advanced / Spent / Variance with a sign, and Refund to the company — compare with the API response; the numbers are copied, not computed.
  4. As the approver: Accept acquittal on the Advance leg; a Refund of the unspent leg appears with its own chip and buttons; its move sends kind:"advance_refund".
  5. Past-due advance: the overdue banner reads the day count from overdue. Then 390px, dark, zh (预支款, 核销, 预支金额 / 实际花费 / 差额, 退还给公司).
  • Step 11: Commit
Terminal window
git add web/src/features/advances web/src/lib/i18n/locales/*/advances.json web/src/app/routes.ts web/src/app/shell/nav.ts
git commit -m "$(cat <<'MSG'
Plan 10 Task 17: advances (list with KPIs, legs, acquittal panel and form)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MSG
)"

Task 18: Team — members, invitations, roles

Section titled “Task 18: Team — members, invitations, roles”

No Figma frame (C, spec §5.8, F19, F20). The Figma nav has 团队 under 管理; pull the Components page (0:1) for tabs, table, checkbox and dialog styles.

Layout: /team with the header Team and tabs Members · Invitations · Roles, each tab shown only with its permission (org.members.invite for Invitations, org.roles.manage for Roles; Members with any of the team permissions).

  • Members (/team/members): a table — Name, Status (Active / Inactive tag), Roles (role-name tags; only with org.roles.manage, from each role’s member_ids), Positions (the seats the member holds today, from /org/chart; only with org.structure.read), and Edit roles (with org.roles.manage) opening a checkbox dialog of the organization’s roles. A search box filters the loaded names (a local filter of a loaded directory, not a paged list). Invite at the top right with org.members.invite.
  • Invitations (/team/invitations): pending invitations — Email, Role, Expires (relative), Sent; Revoke per row (confirm). Invite opens a dialog: email, role (select of roles), valid for (1 day / 3 days / 7 days). On success the dialog turns into a “send this link” panel: the link in a read-only field with Copy link, and the warning that it is shown once and works as a password.
  • Roles (/team/roles): two columns (stacked below 1024px). Left: the role list — name, a System tag for seeded roles, member count — and New role. Right: the selected role — name with Rename, then Start from a preset: Employee · Approver · Finance · Admin (F20: a preset only ticks boxes; nothing is saved until Save), then the permission matrix grouped by module (a heading per module, a checkbox per permission with a human label and the key in small grey mono), and a sticky footer Save changes / Discard shown while the ticks differ from the saved set. The two lock-out refusals (409, code owner_role_manage_required or self_lockout) show as an inline red alert above the footer, worded for the administrator.

Files:

  • Create: web/src/features/team/api.ts
  • Create: web/src/features/team/permissionText.ts
  • Create: web/src/features/team/TeamLayout.tsx
  • Create: web/src/features/team/routes.tsx
  • Create: web/src/features/team/members/MembersPage.tsx
  • Create: web/src/features/team/members/MemberRolesDialog.tsx
  • Create: web/src/features/team/invitations/InvitationsPage.tsx
  • Create: web/src/features/team/invitations/InviteDialog.tsx
  • Create: web/src/features/team/roles/RolesPage.tsx
  • Create: web/src/features/team/roles/PermissionMatrix.tsx
  • Create: web/src/features/team/roles/presets.ts
  • Create: web/src/features/team/roles/RoleNameDialog.tsx
  • Create: web/src/features/team/__tests__/fixtures.ts
  • Create: web/src/features/team/__tests__/RolesPage.test.tsx
  • Create: web/src/features/team/__tests__/MembersAndInvitations.test.tsx
  • Create: web/src/features/team/__tests__/presets.test.ts
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/team.json
  • Modify (append): web/src/app/routes.ts, web/src/app/shell/nav.ts

Interfaces:

  • Consumes: Task 2’s listRoles, createRole, renameRole, setRolePermissions, setMemberRoles, listPermissions (registry names), listMemberDirectory via useMemberDirectory; getOrgChart; issueInvitation, listInvitations, revokeInvitation; useCan, useMe; ui primitives; ProblemError with code.

  • Produces: teamKeys { roles: ['org','roles'], permissions: ['org','permissions'], invitations: ['org','invitations'], chart: ['org','chart'] } (Task 19 uses ['org','chart'] too — R6); ROLE_PRESETS; permissionLabel(t, key). Routes /team, /team/members, /team/invitations, /team/roles.

  • Step 0: Verify imports (C1) and Task 2’s shapes: grep -n "RoleList\|PermissionCatalogue\|setMemberRoles" web/src/lib/api/schema.d.ts. Confirm from internal/modules/identity/role_handlers.go whether renaming a system role is refused; if it is, hide Rename for system: true roles (Part C shows it for non-system roles only, which is the safe reading).

  • Step 1: Fixtures and failing tests

web/src/features/team/__tests__/fixtures.ts
import type { Schemas } from '@/lib/api/types';
export const ME = '00000000-0000-4000-8000-000000000001';
export const SITI = '00000000-0000-4000-8000-000000000003';
export const OWNER_ROLE = '00000000-0000-4000-8000-0000000e0001';
export const EMP_ROLE = '00000000-0000-4000-8000-0000000e0002';
export const members = [{ member_id: ME, name: 'Alex Tan', active: true }, { member_id: SITI, name: 'Siti Aminah', active: true }];
export const catalogue: Schemas['PermissionCatalogue'] = {
permissions: [
{ key: 'expenses.claim.read', module: 'expenses' }, { key: 'expenses.claim.create', module: 'expenses' }, { key: 'expenses.claim.transition', module: 'expenses' },
{ key: 'expenses.claim.read_all', module: 'expenses' }, { key: 'advances.advance.read', module: 'advances' }, { key: 'advances.advance.create', module: 'advances' },
{ key: 'advances.advance.transition', module: 'advances' }, { key: 'documents.document.upload', module: 'documents' }, { key: 'pettycash.read', module: 'pettycash' },
{ key: 'workflow.definition.read', module: 'workflow' }, { key: 'org.roles.manage', module: 'org' },
],
};
export const roles: Schemas['RoleList'] = {
roles: [
{ id: OWNER_ROLE, name: 'Owner', system: true, permissions: catalogue.permissions.map((p) => p.key), member_ids: [ME] },
{ id: EMP_ROLE, name: 'Employee', system: true, permissions: [], member_ids: [SITI] },
],
};
web/src/features/team/__tests__/presets.test.ts
import { describe, expect, it } from 'vitest';
import { applyPreset, ROLE_PRESETS } from '../roles/presets';
describe('role presets (F20)', () => {
it('ticks only preset permissions that exist in the catalogue', () => {
const got = applyPreset('employee', ['expenses.claim.read', 'expenses.claim.create', 'reports.read']);
expect(got).toEqual(['expenses.claim.create', 'expenses.claim.read']);
});
it('makes each preset a superset of the one before it', () => {
const [e, a, f] = [ROLE_PRESETS.employee, ROLE_PRESETS.approver, ROLE_PRESETS.finance];
expect(e.every((p) => a.includes(p))).toBe(true);
expect(a.every((p) => f.includes(p))).toBe(true);
});
it('admin is the whole catalogue', () => {
expect(applyPreset('admin', ['b.x', 'a.y'])).toEqual(['a.y', 'b.x']);
});
});
web/src/features/team/__tests__/RolesPage.test.tsx
import { describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/msw/server';
import { renderWithApp } from '@/test/renderWithApp';
import { catalogue, EMP_ROLE, members, roles } from './fixtures';
function mockApi(onPut?: (body: unknown) => Response | undefined) {
server.use(
http.get('*/v1/org/roles', () => HttpResponse.json(roles)),
http.get('*/v1/org/permissions', () => HttpResponse.json(catalogue)),
http.get('*/v1/org/chart', () => HttpResponse.json({ as_of: '2026-09-23', units: [], positions: [] })),
http.put('*/v1/org/roles/:id/permissions', async ({ request }) => {
const body = await request.json();
const override = onPut?.(body);
return override ?? HttpResponse.json({ ...roles.roles[1], permissions: (body as { permissions: string[] }).permissions });
}),
);
}
describe('Team roles', () => {
it('shows the Roles tab only with org.roles.manage (presence first)', async () => {
mockApi();
const first = await renderWithApp({ path: '/team/members', permissions: ['org.roles.manage'], members });
expect(await screen.findByRole('tab', { name: 'Roles' })).toBeInTheDocument();
first.unmount();
mockApi();
await renderWithApp({ path: '/team/members', permissions: ['org.structure.read'], members });
expect(await screen.findByRole('tab', { name: 'Members' })).toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'Roles' })).not.toBeInTheDocument();
});
it('a preset only ticks boxes; Save sends the whole set', async () => {
const puts: unknown[] = [];
mockApi((b) => { puts.push(b); return undefined; });
const { user } = await renderWithApp({ path: `/team/roles?role=${EMP_ROLE}`, permissions: ['org.roles.manage'], members });
const matrix = await screen.findByRole('group', { name: 'Permissions of Employee' });
expect(within(matrix).getByRole('checkbox', { name: /Read own claims/ })).not.toBeChecked();
await user.click(screen.getByRole('button', { name: 'Employee preset' }));
expect(within(matrix).getByRole('checkbox', { name: /Read own claims/ })).toBeChecked();
expect(within(matrix).getByRole('checkbox', { name: /Read all claims/ })).not.toBeChecked();
expect(puts).toHaveLength(0);
await user.click(screen.getByRole('button', { name: 'Save changes' }));
await waitFor(() => expect(puts).toHaveLength(1));
expect((puts[0] as { permissions: string[] }).permissions).toEqual([
'advances.advance.create', 'advances.advance.read', 'advances.advance.transition', 'documents.document.upload',
'expenses.claim.create', 'expenses.claim.read', 'expenses.claim.transition', 'pettycash.read', 'workflow.definition.read',
]);
});
it('shows a lock-out refusal inline, not as a silent failure', async () => {
mockApi(() => HttpResponse.json({ type: 'https://errors.example/conflict', title: 'Conflict', status: 409, code: 'self_lockout', detail: 'x' }, { status: 409 }));
const { user } = await renderWithApp({ path: `/team/roles?role=${EMP_ROLE}`, permissions: ['org.roles.manage'], members });
await user.click(await screen.findByRole('button', { name: 'Employee preset' }));
await user.click(screen.getByRole('button', { name: 'Save changes' }));
expect(await screen.findByRole('alert')).toHaveTextContent('You would lose the right to manage roles');
});
});
web/src/features/team/__tests__/MembersAndInvitations.test.tsx
import { describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/msw/server';
import { renderWithApp } from '@/test/renderWithApp';
import { catalogue, EMP_ROLE, members, roles, SITI } from './fixtures';
function mockApi(calls: { method: string; path: string; body: unknown }[] = []) {
const cap = async (method: string, r: Request) => calls.push({ method, path: new URL(r.url).pathname, body: await r.json() });
server.use(
http.get('*/v1/org/roles', () => HttpResponse.json(roles)),
http.get('*/v1/org/permissions', () => HttpResponse.json(catalogue)),
http.get('*/v1/org/chart', () => HttpResponse.json({ as_of: '2026-09-23', units: [], positions: [
{ id: 'p1', org_unit_id: 'u1', code: 'FIN-MGR', title: 'Finance Manager', depth: 1, reports_to: null, active: true, holders: [{ member_id: SITI, name: 'Siti Aminah' }] },
] })),
http.get('*/v1/org/invitations', () => HttpResponse.json([{ id: 'inv1', email: 'new@example.com', role_id: EMP_ROLE, expires_at: '2026-09-30T00:00:00Z', created_at: '2026-09-23T00:00:00Z' }])),
http.post('*/v1/org/invitations', async ({ request }) => {
await cap('POST', request);
return HttpResponse.json({ invitation: { id: 'inv2', email: 'lee@example.com', role_id: EMP_ROLE, expires_at: '2026-09-30T00:00:00Z', created_at: '2026-09-23T00:00:00Z' }, link: 'https://app.example/accept?token=abc', token: 'abc' }, { status: 201 });
}),
http.put('*/v1/org/members/:id/roles', async ({ request, params }) => { await cap('PUT', request); return HttpResponse.json({ member_id: params.id, role_ids: [EMP_ROLE] }); }),
);
}
const ADMIN = ['org.roles.manage', 'org.members.invite', 'org.structure.read'];
describe('Team members and invitations', () => {
it('lists members with their roles and the seats they hold today', async () => {
mockApi();
await renderWithApp({ path: '/team/members', permissions: ADMIN, members });
const row = (await screen.findByText('Siti Aminah')).closest('tr')!;
expect(within(row).getByText('Employee')).toBeInTheDocument();
expect(within(row).getByText('Finance Manager')).toBeInTheDocument();
});
it('replaces a member’s roles with the ticked set', async () => {
const calls: { method: string; path: string; body: unknown }[] = [];
mockApi(calls);
const { user } = await renderWithApp({ path: '/team/members', permissions: ADMIN, members });
const row = (await screen.findByText('Siti Aminah')).closest('tr')!;
await user.click(within(row).getByRole('button', { name: 'Edit roles' }));
const dialog = await screen.findByRole('dialog');
await user.click(within(dialog).getByRole('checkbox', { name: 'Owner' }));
await user.click(within(dialog).getByRole('button', { name: 'Save' }));
await waitFor(() => expect(calls[0]).toMatchObject({ method: 'PUT', path: `/v1/org/members/${SITI}/roles` }));
expect((calls[0].body as { role_ids: string[] }).role_ids.sort()).toEqual([roles.roles[0].id, EMP_ROLE].sort());
});
it('issues an invitation and shows its link once, with copy', async () => {
const calls: { method: string; path: string; body: unknown }[] = [];
mockApi(calls);
const { user } = await renderWithApp({ path: '/team/invitations', permissions: ADMIN, members });
expect(await screen.findByText('new@example.com')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Invite' }));
const dialog = await screen.findByRole('dialog', { name: 'Invite someone' });
await user.type(within(dialog).getByRole('textbox', { name: 'Email' }), 'lee@example.com');
await user.click(within(dialog).getByRole('combobox', { name: 'Role' }));
await user.click(await screen.findByRole('option', { name: 'Employee' }));
await user.click(within(dialog).getByRole('button', { name: 'Send invitation' }));
await waitFor(() => expect(calls[0]).toMatchObject({ method: 'POST', body: { email: 'lee@example.com', role_id: EMP_ROLE, ttl_hours: 168 } }));
expect(await within(dialog).findByDisplayValue('https://app.example/accept?token=abc')).toBeInTheDocument();
expect(within(dialog).getByRole('button', { name: 'Copy link' })).toBeInTheDocument();
});
});
  • Step 2: Run them and watch them fail

Run: pnpm -C web test -- src/features/team Expected: FAIL — modules and /team routes do not exist.

  • Step 3: API, permission text, presets
web/src/features/team/api.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCan } from '@/features/auth';
import { api } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
import type { Schemas } from '@/lib/api/types';
export type Role = Schemas['Role'];
export const teamKeys = { roles: ['org', 'roles'] as const, permissions: ['org', 'permissions'] as const, invitations: ['org', 'invitations'] as const, chart: ['org', 'chart'] as const };
export function useRoles() {
const can = useCan('org.roles.manage');
return useQuery({ queryKey: teamKeys.roles, enabled: can, queryFn: async ({ signal }) => unwrap(await api.GET('/org/roles', { signal })), select: (d) => d.roles });
}
export function usePermissionCatalogue() {
return useQuery({ queryKey: teamKeys.permissions, staleTime: Infinity, queryFn: async ({ signal }) => unwrap(await api.GET('/org/permissions', { signal })), select: (d) => d.permissions });
}
export function useOrgChart() {
const can = useCan('org.structure.read');
return useQuery({ queryKey: teamKeys.chart, enabled: can, queryFn: async ({ signal }) => unwrap(await api.GET('/org/chart', { signal })) });
}
export function useInvitations() {
const can = useCan('org.members.invite');
return useQuery({ queryKey: teamKeys.invitations, enabled: can, queryFn: async ({ signal }) => unwrap(await api.GET('/org/invitations', { signal })) });
}
function useInvalidate(...keys: (readonly string[])[]) {
const qc = useQueryClient();
return () => keys.forEach((k) => void qc.invalidateQueries({ queryKey: k }));
}
export function useSetRolePermissions() {
const inv = useInvalidate(teamKeys.roles, ['me']);
return useMutation({ mutationFn: async (v: { id: string; permissions: string[] }) => unwrap(await api.PUT('/org/roles/{id}/permissions', { params: { path: { id: v.id } }, body: { permissions: v.permissions } })), onSuccess: inv });
}
export function useCreateRole() {
const inv = useInvalidate(teamKeys.roles);
return useMutation({ mutationFn: async (name: string) => unwrap(await api.POST('/org/roles', { body: { name } })), onSuccess: inv });
}
export function useRenameRole() {
const inv = useInvalidate(teamKeys.roles);
return useMutation({ mutationFn: async (v: { id: string; name: string }) => unwrap(await api.PATCH('/org/roles/{id}', { params: { path: { id: v.id } }, body: { name: v.name } })), onSuccess: inv });
}
export function useSetMemberRoles() {
const inv = useInvalidate(teamKeys.roles, ['me']);
return useMutation({ mutationFn: async (v: { memberId: string; role_ids: string[] }) => unwrap(await api.PUT('/org/members/{id}/roles', { params: { path: { id: v.memberId } }, body: { role_ids: v.role_ids } })), onSuccess: inv });
}
export function useIssueInvitation() {
const inv = useInvalidate(teamKeys.invitations);
return useMutation({ mutationFn: async (body: Schemas['IssueInvitationRequest']) => unwrap(await api.POST('/org/invitations', { body })), onSuccess: inv });
}
export function useRevokeInvitation() {
const inv = useInvalidate(teamKeys.invitations);
return useMutation({
mutationFn: async (id: string) => { const r = await api.DELETE('/org/invitations/{id}', { params: { path: { id } } }); if (r.error) unwrap(r); },
onSuccess: inv,
});
}

Invalidating ['me'] after a role change refreshes the caller’s own permissions when they changed their own role (the server bumps the permission version; the next token refresh carries it — the /me refetch shows the nav change at once if /me reads live grants).

web/src/features/team/permissionText.ts
import type { TFunction } from 'i18next';
/** i18n keys cannot contain dots (they nest), so "expenses.claim.read" is looked up as perm.expenses_claim_read. */
export const permissionLabel = (t: TFunction, key: string) => t(`perm.${key.replaceAll('.', '_')}`, { defaultValue: key });
export const moduleLabel = (t: TFunction, module: string) => t(`modules.${module}`, { defaultValue: module });
/** The two lock-out refusals of Task 2 (409, code), worded for the administrator. */
export function lockoutMessage(t: TFunction, code: string | undefined): string | undefined {
return code === 'owner_role_manage_required' || code === 'self_lockout' ? t(`lockout.${code}`) : undefined;
}
// web/src/features/team/roles/presets.ts — F20: a UI helper that pre-ticks boxes; nothing is saved by it.
const employee = [
'expenses.claim.read', 'expenses.claim.create', 'expenses.claim.transition',
'advances.advance.read', 'advances.advance.create', 'advances.advance.transition',
'documents.document.upload', 'pettycash.read',
'workflow.definition.read', // status chips and timelines read the definition (gap G1)
];
const approver = [...employee, 'workflow.delegation.create', 'workflow.decision.read', 'pettycash.transition', 'pettycash.spend.review'];
const finance = [
...approver,
'expenses.claim.read_all', 'expenses.claim.approve', 'expenses.claim.verify', 'advances.advance.read_all',
'pettycash.read_all', 'pettycash.float.manage', 'pettycash.float.disburse', 'payout.channel.manage',
'accounting.ledger.read', 'accounting.journal.post', 'accounting.period.close', 'accounting.fx.manage',
'reports.read', 'reports.export',
];
export const ROLE_PRESETS = { employee, approver, finance } as const;
export type PresetKey = keyof typeof ROLE_PRESETS | 'admin';
/** The preset's permissions that the catalogue actually offers, sorted. Admin is the whole catalogue. */
export function applyPreset(preset: PresetKey, catalogue: string[]): string[] {
const offered = new Set(catalogue);
const want = preset === 'admin' ? catalogue : ROLE_PRESETS[preset].filter((p) => offered.has(p));
return [...new Set(want)].sort();
}
  • Step 4: Roles page and matrix
web/src/features/team/roles/PermissionMatrix.tsx
import { useTranslation } from 'react-i18next';
import { Checkbox } from '@/components/ui/Checkbox';
import type { Schemas } from '@/lib/api/types';
import { moduleLabel, permissionLabel } from '../permissionText';
export function PermissionMatrix(p: { roleName: string; catalogue: Schemas['PermissionInfo'][]; value: Set<string>; onToggle(key: string, on: boolean): void; disabled?: boolean }) {
const { t } = useTranslation('team');
const modules = [...new Set(p.catalogue.map((x) => x.module))].sort();
return (
<div role="group" aria-label={t('roles.matrixLabel', { role: p.roleName })} className="flex flex-col gap-4">
{modules.map((m) => (
<fieldset key={m} className="rounded-xl border border-border p-3">
<legend className="px-1 text-sm font-bold">{moduleLabel(t, m)}</legend>
<div className="grid gap-2 md:grid-cols-2">
{p.catalogue.filter((x) => x.module === m).map((x) => {
const id = `perm-${x.key}`;
return (
<label key={x.key} htmlFor={id} className="flex items-start gap-2 text-sm">
<Checkbox id={id} checked={p.value.has(x.key)} disabled={p.disabled} onCheckedChange={(c: boolean) => p.onToggle(x.key, c)} />
<span>{permissionLabel(t, x.key)}<span className="block font-mono text-xs text-muted-foreground">{x.key}</span></span>
</label>
);
})}
</div>
</fieldset>
))}
</div>
);
}
// web/src/features/team/roles/RoleNameDialog.tsx — create or rename
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Dialog } from '@/components/ui/Dialog';
import { Field } from '@/components/ui/Field';
import { Input } from '@/components/ui/Input';
export function RoleNameDialog(p: { title: string; initial?: string; busy: boolean; error?: string | null; onCancel(): void; onSave(name: string): void }) {
const { t } = useTranslation('team');
const [name, setName] = useState(p.initial ?? '');
return (
<Dialog open onOpenChange={(o: boolean) => !o && p.onCancel()} title={p.title}
footer={<><Button variant="outline" onClick={p.onCancel}>{t('cancel')}</Button><Button loading={p.busy} disabled={!name.trim()} onClick={() => p.onSave(name.trim())}>{t('save')}</Button></>}>
<Field label={t('roles.name')} htmlFor="role-name" error={p.error ?? undefined} required>
<Input id="role-name" maxLength={100} value={name} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)} />
</Field>
</Dialog>
);
}
web/src/features/team/roles/RolesPage.tsx
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Skeleton } from '@/components/ui/Skeleton';
import { Tag } from '@/components/ui/Tag';
import { toast } from '@/components/ui/Toaster';
import { ProblemError, problemMessage } from '@/lib/api/problem';
import { cn } from '@/lib/cn';
import { useCreateRole, usePermissionCatalogue, useRenameRole, useRoles, useSetRolePermissions } from '../api';
import { lockoutMessage } from '../permissionText';
import { PermissionMatrix } from './PermissionMatrix';
import { applyPreset, type PresetKey } from './presets';
import { RoleNameDialog } from './RoleNameDialog';
export function RolesPage(p: { selected?: string; onSelect(id: string): void }) {
const { t } = useTranslation('team');
const { t: tc } = useTranslation();
const roles = useRoles();
const catalogue = usePermissionCatalogue();
const save = useSetRolePermissions();
const create = useCreateRole();
const rename = useRenameRole();
const role = roles.data?.find((r) => r.id === p.selected) ?? roles.data?.[0];
const [ticked, setTicked] = useState<Set<string>>(new Set());
const [alert, setAlert] = useState<string | null>(null);
const [dialog, setDialog] = useState<'create' | 'rename' | null>(null);
const [nameError, setNameError] = useState<string | null>(null);
useEffect(() => { setTicked(new Set(role?.permissions ?? [])); setAlert(null); }, [role?.id, role?.permissions]);
const dirty = useMemo(() => {
const saved = new Set(role?.permissions ?? []);
return saved.size !== ticked.size || [...ticked].some((k) => !saved.has(k));
}, [role, ticked]);
if (roles.isLoading || catalogue.isLoading) return <Skeleton className="h-96 w-full" />;
const keys = (catalogue.data ?? []).map((c) => c.key);
async function onSave() {
if (!role) return;
setAlert(null);
try {
await save.mutateAsync({ id: role.id, permissions: [...ticked].sort() });
toast.success(t('roles.saved'));
} catch (e) {
setAlert(lockoutMessage(t, e instanceof ProblemError ? e.code : undefined) ?? problemMessage(e, tc));
}
}
return (
<div className="grid gap-4 lg:grid-cols-[18rem_1fr]">
<aside className="flex flex-col gap-2">
<Button variant="outline" onClick={() => setDialog('create')}><Plus className="size-4" aria-hidden /> {t('roles.new')}</Button>
<ul className="flex flex-col gap-1" aria-label={t('roles.list')}>
{roles.data?.map((r) => (
<li key={r.id}>
<button type="button" aria-current={r.id === role?.id} onClick={() => p.onSelect(r.id)}
className={cn('flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm', r.id === role?.id ? 'bg-accent text-primary' : 'hover:bg-accent')}>
<span>{r.name} {r.system && <Tag tone="muted">{t('roles.system')}</Tag>}</span>
<span className="text-xs text-muted-foreground">{t('roles.members', { count: r.member_ids.length })}</span>
</button>
</li>
))}
</ul>
</aside>
{role && (
<section className="flex flex-col gap-4 rounded-xl border border-border bg-card p-4" aria-labelledby="role-title">
<div className="flex items-center justify-between">
<h2 id="role-title" className="text-lg font-bold">{role.name}</h2>
{!role.system && <Button size="sm" variant="ghost" onClick={() => setDialog('rename')}>{t('roles.rename')}</Button>}
</div>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">{t('roles.presetLead')}</span>
{(['employee', 'approver', 'finance', 'admin'] as PresetKey[]).map((k) => (
<Button key={k} size="sm" variant="outline" aria-label={t('roles.presetButton', { preset: t(`roles.presets.${k}`) })}
onClick={() => setTicked(new Set(applyPreset(k, keys)))}>{t(`roles.presets.${k}`)}</Button>
))}
</div>
<p className="text-xs text-muted-foreground">{t('roles.presetHint')}</p>
<PermissionMatrix roleName={role.name} catalogue={catalogue.data ?? []} value={ticked}
onToggle={(k, on) => setTicked((s) => { const n = new Set(s); if (on) n.add(k); else n.delete(k); return n; })} />
{alert && <p role="alert" className="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm">{alert}</p>}
{dirty && (
<div className="sticky bottom-0 flex justify-end gap-2 border-t border-border bg-card pt-3">
<Button variant="outline" onClick={() => setTicked(new Set(role.permissions))}>{t('roles.discard')}</Button>
<Button loading={save.isPending} onClick={onSave}>{t('roles.save')}</Button>
</div>
)}
</section>
)}
{dialog && (
<RoleNameDialog title={dialog === 'create' ? t('roles.new') : t('roles.rename')} initial={dialog === 'rename' ? role?.name : ''}
busy={create.isPending || rename.isPending} error={nameError}
onCancel={() => { setDialog(null); setNameError(null); }}
onSave={async (name) => {
try {
if (dialog === 'create') { const r = await create.mutateAsync(name); p.onSelect(r.id); } else await rename.mutateAsync({ id: role!.id, name });
setDialog(null); setNameError(null);
} catch (e) { setNameError(e instanceof ProblemError && e.detail ? e.detail : problemMessage(e, tc)); }
}} />
)}
</div>
);
}

Verify: createRole returns the Role (with id) per the registry; if it returns a wrapper, adapt r.id.

  • Step 5: Members and invitations
web/src/features/team/members/MemberRolesDialog.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Checkbox } from '@/components/ui/Checkbox';
import { Dialog } from '@/components/ui/Dialog';
import { toast } from '@/components/ui/Toaster';
import { ProblemError, problemMessage } from '@/lib/api/problem';
import { useSetMemberRoles, type Role } from '../api';
import { lockoutMessage } from '../permissionText';
export function MemberRolesDialog(p: { memberId: string; memberName: string; roles: Role[]; onClose(): void }) {
const { t } = useTranslation('team');
const { t: tc } = useTranslation();
const save = useSetMemberRoles();
const [ticked, setTicked] = useState(new Set(p.roles.filter((r) => r.member_ids.includes(p.memberId)).map((r) => r.id)));
const [alert, setAlert] = useState<string | null>(null);
async function onSave() {
setAlert(null);
try {
await save.mutateAsync({ memberId: p.memberId, role_ids: [...ticked] });
toast.success(t('members.rolesSaved'));
p.onClose();
} catch (e) {
setAlert(lockoutMessage(t, e instanceof ProblemError ? e.code : undefined) ?? problemMessage(e, tc));
}
}
return (
<Dialog open onOpenChange={(o: boolean) => !o && p.onClose()} title={t('members.rolesTitle', { name: p.memberName })} description={t('members.rolesHint')}
footer={<><Button variant="outline" onClick={p.onClose}>{t('cancel')}</Button><Button loading={save.isPending} onClick={onSave}>{t('save')}</Button></>}>
<ul className="flex flex-col gap-2">
{p.roles.map((r) => (
<li key={r.id}>
<label htmlFor={`mr-${r.id}`} className="flex items-center gap-2 text-sm">
<Checkbox id={`mr-${r.id}`} checked={ticked.has(r.id)} onCheckedChange={(c: boolean) => setTicked((s) => { const n = new Set(s); if (c) n.add(r.id); else n.delete(r.id); return n; })} />
{r.name}
</label>
</li>
))}
</ul>
{alert && <p role="alert" className="mt-3 text-sm text-destructive">{alert}</p>}
</Dialog>
);
}
web/src/features/team/members/MembersPage.tsx
import { useMemo, useState } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { DataTable } from '@/components/ui/DataTable';
import { Input } from '@/components/ui/Input';
import { Tag } from '@/components/ui/Tag';
import { useCan } from '@/features/auth';
import { useMemberDirectory } from '@/features/members/useMemberNames';
import type { Schemas } from '@/lib/api/types';
import { useOrgChart, useRoles } from '../api';
import { MemberRolesDialog } from './MemberRolesDialog';
type Entry = Schemas['MemberDirectoryEntry'];
export function MembersPage() {
const { t } = useTranslation('team');
const canRoles = useCan('org.roles.manage');
const canChart = useCan('org.structure.read');
const directory = useMemberDirectory();
const roles = useRoles();
const chart = useOrgChart();
const [q, setQ] = useState('');
const [editing, setEditing] = useState<Entry | null>(null);
const display = (m: Entry) => m.name ?? `…${m.member_id.slice(-4)}`;
const rows = (directory.data ?? []).filter((m) => display(m).toLowerCase().includes(q.trim().toLowerCase()));
const columns = useMemo<ColumnDef<Entry>[]>(() => [
{ id: 'name', header: t('members.name'), cell: ({ row }) => <span className="font-medium">{display(row.original)}</span> },
{ id: 'status', header: t('members.status'), cell: ({ row }) => row.original.active ? <Tag tone="green">{t('members.active')}</Tag> : <Tag tone="muted">{t('members.inactive')}</Tag> },
...(canRoles ? [{ id: 'roles', header: t('members.roles'), cell: ({ row }: { row: { original: Entry } }) => (
<span className="flex flex-wrap gap-1">{(roles.data ?? []).filter((r) => r.member_ids.includes(row.original.member_id)).map((r) => <Tag key={r.id} tone="purple">{r.name}</Tag>)}</span>
) } as ColumnDef<Entry>] : []),
...(canChart ? [{ id: 'positions', header: t('members.positions'), cell: ({ row }: { row: { original: Entry } }) =>
(chart.data?.positions ?? []).filter((p) => p.holders.some((h) => h.member_id === row.original.member_id)).map((p) => p.title).join(', ') || '—' } as ColumnDef<Entry>] : []),
...(canRoles ? [{ id: 'actions', header: '', cell: ({ row }: { row: { original: Entry } }) => <Button size="sm" variant="outline" onClick={() => setEditing(row.original)}>{t('members.editRoles')}</Button> } as ColumnDef<Entry>] : []),
// eslint-disable-next-line react-hooks/exhaustive-deps
], [t, canRoles, canChart, roles.data, chart.data]);
return (
<div className="flex flex-col gap-4">
<Input type="search" aria-label={t('members.search')} placeholder={t('members.search')} className="md:w-72" value={q} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQ(e.target.value)} />
<div className="rounded-xl border border-border bg-card">
<DataTable columns={columns} data={rows} loading={directory.isLoading} getRowId={(m: Entry) => m.member_id} empty={t('members.none')} />
</div>
{editing && roles.data && <MemberRolesDialog memberId={editing.member_id} memberName={display(editing)} roles={roles.data} onClose={() => setEditing(null)} />}
</div>
);
}
web/src/features/team/invitations/InviteDialog.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Copy } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Dialog } from '@/components/ui/Dialog';
import { Field } from '@/components/ui/Field';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { toast } from '@/components/ui/Toaster';
import { ProblemError, problemMessage } from '@/lib/api/problem';
import { useIssueInvitation, useRoles } from '../api';
export function InviteDialog({ onClose }: { onClose(): void }) {
const { t } = useTranslation('team');
const { t: tc } = useTranslation();
const roles = useRoles();
const issue = useIssueInvitation();
const [email, setEmail] = useState('');
const [roleId, setRoleId] = useState('');
const [ttl, setTtl] = useState('168');
const [link, setLink] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function send() {
setError(null);
if (!/^[^\s@]+@[^\s@]+$/.test(email) || !roleId) return setError(t('invite.required'));
try {
const r = await issue.mutateAsync({ email: email.trim(), role_id: roleId, ttl_hours: Number.parseInt(ttl, 10) });
setLink(r.link);
} catch (e) {
setError(e instanceof ProblemError && e.detail ? e.detail : problemMessage(e, tc));
}
}
return (
<Dialog open onOpenChange={(o: boolean) => !o && onClose()} title={t('invite.title')} description={link ? t('invite.linkHint') : t('invite.description')}
footer={link ? <Button onClick={onClose}>{t('done')}</Button> : <><Button variant="outline" onClick={onClose}>{t('cancel')}</Button><Button loading={issue.isPending} onClick={send}>{t('invite.send')}</Button></>}>
{link ? (
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Input readOnly aria-label={t('invite.link')} value={link} onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.select()} />
<Button variant="outline" onClick={() => void navigator.clipboard.writeText(link).then(() => toast.success(t('invite.copied')))}>
<Copy className="size-4" aria-hidden /> {t('invite.copy')}
</Button>
</div>
<p className="text-xs text-warning">{t('invite.once')}</p>
</div>
) : (
<div className="flex flex-col gap-4">
<Field label={t('invite.email')} htmlFor="iv-email" required><Input id="iv-email" type="email" value={email} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)} /></Field>
<Field label={t('invite.role')} htmlFor="iv-role" hint={roles.data ? undefined : t('invite.noRoles')} required>
<Select id="iv-role" aria-label={t('invite.role')} value={roleId} onChange={setRoleId} disabled={!roles.data} options={(roles.data ?? []).map((r) => ({ value: r.id, label: r.name }))} />
</Field>
<Field label={t('invite.ttl')} htmlFor="iv-ttl">
<Select id="iv-ttl" aria-label={t('invite.ttl')} value={ttl} onChange={setTtl} options={[{ value: '24', label: t('invite.ttl24') }, { value: '72', label: t('invite.ttl72') }, { value: '168', label: t('invite.ttl168') }]} />
</Field>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
</div>
)}
</Dialog>
);
}

Number.parseInt(ttl, 10) converts an hour count, not money; the no-float drift test flags Number( and parseFloat, not Number.parseInt. Verify the drift test’s pattern list and, if parseInt is flagged too, send ttl_hours: ttl === '24' ? 24 : ttl === '72' ? 72 : 168.

web/src/features/team/invitations/InvitationsPage.tsx
import { useMemo, useState } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { DataTable } from '@/components/ui/DataTable';
import { toast } from '@/components/ui/Toaster';
import { problemMessage } from '@/lib/api/problem';
import type { Schemas } from '@/lib/api/types';
import { formatDateTime, relativeTime } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import { useInvitations, useRevokeInvitation, useRoles } from '../api';
type Invitation = Schemas['Invitation'];
export function InvitationsPage() {
const { t } = useTranslation('team');
const { t: tc } = useTranslation();
const [locale] = useLocale();
const list = useInvitations();
const roles = useRoles();
const revoke = useRevokeInvitation();
const [revoking, setRevoking] = useState<Invitation | null>(null);
const columns = useMemo<ColumnDef<Invitation>[]>(() => [
{ id: 'email', header: t('invitations.email'), cell: ({ row }) => row.original.email },
{ id: 'role', header: t('invitations.role'), cell: ({ row }) => roles.data?.find((r) => r.id === row.original.role_id)?.name ?? '—' },
{ id: 'expires', header: t('invitations.expires'), cell: ({ row }) => relativeTime(row.original.expires_at, locale) },
{ id: 'sent', header: t('invitations.sent'), cell: ({ row }) => formatDateTime(row.original.created_at, locale) },
{ id: 'actions', header: '', cell: ({ row }) => <Button size="sm" variant="danger-outline" onClick={() => setRevoking(row.original)}>{t('invitations.revoke')}</Button> },
], [t, locale, roles.data]);
return (
<>
<div className="rounded-xl border border-border bg-card">
<DataTable columns={columns} data={list.data ?? []} loading={list.isLoading} getRowId={(i: Invitation) => i.id} empty={t('invitations.none')} />
</div>
<ConfirmDialog open={revoking !== null} onOpenChange={(o: boolean) => !o && setRevoking(null)} tone="danger"
title={t('invitations.revokeTitle', { email: revoking?.email })} description={t('invitations.revokeHint')} confirmLabel={t('invitations.revoke')}
onConfirm={async () => {
try { await revoke.mutateAsync(revoking!.id); toast.success(t('invitations.revoked')); } catch (e) { toast.error(problemMessage(e, tc)); }
setRevoking(null);
}} />
</>
);
}
  • Step 6: Layout, routes, nav
web/src/features/team/TeamLayout.tsx
import { useState } from 'react';
import { Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { KeySquare, Mail, Users } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { PageHeader } from '@/components/ui/PageHeader';
import { Tabs } from '@/components/ui/Tabs';
import { useCan } from '@/features/auth';
import { InviteDialog } from './invitations/InviteDialog';
export function TeamLayout() {
const { t } = useTranslation('team');
const canInvite = useCan('org.members.invite');
const canRoles = useCan('org.roles.manage');
const pathname = useRouterState({ select: (s) => s.location.pathname });
const navigate = useNavigate();
const [inviting, setInviting] = useState(false);
const items = [
{ value: '/team/members', label: t('tabs.members'), icon: Users },
...(canInvite ? [{ value: '/team/invitations', label: t('tabs.invitations'), icon: Mail }] : []),
...(canRoles ? [{ value: '/team/roles', label: t('tabs.roles'), icon: KeySquare }] : []),
];
return (
<div className="flex flex-col gap-4">
<PageHeader title={t('title')} subtitle={t('subtitle')} actions={canInvite ? <Button onClick={() => setInviting(true)}>{t('invite.button')}</Button> : undefined} />
<Tabs value={pathname} onChange={(v: string) => void navigate({ to: v })} items={items} />
<Outlet />
{inviting && <InviteDialog onClose={() => setInviting(false)} />}
</div>
);
}
web/src/features/team/routes.tsx
import { createRoute, redirect } from '@tanstack/react-router';
import { z } from 'zod';
import { appRoute } from '@/app/layouts';
import { RequirePermission } from '@/features/auth';
import { InvitationsPage } from './invitations/InvitationsPage';
import { MembersPage } from './members/MembersPage';
import { RolesPage } from './roles/RolesPage';
import { TeamLayout } from './TeamLayout';
const TEAM = ['org.roles.manage', 'org.members.invite', 'org.structure.read'] as const;
const teamRoute = createRoute({ getParentRoute: () => appRoute, path: 'team', component: () => <RequirePermission permission={[...TEAM]}><TeamLayout /></RequirePermission> });
const indexRoute = createRoute({ getParentRoute: () => teamRoute, path: '/', beforeLoad: () => { throw redirect({ to: '/team/members' }); } });
const membersRoute = createRoute({ getParentRoute: () => teamRoute, path: 'members', component: MembersPage });
const invitationsRoute = createRoute({ getParentRoute: () => teamRoute, path: 'invitations', component: () => <RequirePermission permission="org.members.invite"><InvitationsPage /></RequirePermission> });
const rolesRoute = createRoute({
getParentRoute: () => teamRoute,
path: 'roles',
validateSearch: (s: Record<string, unknown>) => z.object({ role: z.string().uuid().optional().catch(undefined) }).parse(s),
component: function RolesRoute() {
const { role } = rolesRoute.useSearch();
const navigate = rolesRoute.useNavigate();
return <RequirePermission permission="org.roles.manage"><RolesPage selected={role} onSelect={(id) => void navigate({ search: { role: id }, replace: true })} /></RequirePermission>;
},
});
export const routes = [teamRoute.addChildren([indexRoute, membersRoute, invitationsRoute, rolesRoute])] as const;

The Invite button lives in the layout, so the invitations test clicks Invite from the header on /team/invitations.

Append to web/src/app/routes.ts: import { routes as teamRoutes } from '@/features/team/routes'; / ...teamRoutes,. Append to web/src/app/shell/nav.ts:

import { UsersRound } from 'lucide-react';
{ group: 'manage', to: '/team', labelKey: 'team:nav', icon: UsersRound, permission: ['org.roles.manage', 'org.members.invite', 'org.structure.read'] },
  • Step 7: i18n bundles

en/team.json

{
"nav": "Team",
"title": "Team",
"subtitle": "Who is in the organization, and what each role may do",
"save": "Save", "cancel": "Cancel", "done": "Done",
"tabs": { "members": "Members", "invitations": "Invitations", "roles": "Roles" },
"members": { "name": "Name", "status": "Status", "active": "Active", "inactive": "Inactive", "roles": "Roles", "positions": "Positions", "editRoles": "Edit roles", "search": "Search people", "none": "No members", "rolesTitle": "Roles of {{name}}", "rolesHint": "A member holds every permission of every role ticked here.", "rolesSaved": "Roles saved" },
"invitations": { "email": "Email", "role": "Role", "expires": "Expires", "sent": "Sent", "revoke": "Revoke", "revokeTitle": "Revoke the invitation to {{email}}?", "revokeHint": "The link stops working at once.", "revoked": "Invitation revoked", "none": "No pending invitations" },
"invite": { "button": "Invite", "title": "Invite someone", "description": "They receive the role you choose when they accept.", "email": "Email", "role": "Role", "ttl": "Valid for", "ttl24": "1 day", "ttl72": "3 days", "ttl168": "7 days", "send": "Send invitation", "required": "Enter an email address and choose a role", "noRoles": "Only someone who can manage roles can choose one", "link": "Invitation link", "linkHint": "Send this link to the person you invited.", "copy": "Copy link", "copied": "Link copied", "once": "This link is shown only once and works like a password. If it is lost, revoke it and invite again." },
"roles": { "list": "Roles", "new": "New role", "rename": "Rename", "name": "Role name", "system": "System", "members_one": "{{count}} member", "members_other": "{{count}} members", "presetLead": "Start from a preset:", "presetButton": "{{preset}} preset", "presetHint": "A preset only ticks boxes. Nothing changes until you save.", "presets": { "employee": "Employee", "approver": "Approver", "finance": "Finance", "admin": "Admin" }, "matrixLabel": "Permissions of {{role}}", "save": "Save changes", "discard": "Discard", "saved": "Role saved" },
"lockout": { "owner_role_manage_required": "The Owner role must keep the right to manage roles, or nobody could fix roles again.", "self_lockout": "You would lose the right to manage roles. Ask another administrator to make this change." },
"modules": { "expenses": "Expense claims", "advances": "Advances", "pettycash": "Petty cash", "accounting": "Accounting", "workflow": "Workflows", "payout": "Payouts", "documents": "Documents", "reports": "Reports", "org": "Organization", "identity": "Identity" },
"perm": {
"expenses_claim_create": "File claims", "expenses_claim_read": "Read own claims", "expenses_claim_read_all": "Read all claims", "expenses_claim_transition": "Move claims along their workflow", "expenses_claim_approve": "Approve claims", "expenses_claim_verify": "Verify claim categories", "expenses_setup_manage": "Manage categories, rates and groups",
"advances_advance_create": "Request advances", "advances_advance_read": "Read own advances", "advances_advance_read_all": "Read all advances", "advances_advance_transition": "Move advances along their workflow",
"pettycash_read": "Read petty cash", "pettycash_read_all": "Read every float", "pettycash_float_manage": "Open and close floats", "pettycash_float_disburse": "Spend and request from own float", "pettycash_spend_review": "Review petty cash spends", "pettycash_transition": "Move petty cash along its workflow",
"accounting_ledger_read": "Read the ledger", "accounting_journal_post": "Post journal entries", "accounting_account_manage": "Manage the chart of accounts", "accounting_period_close": "Close fiscal periods", "accounting_fx_manage": "Manage exchange rates",
"workflow_definition_read": "Read workflows", "workflow_definition_manage": "Edit workflow drafts", "workflow_definition_publish": "Publish workflows", "workflow_delegation_create": "Delegate approvals", "workflow_decision_read": "Read decision logs",
"payout_channel_manage": "Manage payout channels", "documents_document_upload": "Upload documents", "reports_read": "Read reports", "reports_export": "Export reports",
"org_profile_manage": "Edit the company profile", "org_roles_manage": "Manage roles", "org_members_invite": "Invite members", "org_structure_read": "Read the org structure", "org_structure_manage": "Edit the org structure", "identity_org_administer": "Administer the organization"
}
}

ms/team.json

{
"nav": "Pasukan",
"title": "Pasukan",
"subtitle": "Siapa dalam organisasi, dan apa yang boleh dilakukan oleh setiap peranan",
"save": "Simpan", "cancel": "Batal", "done": "Selesai",
"tabs": { "members": "Ahli", "invitations": "Jemputan", "roles": "Peranan" },
"members": { "name": "Nama", "status": "Status", "active": "Aktif", "inactive": "Tidak aktif", "roles": "Peranan", "positions": "Jawatan", "editRoles": "Sunting peranan", "search": "Cari orang", "none": "Tiada ahli", "rolesTitle": "Peranan {{name}}", "rolesHint": "Ahli memegang semua kebenaran bagi setiap peranan yang ditanda di sini.", "rolesSaved": "Peranan disimpan" },
"invitations": { "email": "E-mel", "role": "Peranan", "expires": "Tamat tempoh", "sent": "Dihantar", "revoke": "Batalkan", "revokeTitle": "Batalkan jemputan kepada {{email}}?", "revokeHint": "Pautan akan berhenti berfungsi serta-merta.", "revoked": "Jemputan dibatalkan", "none": "Tiada jemputan tertunda" },
"invite": { "button": "Jemput", "title": "Jemput seseorang", "description": "Mereka menerima peranan yang anda pilih apabila menerima jemputan.", "email": "E-mel", "role": "Peranan", "ttl": "Sah selama", "ttl24": "1 hari", "ttl72": "3 hari", "ttl168": "7 hari", "send": "Hantar jemputan", "required": "Masukkan alamat e-mel dan pilih peranan", "noRoles": "Hanya orang yang boleh mengurus peranan dapat memilihnya", "link": "Pautan jemputan", "linkHint": "Hantar pautan ini kepada orang yang anda jemput.", "copy": "Salin pautan", "copied": "Pautan disalin", "once": "Pautan ini dipaparkan sekali sahaja dan berfungsi seperti kata laluan. Jika hilang, batalkan dan jemput semula." },
"roles": { "list": "Peranan", "new": "Peranan baharu", "rename": "Namakan semula", "name": "Nama peranan", "system": "Sistem", "members_one": "{{count}} ahli", "members_other": "{{count}} ahli", "presetLead": "Mulakan daripada pratetap:", "presetButton": "Pratetap {{preset}}", "presetHint": "Pratetap hanya menanda kotak. Tiada perubahan sehingga anda menyimpan.", "presets": { "employee": "Pekerja", "approver": "Pelulus", "finance": "Kewangan", "admin": "Pentadbir" }, "matrixLabel": "Kebenaran {{role}}", "save": "Simpan perubahan", "discard": "Buang", "saved": "Peranan disimpan" },
"lockout": { "owner_role_manage_required": "Peranan Pemilik mesti mengekalkan hak mengurus peranan, jika tidak tiada sesiapa dapat membetulkan peranan lagi.", "self_lockout": "Anda akan kehilangan hak mengurus peranan. Minta pentadbir lain membuat perubahan ini." },
"modules": { "expenses": "Tuntutan perbelanjaan", "advances": "Pendahuluan", "pettycash": "Wang runcit", "accounting": "Perakaunan", "workflow": "Aliran kerja", "payout": "Pembayaran", "documents": "Dokumen", "reports": "Laporan", "org": "Organisasi", "identity": "Identiti" },
"perm": {
"expenses_claim_create": "Failkan tuntutan", "expenses_claim_read": "Baca tuntutan sendiri", "expenses_claim_read_all": "Baca semua tuntutan", "expenses_claim_transition": "Gerakkan tuntutan dalam aliran kerja", "expenses_claim_approve": "Luluskan tuntutan", "expenses_claim_verify": "Sahkan kategori tuntutan", "expenses_setup_manage": "Urus kategori, kadar dan kumpulan",
"advances_advance_create": "Mohon pendahuluan", "advances_advance_read": "Baca pendahuluan sendiri", "advances_advance_read_all": "Baca semua pendahuluan", "advances_advance_transition": "Gerakkan pendahuluan dalam aliran kerja",
"pettycash_read": "Baca wang runcit", "pettycash_read_all": "Baca semua wang runcit", "pettycash_float_manage": "Buka dan tutup wang runcit", "pettycash_float_disburse": "Belanja dan mohon daripada wang runcit sendiri", "pettycash_spend_review": "Semak perbelanjaan wang runcit", "pettycash_transition": "Gerakkan wang runcit dalam aliran kerja",
"accounting_ledger_read": "Baca lejar", "accounting_journal_post": "Pos catatan jurnal", "accounting_account_manage": "Urus carta akaun", "accounting_period_close": "Tutup tempoh fiskal", "accounting_fx_manage": "Urus kadar pertukaran",
"workflow_definition_read": "Baca aliran kerja", "workflow_definition_manage": "Sunting draf aliran kerja", "workflow_definition_publish": "Terbitkan aliran kerja", "workflow_delegation_create": "Delegasikan kelulusan", "workflow_decision_read": "Baca log keputusan",
"payout_channel_manage": "Urus saluran pembayaran", "documents_document_upload": "Muat naik dokumen", "reports_read": "Baca laporan", "reports_export": "Eksport laporan",
"org_profile_manage": "Sunting profil syarikat", "org_roles_manage": "Urus peranan", "org_members_invite": "Jemput ahli", "org_structure_read": "Baca struktur organisasi", "org_structure_manage": "Sunting struktur organisasi", "identity_org_administer": "Tadbir organisasi"
}
}

zh/team.json

{
"nav": "团队",
"title": "团队",
"subtitle": "组织成员,以及各角色的权限",
"save": "保存", "cancel": "取消", "done": "完成",
"tabs": { "members": "成员", "invitations": "邀请", "roles": "角色" },
"members": { "name": "姓名", "status": "状态", "active": "在职", "inactive": "停用", "roles": "角色", "positions": "职位", "editRoles": "编辑角色", "search": "搜索成员", "none": "暂无成员", "rolesTitle": "{{name}} 的角色", "rolesHint": "成员拥有此处勾选的每个角色的全部权限。", "rolesSaved": "角色已保存" },
"invitations": { "email": "邮箱", "role": "角色", "expires": "过期时间", "sent": "发送时间", "revoke": "撤销", "revokeTitle": "撤销发给 {{email}} 的邀请?", "revokeHint": "链接将立即失效。", "revoked": "邀请已撤销", "none": "暂无待接受的邀请" },
"invite": { "button": "邀请成员", "title": "邀请成员", "description": "对方接受邀请后将获得您选择的角色。", "email": "邮箱", "role": "角色", "ttl": "有效期", "ttl24": "1 天", "ttl72": "3 天", "ttl168": "7 天", "send": "发送邀请", "required": "请填写邮箱并选择角色", "noRoles": "只有可管理角色的人才能选择角色", "link": "邀请链接", "linkHint": "请将此链接发送给受邀人。", "copy": "复制链接", "copied": "链接已复制", "once": "此链接只显示一次,等同于密码。如遗失,请撤销后重新邀请。" },
"roles": { "list": "角色", "new": "新建角色", "rename": "重命名", "name": "角色名称", "system": "系统", "members_one": "{{count}} 名成员", "members_other": "{{count}} 名成员", "presetLead": "从预设开始:", "presetButton": "{{preset}}预设", "presetHint": "预设只会勾选权限,保存后才会生效。", "presets": { "employee": "员工", "approver": "审批人", "finance": "财务", "admin": "管理员" }, "matrixLabel": "{{role}} 的权限", "save": "保存更改", "discard": "放弃", "saved": "角色已保存" },
"lockout": { "owner_role_manage_required": "所有者角色必须保留管理角色的权限,否则将无人能再修改角色。", "self_lockout": "此更改会让您失去管理角色的权限,请让其他管理员操作。" },
"modules": { "expenses": "费用报销", "advances": "预支款", "pettycash": "备用金", "accounting": "会计", "workflow": "审批流程", "payout": "付款", "documents": "文件", "reports": "报表", "org": "组织", "identity": "身份" },
"perm": {
"expenses_claim_create": "提交报销", "expenses_claim_read": "查看自己的报销", "expenses_claim_read_all": "查看所有报销", "expenses_claim_transition": "推进报销流程", "expenses_claim_approve": "批准报销", "expenses_claim_verify": "核实报销类别", "expenses_setup_manage": "管理类别、费率与项目",
"advances_advance_create": "申请预支", "advances_advance_read": "查看自己的预支", "advances_advance_read_all": "查看所有预支", "advances_advance_transition": "推进预支流程",
"pettycash_read": "查看备用金", "pettycash_read_all": "查看所有备用金", "pettycash_float_manage": "开立与关闭备用金", "pettycash_float_disburse": "使用及申请本人备用金", "pettycash_spend_review": "审核备用金支出", "pettycash_transition": "推进备用金流程",
"accounting_ledger_read": "查看总账", "accounting_journal_post": "过账", "accounting_account_manage": "管理会计科目", "accounting_period_close": "结账", "accounting_fx_manage": "管理汇率",
"workflow_definition_read": "查看审批流程", "workflow_definition_manage": "编辑流程草稿", "workflow_definition_publish": "发布流程", "workflow_delegation_create": "委托审批", "workflow_decision_read": "查看审批记录",
"payout_channel_manage": "管理付款渠道", "documents_document_upload": "上传文件", "reports_read": "查看报表", "reports_export": "导出报表",
"org_profile_manage": "编辑公司资料", "org_roles_manage": "管理角色", "org_members_invite": "邀请成员", "org_structure_read": "查看组织架构", "org_structure_manage": "编辑组织架构", "identity_org_administer": "管理组织"
}
}
  • Step 8: Run the tests

Run: pnpm -C web test -- src/features/team src/drift Expected: PASS. Then typecheck and lint.

  • Step 9: Runtime check

Dev stack and preview, signed in as the Owner. At 1440, light, en:

  1. Sidebar Team → /team/members: every seeded member by name, their roles as tags, and the seats they hold.
  2. Roles → select Employee (zero grants at seed): click Employee preset; boxes tick, the sticky Save changes appears, the network tab shows nothing; Save: one PUT /v1/org/roles/{id}/permissions. Sign in as an Employee in a private window: after the token refresh the Claims nav item appears.
  3. Select Owner, untick Manage roles, Save: the red alert “The Owner role must keep the right to manage roles…” and the box stays as it was on the server (reload to confirm).
  4. Invite: email, role Employee, 7 days → the link panel with Copy link; the invitation appears on the Invitations tab; Revoke removes it.
  5. Edit roles on a member: tick Approver, save; the member’s tags update.
  6. As a member holding only org.structure.read: only the Members tab; no Invite button; /team/roles shows the 403 panel. Then 390px (role list stacks above the matrix, matrix one column), dark, zh (团队, 成员 / 邀请 / 角色, 员工 / 审批人 / 财务 / 管理员).
  • Step 10: Commit
Terminal window
git add web/src/features/team web/src/lib/i18n/locales/*/team.json web/src/app/routes.ts web/src/app/shell/nav.ts
git commit -m "$(cat <<'MSG'
Plan 10 Task 18: team (members, invitations, roles matrix with presets and lock-out refusals)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MSG
)"

Task 19: Team — org structure and readiness

Section titled “Task 19: Team — org structure and readiness”

No Figma frame (C, spec §5.8). Components page (0:1) for cards, tree rows, tags, dialogs.

Layout: /org-structure (sidebar Org structure under Manage — a separate nav item so this task shares no file with Task 18). Header Org structure with the subtitle “Units, positions and who holds them. Approvals are routed through this.” Then:

  • Setup checklist (with identity.org.administer): a card listing every provisioning step from /org/readiness in order — a green check for done, a red cross with the step’s error for not done — and under each step its warnings, one row per warning: an amber dot, the warning in words (by code; the server’s detail in small grey under it), and a Fix button that opens the dialog that fixes it: no_primary_position → Assign a position for that member (primary ticked); vacant_position → Assign a holder for that position; unit_without_head → Edit unit (head field focused); reports_to_inactive → Edit position (reports-to field). When everything is done and warning-free the card collapses to one green line “Setup complete”. This is the first screen an administrator of a new organization needs, so it sits at the top.
  • Two columns at ≥1024px (stacked below). Units: the tree in the API’s pre-order, each row indented by depth, with code (mono), name, the head position’s title, a Retired tag (muted row) for inactive units, and Add sub-unit / Edit per active row; Add unit at the top. Positions: the position tree (pre-order, indented by depth, which follows reports_to), each row: title, code, the unit’s name, holders as name tags (or an amber Vacant tag), Assign and Edit; Add position at the top.
  • Dialogs. Unit (create: parent, code, name, head position; edit: name, parent, head, and Retire unit). Position (create: unit, code, title, reports to; edit: reports to, and Retire position — the title cannot be changed through the API, so it is shown read-only with that note). Assign: member (searchable, active members), position, primary (checkbox), from (default today), until (optional, exclusive — “the first day they no longer hold it”).

Ending an assignment is not offered: the chart does not return assignment ids, and the DELETE needs one (G9).

Files:

  • Create: web/src/features/orgstructure/api.ts
  • Create: web/src/features/orgstructure/OrgStructurePage.tsx
  • Create: web/src/features/orgstructure/ReadinessChecklist.tsx
  • Create: web/src/features/orgstructure/UnitsTree.tsx
  • Create: web/src/features/orgstructure/PositionsTree.tsx
  • Create: web/src/features/orgstructure/UnitDialog.tsx
  • Create: web/src/features/orgstructure/PositionDialog.tsx
  • Create: web/src/features/orgstructure/AssignDialog.tsx
  • Create: web/src/features/orgstructure/RecordLanguageHint.tsx
  • Create: web/src/features/orgstructure/routes.tsx
  • Create: web/src/features/orgstructure/__tests__/fixtures.ts
  • Create: web/src/features/orgstructure/__tests__/OrgStructurePage.test.tsx
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/orgstructure.json
  • Modify (append): web/src/app/routes.ts, web/src/app/shell/nav.ts

Interfaces:

  • Consumes: getOrgChart, getOrgReadiness, createOrgUnit, updateOrgUnit, createOrgPosition, updateOrgPosition, assignMemberPosition; useMemberDirectory, useMemberNames; useCan; todayISO; ui primitives.

  • Produces: routes /org-structure; query keys ['org','chart'] (shared with Task 18, R6) and ['org','readiness'].

  • Step 0: Verify imports (C1). Run the dev stack’s readiness once (curl -s -H "Authorization: Bearer $T" localhost:3000/v1/org/readiness | jq) and note the step keys it returns; every key needs an entry under steps in the three bundles below (the bundles below carry the twelve step keys registered today — grep -rn 'Key() string' internal | grep -v _test — add any key the dev stack returns that is missing).

  • Step 1: Fixtures and failing test

web/src/features/orgstructure/__tests__/fixtures.ts
import type { Schemas } from '@/lib/api/types';
export const ME = '00000000-0000-4000-8000-000000000001';
export const SITI = '00000000-0000-4000-8000-000000000003';
export const U_ROOT = '00000000-0000-4000-8000-0000000a0001';
export const U_FIN = '00000000-0000-4000-8000-0000000a0002';
export const P_CEO = '00000000-0000-4000-8000-0000000b0001';
export const P_FIN = '00000000-0000-4000-8000-0000000b0002';
export const members = [{ member_id: ME, name: 'Alex Tan', active: true }, { member_id: SITI, name: 'Siti Aminah', active: true }];
export const chart: Schemas['OrgChart'] = {
as_of: '2026-09-23',
units: [
{ id: U_ROOT, parent_id: null, code: 'HQ', name: 'Headquarters', depth: 1, head_position_id: P_CEO, active: true },
{ id: U_FIN, parent_id: U_ROOT, code: 'FIN', name: 'Finance', depth: 2, head_position_id: null, active: true },
{ id: '00000000-0000-4000-8000-0000000a0003', parent_id: U_ROOT, code: 'OLD', name: 'Old branch', depth: 2, head_position_id: null, active: false },
],
positions: [
{ id: P_CEO, org_unit_id: U_ROOT, code: 'OWNER', title: 'Owner', depth: 1, reports_to: null, active: true, holders: [{ member_id: ME, name: 'Alex Tan' }] },
{ id: P_FIN, org_unit_id: U_FIN, code: 'FIN-MGR', title: 'Finance Manager', depth: 2, reports_to: P_CEO, active: true, holders: [] },
],
};
export const readiness: Schemas['ProvisioningReport'] = {
steps: [
{ key: 'roles', order: 1, done: true, warnings: [] },
{ key: 'org_structure', order: 2, done: true, warnings: [
{ code: 'no_primary_position', detail: 'member has no primary position', ref: SITI },
{ code: 'unit_without_head', detail: 'unit FIN has no head', ref: U_FIN },
] },
],
};
web/src/features/orgstructure/__tests__/OrgStructurePage.test.tsx
import { describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/msw/server';
import { renderWithApp } from '@/test/renderWithApp';
import { chart, members, P_FIN, readiness, SITI, U_FIN } from './fixtures';
type Call = { method: string; path: string; body: unknown };
function mockApi(calls: Call[] = [], seen: string[] = []) {
const cap = async (method: string, r: Request) => calls.push({ method, path: new URL(r.url).pathname, body: await r.json() });
server.use(
http.get('*/v1/org/chart', () => { seen.push('chart'); return HttpResponse.json(chart); }),
http.get('*/v1/org/readiness', () => { seen.push('readiness'); return HttpResponse.json(readiness); }),
http.post('*/v1/org/members/:id/positions', async ({ request, params }) => { await cap('POST', request); return HttpResponse.json({ id: 'as1', member_id: params.id, position_id: P_FIN, is_primary: true, valid_from: '2026-09-23', valid_to: null, withdrawn_at: null }, { status: 201 }); }),
http.post('*/v1/org/positions', async ({ request }) => { await cap('POST', request); return HttpResponse.json({ id: 'p3', org_unit_id: U_FIN, code: 'FIN-EXEC', title: 'Finance Executive', depth: 3, reports_to: P_FIN, active: true }, { status: 201 }); }),
http.patch('*/v1/org/units/:id', async ({ request }) => { await cap('PATCH', request); return HttpResponse.json(chart.units[1]); }),
);
}
const ADMIN = ['identity.org.administer', 'org.structure.read', 'org.structure.manage'];
describe('OrgStructurePage', () => {
it('shows the setup checklist only with identity.org.administer, and does not fetch it otherwise (presence first)', async () => {
const seen: string[] = [];
mockApi([], seen);
const first = await renderWithApp({ path: '/org-structure', permissions: ADMIN, members });
const list = await screen.findByRole('region', { name: 'Setup checklist' });
expect(within(list).getByText('Siti Aminah has no primary position')).toBeInTheDocument();
expect(within(list).getByText('Finance has no head')).toBeInTheDocument();
expect(seen).toContain('readiness');
first.unmount();
const seen2: string[] = [];
mockApi([], seen2);
await renderWithApp({ path: '/org-structure', permissions: ['org.structure.read'], members });
expect(await screen.findByText('Finance Manager')).toBeInTheDocument();
expect(screen.queryByRole('region', { name: 'Setup checklist' })).not.toBeInTheDocument();
expect(seen2).not.toContain('readiness');
});
it('fixes no_primary_position by assigning that member a primary position', async () => {
const calls: Call[] = [];
mockApi(calls);
const { user } = await renderWithApp({ path: '/org-structure', permissions: ADMIN, members });
const row = (await screen.findByText('Siti Aminah has no primary position')).closest('li')!;
await user.click(within(row).getByRole('button', { name: 'Fix' }));
const dialog = await screen.findByRole('dialog', { name: 'Assign a position' });
expect(within(dialog).getByText('Siti Aminah')).toBeInTheDocument();
expect(within(dialog).getByRole('checkbox', { name: 'Primary position' })).toBeChecked();
await user.click(within(dialog).getByRole('combobox', { name: 'Position' }));
await user.click(await screen.findByRole('option', { name: /Finance Manager/ }));
await user.click(within(dialog).getByRole('button', { name: 'Assign' }));
await waitFor(() => expect(calls[0]).toMatchObject({ method: 'POST', path: `/v1/org/members/${SITI}/positions`, body: { position_id: P_FIN, is_primary: true } }));
expect((calls[0].body as { valid_from: string }).valid_from).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it('fixes unit_without_head by setting the unit head', async () => {
const calls: Call[] = [];
mockApi(calls);
const { user } = await renderWithApp({ path: '/org-structure', permissions: ADMIN, members });
const row = (await screen.findByText('Finance has no head')).closest('li')!;
await user.click(within(row).getByRole('button', { name: 'Fix' }));
const dialog = await screen.findByRole('dialog', { name: 'Edit unit' });
await user.click(within(dialog).getByRole('combobox', { name: 'Head position' }));
await user.click(await screen.findByRole('option', { name: /Finance Manager/ }));
await user.click(within(dialog).getByRole('button', { name: 'Save' }));
await waitFor(() => expect(calls[0]).toMatchObject({ method: 'PATCH', path: `/v1/org/units/${U_FIN}`, body: { head_position_id: P_FIN } }));
});
it('creates a position that reports to another', async () => {
const calls: Call[] = [];
mockApi(calls);
const { user } = await renderWithApp({ path: '/org-structure', permissions: ADMIN, members });
await user.click(await screen.findByRole('button', { name: 'Add position' }));
const dialog = await screen.findByRole('dialog', { name: 'Add position' });
await user.click(within(dialog).getByRole('combobox', { name: 'Unit' }));
await user.click(await screen.findByRole('option', { name: /Finance/ }));
await user.type(within(dialog).getByRole('textbox', { name: 'Code' }), 'FIN-EXEC');
await user.type(within(dialog).getByRole('textbox', { name: 'Title' }), 'Finance Executive');
await user.click(within(dialog).getByRole('combobox', { name: 'Reports to' }));
await user.click(await screen.findByRole('option', { name: /Finance Manager/ }));
await user.click(within(dialog).getByRole('button', { name: 'Save' }));
await waitFor(() => expect(calls[0]).toMatchObject({ method: 'POST', path: '/v1/org/positions', body: { org_unit_id: U_FIN, code: 'FIN-EXEC', title: 'Finance Executive', reports_to: P_FIN } }));
});
it('marks a vacant seat and a retired unit', async () => {
mockApi();
await renderWithApp({ path: '/org-structure', permissions: ['org.structure.read'], members });
const seat = (await screen.findByText('Finance Manager')).closest('li')!;
expect(within(seat).getByText('Vacant')).toBeInTheDocument();
const old = screen.getByText('Old branch').closest('li')!;
expect(within(old).getByText('Retired')).toBeInTheDocument();
expect(within(screen.getByText('Owner').closest('li')!).getByText('Alex Tan')).toBeInTheDocument();
});
});
  • Step 2: Run it and watch it fail

Run: pnpm -C web test -- src/features/orgstructure Expected: FAIL — no /org-structure route.

  • Step 3: API
web/src/features/orgstructure/api.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCan } from '@/features/auth';
import { api } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
import type { Schemas } from '@/lib/api/types';
export type Chart = Schemas['OrgChart'];
export type Unit = Schemas['OrgUnit'];
export type Position = Schemas['OrgChartPosition'];
export function useChart() {
const can = useCan('org.structure.read');
return useQuery({ queryKey: ['org', 'chart'], enabled: can, queryFn: async ({ signal }) => unwrap(await api.GET('/org/chart', { signal })) });
}
export function useReadiness() {
const can = useCan('identity.org.administer');
return useQuery({ queryKey: ['org', 'readiness'], enabled: can, queryFn: async ({ signal }) => unwrap(await api.GET('/org/readiness', { signal })) });
}
function useRefresh() {
const qc = useQueryClient();
return () => {
void qc.invalidateQueries({ queryKey: ['org', 'chart'] });
void qc.invalidateQueries({ queryKey: ['org', 'readiness'] });
};
}
export function useCreateUnit() {
const refresh = useRefresh();
return useMutation({ mutationFn: async (body: Schemas['CreateOrgUnitRequest']) => unwrap(await api.POST('/org/units', { body })), onSuccess: refresh });
}
export function useUpdateUnit() {
const refresh = useRefresh();
return useMutation({ mutationFn: async (v: { id: string; body: Schemas['UpdateOrgUnitRequest'] }) => unwrap(await api.PATCH('/org/units/{id}', { params: { path: { id: v.id } }, body: v.body })), onSuccess: refresh });
}
export function useCreatePosition() {
const refresh = useRefresh();
return useMutation({ mutationFn: async (body: Schemas['CreateOrgPositionRequest']) => unwrap(await api.POST('/org/positions', { body })), onSuccess: refresh });
}
export function useUpdatePosition() {
const refresh = useRefresh();
return useMutation({ mutationFn: async (v: { id: string; body: Schemas['UpdateOrgPositionRequest'] }) => unwrap(await api.PATCH('/org/positions/{id}', { params: { path: { id: v.id } }, body: v.body })), onSuccess: refresh });
}
export function useAssign() {
const refresh = useRefresh();
return useMutation({ mutationFn: async (v: { memberId: string; body: Schemas['AssignPositionRequest'] }) => unwrap(await api.POST('/org/members/{id}/positions', { params: { path: { id: v.memberId } }, body: v.body })), onSuccess: refresh });
}
/** Only the fields that changed: the PATCH endpoints refuse a body that "changes nothing" (422). */
export function diff<T extends Record<string, unknown>>(before: T, after: Partial<T>): Partial<T> {
return Object.fromEntries(Object.entries(after).filter(([k, v]) => before[k] !== v)) as Partial<T>;
}
  • Step 4: Dialogs
// web/src/features/orgstructure/RecordLanguageHint.tsx — C6
import { useTranslation } from 'react-i18next';
import { useLocale } from '@/lib/i18n';
export function RecordLanguageHint() {
const { t } = useTranslation('orgstructure');
const [locale] = useLocale();
return locale === 'zh' ? <p className="text-xs text-muted-foreground">{t('recordLanguageHint')}</p> : null;
}
web/src/features/orgstructure/UnitDialog.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Dialog } from '@/components/ui/Dialog';
import { Field } from '@/components/ui/Field';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { toast } from '@/components/ui/Toaster';
import { ProblemError, problemMessage } from '@/lib/api/problem';
import { diff, useCreateUnit, useUpdateUnit, type Chart, type Unit } from './api';
import { RecordLanguageHint } from './RecordLanguageHint';
export function UnitDialog(p: { chart: Chart; unit?: Unit; parentId?: string | null; onClose(): void }) {
const { t } = useTranslation('orgstructure');
const { t: tc } = useTranslation();
const create = useCreateUnit();
const update = useUpdateUnit();
const [v, setV] = useState({ code: p.unit?.code ?? '', name: p.unit?.name ?? '', parent: p.unit?.parent_id ?? p.parentId ?? '', head: p.unit?.head_position_id ?? '' });
const [error, setError] = useState<string | null>(null);
const units = p.chart.units.filter((u) => u.active && u.id !== p.unit?.id);
const positions = p.chart.positions.filter((x) => x.active);
const opt = (id: string) => (id ? id : null);
async function save(retire = false) {
setError(null);
try {
if (!p.unit) {
if (!v.code.trim() || !v.name.trim()) return setError(t('errors.codeName'));
await create.mutateAsync({ code: v.code.trim(), name: v.name.trim(), parent_id: opt(v.parent), head_position_id: opt(v.head) });
} else {
const body = retire ? { active: false as const } : diff(p.unit as unknown as Record<string, unknown>, { name: v.name.trim(), parent_id: opt(v.parent), head_position_id: opt(v.head) });
if (Object.keys(body).length === 0) return p.onClose();
await update.mutateAsync({ id: p.unit.id, body });
}
toast.success(t('saved'));
p.onClose();
} catch (e) {
setError(e instanceof ProblemError && e.detail ? e.detail : problemMessage(e, tc));
}
}
return (
<Dialog open onOpenChange={(o: boolean) => !o && p.onClose()} title={p.unit ? t('unit.edit') : t('unit.add')}
footer={<>
{p.unit?.active && <Button variant="danger-outline" className="mr-auto" onClick={() => save(true)}>{t('unit.retire')}</Button>}
<Button variant="outline" onClick={p.onClose}>{t('cancel')}</Button>
<Button loading={create.isPending || update.isPending} onClick={() => save()}>{t('save')}</Button>
</>}>
<div className="grid gap-4 md:grid-cols-2">
<Field label={t('unit.code')} htmlFor="un-code" hint={p.unit ? t('unit.codeFixed') : undefined} required>
<Input id="un-code" value={v.code} disabled={Boolean(p.unit)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setV((s) => ({ ...s, code: e.target.value }))} />
</Field>
<Field label={t('unit.name')} htmlFor="un-name" required>
<Input id="un-name" value={v.name} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setV((s) => ({ ...s, name: e.target.value }))} />
</Field>
<Field label={t('unit.parent')} htmlFor="un-parent">
<Select id="un-parent" aria-label={t('unit.parent')} value={v.parent} onChange={(x: string) => setV((s) => ({ ...s, parent: x }))}
options={[{ value: '', label: t('unit.root') }, ...units.map((u) => ({ value: u.id, label: `${u.code} · ${u.name}` }))]} />
</Field>
<Field label={t('unit.head')} htmlFor="un-head">
<Select id="un-head" aria-label={t('unit.head')} value={v.head} onChange={(x: string) => setV((s) => ({ ...s, head: x }))}
options={[{ value: '', label: t('unit.noHead') }, ...positions.map((x) => ({ value: x.id, label: `${x.title} (${x.code})` }))]} />
</Field>
</div>
<RecordLanguageHint />
{error && <p role="alert" className="mt-3 text-sm text-destructive">{error}</p>}
</Dialog>
);
}
web/src/features/orgstructure/PositionDialog.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Dialog } from '@/components/ui/Dialog';
import { Field } from '@/components/ui/Field';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { toast } from '@/components/ui/Toaster';
import { ProblemError, problemMessage } from '@/lib/api/problem';
import { useCreatePosition, useUpdatePosition, type Chart, type Position } from './api';
import { RecordLanguageHint } from './RecordLanguageHint';
export function PositionDialog(p: { chart: Chart; position?: Position; onClose(): void }) {
const { t } = useTranslation('orgstructure');
const { t: tc } = useTranslation();
const create = useCreatePosition();
const update = useUpdatePosition();
const [v, setV] = useState({ unit: p.position?.org_unit_id ?? '', code: p.position?.code ?? '', title: p.position?.title ?? '', reportsTo: p.position?.reports_to ?? '' });
const [error, setError] = useState<string | null>(null);
const others = p.chart.positions.filter((x) => x.active && x.id !== p.position?.id);
async function save(retire = false) {
setError(null);
try {
if (!p.position) {
if (!v.unit || !v.code.trim() || !v.title.trim()) return setError(t('errors.position'));
await create.mutateAsync({ org_unit_id: v.unit, code: v.code.trim(), title: v.title.trim(), reports_to: v.reportsTo || null });
} else if (retire) {
await update.mutateAsync({ id: p.position.id, body: { active: false } });
} else {
if ((v.reportsTo || null) === p.position.reports_to) return p.onClose();
await update.mutateAsync({ id: p.position.id, body: { reports_to: v.reportsTo || null } });
}
toast.success(t('saved'));
p.onClose();
} catch (e) {
setError(e instanceof ProblemError && e.detail ? e.detail : problemMessage(e, tc));
}
}
return (
<Dialog open onOpenChange={(o: boolean) => !o && p.onClose()} title={p.position ? t('position.edit') : t('position.add')}
footer={<>
{p.position?.active && <Button variant="danger-outline" className="mr-auto" onClick={() => save(true)}>{t('position.retire')}</Button>}
<Button variant="outline" onClick={p.onClose}>{t('cancel')}</Button>
<Button loading={create.isPending || update.isPending} onClick={() => save()}>{t('save')}</Button>
</>}>
<div className="grid gap-4 md:grid-cols-2">
<Field label={t('position.unit')} htmlFor="po-unit" required>
<Select id="po-unit" aria-label={t('position.unit')} disabled={Boolean(p.position)} value={v.unit} onChange={(x: string) => setV((s) => ({ ...s, unit: x }))}
options={p.chart.units.filter((u) => u.active).map((u) => ({ value: u.id, label: u.name }))} placeholder={t('position.pickUnit')} />
</Field>
<Field label={t('position.code')} htmlFor="po-code" required>
<Input id="po-code" disabled={Boolean(p.position)} value={v.code} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setV((s) => ({ ...s, code: e.target.value }))} />
</Field>
<Field label={t('position.title')} htmlFor="po-title" hint={p.position ? t('position.titleFixed') : undefined} required>
<Input id="po-title" disabled={Boolean(p.position)} value={v.title} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setV((s) => ({ ...s, title: e.target.value }))} />
</Field>
<Field label={t('position.reportsTo')} htmlFor="po-reports">
<Select id="po-reports" aria-label={t('position.reportsTo')} value={v.reportsTo} onChange={(x: string) => setV((s) => ({ ...s, reportsTo: x }))}
options={[{ value: '', label: t('position.root') }, ...others.map((x) => ({ value: x.id, label: `${x.title} (${x.code})` }))]} />
</Field>
</div>
<RecordLanguageHint />
{error && <p role="alert" className="mt-3 text-sm text-destructive">{error}</p>}
</Dialog>
);
}
web/src/features/orgstructure/AssignDialog.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Checkbox } from '@/components/ui/Checkbox';
import { Combobox } from '@/components/ui/Combobox';
import { DatePicker } from '@/components/ui/DatePicker';
import { Dialog } from '@/components/ui/Dialog';
import { Field } from '@/components/ui/Field';
import { Select } from '@/components/ui/Select';
import { toast } from '@/components/ui/Toaster';
import { useMemberDirectory, useMemberNames } from '@/features/members/useMemberNames';
import { ProblemError, problemMessage } from '@/lib/api/problem';
import { todayISO } from '@/lib/date';
import { useAssign, type Chart } from './api';
export function AssignDialog(p: { chart: Chart; memberId?: string; positionId?: string; primary?: boolean; title: string; onClose(): void }) {
const { t } = useTranslation('orgstructure');
const { t: tc } = useTranslation();
const directory = useMemberDirectory();
const name = useMemberNames();
const assign = useAssign();
const [member, setMember] = useState(p.memberId ?? '');
const [position, setPosition] = useState(p.positionId ?? '');
const [primary, setPrimary] = useState(p.primary ?? false);
const [from, setFrom] = useState(todayISO());
const [until, setUntil] = useState<string | undefined>();
const [error, setError] = useState<string | null>(null);
async function save() {
setError(null);
if (!member || !position) return setError(t('errors.assign'));
try {
await assign.mutateAsync({ memberId: member, body: { position_id: position, is_primary: primary, valid_from: from, valid_to: until ?? null } });
toast.success(t('assigned'));
p.onClose();
} catch (e) {
setError(e instanceof ProblemError && e.detail ? e.detail : problemMessage(e, tc));
}
}
return (
<Dialog open onOpenChange={(o: boolean) => !o && p.onClose()} title={p.title}
footer={<><Button variant="outline" onClick={p.onClose}>{t('cancel')}</Button><Button loading={assign.isPending} onClick={save}>{t('assign.submit')}</Button></>}>
<div className="grid gap-4 md:grid-cols-2">
<Field label={t('assign.member')} htmlFor="as-member" required>
{p.memberId ? <p id="as-member" className="py-2 font-medium">{name(p.memberId)}</p> : (
<Combobox id="as-member" aria-label={t('assign.member')} value={member} onChange={setMember} placeholder={t('assign.pickMember')}
options={(directory.data ?? []).filter((m) => m.active).map((m) => ({ value: m.member_id, label: m.name ?? `…${m.member_id.slice(-4)}` }))} />
)}
</Field>
<Field label={t('assign.position')} htmlFor="as-position" required>
{p.positionId ? <p id="as-position" className="py-2 font-medium">{p.chart.positions.find((x) => x.id === p.positionId)?.title}</p> : (
<Select id="as-position" aria-label={t('assign.position')} value={position} onChange={setPosition} placeholder={t('assign.pickPosition')}
options={p.chart.positions.filter((x) => x.active).map((x) => ({ value: x.id, label: `${x.title} (${x.code})` }))} />
)}
</Field>
<label htmlFor="as-primary" className="flex items-center gap-2 text-sm md:col-span-2">
<Checkbox id="as-primary" checked={primary} onCheckedChange={(c: boolean) => setPrimary(c)} /> {t('assign.primary')}
</label>
<Field label={t('assign.from')} htmlFor="as-from" required><DatePicker id="as-from" value={from} onChange={(x?: string) => setFrom(x ?? todayISO())} /></Field>
<Field label={t('assign.until')} hint={t('assign.untilHint')} htmlFor="as-until"><DatePicker id="as-until" value={until} onChange={setUntil} /></Field>
</div>
{error && <p role="alert" className="mt-3 text-sm text-destructive">{error}</p>}
</Dialog>
);
}
  • Step 5: Checklist, trees, page
web/src/features/orgstructure/ReadinessChecklist.tsx
import { useTranslation } from 'react-i18next';
import { CheckCircle2, CircleAlert, XCircle } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Skeleton } from '@/components/ui/Skeleton';
import { useMemberNames } from '@/features/members/useMemberNames';
import type { Schemas } from '@/lib/api/types';
import type { Chart } from './api';
export type Fix =
| { kind: 'assignMember'; memberId: string }
| { kind: 'assignPosition'; positionId: string }
| { kind: 'editUnit'; unitId: string }
| { kind: 'editPosition'; positionId: string };
const FIX: Record<string, (ref: string) => Fix> = {
no_primary_position: (ref) => ({ kind: 'assignMember', memberId: ref }),
vacant_position: (ref) => ({ kind: 'assignPosition', positionId: ref }),
unit_without_head: (ref) => ({ kind: 'editUnit', unitId: ref }),
reports_to_inactive: (ref) => ({ kind: 'editPosition', positionId: ref }),
};
export function ReadinessChecklist(p: { report?: Schemas['ProvisioningReport']; loading: boolean; chart?: Chart; canFix: boolean; onFix(f: Fix): void }) {
const { t } = useTranslation('orgstructure');
const name = useMemberNames();
const subject = (w: Schemas['ProvisioningWarning']) =>
p.chart?.units.find((u) => u.id === w.ref)?.name ?? p.chart?.positions.find((x) => x.id === w.ref)?.title ?? name(w.ref);
const clean = p.report?.steps.every((s) => s.done && s.warnings.length === 0);
return (
<section aria-labelledby="readiness-title" className="rounded-xl border border-border bg-card p-4">
<h2 id="readiness-title" className="font-bold">{t('readiness.title')}</h2>
{p.loading && <Skeleton className="mt-3 h-24 w-full" />}
{clean && <p className="mt-2 flex items-center gap-2 text-sm text-success"><CheckCircle2 className="size-4" aria-hidden />{t('readiness.complete')}</p>}
{p.report && !clean && (
<ol className="mt-3 flex flex-col gap-3">
{p.report.steps.map((s) => (
<li key={s.key}>
<p className="flex items-center gap-2 text-sm font-medium">
{s.done ? <CheckCircle2 className="size-4 text-success" aria-label={t('readiness.done')} /> : <XCircle className="size-4 text-destructive" aria-label={t('readiness.notDone')} />}
{t(`steps.${s.key}`, { defaultValue: s.key })}
</p>
{!s.done && s.error && <p className="ml-6 text-xs text-destructive">{s.error}</p>}
{s.warnings.length > 0 && (
<ul className="ml-6 mt-1 flex flex-col gap-1">
{s.warnings.map((w) => (
<li key={`${w.code}:${w.ref}`} className="flex items-center justify-between gap-2 text-sm">
<span className="flex items-start gap-2">
<CircleAlert className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
<span>{t(`warnings.${w.code}`, { subject: subject(w), defaultValue: w.detail })}<span className="block text-xs text-muted-foreground">{w.detail}</span></span>
</span>
{p.canFix && FIX[w.code] && <Button size="sm" variant="outline" onClick={() => p.onFix(FIX[w.code](w.ref))}>{t('readiness.fix')}</Button>}
</li>
))}
</ul>
)}
</li>
))}
</ol>
)}
</section>
);
}
web/src/features/orgstructure/UnitsTree.tsx
import { useTranslation } from 'react-i18next';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Tag } from '@/components/ui/Tag';
import { cn } from '@/lib/cn';
import type { Chart, Unit } from './api';
export function UnitsTree(p: { chart: Chart; canEdit: boolean; onAdd(parentId: string | null): void; onEdit(u: Unit): void }) {
const { t } = useTranslation('orgstructure');
const head = (u: Unit) => p.chart.positions.find((x) => x.id === u.head_position_id)?.title;
return (
<section aria-labelledby="units-title" className="rounded-xl border border-border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<h2 id="units-title" className="font-bold">{t('unit.title')}</h2>
{p.canEdit && <Button size="sm" variant="outline" onClick={() => p.onAdd(null)}><Plus className="size-4" aria-hidden /> {t('unit.add')}</Button>}
</div>
<ul className="flex flex-col">
{p.chart.units.map((u) => (
<li key={u.id} className={cn('flex items-center justify-between gap-2 border-t border-border py-2 text-sm', !u.active && 'text-muted-foreground')}
style={{ paddingInlineStart: `${(u.depth - 1) * 1.25}rem` }}>
<span>
<span className="font-mono text-xs">{u.code}</span> <span className="font-medium">{u.name}</span>
{!u.active && <Tag tone="muted">{t('retired')}</Tag>}
<span className="block text-xs text-muted-foreground">{head(u) ? t('unit.headIs', { title: head(u) }) : t('unit.noHead')}</span>
</span>
{p.canEdit && u.active && (
<span className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => p.onAdd(u.id)}>{t('unit.addChild')}</Button>
<Button size="sm" variant="ghost" onClick={() => p.onEdit(u)}>{t('edit')}</Button>
</span>
)}
</li>
))}
</ul>
</section>
);
}

The indentation is an inline style with a rem value (no colour), which the no-hex drift test allows; if Task 5’s lint forbids inline styles, use ps-[calc(var(--depth)*1.25rem)] with a --depth custom property instead.

web/src/features/orgstructure/PositionsTree.tsx
import { useTranslation } from 'react-i18next';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Tag } from '@/components/ui/Tag';
import { cn } from '@/lib/cn';
import type { Chart, Position } from './api';
export function PositionsTree(p: { chart: Chart; canEdit: boolean; onAdd(): void; onEdit(x: Position): void; onAssign(x: Position): void }) {
const { t } = useTranslation('orgstructure');
const unitName = (id: string) => p.chart.units.find((u) => u.id === id)?.name ?? '—';
return (
<section aria-labelledby="positions-title" className="rounded-xl border border-border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<h2 id="positions-title" className="font-bold">{t('position.title_plural')}</h2>
{p.canEdit && <Button size="sm" variant="outline" onClick={p.onAdd}><Plus className="size-4" aria-hidden /> {t('position.add')}</Button>}
</div>
<ul className="flex flex-col">
{p.chart.positions.map((x) => (
<li key={x.id} className={cn('flex items-center justify-between gap-2 border-t border-border py-2 text-sm', !x.active && 'text-muted-foreground')}
style={{ paddingInlineStart: `${(x.depth - 1) * 1.25}rem` }}>
<span>
<span className="font-medium">{x.title}</span> <span className="font-mono text-xs text-muted-foreground">{x.code}</span>
{!x.active && <Tag tone="muted">{t('retired')}</Tag>}
<span className="mt-1 flex flex-wrap items-center gap-1 text-xs">
<span className="text-muted-foreground">{unitName(x.org_unit_id)}</span>
{x.active && x.holders.length === 0 && <Tag tone="amber">{t('position.vacant')}</Tag>}
{x.holders.map((h) => <Tag key={h.member_id} tone="purple">{h.name ?? `…${h.member_id.slice(-4)}`}</Tag>)}
</span>
</span>
{p.canEdit && x.active && (
<span className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => p.onAssign(x)}>{t('position.assign')}</Button>
<Button size="sm" variant="ghost" onClick={() => p.onEdit(x)}>{t('edit')}</Button>
</span>
)}
</li>
))}
</ul>
</section>
);
}
web/src/features/orgstructure/OrgStructurePage.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PageHeader } from '@/components/ui/PageHeader';
import { useCan } from '@/features/auth';
import { useMemberNames } from '@/features/members/useMemberNames';
import { useChart, useReadiness, type Position, type Unit } from './api';
import { AssignDialog } from './AssignDialog';
import { PositionDialog } from './PositionDialog';
import { PositionsTree } from './PositionsTree';
import { ReadinessChecklist, type Fix } from './ReadinessChecklist';
import { UnitDialog } from './UnitDialog';
import { UnitsTree } from './UnitsTree';
type Open =
| { kind: 'unit'; unit?: Unit; parentId?: string | null }
| { kind: 'position'; position?: Position }
| { kind: 'assign'; memberId?: string; positionId?: string; primary?: boolean; title: string };
export function OrgStructurePage() {
const { t } = useTranslation('orgstructure');
const canAdmin = useCan('identity.org.administer');
const canRead = useCan('org.structure.read');
const canEdit = useCan('org.structure.manage');
const chart = useChart();
const readiness = useReadiness();
const name = useMemberNames();
const [open, setOpen] = useState<Open | null>(null);
function onFix(f: Fix) {
const c = chart.data;
if (f.kind === 'assignMember') setOpen({ kind: 'assign', memberId: f.memberId, primary: true, title: t('assign.forMember', { name: name(f.memberId) }) });
if (f.kind === 'assignPosition') setOpen({ kind: 'assign', positionId: f.positionId, title: t('assign.forPosition') });
if (f.kind === 'editUnit') setOpen({ kind: 'unit', unit: c?.units.find((u) => u.id === f.unitId) });
if (f.kind === 'editPosition') setOpen({ kind: 'position', position: c?.positions.find((x) => x.id === f.positionId) });
}
return (
<div className="flex flex-col gap-4">
<PageHeader title={t('title')} subtitle={t('subtitle')} />
{canAdmin && <ReadinessChecklist report={readiness.data} loading={readiness.isLoading} chart={chart.data} canFix={canEdit && Boolean(chart.data)} onFix={onFix} />}
{canRead && chart.data && (
<div className="grid gap-4 lg:grid-cols-2">
<UnitsTree chart={chart.data} canEdit={canEdit} onAdd={(parentId) => setOpen({ kind: 'unit', parentId })} onEdit={(unit) => setOpen({ kind: 'unit', unit })} />
<PositionsTree chart={chart.data} canEdit={canEdit} onAdd={() => setOpen({ kind: 'position' })} onEdit={(position) => setOpen({ kind: 'position', position })}
onAssign={(x) => setOpen({ kind: 'assign', positionId: x.id, title: t('assign.forPosition') })} />
</div>
)}
{chart.data && open?.kind === 'unit' && <UnitDialog chart={chart.data} unit={open.unit} parentId={open.parentId} onClose={() => setOpen(null)} />}
{chart.data && open?.kind === 'position' && <PositionDialog chart={chart.data} position={open.position} onClose={() => setOpen(null)} />}
{chart.data && open?.kind === 'assign' && <AssignDialog chart={chart.data} memberId={open.memberId} positionId={open.positionId} primary={open.primary} title={open.title} onClose={() => setOpen(null)} />}
</div>
);
}

The “fixes no_primary_position” test expects the dialog name Assign a position; so assign.forMember in en is “Assign a position” (the member’s name shows in the dialog body), and assign.forPosition is “Assign a holder”.

  • Step 6: Routes and nav
web/src/features/orgstructure/routes.tsx
import { createRoute } from '@tanstack/react-router';
import { appRoute } from '@/app/layouts';
import { RequirePermission } from '@/features/auth';
import { OrgStructurePage } from './OrgStructurePage';
const orgStructureRoute = createRoute({
getParentRoute: () => appRoute,
path: 'org-structure',
component: () => <RequirePermission permission={['org.structure.read', 'identity.org.administer']}><OrgStructurePage /></RequirePermission>,
});
export const routes = [orgStructureRoute] as const;

Append to web/src/app/routes.ts: import { routes as orgStructureRoutes } from '@/features/orgstructure/routes'; / ...orgStructureRoutes,. Append to web/src/app/shell/nav.ts:

import { Network } from 'lucide-react';
{ group: 'manage', to: '/org-structure', labelKey: 'orgstructure:nav', icon: Network, permission: ['org.structure.read', 'identity.org.administer'] },
  • Step 7: i18n bundles

en/orgstructure.json

{
"nav": "Org structure",
"title": "Org structure",
"subtitle": "Units, positions and who holds them. Approvals are routed through this.",
"recordLanguageHint": "Names are kept in English or Malay. Please type them in English or Malay.",
"save": "Save", "cancel": "Cancel", "edit": "Edit", "retired": "Retired", "saved": "Saved", "assigned": "Assigned",
"readiness": { "title": "Setup checklist", "complete": "Setup complete", "done": "Done", "notDone": "Not done", "fix": "Fix" },
"steps": { "company_profile": "Company profile", "roles": "Roles", "org_structure": "Org structure", "chart_of_accounts": "Chart of accounts", "journals": "Journals", "tax_codes": "Tax codes", "fiscal_year": "Fiscal year", "analytic_axes": "Analytic axes", "workflow_definitions": "Workflows", "payout_registries": "Payout registries", "payout_channels": "Payout channels", "expense_categories": "Expense categories" },
"warnings": { "no_primary_position": "{{subject}} has no primary position", "vacant_position": "{{subject}} has nobody in it", "unit_without_head": "{{subject}} has no head", "reports_to_inactive": "{{subject}} reports to a retired position" },
"unit": { "title": "Units", "add": "Add unit", "addChild": "Add sub-unit", "edit": "Edit unit", "retire": "Retire unit", "code": "Code", "codeFixed": "A unit's code cannot be changed", "name": "Name", "parent": "Parent unit", "root": "None (top level)", "head": "Head position", "noHead": "No head", "headIs": "Head: {{title}}" },
"position": { "title_plural": "Positions", "add": "Add position", "edit": "Edit position", "retire": "Retire position", "unit": "Unit", "pickUnit": "Choose a unit", "code": "Code", "title": "Title", "titleFixed": "A position's title cannot be changed", "reportsTo": "Reports to", "root": "Nobody (top of the tree)", "vacant": "Vacant", "assign": "Assign" },
"assign": { "forMember": "Assign a position", "forPosition": "Assign a holder", "member": "Member", "pickMember": "Search people…", "position": "Position", "pickPosition": "Choose a position", "primary": "Primary position", "from": "From", "until": "Until", "untilHint": "The first day they no longer hold it. Leave empty for no end.", "submit": "Assign" },
"errors": { "codeName": "Enter a code and a name", "position": "Choose a unit and enter a code and a title", "assign": "Choose a member and a position" }
}

ms/orgstructure.json

{
"nav": "Struktur organisasi",
"title": "Struktur organisasi",
"subtitle": "Unit, jawatan dan siapa yang memegangnya. Kelulusan dihalakan melalui struktur ini.",
"recordLanguageHint": "Nama disimpan dalam bahasa Inggeris atau Melayu. Sila taip dalam bahasa Inggeris atau Melayu.",
"save": "Simpan", "cancel": "Batal", "edit": "Sunting", "retired": "Dipersarakan", "saved": "Disimpan", "assigned": "Ditugaskan",
"readiness": { "title": "Senarai semak persediaan", "complete": "Persediaan lengkap", "done": "Selesai", "notDone": "Belum selesai", "fix": "Betulkan" },
"steps": { "company_profile": "Profil syarikat", "roles": "Peranan", "org_structure": "Struktur organisasi", "chart_of_accounts": "Carta akaun", "journals": "Jurnal", "tax_codes": "Kod cukai", "fiscal_year": "Tahun fiskal", "analytic_axes": "Paksi analitik", "workflow_definitions": "Aliran kerja", "payout_registries": "Daftar pembayaran", "payout_channels": "Saluran pembayaran", "expense_categories": "Kategori perbelanjaan" },
"warnings": { "no_primary_position": "{{subject}} tiada jawatan utama", "vacant_position": "{{subject}} tiada pemegang", "unit_without_head": "{{subject}} tiada ketua", "reports_to_inactive": "{{subject}} melapor kepada jawatan yang dipersarakan" },
"unit": { "title": "Unit", "add": "Tambah unit", "addChild": "Tambah sub-unit", "edit": "Sunting unit", "retire": "Persarakan unit", "code": "Kod", "codeFixed": "Kod unit tidak boleh diubah", "name": "Nama", "parent": "Unit induk", "root": "Tiada (peringkat atas)", "head": "Jawatan ketua", "noHead": "Tiada ketua", "headIs": "Ketua: {{title}}" },
"position": { "title_plural": "Jawatan", "add": "Tambah jawatan", "edit": "Sunting jawatan", "retire": "Persarakan jawatan", "unit": "Unit", "pickUnit": "Pilih unit", "code": "Kod", "title": "Gelaran", "titleFixed": "Gelaran jawatan tidak boleh diubah", "reportsTo": "Melapor kepada", "root": "Tiada (puncak struktur)", "vacant": "Kosong", "assign": "Tugaskan" },
"assign": { "forMember": "Tugaskan jawatan", "forPosition": "Tugaskan pemegang", "member": "Ahli", "pickMember": "Cari orang…", "position": "Jawatan", "pickPosition": "Pilih jawatan", "primary": "Jawatan utama", "from": "Dari", "until": "Hingga", "untilHint": "Hari pertama mereka tidak lagi memegangnya. Biarkan kosong jika tiada tarikh akhir.", "submit": "Tugaskan" },
"errors": { "codeName": "Masukkan kod dan nama", "position": "Pilih unit dan masukkan kod serta gelaran", "assign": "Pilih ahli dan jawatan" }
}

zh/orgstructure.json

{
"nav": "组织架构",
"title": "组织架构",
"subtitle": "部门、职位及任职人员。审批按此架构流转。",
"recordLanguageHint": "名称须使用英文或马来文,请用英文或马来文填写。",
"save": "保存", "cancel": "取消", "edit": "编辑", "retired": "已停用", "saved": "已保存", "assigned": "已任命",
"readiness": { "title": "设置检查清单", "complete": "设置已完成", "done": "已完成", "notDone": "未完成", "fix": "去处理" },
"steps": { "company_profile": "公司资料", "roles": "角色", "org_structure": "组织架构", "chart_of_accounts": "会计科目", "journals": "日记账", "tax_codes": "税码", "fiscal_year": "会计年度", "analytic_axes": "分析维度", "workflow_definitions": "审批流程", "payout_registries": "付款登记表", "payout_channels": "付款渠道", "expense_categories": "费用类别" },
"warnings": { "no_primary_position": "{{subject}} 没有主要职位", "vacant_position": "{{subject}} 无人任职", "unit_without_head": "{{subject}} 没有负责人", "reports_to_inactive": "{{subject}} 的上级职位已停用" },
"unit": { "title": "部门", "add": "新增部门", "addChild": "新增下级部门", "edit": "编辑部门", "retire": "停用部门", "code": "编码", "codeFixed": "部门编码不可更改", "name": "名称", "parent": "上级部门", "root": "无(顶级)", "head": "负责人职位", "noHead": "无负责人", "headIs": "负责人:{{title}}" },
"position": { "title_plural": "职位", "add": "新增职位", "edit": "编辑职位", "retire": "停用职位", "unit": "部门", "pickUnit": "选择部门", "code": "编码", "title": "职位名称", "titleFixed": "职位名称不可更改", "reportsTo": "汇报对象", "root": "无(最高职位)", "vacant": "空缺", "assign": "任命" },
"assign": { "forMember": "任命职位", "forPosition": "任命人员", "member": "成员", "pickMember": "搜索成员…", "position": "职位", "pickPosition": "选择职位", "primary": "主要职位", "from": "开始", "until": "结束", "untilHint": "不再任职的第一天。留空表示无结束日期。", "submit": "任命" },
"errors": { "codeName": "请填写编码和名称", "position": "请选择部门并填写编码和职位名称", "assign": "请选择成员和职位" }
}
  • Step 8: Run the tests

Run: pnpm -C web test -- src/features/orgstructure src/drift Expected: PASS. Then typecheck and lint.

  • Step 9: Runtime check

Dev stack and preview, as the Owner of the freshly seeded demo org. At 1440, light, en:

  1. Sidebar Org structure: the checklist lists each step in order; the seed leaves at least one no_primary_position (a seeded Employee) — its row reads “ has no primary position” with Fix.
  2. Fix → “Assign a position” with the member’s name fixed and Primary position ticked; pick a seat, Assign: POST /v1/org/members/{id}/positions; the warning disappears from the refreshed checklist and the seat shows the new holder tag.
  3. Add unit under Headquarters, then Edit it and set a head: the “no head” warning for it clears.
  4. Add position reporting to Finance Manager: it appears indented under it.
  5. Retire a position somebody holds: the server’s 409 detail (“close or withdraw their assignments first”) shows in the dialog.
  6. As a member with only org.structure.read: trees visible, no checklist, no edit buttons; no request to /v1/org/readiness in the network tab. Then 390px (checklist then the two trees stacked; Fix buttons wrap), dark, zh (组织架构, 设置检查清单, 去处理, 部门 / 职位, 空缺).
  • Step 10: Commit
Terminal window
git add web/src/features/orgstructure web/src/lib/i18n/locales/*/orgstructure.json web/src/app/routes.ts web/src/app/shell/nav.ts
git commit -m "$(cat <<'MSG'
Plan 10 Task 19: org structure (units and positions trees, assign holders) and setup checklist
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MSG
)"

Written against api/openapi.yaml at 6d2f48e (branch plan-10-frontend) and the plan skeleton’s Interface registry. Every API shape quoted below was read from that file. Where a task depends on a name the registry does not yet fix, the task says Verify: and the name is listed in Part D notes → Registry changes needed.

These restate the Global Constraints as they apply to Tasks 20–28, and fix the few conventions the registry leaves open. They are binding on each task below.

  • Feature folders and ownership. Each task owns the folder it creates: features/workflow/ (T20), features/payout/ (T21), features/accounting/accounts/ plus features/accounting/queries.ts (T22), features/accounting/entries/ (T23), features/accounting/periods/ (T24), features/reports/ (T25), features/pettycash/register/ (T26 — a subfolder, because features/pettycash/ and its routes.tsx belong to Task 16). Each folder has its own routes.tsx exporting routes.

  • Group G ordering. 23, 24 and 25 import features/accounting/queries.ts, which Task 22 creates. So 22 merges first, then 23, 24 and 25 start from it. 20, 21 and 26 have no dependency inside the group. Task 21 needs the chart and the journals too, but it reads them through its own two queries under the same query keys as Task 22’s (['accounting','accounts'], ['accounting','journals']), so the cache is shared and 21 does not wait for 22.

  • Routes (P1). A feature’s routes hang off the authenticated layout route, and web/src/app/routes.ts gets one import and one array entry per feature:

    import { routes as workflowRoutes } from '@/features/workflow/routes';
    // …inside the exported array:
    ...workflowRoutes,

    Verify: the parent route’s export name and file (this part assumes appRoute from web/src/app/appRoute.tsx) and the array’s name in app/routes.ts (assumed featureRoutes), as Task 8 built them. Use Task 8’s names if they differ; do not add a second parent route.

  • Nav (P2). Each item is one object literal appended to the navItems array in web/src/app/shell/nav.ts. labelKey uses the i18next namespace prefix, e.g. 'workflowEditor:nav', so the key lives in this task’s own namespace file and no task edits another’s bundle. Verify: that the Sidebar calls t(item.labelKey) with the default : namespace separator. If Task 8 resolves labels from a fixed nav namespace, amend the registry rather than editing Task 8’s bundle.

  • i18n namespaces. One per task: workflowEditor, payoutAdmin, accounts, journal, periods, reports, floatRegister. Files are web/src/lib/i18n/locales/{en,ms,zh}/<ns>.json. Verify: that Task 6’s initI18n discovers namespaces by glob (import.meta.glob('./locales/*/*.json')). If it lists them by hand, append the namespace there.

  • Type aliases. Each alias is appended to web/src/lib/api/types.ts by exactly one task, named below, so two parallel appends never declare the same name. Before appending, grep -n "export type <Name> " web/src/lib/api/types.ts. If an earlier task already added it, skip that line. Other tasks write Schemas['X'] inline.

  • Test harness. Component tests use server from web/src/test/msw/server.ts (Task 5), with per-test handlers through server.use(...), and renderWithProviders(ui, { permissions, locale }) from web/src/test/render.tsx. Verify: the helper’s name and options. It must seed the ['me'] query with the given permissions, provide a fresh QueryClient, i18n and a memory router. If Task 6 named it differently, use that name. It is listed under Registry changes needed. Handlers match the /v1/... path the generated client calls.

  • Theme classes. Components use the Tailwind theme tokens Task 7 defines. This part writes bg-surface, bg-surface-muted, text-fg, text-muted, border-outline, bg-primary, text-primary, bg-primary-soft, text-danger, border-danger, stroke-danger, stroke-primary, stroke-outline, fill-surface, fill-fg, fill-muted, text-success, bg-warning-soft. Verify: each name against web/src/styles/tokens.css before the first commit and rename to Task 7’s names. Never write a hex value (the drift test fails).

  • Forbidden in web/src, by the float drift test: parseFloat, Number(, unary +, toFixed, and Math. near an amount. Part D code never uses them anywhere. Integers that are not money (sort order, levels, days) are parsed with parseInt(s, 10).

  • Runtime check. Each screen task ends with the same procedure, and its own clicks. Start the dev stack (scripts/dev-stack.sh, Task 8), then preview_start with name: "web". Log in as the seeded Owner (credentials from web/e2e/seed.ts, DEMO_USERS.owner). At 1440 × 900 (resize_window preset: "desktop") and at 390 (preset: "mobile", reload), in colorScheme: "light" and "dark", and in en and zh (user menu → Language), do the task’s clicks and read read_console_messages { onlyErrors: true }, which must be empty. Record in the task report what was checked, and reset with preset: "desktop" at the end.

  • Commits end with a blank line and Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.


Spec §5.7 item 1, rulings F14 (routing preview) and F15 (no drag-edit canvas). The editor edits a draft. A published version is never edited, because instances are pinned to it. The server validates on every save and returns the violations, and the editor shows them in three places: a banner, the state list, and the graph. Publish is disabled while there are violations or unsaved edits. The server still decides.

What the API gives, read from api/openapi.yaml:

  • GET /workflow/definitions → WorkflowKindList { kinds: [{ kind, latest_version, draft_revision }] } (0 means none).
  • GET /workflow/definitions/{kind} → the latest WorkflowDefinition. This task reads it through Task 9’s useWorkflowDefinition(kind).
  • GET /workflow/drafts/{kind} → WorkflowDraft { kind, revision, base_version, graph, updated_at, updated_by, violations[] }, or 404 when there is no draft.
  • PUT /workflow/drafts/{kind} body { revision, graph }. revision is the draft’s current revision, or 0 to create the draft. It saves even an invalid graph and returns its violations. A stale revision → 409.
  • DELETE /workflow/drafts/{kind}?revision= → 204.
  • POST /workflow/drafts/{kind}/publish body { revision } → 201 WorkflowDefinition. 422 while violations remain, 409 when the revision is stale.
  • GET /workflow/definitions/{kind}/routing?member_id=&amount= → { version, steps: WorkflowStepRouting[] }.
  • Permissions: workflow.definition.read (all reads), .manage (save, discard), .publish.

A violation’s ref is one of: a state key; "<from>-><to>" for an edge; "grants[<i>]", the index into graph.grants as sent (the server’s Normalized() fills defaults but keeps the order); the kind or posting_order value for rule-0 header problems; or empty for graph-wide rules (draft_count, no_terminal, too_large). Seeded from internal/platform/workflow/validate.go. The editor never re-implements a rule. It only maps ref to what it highlights.

Files:

  • Create: web/src/features/workflow/api.ts
  • Create: web/src/features/workflow/graphModel.ts, graphModel.test.ts
  • Create: web/src/features/workflow/bands.ts, bands.test.ts
  • Create: web/src/features/workflow/layout.ts
  • Create: web/src/features/workflow/WorkflowGraphView.tsx
  • Create: web/src/features/workflow/StateList.tsx
  • Create: web/src/features/workflow/LabelsEditor.tsx
  • Create: web/src/features/workflow/StateInspector.tsx
  • Create: web/src/features/workflow/EdgeInspector.tsx
  • Create: web/src/features/workflow/GrantsTable.tsx
  • Create: web/src/features/workflow/BandsPreview.tsx
  • Create: web/src/features/workflow/ViolationsBanner.tsx
  • Create: web/src/features/workflow/RoutingPreview.tsx
  • Create: web/src/features/workflow/WorkflowListPage.tsx
  • Create: web/src/features/workflow/WorkflowEditorPage.tsx, WorkflowEditorPage.test.tsx
  • Create: web/src/features/workflow/__fixtures__/expenseClaim.ts
  • Create: web/src/features/workflow/routes.tsx
  • Create: web/src/lib/i18n/locales/en/workflowEditor.json, ms/workflowEditor.json, zh/workflowEditor.json
  • Append: web/src/app/routes.ts, web/src/app/shell/nav.ts, web/src/lib/api/types.ts
  • Dependency: pnpm -C web add elkjs

Interfaces:

  • Consumes: api, unwrap, ProblemError, problemMessage (T6); pickLabel, useLocale, UiLocale, SUPPORTED_LOCALES (T6); compareMoney, isMoneyInput, isZeroMoney, formatMoney (T6); MoneyInput, MoneyText (T6); formatDateTime, relativeTime (T6); cn (T6); Button, IconButton, Input, Label, Field, Select, Combobox, Checkbox, Tag, Card, PageHeader, EmptyState, Skeleton, ConfirmDialog, Tooltip, Tabs, DataTable, toast (T7); useMe, useCan, RequirePermission, NavItem, navItems (T8); useWorkflowDefinition, semanticTone, useMemberNames, useMemberDirectory (T9); appRoute, featureRoutes, renderWithProviders, server (see Conventions).

  • Produces: routes (/admin/workflows, /admin/workflows/$kind); wfKeys; useWorkflowKinds(), useWorkflowDraft(kind), useSaveDraft(kind), usePublishDraft(kind), useDiscardDraft(kind), useRoutingPreview(kind, memberId, amount); pure graphModel and bands functions (below); WorkflowGraphView. Type aliases appended to types.ts: WorkflowGraph, WorkflowEdge, WorkflowGrant, WorkflowDraft, WorkflowViolation, WorkflowStepRouting, WorkflowKindSummary, OrgPosition (skip OrgPosition if Task 19 added it).

  • Step 1: Add elkjs and the type aliases

Terminal window
pnpm -C web add elkjs
grep -n "export type OrgPosition " web/src/lib/api/types.ts || echo "OrgPosition not yet aliased"

Append to web/src/lib/api/types.ts (leave out any line the grep shows is already there):

export type WorkflowGraph = Schemas['WorkflowGraph'];
export type WorkflowEdge = Schemas['WorkflowEdge'];
export type WorkflowGrant = Schemas['WorkflowGrant'];
export type WorkflowDraft = Schemas['WorkflowDraft'];
export type WorkflowViolation = Schemas['WorkflowViolation'];
export type WorkflowStepRouting = Schemas['WorkflowStepRouting'];
export type WorkflowKindSummary = Schemas['WorkflowKindList']['kinds'][number];
export type OrgPosition = Schemas['OrgPosition'];
  • Step 2: Write the fixture — web/src/features/workflow/__fixtures__/expenseClaim.ts

The seeded expense_claim graph, copied from DefaultExpenseClaim (internal/platform/workflow/seed.go). The owner seat is a fixed test uuid. Tests derive their broken variants from it.

import type { WorkflowDefinition, WorkflowDraft, WorkflowGraph } from '@/lib/api/types';
export const OWNER_SEAT = '00000000-0000-4000-8000-0000000000a1';
export const OWNER_MEMBER = '00000000-0000-4000-8000-0000000000b1';
const ls = (en: string, zh: string, ms: string) => ({ en, zh, ms });
const reject = ls('Reject', '拒绝', 'Tolak');
export const expenseClaimGraph: WorkflowGraph = {
kind: 'expense_claim',
posting_order: 'accrual_first',
states: [
{ key: 'draft', semantic: 'draft', display_order: 1, is_terminal: false, labels: ls('Draft', '草稿', 'Draf') },
{ key: 'pending_review', semantic: 'pending', display_order: 2, is_terminal: false, labels: ls('Pending review', '待审核', 'Menunggu semakan') },
{ key: 'reviewed', semantic: 'pending', display_order: 3, is_terminal: false, labels: ls('Reviewed', '已审核', 'Disemak') },
{ key: 'approved', semantic: 'pending', display_order: 4, is_terminal: false, labels: ls('Approved', '已批准', 'Diluluskan') },
{ key: 'category_verified', semantic: 'accrual', control_kind: 'ap', display_order: 5, is_terminal: false, labels: ls('Category verified', '类别已核实', 'Kategori disahkan') },
{ key: 'completed', semantic: 'payout', display_order: 6, is_terminal: false, labels: ls('Paid', '已付款', 'Telah dibayar') },
{ key: 'received', semantic: 'acknowledged', display_order: 7, is_terminal: true, labels: ls('Received', '已收款', 'Telah diterima') },
{ key: 'rejected', semantic: 'rejected', display_order: 8, is_terminal: false, labels: ls('Rejected', '已拒绝', 'Ditolak') },
{ key: 'disputed', semantic: 'dispute', display_order: 9, is_terminal: false, labels: ls('Disputed', '有争议', 'Dipertikaikan') },
],
edges: [
{ from: 'draft', to: 'pending_review', gate: 'reporter', action_key: 'submit', requires_reason: false, reverses_accrual: false, display_order: 1, labels: ls('Submit', '提交', 'Hantar') },
{ from: 'pending_review', to: 'reviewed', gate: 'target', action_key: 'review', requires_reason: false, reverses_accrual: false, display_order: 2, labels: ls('Mark reviewed', '审核通过', 'Tandakan disemak') },
{ from: 'reviewed', to: 'approved', gate: 'target', action_key: 'approve', requires_reason: false, reverses_accrual: false, display_order: 3, labels: ls('Approve', '批准', 'Luluskan') },
{ from: 'approved', to: 'category_verified', gate: 'target', action_key: 'verify_category', requires_reason: false, reverses_accrual: false, display_order: 4, labels: ls('Verify category', '核实类别', 'Sahkan kategori') },
{ from: 'category_verified', to: 'completed', gate: 'target', action_key: 'pay', requires_reason: false, reverses_accrual: false, display_order: 5, labels: ls('Mark paid', '标记已付款', 'Tandakan telah dibayar') },
{ from: 'completed', to: 'received', gate: 'reporter', action_key: 'acknowledge', requires_reason: false, reverses_accrual: false, display_order: 6, labels: ls('Confirm receipt', '确认收款', 'Sahkan penerimaan') },
{ from: 'pending_review', to: 'rejected', gate: 'advance', action_key: 'reject', requires_reason: true, reverses_accrual: false, display_order: 7, labels: reject },
{ from: 'reviewed', to: 'rejected', gate: 'advance', action_key: 'reject', requires_reason: true, reverses_accrual: false, display_order: 8, labels: reject },
{ from: 'approved', to: 'rejected', gate: 'advance', action_key: 'reject', requires_reason: true, reverses_accrual: false, display_order: 9, labels: reject },
{ from: 'category_verified', to: 'rejected', gate: 'advance', action_key: 'reject', requires_reason: true, reverses_accrual: true, display_order: 10, labels: reject },
{ from: 'rejected', to: 'draft', gate: 'reporter', action_key: 'revise', requires_reason: false, reverses_accrual: false, display_order: 11, labels: ls('Revise', '修改', 'Semak semula') },
{ from: 'completed', to: 'disputed', gate: 'reporter', action_key: 'dispute', requires_reason: true, reverses_accrual: false, display_order: 12, labels: ls('Raise dispute', '提出争议', 'Ajukan pertikaian') },
{ from: 'received', to: 'disputed', gate: 'reporter', action_key: 'dispute', requires_reason: true, reverses_accrual: false, display_order: 13, labels: ls('Raise dispute', '提出争议', 'Ajukan pertikaian') },
{ from: 'disputed', to: 'received', gate: 'source', action_key: 'dismiss', requires_reason: false, reverses_accrual: false, display_order: 14, labels: ls('Dismiss dispute', '驳回争议', 'Ketepikan pertikaian') },
{ from: 'disputed', to: 'rejected', gate: 'source', action_key: 'uphold', requires_reason: true, reverses_accrual: true, display_order: 15, labels: ls('Uphold dispute', '支持争议', 'Terima pertikaian') },
],
grants: [
{ state_key: 'reviewed', all_states: false, grantee_kind: 'superior', on_vacancy: 'escalate', max_escalation_levels: 2 },
{ state_key: 'approved', all_states: false, grantee_kind: 'unit_head', on_vacancy: 'escalate', max_escalation_levels: 2 },
{ state_key: 'category_verified', all_states: false, grantee_kind: 'position', position_id: OWNER_SEAT, on_vacancy: 'escalate', max_escalation_levels: 2 },
{ state_key: 'completed', all_states: false, grantee_kind: 'position', position_id: OWNER_SEAT, on_vacancy: 'escalate', max_escalation_levels: 2 },
{ state_key: 'disputed', all_states: false, grantee_kind: 'position', position_id: OWNER_SEAT, on_vacancy: 'escalate', max_escalation_levels: 2 },
],
};
export const publishedV1: WorkflowDefinition = {
id: '00000000-0000-4000-8000-0000000000c1', kind: 'expense_claim', version: 1,
published_at: '2026-09-01T02:00:00Z', published_by: null, graph: expenseClaimGraph,
};
/** The graph with the superior grant on `reviewed` removed: rule 4 fires twice, on the
* review edge and on the advance-gated reject edge whose gate state is `reviewed`. */
export const graphMissingReviewer: WorkflowGraph = {
...expenseClaimGraph,
grants: expenseClaimGraph.grants.slice(1),
};
export const draftWithViolations: WorkflowDraft = {
kind: 'expense_claim', revision: 3, base_version: 1, graph: graphMissingReviewer,
updated_at: '2026-09-23T01:00:00Z', updated_by: OWNER_MEMBER,
violations: [
{ rule: 4, code: 'gate_without_grant', ref: 'pending_review->reviewed', detail: 'nobody is granted reviewed, which authorizes this edge' },
{ rule: 4, code: 'gate_without_grant', ref: 'pending_review->rejected', detail: 'nobody is granted reviewed, which authorizes this edge' },
{ rule: 0, code: 'currency_mismatch', ref: 'grants[0]', detail: 'floors are in the base currency MYR until FX rates exist' },
{ rule: 1, code: 'no_terminal', detail: 'at least one state must be terminal' },
],
};
export const draftClean: WorkflowDraft = {
...draftWithViolations, revision: 4, graph: expenseClaimGraph, violations: [],
};

Verify: that WorkflowDefinition is the alias name Task 5 created (the registry lists it) and that published_by accepts null in the generated type.

  • Step 3: Write the failing pure tests — graphModel.test.ts and bands.test.ts
web/src/features/workflow/graphModel.test.ts
import { describe, expect, it } from 'vitest';
import { expenseClaimGraph as g, draftWithViolations } from './__fixtures__/expenseClaim';
import {
addEdge, addGrant, addState, edgeRef, indexViolations, moveEdge, moveState,
removeState, stateHasGrantViolation, updateGrant, updateState,
} from './graphModel';
describe('indexViolations', () => {
it('routes each ref to the state, edge or grant it names, and keeps the rest general', () => {
const idx = indexViolations(draftWithViolations.graph, draftWithViolations.violations);
expect(idx.edges.get('pending_review->reviewed')).toHaveLength(1);
expect(idx.edges.get('pending_review->rejected')).toHaveLength(1);
expect(idx.grants.get('0')?.[0].code).toBe('currency_mismatch');
expect(idx.general.map((v) => v.code)).toEqual(['no_terminal']);
// presence first: the state exists and a violation index was built for it…
expect(g.states.some((s) => s.key === 'reviewed')).toBe(true);
// …then absence: no violation names `reviewed` itself.
expect(idx.states.has('reviewed')).toBe(false);
});
it('marks the state that owns a violating grant', () => {
const idx = indexViolations(draftWithViolations.graph, draftWithViolations.violations);
// graphMissingReviewer.grants[0] is the unit_head grant on `approved`
expect(stateHasGrantViolation(draftWithViolations.graph, idx, 'approved')).toBe(true);
expect(stateHasGrantViolation(draftWithViolations.graph, idx, 'draft')).toBe(false);
});
});
describe('graph edits', () => {
it('moveState swaps neighbours and renumbers display_order from 1', () => {
const out = moveState(g, 'reviewed', -1);
const order = [...out.states].sort((a, b) => a.display_order - b.display_order).map((s) => s.key);
expect(order.slice(0, 3)).toEqual(['draft', 'reviewed', 'pending_review']);
expect(out.states.map((s) => s.display_order).sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
it('moveState at the top edge is a no-op', () => {
expect(moveState(g, 'draft', -1)).toBe(g);
});
it('removeState drops its edges and its state-scoped grants', () => {
const out = removeState(g, 'reviewed');
expect(out.states.find((s) => s.key === 'reviewed')).toBeUndefined();
expect(out.edges.some((e) => e.from === 'reviewed' || e.to === 'reviewed')).toBe(false);
expect(out.grants.some((gr) => gr.state_key === 'reviewed')).toBe(false);
expect(out.grants).toHaveLength(g.grants.length - 1);
});
it('updateState clears control_kind when a state stops being an accrual', () => {
const out = updateState(g, 'category_verified', { semantic: 'custom' });
expect(out.states.find((s) => s.key === 'category_verified')?.control_kind).toBeUndefined();
});
it('addState appends with the next display_order and an English label', () => {
const out = addState(g, 'hr_check', 'custom');
const s = out.states.find((x) => x.key === 'hr_check');
expect(s).toMatchObject({ semantic: 'custom', display_order: 10, is_terminal: false, labels: { en: 'Hr check' } });
});
it('addEdge and moveEdge keep exits of one state in display order', () => {
const withEdge = addEdge(g, 'pending_review', 'approved');
const exits = () => withEdge.edges.filter((e) => e.from === 'pending_review');
expect(exits().map(edgeRef)).toContain('pending_review->approved');
const moved = moveEdge(withEdge, 'pending_review->approved', -1);
const order = moved.edges.filter((e) => e.from === 'pending_review')
.sort((a, b) => a.display_order - b.display_order).map((e) => e.to);
expect(order).toEqual(['reviewed', 'approved', 'rejected']);
});
it('updateGrant clears the references the new grantee kind does not use', () => {
const out = updateGrant(g, 2, { grantee_kind: 'levels_up', levels_up: 2 });
expect(out.grants[2].position_id).toBeUndefined();
expect(out.grants[2].levels_up).toBe(2);
});
it('addGrant scopes the new grant to the state with the default vacancy rule', () => {
const out = addGrant(g, 'reviewed');
expect(out.grants.at(-1)).toEqual({
state_key: 'reviewed', all_states: false, grantee_kind: 'superior', on_vacancy: 'escalate', max_escalation_levels: 2,
});
});
});
web/src/features/workflow/bands.test.ts
import { describe, expect, it } from 'vitest';
import type { WorkflowGrant } from '@/lib/api/types';
import { tilingBands } from './bands';
const grant = (min?: string): WorkflowGrant => ({
state_key: 'approved', all_states: false, grantee_kind: 'superior',
...(min === undefined ? {} : { min_amount: min, min_amount_currency: 'MYR' }),
});
describe('tilingBands', () => {
it('sorts floors, shares a band between equal floors, and leaves the top open', () => {
const r = tilingBands([
{ grant: grant('5000'), index: 0 },
{ grant: grant(), index: 1 },
{ grant: grant('5000.00'), index: 2 },
{ grant: grant('20000'), index: 3 },
]);
expect(r.bands).toEqual([
{ floor: '0', ceiling: '5000', grantIndexes: [1] },
{ floor: '5000', ceiling: '20000', grantIndexes: [0, 2] },
{ floor: '20000', ceiling: null, grantIndexes: [3] },
]);
expect(r.startsAboveZero).toBe(false);
expect(r.invalidIndexes).toEqual([]);
});
it('reports a ladder that starts above zero, and skips floors that are not money', () => {
const r = tilingBands([{ grant: grant('100'), index: 0 }, { grant: grant('1,0'), index: 1 }]);
expect(r.bands).toEqual([{ floor: '100', ceiling: null, grantIndexes: [0] }]);
expect(r.startsAboveZero).toBe(true);
expect(r.invalidIndexes).toEqual([1]);
});
it('holds floors beyond 2^53 minor units exactly', () => {
const big = '999999999999999.9999';
const r = tilingBands([{ grant: grant(big), index: 0 }, { grant: grant(), index: 1 }]);
expect(r.bands[1].floor).toBe(big);
});
});

isMoneyInput accepts at most 15 integer digits (registry: ^\d{1,15}(\.\d{0,4})?$). 999999999999999.9999 is the largest such value. It is beyond 2^53 in 1e-4 units, so a float anywhere in the comparison would lose it.

  • Step 4: Run them and watch them fail
Terminal window
pnpm -C web test -- src/features/workflow/graphModel.test.ts src/features/workflow/bands.test.ts

Expected: FAIL. Failed to resolve import "./graphModel" and "./bands".

  • Step 5: Implement graphModel.ts
web/src/features/workflow/graphModel.ts
// Pure edits over a WorkflowGraph. Nothing here validates: the server's
// validator (internal/platform/workflow/validate.go) is the only authority,
// and the editor only maps its violation refs back onto what it draws.
import type {
WorkflowEdge, WorkflowGrant, WorkflowGraph, WorkflowSemantic, WorkflowState, WorkflowViolation,
} from '@/lib/api/types';
export const edgeRef = (e: Pick<WorkflowEdge, 'from' | 'to'>): string => `${e.from}->${e.to}`;
export const grantRef = (i: number): string => `grants[${i}]`;
export const byOrder = <T extends { display_order: number }>(a: T, b: T) => a.display_order - b.display_order;
export type Selection = { type: 'state'; key: string } | { type: 'edge'; ref: string } | null;
export interface ViolationIndex {
states: Map<string, WorkflowViolation[]>;
edges: Map<string, WorkflowViolation[]>;
/** keyed by the grant's index as a string: "0", "1", … */
grants: Map<string, WorkflowViolation[]>;
general: WorkflowViolation[];
}
function push<K>(m: Map<K, WorkflowViolation[]>, k: K, v: WorkflowViolation) {
m.set(k, [...(m.get(k) ?? []), v]);
}
export function indexViolations(g: WorkflowGraph, vs: WorkflowViolation[]): ViolationIndex {
const out: ViolationIndex = { states: new Map(), edges: new Map(), grants: new Map(), general: [] };
const states = new Set(g.states.map((s) => s.key));
const edges = new Set(g.edges.map(edgeRef));
for (const v of vs) {
const ref = v.ref ?? '';
const grant = /^grants\[(\d+)\]$/.exec(ref);
if (grant) push(out.grants, grant[1], v);
else if (edges.has(ref)) push(out.edges, ref, v);
else if (states.has(ref)) push(out.states, ref, v);
else out.general.push(v);
}
return out;
}
export function stateHasGrantViolation(g: WorkflowGraph, idx: ViolationIndex, key: string): boolean {
return g.grants.some((gr, i) => (gr.all_states || gr.state_key === key) && idx.grants.has(String(i)));
}
function renumber(states: WorkflowState[]): WorkflowState[] {
return states.map((s, n) => ({ ...s, display_order: n + 1 }));
}
export function moveState(g: WorkflowGraph, key: string, dir: -1 | 1): WorkflowGraph {
const ordered = [...g.states].sort(byOrder);
const i = ordered.findIndex((s) => s.key === key);
const j = i + dir;
if (i < 0 || j < 0 || j >= ordered.length) return g;
[ordered[i], ordered[j]] = [ordered[j], ordered[i]];
return { ...g, states: renumber(ordered) };
}
/** Drag-and-drop: move `key` to position `to` (0-based) in display order. */
export function placeState(g: WorkflowGraph, key: string, to: number): WorkflowGraph {
const ordered = [...g.states].sort(byOrder);
const i = ordered.findIndex((s) => s.key === key);
if (i < 0 || i === to) return g;
const [s] = ordered.splice(i, 1);
ordered.splice(to, 0, s);
return { ...g, states: renumber(ordered) };
}
function humanize(key: string): string {
const words = key.replace(/_/g, ' ');
return words.charAt(0).toUpperCase() + words.slice(1);
}
export function addState(g: WorkflowGraph, key: string, semantic: WorkflowSemantic): WorkflowGraph {
const next = g.states.reduce((m, s) => (s.display_order > m ? s.display_order : m), 0) + 1;
const s: WorkflowState = { key, semantic, display_order: next, is_terminal: false, labels: { en: humanize(key) } };
return { ...g, states: [...g.states, s] };
}
export function removeState(g: WorkflowGraph, key: string): WorkflowGraph {
return {
...g,
states: renumber(g.states.filter((s) => s.key !== key).sort(byOrder)),
edges: g.edges.filter((e) => e.from !== key && e.to !== key),
grants: g.grants.filter((gr) => gr.state_key !== key),
};
}
export function updateState(g: WorkflowGraph, key: string, patch: Partial<WorkflowState>): WorkflowGraph {
return {
...g,
states: g.states.map((s) => {
if (s.key !== key) return s;
const next: WorkflowState = { ...s, ...patch };
if (next.semantic !== 'accrual') delete next.control_kind;
return next;
}),
};
}
export function addEdge(g: WorkflowGraph, from: string, to: string): WorkflowGraph {
const next = g.edges.reduce((m, e) => (e.display_order > m ? e.display_order : m), 0) + 1;
const target = g.states.find((s) => s.key === to);
const e: WorkflowEdge = {
from, to, gate: 'target', action_key: to, requires_reason: false, reverses_accrual: false,
display_order: next, labels: { en: target?.labels.en ?? humanize(to) },
};
return { ...g, edges: [...g.edges, e] };
}
export function updateEdge(g: WorkflowGraph, ref: string, patch: Partial<WorkflowEdge>): WorkflowGraph {
return { ...g, edges: g.edges.map((e) => (edgeRef(e) === ref ? { ...e, ...patch } : e)) };
}
export function removeEdge(g: WorkflowGraph, ref: string): WorkflowGraph {
return { ...g, edges: g.edges.filter((e) => edgeRef(e) !== ref) };
}
/** Swap an edge with its neighbour among the exits of the same state. */
export function moveEdge(g: WorkflowGraph, ref: string, dir: -1 | 1): WorkflowGraph {
const edge = g.edges.find((e) => edgeRef(e) === ref);
if (!edge) return g;
const exits = g.edges.filter((e) => e.from === edge.from).sort(byOrder);
const i = exits.findIndex((e) => edgeRef(e) === ref);
const other = exits[i + dir];
if (!other) return g;
const a = edge.display_order;
const b = other.display_order;
return {
...g,
edges: g.edges.map((e) => {
if (edgeRef(e) === ref) return { ...e, display_order: b };
if (edgeRef(e) === edgeRef(other)) return { ...e, display_order: a };
return e;
}),
};
}
export function addGrant(g: WorkflowGraph, stateKey: string): WorkflowGraph {
const gr: WorkflowGrant = {
state_key: stateKey, all_states: false, grantee_kind: 'superior', on_vacancy: 'escalate', max_escalation_levels: 2,
};
return { ...g, grants: [...g.grants, gr] };
}
export function updateGrant(g: WorkflowGraph, index: number, patch: Partial<WorkflowGrant>): WorkflowGraph {
return {
...g,
grants: g.grants.map((gr, i) => {
if (i !== index) return gr;
const next: WorkflowGrant = { ...gr, ...patch };
if (patch.grantee_kind && patch.grantee_kind !== gr.grantee_kind) {
if (next.grantee_kind !== 'member') delete next.member_id;
if (next.grantee_kind !== 'position') delete next.position_id;
if (next.grantee_kind !== 'levels_up') delete next.levels_up;
}
if (next.all_states) delete next.state_key;
if (next.min_amount === '') {
delete next.min_amount;
delete next.min_amount_currency;
}
return next;
}),
};
}
export function removeGrant(g: WorkflowGraph, index: number): WorkflowGraph {
return { ...g, grants: g.grants.filter((_, i) => i !== index) };
}
  • Step 6: Implement bands.ts
web/src/features/workflow/bands.ts
// Display only: how the floors on one state tile the amount axis, in the
// order spec 5.4's ladder reads them (the winning floor is the largest floor
// at or below the amount; a band's ceiling is the next larger floor). This
// sorts and compares strings through lib/money. It adds nothing, and rule 7
// ("bands_do_not_reach_zero") remains the server's to report.
import type { WorkflowGrant, WorkflowGraph } from '@/lib/api/types';
import { compareMoney, isMoneyInput, isZeroMoney } from '@/lib/money';
export interface Band { floor: string; ceiling: string | null; grantIndexes: number[] }
export interface Tiling { bands: Band[]; startsAboveZero: boolean; invalidIndexes: number[] }
export interface IndexedGrant { grant: WorkflowGrant; index: number }
export function grantsOnState(g: WorkflowGraph, key: string): IndexedGrant[] {
return g.grants
.map((grant, index) => ({ grant, index }))
.filter(({ grant }) => grant.all_states || grant.state_key === key);
}
export function tilingBands(entries: IndexedGrant[]): Tiling {
const invalidIndexes: number[] = [];
const valid: { floor: string; index: number }[] = [];
for (const { grant, index } of entries) {
const floor = grant.min_amount ?? '0';
if (isMoneyInput(floor)) valid.push({ floor, index });
else invalidIndexes.push(index);
}
const floors: string[] = [];
for (const v of valid) if (!floors.some((f) => compareMoney(f, v.floor) === 0)) floors.push(v.floor);
floors.sort(compareMoney);
const bands = floors.map((floor, i) => ({
floor,
ceiling: floors[i + 1] ?? null,
grantIndexes: valid.filter((v) => compareMoney(v.floor, floor) === 0).map((v) => v.index),
}));
return { bands, startsAboveZero: floors.length > 0 && !isZeroMoney(floors[0]), invalidIndexes };
}
  • Step 7: Run the pure tests and see them pass
Terminal window
pnpm -C web test -- src/features/workflow/graphModel.test.ts src/features/workflow/bands.test.ts

Expected: PASS, 12 tests.

  • Step 8: Queries, layout and the i18n bundles
web/src/features/workflow/api.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { ProblemError, unwrap } from '@/lib/api/problem';
import type { WorkflowGraph } from '@/lib/api/types';
import { useCan } from '@/app/auth';
export const wfKeys = {
kinds: ['workflow', 'kinds'] as const,
draft: (kind: string) => ['workflow', 'draft', kind] as const,
routing: (kind: string, memberId: string, amount: string) => ['workflow', 'routing', kind, { memberId, amount }] as const,
positions: ['org', 'positions'] as const,
};
export function useWorkflowKinds() {
return useQuery({
queryKey: wfKeys.kinds,
queryFn: async () => unwrap(await api.GET('/workflow/definitions')).kinds,
});
}
/** null when the kind has no draft (the API answers 404). */
export function useWorkflowDraft(kind: string) {
return useQuery({
queryKey: wfKeys.draft(kind),
queryFn: async () => {
try {
return unwrap(await api.GET('/workflow/drafts/{kind}', { params: { path: { kind } } }));
} catch (e) {
if (e instanceof ProblemError && e.status === 404) return null;
throw e;
}
},
});
}
export function useSaveDraft(kind: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: { revision: number; graph: WorkflowGraph }) =>
unwrap(await api.PUT('/workflow/drafts/{kind}', { params: { path: { kind } }, body })),
onSuccess: (draft) => {
qc.setQueryData(wfKeys.draft(kind), draft);
void qc.invalidateQueries({ queryKey: wfKeys.kinds });
},
});
}
export function useDiscardDraft(kind: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (revision: number) =>
unwrap(await api.DELETE('/workflow/drafts/{kind}', { params: { path: { kind }, query: { revision } } })),
onSuccess: () => {
qc.setQueryData(wfKeys.draft(kind), null);
void qc.invalidateQueries({ queryKey: wfKeys.kinds });
},
});
}
export function usePublishDraft(kind: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (revision: number) =>
unwrap(await api.POST('/workflow/drafts/{kind}/publish', { params: { path: { kind } }, body: { revision } })),
onSuccess: () => {
qc.setQueryData(wfKeys.draft(kind), null);
// Task 9 owns the definition's query key; invalidate every workflow-ish key.
void qc.invalidateQueries({ predicate: (q) => String(q.queryKey[0]).startsWith('workflow') });
},
onError: (e) => {
if (e instanceof ProblemError && e.status === 422) void qc.invalidateQueries({ queryKey: wfKeys.draft(kind) });
},
});
}
export function useRoutingPreview(kind: string, memberId: string, amount: string, enabled: boolean) {
return useQuery({
queryKey: wfKeys.routing(kind, memberId, amount),
enabled,
queryFn: async () =>
unwrap(await api.GET('/workflow/definitions/{kind}/routing', {
params: { path: { kind }, query: { member_id: memberId, amount } },
})),
});
}
export function useOrgPositions() {
const allowed = useCan('org.structure.read');
return useQuery({
queryKey: wfKeys.positions,
enabled: allowed,
queryFn: async () => unwrap(await api.GET('/org/positions')).positions,
});
}

Verify: the module Task 8 exports useCan from (assumed @/app/auth). The String( above is not Number( and is not near an amount. org.structure.read is not in PERMISSIONS (vocab lists only the keys constants.yaml names). The registry’s Permission type is widened with | string, so this compiles. Confirm the key in internal/modules/orgstructure/permissions.go.

web/src/features/workflow/layout.ts
import ELK from 'elkjs/lib/elk.bundled.js';
import type { WorkflowGraph } from '@/lib/api/types';
import { byOrder, edgeRef } from './graphModel';
export const NODE_W = 176;
export const NODE_H = 52;
const elk = new ELK();
export interface Point { x: number; y: number }
export interface LaidOutGraph {
width: number;
height: number;
nodes: { key: string; x: number; y: number; width: number; height: number }[];
edges: { ref: string; points: Point[] }[];
}
/** elkjs "layered", left to right, orthogonal routing. Positions only: the
* view draws everything, and nothing here can be dragged (F15). */
export async function layoutGraph(g: WorkflowGraph): Promise<LaidOutGraph> {
const keys = new Set(g.states.map((s) => s.key));
const root = await elk.layout({
id: 'root',
layoutOptions: {
'elk.algorithm': 'layered',
'elk.direction': 'RIGHT',
'elk.edgeRouting': 'ORTHOGONAL',
'elk.layered.spacing.nodeNodeBetweenLayers': '64',
'elk.spacing.nodeNode': '32',
'elk.spacing.edgeNode': '16',
},
children: [...g.states].sort(byOrder).map((s) => ({ id: s.key, width: NODE_W, height: NODE_H })),
edges: g.edges
.filter((e) => keys.has(e.from) && keys.has(e.to) && e.from !== e.to)
.map((e) => ({ id: edgeRef(e), sources: [e.from], targets: [e.to] })),
});
return {
width: root.width ?? 0,
height: root.height ?? 0,
nodes: (root.children ?? []).map((c) => ({
key: c.id, x: c.x ?? 0, y: c.y ?? 0, width: c.width ?? NODE_W, height: c.height ?? NODE_H,
})),
edges: (root.edges ?? []).map((e) => {
const s = e.sections?.[0];
return { ref: e.id, points: s ? [s.startPoint, ...(s.bendPoints ?? []), s.endPoint] : [] };
}),
};
}

web/src/lib/i18n/locales/en/workflowEditor.json:

{
"nav": "Workflows",
"list": {
"title": "Workflows",
"subtitle": "The approval flow each kind of request follows. Published versions never change; edit a draft and publish it as the next version.",
"kind": "Kind",
"latest": "Published",
"draft": "Draft",
"noDraft": "None",
"version": "v{{version}}",
"revision": "revision {{revision}}",
"open": "Open",
"empty": "No workflows yet."
},
"kinds": {
"expense_claim": "Expense claim",
"advance": "Advance",
"advance_topup": "Advance top-up",
"advance_refund": "Advance refund",
"petty_cash_spend": "Petty cash spend",
"float_topup": "Float top-up",
"float_closeout": "Float close-out"
},
"editor": {
"published": "Published v{{version}}",
"readOnly": "You are looking at the published version. It cannot be changed.",
"draftMeta": "Draft revision {{revision}} · saved {{when}} by {{who}}",
"unsaved": "Unsaved changes",
"editDraft": "Edit draft",
"save": "Save draft",
"discard": "Discard draft",
"discardTitle": "Discard this draft?",
"discardBody": "The draft and its unsaved edits are thrown away. The published version is not affected.",
"publish": "Publish",
"publishTitle": "Publish revision {{revision}}?",
"publishBody": "It becomes the next version. Requests already in flight stay on the version they started on.",
"publishBlockedViolations": "Fix the problems listed above before publishing.",
"publishBlockedDirty": "Save your changes before publishing.",
"published_toast": "Published version {{version}}",
"saved_toast": "Draft saved",
"savedWithProblems_toast": "Draft saved with {{count}} problem",
"savedWithProblems_toast_other": "Draft saved with {{count}} problems",
"conflictTitle": "This draft changed while you were editing",
"conflictBody": "Someone saved or discarded it after you opened it. Reload to see their version. Your unsaved edits will be lost.",
"reload": "Reload",
"leaveWarning": "You have unsaved changes to this workflow.",
"postingOrder": "Posting order",
"postingOrders": {
"accrual_first": "Accrue, then pay",
"payout_first": "Pay first, accrue at acquittal",
"cash_only": "Cash only (no accrual)"
},
"tabs": { "graph": "Diagram", "routing": "Routing preview" }
},
"violations": {
"title": "{{count}} problem blocks publishing",
"title_other": "{{count}} problems block publishing",
"stale": "These were found at the last save. Save again to re-check your edits.",
"rule": "Rule {{rule}}",
"rule0": "Shape",
"show": "Show",
"none": "No problems. This draft can be published."
},
"states": {
"title": "States",
"moveUp": "Move {{label}} up",
"moveDown": "Move {{label}} down",
"drag": "Drag to reorder {{label}}",
"hasProblem": "Has a problem",
"terminal": "Final",
"addTitle": "Add a state",
"key": "Key",
"keyHint": "Lower-case letters, digits and underscores. It cannot be changed later.",
"semantic": "Meaning",
"add": "Add state"
},
"semantic": {
"draft": "Draft",
"pending": "Pending",
"accrual": "Accrual (posts the expense)",
"payout": "Payout (posts the payment)",
"acknowledged": "Acknowledged",
"dispute": "Dispute",
"rejected": "Rejected (reverses)",
"cancelled": "Cancelled (reverses)",
"custom": "Custom step"
},
"controlKinds": {
"ap": "Accounts payable",
"employee_advances": "Employee advances",
"petty_cash_ap": "Petty cash payable",
"ar": "Accounts receivable",
"bank": "Bank",
"fx_gain_loss": "FX gain or loss"
},
"labels": {
"title": "Names",
"en": "English",
"ms": "Bahasa Melayu",
"zh": "中文"
},
"stateInspector": {
"title": "State: {{label}}",
"controlKind": "Control account",
"controlKindHint": "The account this accrual credits.",
"terminal": "Final state",
"terminalHint": "A final state can only be left by raising a dispute.",
"escalateAfter": "Escalate after (days)",
"escalateAfterHint": "Leave empty for never. 1 to 365.",
"exits": "Exits",
"addExit": "Add exit to…",
"noExits": "No exits.",
"remove": "Remove state",
"removeTitle": "Remove {{label}}?",
"removeBody": "Its exits, the edges into it and the grants on it are removed as well."
},
"edgeInspector": {
"title": "Transition: {{from}} → {{to}}",
"gate": "Who may take it",
"gates": {
"reporter": "The requester",
"target": "Approvers of the next state",
"source": "Approvers of this state",
"advance": "Whoever could approve the forward step"
},
"actionKey": "Action key",
"requiresReason": "Requires a reason",
"reversesAccrual": "Reverses what was posted",
"moveUp": "Move earlier",
"moveDown": "Move later",
"remove": "Remove transition"
},
"grants": {
"title": "Who approves",
"scope": "Applies to",
"thisState": "This state",
"allStates": "All states",
"granteeKind": "Approver",
"granteeKinds": {
"member": "A named member",
"superior": "The requester's superior",
"position": "Whoever holds a position",
"unit_head": "The requester's unit head",
"levels_up": "A number of levels up"
},
"member": "Member",
"position": "Position",
"positionNoAccess": "You need permission to read the org structure to pick a position.",
"levelsUp": "Levels up",
"floor": "From amount",
"floorHint": "Empty means every amount. Floors only: a band ends where the next floor starts.",
"onVacancy": "If nobody holds it",
"vacancy": { "escalate": "Escalate upward", "refuse": "Refuse" },
"maxEscalation": "Escalate at most",
"add": "Add approver",
"remove": "Remove approver",
"none": "Nobody is granted this state."
},
"bands": {
"title": "Amount bands",
"from": "{{floor}} and above",
"range": "{{floor}} to below {{ceiling}}",
"nobody": "Below {{floor}}: nobody",
"invalid": "{{count}} floor is not a valid amount",
"invalid_other": "{{count}} floors are not valid amounts",
"empty": "No approvers, so no bands."
},
"graph": {
"aria": "Workflow diagram",
"layoutFailed": "The diagram could not be drawn. The lists on the left still work.",
"edgeAria": "{{from}} to {{to}}: {{action}}"
},
"routing": {
"title": "Who would approve",
"hint": "For a request by this member, for this amount, today. Uses the published version.",
"member": "Requester",
"amount": "Amount",
"run": "Preview",
"state": "Step",
"routable": "Routable",
"notRoutable": "Nobody can approve",
"winningFloor": "Band from",
"eligible": "Eligible approvers",
"gaps": "Gaps",
"none": "No approval steps.",
"version": "Version {{version}}",
"gapReasons": {
"member_inactive": "The named member is inactive",
"position_retired": "The position is retired",
"vacant": "Nobody holds the position",
"no_primary_position": "The requester has no primary position",
"no_unit_head": "The requester's unit has no head",
"above_root": "There is nobody above the top of the chart",
"only_the_claimant": "Only the requester would approve",
"no_band_covers_amount": "No band reaches down to this amount"
}
}
}

web/src/lib/i18n/locales/ms/workflowEditor.json:

{
"nav": "Aliran kerja",
"list": {
"title": "Aliran kerja",
"subtitle": "Aliran kelulusan yang diikuti oleh setiap jenis permohonan. Versi yang diterbitkan tidak berubah; sunting draf dan terbitkannya sebagai versi seterusnya.",
"kind": "Jenis",
"latest": "Diterbitkan",
"draft": "Draf",
"noDraft": "Tiada",
"version": "v{{version}}",
"revision": "semakan {{revision}}",
"open": "Buka",
"empty": "Belum ada aliran kerja."
},
"kinds": {
"expense_claim": "Tuntutan perbelanjaan",
"advance": "Pendahuluan",
"advance_topup": "Tambahan pendahuluan",
"advance_refund": "Bayaran balik pendahuluan",
"petty_cash_spend": "Perbelanjaan wang runcit",
"float_topup": "Tambahan apungan",
"float_closeout": "Penutupan apungan"
},
"editor": {
"published": "Diterbitkan v{{version}}",
"readOnly": "Anda sedang melihat versi yang diterbitkan. Ia tidak boleh diubah.",
"draftMeta": "Semakan draf {{revision}} · disimpan {{when}} oleh {{who}}",
"unsaved": "Perubahan belum disimpan",
"editDraft": "Sunting draf",
"save": "Simpan draf",
"discard": "Buang draf",
"discardTitle": "Buang draf ini?",
"discardBody": "Draf dan suntingan yang belum disimpan akan dibuang. Versi yang diterbitkan tidak terjejas.",
"publish": "Terbitkan",
"publishTitle": "Terbitkan semakan {{revision}}?",
"publishBody": "Ia menjadi versi seterusnya. Permohonan yang sedang berjalan kekal pada versi asalnya.",
"publishBlockedViolations": "Betulkan masalah yang disenaraikan di atas sebelum menerbitkan.",
"publishBlockedDirty": "Simpan perubahan anda sebelum menerbitkan.",
"published_toast": "Versi {{version}} diterbitkan",
"saved_toast": "Draf disimpan",
"savedWithProblems_toast": "Draf disimpan dengan {{count}} masalah",
"savedWithProblems_toast_other": "Draf disimpan dengan {{count}} masalah",
"conflictTitle": "Draf ini berubah semasa anda menyunting",
"conflictBody": "Seseorang telah menyimpan atau membuangnya selepas anda membukanya. Muat semula untuk melihat versi mereka. Suntingan anda yang belum disimpan akan hilang.",
"reload": "Muat semula",
"leaveWarning": "Anda mempunyai perubahan yang belum disimpan pada aliran kerja ini.",
"postingOrder": "Susunan pengeposan",
"postingOrders": {
"accrual_first": "Akru, kemudian bayar",
"payout_first": "Bayar dahulu, akru semasa penyelesaian",
"cash_only": "Tunai sahaja (tiada akruan)"
},
"tabs": { "graph": "Rajah", "routing": "Pratonton penghalaan" }
},
"violations": {
"title": "{{count}} masalah menghalang penerbitan",
"title_other": "{{count}} masalah menghalang penerbitan",
"stale": "Masalah ini ditemui semasa simpanan terakhir. Simpan semula untuk menyemak suntingan anda.",
"rule": "Peraturan {{rule}}",
"rule0": "Bentuk",
"show": "Tunjuk",
"none": "Tiada masalah. Draf ini boleh diterbitkan."
},
"states": {
"title": "Keadaan",
"moveUp": "Alih {{label}} ke atas",
"moveDown": "Alih {{label}} ke bawah",
"drag": "Seret untuk menyusun semula {{label}}",
"hasProblem": "Ada masalah",
"terminal": "Akhir",
"addTitle": "Tambah keadaan",
"key": "Kunci",
"keyHint": "Huruf kecil, digit dan garis bawah. Ia tidak boleh diubah kemudian.",
"semantic": "Makna",
"add": "Tambah keadaan"
},
"semantic": {
"draft": "Draf",
"pending": "Menunggu",
"accrual": "Akruan (mengepos perbelanjaan)",
"payout": "Pembayaran (mengepos bayaran)",
"acknowledged": "Diakui",
"dispute": "Pertikaian",
"rejected": "Ditolak (membalikkan)",
"cancelled": "Dibatalkan (membalikkan)",
"custom": "Langkah tersuai"
},
"controlKinds": {
"ap": "Akaun belum bayar",
"employee_advances": "Pendahuluan pekerja",
"petty_cash_ap": "Wang runcit belum bayar",
"ar": "Akaun belum terima",
"bank": "Bank",
"fx_gain_loss": "Untung atau rugi tukaran asing"
},
"labels": {
"title": "Nama",
"en": "English",
"ms": "Bahasa Melayu",
"zh": "中文"
},
"stateInspector": {
"title": "Keadaan: {{label}}",
"controlKind": "Akaun kawalan",
"controlKindHint": "Akaun yang dikreditkan oleh akruan ini.",
"terminal": "Keadaan akhir",
"terminalHint": "Keadaan akhir hanya boleh ditinggalkan dengan membangkitkan pertikaian.",
"escalateAfter": "Tingkatkan selepas (hari)",
"escalateAfterHint": "Biarkan kosong untuk tidak sekali-kali. 1 hingga 365.",
"exits": "Jalan keluar",
"addExit": "Tambah jalan keluar ke…",
"noExits": "Tiada jalan keluar.",
"remove": "Buang keadaan",
"removeTitle": "Buang {{label}}?",
"removeBody": "Jalan keluarnya, peralihan ke dalamnya dan pemberian kuasa padanya juga akan dibuang."
},
"edgeInspector": {
"title": "Peralihan: {{from}} → {{to}}",
"gate": "Siapa boleh mengambilnya",
"gates": {
"reporter": "Pemohon",
"target": "Pelulus keadaan seterusnya",
"source": "Pelulus keadaan ini",
"advance": "Sesiapa yang boleh meluluskan langkah hadapan"
},
"actionKey": "Kunci tindakan",
"requiresReason": "Memerlukan sebab",
"reversesAccrual": "Membalikkan apa yang telah dipos",
"moveUp": "Alih lebih awal",
"moveDown": "Alih lebih lewat",
"remove": "Buang peralihan"
},
"grants": {
"title": "Siapa yang meluluskan",
"scope": "Terpakai kepada",
"thisState": "Keadaan ini",
"allStates": "Semua keadaan",
"granteeKind": "Pelulus",
"granteeKinds": {
"member": "Ahli yang dinamakan",
"superior": "Penyelia pemohon",
"position": "Pemegang sesuatu jawatan",
"unit_head": "Ketua unit pemohon",
"levels_up": "Beberapa peringkat ke atas"
},
"member": "Ahli",
"position": "Jawatan",
"positionNoAccess": "Anda memerlukan kebenaran membaca struktur organisasi untuk memilih jawatan.",
"levelsUp": "Peringkat ke atas",
"floor": "Dari amaun",
"floorHint": "Kosong bermaksud semua amaun. Had bawah sahaja: sesuatu jalur berakhir di mana had bawah seterusnya bermula.",
"onVacancy": "Jika tiada pemegang",
"vacancy": { "escalate": "Tingkatkan ke atas", "refuse": "Tolak" },
"maxEscalation": "Tingkatkan paling banyak",
"add": "Tambah pelulus",
"remove": "Buang pelulus",
"none": "Tiada sesiapa diberi kuasa untuk keadaan ini."
},
"bands": {
"title": "Jalur amaun",
"from": "{{floor}} dan ke atas",
"range": "{{floor}} hingga di bawah {{ceiling}}",
"nobody": "Di bawah {{floor}}: tiada sesiapa",
"invalid": "{{count}} had bawah bukan amaun yang sah",
"invalid_other": "{{count}} had bawah bukan amaun yang sah",
"empty": "Tiada pelulus, jadi tiada jalur."
},
"graph": {
"aria": "Rajah aliran kerja",
"layoutFailed": "Rajah tidak dapat dilukis. Senarai di sebelah kiri masih berfungsi.",
"edgeAria": "{{from}} ke {{to}}: {{action}}"
},
"routing": {
"title": "Siapa yang akan meluluskan",
"hint": "Untuk permohonan oleh ahli ini, bagi amaun ini, hari ini. Menggunakan versi yang diterbitkan.",
"member": "Pemohon",
"amount": "Amaun",
"run": "Pratonton",
"state": "Langkah",
"routable": "Boleh dihalakan",
"notRoutable": "Tiada sesiapa boleh meluluskan",
"winningFloor": "Jalur dari",
"eligible": "Pelulus yang layak",
"gaps": "Jurang",
"none": "Tiada langkah kelulusan.",
"version": "Versi {{version}}",
"gapReasons": {
"member_inactive": "Ahli yang dinamakan tidak aktif",
"position_retired": "Jawatan telah ditamatkan",
"vacant": "Tiada sesiapa memegang jawatan itu",
"no_primary_position": "Pemohon tiada jawatan utama",
"no_unit_head": "Unit pemohon tiada ketua",
"above_root": "Tiada sesiapa di atas puncak carta",
"only_the_claimant": "Hanya pemohon yang akan meluluskan",
"no_band_covers_amount": "Tiada jalur yang meliputi amaun ini"
}
}
}

web/src/lib/i18n/locales/zh/workflowEditor.json:

{
"nav": "工作流",
"list": {
"title": "工作流",
"subtitle": "每类申请遵循的审批流程。已发布的版本不会改变;编辑草稿并将其发布为下一个版本。",
"kind": "类型",
"latest": "已发布",
"draft": "草稿",
"noDraft": "无",
"version": "v{{version}}",
"revision": "第 {{revision}} 次修订",
"open": "打开",
"empty": "暂无工作流。"
},
"kinds": {
"expense_claim": "费用报销",
"advance": "预支款",
"advance_topup": "预支款补付",
"advance_refund": "预支款退还",
"petty_cash_spend": "备用金支出",
"float_topup": "备用金补充",
"float_closeout": "备用金结清"
},
"editor": {
"published": "已发布 v{{version}}",
"readOnly": "您正在查看已发布的版本,它不能被修改。",
"draftMeta": "草稿第 {{revision}} 次修订 · {{who}} 于 {{when}} 保存",
"unsaved": "有未保存的更改",
"editDraft": "编辑草稿",
"save": "保存草稿",
"discard": "放弃草稿",
"discardTitle": "放弃此草稿?",
"discardBody": "草稿及其未保存的修改将被丢弃,已发布的版本不受影响。",
"publish": "发布",
"publishTitle": "发布第 {{revision}} 次修订?",
"publishBody": "它将成为下一个版本。进行中的申请仍按其开始时的版本处理。",
"publishBlockedViolations": "请先修正上方列出的问题再发布。",
"publishBlockedDirty": "请先保存更改再发布。",
"published_toast": "已发布版本 {{version}}",
"saved_toast": "草稿已保存",
"savedWithProblems_toast": "草稿已保存,但有 {{count}} 个问题",
"savedWithProblems_toast_other": "草稿已保存,但有 {{count}} 个问题",
"conflictTitle": "您编辑期间此草稿已被更改",
"conflictBody": "有人在您打开后保存或放弃了它。重新加载以查看其版本,您未保存的修改将会丢失。",
"reload": "重新加载",
"leaveWarning": "此工作流有未保存的更改。",
"postingOrder": "记账顺序",
"postingOrders": {
"accrual_first": "先计提,后付款",
"payout_first": "先付款,核销时计提",
"cash_only": "仅现金(不计提)"
},
"tabs": { "graph": "流程图", "routing": "审批路径预览" }
},
"violations": {
"title": "{{count}} 个问题阻止发布",
"title_other": "{{count}} 个问题阻止发布",
"stale": "这些问题是上次保存时发现的。请再次保存以检查您的修改。",
"rule": "规则 {{rule}}",
"rule0": "结构",
"show": "查看",
"none": "没有问题,此草稿可以发布。"
},
"states": {
"title": "状态",
"moveUp": "将 {{label}} 上移",
"moveDown": "将 {{label}} 下移",
"drag": "拖动以调整 {{label}} 的顺序",
"hasProblem": "存在问题",
"terminal": "终态",
"addTitle": "添加状态",
"key": "键",
"keyHint": "小写字母、数字和下划线,之后不能修改。",
"semantic": "含义",
"add": "添加状态"
},
"semantic": {
"draft": "草稿",
"pending": "待处理",
"accrual": "计提(记录费用)",
"payout": "付款(记录付款)",
"acknowledged": "已确认",
"dispute": "争议",
"rejected": "已拒绝(冲销)",
"cancelled": "已取消(冲销)",
"custom": "自定义步骤"
},
"controlKinds": {
"ap": "应付账款",
"employee_advances": "员工预支款",
"petty_cash_ap": "应付备用金",
"ar": "应收账款",
"bank": "银行",
"fx_gain_loss": "汇兑损益"
},
"labels": {
"title": "名称",
"en": "English",
"ms": "Bahasa Melayu",
"zh": "中文"
},
"stateInspector": {
"title": "状态:{{label}}",
"controlKind": "控制科目",
"controlKindHint": "此计提所贷记的科目。",
"terminal": "终态",
"terminalHint": "终态只能通过提出争议离开。",
"escalateAfter": "超过天数后升级",
"escalateAfterHint": "留空表示从不升级。1 至 365。",
"exits": "出口",
"addExit": "添加通往…的出口",
"noExits": "没有出口。",
"remove": "删除状态",
"removeTitle": "删除 {{label}}?",
"removeBody": "它的出口、进入它的转换以及其上的授权也会被删除。"
},
"edgeInspector": {
"title": "转换:{{from}} → {{to}}",
"gate": "谁可以执行",
"gates": {
"reporter": "申请人",
"target": "下一状态的审批人",
"source": "本状态的审批人",
"advance": "可审批前进步骤的人"
},
"actionKey": "操作键",
"requiresReason": "需要填写理由",
"reversesAccrual": "冲销已记账的内容",
"moveUp": "提前",
"moveDown": "推后",
"remove": "删除转换"
},
"grants": {
"title": "由谁审批",
"scope": "适用于",
"thisState": "本状态",
"allStates": "所有状态",
"granteeKind": "审批人",
"granteeKinds": {
"member": "指定成员",
"superior": "申请人的上级",
"position": "某职位的任职者",
"unit_head": "申请人所在部门负责人",
"levels_up": "向上若干级"
},
"member": "成员",
"position": "职位",
"positionNoAccess": "您需要查看组织架构的权限才能选择职位。",
"levelsUp": "向上级数",
"floor": "起始金额",
"floorHint": "留空表示所有金额。仅设下限:一个区间在下一个下限处结束。",
"onVacancy": "职位空缺时",
"vacancy": { "escalate": "向上升级", "refuse": "拒绝" },
"maxEscalation": "最多升级",
"add": "添加审批人",
"remove": "删除审批人",
"none": "此状态没有任何授权。"
},
"bands": {
"title": "金额区间",
"from": "{{floor}} 及以上",
"range": "{{floor}} 至低于 {{ceiling}}",
"nobody": "低于 {{floor}}:无人审批",
"invalid": "{{count}} 个下限不是有效金额",
"invalid_other": "{{count}} 个下限不是有效金额",
"empty": "没有审批人,因此没有区间。"
},
"graph": {
"aria": "工作流图",
"layoutFailed": "无法绘制流程图。左侧列表仍可使用。",
"edgeAria": "{{from}} 至 {{to}}:{{action}}"
},
"routing": {
"title": "由谁审批",
"hint": "按此成员今天提交此金额的申请计算,使用已发布的版本。",
"member": "申请人",
"amount": "金额",
"run": "预览",
"state": "步骤",
"routable": "可审批",
"notRoutable": "无人可审批",
"winningFloor": "所在区间起点",
"eligible": "可审批人",
"gaps": "缺口",
"none": "没有审批步骤。",
"version": "版本 {{version}}",
"gapReasons": {
"member_inactive": "指定的成员已停用",
"position_retired": "该职位已撤销",
"vacant": "该职位无人任职",
"no_primary_position": "申请人没有主要职位",
"no_unit_head": "申请人所在部门没有负责人",
"above_root": "组织架构顶端之上没有人",
"only_the_claimant": "只有申请人本人可以审批",
"no_band_covers_amount": "没有区间覆盖此金额"
}
}
}
  • Step 9: Write the failing editor test — WorkflowEditorPage.test.tsx
import { describe, expect, it } from 'vitest';
import { http, HttpResponse } from 'msw';
import { screen, waitFor, within } from '@testing-library/react';
import { server } from '@/test/msw/server';
import { renderWithProviders } from '@/test/render';
import { WorkflowEditorPage } from './WorkflowEditorPage';
import { draftClean, draftWithViolations, publishedV1 } from './__fixtures__/expenseClaim';
import type { WorkflowDraft } from '@/lib/api/types';
const ALL = ['workflow.definition.read', 'workflow.definition.manage', 'workflow.definition.publish'];
function serve(draft: WorkflowDraft | null, onPut?: (body: unknown) => WorkflowDraft | Response) {
server.use(
http.get('/v1/workflow/definitions/expense_claim', () => HttpResponse.json(publishedV1)),
http.get('/v1/workflow/drafts/expense_claim', () =>
draft ? HttpResponse.json(draft) : HttpResponse.json({ type: 'about:blank', title: 'Not found', status: 404 }, { status: 404 })),
http.put('/v1/workflow/drafts/expense_claim', async ({ request }) => {
const body = await request.json();
const r = onPut?.(body) ?? draftClean;
return r instanceof Response ? r : HttpResponse.json(r);
}),
http.get('/v1/org/members', () => HttpResponse.json({ members: [] })),
http.get('/v1/org/positions', () => HttpResponse.json({ positions: [] })),
);
}
describe('WorkflowEditorPage', () => {
it('highlights exactly the edges and grants the violations name, and lists the rest', async () => {
serve(draftWithViolations);
const { container } = renderWithProviders(<WorkflowEditorPage kind="expense_claim" />, { permissions: ALL });
// Presence: the graph has drawn both a flagged and an unflagged edge.
const flagged = await waitFor(() => {
const el = container.querySelector('[data-ref="pending_review->reviewed"]');
expect(el).not.toBeNull();
return el!;
});
const clean = container.querySelector('[data-ref="draft->pending_review"]');
expect(clean).not.toBeNull();
// Highlight on the flagged one, absence on the clean one.
expect(flagged).toHaveAttribute('data-invalid', 'true');
expect(container.querySelector('[data-ref="pending_review->rejected"]')).toHaveAttribute('data-invalid', 'true');
expect(clean).not.toHaveAttribute('data-invalid');
// The banner counts all four and shows the ref-less one by its detail.
const banner = screen.getByRole('region', { name: /4 problems block publishing/i });
expect(within(banner).getByText('at least one state must be terminal')).toBeInTheDocument();
// The grant violation marks the state that owns grants[0] (approved) in the state list.
const approvedRow = screen.getByRole('listitem', { name: /Approved/ });
expect(within(approvedRow).getByText(/has a problem/i)).toBeInTheDocument();
const draftRow = screen.getByRole('listitem', { name: /^Draft/ });
expect(within(draftRow).queryByText(/has a problem/i)).toBeNull();
// Publish is disabled while violations stand.
expect(screen.getByRole('button', { name: 'Publish' })).toBeDisabled();
});
it('saves with the draft revision, clears the highlights, and enables Publish', async () => {
const puts: unknown[] = [];
serve(draftWithViolations, (b) => { puts.push(b); return draftClean; });
const { container, user } = renderWithProviders(<WorkflowEditorPage kind="expense_claim" />, { permissions: ALL });
await screen.findByRole('listitem', { name: /Reviewed/ });
await user.click(screen.getByRole('button', { name: 'Move Reviewed up' }));
expect(screen.getByText('Unsaved changes')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Save draft' }));
await waitFor(() => expect(puts).toHaveLength(1));
expect(puts[0]).toMatchObject({ revision: 3, graph: { kind: 'expense_claim' } });
await waitFor(() =>
expect(container.querySelector('[data-ref="pending_review->reviewed"]')).not.toHaveAttribute('data-invalid'));
expect(screen.getByRole('button', { name: 'Publish' })).toBeEnabled();
});
it('turns a 409 into a reload prompt instead of overwriting', async () => {
serve(draftWithViolations, () =>
HttpResponse.json({ type: 'https://errors.example/conflict', title: 'Conflict', status: 409, detail: 'revision is 5' }, { status: 409 }));
const { user } = renderWithProviders(<WorkflowEditorPage kind="expense_claim" />, { permissions: ALL });
await screen.findByRole('listitem', { name: /Reviewed/ });
await user.click(screen.getByRole('button', { name: 'Move Reviewed up' }));
await user.click(screen.getByRole('button', { name: 'Save draft' }));
expect(await screen.findByRole('alertdialog', { name: 'This draft changed while you were editing' })).toBeInTheDocument();
});
it('shows the published version read-only, and creates a draft at revision 0 on Edit draft', async () => {
const puts: { revision: number }[] = [];
serve(null, (b) => { puts.push(b as { revision: number }); return { ...draftClean, revision: 1 }; });
const { user } = renderWithProviders(<WorkflowEditorPage kind="expense_claim" />, { permissions: ALL });
expect(await screen.findByText(/cannot be changed/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Move .* up/ })).toBeNull();
await user.click(screen.getByRole('button', { name: 'Edit draft' }));
await waitFor(() => expect(puts[0]?.revision).toBe(0));
});
it('hides editing from a reader without manage', async () => {
serve(draftWithViolations);
renderWithProviders(<WorkflowEditorPage kind="expense_claim" />, { permissions: ['workflow.definition.read'] });
// presence: the page and its state list rendered
expect(await screen.findByRole('listitem', { name: /Reviewed/ })).toBeInTheDocument();
// absence: no edit controls
expect(screen.queryByRole('button', { name: 'Save draft' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Move Reviewed up' })).toBeNull();
});
});

Verify: that Task 7’s ConfirmDialog renders Radix AlertDialog (role alertdialog), and that renderWithProviders returns user (userEvent). If ConfirmDialog is a plain Dialog, query dialog instead.

  • Step 10: Run it and watch it fail
Terminal window
pnpm -C web test -- src/features/workflow/WorkflowEditorPage.test.tsx

Expected: FAIL, Failed to resolve import "./WorkflowEditorPage".

  • Step 11: Implement the components
web/src/features/workflow/LabelsEditor.tsx
import { useTranslation } from 'react-i18next';
import { Field, Input } from '@/components/ui';
import { SUPPORTED_LOCALES } from '@/lib/i18n';
import type { Labels } from '@/lib/api/types';
export function LabelsEditor({ id, value, onChange, disabled }: {
id: string; value: Labels; onChange(v: Labels): void; disabled?: boolean;
}) {
const { t } = useTranslation('workflowEditor');
return (
<fieldset className="grid gap-2">
<legend className="text-sm font-medium">{t('labels.title')}</legend>
{SUPPORTED_LOCALES.map((loc) => (
<Field key={loc} label={t(`labels.${loc}`)} htmlFor={`${id}-${loc}`}>
<Input
id={`${id}-${loc}`}
value={value[loc] ?? ''}
maxLength={200}
disabled={disabled}
required={loc === 'en'}
onChange={(e) => {
const next = { ...value };
if (e.target.value === '' && loc !== 'en') delete next[loc];
else next[loc] = e.target.value;
onChange(next);
}}
/>
</Field>
))}
</fieldset>
);
}

Verify: Task 7’s barrel (@/components/ui re-exporting every primitive). If each primitive is imported from its own file (@/components/ui/Field), use those paths throughout Part D.

web/src/features/workflow/StateList.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, ArrowDown, ArrowUp, GripVertical } from 'lucide-react';
import { Button, IconButton, Input, Field, Select, Tag } from '@/components/ui';
import { semanticTone } from '@/components/workflow';
import { cn } from '@/lib/cn';
import { pickLabel, useLocale } from '@/lib/i18n';
import type { WorkflowGraph, WorkflowSemantic } from '@/lib/api/types';
import { byOrder, stateHasGrantViolation, type Selection, type ViolationIndex } from './graphModel';
const SEMANTICS: WorkflowSemantic[] = ['draft', 'pending', 'accrual', 'payout', 'acknowledged', 'dispute', 'rejected', 'cancelled', 'custom'];
const KEY = /^[a-z][a-z0-9_]{0,62}$/;
export function StateList(p: {
graph: WorkflowGraph; violations: ViolationIndex; selection: Selection; editable: boolean;
onSelect(s: Selection): void; onMove(key: string, dir: -1 | 1): void; onPlace(key: string, to: number): void;
onAdd(key: string, semantic: WorkflowSemantic): void;
}) {
const { t } = useTranslation('workflowEditor');
const [locale] = useLocale();
const [dragKey, setDragKey] = useState<string | null>(null);
const [newKey, setNewKey] = useState('');
const [newSemantic, setNewSemantic] = useState<WorkflowSemantic>('pending');
const states = [...p.graph.states].sort(byOrder);
const taken = new Set(states.map((s) => s.key));
const keyOk = KEY.test(newKey) && !taken.has(newKey);
return (
<section aria-labelledby="wf-states" className="grid gap-2">
<h2 id="wf-states" className="text-sm font-medium text-muted">{t('states.title')}</h2>
<ol className="grid gap-1">
{states.map((s, i) => {
const label = pickLabel(s.labels, locale);
const bad = p.violations.states.has(s.key) || stateHasGrantViolation(p.graph, p.violations, s.key);
const selected = p.selection?.type === 'state' && p.selection.key === s.key;
return (
<li
key={s.key}
aria-label={label}
draggable={p.editable}
onDragStart={() => setDragKey(s.key)}
onDragOver={(e) => p.editable && e.preventDefault()}
onDrop={() => { if (dragKey) p.onPlace(dragKey, i); setDragKey(null); }}
className={cn(
'flex items-center gap-2 rounded-md border px-2 py-1.5',
selected ? 'border-primary bg-primary-soft' : 'border-outline',
bad && 'border-danger',
)}
>
{p.editable && <GripVertical aria-label={t('states.drag', { label })} className="size-4 cursor-grab text-muted" />}
<button type="button" className="flex min-w-0 flex-1 items-center gap-2 text-left" onClick={() => p.onSelect({ type: 'state', key: s.key })}>
<Tag tone={semanticTone(s.semantic)}>{label}</Tag>
{s.is_terminal && <span className="text-xs text-muted">{t('states.terminal')}</span>}
</button>
{bad && (
<span className="flex items-center gap-1 text-xs text-danger">
<AlertTriangle aria-hidden className="size-4" />
{t('states.hasProblem')}
</span>
)}
{p.editable && (
<>
<IconButton size="sm" variant="ghost" aria-label={t('states.moveUp', { label })} disabled={i === 0} onClick={() => p.onMove(s.key, -1)}><ArrowUp /></IconButton>
<IconButton size="sm" variant="ghost" aria-label={t('states.moveDown', { label })} disabled={i === states.length - 1} onClick={() => p.onMove(s.key, 1)}><ArrowDown /></IconButton>
</>
)}
</li>
);
})}
</ol>
{p.editable && (
<form
className="grid gap-2 rounded-md border border-dashed border-outline p-2"
aria-label={t('states.addTitle')}
onSubmit={(e) => { e.preventDefault(); if (keyOk) { p.onAdd(newKey, newSemantic); setNewKey(''); } }}
>
<Field label={t('states.key')} hint={t('states.keyHint')} htmlFor="wf-new-key">
<Input id="wf-new-key" value={newKey} onChange={(e) => setNewKey(e.target.value)} invalid={newKey !== '' && !keyOk} />
</Field>
<Field label={t('states.semantic')} htmlFor="wf-new-semantic">
<Select id="wf-new-semantic" value={newSemantic} onValueChange={(v) => setNewSemantic(v as WorkflowSemantic)}
options={SEMANTICS.map((s) => ({ value: s, label: t(`semantic.${s}`) }))} />
</Field>
<Button type="submit" size="sm" variant="outline" disabled={!keyOk}>{t('states.add')}</Button>
</form>
)}
</section>
);
}

Verify: that Task 9 exports semanticTone from @/components/workflow (the registry names the function, not the barrel), and that Task 7’s Select takes value / onValueChange / options and Input takes invalid. Adjust the prop names to Task 7’s.

web/src/features/workflow/BandsPreview.tsx
import { useTranslation } from 'react-i18next';
import { formatMoney } from '@/lib/money';
import { useLocale } from '@/lib/i18n';
import type { WorkflowGraph } from '@/lib/api/types';
import { grantsOnState, tilingBands } from './bands';
export function BandsPreview({ graph, stateKey, currency, describe }: {
graph: WorkflowGraph; stateKey: string; currency: string; describe(index: number): string;
}) {
const { t } = useTranslation('workflowEditor');
const [locale] = useLocale();
const tiling = tilingBands(grantsOnState(graph, stateKey));
const fmt = (a: string) => formatMoney(a, currency, locale);
if (tiling.bands.length === 0 && tiling.invalidIndexes.length === 0) {
return <p className="text-sm text-muted">{t('bands.empty')}</p>;
}
return (
<section aria-labelledby="wf-bands" className="grid gap-1">
<h4 id="wf-bands" className="text-sm font-medium">{t('bands.title')}</h4>
<ol className="grid gap-1" aria-live="polite">
{tiling.startsAboveZero && (
<li className="rounded-md border border-danger px-2 py-1 text-sm text-danger">
{t('bands.nobody', { floor: fmt(tiling.bands[0].floor) })}
</li>
)}
{tiling.bands.map((b) => (
<li key={b.floor} className="rounded-md border border-outline px-2 py-1 text-sm">
<span className="font-medium">
{b.ceiling === null ? t('bands.from', { floor: fmt(b.floor) }) : t('bands.range', { floor: fmt(b.floor), ceiling: fmt(b.ceiling) })}
</span>
<span className="text-muted"> · {b.grantIndexes.map(describe).join(', ')}</span>
</li>
))}
</ol>
{tiling.invalidIndexes.length > 0 && (
<p className="text-sm text-danger">{t('bands.invalid', { count: tiling.invalidIndexes.length })}</p>
)}
</section>
);
}
web/src/features/workflow/GrantsTable.tsx
import { useTranslation } from 'react-i18next';
import { Trash2 } from 'lucide-react';
import { Button, Combobox, Field, IconButton, Select } from '@/components/ui';
import { MoneyInput } from '@/components/money';
import { useMemberDirectory, useMemberNames } from '@/components/workflow';
import { cn } from '@/lib/cn';
import type { WorkflowGrant, WorkflowGraph, WorkflowGranteeKind } from '@/lib/api/types';
import { grantsOnState } from './bands';
import { BandsPreview } from './BandsPreview';
import { useOrgPositions } from './api';
import type { ViolationIndex } from './graphModel';
const KINDS: WorkflowGranteeKind[] = ['superior', 'unit_head', 'position', 'member', 'levels_up'];
const ONE_TO = (n: number) => Array.from({ length: n }, (_, i) => String(i + 1));
export function GrantsTable(p: {
graph: WorkflowGraph; stateKey: string; currency: string; violations: ViolationIndex; editable: boolean;
onAdd(): void; onChange(index: number, patch: Partial<WorkflowGrant>): void; onRemove(index: number): void;
}) {
const { t } = useTranslation('workflowEditor');
const directory = useMemberDirectory();
const nameOf = useMemberNames();
const positions = useOrgPositions();
const rows = grantsOnState(p.graph, p.stateKey);
const describe = (index: number) => {
const g = p.graph.grants[index];
if (g.grantee_kind === 'member') return nameOf(g.member_id);
if (g.grantee_kind === 'position') return positions.data?.find((x) => x.id === g.position_id)?.title ?? t('grants.granteeKinds.position');
if (g.grantee_kind === 'levels_up') return `${t('grants.granteeKinds.levels_up')} (${g.levels_up ?? '?'})`;
return t(`grants.granteeKinds.${g.grantee_kind}`);
};
return (
<section aria-labelledby="wf-grants" className="grid gap-3">
<h3 id="wf-grants" className="text-sm font-medium">{t('grants.title')}</h3>
{rows.length === 0 && <p className="text-sm text-muted">{t('grants.none')}</p>}
{rows.map(({ grant: g, index }) => {
const bad = p.violations.grants.get(String(index));
const id = `wf-grant-${index}`;
return (
<fieldset key={index} data-grant-index={index} data-invalid={bad ? 'true' : undefined}
className={cn('grid gap-2 rounded-md border p-2 sm:grid-cols-2', bad ? 'border-danger' : 'border-outline')}>
<legend className="sr-only">{describe(index)}</legend>
<Field label={t('grants.scope')} htmlFor={`${id}-scope`}>
<Select id={`${id}-scope`} disabled={!p.editable} value={g.all_states ? 'all' : 'this'}
onValueChange={(v) => p.onChange(index, v === 'all' ? { all_states: true } : { all_states: false, state_key: p.stateKey })}
options={[{ value: 'this', label: t('grants.thisState') }, { value: 'all', label: t('grants.allStates') }]} />
</Field>
<Field label={t('grants.granteeKind')} htmlFor={`${id}-kind`}>
<Select id={`${id}-kind`} disabled={!p.editable} value={g.grantee_kind}
onValueChange={(v) => p.onChange(index, { grantee_kind: v as WorkflowGranteeKind })}
options={KINDS.map((k) => ({ value: k, label: t(`grants.granteeKinds.${k}`) }))} />
</Field>
{g.grantee_kind === 'member' && (
<Field label={t('grants.member')} htmlFor={`${id}-member`}>
<Combobox id={`${id}-member`} disabled={!p.editable} value={g.member_id ?? ''}
onValueChange={(v) => p.onChange(index, { member_id: v })}
options={(directory.data ?? []).filter((m) => m.active).map((m) => ({ value: m.member_id, label: m.name ?? m.member_id }))} />
</Field>
)}
{g.grantee_kind === 'position' && (
<Field label={t('grants.position')} htmlFor={`${id}-position`} hint={positions.fetchStatus === 'idle' && !positions.data ? t('grants.positionNoAccess') : undefined}>
<Combobox id={`${id}-position`} disabled={!p.editable || !positions.data} value={g.position_id ?? ''}
onValueChange={(v) => p.onChange(index, { position_id: v })}
options={(positions.data ?? []).filter((x) => x.active).map((x) => ({ value: x.id, label: `${x.code} · ${x.title}` }))} />
</Field>
)}
{g.grantee_kind === 'levels_up' && (
<Field label={t('grants.levelsUp')} htmlFor={`${id}-levels`}>
<Select id={`${id}-levels`} disabled={!p.editable} value={String(g.levels_up ?? '')}
onValueChange={(v) => p.onChange(index, { levels_up: parseInt(v, 10) })}
options={ONE_TO(9).map((n) => ({ value: n, label: n }))} />
</Field>
)}
<Field label={t('grants.floor')} hint={t('grants.floorHint')} htmlFor={`${id}-floor`}>
<MoneyInput id={`${id}-floor`} currency={p.currency} disabled={!p.editable} value={g.min_amount ?? ''}
onChange={(v) => p.onChange(index, v === '' ? { min_amount: '' } : { min_amount: v, min_amount_currency: p.currency })} />
</Field>
<Field label={t('grants.onVacancy')} htmlFor={`${id}-vacancy`}>
<Select id={`${id}-vacancy`} disabled={!p.editable} value={g.on_vacancy ?? 'escalate'}
onValueChange={(v) => p.onChange(index, { on_vacancy: v as 'escalate' | 'refuse' })}
options={[{ value: 'escalate', label: t('grants.vacancy.escalate') }, { value: 'refuse', label: t('grants.vacancy.refuse') }]} />
</Field>
<Field label={t('grants.maxEscalation')} htmlFor={`${id}-max`}>
<Select id={`${id}-max`} disabled={!p.editable || g.on_vacancy === 'refuse'} value={String(g.max_escalation_levels ?? 2)}
onValueChange={(v) => p.onChange(index, { max_escalation_levels: parseInt(v, 10) })}
options={ONE_TO(10).map((n) => ({ value: n, label: n }))} />
</Field>
{bad && (
<ul className="text-sm text-danger sm:col-span-2">
{bad.map((v) => <li key={v.code}>{v.detail}</li>)}
</ul>
)}
{p.editable && (
<div className="sm:col-span-2">
<IconButton size="sm" variant="ghost" aria-label={t('grants.remove')} onClick={() => p.onRemove(index)}><Trash2 /></IconButton>
</div>
)}
</fieldset>
);
})}
{p.editable && <Button size="sm" variant="outline" onClick={p.onAdd}>{t('grants.add')}</Button>}
<BandsPreview graph={p.graph} stateKey={p.stateKey} currency={p.currency} describe={describe} />
</section>
);
}

Verify: that Task 9 exports useMemberDirectory and useMemberNames from @/components/workflow (the registry puts useMemberNames in features/members/useMemberNames.ts, so import it from there if that is where it lives). Confirm that Combobox accepts static options as well as async ones.

web/src/features/workflow/StateInspector.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ArrowDown, ArrowUp } from 'lucide-react';
import { Button, Checkbox, ConfirmDialog, Field, IconButton, Input, Select } from '@/components/ui';
import { pickLabel, useLocale } from '@/lib/i18n';
import type { WorkflowGrant, WorkflowGraph, WorkflowSemantic, WorkflowState } from '@/lib/api/types';
import { byOrder, edgeRef, type Selection, type ViolationIndex } from './graphModel';
import { LabelsEditor } from './LabelsEditor';
import { GrantsTable } from './GrantsTable';
const SEMANTICS: WorkflowSemantic[] = ['draft', 'pending', 'accrual', 'payout', 'acknowledged', 'dispute', 'rejected', 'cancelled', 'custom'];
const CONTROL_KINDS = ['ap', 'employee_advances', 'petty_cash_ap', 'ar', 'bank', 'fx_gain_loss'] as const;
export function StateInspector(p: {
graph: WorkflowGraph; state: WorkflowState; currency: string; editable: boolean; violations: ViolationIndex;
onChange(patch: Partial<WorkflowState>): void; onRemove(): void; onSelect(s: Selection): void;
onAddExit(to: string): void; onMoveEdge(ref: string, dir: -1 | 1): void;
onAddGrant(): void; onChangeGrant(i: number, patch: Partial<WorkflowGrant>): void; onRemoveGrant(i: number): void;
}) {
const { t } = useTranslation('workflowEditor');
const [locale] = useLocale();
const [confirm, setConfirm] = useState(false);
const s = p.state;
const label = pickLabel(s.labels, locale);
const exits = p.graph.edges.filter((e) => e.from === s.key).sort(byOrder);
const labelOf = (key: string) => pickLabel(p.graph.states.find((x) => x.key === key)?.labels, locale) || key;
const targets = p.graph.states.filter((x) => x.key !== s.key && !exits.some((e) => e.to === x.key));
const own = p.violations.states.get(s.key) ?? [];
return (
<div className="grid gap-4">
<h2 className="text-base font-medium">{t('stateInspector.title', { label })}</h2>
{own.length > 0 && <ul className="text-sm text-danger">{own.map((v) => <li key={v.code}>{v.detail}</li>)}</ul>}
<LabelsEditor id={`wf-state-${s.key}`} value={s.labels} disabled={!p.editable} onChange={(labels) => p.onChange({ labels })} />
<Field label={t('states.semantic')} htmlFor="wf-state-semantic">
<Select id="wf-state-semantic" disabled={!p.editable} value={s.semantic}
onValueChange={(v) => p.onChange({ semantic: v as WorkflowSemantic })}
options={SEMANTICS.map((x) => ({ value: x, label: t(`semantic.${x}`) }))} />
</Field>
{s.semantic === 'accrual' && (
<Field label={t('stateInspector.controlKind')} hint={t('stateInspector.controlKindHint')} htmlFor="wf-state-control">
<Select id="wf-state-control" disabled={!p.editable} value={s.control_kind ?? ''}
onValueChange={(v) => p.onChange({ control_kind: v as WorkflowState['control_kind'] })}
options={CONTROL_KINDS.map((k) => ({ value: k, label: t(`controlKinds.${k}`) }))} />
</Field>
)}
<Checkbox id="wf-state-terminal" disabled={!p.editable} checked={s.is_terminal}
onCheckedChange={(v) => p.onChange({ is_terminal: v === true })}
label={t('stateInspector.terminal')} description={t('stateInspector.terminalHint')} />
<Field label={t('stateInspector.escalateAfter')} hint={t('stateInspector.escalateAfterHint')} htmlFor="wf-state-escalate">
<Input id="wf-state-escalate" inputMode="numeric" pattern="[0-9]*" disabled={!p.editable}
value={s.escalate_after_days === undefined ? '' : String(s.escalate_after_days)}
onChange={(e) => {
const v = e.target.value.trim();
if (v === '') p.onChange({ escalate_after_days: undefined });
else if (/^\d{1,3}$/.test(v)) p.onChange({ escalate_after_days: parseInt(v, 10) });
}} />
</Field>
<section aria-labelledby="wf-exits" className="grid gap-2">
<h3 id="wf-exits" className="text-sm font-medium">{t('stateInspector.exits')}</h3>
{exits.length === 0 && <p className="text-sm text-muted">{t('stateInspector.noExits')}</p>}
<ol className="grid gap-1">
{exits.map((e, i) => {
const ref = edgeRef(e);
return (
<li key={ref} className="flex items-center gap-2">
<button type="button" className="flex-1 text-left text-sm underline-offset-2 hover:underline"
onClick={() => p.onSelect({ type: 'edge', ref })}>
{pickLabel(e.labels, locale)} → {labelOf(e.to)}
</button>
{p.editable && (
<>
<IconButton size="sm" variant="ghost" aria-label={t('edgeInspector.moveUp')} disabled={i === 0} onClick={() => p.onMoveEdge(ref, -1)}><ArrowUp /></IconButton>
<IconButton size="sm" variant="ghost" aria-label={t('edgeInspector.moveDown')} disabled={i === exits.length - 1} onClick={() => p.onMoveEdge(ref, 1)}><ArrowDown /></IconButton>
</>
)}
</li>
);
})}
</ol>
{p.editable && targets.length > 0 && (
<Field label={t('stateInspector.addExit')} htmlFor="wf-add-exit">
<Select id="wf-add-exit" value="" onValueChange={(v) => v && p.onAddExit(v)}
options={targets.map((x) => ({ value: x.key, label: labelOf(x.key) }))} />
</Field>
)}
</section>
<GrantsTable graph={p.graph} stateKey={s.key} currency={p.currency} violations={p.violations} editable={p.editable}
onAdd={p.onAddGrant} onChange={p.onChangeGrant} onRemove={p.onRemoveGrant} />
{p.editable && (
<>
<Button variant="danger-outline" size="sm" onClick={() => setConfirm(true)}>{t('stateInspector.remove')}</Button>
<ConfirmDialog open={confirm} onOpenChange={setConfirm} title={t('stateInspector.removeTitle', { label })}
description={t('stateInspector.removeBody')} confirmLabel={t('stateInspector.remove')} tone="danger"
onConfirm={() => { setConfirm(false); p.onRemove(); }} />
</>
)}
</div>
);
}
web/src/features/workflow/EdgeInspector.tsx
import { useTranslation } from 'react-i18next';
import { Button, Checkbox, Field, Input, Select } from '@/components/ui';
import { pickLabel, useLocale } from '@/lib/i18n';
import type { WorkflowEdge, WorkflowGraph } from '@/lib/api/types';
import { edgeRef, type ViolationIndex } from './graphModel';
import { LabelsEditor } from './LabelsEditor';
const GATES = ['reporter', 'target', 'source', 'advance'] as const;
export function EdgeInspector(p: {
graph: WorkflowGraph; edge: WorkflowEdge; editable: boolean; violations: ViolationIndex;
onChange(patch: Partial<WorkflowEdge>): void; onRemove(): void;
}) {
const { t } = useTranslation('workflowEditor');
const [locale] = useLocale();
const e = p.edge;
const ref = edgeRef(e);
const labelOf = (key: string) => pickLabel(p.graph.states.find((x) => x.key === key)?.labels, locale) || key;
const own = p.violations.edges.get(ref) ?? [];
return (
<div className="grid gap-4">
<h2 className="text-base font-medium">{t('edgeInspector.title', { from: labelOf(e.from), to: labelOf(e.to) })}</h2>
{own.length > 0 && <ul className="text-sm text-danger">{own.map((v) => <li key={v.code}>{v.detail}</li>)}</ul>}
<LabelsEditor id={`wf-edge-${ref}`} value={e.labels} disabled={!p.editable} onChange={(labels) => p.onChange({ labels })} />
<Field label={t('edgeInspector.gate')} htmlFor="wf-edge-gate">
<Select id="wf-edge-gate" disabled={!p.editable} value={e.gate}
onValueChange={(v) => p.onChange({ gate: v as WorkflowEdge['gate'] })}
options={GATES.map((g) => ({ value: g, label: t(`edgeInspector.gates.${g}`) }))} />
</Field>
<Field label={t('edgeInspector.actionKey')} htmlFor="wf-edge-action">
<Input id="wf-edge-action" disabled={!p.editable} value={e.action_key} onChange={(ev) => p.onChange({ action_key: ev.target.value })} />
</Field>
<Checkbox id="wf-edge-reason" disabled={!p.editable} checked={e.requires_reason}
onCheckedChange={(v) => p.onChange({ requires_reason: v === true })} label={t('edgeInspector.requiresReason')} />
<Checkbox id="wf-edge-reverses" disabled={!p.editable} checked={e.reverses_accrual}
onCheckedChange={(v) => p.onChange({ reverses_accrual: v === true })} label={t('edgeInspector.reversesAccrual')} />
{p.editable && <Button variant="danger-outline" size="sm" onClick={p.onRemove}>{t('edgeInspector.remove')}</Button>}
</div>
);
}
web/src/features/workflow/WorkflowGraphView.tsx
import { useEffect, useState, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { EmptyState, Skeleton } from '@/components/ui';
import { cn } from '@/lib/cn';
import { pickLabel, useLocale } from '@/lib/i18n';
import type { WorkflowGraph } from '@/lib/api/types';
import { edgeRef, stateHasGrantViolation, type Selection, type ViolationIndex } from './graphModel';
import { layoutGraph, type LaidOutGraph } from './layout';
const activate = (fn: () => void) => (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fn(); }
};
export function WorkflowGraphView({ graph, selection, onSelect, violations }: {
graph: WorkflowGraph; selection: Selection; onSelect(s: Selection): void; violations: ViolationIndex;
}) {
const { t } = useTranslation('workflowEditor');
const [locale] = useLocale();
const [layout, setLayout] = useState<LaidOutGraph | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let live = true;
setFailed(false);
layoutGraph(graph).then((l) => live && setLayout(l)).catch(() => live && setFailed(true));
return () => { live = false; };
}, [graph]);
if (failed) return <EmptyState title={t('graph.layoutFailed')} />;
if (!layout) return <Skeleton className="h-72 w-full" />;
const states = new Map(graph.states.map((s) => [s.key, s]));
const edges = new Map(graph.edges.map((e) => [edgeRef(e), e]));
const label = (key: string) => pickLabel(states.get(key)?.labels, locale) || key;
const pad = 16;
return (
<div className="overflow-auto rounded-md border border-outline bg-surface">
<svg role="group" aria-label={t('graph.aria')} width={layout.width + pad * 2} height={layout.height + pad * 2}
viewBox={`${-pad} ${-pad} ${layout.width + pad * 2} ${layout.height + pad * 2}`}>
<defs>
<marker id="wf-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M0,0 L8,4 L0,8 z" className="fill-muted" />
</marker>
</defs>
{layout.edges.map((le) => {
const e = edges.get(le.ref);
if (!e || le.points.length === 0) return null;
const invalid = violations.edges.has(le.ref);
const selected = selection?.type === 'edge' && selection.ref === le.ref;
const mid = le.points[(le.points.length - 1) >> 1];
const action = pickLabel(e.labels, locale);
return (
<g key={le.ref} data-ref={le.ref} data-invalid={invalid ? 'true' : undefined} role="button" tabIndex={0}
aria-pressed={selected} aria-label={t('graph.edgeAria', { from: label(e.from), to: label(e.to), action })}
onClick={() => onSelect({ type: 'edge', ref: le.ref })} onKeyDown={activate(() => onSelect({ type: 'edge', ref: le.ref }))}
className="cursor-pointer focus:outline-none">
<polyline points={le.points.map((pt) => `${pt.x},${pt.y}`).join(' ')} markerEnd="url(#wf-arrow)"
className={cn('fill-none', invalid ? 'stroke-danger' : selected ? 'stroke-primary' : 'stroke-outline')}
strokeWidth={invalid || selected ? 2.5 : 1.5} strokeDasharray={e.gate === 'advance' ? '4 3' : undefined} />
<text x={mid.x + 4} y={mid.y - 4} className={cn('text-[11px]', invalid ? 'fill-danger' : 'fill-muted')}>{action}</text>
</g>
);
})}
{layout.nodes.map((n) => {
const s = states.get(n.key);
if (!s) return null;
const invalid = violations.states.has(n.key) || stateHasGrantViolation(graph, violations, n.key);
const selected = selection?.type === 'state' && selection.key === n.key;
return (
<g key={n.key} data-ref={n.key} data-invalid={invalid ? 'true' : undefined} role="button" tabIndex={0}
aria-pressed={selected} aria-label={`${label(n.key)} (${t(`semantic.${s.semantic}`)})`}
transform={`translate(${n.x},${n.y})`} className="cursor-pointer focus:outline-none"
onClick={() => onSelect({ type: 'state', key: n.key })} onKeyDown={activate(() => onSelect({ type: 'state', key: n.key }))}>
<rect width={n.width} height={n.height} rx={8}
className={cn('fill-surface', invalid ? 'stroke-danger' : selected ? 'stroke-primary' : 'stroke-outline')}
strokeWidth={invalid || selected ? 2.5 : 1} />
{s.is_terminal && <rect x={3} y={3} width={n.width - 6} height={n.height - 6} rx={6} className="fill-none stroke-outline" />}
<text x={12} y={21} className="fill-fg text-[13px] font-medium">{label(n.key)}</text>
<text x={12} y={38} className="fill-muted text-[11px]">{t(`semantic.${s.semantic}`)}</text>
</g>
);
})}
</svg>
</div>
);
}
web/src/features/workflow/ViolationsBanner.tsx
import { useTranslation } from 'react-i18next';
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
import { Button } from '@/components/ui';
import type { WorkflowGraph, WorkflowViolation } from '@/lib/api/types';
import { edgeRef, type Selection } from './graphModel';
export function ViolationsBanner({ graph, violations, stale, onShow }: {
graph: WorkflowGraph; violations: WorkflowViolation[]; stale: boolean; onShow(s: Selection): void;
}) {
const { t } = useTranslation('workflowEditor');
if (violations.length === 0) {
return (
<p role="status" className="flex items-center gap-2 text-sm text-success">
<CheckCircle2 aria-hidden className="size-4" /> {t('violations.none')}
</p>
);
}
const title = t('violations.title', { count: violations.length });
const target = (ref?: string): Selection => {
if (!ref) return null;
const grant = /^grants\[(\d+)\]$/.exec(ref);
if (grant) {
const g = graph.grants[parseInt(grant[1], 10)];
const key = g?.state_key ?? graph.states[0]?.key;
return key ? { type: 'state', key } : null;
}
if (graph.edges.some((e) => edgeRef(e) === ref)) return { type: 'edge', ref };
if (graph.states.some((s) => s.key === ref)) return { type: 'state', key: ref };
return null;
};
return (
<section role="region" aria-label={title} className="grid gap-2 rounded-md border border-danger bg-surface p-3">
<h2 className="flex items-center gap-2 text-sm font-medium text-danger">
<AlertTriangle aria-hidden className="size-4" /> {title}
</h2>
{stale && <p className="text-sm text-muted">{t('violations.stale')}</p>}
<ul className="grid gap-1">
{violations.map((v, i) => {
const sel = target(v.ref);
return (
<li key={`${v.code}-${v.ref ?? ''}-${i}`} className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">{v.rule === 0 ? t('violations.rule0') : t('violations.rule', { rule: v.rule })}</span>
<span>{v.detail}</span>
{v.ref && <code className="text-xs text-muted">{v.ref}</code>}
{sel && <Button size="sm" variant="ghost" onClick={() => onShow(sel)}>{t('violations.show')}</Button>}
</li>
);
})}
</ul>
</section>
);
}
web/src/features/workflow/RoutingPreview.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Combobox, DataTable, EmptyState, Field, Tag } from '@/components/ui';
import { MoneyInput } from '@/components/money';
import { useMemberDirectory, useMemberNames } from '@/components/workflow';
import { formatMoney, isMoneyInput } from '@/lib/money';
import { pickLabel, useLocale } from '@/lib/i18n';
import { problemMessage } from '@/lib/api/problem';
import type { WorkflowGraph, WorkflowStepRouting } from '@/lib/api/types';
import { useRoutingPreview } from './api';
export function RoutingPreview({ kind, graph, currency }: { kind: string; graph: WorkflowGraph; currency: string }) {
const { t } = useTranslation('workflowEditor');
const [locale] = useLocale();
const directory = useMemberDirectory();
const nameOf = useMemberNames();
const [member, setMember] = useState('');
const [amount, setAmount] = useState('');
const [asked, setAsked] = useState<{ member: string; amount: string } | null>(null);
const q = useRoutingPreview(kind, asked?.member ?? '', asked?.amount ?? '', asked !== null);
const stateLabel = (key: string) => pickLabel(graph.states.find((s) => s.key === key)?.labels, locale) || key;
return (
<section aria-labelledby="wf-routing" className="grid gap-3">
<h2 id="wf-routing" className="text-base font-medium">{t('routing.title')}</h2>
<p className="text-sm text-muted">{t('routing.hint')}</p>
<form className="grid gap-2 sm:grid-cols-[1fr_12rem_auto] sm:items-end"
onSubmit={(e) => { e.preventDefault(); setAsked({ member, amount }); }}>
<Field label={t('routing.member')} htmlFor="wf-route-member">
<Combobox id="wf-route-member" value={member} onValueChange={setMember}
options={(directory.data ?? []).filter((m) => m.active).map((m) => ({ value: m.member_id, label: m.name ?? m.member_id }))} />
</Field>
<Field label={t('routing.amount')} htmlFor="wf-route-amount">
<MoneyInput id="wf-route-amount" currency={currency} value={amount} onChange={setAmount} />
</Field>
<Button type="submit" disabled={!member || !isMoneyInput(amount)} loading={q.isFetching}>{t('routing.run')}</Button>
</form>
{q.error && <p role="alert" className="text-sm text-danger">{problemMessage(q.error, t)}</p>}
{q.data && q.data.steps.length === 0 && <EmptyState title={t('routing.none')} />}
{q.data && q.data.steps.length > 0 && (
<>
<p className="text-xs text-muted">{t('routing.version', { version: q.data.version })}</p>
<DataTable<WorkflowStepRouting>
data={q.data.steps}
columns={[
{ id: 'state', header: t('routing.state'), cell: ({ row }) => stateLabel(row.original.state) },
{ id: 'routable', header: t('routing.routable'), cell: ({ row }) => row.original.routable
? <Tag tone="green">{t('routing.routable')}</Tag> : <Tag tone="red">{t('routing.notRoutable')}</Tag> },
{ id: 'floor', header: t('routing.winningFloor'), cell: ({ row }) => row.original.winning_floor
? formatMoney(row.original.winning_floor, currency, locale) : '—' },
{ id: 'eligible', header: t('routing.eligible'), cell: ({ row }) => row.original.eligible.map(nameOf).join(', ') || '—' },
{ id: 'gaps', header: t('routing.gaps'), cell: ({ row }) => (row.original.gaps ?? [])
.map((g) => t(`routing.gapReasons.${g.reason}`, { defaultValue: g.reason })).join('; ') || '—' },
]}
/>
</>
)}
</section>
);
}

Verify: the column-definition shape Task 7’s DataTable takes (TanStack ColumnDef with cell: ({ row }) is assumed).

web/src/features/workflow/WorkflowEditorPage.tsx
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, ConfirmDialog, EmptyState, Field, PageHeader, Select, Skeleton, Tabs, Tag, Tooltip, toast } from '@/components/ui';
import { useCan, useMe } from '@/app/auth';
import { useMemberNames, useWorkflowDefinition } from '@/components/workflow';
import { ProblemError, problemMessage } from '@/lib/api/problem';
import { relativeTime } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import type { WorkflowGraph } from '@/lib/api/types';
import { useDiscardDraft, usePublishDraft, useSaveDraft, useWorkflowDraft } from './api';
import * as m from './graphModel';
import { EdgeInspector } from './EdgeInspector';
import { RoutingPreview } from './RoutingPreview';
import { StateInspector } from './StateInspector';
import { StateList } from './StateList';
import { ViolationsBanner } from './ViolationsBanner';
import { WorkflowGraphView } from './WorkflowGraphView';
export function WorkflowEditorPage({ kind }: { kind: string }) {
const { t } = useTranslation('workflowEditor');
const [locale] = useLocale();
const me = useMe();
const nameOf = useMemberNames();
const canManage = useCan('workflow.definition.manage');
const canPublish = useCan('workflow.definition.publish');
const published = useWorkflowDefinition(kind);
const draftQ = useWorkflowDraft(kind);
const save = useSaveDraft(kind);
const publish = usePublishDraft(kind);
const discard = useDiscardDraft(kind);
const [working, setWorking] = useState<WorkflowGraph | null>(null);
const [selection, setSelection] = useState<m.Selection>(null);
const [conflict, setConflict] = useState(false);
const [confirmPublish, setConfirmPublish] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState(false);
const draft = draftQ.data ?? null;
const graph = working ?? draft?.graph ?? published.data?.graph ?? null;
const editable = canManage && draft !== null;
const dirty = working !== null;
const serverViolations = draft?.violations ?? [];
const violations = useMemo(() => (graph ? m.indexViolations(graph, serverViolations) : null), [graph, serverViolations]);
const currency = me.data?.organization.base_currency ?? 'MYR';
useEffect(() => {
if (!dirty) return;
const warn = (e: BeforeUnloadEvent) => { e.preventDefault(); };
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [dirty]);
if (published.isPending || draftQ.isPending) return <Skeleton className="h-96 w-full" />;
if (!graph || !violations) return <EmptyState title={problemMessage(published.error ?? draftQ.error, t)} />;
const edit = (fn: (g: WorkflowGraph) => WorkflowGraph) => setWorking(fn(graph));
const onSave = (revision: number, g: WorkflowGraph) =>
save.mutate({ revision, graph: g }, {
onSuccess: (d) => {
setWorking(null);
if (d.violations.length === 0) toast.success(t('editor.saved_toast'));
else toast.info(t('editor.savedWithProblems_toast', { count: d.violations.length }));
},
onError: (e) => {
if (e instanceof ProblemError && e.status === 409) setConflict(true);
else toast.error(problemMessage(e, t));
},
});
const selectedState = selection?.type === 'state' ? graph.states.find((s) => s.key === selection.key) : undefined;
const selectedEdge = selection?.type === 'edge' ? graph.edges.find((e) => m.edgeRef(e) === selection.ref) : undefined;
const publishBlocked = serverViolations.length > 0 ? t('editor.publishBlockedViolations') : dirty ? t('editor.publishBlockedDirty') : null;
return (
<div className="grid gap-4">
<PageHeader
title={t(`kinds.${kind}`, { defaultValue: kind })}
subtitle={draft
? t('editor.draftMeta', { revision: draft.revision, when: relativeTime(draft.updated_at, locale), who: nameOf(draft.updated_by) })
: published.data ? t('editor.published', { version: published.data.version }) : undefined}
actions={
<div className="flex flex-wrap items-center gap-2">
{dirty && <Tag tone="amber">{t('editor.unsaved')}</Tag>}
{canManage && draft === null && published.data && (
<Button variant="primary" loading={save.isPending} onClick={() => onSave(0, published.data!.graph)}>{t('editor.editDraft')}</Button>
)}
{editable && (
<>
<Button variant="outline" disabled={!dirty} loading={save.isPending} onClick={() => onSave(draft!.revision, graph)}>{t('editor.save')}</Button>
<Button variant="ghost" onClick={() => setConfirmDiscard(true)}>{t('editor.discard')}</Button>
</>
)}
{canPublish && draft !== null && (
<Tooltip content={publishBlocked ?? undefined}>
<span>
<Button variant="primary" disabled={publishBlocked !== null} loading={publish.isPending} onClick={() => setConfirmPublish(true)}>
{t('editor.publish')}
</Button>
</span>
</Tooltip>
)}
</div>
}
/>
{draft === null && <p className="text-sm text-muted">{t('editor.readOnly')}</p>}
{draft !== null && (
<ViolationsBanner graph={graph} violations={serverViolations} stale={dirty} onShow={setSelection} />
)}
<div className="grid gap-4 xl:grid-cols-[18rem_1fr_24rem]">
<aside className="grid content-start gap-4">
{editable && (
<Field label={t('editor.postingOrder')} htmlFor="wf-posting-order">
<Select id="wf-posting-order" value={graph.posting_order}
onValueChange={(v) => edit((g) => ({ ...g, posting_order: v as WorkflowGraph['posting_order'] }))}
options={(['accrual_first', 'payout_first', 'cash_only'] as const).map((o) => ({ value: o, label: t(`editor.postingOrders.${o}`) }))} />
</Field>
)}
<StateList graph={graph} violations={violations} selection={selection} editable={editable}
onSelect={setSelection}
onMove={(key, dir) => edit((g) => m.moveState(g, key, dir))}
onPlace={(key, to) => edit((g) => m.placeState(g, key, to))}
onAdd={(key, sem) => { edit((g) => m.addState(g, key, sem)); setSelection({ type: 'state', key }); }} />
</aside>
<Tabs
defaultValue="graph"
items={[
{ value: 'graph', label: t('editor.tabs.graph'),
content: <WorkflowGraphView graph={graph} selection={selection} onSelect={setSelection} violations={violations} /> },
{ value: 'routing', label: t('editor.tabs.routing'),
content: <RoutingPreview kind={kind} graph={published.data?.graph ?? graph} currency={currency} /> },
]}
/>
<aside className="rounded-md border border-outline p-3">
{selectedState && (
<StateInspector graph={graph} state={selectedState} currency={currency} editable={editable} violations={violations}
onChange={(patch) => edit((g) => m.updateState(g, selectedState.key, patch))}
onRemove={() => { edit((g) => m.removeState(g, selectedState.key)); setSelection(null); }}
onSelect={setSelection}
onAddExit={(to) => edit((g) => m.addEdge(g, selectedState.key, to))}
onMoveEdge={(ref, dir) => edit((g) => m.moveEdge(g, ref, dir))}
onAddGrant={() => edit((g) => m.addGrant(g, selectedState.key))}
onChangeGrant={(i, patch) => edit((g) => m.updateGrant(g, i, patch))}
onRemoveGrant={(i) => edit((g) => m.removeGrant(g, i))} />
)}
{selectedEdge && (
<EdgeInspector graph={graph} edge={selectedEdge} editable={editable} violations={violations}
onChange={(patch) => {
const next = { ...selectedEdge, ...patch };
edit((g) => m.updateEdge(g, m.edgeRef(selectedEdge), patch));
setSelection({ type: 'edge', ref: m.edgeRef(next) });
}}
onRemove={() => { edit((g) => m.removeEdge(g, m.edgeRef(selectedEdge))); setSelection(null); }} />
)}
</aside>
</div>
<ConfirmDialog open={confirmPublish} onOpenChange={setConfirmPublish}
title={t('editor.publishTitle', { revision: draft?.revision ?? 0 })} description={t('editor.publishBody')}
confirmLabel={t('editor.publish')}
onConfirm={() => {
setConfirmPublish(false);
if (!draft) return;
publish.mutate(draft.revision, {
onSuccess: (def) => toast.success(t('editor.published_toast', { version: def.version })),
onError: (e) => (e instanceof ProblemError && e.status === 409 ? setConflict(true) : toast.error(problemMessage(e, t))),
});
}} />
<ConfirmDialog open={confirmDiscard} onOpenChange={setConfirmDiscard} tone="danger"
title={t('editor.discardTitle')} description={t('editor.discardBody')} confirmLabel={t('editor.discard')}
onConfirm={() => {
setConfirmDiscard(false);
if (!draft) return;
discard.mutate(draft.revision, {
onSuccess: () => { setWorking(null); setSelection(null); },
onError: (e) => (e instanceof ProblemError && e.status === 409 ? setConflict(true) : toast.error(problemMessage(e, t))),
});
}} />
<ConfirmDialog open={conflict} onOpenChange={setConflict}
title={t('editor.conflictTitle')} description={t('editor.conflictBody')} confirmLabel={t('editor.reload')}
onConfirm={() => { setConflict(false); setWorking(null); void draftQ.refetch(); }} />
</div>
);
}

Verify: that Task 7’s Tabs takes items: {value,label,content}[] and Tooltip takes content, and that ConfirmDialog takes open, onOpenChange, title, description, confirmLabel, tone and onConfirm. Adapt the call sites to Task 7’s actual props. Behaviour must stay the same.

web/src/features/workflow/WorkflowListPage.tsx
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { DataTable, EmptyState, PageHeader } from '@/components/ui';
import type { WorkflowKindSummary } from '@/lib/api/types';
import { useWorkflowKinds } from './api';
export function WorkflowListPage() {
const { t } = useTranslation('workflowEditor');
const kinds = useWorkflowKinds();
return (
<div className="grid gap-4">
<PageHeader title={t('list.title')} subtitle={t('list.subtitle')} />
<DataTable<WorkflowKindSummary>
loading={kinds.isPending}
data={kinds.data ?? []}
empty={<EmptyState title={t('list.empty')} />}
columns={[
{ id: 'kind', header: t('list.kind'), cell: ({ row }) => (
<Link to="/admin/workflows/$kind" params={{ kind: row.original.kind }} className="font-medium text-primary">
{t(`kinds.${row.original.kind}`, { defaultValue: row.original.kind })}
</Link>) },
{ id: 'latest', header: t('list.latest'), cell: ({ row }) => row.original.latest_version > 0
? t('list.version', { version: row.original.latest_version }) : '—' },
{ id: 'draft', header: t('list.draft'), cell: ({ row }) => row.original.draft_revision > 0
? t('list.revision', { revision: row.original.draft_revision }) : t('list.noDraft') },
]}
/>
</div>
);
}
web/src/features/workflow/routes.tsx
import { createRoute } from '@tanstack/react-router';
import { appRoute } from '@/app/layouts';
import { RequirePermission } from '@/app/auth';
import { WorkflowListPage } from './WorkflowListPage';
import { WorkflowEditorPage } from './WorkflowEditorPage';
const listRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/workflows',
component: () => (
<RequirePermission permission="workflow.definition.read"><WorkflowListPage /></RequirePermission>
),
});
const editorRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/workflows/$kind',
component: function EditorRoute() {
const { kind } = editorRoute.useParams();
return (
<RequirePermission permission="workflow.definition.read"><WorkflowEditorPage key={kind} kind={kind} /></RequirePermission>
);
},
});
export const routes = [listRoute, editorRoute];

Append to web/src/app/routes.ts:

import { routes as workflowRoutes } from '@/features/workflow/routes';
// in the array:
...workflowRoutes,

Append to navItems in web/src/app/shell/nav.ts (with Workflow imported from lucide-react at the top of the file, one import line):

{ group: 'manage', to: '/admin/workflows', labelKey: 'workflowEditor:nav', icon: Workflow, permission: 'workflow.definition.read' },
  • Step 12: Run the editor test and see it pass
Terminal window
pnpm -C web test -- src/features/workflow

Expected: PASS, all 17 tests in the three files. If the data-invalid assertions fail because the graph never rendered, check that the test ran elkjs in jsdom. The bundled build has no Worker dependency. A timeout means layoutGraph threw, and failed is showing the EmptyState.

  • Step 13: Watch the highlighting test fail once

In WorkflowGraphView.tsx, change violations.edges.has(le.ref) to false. Run pnpm -C web test -- src/features/workflow/WorkflowEditorPage.test.tsx and expect the first test to go red on toHaveAttribute('data-invalid', 'true'), with the clean-edge presence assertion still green. Restore, and re-run to green.

  • Step 14: Typecheck and lint the feature
Terminal window
pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test -- src/drift

Expected: no errors. The float, hex and i18n-parity drift tests pass, and the parity test now counts the workflowEditor namespace in all three locales.

  • Step 15: Runtime check in the in-app browser

Follow Conventions → Runtime check, as the Owner:

  1. Sidebar → Workflows. Expect a row Expense claim with v1 published and None under Draft.
  2. Open it. Expect the read-only note, a laid-out diagram of 9 states, and the Paid → Received edge. Tab to a node and press Enter: it selects, and the right panel shows its grants.
  3. Edit draft. Expect Draft revision 1, the violation panel’s “No problems”, and Publish enabled.
  4. Select Reviewed → Who approves → remove the superior grant → Save draft. Expect “2 problems block publishing” (rule 4, twice), the Pending review → Reviewed and Pending review → Rejected edges drawn red, and Publish disabled with its tooltip.
  5. Show on the first problem selects that edge. Re-add an approver on Reviewed (The requester’s superior), then Save. Expect “No problems” and Publish enabled.
  6. On Approved, add a second approver A named member (the Owner) with From amount 5000. Expect the bands panel: “RM 0.00 to below RM 5,000.00 · The requester’s unit head” and “RM 5,000.00 and above · ”.
  7. Routing preview tab: requester = the seeded Employee, amount 120.00, Preview. Expect one row per approval step, with names and no raw uuids.
  8. Discard draft → confirm. Expect it back to read-only v1. (Do not publish in the runtime check. Task 28’s Playwright spec publishes.)
  9. At 390 wide, the three columns stack (list, diagram with horizontal scroll, inspector), and nothing overflows the page horizontally. In dark mode, the red edges and the selected node are still distinguishable from unselected ones. In zh, state names come from the graph labels (待审核, 已审核…) and the editor chrome is Chinese.
  • Step 16: Commit
Terminal window
git add web/src/features/workflow web/src/lib/i18n/locales/*/workflowEditor.json \
web/src/app/routes.ts web/src/app/shell/nav.ts web/src/lib/api/types.ts web/package.json web/pnpm-lock.yaml
git commit -m "$(cat <<'EOF'
web: workflow definition editor (spec 5.7 #1)
State list with reorder, inspector for states, edges and grants with a live
band tiling preview, an elkjs-laid-out read-only diagram, draft save by
revision with server violations highlighted by ref, publish, discard, and
the routing preview. No canvas editing (F15).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"

Spec §5.7 item 2. An administrator defines how the organization pays people: a channel’s names, settlement behaviour, the GL account it credits, the currencies and claim kinds it allows, an optional cap, and the field_schema a member fills in to save their payout details. The schema builder has a live preview drawn by the same renderer the member sees (Task 14’s FieldSchemaForm), so the administrator sees exactly the form they are building.

What the API gives, read from api/openapi.yaml and internal/platform/payout:

  • GET /payout/channels → { channels: PayoutChannel[] }, retired ones included. A PayoutChannel carries name (the record name) and label (in the request’s locale), not the names object it was created with.
  • POST /payout/channels body CreatePayoutChannelRequest: key (fixed for life, ^[a-z][a-z0-9_]{0,39}$), names {en, ms, zh?}, settlement_behaviour (external | petty_cash, fixed for life), credit_account_id, field_schema, allowed_currencies (never empty), allowed_claim_types (empty = every kind), max_amount?, requires_settlement_proof?, sort_order?, active?. 409 on a duplicate key, 422 on a rule.
  • PATCH /payout/channels/{id} body UpdatePayoutChannelRequest: any of the above except key and settlement_behaviour. max_amount: null clears the cap. Absent means leave alone.
  • GET /payout/registry-entries?registry= → { entries: PayoutRegistryEntry[] } (id, registry, key, name, label, sort_order, active). POST adds one (registry, key, names, sort_order?). PATCH renames, reorders or retires one. Retiring the last active entry of a registry that an active channel uses → 409.
  • PayoutField: key (^[a-z][a-z0-9_]{0,39}$), type (text | select | registry_ref | phone | account_no | file | address), required?, max? (text and address only, 1–500), sensitivity?: 'masked' (required on account_no), label and help as {en, ms, zh?}, options (select only, keys ^[a-z0-9][a-z0-9_]{0,39}$), registry (registry_ref only). These rules are payout.ValidateSchema. The UI states them as hints, and the server’s 422 decides.
  • The credit account must be the default account of an active bank or cash journal (spec 9.7). The picker lists exactly those: GET /accounting/journals joined to GET /accounting/accounts.
  • allowed_claim_types holds workflow kinds. Expenses compare it against expense_claim, and advances against the settling kind (advance, advance_topup); see internal/platform/{expenses,advances}/settlement.go.
  • Permission payout.channel.manage for every call. The two accounting reads need accounting.ledger.read.

Files:

  • Create: web/src/features/payout/api.ts
  • Create: web/src/features/payout/fieldSchema.ts, fieldSchema.test.ts
  • Create: web/src/features/payout/FieldSchemaBuilder.tsx
  • Create: web/src/features/payout/ChannelEditor.tsx, ChannelEditor.test.tsx
  • Create: web/src/features/payout/ChannelsPage.tsx
  • Create: web/src/features/payout/RegistryPanel.tsx
  • Create: web/src/features/payout/routes.tsx
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/payoutAdmin.json
  • Append: web/src/app/routes.ts, web/src/app/shell/nav.ts, web/src/lib/api/types.ts

Interfaces:

  • Consumes: FieldSchemaForm from web/src/features/settings/FieldSchemaForm.tsx (Task 14), props { schema: MyPayoutField[]; value: Record<string, string>; onChange(v: Record<string, string>): void; disabled?: boolean }. It also consumes api, unwrap, ProblemError, applyProblemToForm, problemMessage (T6); pickLabel, useLocale, UiLocale (T6); isMoneyInput, MoneyInput (T6); Button, IconButton, Input, Field, Select, Checkbox, Tag, Card, Drawer, DataTable, PageHeader, EmptyState, Tabs, toast (T7); useMe, useCan, RequirePermission, navItems (T8); appRoute, featureRoutes, renderWithProviders, server.

  • Produces: routes (/admin/payout-channels); payoutKeys; toPreviewSchema(fields, entries): MyPayoutField[]; fieldToRow, rowToField, emptyRow; FieldSchemaBuilder. Type aliases: PayoutChannel, PayoutField, PayoutNames, PayoutRegistryEntry, MyPayoutField (skip any Task 14 already added).

  • Step 1: Type aliases

Terminal window
grep -nE "export type (PayoutChannel|PayoutField|PayoutNames|PayoutRegistryEntry|MyPayoutField) " web/src/lib/api/types.ts

Append the ones not listed:

export type PayoutChannel = Schemas['PayoutChannel'];
export type PayoutField = Schemas['PayoutField'];
export type PayoutNames = Schemas['PayoutNames'];
export type PayoutRegistryEntry = Schemas['PayoutRegistryEntry'];
export type MyPayoutField = Schemas['MyPayoutField'];
  • Step 2: Failing tests for the schema conversions — fieldSchema.test.ts
import { describe, expect, it } from 'vitest';
import type { PayoutField, PayoutRegistryEntry } from '@/lib/api/types';
import { emptyRow, fieldToRow, rowToField, toPreviewSchema } from './fieldSchema';
const bankRef: PayoutField = {
key: 'bank', type: 'registry_ref', required: true, registry: 'my_banks',
label: { en: 'Bank', ms: 'Bank', zh: '银行' },
};
const acct: PayoutField = {
key: 'account_no', type: 'account_no', required: true, sensitivity: 'masked',
label: { en: 'Account number', ms: 'Nombor akaun' },
};
const kind: PayoutField = {
key: 'kind', type: 'select',
label: { en: 'Account type', ms: 'Jenis akaun' },
options: [{ key: 'savings', label: { en: 'Savings', ms: 'Simpanan', zh: '储蓄' } }],
};
const entries: PayoutRegistryEntry[] = [
{ id: 'e2', registry: 'my_banks', key: 'rhb', name: 'RHB Bank', label: 'RHB Bank', sort_order: 2, active: true },
{ id: 'e1', registry: 'my_banks', key: 'maybank', name: 'Maybank', label: 'Maybank', sort_order: 1, active: true },
{ id: 'e3', registry: 'my_banks', key: 'gone', name: 'Gone Bank', label: 'Gone Bank', sort_order: 0, active: false },
{ id: 'e4', registry: 'my_ewallets', key: 'tng', name: "Touch 'n Go", label: "Touch 'n Go", sort_order: 1, active: true },
];
describe('toPreviewSchema', () => {
it('gives a registry_ref field its registry’s active entries as choices, in sort order', () => {
const [f] = toPreviewSchema([bankRef], entries);
expect(f.choices).toEqual([{ key: 'maybank', label: 'Maybank' }, { key: 'rhb', label: 'RHB Bank' }]);
});
it('leaves other fields as they are', () => {
const out = toPreviewSchema([acct, kind], entries);
expect(out[0]).toEqual(acct);
expect(out[1].options?.[0].label.zh).toBe('储蓄');
});
});
describe('row <-> field', () => {
it('round-trips every shape the builder edits', () => {
for (const f of [bankRef, acct, kind]) expect(rowToField(fieldToRow(f))).toEqual(f);
});
it('drops what the chosen type does not use', () => {
const r = { ...fieldToRow(kind), type: 'text' as const, max: '80', registry: 'my_banks' };
const f = rowToField(r);
expect(f.options).toBeUndefined();
expect(f.registry).toBeUndefined();
expect(f.max).toBe(80);
});
it('omits an empty zh and an empty help', () => {
const f = rowToField({ ...emptyRow(), key: 'holder', label: { en: 'Holder', ms: 'Pemegang', zh: '' } });
expect(f).toEqual({ key: 'holder', type: 'text', label: { en: 'Holder', ms: 'Pemegang' } });
});
});

Run: pnpm -C web test -- src/features/payout/fieldSchema.test.ts. Expected: FAIL, Failed to resolve import "./fieldSchema".

  • Step 3: Implement fieldSchema.ts
web/src/features/payout/fieldSchema.ts
// Converts between PayoutField (the wire shape) and FieldRow (the builder's
// all-strings form row), and builds the preview schema the member-side
// renderer takes. Validation is payout.ValidateSchema's (server).
import type { MyPayoutField, PayoutField, PayoutRegistryEntry } from '@/lib/api/types';
export type FieldType = PayoutField['type'];
export const FIELD_TYPES: FieldType[] = ['text', 'select', 'registry_ref', 'phone', 'account_no', 'file', 'address'];
export interface NamesRow { en: string; ms: string; zh: string }
export interface FieldRow {
key: string; type: FieldType; required: boolean; masked: boolean; max: string;
label: NamesRow; help: NamesRow; options: { key: string; label: NamesRow }[]; registry: string;
}
const blank = (): NamesRow => ({ en: '', ms: '', zh: '' });
export const emptyRow = (): FieldRow => ({
key: '', type: 'text', required: false, masked: false, max: '', label: blank(), help: blank(), options: [], registry: '',
});
export function namesOut(n: NamesRow): PayoutField['label'] {
return n.zh.trim() === '' ? { en: n.en, ms: n.ms } : { en: n.en, ms: n.ms, zh: n.zh };
}
const namesIn = (n?: { en: string; ms: string; zh?: string }): NamesRow => ({ en: n?.en ?? '', ms: n?.ms ?? '', zh: n?.zh ?? '' });
export function rowToField(r: FieldRow): PayoutField {
const f: PayoutField = { key: r.key, type: r.type, label: namesOut(r.label) };
if (r.required) f.required = true;
if (r.masked) f.sensitivity = 'masked';
if ((r.type === 'text' || r.type === 'address') && /^\d+$/.test(r.max)) f.max = parseInt(r.max, 10);
if (r.help.en.trim() !== '' || r.help.ms.trim() !== '') f.help = namesOut(r.help);
if (r.type === 'select') f.options = r.options.map((o) => ({ key: o.key, label: namesOut(o.label) }));
if (r.type === 'registry_ref') f.registry = r.registry;
return f;
}
export function fieldToRow(f: PayoutField): FieldRow {
return {
key: f.key, type: f.type, required: f.required === true, masked: f.sensitivity === 'masked',
max: f.max === undefined ? '' : String(f.max), label: namesIn(f.label), help: namesIn(f.help),
options: (f.options ?? []).map((o) => ({ key: o.key, label: namesIn(o.label) })), registry: f.registry ?? '',
};
}
export function toPreviewSchema(fields: PayoutField[], entries: PayoutRegistryEntry[]): MyPayoutField[] {
return fields.map((f) => {
if (f.type !== 'registry_ref') return f;
const choices = entries
.filter((e) => e.registry === f.registry && e.active)
.sort((a, b) => a.sort_order - b.sort_order)
.map((e) => ({ key: e.key, label: e.label }));
return { ...f, choices };
});
}

A select field keeps its options with their per-locale labels. The member-side renderer picks the label with pickLabel, exactly as it does for a live channel.

Run again: pnpm -C web test -- src/features/payout/fieldSchema.test.ts. Expected: PASS, 5 tests.

  • Step 4: Queries — web/src/features/payout/api.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
import type { Schemas } from '@/lib/api/types';
export const payoutKeys = {
channels: ['payout', 'channels'] as const,
registry: ['payout', 'registry-entries'] as const,
// Shared with Task 22's features/accounting/queries.ts: same key, same fetch.
accounts: ['accounting', 'accounts'] as const,
journals: ['accounting', 'journals'] as const,
};
export function usePayoutChannels() {
return useQuery({ queryKey: payoutKeys.channels, queryFn: async () => unwrap(await api.GET('/payout/channels')).channels });
}
export function useRegistryEntries() {
return useQuery({ queryKey: payoutKeys.registry, queryFn: async () => unwrap(await api.GET('/payout/registry-entries')).entries });
}
/** Eligible credit accounts: the default account of every active bank or cash journal (spec 9.7). */
export function useCreditAccountChoices() {
const journals = useQuery({ queryKey: payoutKeys.journals, queryFn: async () => unwrap(await api.GET('/accounting/journals')).journals });
const accounts = useQuery({ queryKey: payoutKeys.accounts, queryFn: async () => unwrap(await api.GET('/accounting/accounts')).accounts });
const byId = new Map((accounts.data ?? []).map((a) => [a.id, a]));
const choices = (journals.data ?? [])
.filter((j) => j.active && (j.type === 'bank' || j.type === 'cash') && j.default_account_id)
.flatMap((j) => {
const a = byId.get(j.default_account_id!);
return a ? [{ value: a.id, label: `${a.code} · ${a.label} (${j.label})` }] : [];
});
return { choices, isPending: journals.isPending || accounts.isPending, error: journals.error ?? accounts.error };
}
export function useSaveChannel() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (v: { id?: string; create?: Schemas['CreatePayoutChannelRequest']; update?: Schemas['UpdatePayoutChannelRequest'] }) =>
v.id
? unwrap(await api.PATCH('/payout/channels/{id}', { params: { path: { id: v.id } }, body: v.update! }))
: unwrap(await api.POST('/payout/channels', { body: v.create! })),
onSuccess: () => qc.invalidateQueries({ queryKey: ['payout'] }),
});
}
export function useSaveRegistryEntry() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (v: { id?: string; create?: Schemas['CreatePayoutRegistryEntryRequest']; update?: Schemas['UpdatePayoutRegistryEntryRequest'] }) =>
v.id
? unwrap(await api.PATCH('/payout/registry-entries/{id}', { params: { path: { id: v.id } }, body: v.update! }))
: unwrap(await api.POST('/payout/registry-entries', { body: v.create! })),
onSuccess: () => qc.invalidateQueries({ queryKey: payoutKeys.registry }),
});
}
  • Step 5: i18n bundles

en/payoutAdmin.json:

{
"nav": "Payout channels",
"page": {
"title": "Payout channels",
"subtitle": "How the organization pays people, and the details each member saves for it.",
"new": "New channel",
"tabs": { "channels": "Channels", "registries": "Registries" },
"empty": "No payout channels yet."
},
"list": {
"name": "Name",
"key": "Key",
"behaviour": "Paid by",
"currencies": "Currencies",
"status": "Status",
"active": "Active",
"retired": "Retired"
},
"behaviour": {
"external": "Bank or e-wallet transfer",
"petty_cash": "Cash from a petty-cash float"
},
"editor": {
"createTitle": "New payout channel",
"editTitle": "Edit {{name}}",
"key": "Key",
"keyHint": "Lower-case letters, digits and underscores. Fixed once created.",
"names": "Name",
"namesCreateHint": "English and Bahasa Melayu are required; Chinese is optional.",
"namesEditHint": "Current name: {{name}}. Fill in both English and Bahasa Melayu to rename it; leave them empty to keep it.",
"en": "English",
"ms": "Bahasa Melayu",
"zh": "中文 (optional)",
"behaviour": "Paid by",
"behaviourFixed": "Fixed once created.",
"creditAccount": "Credit account",
"creditAccountHint": "The default account of an active bank or cash journal.",
"creditAccountNone": "No active bank or cash journal has a default account yet.",
"currencies": "Allowed currencies",
"currenciesHint": "Three-letter codes, separated by commas. At least one.",
"claimTypes": "Used for",
"claimTypesHint": "Tick none to allow every kind.",
"claimTypeOptions": {
"expense_claim": "Expense claims",
"advance": "Advances",
"advance_topup": "Advance top-ups"
},
"maxAmount": "Maximum per payment",
"maxAmountHint": "Empty means no cap. Only for a channel with one currency.",
"requiresProof": "Require proof of payment when paying",
"sortOrder": "Sort order",
"active": "Active",
"save": "Save channel",
"saved": "Channel saved",
"cancel": "Cancel"
},
"fields": {
"title": "Details members fill in",
"add": "Add field",
"remove": "Remove field {{n}}",
"moveUp": "Move field {{n}} up",
"moveDown": "Move field {{n}} down",
"field": "Field {{n}}",
"key": "Key",
"type": "Type",
"types": {
"text": "Text",
"select": "Choice list",
"registry_ref": "Choice from a registry",
"phone": "Phone number",
"account_no": "Account number",
"file": "File",
"address": "Address"
},
"required": "Required",
"masked": "Masked when shown",
"maskedRequired": "An account number is always masked.",
"max": "Maximum length",
"label": "Label",
"help": "Help text (optional)",
"registry": "Registry",
"options": "Choices",
"optionKey": "Choice key",
"addOption": "Add choice",
"removeOption": "Remove choice",
"empty": "No fields yet. Members will not be asked for anything."
},
"preview": {
"title": "Preview",
"hint": "What a member sees when saving their details for this channel, in your language."
},
"registries": {
"title": "Registries",
"hint": "Lists of banks, e-wallets and networks that a \"Choice from a registry\" field picks from. Entries are retired, never deleted.",
"registry": "Registry",
"newRegistry": "or a new registry key",
"key": "Key",
"name": "Name",
"sortOrder": "Sort order",
"status": "Status",
"retire": "Retire",
"restore": "Restore",
"add": "Add entry",
"added": "Entry added",
"empty": "No entries in this registry."
}
}

ms/payoutAdmin.json:

{
"nav": "Saluran pembayaran",
"page": {
"title": "Saluran pembayaran",
"subtitle": "Cara organisasi membayar orang, dan butiran yang disimpan oleh setiap ahli untuknya.",
"new": "Saluran baharu",
"tabs": { "channels": "Saluran", "registries": "Daftar" },
"empty": "Belum ada saluran pembayaran."
},
"list": {
"name": "Nama",
"key": "Kunci",
"behaviour": "Dibayar melalui",
"currencies": "Mata wang",
"status": "Status",
"active": "Aktif",
"retired": "Ditamatkan"
},
"behaviour": {
"external": "Pindahan bank atau e-dompet",
"petty_cash": "Tunai daripada apungan wang runcit"
},
"editor": {
"createTitle": "Saluran pembayaran baharu",
"editTitle": "Sunting {{name}}",
"key": "Kunci",
"keyHint": "Huruf kecil, digit dan garis bawah. Tetap selepas dicipta.",
"names": "Nama",
"namesCreateHint": "Bahasa Inggeris dan Bahasa Melayu diwajibkan; Bahasa Cina adalah pilihan.",
"namesEditHint": "Nama semasa: {{name}}. Isikan kedua-dua Bahasa Inggeris dan Bahasa Melayu untuk menukar nama; biarkan kosong untuk mengekalkannya.",
"en": "English",
"ms": "Bahasa Melayu",
"zh": "中文 (pilihan)",
"behaviour": "Dibayar melalui",
"behaviourFixed": "Tetap selepas dicipta.",
"creditAccount": "Akaun kredit",
"creditAccountHint": "Akaun lalai bagi jurnal bank atau tunai yang aktif.",
"creditAccountNone": "Belum ada jurnal bank atau tunai aktif yang mempunyai akaun lalai.",
"currencies": "Mata wang yang dibenarkan",
"currenciesHint": "Kod tiga huruf, dipisahkan dengan koma. Sekurang-kurangnya satu.",
"claimTypes": "Digunakan untuk",
"claimTypesHint": "Jangan tanda apa-apa untuk membenarkan semua jenis.",
"claimTypeOptions": {
"expense_claim": "Tuntutan perbelanjaan",
"advance": "Pendahuluan",
"advance_topup": "Tambahan pendahuluan"
},
"maxAmount": "Maksimum setiap bayaran",
"maxAmountHint": "Kosong bermaksud tiada had. Hanya untuk saluran dengan satu mata wang.",
"requiresProof": "Wajibkan bukti pembayaran semasa membayar",
"sortOrder": "Susunan",
"active": "Aktif",
"save": "Simpan saluran",
"saved": "Saluran disimpan",
"cancel": "Batal"
},
"fields": {
"title": "Butiran yang diisi oleh ahli",
"add": "Tambah medan",
"remove": "Buang medan {{n}}",
"moveUp": "Alih medan {{n}} ke atas",
"moveDown": "Alih medan {{n}} ke bawah",
"field": "Medan {{n}}",
"key": "Kunci",
"type": "Jenis",
"types": {
"text": "Teks",
"select": "Senarai pilihan",
"registry_ref": "Pilihan daripada daftar",
"phone": "Nombor telefon",
"account_no": "Nombor akaun",
"file": "Fail",
"address": "Alamat"
},
"required": "Wajib",
"masked": "Disembunyikan semasa dipaparkan",
"maskedRequired": "Nombor akaun sentiasa disembunyikan.",
"max": "Panjang maksimum",
"label": "Label",
"help": "Teks bantuan (pilihan)",
"registry": "Daftar",
"options": "Pilihan",
"optionKey": "Kunci pilihan",
"addOption": "Tambah pilihan",
"removeOption": "Buang pilihan",
"empty": "Belum ada medan. Ahli tidak akan diminta apa-apa."
},
"preview": {
"title": "Pratonton",
"hint": "Apa yang dilihat oleh ahli semasa menyimpan butiran mereka untuk saluran ini, dalam bahasa anda."
},
"registries": {
"title": "Daftar",
"hint": "Senarai bank, e-dompet dan rangkaian yang dipilih oleh medan \"Pilihan daripada daftar\". Entri ditamatkan, tidak sekali-kali dipadam.",
"registry": "Daftar",
"newRegistry": "atau kunci daftar baharu",
"key": "Kunci",
"name": "Nama",
"sortOrder": "Susunan",
"status": "Status",
"retire": "Tamatkan",
"restore": "Pulihkan",
"add": "Tambah entri",
"added": "Entri ditambah",
"empty": "Tiada entri dalam daftar ini."
}
}

zh/payoutAdmin.json:

{
"nav": "付款渠道",
"page": {
"title": "付款渠道",
"subtitle": "机构向员工付款的方式,以及每位成员为此保存的收款资料。",
"new": "新建渠道",
"tabs": { "channels": "渠道", "registries": "登记表" },
"empty": "暂无付款渠道。"
},
"list": {
"name": "名称",
"key": "键",
"behaviour": "付款方式",
"currencies": "币种",
"status": "状态",
"active": "启用",
"retired": "已停用"
},
"behaviour": {
"external": "银行或电子钱包转账",
"petty_cash": "从备用金支付现金"
},
"editor": {
"createTitle": "新建付款渠道",
"editTitle": "编辑 {{name}}",
"key": "键",
"keyHint": "小写字母、数字和下划线,创建后不可更改。",
"names": "名称",
"namesCreateHint": "英文和马来文为必填,中文可选。",
"namesEditHint": "当前名称:{{name}}。如需改名,请同时填写英文和马来文;留空则保持不变。",
"en": "English",
"ms": "Bahasa Melayu",
"zh": "中文(可选)",
"behaviour": "付款方式",
"behaviourFixed": "创建后不可更改。",
"creditAccount": "贷方科目",
"creditAccountHint": "某个启用中的银行或现金日记账的默认科目。",
"creditAccountNone": "还没有设置默认科目的启用中银行或现金日记账。",
"currencies": "允许的币种",
"currenciesHint": "三位字母代码,用逗号分隔,至少一个。",
"claimTypes": "适用于",
"claimTypesHint": "都不勾选表示适用于所有类型。",
"claimTypeOptions": {
"expense_claim": "费用报销",
"advance": "预支款",
"advance_topup": "预支款补付"
},
"maxAmount": "单笔付款上限",
"maxAmountHint": "留空表示不设上限。仅适用于只有一种币种的渠道。",
"requiresProof": "付款时须提供付款凭证",
"sortOrder": "排序",
"active": "启用",
"save": "保存渠道",
"saved": "渠道已保存",
"cancel": "取消"
},
"fields": {
"title": "成员需填写的资料",
"add": "添加字段",
"remove": "删除第 {{n}} 个字段",
"moveUp": "将第 {{n}} 个字段上移",
"moveDown": "将第 {{n}} 个字段下移",
"field": "字段 {{n}}",
"key": "键",
"type": "类型",
"types": {
"text": "文本",
"select": "选项列表",
"registry_ref": "从登记表中选择",
"phone": "电话号码",
"account_no": "账号",
"file": "文件",
"address": "地址"
},
"required": "必填",
"masked": "显示时隐藏",
"maskedRequired": "账号始终隐藏显示。",
"max": "最大长度",
"label": "标签",
"help": "帮助文字(可选)",
"registry": "登记表",
"options": "选项",
"optionKey": "选项键",
"addOption": "添加选项",
"removeOption": "删除选项",
"empty": "还没有字段,成员将无需填写任何内容。"
},
"preview": {
"title": "预览",
"hint": "成员为此渠道保存资料时看到的表单(以您的语言显示)。"
},
"registries": {
"title": "登记表",
"hint": "“从登记表中选择”字段所用的银行、电子钱包和网络列表。条目只能停用,不会被删除。",
"registry": "登记表",
"newRegistry": "或新的登记表键",
"key": "键",
"name": "名称",
"sortOrder": "排序",
"status": "状态",
"retire": "停用",
"restore": "恢复",
"add": "添加条目",
"added": "条目已添加",
"empty": "此登记表中没有条目。"
}
}
  • Step 6: Failing editor test — ChannelEditor.test.tsx
import { describe, expect, it, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { screen, waitFor, within } from '@testing-library/react';
import { server } from '@/test/msw/server';
import { renderWithProviders } from '@/test/render';
import type { PayoutChannel } from '@/lib/api/types';
import { ChannelEditor } from './ChannelEditor';
const PERMS = ['payout.channel.manage', 'accounting.ledger.read'];
const BANK_ACCOUNT = '00000000-0000-4000-8000-00000000a100';
function serveRefs() {
server.use(
http.get('/v1/accounting/journals', () => HttpResponse.json({ journals: [
{ id: 'j1', code: 'BNK', name: 'Bank', label: 'Bank', type: 'bank', default_account_id: BANK_ACCOUNT, sequence_key: 'BNK', currency: null, default_for_type: true, active: true },
{ id: 'j2', code: 'MSC', name: 'Misc', label: 'Misc', type: 'misc', default_account_id: null, sequence_key: 'MSC', currency: null, default_for_type: true, active: true },
] })),
http.get('/v1/accounting/accounts', () => HttpResponse.json({ accounts: [
{ id: BANK_ACCOUNT, parent_id: null, code: '1110', name: 'Maybank current', label: 'Maybank current', type: 'asset', normal_balance: 'debit', control_kind: 'bank', system_role: null, postable: true, currency: null, active: true, owner_module: null },
] })),
http.get('/v1/payout/registry-entries', () => HttpResponse.json({ entries: [
{ id: 'e1', registry: 'my_banks', key: 'maybank', name: 'Maybank', label: 'Maybank', sort_order: 1, active: true },
] })),
);
}
describe('ChannelEditor', () => {
it('previews a field the moment it is added, through the member-side renderer', async () => {
serveRefs();
const { user } = renderWithProviders(<ChannelEditor onDone={vi.fn()} />, { permissions: PERMS });
const preview = await screen.findByRole('region', { name: 'Preview' });
// presence of the preview container before any field
expect(within(preview).queryByLabelText('Account holder')).toBeNull();
await user.click(screen.getByRole('button', { name: 'Add field' }));
await user.type(screen.getByLabelText('Key', { selector: '#pf-0-key' }), 'holder');
await user.type(screen.getByLabelText('English', { selector: '#pf-0-label-en' }), 'Account holder');
expect(await within(preview).findByLabelText('Account holder')).toBeInTheDocument();
});
it('creates a channel with the wire shape the API takes', async () => {
serveRefs();
const bodies: unknown[] = [];
server.use(http.post('/v1/payout/channels', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ id: 'c1' }, { status: 201 });
}));
const onDone = vi.fn();
const { user } = renderWithProviders(<ChannelEditor onDone={onDone} />, { permissions: PERMS });
await user.type(await screen.findByLabelText('Key', { selector: '#pc-key' }), 'duitnow');
await user.type(screen.getByLabelText('English', { selector: '#pc-name-en' }), 'DuitNow');
await user.type(screen.getByLabelText('Bahasa Melayu', { selector: '#pc-name-ms' }), 'DuitNow');
await user.click(screen.getByLabelText('Credit account'));
await user.click(await screen.findByRole('option', { name: /1110 · Maybank current/ }));
await user.click(screen.getByRole('button', { name: 'Add field' }));
await user.type(screen.getByLabelText('Key', { selector: '#pf-0-key' }), 'phone');
await user.click(screen.getByLabelText('Type', { selector: '#pf-0-type' }));
await user.click(await screen.findByRole('option', { name: 'Phone number' }));
await user.type(screen.getByLabelText('English', { selector: '#pf-0-label-en' }), 'Mobile number');
await user.type(screen.getByLabelText('Bahasa Melayu', { selector: '#pf-0-label-ms' }), 'Nombor telefon bimbit');
await user.click(screen.getByRole('button', { name: 'Save channel' }));
await waitFor(() => expect(bodies).toHaveLength(1));
expect(bodies[0]).toEqual({
key: 'duitnow',
names: { en: 'DuitNow', ms: 'DuitNow' },
settlement_behaviour: 'external',
credit_account_id: BANK_ACCOUNT,
field_schema: [{ key: 'phone', type: 'phone', label: { en: 'Mobile number', ms: 'Nombor telefon bimbit' } }],
allowed_currencies: ['MYR'],
allowed_claim_types: [],
requires_settlement_proof: false,
sort_order: 0,
active: true,
});
expect(onDone).toHaveBeenCalled();
});
it('on edit, sends names only when both are filled, and clears the cap with null', async () => {
serveRefs();
const channel: PayoutChannel = {
id: 'c9', key: 'bank_transfer', name: 'Bank transfer', label: 'Bank transfer', settlement_behaviour: 'external',
credit_account_id: BANK_ACCOUNT, field_schema: [], allowed_currencies: ['MYR'], allowed_claim_types: [],
max_amount: '5000.0000', requires_settlement_proof: true, sort_order: 1, active: true,
};
const bodies: Record<string, unknown>[] = [];
server.use(http.patch('/v1/payout/channels/c9', async ({ request }) => {
bodies.push((await request.json()) as Record<string, unknown>);
return HttpResponse.json(channel);
}));
const { user } = renderWithProviders(<ChannelEditor channel={channel} onDone={vi.fn()} />, { permissions: PERMS });
const cap = await screen.findByLabelText('Maximum per payment');
// presence: the cap is prefilled from the channel…
expect(cap).toHaveValue('5000.0000');
await user.clear(cap);
await user.click(screen.getByRole('button', { name: 'Save channel' }));
await waitFor(() => expect(bodies).toHaveLength(1));
expect(bodies[0].max_amount).toBeNull();
// …and absence: names were left empty, so none are sent; key and behaviour never are
expect(bodies[0]).not.toHaveProperty('names');
expect(bodies[0]).not.toHaveProperty('key');
expect(bodies[0]).not.toHaveProperty('settlement_behaviour');
});
});

The ChannelEditor reads the organization’s base currency from useMe() (MYR in renderWithProviders’ default me). Verify: that the helper’s default me has organization.base_currency: 'MYR'.

Run: pnpm -C web test -- src/features/payout/ChannelEditor.test.tsx. Expected: FAIL, Failed to resolve import "./ChannelEditor".

  • Step 7: Implement the builder and the editor
web/src/features/payout/FieldSchemaBuilder.tsx
import { useTranslation } from 'react-i18next';
import { useFieldArray, useFormContext, useWatch, Controller } from 'react-hook-form';
import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-react';
import { Button, Checkbox, Field, IconButton, Input, Select } from '@/components/ui';
import { emptyRow, FIELD_TYPES, type FieldRow } from './fieldSchema';
import type { ChannelFormValues } from './ChannelEditor';
function NamesInputs({ base, idPrefix }: { base: `fields.${number}.label` | `fields.${number}.help` | `fields.${number}.options.${number}.label`; idPrefix: string }) {
const { t } = useTranslation('payoutAdmin');
const { register } = useFormContext<ChannelFormValues>();
return (
<div className="grid gap-2 sm:grid-cols-3">
{(['en', 'ms', 'zh'] as const).map((loc) => (
<Field key={loc} label={t(`editor.${loc}`)} htmlFor={`${idPrefix}-${loc}`}>
<Input id={`${idPrefix}-${loc}`} {...register(`${base}.${loc}` as const)} />
</Field>
))}
</div>
);
}
function OptionsEditor({ index }: { index: number }) {
const { t } = useTranslation('payoutAdmin');
const { control, register } = useFormContext<ChannelFormValues>();
const options = useFieldArray({ control, name: `fields.${index}.options` });
return (
<fieldset className="grid gap-2">
<legend className="text-sm font-medium">{t('fields.options')}</legend>
{options.fields.map((o, j) => (
<div key={o.id} className="grid gap-2 rounded-md border border-outline p-2">
<Field label={t('fields.optionKey')} htmlFor={`pf-${index}-opt-${j}-key`}>
<Input id={`pf-${index}-opt-${j}-key`} {...register(`fields.${index}.options.${j}.key`)} />
</Field>
<NamesInputs base={`fields.${index}.options.${j}.label`} idPrefix={`pf-${index}-opt-${j}-label`} />
<Button size="sm" variant="ghost" onClick={() => options.remove(j)}>{t('fields.removeOption')}</Button>
</div>
))}
<Button size="sm" variant="outline" onClick={() => options.append({ key: '', label: { en: '', ms: '', zh: '' } })}>
<Plus aria-hidden /> {t('fields.addOption')}
</Button>
</fieldset>
);
}
export function FieldSchemaBuilder({ registries }: { registries: string[] }) {
const { t } = useTranslation('payoutAdmin');
const { control, register, setValue } = useFormContext<ChannelFormValues>();
const rows = useFieldArray({ control, name: 'fields' });
const values = useWatch({ control, name: 'fields' }) as FieldRow[];
return (
<section aria-labelledby="pf-title" className="grid gap-3">
<h3 id="pf-title" className="text-sm font-medium">{t('fields.title')}</h3>
{rows.fields.length === 0 && <p className="text-sm text-muted">{t('fields.empty')}</p>}
{rows.fields.map((row, i) => {
const n = i + 1;
const type = values[i]?.type ?? 'text';
return (
<fieldset key={row.id} className="grid gap-2 rounded-md border border-outline p-3">
<legend className="text-sm font-medium">{t('fields.field', { n })}</legend>
<div className="grid gap-2 sm:grid-cols-2">
<Field label={t('fields.key')} htmlFor={`pf-${i}-key`}>
<Input id={`pf-${i}-key`} {...register(`fields.${i}.key`)} />
</Field>
<Field label={t('fields.type')} htmlFor={`pf-${i}-type`}>
<Controller control={control} name={`fields.${i}.type`} render={({ field }) => (
<Select id={`pf-${i}-type`} value={field.value}
onValueChange={(v) => {
field.onChange(v);
if (v === 'account_no') setValue(`fields.${i}.masked`, true);
}}
options={FIELD_TYPES.map((ft) => ({ value: ft, label: t(`fields.types.${ft}`) }))} />
)} />
</Field>
</div>
<div className="flex flex-wrap gap-4">
<Controller control={control} name={`fields.${i}.required`} render={({ field }) => (
<Checkbox id={`pf-${i}-required`} checked={field.value} onCheckedChange={(v) => field.onChange(v === true)} label={t('fields.required')} />
)} />
<Controller control={control} name={`fields.${i}.masked`} render={({ field }) => (
<Checkbox id={`pf-${i}-masked`} checked={field.value} disabled={type === 'account_no'}
onCheckedChange={(v) => field.onChange(v === true)} label={t('fields.masked')}
description={type === 'account_no' ? t('fields.maskedRequired') : undefined} />
)} />
</div>
{(type === 'text' || type === 'address') && (
<Field label={t('fields.max')} htmlFor={`pf-${i}-max`}>
<Input id={`pf-${i}-max`} inputMode="numeric" pattern="[0-9]*" {...register(`fields.${i}.max`)} />
</Field>
)}
{type === 'registry_ref' && (
<Field label={t('fields.registry')} htmlFor={`pf-${i}-registry`}>
<Controller control={control} name={`fields.${i}.registry`} render={({ field }) => (
<Select id={`pf-${i}-registry`} value={field.value} onValueChange={field.onChange}
options={registries.map((r) => ({ value: r, label: r }))} />
)} />
</Field>
)}
<p className="text-sm font-medium">{t('fields.label')}</p>
<NamesInputs base={`fields.${i}.label`} idPrefix={`pf-${i}-label`} />
<p className="text-sm font-medium">{t('fields.help')}</p>
<NamesInputs base={`fields.${i}.help`} idPrefix={`pf-${i}-help`} />
{type === 'select' && <OptionsEditor index={i} />}
<div className="flex gap-1">
<IconButton size="sm" variant="ghost" aria-label={t('fields.moveUp', { n })} disabled={i === 0} onClick={() => rows.move(i, i - 1)}><ArrowUp /></IconButton>
<IconButton size="sm" variant="ghost" aria-label={t('fields.moveDown', { n })} disabled={i === rows.fields.length - 1} onClick={() => rows.move(i, i + 1)}><ArrowDown /></IconButton>
<IconButton size="sm" variant="ghost" aria-label={t('fields.remove', { n })} onClick={() => rows.remove(i)}><Trash2 /></IconButton>
</div>
</fieldset>
);
})}
<Button size="sm" variant="outline" onClick={() => rows.append(emptyRow())}><Plus aria-hidden /> {t('fields.add')}</Button>
</section>
);
}

The test’s getByLabelText('English', { selector: '#pf-0-label-en' }) depends on Field associating its label with htmlFor.

web/src/features/payout/ChannelEditor.tsx
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button, Checkbox, Field, Input, Select, toast } from '@/components/ui';
import { MoneyInput } from '@/components/money';
import { FieldSchemaForm } from '@/features/settings/FieldSchemaForm';
import { useMe } from '@/app/auth';
import { applyProblemToForm, problemMessage } from '@/lib/api/problem';
import { isMoneyInput } from '@/lib/money';
import type { PayoutChannel, Schemas } from '@/lib/api/types';
import { useCreditAccountChoices, useRegistryEntries, useSaveChannel } from './api';
import { fieldToRow, namesOut, rowToField, toPreviewSchema } from './fieldSchema';
import { FieldSchemaBuilder } from './FieldSchemaBuilder';
const names = z.object({ en: z.string(), ms: z.string(), zh: z.string() });
const schema = z.object({
key: z.string().regex(/^[a-z][a-z0-9_]{0,39}$/),
names,
settlement_behaviour: z.enum(['external', 'petty_cash']),
credit_account_id: z.string().uuid(),
allowed_currencies: z.string().regex(/^\s*[A-Z]{3}(\s*,\s*[A-Z]{3})*\s*$/),
allowed_claim_types: z.array(z.string()),
max_amount: z.string().refine((s) => s === '' || isMoneyInput(s)),
requires_settlement_proof: z.boolean(),
sort_order: z.string().regex(/^-?\d{1,6}$/),
active: z.boolean(),
fields: z.array(z.object({
key: z.string().regex(/^[a-z][a-z0-9_]{0,39}$/),
type: z.enum(['text', 'select', 'registry_ref', 'phone', 'account_no', 'file', 'address']),
required: z.boolean(), masked: z.boolean(), max: z.string().regex(/^\d*$/),
label: names, help: names, registry: z.string(),
options: z.array(z.object({ key: z.string(), label: names })),
})),
});
export type ChannelFormValues = z.infer<typeof schema>;
const CLAIM_TYPES = ['expense_claim', 'advance', 'advance_topup'] as const;
export function ChannelEditor({ channel, onDone }: { channel?: PayoutChannel; onDone(): void }) {
const { t } = useTranslation('payoutAdmin');
const me = useMe();
const base = me.data?.organization.base_currency ?? 'MYR';
const credit = useCreditAccountChoices();
const registry = useRegistryEntries();
const save = useSaveChannel();
const [preview, setPreview] = useState<Record<string, string>>({});
const editing = channel !== undefined;
const form = useForm<ChannelFormValues>({
resolver: zodResolver(schema),
defaultValues: {
key: channel?.key ?? '',
names: { en: '', ms: '', zh: '' },
settlement_behaviour: channel?.settlement_behaviour ?? 'external',
credit_account_id: channel?.credit_account_id ?? '',
allowed_currencies: (channel?.allowed_currencies ?? [base]).join(', '),
allowed_claim_types: channel?.allowed_claim_types ?? [],
max_amount: channel?.max_amount ?? '',
requires_settlement_proof: channel?.requires_settlement_proof ?? false,
sort_order: String(channel?.sort_order ?? 0),
active: channel?.active ?? true,
fields: (channel?.field_schema ?? []).map(fieldToRow),
},
});
const rows = useWatch({ control: form.control, name: 'fields' });
const previewSchema = useMemo(() => toPreviewSchema(rows.map(rowToField), registry.data ?? []), [rows, registry.data]);
const registries = useMemo(() => [...new Set((registry.data ?? []).map((e) => e.registry))].sort(), [registry.data]);
const onSubmit = form.handleSubmit((v) => {
const currencies = v.allowed_currencies.split(',').map((c) => c.trim()).filter(Boolean);
const field_schema = v.fields.map(rowToField);
const sort_order = parseInt(v.sort_order, 10);
const common = {
credit_account_id: v.credit_account_id, field_schema, allowed_currencies: currencies,
allowed_claim_types: v.allowed_claim_types, requires_settlement_proof: v.requires_settlement_proof, sort_order, active: v.active,
};
let req: Parameters<typeof save.mutate>[0];
if (editing) {
const update: Schemas['UpdatePayoutChannelRequest'] = { ...common, max_amount: v.max_amount === '' ? null : v.max_amount };
if (v.names.en.trim() !== '' && v.names.ms.trim() !== '') update.names = namesOut(v.names);
req = { id: channel.id, update };
} else {
const create: Schemas['CreatePayoutChannelRequest'] = {
key: v.key, names: namesOut(v.names), settlement_behaviour: v.settlement_behaviour, ...common,
};
if (v.max_amount !== '') create.max_amount = v.max_amount;
req = { create };
}
save.mutate(req, {
onSuccess: () => { toast.success(t('editor.saved')); onDone(); },
onError: (e) => { if (!applyProblemToForm(form, e)) toast.error(problemMessage(e, t)); },
});
});
return (
<FormProvider {...form}>
<form onSubmit={onSubmit} className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]" noValidate>
<div className="grid content-start gap-4">
<Field label={t('editor.key')} hint={editing ? t('editor.behaviourFixed') : t('editor.keyHint')} htmlFor="pc-key"
error={form.formState.errors.key && t('editor.keyHint')}>
<Input id="pc-key" readOnly={editing} aria-readonly={editing} {...form.register('key')} />
</Field>
<fieldset className="grid gap-2">
<legend className="text-sm font-medium">{t('editor.names')}</legend>
<p className="text-xs text-muted">{editing ? t('editor.namesEditHint', { name: channel.name }) : t('editor.namesCreateHint')}</p>
<div className="grid gap-2 sm:grid-cols-3">
{(['en', 'ms', 'zh'] as const).map((loc) => (
<Field key={loc} label={t(`editor.${loc}`)} htmlFor={`pc-name-${loc}`}>
<Input id={`pc-name-${loc}`} {...form.register(`names.${loc}`)} />
</Field>
))}
</div>
</fieldset>
<Field label={t('editor.behaviour')} hint={t('editor.behaviourFixed')} htmlFor="pc-behaviour">
<Controller control={form.control} name="settlement_behaviour" render={({ field }) => (
<Select id="pc-behaviour" disabled={editing} value={field.value} onValueChange={field.onChange}
options={(['external', 'petty_cash'] as const).map((b) => ({ value: b, label: t(`behaviour.${b}`) }))} />
)} />
</Field>
<Field label={t('editor.creditAccount')} htmlFor="pc-credit"
hint={credit.choices.length === 0 && !credit.isPending ? t('editor.creditAccountNone') : t('editor.creditAccountHint')}>
<Controller control={form.control} name="credit_account_id" render={({ field }) => (
<Select id="pc-credit" value={field.value} onValueChange={field.onChange} options={credit.choices} />
)} />
</Field>
<Field label={t('editor.currencies')} hint={t('editor.currenciesHint')} htmlFor="pc-currencies">
<Input id="pc-currencies" {...form.register('allowed_currencies')} />
</Field>
<fieldset className="grid gap-1">
<legend className="text-sm font-medium">{t('editor.claimTypes')}</legend>
<p className="text-xs text-muted">{t('editor.claimTypesHint')}</p>
<Controller control={form.control} name="allowed_claim_types" render={({ field }) => (
<div className="flex flex-wrap gap-4">
{CLAIM_TYPES.map((k) => (
<Checkbox key={k} id={`pc-ct-${k}`} label={t(`editor.claimTypeOptions.${k}`)} checked={field.value.includes(k)}
onCheckedChange={(on) => field.onChange(on === true ? [...field.value, k] : field.value.filter((x) => x !== k))} />
))}
</div>
)} />
</fieldset>
<Field label={t('editor.maxAmount')} hint={t('editor.maxAmountHint')} htmlFor="pc-max">
<Controller control={form.control} name="max_amount" render={({ field }) => (
<MoneyInput id="pc-max" currency={base} value={field.value} onChange={field.onChange} />
)} />
</Field>
<Controller control={form.control} name="requires_settlement_proof" render={({ field }) => (
<Checkbox id="pc-proof" checked={field.value} onCheckedChange={(v) => field.onChange(v === true)} label={t('editor.requiresProof')} />
)} />
<div className="grid gap-2 sm:grid-cols-2">
<Field label={t('editor.sortOrder')} htmlFor="pc-sort">
<Input id="pc-sort" inputMode="numeric" {...form.register('sort_order')} />
</Field>
<Controller control={form.control} name="active" render={({ field }) => (
<Checkbox id="pc-active" checked={field.value} onCheckedChange={(v) => field.onChange(v === true)} label={t('editor.active')} />
)} />
</div>
<FieldSchemaBuilder registries={registries} />
<div className="flex gap-2">
<Button type="submit" variant="primary" loading={save.isPending}>{t('editor.save')}</Button>
<Button type="button" variant="ghost" onClick={onDone}>{t('editor.cancel')}</Button>
</div>
</div>
<section role="region" aria-label={t('preview.title')} className="grid content-start gap-2 rounded-md border border-outline p-3 lg:sticky lg:top-4">
<h3 className="text-sm font-medium">{t('preview.title')}</h3>
<p className="text-xs text-muted">{t('preview.hint')}</p>
<FieldSchemaForm schema={previewSchema} value={preview} onChange={setPreview} />
</section>
</form>
</FormProvider>
);
}

In the create test, sort_order goes over the wire as 0, from parseInt('0', 10). max_amount is omitted when empty. allowed_claim_types is [].

In edit mode the key input is readOnly, not disabled. react-hook-form drops a disabled input’s value from the submitted data, and the zod schema would then refuse the missing key. The key is never sent on a PATCH, because update does not include it.

web/src/features/payout/RegistryPanel.tsx
import { useMemo, useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, DataTable, EmptyState, Field, Input, Select, Tag, toast } from '@/components/ui';
import { problemMessage } from '@/lib/api/problem';
import type { PayoutRegistryEntry } from '@/lib/api/types';
import { useRegistryEntries, useSaveRegistryEntry } from './api';
export function RegistryPanel() {
const { t } = useTranslation('payoutAdmin');
const entries = useRegistryEntries();
const save = useSaveRegistryEntry();
const registries = useMemo(() => [...new Set((entries.data ?? []).map((e) => e.registry))].sort(), [entries.data]);
const [registry, setRegistry] = useState('');
const [form, setForm] = useState({ registry: '', key: '', en: '', ms: '', zh: '', sort: '0' });
const current = registry || registries[0] || '';
const rows = (entries.data ?? []).filter((e) => e.registry === current).sort((a, b) => a.sort_order - b.sort_order);
const add = (e: FormEvent) => {
e.preventDefault();
const names = form.zh.trim() ? { en: form.en, ms: form.ms, zh: form.zh } : { en: form.en, ms: form.ms };
save.mutate(
{ create: { registry: form.registry || current, key: form.key, names, sort_order: parseInt(form.sort || '0', 10) } },
{
onSuccess: () => { toast.success(t('registries.added')); setForm({ registry: '', key: '', en: '', ms: '', zh: '', sort: '0' }); },
onError: (err) => toast.error(problemMessage(err, t)),
},
);
};
return (
<section className="grid gap-4">
<p className="text-sm text-muted">{t('registries.hint')}</p>
<Field label={t('registries.registry')} htmlFor="pr-registry">
<Select id="pr-registry" value={current} onValueChange={setRegistry} options={registries.map((r) => ({ value: r, label: r }))} />
</Field>
<DataTable<PayoutRegistryEntry>
loading={entries.isPending}
data={rows}
empty={<EmptyState title={t('registries.empty')} />}
columns={[
{ id: 'key', header: t('registries.key'), cell: ({ row }) => <code>{row.original.key}</code> },
{ id: 'name', header: t('registries.name'), cell: ({ row }) => row.original.label },
{ id: 'sort', header: t('registries.sortOrder'), cell: ({ row }) => row.original.sort_order },
{ id: 'status', header: t('registries.status'), cell: ({ row }) => (
<div className="flex items-center gap-2">
<Tag tone={row.original.active ? 'green' : 'muted'}>{row.original.active ? t('list.active') : t('list.retired')}</Tag>
<Button size="sm" variant="ghost" onClick={() => save.mutate(
{ id: row.original.id, update: { active: !row.original.active } },
{ onError: (err) => toast.error(problemMessage(err, t)) },
)}>
{row.original.active ? t('registries.retire') : t('registries.restore')}
</Button>
</div>) },
]}
/>
<form onSubmit={add} className="grid gap-2 rounded-md border border-dashed border-outline p-3 sm:grid-cols-2">
<Field label={t('registries.newRegistry')} htmlFor="pr-new-registry">
<Input id="pr-new-registry" placeholder={current} value={form.registry} onChange={(e) => setForm({ ...form, registry: e.target.value })} />
</Field>
<Field label={t('registries.key')} htmlFor="pr-key">
<Input id="pr-key" value={form.key} onChange={(e) => setForm({ ...form, key: e.target.value })} />
</Field>
{(['en', 'ms', 'zh'] as const).map((loc) => (
<Field key={loc} label={t(`editor.${loc}`)} htmlFor={`pr-${loc}`}>
<Input id={`pr-${loc}`} value={form[loc]} onChange={(e) => setForm({ ...form, [loc]: e.target.value })} />
</Field>
))}
<Field label={t('registries.sortOrder')} htmlFor="pr-sort">
<Input id="pr-sort" inputMode="numeric" value={form.sort} onChange={(e) => setForm({ ...form, sort: e.target.value })} />
</Field>
<div className="sm:col-span-2">
<Button type="submit" variant="outline" loading={save.isPending} disabled={!form.key || !form.en || !form.ms}>{t('registries.add')}</Button>
</div>
</form>
</section>
);
}
web/src/features/payout/ChannelsPage.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, DataTable, Drawer, EmptyState, PageHeader, Tabs, Tag } from '@/components/ui';
import type { PayoutChannel } from '@/lib/api/types';
import { usePayoutChannels } from './api';
import { ChannelEditor } from './ChannelEditor';
import { RegistryPanel } from './RegistryPanel';
export function ChannelsPage() {
const { t } = useTranslation('payoutAdmin');
const channels = usePayoutChannels();
const [open, setOpen] = useState<PayoutChannel | 'new' | null>(null);
return (
<div className="grid gap-4">
<PageHeader title={t('page.title')} subtitle={t('page.subtitle')}
actions={<Button variant="primary" onClick={() => setOpen('new')}>{t('page.new')}</Button>} />
<Tabs defaultValue="channels" items={[
{ value: 'channels', label: t('page.tabs.channels'), content: (
<DataTable<PayoutChannel>
loading={channels.isPending}
data={channels.data ?? []}
onRowClick={(c) => setOpen(c)}
empty={<EmptyState title={t('page.empty')} />}
columns={[
{ id: 'name', header: t('list.name'), cell: ({ row }) => <span className="font-medium">{row.original.label}</span> },
{ id: 'key', header: t('list.key'), cell: ({ row }) => <code>{row.original.key}</code> },
{ id: 'behaviour', header: t('list.behaviour'), cell: ({ row }) => t(`behaviour.${row.original.settlement_behaviour}`) },
{ id: 'currencies', header: t('list.currencies'), cell: ({ row }) => row.original.allowed_currencies.join(', ') },
{ id: 'status', header: t('list.status'), cell: ({ row }) => (
<Tag tone={row.original.active ? 'green' : 'muted'}>{row.original.active ? t('list.active') : t('list.retired')}</Tag>) },
]}
/>) },
{ value: 'registries', label: t('page.tabs.registries'), content: <RegistryPanel /> },
]} />
<Drawer open={open !== null} onOpenChange={(o) => !o && setOpen(null)} size="xl"
header={open === 'new' ? t('editor.createTitle') : open ? t('editor.editTitle', { name: open.label }) : ''}>
{open !== null && <ChannelEditor key={open === 'new' ? 'new' : open.id} channel={open === 'new' ? undefined : open} onDone={() => setOpen(null)} />}
</Drawer>
</div>
);
}

Verify: that Drawer accepts a size for a wide editor. If it does not, pass className="max-w-5xl" or whatever Task 7 exposes. At 390 px the drawer is full-screen (spec §5.1).

web/src/features/payout/routes.tsx
import { createRoute } from '@tanstack/react-router';
import { appRoute } from '@/app/layouts';
import { RequirePermission } from '@/app/auth';
import { ChannelsPage } from './ChannelsPage';
const channelsRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/payout-channels',
component: () => <RequirePermission permission="payout.channel.manage"><ChannelsPage /></RequirePermission>,
});
export const routes = [channelsRoute];

Append to web/src/app/routes.ts: import { routes as payoutRoutes } from '@/features/payout/routes'; and ...payoutRoutes,. Append to navItems (import Landmark from lucide-react):

{ group: 'manage', to: '/admin/payout-channels', labelKey: 'payoutAdmin:nav', icon: Landmark, permission: 'payout.channel.manage' },
  • Step 8: Run the feature’s tests and watch the preview test fail once
Terminal window
pnpm -C web test -- src/features/payout

Expected: PASS, 8 tests. Then, in ChannelEditor.tsx, replace schema={previewSchema} with schema={[]} and re-run. The first editor test must fail on findByLabelText('Account holder'). Restore it.

  • Step 9: Typecheck, lint, drift
Terminal window
pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test -- src/drift
  • Step 10: Runtime check (Conventions → Runtime check, as the Owner)
  1. Sidebar → Payout channels. Expect the seven Malaysia seeds (Plan 6), each Active.
  2. Open Bank transfer (or whichever registry_ref seed exists). Expect its fields in the builder, and in the preview a bank select listing the my_banks registry. An account-number field shows the masked checkbox ticked and disabled.
  3. Add a field: key reference, type Text, max 40, label EN Payment reference. It appears in the preview as you type. Save → toast Channel saved. Reopen: the field is there.
  4. New channel: key test_wallet, names Test wallet / Dompet ujian, credit account = the bank journal’s account. Add one Phone number field. Save. The list shows it. Then set Active off and save. It shows Retired.
  5. Registries tab → my_banks → add key test_bank, EN Test Bank, MS Bank Ujian → appears. Retire it → Retired.
  6. Log in as the seeded Employee in a second tab: Settings → My payout details does not list test_wallet (retired). This confirms the member side reads the same data.
  7. At 390, in dark and in zh: the drawer is full-screen, and the preview drops below the form. Chinese labels come from the field’s zh label when set, and fall back to en.
  • Step 11: Commit
Terminal window
git add web/src/features/payout web/src/lib/i18n/locales/*/payoutAdmin.json web/src/app/routes.ts web/src/app/shell/nav.ts web/src/lib/api/types.ts
git commit -m "$(cat <<'EOF'
web: payout channel editor with field_schema builder and live preview
Channels list and editor (names, behaviour, credit account limited to the
default accounts of active bank/cash journals, currencies, kinds, cap,
proof), a field_schema builder whose preview is the member-side
FieldSchemaForm, and registry entry management.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"

Spec §5.7 item 3. The chart shown as a tree of header and posting accounts, with each account’s type, control kind, system role and status. An editor creates, renames, recodes, re-maps and retires accounts. Module-owned accounts are shown locked, with the module that owns them. This task also creates features/accounting/queries.ts, which Tasks 23, 24 and 25 import.

What the API gives:

  • GET /accounting/accounts → { accounts: Account[] } in tree pre-order (a parent always precedes its children), retired accounts included. Account: id, parent_id, code, name, label, type (asset|liability|equity|income|expense), normal_balance, control_kind|null, system_role ('retained_earnings')|null, postable, currency|null, active, owner_module ('pettycash')|null. Needs accounting.ledger.read.
  • POST /accounting/accounts body { parent_id?, code, name, type, header?, control_kind?, system_role?, currency? }. The parent must share the type, and must not be a control or system-role account. A parent that gains its first child stops being postable. 409 on a duplicate code, a second holder of a control kind (only bank may repeat), a second retained-earnings account, or a parent with postings. Needs accounting.account.manage.
  • PATCH /accounting/accounts/{id} body with at least one of code, name, control_kind (null clears), currency (null clears), active. Type and parent are fixed. 409 when postings fix the control kind or the currency. 422 when the body changes nothing.
  • owner_module is read-only and set by no request. An owned account refuses manual journal lines, and the API offers no way to edit its ownership. Petty-cash float accounts under 1530 are the only case today.
  • GET /accounting/journals → { journals: Journal[] }, and GET /accounting/fiscal-years → { years: FiscalYear[] } (each with its twelve periods). They are used by 23–25 through queries.ts.

The UI does not hide the create or edit controls on accounts the server would refuse (parents with postings, a second control-kind holder). It cannot know about postings, and the 409’s detail says exactly why. The one exception is owner_module: the API states the owned account cannot be changed by any request here, so the editor is shown read-only, with the reason.

Files:

  • Create: web/src/features/accounting/queries.ts
  • Create: web/src/features/accounting/accounts/tree.ts, tree.test.ts
  • Create: web/src/features/accounting/accounts/AccountsPage.tsx, AccountsPage.test.tsx
  • Create: web/src/features/accounting/accounts/AccountEditor.tsx
  • Create: web/src/features/accounting/accounts/routes.tsx
  • Create: web/src/features/accounting/accounts/__fixtures__/chart.ts
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/accounts.json
  • Append: web/src/app/routes.ts, web/src/app/shell/nav.ts, web/src/lib/api/types.ts

Interfaces:

  • Consumes: api, unwrap, applyProblemToForm, problemMessage (T6); useLocale (T6); Button, Input, Field, Select, Combobox, Checkbox, Tag, Drawer, DataTable, PageHeader, EmptyState, Skeleton, toast (T7); useCan, RequirePermission, navItems (T8); appRoute, featureRoutes, renderWithProviders, server.

  • Produces (binding on 23–25): features/accounting/queries.ts exporting accountingKeys = { accounts: ['accounting','accounts'], journals: ['accounting','journals'], fiscalYears: ['accounting','fiscal-years'] }, useAccounts(): UseQueryResult<Account[]>, useJournals(): UseQueryResult<Journal[]>, useFiscalYears(): UseQueryResult<FiscalYear[]>, accountOptions(accounts, filter?): {value,label}[] (label "<code> · <label>"). Also buildRows, visibleRows, AccountRow, and routes (/accounting/accounts). Type aliases: Account, AccountType, Journal, FiscalYear, FiscalPeriod.

  • Step 1: Aliases and shared queries

Append to web/src/lib/api/types.ts:

export type Account = Schemas['Account'];
export type AccountType = Schemas['AccountType'];
export type Journal = Schemas['Journal'];
export type FiscalYear = Schemas['FiscalYear'];
export type FiscalPeriod = Schemas['FiscalPeriod'];
web/src/features/accounting/queries.ts
// Shared reads for the accounting screens (Tasks 22-25) and, by the same keys,
// Task 21's credit-account picker. The chart and the journals change rarely;
// a mutation that changes either invalidates its key.
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
import type { Account } from '@/lib/api/types';
export const accountingKeys = {
accounts: ['accounting', 'accounts'] as const,
journals: ['accounting', 'journals'] as const,
fiscalYears: ['accounting', 'fiscal-years'] as const,
};
export function useAccounts() {
return useQuery({ queryKey: accountingKeys.accounts, queryFn: async () => unwrap(await api.GET('/accounting/accounts')).accounts });
}
export function useJournals() {
return useQuery({ queryKey: accountingKeys.journals, queryFn: async () => unwrap(await api.GET('/accounting/journals')).journals });
}
export function useFiscalYears() {
return useQuery({ queryKey: accountingKeys.fiscalYears, queryFn: async () => unwrap(await api.GET('/accounting/fiscal-years')).years });
}
export function accountOptions(accounts: Account[] | undefined, filter: (a: Account) => boolean = () => true) {
return (accounts ?? []).filter(filter).map((a) => ({ value: a.id, label: `${a.code} · ${a.label}` }));
}
  • Step 2: Fixture and failing tree test
web/src/features/accounting/accounts/__fixtures__/chart.ts
import type { Account } from '@/lib/api/types';
const acc = (p: Partial<Account> & Pick<Account, 'id' | 'code' | 'name' | 'type'>): Account => ({
parent_id: null, label: p.name, normal_balance: p.type === 'asset' || p.type === 'expense' ? 'debit' : 'credit',
control_kind: null, system_role: null, postable: true, currency: null, active: true, owner_module: null, ...p,
});
export const chart: Account[] = [
acc({ id: 'a1000', code: '1000', name: 'Assets', type: 'asset', postable: false }),
acc({ id: 'a1100', code: '1100', name: 'Bank', type: 'asset', parent_id: 'a1000', postable: false }),
acc({ id: 'a1110', code: '1110', name: 'Maybank current', type: 'asset', parent_id: 'a1100', control_kind: 'bank' }),
acc({ id: 'a1530', code: '1530', name: 'Petty cash floats', type: 'asset', parent_id: 'a1000', postable: false }),
acc({ id: 'a1531', code: '1530-PF-2026-0001', name: 'Float PF-2026-0001', type: 'asset', parent_id: 'a1530', owner_module: 'pettycash' }),
acc({ id: 'a2000', code: '2000', name: 'Liabilities', type: 'liability', postable: false }),
acc({ id: 'a2100', code: '2100', name: 'Trade payables', type: 'liability', parent_id: 'a2000', control_kind: 'ap' }),
acc({ id: 'a3200', code: '3200', name: 'Retained earnings', type: 'equity', system_role: 'retained_earnings' }),
acc({ id: 'a6100', code: '6100', name: 'Travel', type: 'expense', active: false }),
];
web/src/features/accounting/accounts/tree.test.ts
import { describe, expect, it } from 'vitest';
import { chart } from './__fixtures__/chart';
import { buildRows, visibleRows } from './tree';
describe('buildRows', () => {
it('derives depth and children from the pre-order list', () => {
const rows = buildRows(chart);
expect(rows.map((r) => [r.account.code, r.depth, r.hasChildren])).toEqual([
['1000', 0, true], ['1100', 1, true], ['1110', 2, false], ['1530', 1, true],
['1530-PF-2026-0001', 2, false], ['2000', 0, true], ['2100', 1, false], ['3200', 0, false], ['6100', 0, false],
]);
});
});
describe('visibleRows', () => {
it('hides every descendant of a collapsed account and nothing else', () => {
const rows = buildRows(chart);
// presence: all nine rows are visible with nothing collapsed
expect(visibleRows(rows, new Set())).toHaveLength(9);
const shown = visibleRows(rows, new Set(['a1000'])).map((r) => r.account.code);
expect(shown).toEqual(['1000', '2000', '2100', '3200', '6100']);
});
it('filters by code or label, keeping indentation', () => {
const rows = buildRows(chart);
expect(visibleRows(rows, new Set(), 'maybank').map((r) => [r.account.code, r.depth])).toEqual([['1110', 2]]);
expect(visibleRows(rows, new Set(['a1000']), '1110')).toHaveLength(1); // a search ignores collapse
});
});

Run pnpm -C web test -- src/features/accounting/accounts/tree.test.ts. Expected FAIL: cannot resolve ./tree.

  • Step 3: Implement tree.ts
web/src/features/accounting/accounts/tree.ts
import type { Account } from '@/lib/api/types';
export interface AccountRow { account: Account; depth: number; hasChildren: boolean }
/** The API returns the chart in pre-order, so one pass gives each row its depth. */
export function buildRows(accounts: Account[]): AccountRow[] {
const depth = new Map<string, number>();
const parents = new Set(accounts.map((a) => a.parent_id).filter((p): p is string => p !== null));
return accounts.map((a) => {
const d = a.parent_id === null ? 0 : (depth.get(a.parent_id) ?? 0) + 1;
depth.set(a.id, d);
return { account: a, depth: d, hasChildren: parents.has(a.id) };
});
}
export function visibleRows(rows: AccountRow[], collapsed: Set<string>, query = ''): AccountRow[] {
const q = query.trim().toLowerCase();
if (q !== '') {
return rows.filter((r) => r.account.code.toLowerCase().includes(q) || r.account.label.toLowerCase().includes(q));
}
const out: AccountRow[] = [];
let hideBelow: number | null = null;
for (const r of rows) {
if (hideBelow !== null && r.depth > hideBelow) continue;
hideBelow = null;
out.push(r);
if (r.hasChildren && collapsed.has(r.account.id)) hideBelow = r.depth;
}
return out;
}

Run again. Expected: PASS, 3 tests.

  • Step 4: i18n bundles

en/accounts.json:

{
"nav": "Chart of accounts",
"page": {
"title": "Chart of accounts",
"subtitle": "Header accounts group; posting accounts take journal lines. Accounts are retired, never deleted.",
"new": "New account",
"search": "Search by code or name",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"empty": "No accounts match."
},
"cols": { "code": "Code", "name": "Name", "type": "Type", "kind": "Kind", "status": "Status" },
"row": {
"expand": "Expand {{code}}",
"collapse": "Collapse {{code}}",
"header": "Header",
"posting": "Posting",
"active": "Active",
"retired": "Retired",
"ownedBy": "Owned by {{module}}",
"retainedEarnings": "Retained earnings"
},
"types": { "asset": "Asset", "liability": "Liability", "equity": "Equity", "income": "Income", "expense": "Expense" },
"modules": { "pettycash": "Petty cash" },
"controlKinds": {
"none": "None",
"ap": "Accounts payable",
"employee_advances": "Employee advances",
"petty_cash_ap": "Petty cash payable",
"ar": "Accounts receivable",
"bank": "Bank",
"fx_gain_loss": "FX gain or loss"
},
"editor": {
"createTitle": "New account",
"editTitle": "{{code}} · {{name}}",
"lockedTitle": "This account is managed by {{module}}",
"lockedBody": "Petty cash created it for one float and alone posts to it. It cannot be renamed, re-mapped or retired here, and it takes no manual journal lines.",
"code": "Code",
"name": "Name",
"nameHint": "Account names are records: write them in English or Bahasa Melayu.",
"type": "Type",
"typeFixed": "Type and parent are fixed once the account exists.",
"parent": "Parent",
"parentNone": "None (a top-level account)",
"header": "Header account (groups other accounts, takes no postings)",
"controlKind": "Control kind",
"controlKindHint": "Modules find their accounts by this. Only Bank may be used by more than one active account.",
"retainedEarnings": "This is the retained earnings account",
"currency": "Currency",
"currencyHint": "Empty accepts any currency. Assets and liabilities only.",
"active": "Active",
"activeHint": "A retired account keeps its history and takes only reversals and the year-end close.",
"save": "Save",
"saved": "Account saved",
"created": "Account created",
"nothingChanged": "Nothing has changed.",
"cancel": "Cancel",
"required": "Required",
"readOnly": "You can view the chart but not change it."
}
}

ms/accounts.json:

{
"nav": "Carta akaun",
"page": {
"title": "Carta akaun",
"subtitle": "Akaun kepala mengumpulkan akaun lain; akaun pengeposan menerima baris jurnal. Akaun ditamatkan, tidak sekali-kali dipadam.",
"new": "Akaun baharu",
"search": "Cari mengikut kod atau nama",
"expandAll": "Kembangkan semua",
"collapseAll": "Kuncupkan semua",
"empty": "Tiada akaun yang sepadan."
},
"cols": { "code": "Kod", "name": "Nama", "type": "Jenis", "kind": "Kategori", "status": "Status" },
"row": {
"expand": "Kembangkan {{code}}",
"collapse": "Kuncupkan {{code}}",
"header": "Kepala",
"posting": "Pengeposan",
"active": "Aktif",
"retired": "Ditamatkan",
"ownedBy": "Dimiliki oleh {{module}}",
"retainedEarnings": "Pendapatan tertahan"
},
"types": { "asset": "Aset", "liability": "Liabiliti", "equity": "Ekuiti", "income": "Pendapatan", "expense": "Perbelanjaan" },
"modules": { "pettycash": "Wang runcit" },
"controlKinds": {
"none": "Tiada",
"ap": "Akaun belum bayar",
"employee_advances": "Pendahuluan pekerja",
"petty_cash_ap": "Wang runcit belum bayar",
"ar": "Akaun belum terima",
"bank": "Bank",
"fx_gain_loss": "Untung atau rugi tukaran asing"
},
"editor": {
"createTitle": "Akaun baharu",
"editTitle": "{{code}} · {{name}}",
"lockedTitle": "Akaun ini diuruskan oleh {{module}}",
"lockedBody": "Modul wang runcit menciptanya untuk satu apungan dan hanya ia yang mengepos kepadanya. Ia tidak boleh dinamakan semula, dipetakan semula atau ditamatkan di sini, dan tidak menerima baris jurnal manual.",
"code": "Kod",
"name": "Nama",
"nameHint": "Nama akaun ialah rekod: tulis dalam Bahasa Inggeris atau Bahasa Melayu.",
"type": "Jenis",
"typeFixed": "Jenis dan induk adalah tetap selepas akaun wujud.",
"parent": "Induk",
"parentNone": "Tiada (akaun peringkat atas)",
"header": "Akaun kepala (mengumpulkan akaun lain, tidak menerima pengeposan)",
"controlKind": "Kategori kawalan",
"controlKindHint": "Modul mencari akaun mereka melalui ini. Hanya Bank boleh digunakan oleh lebih daripada satu akaun aktif.",
"retainedEarnings": "Ini ialah akaun pendapatan tertahan",
"currency": "Mata wang",
"currencyHint": "Kosong menerima sebarang mata wang. Aset dan liabiliti sahaja.",
"active": "Aktif",
"activeHint": "Akaun yang ditamatkan mengekalkan sejarahnya dan hanya menerima pembalikan serta penutupan akhir tahun.",
"save": "Simpan",
"saved": "Akaun disimpan",
"created": "Akaun dicipta",
"nothingChanged": "Tiada apa-apa yang berubah.",
"cancel": "Batal",
"required": "Wajib diisi",
"readOnly": "Anda boleh melihat carta tetapi tidak boleh mengubahnya."
}
}

zh/accounts.json:

{
"nav": "会计科目表",
"page": {
"title": "会计科目表",
"subtitle": "汇总科目用于归类;明细科目可记账。科目只能停用,不能删除。",
"new": "新建科目",
"search": "按代码或名称搜索",
"expandAll": "全部展开",
"collapseAll": "全部收起",
"empty": "没有匹配的科目。"
},
"cols": { "code": "代码", "name": "名称", "type": "类别", "kind": "性质", "status": "状态" },
"row": {
"expand": "展开 {{code}}",
"collapse": "收起 {{code}}",
"header": "汇总",
"posting": "明细",
"active": "启用",
"retired": "已停用",
"ownedBy": "由{{module}}管理",
"retainedEarnings": "留存收益"
},
"types": { "asset": "资产", "liability": "负债", "equity": "权益", "income": "收入", "expense": "费用" },
"modules": { "pettycash": "备用金" },
"controlKinds": {
"none": "无",
"ap": "应付账款",
"employee_advances": "员工预支款",
"petty_cash_ap": "应付备用金",
"ar": "应收账款",
"bank": "银行",
"fx_gain_loss": "汇兑损益"
},
"editor": {
"createTitle": "新建科目",
"editTitle": "{{code}} · {{name}}",
"lockedTitle": "此科目由{{module}}管理",
"lockedBody": "备用金模块为某一备用金创建了此科目,且只有它能向其记账。此处不能改名、改映射或停用,也不接受手工分录行。",
"code": "代码",
"name": "名称",
"nameHint": "科目名称属于记录,请用英文或马来文填写。",
"type": "类别",
"typeFixed": "科目建立后,类别和上级不可更改。",
"parent": "上级科目",
"parentNone": "无(顶级科目)",
"header": "汇总科目(归类其他科目,不可记账)",
"controlKind": "控制性质",
"controlKindHint": "各模块按此查找科目。只有“银行”可由多个启用中的科目使用。",
"retainedEarnings": "此为留存收益科目",
"currency": "币种",
"currencyHint": "留空表示接受任何币种。仅限资产和负债科目。",
"active": "启用",
"activeHint": "停用的科目保留历史记录,只接受冲销和年终结账。",
"save": "保存",
"saved": "科目已保存",
"created": "科目已创建",
"nothingChanged": "没有任何更改。",
"cancel": "取消",
"required": "必填",
"readOnly": "您可以查看科目表,但不能修改。"
}
}
  • Step 5: Failing page test — AccountsPage.test.tsx
import { describe, expect, it } from 'vitest';
import { http, HttpResponse } from 'msw';
import { screen, waitFor, within } from '@testing-library/react';
import { server } from '@/test/msw/server';
import { renderWithProviders } from '@/test/render';
import { chart } from './__fixtures__/chart';
import { AccountsPage } from './AccountsPage';
const MANAGE = ['accounting.ledger.read', 'accounting.account.manage'];
describe('AccountsPage', () => {
it('collapses a header, hiding exactly its subtree', async () => {
server.use(http.get('/v1/accounting/accounts', () => HttpResponse.json({ accounts: chart })));
const { user } = renderWithProviders(<AccountsPage />, { permissions: MANAGE });
// presence
expect(await screen.findByText('Maybank current')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Collapse 1000' }));
// absence below the collapsed header, presence beside it
expect(screen.queryByText('Maybank current')).toBeNull();
expect(screen.getByText('Trade payables')).toBeInTheDocument();
});
it('shows a module-owned account locked, with its owner, and an ordinary one editable', async () => {
server.use(http.get('/v1/accounting/accounts', () => HttpResponse.json({ accounts: chart })));
const { user } = renderWithProviders(<AccountsPage />, { permissions: MANAGE });
await user.click(await screen.findByText('Maybank current'));
const plain = await screen.findByRole('dialog');
expect(within(plain).getByLabelText('Name')).toBeEnabled(); // presence of an editable editor
await user.keyboard('{Escape}');
const owned = screen.getByText('Float PF-2026-0001');
expect(within(owned.closest('tr')!).getByText('Owned by Petty cash')).toBeInTheDocument();
await user.click(owned);
const locked = await screen.findByRole('dialog');
expect(within(locked).getByText('This account is managed by Petty cash')).toBeInTheDocument();
expect(within(locked).getByLabelText('Name')).toBeDisabled();
expect(within(locked).queryByRole('button', { name: 'Save' })).toBeNull();
});
it('PATCHes only what changed, and sends null to clear a control kind', async () => {
const bodies: Record<string, unknown>[] = [];
server.use(
http.get('/v1/accounting/accounts', () => HttpResponse.json({ accounts: chart })),
http.patch('/v1/accounting/accounts/a2100', async ({ request }) => {
bodies.push((await request.json()) as Record<string, unknown>);
return HttpResponse.json({ ...chart[6], control_kind: null, name: 'Trade creditors' });
}),
);
const { user } = renderWithProviders(<AccountsPage />, { permissions: MANAGE });
await user.click(await screen.findByText('Trade payables'));
const dlg = await screen.findByRole('dialog');
const name = within(dlg).getByLabelText('Name');
await user.clear(name);
await user.type(name, 'Trade creditors');
await user.click(within(dlg).getByLabelText('Control kind'));
await user.click(await screen.findByRole('option', { name: 'None' }));
await user.click(within(dlg).getByRole('button', { name: 'Save' }));
await waitFor(() => expect(bodies).toHaveLength(1));
expect(bodies[0]).toEqual({ name: 'Trade creditors', control_kind: null });
});
it('hides New account from a reader', async () => {
server.use(http.get('/v1/accounting/accounts', () => HttpResponse.json({ accounts: chart })));
renderWithProviders(<AccountsPage />, { permissions: ['accounting.ledger.read'] });
expect(await screen.findByText('Maybank current')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'New account' })).toBeNull();
});
});

Run pnpm -C web test -- src/features/accounting/accounts/AccountsPage.test.tsx. Expected FAIL: cannot resolve ./AccountsPage.

  • Step 6: Implement the editor and the page
web/src/features/accounting/accounts/AccountEditor.tsx
import { useTranslation } from 'react-i18next';
import { Controller, useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Lock } from 'lucide-react';
import { Button, Checkbox, Combobox, Field, Input, Select, toast } from '@/components/ui';
import { api } from '@/lib/api/client';
import { applyProblemToForm, problemMessage, unwrap } from '@/lib/api/problem';
import { useLocale } from '@/lib/i18n';
import type { Account, AccountType, Schemas } from '@/lib/api/types';
import { accountingKeys, accountOptions, useAccounts } from '../queries';
const TYPES: AccountType[] = ['asset', 'liability', 'equity', 'income', 'expense'];
const KINDS = ['none', 'ap', 'employee_advances', 'petty_cash_ap', 'ar', 'bank', 'fx_gain_loss'] as const;
const schema = z.object({
code: z.string().trim().min(1),
name: z.string().trim().min(1),
type: z.enum(['asset', 'liability', 'equity', 'income', 'expense']),
parent_id: z.string(),
header: z.boolean(),
control_kind: z.enum(KINDS),
retained_earnings: z.boolean(),
currency: z.string().regex(/^([A-Z]{3})?$/),
active: z.boolean(),
});
type Values = z.infer<typeof schema>;
export function AccountEditor({ account, canManage, onDone }: { account?: Account; canManage: boolean; onDone(): void }) {
const { t } = useTranslation('accounts');
const [locale] = useLocale();
const qc = useQueryClient();
const accounts = useAccounts();
const locked = account?.owner_module != null;
const readOnly = locked || !canManage;
const editing = account !== undefined;
const form = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: {
code: account?.code ?? '', name: account?.name ?? '', type: account?.type ?? 'expense',
parent_id: account?.parent_id ?? '', header: account ? !account.postable : false,
control_kind: (account?.control_kind as Values['control_kind'] | null) ?? 'none',
retained_earnings: account?.system_role === 'retained_earnings', currency: account?.currency ?? '', active: account?.active ?? true,
},
});
const type = useWatch({ control: form.control, name: 'type' });
const save = useMutation({
mutationFn: async (v: Values) => {
if (editing) {
const body: Schemas['UpdateAccountRequest'] = {};
if (v.code !== account.code) body.code = v.code;
if (v.name !== account.name) body.name = v.name;
const kind = v.control_kind === 'none' ? null : v.control_kind;
if (kind !== account.control_kind) body.control_kind = kind;
const cur = v.currency === '' ? null : v.currency;
if (cur !== account.currency) body.currency = cur;
if (v.active !== account.active) body.active = v.active;
if (Object.keys(body).length === 0) return null;
return unwrap(await api.PATCH('/accounting/accounts/{id}', { params: { path: { id: account.id } }, body }));
}
const body: Schemas['CreateAccountRequest'] = { code: v.code, name: v.name, type: v.type, header: v.header };
if (v.parent_id) body.parent_id = v.parent_id;
if (v.control_kind !== 'none') body.control_kind = v.control_kind;
if (v.retained_earnings) body.system_role = 'retained_earnings';
if (v.currency) body.currency = v.currency;
return unwrap(await api.POST('/accounting/accounts', { body }));
},
onSuccess: (r) => {
if (r === null) { toast.info(t('editor.nothingChanged')); return; }
void qc.invalidateQueries({ queryKey: accountingKeys.accounts });
toast.success(editing ? t('editor.saved') : t('editor.created'));
onDone();
},
onError: (e) => { if (!applyProblemToForm(form, e)) toast.error(problemMessage(e, t)); },
});
const parentChoices = accountOptions(accounts.data, (a) =>
a.type === type && a.active && a.control_kind === null && a.system_role === null && a.owner_module === null);
return (
<form className="grid gap-4" noValidate onSubmit={form.handleSubmit((v) => save.mutate(v))}>
{locked && (
<div role="note" className="flex gap-2 rounded-md border border-outline bg-surface-muted p-3 text-sm">
<Lock aria-hidden className="mt-0.5 size-4 shrink-0" />
<div>
<p className="font-medium">{t('editor.lockedTitle', { module: t(`modules.${account!.owner_module}`) })}</p>
<p className="text-muted">{t('editor.lockedBody')}</p>
</div>
</div>
)}
{!locked && !canManage && <p className="text-sm text-muted">{t('editor.readOnly')}</p>}
<Field label={t('editor.code')} htmlFor="acc-code" error={form.formState.errors.code ? t('editor.required') : undefined}>
<Input id="acc-code" disabled={readOnly} {...form.register('code')} />
</Field>
<Field label={t('editor.name')} htmlFor="acc-name" hint={locale === 'zh' ? t('editor.nameHint') : undefined}
error={form.formState.errors.name ? t('editor.required') : undefined}>
<Input id="acc-name" disabled={readOnly} {...form.register('name')} />
</Field>
<Field label={t('editor.type')} htmlFor="acc-type" hint={editing ? t('editor.typeFixed') : undefined}>
<Controller control={form.control} name="type" render={({ field }) => (
<Select id="acc-type" disabled={editing || readOnly} value={field.value} onValueChange={field.onChange}
options={TYPES.map((x) => ({ value: x, label: t(`types.${x}`) }))} />
)} />
</Field>
{!editing && (
<>
<Field label={t('editor.parent')} htmlFor="acc-parent">
<Controller control={form.control} name="parent_id" render={({ field }) => (
<Combobox id="acc-parent" value={field.value} onValueChange={field.onChange}
options={[{ value: '', label: t('editor.parentNone') }, ...parentChoices]} />
)} />
</Field>
<Controller control={form.control} name="header" render={({ field }) => (
<Checkbox id="acc-header" checked={field.value} onCheckedChange={(v) => field.onChange(v === true)} label={t('editor.header')} />
)} />
</>
)}
<Field label={t('editor.controlKind')} hint={t('editor.controlKindHint')} htmlFor="acc-kind">
<Controller control={form.control} name="control_kind" render={({ field }) => (
<Select id="acc-kind" disabled={readOnly} value={field.value} onValueChange={field.onChange}
options={KINDS.map((k) => ({ value: k, label: t(`controlKinds.${k}`) }))} />
)} />
</Field>
{!editing && type === 'equity' && (
<Controller control={form.control} name="retained_earnings" render={({ field }) => (
<Checkbox id="acc-re" checked={field.value} onCheckedChange={(v) => field.onChange(v === true)} label={t('editor.retainedEarnings')} />
)} />
)}
{(type === 'asset' || type === 'liability') && (
<Field label={t('editor.currency')} hint={t('editor.currencyHint')} htmlFor="acc-currency">
<Input id="acc-currency" maxLength={3} disabled={readOnly} {...form.register('currency', { setValueAs: (v: string) => v.toUpperCase() })} />
</Field>
)}
{editing && (
<Controller control={form.control} name="active" render={({ field }) => (
<Checkbox id="acc-active" disabled={readOnly} checked={field.value} onCheckedChange={(v) => field.onChange(v === true)}
label={t('editor.active')} description={t('editor.activeHint')} />
)} />
)}
<div className="flex gap-2">
{!readOnly && <Button type="submit" variant="primary" loading={save.isPending}>{t('editor.save')}</Button>}
<Button type="button" variant="ghost" onClick={onDone}>{t('editor.cancel')}</Button>
</div>
</form>
);
}
web/src/features/accounting/accounts/AccountsPage.tsx
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronDown, ChevronRight, Lock } from 'lucide-react';
import { Button, DataTable, Drawer, EmptyState, IconButton, Input, PageHeader, Tag } from '@/components/ui';
import { useCan } from '@/app/auth';
import type { Account } from '@/lib/api/types';
import { useAccounts } from '../queries';
import { AccountEditor } from './AccountEditor';
import { buildRows, visibleRows, type AccountRow } from './tree';
export function AccountsPage() {
const { t } = useTranslation('accounts');
const canManage = useCan('accounting.account.manage');
const accounts = useAccounts();
const rows = useMemo(() => buildRows(accounts.data ?? []), [accounts.data]);
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [query, setQuery] = useState('');
const [open, setOpen] = useState<Account | 'new' | null>(null);
const shown = visibleRows(rows, collapsed, query);
const toggle = (id: string) => setCollapsed((c) => {
const n = new Set(c);
if (n.has(id)) n.delete(id); else n.add(id);
return n;
});
return (
<div className="grid gap-4">
<PageHeader title={t('page.title')} subtitle={t('page.subtitle')}
actions={canManage ? <Button variant="primary" onClick={() => setOpen('new')}>{t('page.new')}</Button> : undefined} />
<div className="flex flex-wrap items-center gap-2">
<Input aria-label={t('page.search')} placeholder={t('page.search')} value={query} onChange={(e) => setQuery(e.target.value)} className="max-w-xs" />
<Button size="sm" variant="ghost" onClick={() => setCollapsed(new Set())}>{t('page.expandAll')}</Button>
<Button size="sm" variant="ghost" onClick={() => setCollapsed(new Set(rows.filter((r) => r.hasChildren).map((r) => r.account.id)))}>{t('page.collapseAll')}</Button>
</div>
<DataTable<AccountRow>
loading={accounts.isPending}
data={shown}
onRowClick={(r) => setOpen(r.account)}
empty={<EmptyState title={t('page.empty')} />}
columns={[
{ id: 'code', header: t('cols.code'), cell: ({ row }) => {
const r = row.original;
const isCollapsed = collapsed.has(r.account.id);
return (
<div className="flex items-center gap-1" style={{ paddingInlineStart: `${r.depth * 1.25}rem` }}>
{r.hasChildren && query === '' ? (
<IconButton size="sm" variant="ghost" aria-expanded={!isCollapsed}
aria-label={t(isCollapsed ? 'row.expand' : 'row.collapse', { code: r.account.code })}
onClick={(e) => { e.stopPropagation(); toggle(r.account.id); }}>
{isCollapsed ? <ChevronRight /> : <ChevronDown />}
</IconButton>
) : <span className="inline-block w-7" />}
<code className="tabular-nums">{r.account.code}</code>
</div>
);
} },
{ id: 'name', header: t('cols.name'), cell: ({ row }) => (
<span className={row.original.account.postable ? '' : 'font-medium'}>{row.original.account.label}</span>) },
{ id: 'type', header: t('cols.type'), cell: ({ row }) => t(`types.${row.original.account.type}`) },
{ id: 'kind', header: t('cols.kind'), cell: ({ row }) => {
const a = row.original.account;
return (
<div className="flex flex-wrap gap-1">
<Tag tone="neutral">{a.postable ? t('row.posting') : t('row.header')}</Tag>
{a.control_kind && <Tag tone="blue">{t(`controlKinds.${a.control_kind}`, { defaultValue: a.control_kind })}</Tag>}
{a.system_role === 'retained_earnings' && <Tag tone="purple">{t('row.retainedEarnings')}</Tag>}
{a.owner_module && (
<Tag tone="muted"><Lock aria-hidden className="size-3" /> {t('row.ownedBy', { module: t(`modules.${a.owner_module}`) })}</Tag>
)}
</div>
);
} },
{ id: 'status', header: t('cols.status'), cell: ({ row }) => (
<Tag tone={row.original.account.active ? 'green' : 'muted'}>{row.original.account.active ? t('row.active') : t('row.retired')}</Tag>) },
]}
/>
<Drawer open={open !== null} onOpenChange={(o) => !o && setOpen(null)}
header={open === 'new' ? t('editor.createTitle') : open ? t('editor.editTitle', { code: open.code, name: open.label }) : ''}>
{open !== null && (
<AccountEditor key={open === 'new' ? 'new' : open.id} account={open === 'new' ? undefined : open}
canManage={canManage} onDone={() => setOpen(null)} />
)}
</Drawer>
</div>
);
}

The paddingInlineStart style is a computed indent, not a colour. It does not trip the hex drift test. Verify: that Task 5’s ESLint config does not forbid style={}. If it does, use a data-depth attribute with Tailwind ps-[…] classes for depths 0–9.

web/src/features/accounting/accounts/routes.tsx
import { createRoute } from '@tanstack/react-router';
import { appRoute } from '@/app/layouts';
import { RequirePermission } from '@/app/auth';
import { AccountsPage } from './AccountsPage';
const accountsRoute = createRoute({
getParentRoute: () => appRoute,
path: '/accounting/accounts',
component: () => <RequirePermission permission="accounting.ledger.read"><AccountsPage /></RequirePermission>,
});
export const routes = [accountsRoute];

Append to app/routes.ts: import { routes as accountsRoutes } from '@/features/accounting/accounts/routes'; and ...accountsRoutes,. Nav (import ListTree):

{ group: 'manage', to: '/accounting/accounts', labelKey: 'accounts:nav', icon: ListTree, permission: 'accounting.ledger.read' },
  • Step 7: Run the tests and watch the lock fail once
Terminal window
pnpm -C web test -- src/features/accounting/accounts

Expected: PASS, 7 tests. Then set const locked = false; in AccountEditor.tsx. The second page test must fail on toBeDisabled(), after its presence assertion on the ordinary account passed. Restore.

  • Step 8: Typecheck, lint, drift
Terminal window
pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test -- src/drift
  • Step 9: Runtime check (as the Owner)
  1. Sidebar → Chart of accounts. Expect the Malaysia pack chart as a tree, with headers in medium weight and control-kind tags on AP, bank, employee advances and petty-cash AP.
  2. Collapse all, then expand 1000: only its direct children appear.
  3. Search 1530. Expect the floats header and, once Task 26 or the seed has opened a float, a 1530-PF-… row tagged Owned by Petty cash. Open it: the lock note shows, every input is disabled, and there is no Save.
  4. New account → Expense, parent = the operating expenses header, code 6199, name Test expense → Save → Account created, and it appears under its parent.
  5. Open 6199, untick Active → Save → Retired. Open it again and change nothing → Save → Nothing has changed., with no request sent (check read_network_requests for no PATCH).
  6. Try creating a second account with control kind Accounts payable. Expect the 409’s detail in a toast. The UI did not pre-empt it.
  7. At 390, dark and zh: the tree indentation still reads, the Drawer is full-screen, and in zh the Name field shows the record-language hint.
  • Step 10: Commit
Terminal window
git add web/src/features/accounting/queries.ts web/src/features/accounting/accounts \
web/src/lib/i18n/locales/*/accounts.json web/src/app/routes.ts web/src/app/shell/nav.ts web/src/lib/api/types.ts
git commit -m "$(cat <<'EOF'
web: chart of accounts tree and editor; shared accounting queries
Tree view in the API's pre-order with collapse and search, control-kind,
system-role and ownership badges, a create/edit drawer that PATCHes only
changed fields, and module-owned accounts shown locked with their owner.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"

Task 23: Journal entries, manual journal, reversal

Section titled “Task 23: Journal entries, manual journal, reversal”

Spec §5.7 item 4 and ruling F16. The screens: an entry list filtered by journal and fiscal period, an entry drawer showing lines and analytic distributions, a manual-journal form with a live difference, and reversal with a reason. The difference is the only place the UI does arithmetic on money. It uses exact string subtraction through subtractMoney, and the server’s balance check (422 the entry does not balance) decides whether the entry posts. The Post button is not disabled on an unbalanced form. The server’s answer is shown inline next to the difference.

Depends on Task 22 (features/accounting/queries.ts).

What the API gives:

  • GET /accounting/entries?fiscal_period_id=&journal_id=&source_kind=&source_id=&limit=&offset= → { entries: JournalEntryHeader[] }, newest first, paged by limit/offset (1–200, default 50). Not keyset: the order is total, so offset paging is stable. A header has id, entry_no, journal_id, entry_date, fiscal_period_id, currency, semantic (accrual|payout|reversal|manual|year_end_close), source_kind, source_id, instance_id, description, reversal_of_id, posted_at, posted_by. There is no amount on a header, and the list shows none.
  • GET /accounting/entries/{id} → JournalEntry = header + reversed_by_id + lines[] (line_no, account_id, kind (normal|fx_difference), debit, credit, base_debit, base_credit, currency, fx_rate, fx_rate_source, party_id, tax_code_id, memo, distribution[] {analytic_account_id, axis_id, percentage, base_amount}).
  • POST /accounting/entries body { journal_id, entry_date?, currency?, description, lines: [{ account_id, debit?, credit?, party_id?, tax_code_id?, memo?, distribution?: [{analytic_account_id, percentage}] }] (min 2) } → 201 JournalEntry. It refuses (422) unbalanced entries, lines with other than exactly one positive side, header, retired, subledger-control (ap, ar, employee_advances, petty_cash_ap) and module-owned accounts, CJK text in description or memo, and missing required analytic axes. 409 on a closed or locked period. Needs accounting.journal.post. There is no Idempotency-Key on this route, so the button is disabled while the request is in flight.
  • POST /accounting/entries/{id}/reversal body { entry_date?, description? } → 201. Only manual entries are reversed here, at most once. A reversal and the year-end close cannot be reversed. There is no reason field: the reason the user gives becomes description, which is the record text of the reversal entry. It must be English or Malay, like every record (F8).
  • GET /accounting/analytic-axes → { axes: AnalyticAxis[] }, each with required, applies_to: AccountType[] and its accounts[].

Files:

  • Create: web/src/features/accounting/entries/api.ts
  • Create: web/src/features/accounting/entries/difference.ts, difference.test.ts
  • Create: web/src/features/accounting/entries/EntriesPage.tsx
  • Create: web/src/features/accounting/entries/EntryDrawer.tsx, EntryDrawer.test.tsx
  • Create: web/src/features/accounting/entries/ManualJournalPage.tsx, ManualJournalPage.test.tsx
  • Create: web/src/features/accounting/entries/routes.tsx
  • Create: web/src/features/accounting/entries/__fixtures__/entries.ts
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/journal.json
  • Append: web/src/app/routes.ts, web/src/app/shell/nav.ts, web/src/lib/api/types.ts

Interfaces:

  • Consumes: useAccounts, useJournals, useFiscalYears, accountOptions, accountingKeys (T22); subtractMoney, isMoneyInput, isZeroMoney, compareMoney, formatMoney (T6); MoneyText, MoneyInput (T6); formatDate, formatDateTime, todayISO (T6); useLocale (T6); api, unwrap, ProblemError, problemMessage, applyProblemToForm (T6); Button, IconButton, Input, Textarea, Field, Select, Combobox, DatePicker, Tag, Drawer, DataTable, PageHeader, EmptyState, LoadMore, Skeleton, toast (T7); useCan, RequirePermission, navItems (T8); ReasonDialog, useMemberNames (T9); appRoute, featureRoutes, renderWithProviders, server.

  • Produces: routes (/accounting/entries with search { journal?, period?, entry? }, /accounting/entries/new); journalDifference(lines): string; entryKeys; useEntries, useEntry, usePostEntry, useReverseEntry, useAnalyticAxes. Type aliases: JournalEntry, JournalEntryHeader, JournalLine, AnalyticAxis.

  • Step 1: Aliases

export type JournalEntry = Schemas['JournalEntry'];
export type JournalEntryHeader = Schemas['JournalEntryHeader'];
export type JournalLine = Schemas['JournalLine'];
export type AnalyticAxis = Schemas['AnalyticAxis'];
  • Step 2: Failing difference test — difference.test.ts
import { describe, expect, it } from 'vitest';
import { compareMoney, isZeroMoney, subtractMoney } from '@/lib/money';
import { journalDifference } from './difference';
describe('journalDifference (F16)', () => {
it('relies on subtractMoney taking a negative operand', () => {
// presence of the property the fold below depends on
expect(compareMoney(subtractMoney('-1.5', '-2'), '0.5')).toBe(0);
expect(compareMoney(subtractMoney('0', '5'), '-5')).toBe(0);
});
it('is exact where a float is not: 0.1 + 0.2 − 0.3 is zero', () => {
const d = journalDifference([
{ debit: '0.1', credit: '' }, { debit: '0.2', credit: '' }, { debit: '', credit: '0.3' },
]);
expect(isZeroMoney(d)).toBe(true);
});
it('is positive when debits exceed credits, negative the other way', () => {
expect(compareMoney(journalDifference([{ debit: '100', credit: '' }, { debit: '', credit: '99.70' }]), '0.3')).toBe(0);
expect(compareMoney(journalDifference([{ debit: '1', credit: '' }, { debit: '', credit: '3' }]), '-2')).toBe(0);
});
it('holds values beyond 2^53 minor units exactly', () => {
const d = journalDifference([{ debit: '999999999999999.9999', credit: '' }, { debit: '', credit: '0.0001' }]);
expect(compareMoney(d, '999999999999999.9998')).toBe(0);
});
it('counts an unfinished amount as nothing', () => {
expect(isZeroMoney(journalDifference([{ debit: 'abc', credit: '' }, { debit: '', credit: '' }]))).toBe(true);
});
});

Run pnpm -C web test -- src/features/accounting/entries/difference.test.ts. Expected: FAIL, cannot resolve ./difference. If the first test also fails once difference.ts exists, subtractMoney does not accept a negative operand. That is Task 6’s function. Amend it there, with a test, and note the registry amendment. The registry’s signature does not change. Do not work around it here, because a second arithmetic helper would break F16’s “single place”.

  • Step 3: Implement difference.ts
web/src/features/accounting/entries/difference.ts
// F16: the one place the UI does arithmetic on money. Σdebit − Σcredit is
// folded with subtractMoney alone (a debit d is added as "subtract (0 − d)"),
// so every step is lib/money's exact BigInt subtraction. The result is a
// hint; the server's balance trigger is the authority.
import { isMoneyInput, subtractMoney } from '@/lib/money';
export interface LineAmounts { debit: string; credit: string }
export function journalDifference(lines: LineAmounts[]): string {
let d = '0';
for (const l of lines) {
if (isMoneyInput(l.debit)) d = subtractMoney(d, subtractMoney('0', l.debit));
if (isMoneyInput(l.credit)) d = subtractMoney(d, l.credit);
}
return d;
}

Add web/src/features/accounting/entries/difference.ts to the float drift test’s documentation comment (not its allowlist: the file uses none of the banned tokens). The comment says F16’s arithmetic lives in lib/money.ts and is folded here. Run again. Expected: PASS, 5 tests.

  • Step 4: Queries — api.ts
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
import type { Schemas } from '@/lib/api/types';
export const PAGE = 50;
export interface EntryFilters { journal?: string; period?: string }
export const entryKeys = {
all: ['accounting', 'entries'] as const,
list: (f: EntryFilters) => ['accounting', 'entries', 'list', f] as const,
detail: (id: string) => ['accounting', 'entries', 'detail', id] as const,
axes: ['accounting', 'analytic-axes'] as const,
};
export function useEntries(f: EntryFilters) {
return useInfiniteQuery({
queryKey: entryKeys.list(f),
initialPageParam: 0,
queryFn: async ({ pageParam }) => unwrap(await api.GET('/accounting/entries', {
params: { query: { journal_id: f.journal, fiscal_period_id: f.period, limit: PAGE, offset: pageParam } },
})).entries,
getNextPageParam: (last, pages) => (last.length === PAGE ? pages.length * PAGE : undefined),
});
}
export function useEntry(id: string | undefined) {
return useQuery({
queryKey: entryKeys.detail(id ?? ''),
enabled: id !== undefined,
queryFn: async () => unwrap(await api.GET('/accounting/entries/{id}', { params: { path: { id: id! } } })),
});
}
export function useAnalyticAxes() {
return useQuery({ queryKey: entryKeys.axes, queryFn: async () => unwrap(await api.GET('/accounting/analytic-axes')).axes });
}
export function usePostEntry() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: Schemas['PostJournalEntryRequest']) => unwrap(await api.POST('/accounting/entries', { body })),
onSuccess: () => qc.invalidateQueries({ queryKey: entryKeys.all }),
});
}
export function useReverseEntry(id: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (description: string) =>
unwrap(await api.POST('/accounting/entries/{id}/reversal', { params: { path: { id } }, body: { description } })),
onSuccess: () => qc.invalidateQueries({ queryKey: entryKeys.all }),
});
}
  • Step 5: i18n bundles

en/journal.json:

{
"nav": "Journal entries",
"list": {
"title": "Journal entries",
"subtitle": "Every posting, newest first. Entries are never edited; a correction is a reversal.",
"new": "New manual journal",
"journal": "Journal",
"period": "Period",
"allJournals": "All journals",
"allPeriods": "All periods",
"periodLabel": "{{year}} · period {{seq}} ({{from}} – {{to}})",
"cols": { "number": "Number", "date": "Date", "journal": "Journal", "description": "Description", "kind": "Kind" },
"empty": "No entries match these filters.",
"loadMore": "Load more"
},
"semantic": {
"manual": "Manual",
"accrual": "Accrual",
"payout": "Payout",
"reversal": "Reversal",
"year_end_close": "Year-end close"
},
"detail": {
"title": "Entry {{number}}",
"date": "Entry date",
"journal": "Journal",
"postedAt": "Posted",
"postedBy": "Posted by",
"source": "Source",
"currency": "Currency",
"lines": "Lines",
"line": { "account": "Account", "debit": "Debit", "credit": "Credit", "baseDebit": "Debit ({{currency}})", "baseCredit": "Credit ({{currency}})", "memo": "Memo", "rate": "Rate", "fxDifference": "FX difference", "analytics": "Analytics" },
"reverses": "Reverses {{number}}",
"reversedBy": "Reversed by {{number}}",
"openEntry": "Open",
"reverse": "Reverse",
"reverseTitle": "Reverse {{number}}?",
"reverseHint": "A mirror entry is posted today with every side swapped. Describe why; this becomes the reversal's description, in English or Bahasa Melayu.",
"reversed_toast": "Posted reversal {{number}}",
"notHere": "Posted by a workflow. Reverse it through its source document.",
"notFound": "This entry was not found, or you cannot see it."
},
"form": {
"title": "New manual journal",
"journal": "Journal",
"date": "Entry date",
"currency": "Currency",
"currencyNote": "The journal's currency, or the base currency if the journal has none.",
"description": "Description",
"recordHint": "Journal text is a record: write it in English or Bahasa Melayu.",
"lines": "Lines",
"account": "Account",
"accountsNote": "Header, retired, subledger control and module-owned accounts take no manual lines and are not listed.",
"debit": "Debit",
"credit": "Credit",
"memo": "Memo",
"analytic": "{{axis}}",
"analyticNone": "None",
"addLine": "Add line",
"removeLine": "Remove line {{n}}",
"lineN": "Line {{n}}",
"difference": "Difference",
"balanced": "Balanced",
"debitsOver": "Debits exceed credits by {{amount}}",
"creditsOver": "Credits exceed debits by {{amount}}",
"differenceNote": "A guide only. The ledger checks the balance when you post.",
"post": "Post entry",
"posted_toast": "Posted {{number}}",
"refused": "Not posted",
"cancel": "Cancel",
"required": "Required"
}
}

ms/journal.json:

{
"nav": "Catatan jurnal",
"list": {
"title": "Catatan jurnal",
"subtitle": "Setiap pengeposan, terkini dahulu. Catatan tidak sekali-kali disunting; pembetulan ialah pembalikan.",
"new": "Jurnal manual baharu",
"journal": "Jurnal",
"period": "Tempoh",
"allJournals": "Semua jurnal",
"allPeriods": "Semua tempoh",
"periodLabel": "{{year}} · tempoh {{seq}} ({{from}} – {{to}})",
"cols": { "number": "Nombor", "date": "Tarikh", "journal": "Jurnal", "description": "Keterangan", "kind": "Jenis" },
"empty": "Tiada catatan yang sepadan dengan penapis ini.",
"loadMore": "Muat lagi"
},
"semantic": {
"manual": "Manual",
"accrual": "Akruan",
"payout": "Pembayaran",
"reversal": "Pembalikan",
"year_end_close": "Penutupan akhir tahun"
},
"detail": {
"title": "Catatan {{number}}",
"date": "Tarikh catatan",
"journal": "Jurnal",
"postedAt": "Dipos",
"postedBy": "Dipos oleh",
"source": "Sumber",
"currency": "Mata wang",
"lines": "Baris",
"line": { "account": "Akaun", "debit": "Debit", "credit": "Kredit", "baseDebit": "Debit ({{currency}})", "baseCredit": "Kredit ({{currency}})", "memo": "Memo", "rate": "Kadar", "fxDifference": "Perbezaan tukaran asing", "analytics": "Analitik" },
"reverses": "Membalikkan {{number}}",
"reversedBy": "Dibalikkan oleh {{number}}",
"openEntry": "Buka",
"reverse": "Balikkan",
"reverseTitle": "Balikkan {{number}}?",
"reverseHint": "Satu catatan cermin dipos hari ini dengan setiap sisi ditukar. Terangkan sebabnya; ini menjadi keterangan pembalikan, dalam Bahasa Inggeris atau Bahasa Melayu.",
"reversed_toast": "Pembalikan {{number}} dipos",
"notHere": "Dipos oleh aliran kerja. Balikkannya melalui dokumen sumbernya.",
"notFound": "Catatan ini tidak dijumpai, atau anda tidak boleh melihatnya."
},
"form": {
"title": "Jurnal manual baharu",
"journal": "Jurnal",
"date": "Tarikh catatan",
"currency": "Mata wang",
"currencyNote": "Mata wang jurnal, atau mata wang asas jika jurnal tiada mata wang.",
"description": "Keterangan",
"recordHint": "Teks jurnal ialah rekod: tulis dalam Bahasa Inggeris atau Bahasa Melayu.",
"lines": "Baris",
"account": "Akaun",
"accountsNote": "Akaun kepala, yang ditamatkan, akaun kawalan sublejar dan akaun milik modul tidak menerima baris manual dan tidak disenaraikan.",
"debit": "Debit",
"credit": "Kredit",
"memo": "Memo",
"analytic": "{{axis}}",
"analyticNone": "Tiada",
"addLine": "Tambah baris",
"removeLine": "Buang baris {{n}}",
"lineN": "Baris {{n}}",
"difference": "Perbezaan",
"balanced": "Seimbang",
"debitsOver": "Debit melebihi kredit sebanyak {{amount}}",
"creditsOver": "Kredit melebihi debit sebanyak {{amount}}",
"differenceNote": "Panduan sahaja. Lejar menyemak keseimbangan semasa anda mengepos.",
"post": "Pos catatan",
"posted_toast": "{{number}} dipos",
"refused": "Tidak dipos",
"cancel": "Batal",
"required": "Wajib diisi"
}
}

zh/journal.json:

{
"nav": "会计分录",
"list": {
"title": "会计分录",
"subtitle": "所有记账记录,最新的在前。分录从不修改;更正通过冲销完成。",
"new": "新建手工分录",
"journal": "日记账",
"period": "会计期间",
"allJournals": "所有日记账",
"allPeriods": "所有期间",
"periodLabel": "{{year}} · 第 {{seq}} 期({{from}} – {{to}})",
"cols": { "number": "编号", "date": "日期", "journal": "日记账", "description": "摘要", "kind": "类型" },
"empty": "没有符合筛选条件的分录。",
"loadMore": "加载更多"
},
"semantic": {
"manual": "手工",
"accrual": "计提",
"payout": "付款",
"reversal": "冲销",
"year_end_close": "年终结账"
},
"detail": {
"title": "分录 {{number}}",
"date": "记账日期",
"journal": "日记账",
"postedAt": "记账时间",
"postedBy": "记账人",
"source": "来源",
"currency": "币种",
"lines": "分录行",
"line": { "account": "科目", "debit": "借方", "credit": "贷方", "baseDebit": "借方({{currency}})", "baseCredit": "贷方({{currency}})", "memo": "备注", "rate": "汇率", "fxDifference": "汇兑差额", "analytics": "辅助核算" },
"reverses": "冲销 {{number}}",
"reversedBy": "已被 {{number}} 冲销",
"openEntry": "打开",
"reverse": "冲销",
"reverseTitle": "冲销 {{number}}?",
"reverseHint": "将于今天记一笔借贷方向互换的镜像分录。请说明原因;它将作为冲销分录的摘要,须用英文或马来文填写。",
"reversed_toast": "已记冲销分录 {{number}}",
"notHere": "此分录由工作流记账,请通过其来源单据冲销。",
"notFound": "找不到此分录,或您无权查看。"
},
"form": {
"title": "新建手工分录",
"journal": "日记账",
"date": "记账日期",
"currency": "币种",
"currencyNote": "日记账的币种;日记账未设币种时为本位币。",
"description": "摘要",
"recordHint": "分录文字属于记录,请用英文或马来文填写。",
"lines": "分录行",
"account": "科目",
"accountsNote": "汇总科目、已停用科目、明细账控制科目和模块专属科目不接受手工分录,因此未列出。",
"debit": "借方",
"credit": "贷方",
"memo": "备注",
"analytic": "{{axis}}",
"analyticNone": "无",
"addLine": "添加分录行",
"removeLine": "删除第 {{n}} 行",
"lineN": "第 {{n}} 行",
"difference": "差额",
"balanced": "借贷平衡",
"debitsOver": "借方比贷方多 {{amount}}",
"creditsOver": "贷方比借方多 {{amount}}",
"differenceNote": "仅供参考。记账时由总账校验是否平衡。",
"post": "记账",
"posted_toast": "已记账 {{number}}",
"refused": "未能记账",
"cancel": "取消",
"required": "必填"
}
}
  • Step 6: Fixture and failing component tests
web/src/features/accounting/entries/__fixtures__/entries.ts
import type { JournalEntry } from '@/lib/api/types';
export const manualEntry: JournalEntry = {
id: '00000000-0000-4000-8000-00000000e001', entry_no: 'MSC-2026-0001', journal_id: 'j-msc',
entry_date: '2026-09-10', fiscal_period_id: 'p-2026-09', currency: 'MYR', semantic: 'manual',
source_kind: null, source_id: null, instance_id: null, description: 'Accrue September rent',
reversal_of_id: null, reversed_by_id: null, posted_at: '2026-09-10T03:00:00Z', posted_by: 'm-owner',
lines: [
{ line_no: 1, account_id: 'a6200', kind: 'normal', debit: '3000.0000', credit: '0.0000', base_debit: '3000.0000', base_credit: '0.0000',
currency: 'MYR', fx_rate: '1.0000000000', fx_rate_source: 'identity', party_id: null, tax_code_id: null, memo: 'Office', distribution: [] },
{ line_no: 2, account_id: 'a2300', kind: 'normal', debit: '0.0000', credit: '3000.0000', base_debit: '0.0000', base_credit: '3000.0000',
currency: 'MYR', fx_rate: '1.0000000000', fx_rate_source: 'identity', party_id: null, tax_code_id: null, memo: '', distribution: [] },
],
};
export const accrualEntry: JournalEntry = {
...manualEntry, id: '00000000-0000-4000-8000-00000000e002', entry_no: 'PUR-2026-0007', semantic: 'accrual',
source_kind: 'expense_claim', source_id: '00000000-0000-4000-8000-00000000c777', posted_by: null,
};
web/src/features/accounting/entries/EntryDrawer.test.tsx
import { describe, expect, it } from 'vitest';
import { http, HttpResponse } from 'msw';
import { screen, waitFor } from '@testing-library/react';
import { server } from '@/test/msw/server';
import { renderWithProviders } from '@/test/render';
import { accrualEntry, manualEntry } from './__fixtures__/entries';
import { EntryDrawer } from './EntryDrawer';
const POST = ['accounting.ledger.read', 'accounting.journal.post'];
function serveRefs() {
server.use(
http.get('/v1/accounting/accounts', () => HttpResponse.json({ accounts: [] })),
http.get('/v1/accounting/journals', () => HttpResponse.json({ journals: [] })),
http.get('/v1/accounting/analytic-axes', () => HttpResponse.json({ axes: [] })),
http.get('/v1/org/members', () => HttpResponse.json({ members: [] })),
);
}
describe('EntryDrawer', () => {
it('offers Reverse on a manual entry and posts the reason as the description', async () => {
serveRefs();
const bodies: unknown[] = [];
server.use(
http.get(`/v1/accounting/entries/${manualEntry.id}`, () => HttpResponse.json(manualEntry)),
http.post(`/v1/accounting/entries/${manualEntry.id}/reversal`, async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ ...manualEntry, id: 'rev', entry_no: 'MSC-2026-0002', semantic: 'reversal', reversal_of_id: manualEntry.id }, { status: 201 });
}),
);
const { user } = renderWithProviders(<EntryDrawer id={manualEntry.id} onClose={() => {}} onOpen={() => {}} />, { permissions: POST });
await user.click(await screen.findByRole('button', { name: 'Reverse' }));
await user.type(await screen.findByRole('textbox'), 'Rent accrued twice');
await user.click(screen.getByRole('button', { name: /confirm|reverse/i, hidden: false }));
await waitFor(() => expect(bodies).toEqual([{ description: 'Rent accrued twice' }]));
});
it('shows why a workflow entry has no Reverse button', async () => {
serveRefs();
server.use(http.get(`/v1/accounting/entries/${accrualEntry.id}`, () => HttpResponse.json(accrualEntry)));
renderWithProviders(<EntryDrawer id={accrualEntry.id} onClose={() => {}} onOpen={() => {}} />, { permissions: POST });
// presence: the entry rendered
expect(await screen.findByText('Entry PUR-2026-0007')).toBeInTheDocument();
expect(screen.getByText(/Reverse it through its source document/)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Reverse' })).toBeNull();
});
});

Verify: the accessible name of ReasonDialog’s confirm button (Task 9). Replace /confirm|reverse/i with its exact name. Assert the dialog’s textbox with within(dialog) if the drawer holds other textboxes.

web/src/features/accounting/entries/ManualJournalPage.test.tsx
import { describe, expect, it } from 'vitest';
import { http, HttpResponse } from 'msw';
import { screen, waitFor } from '@testing-library/react';
import { server } from '@/test/msw/server';
import { renderWithProviders } from '@/test/render';
import { chart } from '../accounts/__fixtures__/chart';
import { ManualJournalPage } from './ManualJournalPage';
const EXP = { ...chart[8], id: 'a6300', code: '6300', name: 'Rent', label: 'Rent', active: true };
const BANK = chart[2];
function serveRefs() {
server.use(
http.get('/v1/accounting/accounts', () => HttpResponse.json({ accounts: [...chart, EXP] })),
http.get('/v1/accounting/journals', () => HttpResponse.json({ journals: [
{ id: 'j-msc', code: 'MSC', name: 'Miscellaneous', label: 'Miscellaneous', type: 'misc', default_account_id: null, sequence_key: 'MSC', currency: null, default_for_type: true, active: true },
] })),
http.get('/v1/accounting/analytic-axes', () => HttpResponse.json({ axes: [] })),
);
}
async function fillLine(user: ReturnType<typeof renderWithProviders>['user'], n: number, account: RegExp, side: 'Debit' | 'Credit', amount: string) {
await user.click(screen.getByLabelText('Account', { selector: `#mj-${n}-account` }));
await user.click(await screen.findByRole('option', { name: account }));
await user.type(screen.getByLabelText(side, { selector: `#mj-${n}-${side.toLowerCase()}` }), amount);
}
describe('ManualJournalPage', () => {
it('lists only accounts that take manual lines', async () => {
serveRefs();
const { user } = renderWithProviders(<ManualJournalPage />, { permissions: ['accounting.ledger.read', 'accounting.journal.post'] });
await user.click(await screen.findByLabelText('Account', { selector: '#mj-0-account' }));
// presence: an eligible account is offered…
expect(await screen.findByRole('option', { name: /6300 · Rent/ })).toBeInTheDocument();
// …absence: header, AP control, module-owned and retired accounts are not
for (const code of ['1000', '2100', '1530-PF-2026-0001', '6100']) {
expect(screen.queryByRole('option', { name: new RegExp(`^${code} ·`) })).toBeNull();
}
});
it('shows the live difference, and posts even when unbalanced so the server can say why', async () => {
serveRefs();
const bodies: unknown[] = [];
server.use(http.post('/v1/accounting/entries', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ type: 'https://errors.example/validation', title: 'Validation failed', status: 422, detail: 'the entry does not balance' }, { status: 422 });
}));
const { user } = renderWithProviders(<ManualJournalPage />, { permissions: ['accounting.ledger.read', 'accounting.journal.post'] });
await user.click(await screen.findByLabelText('Journal'));
await user.click(await screen.findByRole('option', { name: /MSC/ }));
await user.type(screen.getByLabelText('Description'), 'September rent');
await fillLine(user, 0, /6300 · Rent/, 'Debit', '0.1');
await fillLine(user, 1, /1110 · Maybank current/, 'Credit', '0.3');
await user.click(screen.getByRole('button', { name: 'Add line' }));
await fillLine(user, 2, /6300 · Rent/, 'Debit', '0.1');
expect(screen.getByRole('status', { name: 'Difference' })).toHaveTextContent(/Credits exceed debits by RM\s?0\.10/);
await user.click(screen.getByRole('button', { name: 'Post entry' }));
await waitFor(() => expect(bodies).toHaveLength(1));
expect(bodies[0]).toMatchObject({
journal_id: 'j-msc', description: 'September rent',
lines: [{ account_id: 'a6300', debit: '0.1' }, { account_id: BANK.id, credit: '0.3' }, { account_id: 'a6300', debit: '0.1' }],
});
expect((bodies[0] as { lines: object[] }).lines[0]).not.toHaveProperty('credit');
expect(await screen.findByRole('alert')).toHaveTextContent('the entry does not balance');
await user.clear(screen.getByLabelText('Debit', { selector: '#mj-2-debit' }));
await user.type(screen.getByLabelText('Debit', { selector: '#mj-2-debit' }), '0.2');
expect(screen.getByRole('status', { name: 'Difference' })).toHaveTextContent('Balanced');
});
});

The fixture’s EXP copies the retired 6100 row with active: true under a new id, so the absence assertion on 6100 really tests the retired filter. Verify: that formatMoney('0.1', 'MYR', 'en') renders RM 0.10 (the registry’s example "RM 1,234.50"). The regex allows the separator to be a no-break space.

Run: pnpm -C web test -- src/features/accounting/entries. Expected FAIL: cannot resolve ./EntryDrawer and ./ManualJournalPage.

  • Step 7: Implement the drawer, the list, the form and the routes
web/src/features/accounting/entries/EntryDrawer.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, DataTable, Drawer, EmptyState, Skeleton, Tag, toast } from '@/components/ui';
import { MoneyText } from '@/components/money';
import { ReasonDialog, useMemberNames } from '@/components/workflow';
import { useCan } from '@/app/auth';
import { problemMessage } from '@/lib/api/problem';
import { formatDate, formatDateTime } from '@/lib/date';
import { isZeroMoney } from '@/lib/money';
import { useLocale } from '@/lib/i18n';
import type { JournalLine } from '@/lib/api/types';
import { useAccounts, useJournals } from '../queries';
import { useAnalyticAxes, useEntry, useReverseEntry } from './api';
export function EntryDrawer({ id, onClose, onOpen }: { id: string; onClose(): void; onOpen(id: string): void }) {
const { t } = useTranslation('journal');
const [locale] = useLocale();
const canPost = useCan('accounting.journal.post');
const entry = useEntry(id);
const accounts = useAccounts();
const journals = useJournals();
const axes = useAnalyticAxes();
const nameOf = useMemberNames();
const reverse = useReverseEntry(id);
const [asking, setAsking] = useState(false);
const e = entry.data;
const acct = new Map((accounts.data ?? []).map((a) => [a.id, `${a.code} · ${a.label}`]));
const analytic = new Map((axes.data ?? []).flatMap((ax) => ax.accounts.map((a) => [a.id, `${ax.label}: ${a.code} ${a.name}`] as const)));
const journal = journals.data?.find((j) => j.id === e?.journal_id);
const foreign = e !== undefined && e.lines.some((l) => l.currency !== e.currency || l.fx_rate_source !== 'identity');
const amount = (a: string, c: string) => (isZeroMoney(a) ? '' : <MoneyText amount={a} currency={c} />);
return (
<Drawer open onOpenChange={(o) => !o && onClose()} header={e ? t('detail.title', { number: e.entry_no }) : ''}>
{entry.isPending && <Skeleton className="h-64 w-full" />}
{entry.isError && <EmptyState title={t('detail.notFound')} />}
{e && (
<div className="grid gap-4">
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
<dt className="text-muted">{t('detail.date')}</dt><dd>{formatDate(e.entry_date, locale)}</dd>
<dt className="text-muted">{t('detail.journal')}</dt><dd>{journal ? `${journal.code} · ${journal.label}` : '—'}</dd>
<dt className="text-muted">{t('list.cols.kind')}</dt><dd><Tag tone="neutral">{t(`semantic.${e.semantic}`)}</Tag></dd>
<dt className="text-muted">{t('list.cols.description')}</dt><dd>{e.description}</dd>
<dt className="text-muted">{t('detail.postedAt')}</dt><dd>{formatDateTime(e.posted_at, locale)}</dd>
{e.posted_by && (<><dt className="text-muted">{t('detail.postedBy')}</dt><dd>{nameOf(e.posted_by)}</dd></>)}
{e.source_kind && (<><dt className="text-muted">{t('detail.source')}</dt><dd><code>{e.source_kind}</code></dd></>)}
</dl>
{e.reversal_of_id && (
<Button size="sm" variant="ghost" onClick={() => onOpen(e.reversal_of_id!)}>{t('detail.openEntry')}: {t('semantic.reversal')}</Button>
)}
{e.reversed_by_id && (
<Button size="sm" variant="ghost" onClick={() => onOpen(e.reversed_by_id!)}>{t('detail.openEntry')} → {t('semantic.reversal')}</Button>
)}
<h3 className="text-sm font-medium">{t('detail.lines')}</h3>
<DataTable<JournalLine>
data={e.lines}
columns={[
{ id: 'account', header: t('detail.line.account'), cell: ({ row }) => (
<div>
<div>{acct.get(row.original.account_id) ?? row.original.account_id}</div>
{row.original.kind === 'fx_difference' && <Tag tone="blue">{t('detail.line.fxDifference')}</Tag>}
{row.original.memo && <div className="text-xs text-muted">{row.original.memo}</div>}
{row.original.distribution.length > 0 && (
<ul className="text-xs text-muted" aria-label={t('detail.line.analytics')}>
{row.original.distribution.map((d) => (
<li key={d.analytic_account_id}>{analytic.get(d.analytic_account_id) ?? d.analytic_account_id} · {d.percentage}%</li>
))}
</ul>
)}
</div>) },
{ id: 'debit', header: t('detail.line.debit'), cell: ({ row }) => amount(row.original.debit, row.original.currency) },
{ id: 'credit', header: t('detail.line.credit'), cell: ({ row }) => amount(row.original.credit, row.original.currency) },
...(foreign ? [
{ id: 'bdebit', header: t('detail.line.baseDebit', { currency: e.currency }), cell: ({ row }: { row: { original: JournalLine } }) => amount(row.original.base_debit, e.currency) },
{ id: 'bcredit', header: t('detail.line.baseCredit', { currency: e.currency }), cell: ({ row }: { row: { original: JournalLine } }) => amount(row.original.base_credit, e.currency) },
{ id: 'rate', header: t('detail.line.rate'), cell: ({ row }: { row: { original: JournalLine } }) => `${row.original.fx_rate} (${row.original.fx_rate_source})` },
] : []),
]}
/>
{e.semantic === 'manual' && e.reversed_by_id === null && canPost && (
<Button variant="danger-outline" onClick={() => setAsking(true)}>{t('detail.reverse')}</Button>
)}
{(e.semantic === 'accrual' || e.semantic === 'payout') && <p className="text-sm text-muted">{t('detail.notHere')}</p>}
<ReasonDialog open={asking} title={t('detail.reverseTitle', { number: e.entry_no })}
onCancel={() => setAsking(false)}
onConfirm={(reason) => {
setAsking(false);
reverse.mutate(reason, {
onSuccess: (r) => { toast.success(t('detail.reversed_toast', { number: r.entry_no })); onOpen(r.id); },
onError: (err) => toast.error(problemMessage(err, t)),
});
}} />
</div>
)}
</Drawer>
);
}

ReasonDialog (Task 9) has no description prop. The record-language rule for the reason is enforced by the server (422, CJK refused), and its message reaches the toast. Verify: if ReasonDialog accepts an optional description, pass t('detail.reverseHint'). Otherwise render the hint above the Reverse button.

web/src/features/accounting/entries/EntriesPage.tsx
import { useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { Button, DataTable, EmptyState, LoadMore, PageHeader, Select, Tag } from '@/components/ui';
import { useCan } from '@/app/auth';
import { formatDate } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import type { JournalEntryHeader } from '@/lib/api/types';
import { useFiscalYears, useJournals } from '../queries';
import { useEntries } from './api';
import { EntryDrawer } from './EntryDrawer';
export interface EntriesSearch { journal?: string; period?: string; entry?: string }
export function EntriesPage({ search }: { search: EntriesSearch }) {
const { t } = useTranslation('journal');
const [locale] = useLocale();
const navigate = useNavigate();
const canPost = useCan('accounting.journal.post');
const journals = useJournals();
const years = useFiscalYears();
const list = useEntries({ journal: search.journal, period: search.period });
const rows = list.data?.pages.flat() ?? [];
const jmap = new Map((journals.data ?? []).map((j) => [j.id, j.code]));
const set = (patch: Partial<EntriesSearch>) =>
navigate({ to: '/accounting/entries', search: (s: EntriesSearch) => ({ ...s, ...patch }) });
const periodOptions = (years.data ?? []).flatMap((y) => y.periods.map((p) => ({
value: p.id,
label: t('list.periodLabel', { year: y.name, seq: p.seq, from: formatDate(p.starts_on, locale), to: formatDate(p.ends_on, locale) }),
})));
return (
<div className="grid gap-4">
<PageHeader title={t('list.title')} subtitle={t('list.subtitle')}
actions={canPost ? <Button variant="primary" onClick={() => navigate({ to: '/accounting/entries/new' })}>{t('list.new')}</Button> : undefined} />
<div className="flex flex-wrap gap-2">
<Select aria-label={t('list.journal')} value={search.journal ?? ''} onValueChange={(v) => set({ journal: v || undefined })}
options={[{ value: '', label: t('list.allJournals') }, ...(journals.data ?? []).map((j) => ({ value: j.id, label: `${j.code} · ${j.label}` }))]} />
<Select aria-label={t('list.period')} value={search.period ?? ''} onValueChange={(v) => set({ period: v || undefined })}
options={[{ value: '', label: t('list.allPeriods') }, ...periodOptions]} />
</div>
<DataTable<JournalEntryHeader>
loading={list.isPending}
data={rows}
onRowClick={(r) => set({ entry: r.id })}
empty={<EmptyState title={t('list.empty')} />}
columns={[
{ id: 'no', header: t('list.cols.number'), cell: ({ row }) => <code>{row.original.entry_no}</code> },
{ id: 'date', header: t('list.cols.date'), cell: ({ row }) => formatDate(row.original.entry_date, locale) },
{ id: 'journal', header: t('list.cols.journal'), cell: ({ row }) => jmap.get(row.original.journal_id) ?? '—' },
{ id: 'desc', header: t('list.cols.description'), cell: ({ row }) => row.original.description },
{ id: 'kind', header: t('list.cols.kind'), cell: ({ row }) => <Tag tone="neutral">{t(`semantic.${row.original.semantic}`)}</Tag> },
]}
/>
{list.hasNextPage && <LoadMore onClick={() => void list.fetchNextPage()} loading={list.isFetchingNextPage} label={t('list.loadMore')} />}
{search.entry && (
<EntryDrawer key={search.entry} id={search.entry} onClose={() => set({ entry: undefined })} onOpen={(id) => set({ entry: id })} />
)}
</div>
);
}
web/src/features/accounting/entries/ManualJournalPage.tsx
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { Controller, useFieldArray, useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Trash2 } from 'lucide-react';
import { Button, Combobox, DatePicker, Field, IconButton, Input, PageHeader, Select, Textarea, toast } from '@/components/ui';
import { MoneyInput } from '@/components/money';
import { useMe } from '@/app/auth';
import { applyProblemToForm, problemMessage } from '@/lib/api/problem';
import { formatMoney, isMoneyInput, isZeroMoney, compareMoney, subtractMoney } from '@/lib/money';
import { todayISO } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import type { Account, Schemas } from '@/lib/api/types';
import { accountOptions, useAccounts, useJournals } from '../queries';
import { useAnalyticAxes, usePostEntry } from './api';
import { journalDifference } from './difference';
const SUBLEDGER = new Set(['ap', 'ar', 'employee_advances', 'petty_cash_ap']);
const takesManualLines = (a: Account) =>
a.postable && a.active && a.owner_module === null && !(a.control_kind && SUBLEDGER.has(a.control_kind));
const money = z.string().refine((s) => s === '' || isMoneyInput(s));
const schema = z.object({
journal_id: z.string().min(1),
entry_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
description: z.string().trim().min(1),
lines: z.array(z.object({
account_id: z.string().min(1), debit: money, credit: money, memo: z.string(),
analytic: z.record(z.string(), z.string()),
})).min(2),
});
type Values = z.infer<typeof schema>;
const blankLine = () => ({ account_id: '', debit: '', credit: '', memo: '', analytic: {} as Record<string, string> });
export function ManualJournalPage() {
const { t } = useTranslation('journal');
const [locale] = useLocale();
const navigate = useNavigate();
const me = useMe();
const accounts = useAccounts();
const journals = useJournals();
const axes = useAnalyticAxes();
const post = usePostEntry();
const [refusal, setRefusal] = useState<string | null>(null);
const form = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: { journal_id: '', entry_date: todayISO(), description: '', lines: [blankLine(), blankLine()] },
});
const lines = useFieldArray({ control: form.control, name: 'lines' });
const watched = useWatch({ control: form.control, name: 'lines' });
const journalId = useWatch({ control: form.control, name: 'journal_id' });
const journal = journals.data?.find((j) => j.id === journalId);
const currency = journal?.currency ?? me.data?.organization.base_currency ?? 'MYR';
const byId = new Map((accounts.data ?? []).map((a) => [a.id, a]));
const options = accountOptions(accounts.data, takesManualLines);
const diff = journalDifference(watched.map((l) => ({ debit: l.debit, credit: l.credit })));
const diffText = isZeroMoney(diff)
? t('form.balanced')
: compareMoney(diff, '0') > 0
? t('form.debitsOver', { amount: formatMoney(diff, currency, locale) })
: t('form.creditsOver', { amount: formatMoney(subtractMoney('0', diff), currency, locale) });
const onSubmit = form.handleSubmit((v) => {
setRefusal(null);
const body: Schemas['PostJournalEntryRequest'] = {
journal_id: v.journal_id, entry_date: v.entry_date, description: v.description,
lines: v.lines.map((l) => {
const out: Schemas['PostJournalLine'] = { account_id: l.account_id };
if (l.debit !== '' && !isZeroMoney(l.debit)) out.debit = l.debit;
if (l.credit !== '' && !isZeroMoney(l.credit)) out.credit = l.credit;
if (l.memo.trim() !== '') out.memo = l.memo;
const dist = Object.values(l.analytic).filter(Boolean).map((id) => ({ analytic_account_id: id, percentage: '100' }));
if (dist.length > 0) out.distribution = dist;
return out;
}),
};
post.mutate(body, {
onSuccess: (e) => {
toast.success(t('form.posted_toast', { number: e.entry_no }));
void navigate({ to: '/accounting/entries', search: { entry: e.id } });
},
onError: (e) => { if (!applyProblemToForm(form, e)) setRefusal(problemMessage(e, t)); },
});
});
return (
<form className="grid gap-4" onSubmit={onSubmit} noValidate>
<PageHeader title={t('form.title')} />
<div className="grid gap-3 sm:grid-cols-3">
<Field label={t('form.journal')} htmlFor="mj-journal" error={form.formState.errors.journal_id ? t('form.required') : undefined}>
<Controller control={form.control} name="journal_id" render={({ field }) => (
<Select id="mj-journal" value={field.value} onValueChange={field.onChange}
options={(journals.data ?? []).filter((j) => j.active).map((j) => ({ value: j.id, label: `${j.code} · ${j.label}` }))} />
)} />
</Field>
<Field label={t('form.date')} htmlFor="mj-date">
<Controller control={form.control} name="entry_date" render={({ field }) => (
<DatePicker id="mj-date" value={field.value} onChange={field.onChange} />
)} />
</Field>
<Field label={t('form.currency')} hint={t('form.currencyNote')} htmlFor="mj-currency">
<Input id="mj-currency" value={currency} readOnly />
</Field>
</div>
<Field label={t('form.description')} hint={locale === 'zh' ? t('form.recordHint') : undefined} htmlFor="mj-description"
error={form.formState.errors.description ? t('form.required') : undefined}>
<Textarea id="mj-description" rows={2} {...form.register('description')} />
</Field>
<section aria-labelledby="mj-lines" className="grid gap-2">
<h2 id="mj-lines" className="text-sm font-medium">{t('form.lines')}</h2>
<p className="text-xs text-muted">{t('form.accountsNote')}</p>
{lines.fields.map((f, i) => {
const acc = byId.get(watched[i]?.account_id ?? '');
const applicable = (axes.data ?? []).filter((ax) => ax.active && acc !== undefined && ax.applies_to.includes(acc.type));
return (
<fieldset key={f.id} className="grid gap-2 rounded-md border border-outline p-2 md:grid-cols-[minmax(0,2fr)_9rem_9rem_minmax(0,1fr)_auto] md:items-end">
<legend className="sr-only">{t('form.lineN', { n: i + 1 })}</legend>
<Field label={t('form.account')} htmlFor={`mj-${i}-account`}>
<Controller control={form.control} name={`lines.${i}.account_id`} render={({ field }) => (
<Combobox id={`mj-${i}-account`} value={field.value} onValueChange={field.onChange} options={options} />
)} />
</Field>
<Field label={t('form.debit')} htmlFor={`mj-${i}-debit`}>
<Controller control={form.control} name={`lines.${i}.debit`} render={({ field }) => (
<MoneyInput id={`mj-${i}-debit`} currency={currency} value={field.value} onChange={field.onChange} />
)} />
</Field>
<Field label={t('form.credit')} htmlFor={`mj-${i}-credit`}>
<Controller control={form.control} name={`lines.${i}.credit`} render={({ field }) => (
<MoneyInput id={`mj-${i}-credit`} currency={currency} value={field.value} onChange={field.onChange} />
)} />
</Field>
<Field label={t('form.memo')} htmlFor={`mj-${i}-memo`}>
<Input id={`mj-${i}-memo`} {...form.register(`lines.${i}.memo`)} />
</Field>
<IconButton variant="ghost" aria-label={t('form.removeLine', { n: i + 1 })} disabled={lines.fields.length <= 2}
onClick={() => lines.remove(i)}><Trash2 /></IconButton>
{applicable.map((ax) => (
<Field key={ax.id} label={t('form.analytic', { axis: ax.label })} htmlFor={`mj-${i}-ax-${ax.id}`}>
<Controller control={form.control} name={`lines.${i}.analytic.${ax.id}`} render={({ field }) => (
<Select id={`mj-${i}-ax-${ax.id}`} value={field.value ?? ''} onValueChange={field.onChange}
options={[{ value: '', label: t('form.analyticNone') },
...ax.accounts.filter((a) => a.active).map((a) => ({ value: a.id, label: `${a.code} · ${a.name}` }))]} />
)} />
</Field>
))}
</fieldset>
);
})}
<Button size="sm" variant="outline" onClick={() => lines.append(blankLine())}>{t('form.addLine')}</Button>
</section>
<div role="status" aria-label={t('form.difference')} aria-live="polite"
className="flex flex-wrap items-baseline gap-2 rounded-md border border-outline p-3">
<span className="text-sm text-muted">{t('form.difference')}:</span>
<span className={isZeroMoney(diff) ? 'font-medium text-success' : 'font-medium'}>{diffText}</span>
<span className="text-xs text-muted">{t('form.differenceNote')}</span>
</div>
{refusal && (
<div role="alert" className="rounded-md border border-danger p-3 text-sm text-danger">
<span className="font-medium">{t('form.refused')}: </span>{refusal}
</div>
)}
<div className="flex gap-2">
<Button type="submit" variant="primary" loading={post.isPending}>{t('form.post')}</Button>
<Button type="button" variant="ghost" onClick={() => navigate({ to: '/accounting/entries' })}>{t('form.cancel')}</Button>
</div>
</form>
);
}

An analytic distribution is sent as one analytic account at "100" per applicable axis. That covers every required axis. Splitting a line across two analytic accounts on one axis is not built. See Part D notes.

Verify: problemMessage returns the problem’s detail when there is one (so the alert reads the entry does not balance). If it returns only title, render e instanceof ProblemError ? e.detail ?? e.title : problemMessage(e, t) here.

web/src/features/accounting/entries/routes.tsx
import { createRoute } from '@tanstack/react-router';
import { z } from 'zod';
import { appRoute } from '@/app/layouts';
import { RequirePermission } from '@/app/auth';
import { EntriesPage } from './EntriesPage';
import { ManualJournalPage } from './ManualJournalPage';
const search = z.object({
journal: z.string().uuid().optional(),
period: z.string().uuid().optional(),
entry: z.string().uuid().optional(),
});
const entriesRoute = createRoute({
getParentRoute: () => appRoute,
path: '/accounting/entries',
validateSearch: (s: Record<string, unknown>) => search.catch({}).parse(s),
component: function Entries() {
const s = entriesRoute.useSearch();
return <RequirePermission permission="accounting.ledger.read"><EntriesPage search={s} /></RequirePermission>;
},
});
const newRoute = createRoute({
getParentRoute: () => appRoute,
path: '/accounting/entries/new',
component: () => (
<RequirePermission permission={['accounting.journal.post']}><ManualJournalPage /></RequirePermission>
),
});
export const routes = [entriesRoute, newRoute];

Append to app/routes.ts: import { routes as entriesRoutes } from '@/features/accounting/entries/routes'; and ...entriesRoutes,. Nav (import BookOpen):

{ group: 'manage', to: '/accounting/entries', labelKey: 'journal:nav', icon: BookOpen, permission: 'accounting.ledger.read' },
  • Step 8: Run, and watch the difference fail once
Terminal window
pnpm -C web test -- src/features/accounting/entries

Expected: PASS, 9 tests. Then swap the two if lines’ operations in difference.ts (subtract debits, add credits) and re-run. difference.test.ts and the “Credits exceed” assertion must fail. Restore.

  • Step 9: Typecheck, lint, drift
Terminal window
pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test -- src/drift
  • Step 10: Runtime check (as the Owner)
  1. Sidebar → Journal entries. Expect the seeded or earlier postings, newest first. If there are more than 50, Load more appends the next 50.
  2. Filter by the MSC journal and the current period. The URL carries ?journal=…&period=…. Reload: the same view comes back (P7).
  3. New manual journal: MSC, today, description Test accrual, line 1 = an expense account debit 100.10, line 2 = the bank account credit 100. Expect Debits exceed credits by RM 0.10. Post entry. Expect the red alert with the server’s “does not balance” detail, and no navigation.
  4. Change the credit to 100.10. Expect Balanced. Post. The drawer opens on the new entry, and the toast reads Posted MSC-….
  5. In the drawer, Reverse → reason Test reversal → confirm. The drawer switches to the reversal entry. Reopen the original: it shows Reversed by …, and there is no Reverse button.
  6. Open an accrual posted by a claim (from Task 12’s flow or the seed). There is no Reverse button, and the “through its source document” note shows.
  7. In zh, type a Chinese description and post. Expect the server’s CJK refusal in the alert, and the record hint under Description. At 390 the line fieldsets stack. In dark mode the difference and alert stay legible.
  • Step 11: Commit
Terminal window
git add web/src/features/accounting/entries web/src/lib/i18n/locales/*/journal.json web/src/app/routes.ts web/src/app/shell/nav.ts web/src/lib/api/types.ts
git commit -m "$(cat <<'EOF'
web: journal entries, manual journal with live difference, reversal
Entry list with journal/period filters in the URL and offset paging, an
entry drawer with lines, FX and analytics, a manual journal whose running
difference is folded from subtractMoney alone (F16) while the server's
balance check decides, and reversal of manual entries with a reason.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"

Spec §5.7 item 5. Fiscal years with their twelve periods and each period’s status, with close and reopen per period, year close, and opening the next year. The close dialog runs the reconciliation set first and shows every check as pass or fail with its findings. Close is enabled only when every check passed. The server re-runs the same set inside the close and still decides.

Depends on Task 22 (useFiscalYears, accountingKeys).

What the API gives:

  • GET /accounting/fiscal-years → { years: FiscalYear[] }, oldest first. FiscalYear: id, name ("FY2026"), starts_on, ends_on, end_year, status (open|closed), closed_at, closed_by, periods[]. FiscalPeriod: id, fiscal_year_id, seq (1–12), starts_on, ends_on, status (open|closed|locked), closed_at, closed_by.
  • POST /accounting/fiscal-years body { starts_on }. It must be the day after the latest year ends, and no later than the 28th of its month. 409 or 422 otherwise.
  • GET /accounting/reconciliation → ReconciliationResult { passed, checks: [{ key, passed, findings: [{ check, ref, detail }] }] }. It runs every registered check over the whole ledger, and stores and changes nothing. Keys in the code today: trial_balance, entry_balance, entry_period, reversal_mirror, audit_chain (accounting); float_integrity, float_commitment, float_ledger (petty cash); expense_claim_accrual, expense_claim_ap (claims); advance_control, advance_closed_nets_zero (advances). An unknown key is shown as its raw key.
  • POST /accounting/fiscal-periods/{id}/close → 200 CloseResult { close_log_id, closing_entry_id, reconciliation }. 409 when the period is not open or the year is closed, or when reconciliation failed. In that case the body is a ReconciliationProblem carrying reconciliation.
  • POST /accounting/fiscal-periods/{id}/reopen body { reason } → the period. 409 when it is open, locked, or its year is closed.
  • POST /accounting/fiscal-years/{id}/close → CloseResult (closing_entry_id is the year-end entry, or null when income and expense net to zero). 409 when the year is closed, an earlier period is open (the detail lists them), the last period is not open, or reconciliation failed. 422 when no account is designated retained earnings.
  • Reads need accounting.ledger.read. Every write needs accounting.period.close.

ProblemError (registry) keeps status, type, title, detail, code, errors, requestId, but not extension members such as reconciliation. On a 409 the dialog therefore re-runs GET /accounting/reconciliation and shows the problem’s detail, instead of reading the extension. The result is the same: the list the user sees is the set the server just failed on, re-run a moment later.

Files:

  • Create: web/src/features/accounting/periods/api.ts
  • Create: web/src/features/accounting/periods/ReconciliationChecklist.tsx
  • Create: web/src/features/accounting/periods/CloseDialog.tsx
  • Create: web/src/features/accounting/periods/OpenYearDialog.tsx
  • Create: web/src/features/accounting/periods/PeriodsPage.tsx, PeriodsPage.test.tsx
  • Create: web/src/features/accounting/periods/__fixtures__/years.ts
  • Create: web/src/features/accounting/periods/routes.tsx
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/periods.json
  • Append: web/src/app/routes.ts, web/src/app/shell/nav.ts, web/src/lib/api/types.ts

Interfaces:

  • Consumes: useFiscalYears, accountingKeys (T22); api, unwrap, ProblemError, problemMessage (T6); formatDate, formatDateTime (T6); useLocale (T6); Button, Card, Dialog, DatePicker, Field, Tag, PageHeader, EmptyState, Skeleton, Spinner, toast (T7); useCan, RequirePermission, navItems (T8); ReasonDialog, useMemberNames (T9); appRoute, featureRoutes, renderWithProviders, server.

  • Produces: routes (/accounting/periods); useReconciliation(enabled), useClosePeriod, useCloseYear, useReopenPeriod, useOpenYear; ReconciliationChecklist (reusable). Type aliases: ReconciliationResult, CloseResult.

  • Step 1: Aliases

export type ReconciliationResult = Schemas['ReconciliationResult'];
export type CloseResult = Schemas['CloseResult'];
  • Step 2: Queries — api.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
import { accountingKeys } from '../queries';
export const reconciliationKey = ['accounting', 'reconciliation'] as const;
/** Run on demand (the dialog opening), never cached as fresh: a close must see now. */
export function useReconciliation(enabled: boolean) {
return useQuery({
queryKey: reconciliationKey,
enabled,
staleTime: 0,
gcTime: 0,
queryFn: async () => unwrap(await api.GET('/accounting/reconciliation')),
});
}
function useLedgerMutation<V, R>(fn: (v: V) => Promise<R>) {
const qc = useQueryClient();
return useMutation({
mutationFn: fn,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: accountingKeys.fiscalYears });
void qc.invalidateQueries({ queryKey: ['accounting', 'entries'] });
},
});
}
export const useClosePeriod = () =>
useLedgerMutation(async (id: string) => unwrap(await api.POST('/accounting/fiscal-periods/{id}/close', { params: { path: { id } } })));
export const useCloseYear = () =>
useLedgerMutation(async (id: string) => unwrap(await api.POST('/accounting/fiscal-years/{id}/close', { params: { path: { id } } })));
export const useReopenPeriod = () =>
useLedgerMutation(async (v: { id: string; reason: string }) =>
unwrap(await api.POST('/accounting/fiscal-periods/{id}/reopen', { params: { path: { id: v.id } }, body: { reason: v.reason } })));
export const useOpenYear = () =>
useLedgerMutation(async (starts_on: string) => unwrap(await api.POST('/accounting/fiscal-years', { body: { starts_on } })));
  • Step 3: i18n bundles

en/periods.json:

{
"nav": "Fiscal periods",
"page": {
"title": "Fiscal periods",
"subtitle": "Close a month once its books are complete; postings dated in a closed period are refused. Closing the year moves its result to retained earnings and locks every period.",
"openYear": "Open next fiscal year",
"empty": "No fiscal years yet."
},
"year": {
"range": "{{from}} – {{to}}",
"open": "Open",
"closed": "Closed",
"closedOn": "Closed {{when}} by {{who}}",
"close": "Close year"
},
"period": {
"name": "Period {{seq}}",
"range": "{{from}} – {{to}}",
"status": { "open": "Open", "closed": "Closed", "locked": "Locked" },
"close": "Close",
"reopen": "Reopen",
"reopenTitle": "Reopen period {{seq}} of {{year}}?",
"reopened": "Period reopened",
"closedOn": "Closed {{when}}"
},
"close": {
"periodTitle": "Close period {{seq}} of {{year}}",
"yearTitle": "Close {{year}}",
"yearWarning": "This posts the year-end closing entry, locks all twelve periods and cannot be undone.",
"running": "Running the reconciliation checks…",
"allPassed": "All {{count}} checks passed.",
"someFailed": "{{failed}} of {{count}} checks failed. Fix what they found before closing.",
"confirm": "Close now",
"closedPeriod": "Period closed",
"closedYear": "Year closed",
"closingEntry": "Closing entry posted",
"refused": "The close was refused",
"rerun": "Run the checks again",
"cancel": "Cancel"
},
"checks": {
"pass": "Passed",
"fail": "Failed",
"findings": "{{count}} finding",
"findings_other": "{{count}} findings",
"names": {
"trial_balance": "The trial balance balances",
"entry_balance": "Every entry balances",
"entry_period": "Every entry sits in the period of its date",
"reversal_mirror": "Every reversal mirrors its original",
"audit_chain": "The audit trail is unbroken",
"float_integrity": "Float balances match their movements",
"float_commitment": "Float commitments match pending spends",
"float_ledger": "Float balances match the ledger",
"expense_claim_accrual": "Every accrued claim has its accrual",
"expense_claim_ap": "Accounts payable matches unpaid claims",
"advance_control": "Employee advances match open advances",
"advance_closed_nets_zero": "Closed advances net to zero"
}
},
"openYear": {
"title": "Open the next fiscal year",
"startsOn": "Starts on",
"hint": "The day after the latest year ends. Twelve monthly periods are created.",
"confirm": "Open year",
"opened": "{{year}} opened"
}
}

ms/periods.json:

{
"nav": "Tempoh fiskal",
"page": {
"title": "Tempoh fiskal",
"subtitle": "Tutup sesuatu bulan apabila bukunya lengkap; pengeposan bertarikh dalam tempoh yang ditutup akan ditolak. Menutup tahun memindahkan keputusannya ke pendapatan tertahan dan mengunci setiap tempoh.",
"openYear": "Buka tahun fiskal seterusnya",
"empty": "Belum ada tahun fiskal."
},
"year": {
"range": "{{from}} – {{to}}",
"open": "Dibuka",
"closed": "Ditutup",
"closedOn": "Ditutup {{when}} oleh {{who}}",
"close": "Tutup tahun"
},
"period": {
"name": "Tempoh {{seq}}",
"range": "{{from}} – {{to}}",
"status": { "open": "Dibuka", "closed": "Ditutup", "locked": "Dikunci" },
"close": "Tutup",
"reopen": "Buka semula",
"reopenTitle": "Buka semula tempoh {{seq}} bagi {{year}}?",
"reopened": "Tempoh dibuka semula",
"closedOn": "Ditutup {{when}}"
},
"close": {
"periodTitle": "Tutup tempoh {{seq}} bagi {{year}}",
"yearTitle": "Tutup {{year}}",
"yearWarning": "Ini mengepos catatan penutupan akhir tahun, mengunci kesemua dua belas tempoh dan tidak boleh dibatalkan.",
"running": "Menjalankan semakan penyesuaian…",
"allPassed": "Kesemua {{count}} semakan lulus.",
"someFailed": "{{failed}} daripada {{count}} semakan gagal. Betulkan apa yang ditemui sebelum menutup.",
"confirm": "Tutup sekarang",
"closedPeriod": "Tempoh ditutup",
"closedYear": "Tahun ditutup",
"closingEntry": "Catatan penutupan dipos",
"refused": "Penutupan ditolak",
"rerun": "Jalankan semakan semula",
"cancel": "Batal"
},
"checks": {
"pass": "Lulus",
"fail": "Gagal",
"findings": "{{count}} penemuan",
"findings_other": "{{count}} penemuan",
"names": {
"trial_balance": "Imbangan duga seimbang",
"entry_balance": "Setiap catatan seimbang",
"entry_period": "Setiap catatan berada dalam tempoh tarikhnya",
"reversal_mirror": "Setiap pembalikan mencerminkan catatan asalnya",
"audit_chain": "Jejak audit tidak terputus",
"float_integrity": "Baki apungan sepadan dengan pergerakannya",
"float_commitment": "Komitmen apungan sepadan dengan perbelanjaan yang belum selesai",
"float_ledger": "Baki apungan sepadan dengan lejar",
"expense_claim_accrual": "Setiap tuntutan yang diakru mempunyai akruannya",
"expense_claim_ap": "Akaun belum bayar sepadan dengan tuntutan yang belum dibayar",
"advance_control": "Pendahuluan pekerja sepadan dengan pendahuluan terbuka",
"advance_closed_nets_zero": "Pendahuluan yang ditutup berbaki sifar"
}
},
"openYear": {
"title": "Buka tahun fiskal seterusnya",
"startsOn": "Bermula pada",
"hint": "Hari selepas tahun terkini berakhir. Dua belas tempoh bulanan akan dicipta.",
"confirm": "Buka tahun",
"opened": "{{year}} dibuka"
}
}

zh/periods.json:

{
"nav": "会计期间",
"page": {
"title": "会计期间",
"subtitle": "账目完整后即可关闭该月;日期落在已关闭期间的记账会被拒绝。年终结账会将当年损益转入留存收益,并锁定所有期间。",
"openYear": "开启下一会计年度",
"empty": "暂无会计年度。"
},
"year": {
"range": "{{from}} – {{to}}",
"open": "进行中",
"closed": "已结账",
"closedOn": "{{who}} 于 {{when}} 结账",
"close": "年终结账"
},
"period": {
"name": "第 {{seq}} 期",
"range": "{{from}} – {{to}}",
"status": { "open": "开放", "closed": "已关闭", "locked": "已锁定" },
"close": "关闭",
"reopen": "重新开放",
"reopenTitle": "重新开放 {{year}} 第 {{seq}} 期?",
"reopened": "期间已重新开放",
"closedOn": "于 {{when}} 关闭"
},
"close": {
"periodTitle": "关闭 {{year}} 第 {{seq}} 期",
"yearTitle": "{{year}} 年终结账",
"yearWarning": "此操作将记录年终结账分录、锁定全部十二个期间,且无法撤销。",
"running": "正在运行对账检查…",
"allPassed": "全部 {{count}} 项检查均已通过。",
"someFailed": "{{count}} 项检查中有 {{failed}} 项未通过。请先处理发现的问题再关闭。",
"confirm": "立即关闭",
"closedPeriod": "期间已关闭",
"closedYear": "年度已结账",
"closingEntry": "已记录结账分录",
"refused": "关闭被拒绝",
"rerun": "重新运行检查",
"cancel": "取消"
},
"checks": {
"pass": "通过",
"fail": "未通过",
"findings": "{{count}} 项发现",
"findings_other": "{{count}} 项发现",
"names": {
"trial_balance": "试算表平衡",
"entry_balance": "每笔分录借贷平衡",
"entry_period": "每笔分录都位于其日期所属期间",
"reversal_mirror": "每笔冲销都与原分录镜像一致",
"audit_chain": "审计轨迹完整无断",
"float_integrity": "备用金余额与其流水一致",
"float_commitment": "备用金占用额与待处理支出一致",
"float_ledger": "备用金余额与总账一致",
"expense_claim_accrual": "每笔已计提的报销都有对应计提",
"expense_claim_ap": "应付账款与未付报销一致",
"advance_control": "员工预支款与未结预支款一致",
"advance_closed_nets_zero": "已结清的预支款净额为零"
}
},
"openYear": {
"title": "开启下一会计年度",
"startsOn": "开始日期",
"hint": "最近一个年度结束后的第二天。将自动创建十二个月度期间。",
"confirm": "开启年度",
"opened": "已开启 {{year}}"
}
}
  • Step 4: Fixture and failing test
web/src/features/accounting/periods/__fixtures__/years.ts
import type { FiscalYear, ReconciliationResult } from '@/lib/api/types';
const month = (seq: number, status: 'open' | 'closed' | 'locked') => {
const mm = String(seq).padStart(2, '0');
const last = ['31', '28', '31', '30', '31', '30', '31', '31', '30', '31', '30', '31'][seq - 1];
return { id: `p-2026-${mm}`, fiscal_year_id: 'fy2026', seq, starts_on: `2026-${mm}-01`, ends_on: `2026-${mm}-${last}`,
status, closed_at: status === 'open' ? null : '2026-09-05T02:00:00Z', closed_by: status === 'open' ? null : 'm-owner' };
};
export const fy2026: FiscalYear = {
id: 'fy2026', name: 'FY2026', starts_on: '2026-01-01', ends_on: '2026-12-31', end_year: 2026, status: 'open',
closed_at: null, closed_by: null,
periods: Array.from({ length: 12 }, (_, i) => month(i + 1, i < 7 ? 'closed' : 'open')),
};
export const reconFailing: ReconciliationResult = {
passed: false,
checks: [
{ key: 'audit_chain', passed: true, findings: [] },
{ key: 'expense_claim_ap', passed: false, findings: [
{ check: 'expense_claim_ap', ref: '00000000-0000-4000-8000-00000000c101', detail: 'claim EC-2026-0012 owes 120.0000 but AP holds 100.0000' },
] },
],
};
export const reconPassing: ReconciliationResult = {
passed: true,
checks: reconFailing.checks.map((c) => ({ ...c, passed: true, findings: [] })),
};
web/src/features/accounting/periods/PeriodsPage.test.tsx
import { describe, expect, it } from 'vitest';
import { http, HttpResponse } from 'msw';
import { screen, waitFor, within } from '@testing-library/react';
import { server } from '@/test/msw/server';
import { renderWithProviders } from '@/test/render';
import type { ReconciliationResult } from '@/lib/api/types';
import { fy2026, reconFailing, reconPassing } from './__fixtures__/years';
import { PeriodsPage } from './PeriodsPage';
const CLOSE = ['accounting.ledger.read', 'accounting.period.close'];
function serve(recon: () => ReconciliationResult) {
server.use(
http.get('/v1/accounting/fiscal-years', () => HttpResponse.json({ years: [fy2026] })),
http.get('/v1/accounting/reconciliation', () => HttpResponse.json(recon())),
http.get('/v1/org/members', () => HttpResponse.json({ members: [] })),
);
}
const august = () => screen.getByRole('row', { name: /Period 8/ });
describe('PeriodsPage', () => {
it('runs the checks before closing, and keeps Close disabled while one fails', async () => {
serve(() => reconFailing);
const { user } = renderWithProviders(<PeriodsPage />, { permissions: CLOSE });
await user.click(within(await screen.findByRole('row', { name: /Period 8/ })).getByRole('button', { name: 'Close' }));
const dlg = await screen.findByRole('dialog', { name: 'Close period 8 of FY2026' });
// presence: a passing check is listed as passed…
expect(await within(dlg).findByText('The audit trail is unbroken')).toBeInTheDocument();
expect(within(within(dlg).getByRole('listitem', { name: /audit trail/ })).getByText('Passed')).toBeInTheDocument();
// …and the failing one with its finding
const ap = within(dlg).getByRole('listitem', { name: /Accounts payable matches/ });
expect(within(ap).getByText('Failed')).toBeInTheDocument();
expect(within(ap).getByText(/EC-2026-0012/)).toBeInTheDocument();
expect(within(dlg).getByRole('button', { name: 'Close now' })).toBeDisabled();
});
it('closes when every check passes', async () => {
serve(() => reconPassing);
const closed: string[] = [];
server.use(http.post('/v1/accounting/fiscal-periods/:id/close', ({ params }) => {
closed.push(String(params.id));
return HttpResponse.json({ close_log_id: 'cl1', closing_entry_id: null, reconciliation: reconPassing });
}));
const { user } = renderWithProviders(<PeriodsPage />, { permissions: CLOSE });
await user.click(within(await screen.findByRole('row', { name: /Period 8/ })).getByRole('button', { name: 'Close' }));
const dlg = await screen.findByRole('dialog');
await within(dlg).findByText('All 2 checks passed.');
await user.click(within(dlg).getByRole('button', { name: 'Close now' }));
await waitFor(() => expect(closed).toEqual(['p-2026-08']));
});
it('on a refused close, shows the detail and re-runs the checks', async () => {
let calls = 0;
serve(() => { calls += 1; return calls === 1 ? reconPassing : reconFailing; });
server.use(http.post('/v1/accounting/fiscal-periods/:id/close', () => HttpResponse.json(
{ type: 'https://errors.example/conflict', title: 'Conflict', status: 409, detail: 'reconciliation failed: expense_claim_ap (1)', reconciliation: reconFailing },
{ status: 409 })));
const { user } = renderWithProviders(<PeriodsPage />, { permissions: CLOSE });
await user.click(within(await screen.findByRole('row', { name: /Period 8/ })).getByRole('button', { name: 'Close' }));
const dlg = await screen.findByRole('dialog');
await within(dlg).findByText('All 2 checks passed.');
await user.click(within(dlg).getByRole('button', { name: 'Close now' }));
expect(await within(dlg).findByRole('alert')).toHaveTextContent('reconciliation failed: expense_claim_ap (1)');
expect(await within(dlg).findByText('1 of 2 checks failed. Fix what they found before closing.')).toBeInTheDocument();
expect(calls).toBe(2);
});
it('offers Reopen on a closed period and none of the writes to a reader', async () => {
serve(() => reconPassing);
renderWithProviders(<PeriodsPage />, { permissions: ['accounting.ledger.read'] });
// presence: the periods rendered, including a closed one
const july = await screen.findByRole('row', { name: /Period 7/ });
expect(within(july).getByText('Closed')).toBeInTheDocument();
// absence: a reader sees no Reopen / Close / Close year
expect(within(july).queryByRole('button', { name: 'Reopen' })).toBeNull();
expect(within(august()).queryByRole('button', { name: 'Close' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Close year' })).toBeNull();
});
});

Run pnpm -C web test -- src/features/accounting/periods. Expected FAIL: cannot resolve ./PeriodsPage.

  • Step 5: Implement
web/src/features/accounting/periods/ReconciliationChecklist.tsx
import { useTranslation } from 'react-i18next';
import { CheckCircle2, XCircle } from 'lucide-react';
import type { ReconciliationResult } from '@/lib/api/types';
export function ReconciliationChecklist({ result }: { result: ReconciliationResult }) {
const { t } = useTranslation('periods');
const failed = result.checks.filter((c) => !c.passed).length;
return (
<div className="grid gap-2">
<p className={failed === 0 ? 'text-sm text-success' : 'text-sm text-danger'}>
{failed === 0 ? t('close.allPassed', { count: result.checks.length }) : t('close.someFailed', { failed, count: result.checks.length })}
</p>
<ul className="grid gap-1">
{result.checks.map((c) => {
const name = t(`checks.names.${c.key}`, { defaultValue: c.key });
return (
<li key={c.key} aria-label={name} className="rounded-md border border-outline p-2">
<div className="flex items-center gap-2 text-sm">
{c.passed ? <CheckCircle2 aria-hidden className="size-4 text-success" /> : <XCircle aria-hidden className="size-4 text-danger" />}
<span className="flex-1">{name}</span>
<span className={c.passed ? 'text-success' : 'font-medium text-danger'}>{c.passed ? t('checks.pass') : t('checks.fail')}</span>
</div>
{c.findings.length > 0 && (
<details className="mt-1 text-xs" open={c.findings.length <= 3}>
<summary>{t('checks.findings', { count: c.findings.length })}</summary>
<ul className="mt-1 grid gap-0.5">
{c.findings.map((f) => <li key={`${f.ref}-${f.detail}`}><code className="text-muted">{f.ref.slice(-8)}</code> {f.detail}</li>)}
</ul>
</details>
)}
</li>
);
})}
</ul>
</div>
);
}
web/src/features/accounting/periods/CloseDialog.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dialog, Spinner, toast } from '@/components/ui';
import { ProblemError, problemMessage } from '@/lib/api/problem';
import { useCloseYear, useClosePeriod, useReconciliation } from './api';
import { ReconciliationChecklist } from './ReconciliationChecklist';
export type CloseTarget = { kind: 'period'; id: string; seq: number; year: string } | { kind: 'year'; id: string; year: string };
export function CloseDialog({ target, onClose }: { target: CloseTarget; onClose(): void }) {
const { t } = useTranslation('periods');
const recon = useReconciliation(true);
const closePeriod = useClosePeriod();
const closeYear = useCloseYear();
const [refusal, setRefusal] = useState<string | null>(null);
const pending = closePeriod.isPending || closeYear.isPending;
const confirm = () => {
setRefusal(null);
const opts = {
onSuccess: (r: { closing_entry_id: string | null }) => {
toast.success(target.kind === 'period' ? t('close.closedPeriod') : t('close.closedYear'));
if (r.closing_entry_id) toast.info(t('close.closingEntry'));
onClose();
},
onError: (e: unknown) => {
setRefusal(e instanceof ProblemError ? e.detail ?? e.title : problemMessage(e, t));
if (e instanceof ProblemError && e.status === 409) void recon.refetch();
},
};
if (target.kind === 'period') closePeriod.mutate(target.id, opts);
else closeYear.mutate(target.id, opts);
};
return (
<Dialog open onOpenChange={(o) => !o && onClose()}
title={target.kind === 'period' ? t('close.periodTitle', { seq: target.seq, year: target.year }) : t('close.yearTitle', { year: target.year })}
description={target.kind === 'year' ? t('close.yearWarning') : undefined}
footer={
<>
<Button variant="ghost" onClick={onClose}>{t('close.cancel')}</Button>
<Button variant="outline" onClick={() => void recon.refetch()} disabled={recon.isFetching}>{t('close.rerun')}</Button>
<Button variant={target.kind === 'year' ? 'danger' : 'primary'} loading={pending}
disabled={recon.isFetching || recon.data?.passed !== true} onClick={confirm}>{t('close.confirm')}</Button>
</>
}>
{refusal && (
<div role="alert" className="mb-3 rounded-md border border-danger p-2 text-sm text-danger">
<span className="font-medium">{t('close.refused')}: </span>{refusal}
</div>
)}
{recon.isFetching && !recon.data && (
<p className="flex items-center gap-2 text-sm"><Spinner /> {t('close.running')}</p>
)}
{recon.error && <p role="alert" className="text-sm text-danger">{problemMessage(recon.error, t)}</p>}
{recon.data && <ReconciliationChecklist result={recon.data} />}
</Dialog>
);
}
web/src/features/accounting/periods/OpenYearDialog.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, DatePicker, Dialog, Field, toast } from '@/components/ui';
import { problemMessage } from '@/lib/api/problem';
import { useOpenYear } from './api';
/** The day after an ISO date, in UTC arithmetic on the calendar date (not money). */
export function nextDay(iso: string): string {
const d = new Date(`${iso}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + 1);
return d.toISOString().slice(0, 10);
}
export function OpenYearDialog({ after, onClose }: { after: string | undefined; onClose(): void }) {
const { t } = useTranslation('periods');
const open = useOpenYear();
const [startsOn, setStartsOn] = useState(after ? nextDay(after) : '');
return (
<Dialog open onOpenChange={(o) => !o && onClose()} title={t('openYear.title')}
footer={
<>
<Button variant="ghost" onClick={onClose}>{t('close.cancel')}</Button>
<Button variant="primary" loading={open.isPending} disabled={!startsOn}
onClick={() => open.mutate(startsOn, {
onSuccess: (y) => { toast.success(t('openYear.opened', { year: y.name })); onClose(); },
onError: (e) => toast.error(problemMessage(e, t)),
})}>{t('openYear.confirm')}</Button>
</>
}>
<Field label={t('openYear.startsOn')} hint={t('openYear.hint')} htmlFor="fy-start">
<DatePicker id="fy-start" value={startsOn} onChange={setStartsOn} />
</Field>
</Dialog>
);
}
web/src/features/accounting/periods/PeriodsPage.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Card, EmptyState, PageHeader, Skeleton, Tag, toast } from '@/components/ui';
import { ReasonDialog, useMemberNames } from '@/components/workflow';
import { useCan } from '@/app/auth';
import { problemMessage } from '@/lib/api/problem';
import { formatDate, formatDateTime } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import type { FiscalPeriod, FiscalYear } from '@/lib/api/types';
import { useFiscalYears } from '../queries';
import { useReopenPeriod } from './api';
import { CloseDialog, type CloseTarget } from './CloseDialog';
import { OpenYearDialog } from './OpenYearDialog';
const TONE = { open: 'green', closed: 'neutral', locked: 'muted' } as const;
export function PeriodsPage() {
const { t } = useTranslation('periods');
const [locale] = useLocale();
const canClose = useCan('accounting.period.close');
const years = useFiscalYears();
const nameOf = useMemberNames();
const reopen = useReopenPeriod();
const [closing, setClosing] = useState<CloseTarget | null>(null);
const [reopening, setReopening] = useState<{ period: FiscalPeriod; year: FiscalYear } | null>(null);
const [opening, setOpening] = useState(false);
const list = [...(years.data ?? [])].reverse(); // newest year first on screen
const latestEnd = years.data?.at(-1)?.ends_on;
if (years.isPending) return <Skeleton className="h-96 w-full" />;
return (
<div className="grid gap-4">
<PageHeader title={t('page.title')} subtitle={t('page.subtitle')}
actions={canClose ? <Button variant="outline" onClick={() => setOpening(true)}>{t('page.openYear')}</Button> : undefined} />
{list.length === 0 && <EmptyState title={t('page.empty')} />}
{list.map((y) => (
<Card key={y.id} className="grid gap-3">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-medium">{y.name}</h2>
<span className="text-sm text-muted">{t('year.range', { from: formatDate(y.starts_on, locale), to: formatDate(y.ends_on, locale) })}</span>
<Tag tone={y.status === 'open' ? 'green' : 'muted'}>{y.status === 'open' ? t('year.open') : t('year.closed')}</Tag>
{y.closed_at && <span className="text-xs text-muted">{t('year.closedOn', { when: formatDateTime(y.closed_at, locale), who: nameOf(y.closed_by) })}</span>}
{canClose && y.status === 'open' && (
<Button className="ms-auto" size="sm" variant="danger-outline" onClick={() => setClosing({ kind: 'year', id: y.id, year: y.name })}>{t('year.close')}</Button>
)}
</div>
<table className="w-full text-sm">
<tbody>
{y.periods.map((p) => (
<tr key={p.id} aria-label={t('period.name', { seq: p.seq })} className="border-t border-outline">
<td className="py-1.5 font-medium">{t('period.name', { seq: p.seq })}</td>
<td className="py-1.5 text-muted">{t('period.range', { from: formatDate(p.starts_on, locale), to: formatDate(p.ends_on, locale) })}</td>
<td className="py-1.5"><Tag tone={TONE[p.status]}>{t(`period.status.${p.status}`)}</Tag></td>
<td className="hidden py-1.5 text-xs text-muted sm:table-cell">{p.closed_at ? t('period.closedOn', { when: formatDateTime(p.closed_at, locale) }) : ''}</td>
<td className="py-1.5 text-end">
{canClose && y.status === 'open' && p.status === 'open' && (
<Button size="sm" variant="outline" onClick={() => setClosing({ kind: 'period', id: p.id, seq: p.seq, year: y.name })}>{t('period.close')}</Button>
)}
{canClose && y.status === 'open' && p.status === 'closed' && (
<Button size="sm" variant="ghost" onClick={() => setReopening({ period: p, year: y })}>{t('period.reopen')}</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</Card>
))}
{closing && <CloseDialog target={closing} onClose={() => setClosing(null)} />}
{opening && <OpenYearDialog after={latestEnd} onClose={() => setOpening(false)} />}
<ReasonDialog open={reopening !== null}
title={reopening ? t('period.reopenTitle', { seq: reopening.period.seq, year: reopening.year.name }) : ''}
onCancel={() => setReopening(null)}
onConfirm={(reason) => {
const r = reopening;
setReopening(null);
if (r) reopen.mutate({ id: r.period.id, reason }, {
onSuccess: () => toast.success(t('period.reopened')),
onError: (e) => toast.error(problemMessage(e, t)),
});
}} />
</div>
);
}

Close and Reopen are offered from the period’s own status and its year’s status, both of which the API returns. The ordering rules (“an earlier period is still open”, “the last period is not open” for a year close) are not pre-evaluated. The server’s 409 detail names the periods, and the dialog shows it.

web/src/features/accounting/periods/routes.tsx
import { createRoute } from '@tanstack/react-router';
import { appRoute } from '@/app/layouts';
import { RequirePermission } from '@/app/auth';
import { PeriodsPage } from './PeriodsPage';
const periodsRoute = createRoute({
getParentRoute: () => appRoute,
path: '/accounting/periods',
component: () => <RequirePermission permission="accounting.ledger.read"><PeriodsPage /></RequirePermission>,
});
export const routes = [periodsRoute];

Append to app/routes.ts: import { routes as periodsRoutes } from '@/features/accounting/periods/routes'; and ...periodsRoutes,. Nav (import CalendarCheck):

{ group: 'manage', to: '/accounting/periods', labelKey: 'periods:nav', icon: CalendarCheck, permission: 'accounting.ledger.read' },
  • Step 6: Run, and watch the gate fail once
Terminal window
pnpm -C web test -- src/features/accounting/periods

Expected: PASS, 4 tests. Then change disabled={recon.isFetching || recon.data?.passed !== true} to disabled={recon.isFetching}. The first test must fail on toBeDisabled(), with its two presence assertions still green. Restore.

  • Step 7: Typecheck, lint, drift
Terminal window
pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test -- src/drift
  • Step 8: Runtime check (as the Owner, on a scratch org or the dev stack’s demo org)
  1. Sidebar → Fiscal periods. Expect the current year with twelve periods, all Open on a fresh org.
  2. Close on period 1. The dialog shows Running the reconciliation checks…, then the 12 checks with their names, each Passed. Close now → toast Period closed, and period 1 is Closed.
  3. Reopen period 1 with reason Late invoice → Period reopened.
  4. Make a check fail. Post a manual entry dated in period 1 (Task 23), close period 1, and try to post another entry dated in it: the server refuses (409). This check is on the posting side, not reconciliation. Reconciliation failures cannot be induced through the UI on a healthy ledger. Rely on the Vitest case for the failing display, and say so in the report.
  5. Close year on the current year while periods are open. Expect the refusal alert listing the open periods. Do not close a year on the shared dev stack.
  6. Open next fiscal year: the date is prefilled as the day after the year’s end → confirm → the new year appears on top.
  7. At 390 the period table drops the closed-at column. Dark and zh: the check names are translated, and the findings keep their server detail text.
  • Step 9: Commit
Terminal window
git add web/src/features/accounting/periods web/src/lib/i18n/locales/*/periods.json web/src/app/routes.ts web/src/app/shell/nav.ts web/src/lib/api/types.ts
git commit -m "$(cat <<'EOF'
web: fiscal periods, close with reconciliation checklist, reopen, year close
Fiscal years and periods with status; closing runs GET /reconciliation
first and enables Close only when every check passed; a refused close
shows the server's detail and re-runs the checks. Reopen takes a reason;
the next year opens from the day after the last one ends.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"

Spec §5.7 item 6. The four ledger reports are the trial balance, profit and loss, the balance sheet, and general-ledger detail. Each has its date controls in the URL (P7), an account tree that expands and collapses, and Export (CSV, XLSX or PDF), which requests the file, polls until it is rendered, then downloads it. Every figure, including every total and the balanced flag, is the server’s (F13). The UI adds nothing up. Aged advances and the float register are not shown: they have no endpoints (Plan 9 D1/D2).

Depends on Task 22 (useAccounts for the GL and TB account filter).

What the API gives (all need reports.read; exports need reports.export):

  • GET /reports/trial-balance?from&to&account_id?&exclude_closing? → TrialBalance { from, to, currency, provisional, filtered, exclude_closing, rows[{account_id, code, name, label, type, depth, opening_debit, opening_credit, debit, credit, closing_debit, closing_credit}], totals{…same six}, balanced }. It refuses an unknown parameter (422).
  • GET /reports/profit-and-loss?from&to&account_id?&analytic_account_id* → { …, income: StatementRow[], expenses: StatementRow[], total_income, total_expenses, net_profit }. A StatementRow has account_id, code, name, label, type, depth, header, amount.
  • GET /reports/balance-sheet?as_of → { as_of, currency, provisional, assets[], liabilities[], equity[], current_year_earnings, prior_unclosed_earnings, total_assets, total_liabilities, total_equity, balanced }.
  • GET /reports/general-ledger?from&to&account_id?&analytic_account_id*&cursor?&limit? → GeneralLedger { …, accounts: GeneralLedgerSection[{account_id, code, label, depth, opening, closing, lines[]}], next_cursor }. Paged by keyset cursor. A section’s opening and closing are “for the page”, so an account can span two pages: the first page carries its opening, the last page its closing.
  • POST /reports/exports body { report: trial_balance|profit_and_loss|balance_sheet|general_ledger, format: csv|xlsx|pdf, params: { from?, to?, as_of?, account_id?, analytic_account_ids?, exclude_closing? } } → 202 ReportExport { id, status: queued|succeeded|failed, failure_code: too_large|invalid_parameters|internal|null, filename, … }. A GL export over its format’s line cap → 422 up front.
  • GET /reports/exports/{id} → the export. GET /reports/exports/{id}/download → { url, filename, expires_at }, a presigned object-store URL (409 until succeeded). GET /reports/exports?limit&offset → { exports[] }, newest first.

Files:

  • Create: web/src/features/reports/api.ts
  • Create: web/src/features/reports/glMerge.ts, glMerge.test.ts
  • Create: web/src/features/reports/collapse.ts
  • Create: web/src/features/reports/download.ts
  • Create: web/src/features/reports/useExport.ts, ExportMenu.tsx, ExportMenu.test.tsx
  • Create: web/src/features/reports/ReportFlags.tsx, TrialBalanceView.tsx, StatementTable.tsx, ProfitAndLossView.tsx, BalanceSheetView.tsx, GeneralLedgerView.tsx, RecentExports.tsx
  • Create: web/src/features/reports/ReportsPage.tsx, ReportsPage.test.tsx
  • Create: web/src/features/reports/routes.tsx
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/reports.json
  • Append: web/src/app/routes.ts, web/src/app/shell/nav.ts, web/src/lib/api/types.ts

Interfaces:

  • Consumes: useAccounts, accountOptions (T22); MoneyText (T6); isZeroMoney (T6); formatDate, formatDateTime, todayISO, monthKey (T6); useLocale (T6); api, unwrap, ProblemError, problemMessage (T6); Button, Combobox, DatePicker, Field, Checkbox, Tabs, Tag, Card, DataTable, PageHeader, EmptyState, Skeleton, LoadMore, Spinner, toast (T7); useCan, RequirePermission, navItems (T8); useMemberNames (T9); appRoute, featureRoutes, renderWithProviders, server.

  • Produces: routes (/reports, search { report, from, to, as_of, account, closing }); mergeLedgerPages(pages): GeneralLedgerSection[]; visibleStatementRows(rows, collapsed); useExport(); ExportMenu; openDownload(url). Type aliases: TrialBalance, ProfitAndLoss, BalanceSheet, GeneralLedger, GeneralLedgerSection, StatementRow, ReportExport, ReportExportRequest.

  • Step 1: Aliases

export type TrialBalance = Schemas['TrialBalance'];
export type ProfitAndLoss = Schemas['ProfitAndLoss'];
export type BalanceSheet = Schemas['BalanceSheet'];
export type GeneralLedger = Schemas['GeneralLedger'];
export type GeneralLedgerSection = Schemas['GeneralLedgerSection'];
export type StatementRow = Schemas['StatementRow'];
export type ReportExport = Schemas['ReportExport'];
export type ReportExportRequest = Schemas['ReportExportRequest'];
  • Step 2: Failing GL merge test — glMerge.test.ts
import { describe, expect, it } from 'vitest';
import type { GeneralLedger, GeneralLedgerSection } from '@/lib/api/types';
import { mergeLedgerPages } from './glMerge';
const line = (n: number, balance: string) => ({
entry_id: `e${n}`, entry_no: `MSC-2026-000${n}`, entry_date: '2026-09-01', journal_code: 'MSC', semantic: 'manual' as const,
description: 'x', memo: '', line_no: 1, debit: '10.0000', credit: '0.0000', balance, currency: 'MYR',
tx_debit: null, tx_credit: null, source_kind: null, source_id: null, reversal_of_id: null,
});
const section = (id: string, opening: string, closing: string, lines: ReturnType<typeof line>[]): GeneralLedgerSection =>
({ account_id: id, code: id, name: id, label: id, type: 'expense', depth: 0, opening, closing, lines });
const page = (accounts: GeneralLedgerSection[], next: string | null): GeneralLedger =>
({ from: '2026-09-01', to: '2026-09-30', currency: 'MYR', provisional: true, filtered: false, accounts, next_cursor: next });
describe('mergeLedgerPages', () => {
it('joins an account split across pages: opening from the first page, closing from the last', () => {
const merged = mergeLedgerPages([
page([section('6100', '0.0000', '20.0000', [line(1, '10.0000'), line(2, '20.0000')])], 'c1'),
page([section('6100', '20.0000', '30.0000', [line(3, '30.0000')]), section('6200', '5.0000', '5.0000', [])], null),
]);
expect(merged.map((s) => [s.account_id, s.opening, s.closing, s.lines.length])).toEqual([
['6100', '0.0000', '30.0000', 3], ['6200', '5.0000', '5.0000', 0],
]);
});
it('keeps separate accounts separate', () => {
const merged = mergeLedgerPages([page([section('6100', '0', '1', [])], 'c'), page([section('6200', '0', '2', [])], null)]);
expect(merged).toHaveLength(2);
});
});

Run pnpm -C web test -- src/features/reports/glMerge.test.ts. Expected FAIL.

  • Step 3: Implement the pure helpers
web/src/features/reports/glMerge.ts
// Joins keyset pages of the general ledger. Only server values are picked:
// the first page's opening, the last page's closing, lines concatenated.
import type { GeneralLedger, GeneralLedgerSection } from '@/lib/api/types';
export function mergeLedgerPages(pages: GeneralLedger[]): GeneralLedgerSection[] {
const out: GeneralLedgerSection[] = [];
for (const p of pages) {
for (const s of p.accounts) {
const last = out.at(-1);
if (last && last.account_id === s.account_id) {
out[out.length - 1] = { ...last, closing: s.closing, lines: [...last.lines, ...s.lines] };
} else {
out.push(s);
}
}
}
return out;
}
web/src/features/reports/collapse.ts
// Hide the rows under a collapsed header in a depth-ordered statement.
export function visibleStatementRows<T extends { account_id: string; depth: number }>(rows: T[], collapsed: Set<string>): T[] {
const out: T[] = [];
let below: number | null = null;
for (const r of rows) {
if (below !== null && r.depth > below) continue;
below = null;
out.push(r);
if (collapsed.has(r.account_id)) below = r.depth;
}
return out;
}
web/src/features/reports/download.ts
/** Navigate to a presigned export URL. Its own module so tests can replace it. */
export function openDownload(url: string): void {
window.location.assign(url);
}

Run the merge test. Expected: PASS, 2 tests.

  • Step 4: Queries and the export hook
web/src/features/reports/api.ts
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
export interface RangeParams { from: string; to: string; account?: string }
export const reportKeys = {
tb: (p: RangeParams & { closing: boolean }) => ['reports', 'tb', p] as const,
pl: (p: RangeParams) => ['reports', 'pl', p] as const,
bs: (asOf: string) => ['reports', 'bs', asOf] as const,
gl: (p: RangeParams) => ['reports', 'gl', p] as const,
exports: ['reports', 'exports'] as const,
export: (id: string) => ['reports', 'exports', id] as const,
};
export const useTrialBalance = (p: RangeParams & { closing: boolean }) => useQuery({
queryKey: reportKeys.tb(p),
queryFn: async () => unwrap(await api.GET('/reports/trial-balance', { params: { query: {
from: p.from, to: p.to, ...(p.account ? { account_id: p.account } : {}), ...(p.closing ? {} : { exclude_closing: true }),
} } })),
});
export const useProfitAndLoss = (p: RangeParams) => useQuery({
queryKey: reportKeys.pl(p),
queryFn: async () => unwrap(await api.GET('/reports/profit-and-loss', { params: { query: {
from: p.from, to: p.to, ...(p.account ? { account_id: p.account } : {}),
} } })),
});
export const useBalanceSheet = (asOf: string) => useQuery({
queryKey: reportKeys.bs(asOf),
queryFn: async () => unwrap(await api.GET('/reports/balance-sheet', { params: { query: { as_of: asOf } } })),
});
export const useGeneralLedger = (p: RangeParams) => useInfiniteQuery({
queryKey: reportKeys.gl(p),
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) => unwrap(await api.GET('/reports/general-ledger', { params: { query: {
from: p.from, to: p.to, limit: 500, ...(p.account ? { account_id: p.account } : {}), ...(pageParam ? { cursor: pageParam } : {}),
} } })),
getNextPageParam: (last) => last.next_cursor ?? undefined,
});
export const useRecentExports = () => useQuery({
queryKey: reportKeys.exports,
queryFn: async () => unwrap(await api.GET('/reports/exports', { params: { query: { limit: 10 } } })).exports,
});

exclude_closing is sent only when true. The endpoint refuses unknown parameters, and a false is simply the default.

web/src/features/reports/useExport.ts
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
import type { ReportExportRequest } from '@/lib/api/types';
import { reportKeys } from './api';
import { openDownload } from './download';
export const POLL_MS = 1500;
export type ExportPhase = 'idle' | 'requesting' | 'rendering' | 'downloading' | 'done' | 'failed';
/** Request → poll GET /reports/exports/{id} until it leaves "queued" → fetch the presigned URL → navigate. */
export function useExport(pollMs = POLL_MS) {
const qc = useQueryClient();
const [id, setId] = useState<string | null>(null);
const [phase, setPhase] = useState<ExportPhase>('idle');
const request = useMutation({
mutationFn: async (body: ReportExportRequest) => unwrap(await api.POST('/reports/exports', { body })),
onMutate: () => setPhase('requesting'),
onSuccess: (e) => { setId(e.id); setPhase('rendering'); },
onError: () => setPhase('failed'),
});
const status = useQuery({
queryKey: reportKeys.export(id ?? ''),
enabled: id !== null && phase === 'rendering',
queryFn: async () => unwrap(await api.GET('/reports/exports/{id}', { params: { path: { id: id! } } })),
refetchInterval: (q) => (q.state.data?.status === 'queued' || q.state.data === undefined ? pollMs : false),
});
useEffect(() => {
const s = status.data;
if (!s || phase !== 'rendering') return;
if (s.status === 'failed') { setPhase('failed'); return; }
if (s.status !== 'succeeded') return;
setPhase('downloading');
void (async () => {
try {
const d = unwrap(await api.GET('/reports/exports/{id}/download', { params: { path: { id: s.id } } }));
openDownload(d.url);
setPhase('done');
void qc.invalidateQueries({ queryKey: reportKeys.exports });
} catch {
setPhase('failed');
}
})();
}, [status.data, phase, qc]);
return {
start: (body: ReportExportRequest) => request.mutate(body),
phase,
failureCode: status.data?.failure_code ?? null,
error: request.error,
reset: () => { setId(null); setPhase('idle'); },
};
}
  • Step 5: i18n bundles

en/reports.json:

{
"nav": "Reports",
"page": {
"title": "Reports",
"subtitle": "Figures from posted entries only, in the base currency.",
"tabs": { "trial_balance": "Trial balance", "profit_and_loss": "Profit and loss", "balance_sheet": "Balance sheet", "general_ledger": "General ledger" },
"from": "From",
"to": "To",
"asOf": "As of",
"account": "Account",
"allAccounts": "All accounts",
"includeClosing": "Include the year-end closing entry",
"provisional": "Provisional: the period is not closed, so these figures can still change.",
"filtered": "Filtered to one account and its sub-accounts.",
"balanced": "Balanced",
"notBalanced": "Not balanced",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"collapse": "Collapse {{code}}",
"expand": "Expand {{code}}",
"empty": "Nothing posted in this range."
},
"tb": {
"code": "Code", "account": "Account",
"openingDebit": "Opening Dr", "openingCredit": "Opening Cr",
"debit": "Debit", "credit": "Credit",
"closingDebit": "Closing Dr", "closingCredit": "Closing Cr",
"totals": "Totals"
},
"pl": { "income": "Income", "expenses": "Expenses", "totalIncome": "Total income", "totalExpenses": "Total expenses", "netProfit": "Net profit" },
"bs": {
"assets": "Assets", "liabilities": "Liabilities", "equity": "Equity",
"currentYearEarnings": "Current year earnings", "priorUnclosedEarnings": "Earlier years not yet closed",
"totalAssets": "Total assets", "totalLiabilities": "Total liabilities", "totalEquity": "Total equity"
},
"gl": {
"opening": "Opening balance", "closing": "Closing balance",
"date": "Date", "entry": "Entry", "description": "Description", "debit": "Debit", "credit": "Credit", "balance": "Balance",
"loadMore": "Load more"
},
"export": {
"button": "Export",
"csv": "CSV",
"xlsx": "Excel (XLSX)",
"pdf": "PDF",
"requesting": "Requesting the export…",
"rendering": "Preparing the file…",
"downloading": "Starting the download…",
"done": "Your download has started.",
"failed": "The export failed.",
"failure": { "too_large": "Too many lines for this format. Narrow the range or choose CSV.", "invalid_parameters": "The report's parameters were not accepted.", "internal": "Something went wrong while rendering the file." }
},
"recent": {
"title": "Recent exports",
"report": "Report", "format": "Format", "requested": "Requested", "by": "By", "status": "Status",
"status_queued": "Preparing", "status_succeeded": "Ready", "status_failed": "Failed",
"download": "Download",
"empty": "No exports yet."
}
}

ms/reports.json:

{
"nav": "Laporan",
"page": {
"title": "Laporan",
"subtitle": "Angka daripada catatan yang telah dipos sahaja, dalam mata wang asas.",
"tabs": { "trial_balance": "Imbangan duga", "profit_and_loss": "Untung rugi", "balance_sheet": "Kunci kira-kira", "general_ledger": "Lejar am" },
"from": "Dari",
"to": "Hingga",
"asOf": "Setakat",
"account": "Akaun",
"allAccounts": "Semua akaun",
"includeClosing": "Sertakan catatan penutupan akhir tahun",
"provisional": "Sementara: tempoh belum ditutup, jadi angka ini masih boleh berubah.",
"filtered": "Ditapis kepada satu akaun dan subakaunnya.",
"balanced": "Seimbang",
"notBalanced": "Tidak seimbang",
"expandAll": "Kembangkan semua",
"collapseAll": "Kuncupkan semua",
"collapse": "Kuncupkan {{code}}",
"expand": "Kembangkan {{code}}",
"empty": "Tiada apa-apa dipos dalam julat ini."
},
"tb": {
"code": "Kod", "account": "Akaun",
"openingDebit": "Dt pembukaan", "openingCredit": "Kt pembukaan",
"debit": "Debit", "credit": "Kredit",
"closingDebit": "Dt penutupan", "closingCredit": "Kt penutupan",
"totals": "Jumlah"
},
"pl": { "income": "Pendapatan", "expenses": "Perbelanjaan", "totalIncome": "Jumlah pendapatan", "totalExpenses": "Jumlah perbelanjaan", "netProfit": "Untung bersih" },
"bs": {
"assets": "Aset", "liabilities": "Liabiliti", "equity": "Ekuiti",
"currentYearEarnings": "Pendapatan tahun semasa", "priorUnclosedEarnings": "Tahun terdahulu yang belum ditutup",
"totalAssets": "Jumlah aset", "totalLiabilities": "Jumlah liabiliti", "totalEquity": "Jumlah ekuiti"
},
"gl": {
"opening": "Baki pembukaan", "closing": "Baki penutupan",
"date": "Tarikh", "entry": "Catatan", "description": "Keterangan", "debit": "Debit", "credit": "Kredit", "balance": "Baki",
"loadMore": "Muat lagi"
},
"export": {
"button": "Eksport",
"csv": "CSV",
"xlsx": "Excel (XLSX)",
"pdf": "PDF",
"requesting": "Meminta eksport…",
"rendering": "Menyediakan fail…",
"downloading": "Memulakan muat turun…",
"done": "Muat turun anda telah bermula.",
"failed": "Eksport gagal.",
"failure": { "too_large": "Terlalu banyak baris untuk format ini. Kecilkan julat atau pilih CSV.", "invalid_parameters": "Parameter laporan tidak diterima.", "internal": "Berlaku ralat semasa menjana fail." }
},
"recent": {
"title": "Eksport terkini",
"report": "Laporan", "format": "Format", "requested": "Diminta", "by": "Oleh", "status": "Status",
"status_queued": "Sedang disediakan", "status_succeeded": "Sedia", "status_failed": "Gagal",
"download": "Muat turun",
"empty": "Belum ada eksport."
}
}

zh/reports.json:

{
"nav": "报表",
"page": {
"title": "报表",
"subtitle": "仅统计已记账分录,以本位币列示。",
"tabs": { "trial_balance": "试算表", "profit_and_loss": "损益表", "balance_sheet": "资产负债表", "general_ledger": "总分类账" },
"from": "起始日期",
"to": "截止日期",
"asOf": "截至",
"account": "科目",
"allAccounts": "所有科目",
"includeClosing": "包含年终结账分录",
"provisional": "暂定数:期间尚未关闭,数字仍可能变动。",
"filtered": "已筛选为一个科目及其下级科目。",
"balanced": "平衡",
"notBalanced": "不平衡",
"expandAll": "全部展开",
"collapseAll": "全部收起",
"collapse": "收起 {{code}}",
"expand": "展开 {{code}}",
"empty": "此期间内没有记账记录。"
},
"tb": {
"code": "代码", "account": "科目",
"openingDebit": "期初借方", "openingCredit": "期初贷方",
"debit": "本期借方", "credit": "本期贷方",
"closingDebit": "期末借方", "closingCredit": "期末贷方",
"totals": "合计"
},
"pl": { "income": "收入", "expenses": "费用", "totalIncome": "收入合计", "totalExpenses": "费用合计", "netProfit": "净利润" },
"bs": {
"assets": "资产", "liabilities": "负债", "equity": "权益",
"currentYearEarnings": "本年利润", "priorUnclosedEarnings": "以前年度未结转利润",
"totalAssets": "资产合计", "totalLiabilities": "负债合计", "totalEquity": "权益合计"
},
"gl": {
"opening": "期初余额", "closing": "期末余额",
"date": "日期", "entry": "分录", "description": "摘要", "debit": "借方", "credit": "贷方", "balance": "余额",
"loadMore": "加载更多"
},
"export": {
"button": "导出",
"csv": "CSV",
"xlsx": "Excel (XLSX)",
"pdf": "PDF",
"requesting": "正在提交导出请求…",
"rendering": "正在生成文件…",
"downloading": "正在开始下载…",
"done": "下载已开始。",
"failed": "导出失败。",
"failure": { "too_large": "此格式的行数过多。请缩小范围或选择 CSV。", "invalid_parameters": "报表参数未被接受。", "internal": "生成文件时出错。" }
},
"recent": {
"title": "最近的导出",
"report": "报表", "format": "格式", "requested": "请求时间", "by": "请求人", "status": "状态",
"status_queued": "生成中", "status_succeeded": "可下载", "status_failed": "失败",
"download": "下载",
"empty": "暂无导出记录。"
}
}
  • Step 6: Failing tests — ExportMenu.test.tsx and ReportsPage.test.tsx
web/src/features/reports/ExportMenu.test.tsx
import { describe, expect, it, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { screen, waitFor } from '@testing-library/react';
import { server } from '@/test/msw/server';
import { renderWithProviders } from '@/test/render';
import { ExportMenu } from './ExportMenu';
vi.mock('./download', () => ({ openDownload: vi.fn() }));
import { openDownload } from './download';
const exportRow = (status: 'queued' | 'succeeded' | 'failed', failure_code: string | null = null) => ({
id: 'x1', report: 'trial_balance', format: 'csv', params: { from: '2026-09-01', to: '2026-09-30' }, status,
requested_by: 'm1', requested_at: '2026-09-23T01:00:00Z', filename: 'trial_balance.csv', finished_at: null,
content_type: null, byte_size: null, sha256: null, row_count: null, failure_code,
});
describe('ExportMenu', () => {
it('requests, polls until the file is ready, then downloads the presigned URL', async () => {
const bodies: unknown[] = [];
let polls = 0;
server.use(
http.post('/v1/reports/exports', async ({ request }) => { bodies.push(await request.json()); return HttpResponse.json(exportRow('queued'), { status: 202 }); }),
http.get('/v1/reports/exports/x1', () => { polls += 1; return HttpResponse.json(exportRow(polls < 2 ? 'queued' : 'succeeded')); }),
http.get('/v1/reports/exports/x1/download', () => HttpResponse.json({ url: 'https://files.example.my/x1.csv?sig=abc', filename: 'trial_balance.csv', expires_at: '2026-09-23T01:05:00Z' })),
);
const { user } = renderWithProviders(
<ExportMenu report="trial_balance" params={{ from: '2026-09-01', to: '2026-09-30' }} pollMs={10} />,
{ permissions: ['reports.read', 'reports.export'] },
);
await user.click(screen.getByRole('button', { name: 'Export' }));
await user.click(await screen.findByRole('menuitem', { name: 'CSV' }));
await waitFor(() => expect(openDownload).toHaveBeenCalledWith('https://files.example.my/x1.csv?sig=abc'), { timeout: 2000 });
expect(bodies).toEqual([{ report: 'trial_balance', format: 'csv', params: { from: '2026-09-01', to: '2026-09-30' } }]);
expect(polls).toBeGreaterThanOrEqual(2);
expect(screen.getByRole('status')).toHaveTextContent('Your download has started.');
});
it('explains a failed render by its failure code, and never downloads', async () => {
vi.mocked(openDownload).mockClear();
server.use(
http.post('/v1/reports/exports', () => HttpResponse.json(exportRow('queued'), { status: 202 })),
http.get('/v1/reports/exports/x1', () => HttpResponse.json(exportRow('failed', 'too_large'))),
);
const { user } = renderWithProviders(
<ExportMenu report="general_ledger" params={{ from: '2026-01-01', to: '2026-12-31' }} pollMs={10} />,
{ permissions: ['reports.read', 'reports.export'] },
);
await user.click(screen.getByRole('button', { name: 'Export' }));
await user.click(await screen.findByRole('menuitem', { name: 'PDF' }));
expect(await screen.findByRole('status')).toHaveTextContent('Too many lines for this format');
expect(openDownload).not.toHaveBeenCalled();
});
});
web/src/features/reports/ReportsPage.test.tsx
import { describe, expect, it } from 'vitest';
import { http, HttpResponse } from 'msw';
import { screen, within } from '@testing-library/react';
import { server } from '@/test/msw/server';
import { renderWithProviders } from '@/test/render';
import { ReportsPage } from './ReportsPage';
const tb = {
from: '2026-09-01', to: '2026-09-30', currency: 'MYR', provisional: true, filtered: false, exclude_closing: false, balanced: true,
rows: [
{ account_id: 'a1110', code: '1110', name: 'Maybank', label: 'Maybank', type: 'asset', depth: 2, opening_debit: '0.0000', opening_credit: '0.0000', debit: '1000.0000', credit: '0.0000', closing_debit: '1000.0000', closing_credit: '0.0000' },
{ account_id: 'a3100', code: '3100', name: 'Capital', label: 'Capital', type: 'equity', depth: 1, opening_debit: '0.0000', opening_credit: '0.0000', debit: '0.0000', credit: '1000.0000', closing_debit: '0.0000', closing_credit: '1000.0000' },
],
totals: { opening_debit: '0.0000', opening_credit: '0.0000', debit: '1000.0000', credit: '1000.0000', closing_debit: '1000.0000', closing_credit: '1000.0000' },
};
describe('ReportsPage', () => {
it('shows the server totals and flags, and does not add rows up itself', async () => {
// The totals below deliberately differ from the rows' sum: the page must show the server's.
server.use(
http.get('/v1/reports/trial-balance', () => HttpResponse.json({ ...tb, totals: { ...tb.totals, debit: '1234.5600' } })),
http.get('/v1/accounting/accounts', () => HttpResponse.json({ accounts: [] })),
http.get('/v1/reports/exports', () => HttpResponse.json({ exports: [] })),
http.get('/v1/org/members', () => HttpResponse.json({ members: [] })),
);
renderWithProviders(<ReportsPage search={{ report: 'trial_balance', from: '2026-09-01', to: '2026-09-30', closing: true }} />,
{ permissions: ['reports.read'] });
const totals = await screen.findByRole('row', { name: /Totals/ });
expect(within(totals).getByText(/1,234\.56/)).toBeInTheDocument();
expect(screen.getByText(/Provisional/)).toBeInTheDocument();
expect(screen.getByText('Balanced')).toBeInTheDocument();
// presence of the export list; absence of the Export button for a reader without reports.export
expect(await screen.findByText('No exports yet.')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Export' })).toBeNull();
});
});

Run pnpm -C web test -- src/features/reports. Expected FAIL: cannot resolve ./ExportMenu and ./ReportsPage.

  • Step 7: Implement the views, the menu, the page and the routes
web/src/features/reports/ExportMenu.tsx
import { useTranslation } from 'react-i18next';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { Download } from 'lucide-react';
import { Button, Spinner } from '@/components/ui';
import { problemMessage } from '@/lib/api/problem';
import type { ReportExportRequest } from '@/lib/api/types';
import { POLL_MS, useExport } from './useExport';
const FORMATS = ['csv', 'xlsx', 'pdf'] as const;
export function ExportMenu({ report, params, pollMs = POLL_MS }: {
report: ReportExportRequest['report']; params: ReportExportRequest['params']; pollMs?: number;
}) {
const { t } = useTranslation('reports');
const ex = useExport(pollMs);
const busy = ex.phase === 'requesting' || ex.phase === 'rendering' || ex.phase === 'downloading';
const message =
ex.phase === 'failed'
? ex.failureCode ? t(`export.failure.${ex.failureCode}`) : ex.error ? problemMessage(ex.error, t) : t('export.failed')
: ex.phase === 'idle' ? '' : t(`export.${ex.phase}`);
return (
<div className="flex items-center gap-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button variant="outline" disabled={busy}><Download aria-hidden /> {t('export.button')}</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" className="rounded-md border border-outline bg-surface p-1 shadow">
{FORMATS.map((f) => (
<DropdownMenu.Item key={f} className="cursor-pointer rounded px-3 py-1.5 text-sm data-[highlighted]:bg-primary-soft"
onSelect={() => ex.start({ report, format: f, params })}>{t(`export.${f}`)}</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
<span role="status" aria-live="polite" className={ex.phase === 'failed' ? 'text-sm text-danger' : 'text-sm text-muted'}>
{busy && <Spinner className="me-1 inline" />}{message}
</span>
</div>
);
}

Verify: whether Task 7 ships a DropdownMenu primitive. The registry does not list one, so this file uses @radix-ui/react-dropdown-menu directly (pnpm -C web add @radix-ui/react-dropdown-menu if Task 7 did not add it). Swap to Task 7’s wrapper if one exists.

web/src/features/reports/StatementTable.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { IconButton } from '@/components/ui';
import { MoneyText } from '@/components/money';
import type { StatementRow } from '@/lib/api/types';
import { visibleStatementRows } from './collapse';
/** One section of a statement (income, expenses, assets…) as an expandable tree, with the server's total. */
export function StatementTable({ title, rows, total, totalLabel, currency }: {
title: string; rows: StatementRow[]; total: string; totalLabel: string; currency: string;
}) {
const { t } = useTranslation('reports');
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const toggle = (id: string) => setCollapsed((c) => { const n = new Set(c); if (n.has(id)) n.delete(id); else n.add(id); return n; });
return (
<table className="w-full text-sm">
<caption className="py-2 text-start text-base font-medium">{title}</caption>
<tbody>
{visibleStatementRows(rows, collapsed).map((r) => (
<tr key={r.account_id} className="border-t border-outline">
<td className="py-1.5" style={{ paddingInlineStart: `${r.depth * 1.25}rem` }}>
{r.header && (
<IconButton size="sm" variant="ghost" aria-expanded={!collapsed.has(r.account_id)}
aria-label={t(collapsed.has(r.account_id) ? 'page.expand' : 'page.collapse', { code: r.code })}
onClick={() => toggle(r.account_id)}>
{collapsed.has(r.account_id) ? <ChevronRight /> : <ChevronDown />}
</IconButton>
)}
<span className={r.header ? 'font-medium' : ''}><code className="me-2 text-muted">{r.code}</code>{r.label}</span>
</td>
<td className="py-1.5 text-end tabular-nums"><MoneyText amount={r.amount} currency={currency} /></td>
</tr>
))}
<tr aria-label={totalLabel} className="border-t-2 border-outline font-medium">
<td className="py-2">{totalLabel}</td>
<td className="py-2 text-end tabular-nums"><MoneyText amount={total} currency={currency} /></td>
</tr>
</tbody>
</table>
);
}
web/src/features/reports/TrialBalanceView.tsx
import { useTranslation } from 'react-i18next';
import { EmptyState, Skeleton } from '@/components/ui';
import { MoneyText } from '@/components/money';
import { isZeroMoney } from '@/lib/money';
import { problemMessage } from '@/lib/api/problem';
import { useTrialBalance, type RangeParams } from './api';
import { ReportFlags } from './ReportFlags';
const COLS = ['opening_debit', 'opening_credit', 'debit', 'credit', 'closing_debit', 'closing_credit'] as const;
const HEAD = ['openingDebit', 'openingCredit', 'debit', 'credit', 'closingDebit', 'closingCredit'] as const;
export function TrialBalanceView({ params }: { params: RangeParams & { closing: boolean } }) {
const { t } = useTranslation('reports');
const q = useTrialBalance(params);
if (q.isPending) return <Skeleton className="h-64 w-full" />;
if (q.error) return <EmptyState title={problemMessage(q.error, t)} />;
const r = q.data;
const cell = (a: string) => (isZeroMoney(a) ? '' : <MoneyText amount={a} currency={r.currency} />);
return (
<div className="grid gap-2">
<ReportFlags provisional={r.provisional} filtered={r.filtered} balanced={r.balanced} />
{r.rows.length === 0 ? <EmptyState title={t('page.empty')} /> : (
<div className="overflow-x-auto">
<table className="w-full min-w-[56rem] text-sm">
<thead>
<tr className="text-muted">
<th className="py-1 text-start">{t('tb.code')}</th><th className="py-1 text-start">{t('tb.account')}</th>
{HEAD.map((h) => <th key={h} className="py-1 text-end">{t(`tb.${h}`)}</th>)}
</tr>
</thead>
<tbody>
{r.rows.map((row) => (
<tr key={row.account_id} className="border-t border-outline">
<td className="py-1.5"><code>{row.code}</code></td>
<td className="py-1.5" style={{ paddingInlineStart: `${row.depth * 0.75}rem` }}>{row.label}</td>
{COLS.map((c) => <td key={c} className="py-1.5 text-end tabular-nums">{cell(row[c])}</td>)}
</tr>
))}
<tr aria-label={t('tb.totals')} className="border-t-2 border-outline font-medium">
<td className="py-2" colSpan={2}>{t('tb.totals')}</td>
{COLS.map((c) => <td key={c} className="py-2 text-end tabular-nums"><MoneyText amount={r.totals[c]} currency={r.currency} /></td>)}
</tr>
</tbody>
</table>
</div>
)}
</div>
);
}

The TB rows are postable accounts only (“per postable account”), so there are no header rows to collapse. The indentation shows each row’s depth.

web/src/features/reports/ProfitAndLossView.tsx
import { useTranslation } from 'react-i18next';
import { EmptyState, Skeleton } from '@/components/ui';
import { MoneyText } from '@/components/money';
import { problemMessage } from '@/lib/api/problem';
import { useProfitAndLoss, type RangeParams } from './api';
import { ReportFlags } from './ReportFlags';
import { StatementTable } from './StatementTable';
export function ProfitAndLossView({ params }: { params: RangeParams }) {
const { t } = useTranslation('reports');
const q = useProfitAndLoss(params);
if (q.isPending) return <Skeleton className="h-64 w-full" />;
if (q.error) return <EmptyState title={problemMessage(q.error, t)} />;
const r = q.data;
return (
<div className="grid gap-4">
<ReportFlags provisional={r.provisional} filtered={r.filtered} />
<StatementTable title={t('pl.income')} rows={r.income} total={r.total_income} totalLabel={t('pl.totalIncome')} currency={r.currency} />
<StatementTable title={t('pl.expenses')} rows={r.expenses} total={r.total_expenses} totalLabel={t('pl.totalExpenses')} currency={r.currency} />
<p className="flex justify-between border-t-2 border-outline pt-2 text-base font-medium">
<span>{t('pl.netProfit')}</span><MoneyText amount={r.net_profit} currency={r.currency} sign="auto" />
</p>
</div>
);
}
web/src/features/reports/BalanceSheetView.tsx
import { useTranslation } from 'react-i18next';
import { EmptyState, Skeleton } from '@/components/ui';
import { MoneyText } from '@/components/money';
import { problemMessage } from '@/lib/api/problem';
import { useBalanceSheet } from './api';
import { ReportFlags } from './ReportFlags';
import { StatementTable } from './StatementTable';
export function BalanceSheetView({ asOf }: { asOf: string }) {
const { t } = useTranslation('reports');
const q = useBalanceSheet(asOf);
if (q.isPending) return <Skeleton className="h-64 w-full" />;
if (q.error) return <EmptyState title={problemMessage(q.error, t)} />;
const r = q.data;
return (
<div className="grid gap-4">
<ReportFlags provisional={r.provisional} balanced={r.balanced} />
<StatementTable title={t('bs.assets')} rows={r.assets} total={r.total_assets} totalLabel={t('bs.totalAssets')} currency={r.currency} />
<StatementTable title={t('bs.liabilities')} rows={r.liabilities} total={r.total_liabilities} totalLabel={t('bs.totalLiabilities')} currency={r.currency} />
<div className="grid gap-1">
<StatementTable title={t('bs.equity')} rows={r.equity} total={r.total_equity} totalLabel={t('bs.totalEquity')} currency={r.currency} />
<p className="flex justify-between text-sm"><span>{t('bs.currentYearEarnings')}</span><MoneyText amount={r.current_year_earnings} currency={r.currency} /></p>
<p className="flex justify-between text-sm"><span>{t('bs.priorUnclosedEarnings')}</span><MoneyText amount={r.prior_unclosed_earnings} currency={r.currency} /></p>
</div>
</div>
);
}

Verify against internal/platform/reporting: is total_equity inclusive of current_year_earnings and prior_unclosed_earnings? The layout above lists the two earnings lines below the equity total. If the total includes them, move them above the total row (pass them as extra rows into StatementTable), so the reader sees the components before the sum.

web/src/features/reports/GeneralLedgerView.tsx
import { useTranslation } from 'react-i18next';
import { EmptyState, LoadMore, Skeleton } from '@/components/ui';
import { MoneyText } from '@/components/money';
import { formatDate } from '@/lib/date';
import { isZeroMoney } from '@/lib/money';
import { useLocale } from '@/lib/i18n';
import { problemMessage } from '@/lib/api/problem';
import { useGeneralLedger, type RangeParams } from './api';
import { mergeLedgerPages } from './glMerge';
import { ReportFlags } from './ReportFlags';
export function GeneralLedgerView({ params }: { params: RangeParams }) {
const { t } = useTranslation('reports');
const [locale] = useLocale();
const q = useGeneralLedger(params);
if (q.isPending) return <Skeleton className="h-64 w-full" />;
if (q.error) return <EmptyState title={problemMessage(q.error, t)} />;
const first = q.data.pages[0];
const sections = mergeLedgerPages(q.data.pages);
const cur = first.currency;
return (
<div className="grid gap-4">
<ReportFlags provisional={first.provisional} filtered={first.filtered} />
{sections.length === 0 && <EmptyState title={t('page.empty')} />}
{sections.map((s) => (
<section key={s.account_id} aria-label={`${s.code} ${s.label}`} className="overflow-x-auto">
<h3 className="text-sm font-medium"><code className="me-2">{s.code}</code>{s.label}</h3>
<table className="w-full min-w-[44rem] text-sm">
<thead>
<tr className="text-muted">
<th className="py-1 text-start">{t('gl.date')}</th><th className="py-1 text-start">{t('gl.entry')}</th>
<th className="py-1 text-start">{t('gl.description')}</th>
<th className="py-1 text-end">{t('gl.debit')}</th><th className="py-1 text-end">{t('gl.credit')}</th><th className="py-1 text-end">{t('gl.balance')}</th>
</tr>
</thead>
<tbody>
<tr className="text-muted"><td colSpan={5} className="py-1">{t('gl.opening')}</td><td className="py-1 text-end tabular-nums"><MoneyText amount={s.opening} currency={cur} /></td></tr>
{s.lines.map((l) => (
<tr key={`${l.entry_id}-${l.line_no}`} className="border-t border-outline">
<td className="py-1">{formatDate(l.entry_date, locale)}</td>
<td className="py-1"><code>{l.entry_no}</code></td>
<td className="py-1">{l.description}{l.memo && <span className="text-muted"> · {l.memo}</span>}</td>
<td className="py-1 text-end tabular-nums">{isZeroMoney(l.debit) ? '' : <MoneyText amount={l.debit} currency={cur} />}</td>
<td className="py-1 text-end tabular-nums">{isZeroMoney(l.credit) ? '' : <MoneyText amount={l.credit} currency={cur} />}</td>
<td className="py-1 text-end tabular-nums"><MoneyText amount={l.balance} currency={cur} /></td>
</tr>
))}
<tr className="border-t-2 border-outline font-medium"><td colSpan={5} className="py-1">{t('gl.closing')}</td><td className="py-1 text-end tabular-nums"><MoneyText amount={s.closing} currency={cur} /></td></tr>
</tbody>
</table>
</section>
))}
{q.hasNextPage && <LoadMore onClick={() => void q.fetchNextPage()} loading={q.isFetchingNextPage} label={t('gl.loadMore')} />}
</div>
);
}

While more pages remain, a section’s “closing balance” is the closing of the last page loaded. It is still the server’s figure for that point, and loading more replaces it.

web/src/features/reports/RecentExports.tsx
import { useTranslation } from 'react-i18next';
import { Button, DataTable, EmptyState, Tag, toast } from '@/components/ui';
import { useMemberNames } from '@/components/workflow';
import { api } from '@/lib/api/client';
import { problemMessage, unwrap } from '@/lib/api/problem';
import { formatDateTime } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import type { ReportExport } from '@/lib/api/types';
import { useRecentExports } from './api';
import { openDownload } from './download';
const TONE = { queued: 'amber', succeeded: 'green', failed: 'red' } as const;
export function RecentExports() {
const { t } = useTranslation('reports');
const [locale] = useLocale();
const nameOf = useMemberNames();
const q = useRecentExports();
const download = async (id: string) => {
try { openDownload(unwrap(await api.GET('/reports/exports/{id}/download', { params: { path: { id } } })).url); }
catch (e) { toast.error(problemMessage(e, t)); }
};
return (
<section aria-labelledby="rx-title" className="grid gap-2">
<h2 id="rx-title" className="text-base font-medium">{t('recent.title')}</h2>
<DataTable<ReportExport>
loading={q.isPending}
data={q.data ?? []}
empty={<EmptyState title={t('recent.empty')} />}
columns={[
{ id: 'report', header: t('recent.report'), cell: ({ row }) => t(`page.tabs.${row.original.report}`) },
{ id: 'format', header: t('recent.format'), cell: ({ row }) => row.original.format.toUpperCase() },
{ id: 'requested', header: t('recent.requested'), cell: ({ row }) => formatDateTime(row.original.requested_at, locale) },
{ id: 'by', header: t('recent.by'), cell: ({ row }) => nameOf(row.original.requested_by) },
{ id: 'status', header: t('recent.status'), cell: ({ row }) => <Tag tone={TONE[row.original.status]}>{t(`recent.status_${row.original.status}`)}</Tag> },
{ id: 'dl', header: '', cell: ({ row }) => row.original.status === 'succeeded'
? <Button size="sm" variant="ghost" onClick={() => void download(row.original.id)}>{t('recent.download')}</Button> : null },
]}
/>
</section>
);
}
web/src/features/reports/ReportFlags.tsx
import { useTranslation } from 'react-i18next';
import { Tag } from '@/components/ui';
export function ReportFlags({ provisional, filtered, balanced }: { provisional: boolean; filtered?: boolean; balanced?: boolean }) {
const { t } = useTranslation('reports');
return (
<div className="flex flex-wrap items-center gap-2 text-sm">
{balanced !== undefined && <Tag tone={balanced ? 'green' : 'red'}>{balanced ? t('page.balanced') : t('page.notBalanced')}</Tag>}
{provisional && <span className="rounded-md bg-warning-soft px-2 py-1">{t('page.provisional')}</span>}
{filtered && <span className="text-muted">{t('page.filtered')}</span>}
</div>
);
}
web/src/features/reports/ReportsPage.tsx
import { useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { Checkbox, Combobox, DatePicker, Field, PageHeader, Tabs } from '@/components/ui';
import { useCan } from '@/app/auth';
import type { ReportExportRequest } from '@/lib/api/types';
import { accountOptions, useAccounts } from '../accounting/queries';
import { BalanceSheetView } from './BalanceSheetView';
import { ExportMenu } from './ExportMenu';
import { GeneralLedgerView } from './GeneralLedgerView';
import { ProfitAndLossView } from './ProfitAndLossView';
import { RecentExports } from './RecentExports';
import { TrialBalanceView } from './TrialBalanceView';
export type ReportKey = ReportExportRequest['report'];
export interface ReportsSearch { report: ReportKey; from: string; to: string; as_of?: string; account?: string; closing: boolean }
export function ReportsPage({ search }: { search: ReportsSearch }) {
const { t } = useTranslation('reports');
const navigate = useNavigate();
const canExport = useCan('reports.export');
const accounts = useAccounts();
const set = (patch: Partial<ReportsSearch>) => navigate({ to: '/reports', search: (s: ReportsSearch) => ({ ...s, ...patch }) });
const range = { from: search.from, to: search.to, account: search.account };
const asOf = search.as_of ?? search.to;
const exportParams: ReportExportRequest['params'] =
search.report === 'balance_sheet' ? { as_of: asOf }
: { from: search.from, to: search.to,
...(search.account && search.report !== 'profit_and_loss' ? { account_id: search.account } : {}),
...(search.report === 'trial_balance' && !search.closing ? { exclude_closing: true } : {}) };
return (
<div className="grid gap-4">
<PageHeader title={t('page.title')} subtitle={t('page.subtitle')}
actions={canExport ? <ExportMenu report={search.report} params={exportParams} /> : undefined} />
<Tabs value={search.report} onValueChange={(v) => set({ report: v as ReportKey })}
items={(['trial_balance', 'profit_and_loss', 'balance_sheet', 'general_ledger'] as const).map((k) => ({ value: k, label: t(`page.tabs.${k}`) }))} />
<div className="flex flex-wrap items-end gap-3">
{search.report === 'balance_sheet' ? (
<Field label={t('page.asOf')} htmlFor="rp-asof"><DatePicker id="rp-asof" value={asOf} onChange={(v) => set({ as_of: v })} /></Field>
) : (
<>
<Field label={t('page.from')} htmlFor="rp-from"><DatePicker id="rp-from" value={search.from} onChange={(v) => set({ from: v })} /></Field>
<Field label={t('page.to')} htmlFor="rp-to"><DatePicker id="rp-to" value={search.to} onChange={(v) => set({ to: v })} /></Field>
{search.report !== 'profit_and_loss' && (
<Field label={t('page.account')} htmlFor="rp-account">
<Combobox id="rp-account" value={search.account ?? ''} onValueChange={(v) => set({ account: v || undefined })}
options={[{ value: '', label: t('page.allAccounts') }, ...accountOptions(accounts.data)]} />
</Field>
)}
{search.report === 'trial_balance' && (
<Checkbox id="rp-closing" checked={search.closing} onCheckedChange={(v) => set({ closing: v === true })} label={t('page.includeClosing')} />
)}
</>
)}
</div>
{search.report === 'trial_balance' && <TrialBalanceView params={{ ...range, closing: search.closing }} />}
{search.report === 'profit_and_loss' && <ProfitAndLossView params={{ from: search.from, to: search.to }} />}
{search.report === 'balance_sheet' && <BalanceSheetView asOf={asOf} />}
{search.report === 'general_ledger' && <GeneralLedgerView params={range} />}
<RecentExports />
</div>
);
}

Verify: Tabs in controlled mode (value / onValueChange, items without content), as a tab strip whose panels are rendered below.

web/src/features/reports/routes.tsx
import { createRoute } from '@tanstack/react-router';
import { z } from 'zod';
import { appRoute } from '@/app/layouts';
import { RequirePermission } from '@/app/auth';
import { monthKey, todayISO } from '@/lib/date';
import { ReportsPage } from './ReportsPage';
const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const search = z.object({
report: z.enum(['trial_balance', 'profit_and_loss', 'balance_sheet', 'general_ledger']).catch('trial_balance'),
from: date.catch(() => `${monthKey()}-01`),
to: date.catch(() => todayISO()),
as_of: date.optional().catch(undefined),
account: z.string().uuid().optional().catch(undefined),
closing: z.coerce.boolean().catch(true),
});
const reportsRoute = createRoute({
getParentRoute: () => appRoute,
path: '/reports',
validateSearch: (s: Record<string, unknown>) => search.parse(s),
component: function Reports() {
const s = reportsRoute.useSearch();
return <RequirePermission permission="reports.read"><ReportsPage search={s} /></RequirePermission>;
},
});
export const routes = [reportsRoute];

z.coerce.boolean() turns the string "false" into true. TanStack Router parses search values as JSON, so closing=false arrives as the boolean false. Verify this with a reload of /reports?closing=false. If it arrives as a string, use z.union([z.boolean(), z.enum(['true','false']).transform((v) => v === 'true')]).catch(true).

Append to app/routes.ts: import { routes as reportsRoutes } from '@/features/reports/routes'; and ...reportsRoutes,. Nav (import BarChart3):

{ group: 'manage', to: '/reports', labelKey: 'reports:nav', icon: BarChart3, permission: 'reports.read' },
  • Step 8: Run, and watch the totals test fail once
Terminal window
pnpm -C web test -- src/features/reports

Expected: PASS, 5 tests. Then, in TrialBalanceView, render the totals row’s debit from r.rows[0].debit instead of r.totals.debit. The page test must fail on 1,234.56. Restore.

  • Step 9: Typecheck, lint, drift
Terminal window
pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test -- src/drift
  • Step 10: Runtime check (as the Owner, with some postings from Tasks 12/23 present)
  1. Sidebar → Reports. It opens on the trial balance for this month. The totals row, the Balanced tag and the Provisional note show while the period is open.
  2. Switch to Profit and loss. Collapse an expense header: its children hide, and the total is unchanged. Change From to 1 January. The URL updates. Reload restores it.
  3. Balance sheet as of today: the three sections, the earnings lines, and Balanced.
  4. General ledger for the bank account. With more than 500 lines (unlikely on the dev stack), Load more appends and the account’s closing balance moves. Otherwise note in the report that paging was covered by the Vitest merge case.
  5. Export → CSV on the trial balance. The status reads Preparing the file…, then Your download has started., and the browser downloads trial_balance…csv from the object store. Check the console for a CSP violation: navigating to the presigned URL is not governed by CSP, and this is where it would show if it were. Recent exports shows the row as Ready, with Download working again.
  6. Export the GL as PDF over a whole year on a large ledger to see too_large, if the data allows. Otherwise rely on the Vitest case.
  7. At 390 the tables scroll horizontally inside their card, and the page itself does not. Dark and zh: the tabs and labels are translated, and account labels come from the server’s label.
  • Step 11: Commit
Terminal window
git add web/src/features/reports web/src/lib/i18n/locales/*/reports.json web/src/app/routes.ts web/src/app/shell/nav.ts web/src/lib/api/types.ts web/package.json web/pnpm-lock.yaml
git commit -m "$(cat <<'EOF'
web: reports (TB, P&L, balance sheet, GL detail) and exports
Date controls in the URL, expandable statement trees, server totals and
balanced/provisional flags only, GL keyset paging with sections joined
across pages, and export request -> poll -> presigned download, with the
recent exports list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"

Spec §5.7 item 8. This is the custodian’s view of every petty-cash float: the list with open, closing and closed status and the server’s cash_balance, committed and available; opening a float for a member; each float’s movement ledger; closing an untouched float directly; review of a holder’s close-out (confirm or withdraw); and a replenishment request for the caller’s own float. Task 16 built the holder’s page. This task adds the register beside it, in its own subfolder, so it touches none of Task 16’s files.

What the API gives:

  • GET /pettycash/floats?scope=all → { floats: PettyCashFloat[] }. scope=all needs pettycash.read_all or pettycash.float.manage, and 403 otherwise (not an empty list). PettyCashFloat: id, code, holder_member_id, holder_name, account_id, currency, cash_balance, committed, available, status (open|closing|closed), refund_proof_document_id, opened_on, closed_on.
  • POST /pettycash/floats body { holder_member_id } → 201 { float }. 409 when the chart has no active 1530 header, or the holder already holds a float that is not closed. Needs pettycash.float.manage.
  • GET /pettycash/floats/{id}/movements → { movements: FloatMovement[] }, newest first: id, occurred_at, amount (signed), kind (topup|topup_reversal|spend_payout|spend_payout_reversal|claim_settlement|claim_settlement_reversal|closeout), source_type, source_id, journal_entry_id, reverses_movement_id.
  • POST /pettycash/floats/{id}/close → { float }. Only for a float that never took or committed money. 409 otherwise (“close it out instead”). Needs float.manage.
  • POST /pettycash/floats/{id}/replenishments (no body) → 201 PettyCashTopUpView, sized by the server. Holder only (ErrNotHolder) and needs pettycash.float.disburse. 409 when nothing awaits replenishment.
  • GET /pettycash/closeouts/{id} → PettyCashCloseoutView { closeout{ id, reference, float_id, requested_by, amount, currency, proof_document_id, state, semantic }, available_transitions[], decisions[] }.
  • POST /pettycash/closeouts/{id}/transitions body { from, to, reason? }, Idempotency-Key required. confirm (requested → confirmed) empties and closes the float. withdraw (→ cancelled) reopens it.
  • There is no endpoint that lists close-outs. A custodian finds pending ones through the activity feed: GET /activity?subject=float_closeout (verb requested, subject_id = the close-out id; internal/platform/pettycash/closeout.go). Each is then read through GET /pettycash/closeouts/{id}, and those with an allowed transition are shown. That needs activity.read. Without it, the panel says so. This is recorded as an API gap in Part D notes.

Files:

  • Create: web/src/features/pettycash/register/api.ts
  • Create: web/src/features/pettycash/register/FloatRegisterPage.tsx, FloatRegisterPage.test.tsx
  • Create: web/src/features/pettycash/register/OpenFloatDialog.tsx
  • Create: web/src/features/pettycash/register/FloatDrawer.tsx
  • Create: web/src/features/pettycash/register/PendingCloseouts.tsx
  • Create: web/src/features/pettycash/register/CloseoutReviewPage.tsx, CloseoutReviewPage.test.tsx
  • Create: web/src/features/pettycash/register/__fixtures__/floats.ts
  • Create: web/src/features/pettycash/register/routes.tsx
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/floatRegister.json
  • Append: web/src/app/routes.ts, web/src/app/shell/nav.ts, web/src/lib/api/types.ts

Interfaces:

  • Consumes: api, unwrap, ProblemError, problemMessage, newIdempotencyKey (T6); MoneyText (T6); formatDate, formatDateTime (T6); useLocale (T6); Button, Combobox, ConfirmDialog, Dialog, Drawer, DataTable, EmptyState, Field, PageHeader, Skeleton, Tabs, Tag, Card, toast (T7); useMe, useCan, RequirePermission, navItems (T8); StatusChip, TransitionBar, DecisionTimeline, useMemberNames, useMemberDirectory (T9); DocumentThumb, DocumentViewer (T10); appRoute, featureRoutes, renderWithProviders, server.

  • Produces: routes (/petty-cash/floats with search { status?, float? }, /petty-cash/closeouts/$id); registerKeys; useAllFloats, useMovements, useOpenFloat, useCloseFloat, useReplenish, useCloseout, useCloseoutTransition, usePendingCloseoutIds. Type aliases (skip any Task 16 added): PettyCashFloat, FloatMovement, PettyCashCloseoutView, PettyCashTopUpView.

  • Step 1: Aliases

Terminal window
grep -nE "export type (PettyCashFloat|FloatMovement|PettyCashCloseoutView|PettyCashTopUpView) " web/src/lib/api/types.ts

Append the ones not already there:

export type PettyCashFloat = Schemas['PettyCashFloat'];
export type FloatMovement = Schemas['FloatMovement'];
export type PettyCashCloseoutView = Schemas['PettyCashCloseoutView'];
export type PettyCashTopUpView = Schemas['PettyCashTopUpView'];
  • Step 2: Queries — api.ts
import { useRef } from 'react';
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
import { api, newIdempotencyKey } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
import { useCan } from '@/app/auth';
export const registerKeys = {
floats: ['pettycash', 'floats', 'all'] as const,
movements: (id: string) => ['pettycash', 'floats', id, 'movements'] as const,
closeout: (id: string) => ['pettycash', 'closeouts', id] as const,
pending: ['pettycash', 'closeouts', 'pending-activity'] as const,
};
export const useAllFloats = () => useQuery({
queryKey: registerKeys.floats,
queryFn: async () => unwrap(await api.GET('/pettycash/floats', { params: { query: { scope: 'all' } } })).floats,
});
export const useMovements = (id: string) => useQuery({
queryKey: registerKeys.movements(id),
queryFn: async () => unwrap(await api.GET('/pettycash/floats/{id}/movements', { params: { path: { id } } })).movements,
});
function useInvalidatePettyCash() {
const qc = useQueryClient();
return () => qc.invalidateQueries({ queryKey: ['pettycash'] });
}
export function useOpenFloat() {
const done = useInvalidatePettyCash();
return useMutation({
mutationFn: async (holder: string) => unwrap(await api.POST('/pettycash/floats', { body: { holder_member_id: holder } })).float,
onSuccess: done,
});
}
export function useCloseFloat() {
const done = useInvalidatePettyCash();
return useMutation({
mutationFn: async (id: string) => unwrap(await api.POST('/pettycash/floats/{id}/close', { params: { path: { id } } })).float,
onSuccess: done,
});
}
export function useReplenish() {
const done = useInvalidatePettyCash();
return useMutation({
mutationFn: async (id: string) => unwrap(await api.POST('/pettycash/floats/{id}/replenishments', { params: { path: { id } } })),
onSuccess: done,
});
}
export const useCloseout = (id: string) => useQuery({
queryKey: registerKeys.closeout(id),
queryFn: async () => unwrap(await api.GET('/pettycash/closeouts/{id}', { params: { path: { id } } })),
});
/** One Idempotency-Key per user action, reused if the same action is retried (spec 3.1). */
export function useCloseoutTransition(id: string) {
const done = useInvalidatePettyCash();
const keys = useRef(new Map<string, string>());
return useMutation({
mutationFn: async (v: { from: string; to: string; reason?: string }) => {
const action = `${v.from}->${v.to}`;
const key = keys.current.get(action) ?? newIdempotencyKey();
keys.current.set(action, key);
const r = unwrap(await api.POST('/pettycash/closeouts/{id}/transitions', {
params: { path: { id }, header: { 'Idempotency-Key': key } },
body: v.reason ? { from: v.from, to: v.to, reason: v.reason } : { from: v.from, to: v.to },
}));
keys.current.delete(action);
return r;
},
onSuccess: done,
});
}
/** Close-outs awaiting a decision. There is no list endpoint; the activity feed names every
* requested close-out, and each is read to see whether the caller can act on it now. */
export function usePendingCloseoutIds() {
const canReadActivity = useCan('activity.read');
const feed = useQuery({
queryKey: registerKeys.pending,
enabled: canReadActivity,
queryFn: async () => unwrap(await api.GET('/activity', { params: { query: { subject: 'float_closeout', limit: 50 } } })).items,
});
const ids = [...new Set((feed.data ?? []).filter((i) => i.verb === 'requested').map((i) => i.subject_id))];
const views = useQueries({
queries: ids.map((id) => ({
queryKey: registerKeys.closeout(id),
queryFn: async () => unwrap(await api.GET('/pettycash/closeouts/{id}', { params: { path: { id } } })),
})),
});
const pending = views.flatMap((v) => (v.data && v.data.available_transitions.some((t) => t.allowed) ? [v.data] : []));
return { canReadActivity, pending, isPending: feed.isPending && canReadActivity };
}

Verify: activity.read is the key in internal/modules/activity/permissions.go. It is not in constants.yaml or packages/contracts/vocab.ts, so it works only because the registry widens Permission with | string. Confirm the Owner holds it after provisioning. Verify: that newIdempotencyKey is exported from lib/api/client.ts (the registry places it there).

  • Step 3: i18n bundles

en/floatRegister.json:

{
"nav": "Float register",
"page": {
"title": "Float register",
"subtitle": "Every petty-cash float, its cash and what is already committed against it.",
"open": "Open a float",
"tabs": { "open": "Open", "closing": "Closing", "closed": "Closed", "all": "All" },
"empty": "No floats here."
},
"cols": { "code": "Float", "holder": "Holder", "status": "Status", "cash": "Cash in tin", "committed": "Committed", "available": "Available", "opened": "Opened" },
"status": { "open": "Open", "closing": "Closing", "closed": "Closed" },
"openDialog": {
"title": "Open a float",
"holder": "Holder",
"hint": "A member may hold one float that is not closed. It opens at zero; the holder then requests a top-up.",
"confirm": "Open float",
"cancel": "Cancel",
"opened": "Float {{code}} opened for {{name}}"
},
"drawer": {
"title": "{{code}} · {{name}}",
"opened": "Opened {{date}}",
"closed": "Closed {{date}}",
"refundProof": "Refund proof",
"movements": "Movements",
"noMovements": "No cash has moved through this float yet.",
"when": "When",
"kind": "Movement",
"amount": "Amount",
"reversal": "reversal",
"replenish": "Request replenishment",
"replenishHint": "The amount is worked out from the float's approved payouts since the last replenishment.",
"replenished": "Replenishment {{reference}} requested for {{amount}}",
"close": "Close float",
"closeTitle": "Close {{code}}?",
"closeBody": "Only a float that never held or committed any money can be closed directly. One that did is returned through a close-out instead.",
"closedToast": "Float closed"
},
"kinds": {
"topup": "Top-up",
"topup_reversal": "Top-up reversed",
"spend_payout": "Spend paid",
"spend_payout_reversal": "Spend payment reversed",
"claim_settlement": "Claim paid from the tin",
"claim_settlement_reversal": "Claim payment reversed",
"closeout": "Close-out"
},
"pending": {
"title": "Close-outs awaiting a decision",
"none": "Nothing is waiting for you.",
"noActivity": "You need permission to read the activity feed to see pending close-outs here. Open a close-out from its link instead.",
"review": "Review"
},
"closeout": {
"title": "Close-out {{reference}}",
"float": "Float",
"requestedBy": "Requested by",
"amount": "Cash returned",
"amountHint": "Frozen at the float's cash balance when the close-out was requested.",
"proof": "Refund proof",
"history": "History",
"done": "Close-out updated",
"back": "Back to the register",
"notFound": "This close-out was not found, or you cannot see it."
}
}

ms/floatRegister.json:

{
"nav": "Daftar apungan",
"page": {
"title": "Daftar apungan",
"subtitle": "Setiap apungan wang runcit, tunainya dan apa yang telah dikomitkan terhadapnya.",
"open": "Buka apungan",
"tabs": { "open": "Dibuka", "closing": "Sedang ditutup", "closed": "Ditutup", "all": "Semua" },
"empty": "Tiada apungan di sini."
},
"cols": { "code": "Apungan", "holder": "Pemegang", "status": "Status", "cash": "Tunai dalam tin", "committed": "Dikomitkan", "available": "Tersedia", "opened": "Dibuka" },
"status": { "open": "Dibuka", "closing": "Sedang ditutup", "closed": "Ditutup" },
"openDialog": {
"title": "Buka apungan",
"holder": "Pemegang",
"hint": "Seorang ahli boleh memegang satu apungan yang belum ditutup. Ia dibuka pada sifar; pemegang kemudian memohon tambahan.",
"confirm": "Buka apungan",
"cancel": "Batal",
"opened": "Apungan {{code}} dibuka untuk {{name}}"
},
"drawer": {
"title": "{{code}} · {{name}}",
"opened": "Dibuka {{date}}",
"closed": "Ditutup {{date}}",
"refundProof": "Bukti pemulangan",
"movements": "Pergerakan",
"noMovements": "Belum ada tunai bergerak melalui apungan ini.",
"when": "Bila",
"kind": "Pergerakan",
"amount": "Amaun",
"reversal": "pembalikan",
"replenish": "Mohon penambahan semula",
"replenishHint": "Amaun dikira daripada pembayaran apungan yang diluluskan sejak penambahan semula terakhir.",
"replenished": "Penambahan semula {{reference}} dimohon untuk {{amount}}",
"close": "Tutup apungan",
"closeTitle": "Tutup {{code}}?",
"closeBody": "Hanya apungan yang tidak pernah memegang atau mengkomitkan wang boleh ditutup terus. Apungan yang pernah berbuat demikian dipulangkan melalui penutupan.",
"closedToast": "Apungan ditutup"
},
"kinds": {
"topup": "Tambahan",
"topup_reversal": "Tambahan dibalikkan",
"spend_payout": "Perbelanjaan dibayar",
"spend_payout_reversal": "Bayaran perbelanjaan dibalikkan",
"claim_settlement": "Tuntutan dibayar dari tin",
"claim_settlement_reversal": "Bayaran tuntutan dibalikkan",
"closeout": "Penutupan"
},
"pending": {
"title": "Penutupan menunggu keputusan",
"none": "Tiada apa-apa menunggu anda.",
"noActivity": "Anda memerlukan kebenaran membaca suapan aktiviti untuk melihat penutupan yang tertunggak di sini. Buka penutupan melalui pautannya.",
"review": "Semak"
},
"closeout": {
"title": "Penutupan {{reference}}",
"float": "Apungan",
"requestedBy": "Dimohon oleh",
"amount": "Tunai dipulangkan",
"amountHint": "Ditetapkan pada baki tunai apungan semasa penutupan dimohon.",
"proof": "Bukti pemulangan",
"history": "Sejarah",
"done": "Penutupan dikemas kini",
"back": "Kembali ke daftar",
"notFound": "Penutupan ini tidak dijumpai, atau anda tidak boleh melihatnya."
}
}

zh/floatRegister.json:

{
"nav": "备用金登记簿",
"page": {
"title": "备用金登记簿",
"subtitle": "所有备用金、其现金余额以及已占用的金额。",
"open": "开立备用金",
"tabs": { "open": "使用中", "closing": "结清中", "closed": "已关闭", "all": "全部" },
"empty": "这里没有备用金。"
},
"cols": { "code": "备用金", "holder": "保管人", "status": "状态", "cash": "现金余额", "committed": "已占用", "available": "可用", "opened": "开立日期" },
"status": { "open": "使用中", "closing": "结清中", "closed": "已关闭" },
"openDialog": {
"title": "开立备用金",
"holder": "保管人",
"hint": "每位成员只能持有一个未关闭的备用金。开立时余额为零,之后由保管人申请补充。",
"confirm": "开立",
"cancel": "取消",
"opened": "已为 {{name}} 开立备用金 {{code}}"
},
"drawer": {
"title": "{{code}} · {{name}}",
"opened": "{{date}} 开立",
"closed": "{{date}} 关闭",
"refundProof": "退还凭证",
"movements": "流水",
"noMovements": "此备用金尚无现金流动。",
"when": "时间",
"kind": "类型",
"amount": "金额",
"reversal": "冲销",
"replenish": "申请补足",
"replenishHint": "金额根据上次补足以来已批准的支付自动计算。",
"replenished": "已申请补足 {{reference}},金额 {{amount}}",
"close": "关闭备用金",
"closeTitle": "关闭 {{code}}?",
"closeBody": "只有从未持有或占用资金的备用金才能直接关闭;否则须通过结清流程退还。",
"closedToast": "备用金已关闭"
},
"kinds": {
"topup": "补充",
"topup_reversal": "补充已冲销",
"spend_payout": "支出已支付",
"spend_payout_reversal": "支出付款已冲销",
"claim_settlement": "从备用金支付报销",
"claim_settlement_reversal": "报销付款已冲销",
"closeout": "结清"
},
"pending": {
"title": "待审批的结清申请",
"none": "没有等待您处理的事项。",
"noActivity": "您需要查看动态的权限才能在此看到待处理的结清申请。请通过链接打开结清申请。",
"review": "审核"
},
"closeout": {
"title": "结清 {{reference}}",
"float": "备用金",
"requestedBy": "申请人",
"amount": "退还现金",
"amountHint": "按提出结清申请时的备用金现金余额锁定。",
"proof": "退还凭证",
"history": "记录",
"done": "结清申请已更新",
"back": "返回登记簿",
"notFound": "找不到此结清申请,或您无权查看。"
}
}
  • Step 4: Fixtures and failing tests
web/src/features/pettycash/register/__fixtures__/floats.ts
import type { PettyCashCloseoutView, PettyCashFloat } from '@/lib/api/types';
export const ME = '00000000-0000-4000-8000-0000000000b1';
export const AISYAH = '00000000-0000-4000-8000-0000000000b2';
export const floats: PettyCashFloat[] = [
{ id: 'f1', code: 'PF-2026-0001', holder_member_id: ME, holder_name: 'Owner Person', account_id: 'a1531', currency: 'MYR',
cash_balance: '500.0000', committed: '120.5000', available: '379.5000', status: 'open', refund_proof_document_id: null, opened_on: '2026-09-01', closed_on: null },
{ id: 'f2', code: 'PF-2026-0002', holder_member_id: AISYAH, holder_name: 'Nur Aisyah', account_id: 'a1532', currency: 'MYR',
cash_balance: '80.0000', committed: '0.0000', available: '80.0000', status: 'closing', refund_proof_document_id: 'doc-proof', opened_on: '2026-08-01', closed_on: null },
];
export const closeoutView: PettyCashCloseoutView = {
closeout: { id: 'co1', reference: 'PX-2026-0001', float_id: 'f2', requested_by: AISYAH, amount: '80.0000', currency: 'MYR',
proof_document_id: 'doc-proof', state: 'requested', semantic: 'pending' },
available_transitions: [
{ to: 'confirmed', action_key: 'confirm', gate: 'target', requires_reason: false, allowed: true, labels: { en: 'Confirm', ms: 'Sahkan', zh: '确认' } },
{ to: 'cancelled', action_key: 'withdraw', gate: 'reporter', requires_reason: true, allowed: false, labels: { en: 'Withdraw', ms: 'Tarik balik', zh: '撤回' },
refusal: { code: 'not_reporter', detail: 'only the requester may withdraw' } },
],
decisions: [],
};

Verify: the float_closeout graph’s real state keys and edge labels (internal/platform/pettycash/graphs.go or wherever the kind is registered). The fixture uses requested, confirmed and cancelled from the OpenAPI description. Correct the labels to the seeded ones.

web/src/features/pettycash/register/FloatRegisterPage.test.tsx
import { describe, expect, it } from 'vitest';
import { http, HttpResponse } from 'msw';
import { screen, waitFor, within } from '@testing-library/react';
import { server } from '@/test/msw/server';
import { renderWithProviders } from '@/test/render';
import { AISYAH, floats, ME } from './__fixtures__/floats';
import { FloatRegisterPage } from './FloatRegisterPage';
const CUSTODIAN = ['pettycash.read', 'pettycash.read_all', 'pettycash.float.manage', 'pettycash.float.disburse'];
function serve() {
server.use(
http.get('/v1/pettycash/floats', ({ request }) => {
expect(new URL(request.url).searchParams.get('scope')).toBe('all');
return HttpResponse.json({ floats });
}),
http.get('/v1/pettycash/floats/:id/movements', () => HttpResponse.json({ movements: [
{ id: 'mv1', occurred_at: '2026-09-02T02:00:00Z', amount: '500.0000', kind: 'topup', source_type: 'float_topup', source_id: 't1', journal_entry_id: 'e1', reverses_movement_id: null },
] })),
http.get('/v1/org/members', () => HttpResponse.json({ members: [
{ member_id: ME, name: 'Owner Person', active: true }, { member_id: AISYAH, name: 'Nur Aisyah', active: true },
] })),
http.get('/v1/activity', () => HttpResponse.json({ items: [] })),
);
}
describe('FloatRegisterPage', () => {
it('shows the server’s balances for every float, filtered by status tab', async () => {
serve();
renderWithProviders(<FloatRegisterPage search={{ status: 'all' }} />, { permissions: CUSTODIAN, memberId: ME });
const row = (await screen.findByText('PF-2026-0001')).closest('tr')!;
expect(within(row).getByText(/379\.50/)).toBeInTheDocument();
expect(within(row).getByText(/120\.50/)).toBeInTheDocument();
expect(screen.getByText('PF-2026-0002')).toBeInTheDocument();
});
it('offers replenishment on the caller’s own float only', async () => {
serve();
renderWithProviders(<FloatRegisterPage search={{ status: 'all', float: 'f1' }} />, { permissions: CUSTODIAN, memberId: ME });
const own = await screen.findByRole('dialog', { name: /PF-2026-0001/ });
expect(within(own).getByRole('button', { name: 'Request replenishment' })).toBeInTheDocument();
expect(await within(own).findByText('Top-up')).toBeInTheDocument();
});
it('does not offer replenishment on someone else’s float', async () => {
serve();
renderWithProviders(<FloatRegisterPage search={{ status: 'all', float: 'f2' }} />, { permissions: CUSTODIAN, memberId: ME });
const other = await screen.findByRole('dialog', { name: /PF-2026-0002/ });
// presence: the drawer and its movements rendered
expect(await within(other).findByText('Top-up')).toBeInTheDocument();
expect(within(other).queryByRole('button', { name: 'Request replenishment' })).toBeNull();
});
it('opens a float for the chosen member', async () => {
serve();
const bodies: unknown[] = [];
server.use(http.post('/v1/pettycash/floats', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ float: { ...floats[0], id: 'f3', code: 'PF-2026-0003', holder_member_id: AISYAH, holder_name: 'Nur Aisyah' } }, { status: 201 });
}));
const { user } = renderWithProviders(<FloatRegisterPage search={{ status: 'open' }} />, { permissions: CUSTODIAN, memberId: ME });
await user.click(await screen.findByRole('button', { name: 'Open a float' }));
const dlg = await screen.findByRole('dialog', { name: 'Open a float' });
await user.click(within(dlg).getByLabelText('Holder'));
await user.click(await screen.findByRole('option', { name: 'Nur Aisyah' }));
await user.click(within(dlg).getByRole('button', { name: 'Open float' }));
await waitFor(() => expect(bodies).toEqual([{ holder_member_id: AISYAH }]));
});
it('hides Open a float from a reader without float.manage', async () => {
serve();
renderWithProviders(<FloatRegisterPage search={{ status: 'all' }} />, { permissions: ['pettycash.read', 'pettycash.read_all'], memberId: ME });
expect(await screen.findByText('PF-2026-0001')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Open a float' })).toBeNull();
});
});

renderWithProviders(…, { memberId }) sets me.member_id. Verify: the helper supports it. If it does not, add the option in Task 6’s helper (append-only change, noted in the report).

web/src/features/pettycash/register/CloseoutReviewPage.test.tsx
import { describe, expect, it } from 'vitest';
import { http, HttpResponse } from 'msw';
import { screen, waitFor } from '@testing-library/react';
import { server } from '@/test/msw/server';
import { renderWithProviders } from '@/test/render';
import { closeoutView, floats } from './__fixtures__/floats';
import { CloseoutReviewPage } from './CloseoutReviewPage';
describe('CloseoutReviewPage', () => {
it('confirms with the current state as `from` and an Idempotency-Key', async () => {
const seen: { body: unknown; key: string | null }[] = [];
server.use(
http.get('/v1/pettycash/closeouts/co1', () => HttpResponse.json(closeoutView)),
http.get('/v1/pettycash/floats/f2', () => HttpResponse.json({ float: floats[1] })),
http.get('/v1/org/members', () => HttpResponse.json({ members: [] })),
http.get('/v1/workflow/definitions/float_closeout', () => HttpResponse.json({ id: 'd', kind: 'float_closeout', version: 1, published_at: '2026-09-01T00:00:00Z', graph: {
kind: 'float_closeout', posting_order: 'cash_only', edges: [], grants: [],
states: [
{ key: 'requested', semantic: 'pending', display_order: 1, is_terminal: false, labels: { en: 'Requested' } },
{ key: 'confirmed', semantic: 'payout', display_order: 2, is_terminal: true, labels: { en: 'Confirmed' } },
] } })),
http.get('/v1/documents/doc-proof', () => HttpResponse.json({ id: 'doc-proof', filename: 'proof.jpg' })),
http.post('/v1/pettycash/closeouts/co1/transitions', async ({ request }) => {
seen.push({ body: await request.json(), key: request.headers.get('Idempotency-Key') });
return HttpResponse.json({ ...closeoutView, closeout: { ...closeoutView.closeout, state: 'confirmed', semantic: 'payout' }, available_transitions: [] });
}),
);
const { user } = renderWithProviders(<CloseoutReviewPage id="co1" />, { permissions: ['pettycash.read', 'pettycash.transition', 'pettycash.float.manage'] });
// presence of the disabled Withdraw with its refusal, before acting
expect(await screen.findByRole('button', { name: 'Withdraw' })).toBeDisabled();
await user.click(screen.getByRole('button', { name: 'Confirm' }));
await waitFor(() => expect(seen).toHaveLength(1));
expect(seen[0].body).toEqual({ from: 'requested', to: 'confirmed' });
expect(seen[0].key).toMatch(/^[0-9a-f-]{36}$/);
});
});

Verify: the document read path Task 10’s DocumentThumb calls (GET /documents/{id} per spec §5.2). The handler above answers it with a minimal body. Match Task 10’s MSW fixture shape so the thumbnail renders without errors.

Run pnpm -C web test -- src/features/pettycash/register. Expected FAIL: cannot resolve the page modules.

  • Step 5: Implement
web/src/features/pettycash/register/OpenFloatDialog.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Combobox, Dialog, Field, toast } from '@/components/ui';
import { useMemberDirectory } from '@/components/workflow';
import { problemMessage } from '@/lib/api/problem';
import { useOpenFloat } from './api';
export function OpenFloatDialog({ onClose, onOpened }: { onClose(): void; onOpened(id: string): void }) {
const { t } = useTranslation('floatRegister');
const directory = useMemberDirectory();
const open = useOpenFloat();
const [holder, setHolder] = useState('');
return (
<Dialog open onOpenChange={(o) => !o && onClose()} title={t('openDialog.title')} description={t('openDialog.hint')}
footer={
<>
<Button variant="ghost" onClick={onClose}>{t('openDialog.cancel')}</Button>
<Button variant="primary" disabled={!holder} loading={open.isPending}
onClick={() => open.mutate(holder, {
onSuccess: (f) => { toast.success(t('openDialog.opened', { code: f.code, name: f.holder_name })); onOpened(f.id); },
onError: (e) => toast.error(problemMessage(e, t)),
})}>{t('openDialog.confirm')}</Button>
</>
}>
<Field label={t('openDialog.holder')} htmlFor="fr-holder">
<Combobox id="fr-holder" value={holder} onValueChange={setHolder}
options={(directory.data ?? []).filter((m) => m.active).map((m) => ({ value: m.member_id, label: m.name ?? m.member_id }))} />
</Field>
</Dialog>
);
}
web/src/features/pettycash/register/FloatDrawer.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, ConfirmDialog, DataTable, Drawer, EmptyState, Skeleton, Tag, toast } from '@/components/ui';
import { MoneyText } from '@/components/money';
import { DocumentThumb, DocumentViewer } from '@/components/upload';
import { useCan, useMe } from '@/app/auth';
import { problemMessage } from '@/lib/api/problem';
import { formatDate, formatDateTime } from '@/lib/date';
import { formatMoney } from '@/lib/money';
import { useLocale } from '@/lib/i18n';
import type { FloatMovement, PettyCashFloat } from '@/lib/api/types';
import { useCloseFloat, useMovements, useReplenish } from './api';
export function FloatDrawer({ float: f, onClose }: { float: PettyCashFloat; onClose(): void }) {
const { t } = useTranslation('floatRegister');
const [locale] = useLocale();
const me = useMe();
const canManage = useCan('pettycash.float.manage');
const canDisburse = useCan('pettycash.float.disburse');
const movements = useMovements(f.id);
const replenish = useReplenish();
const close = useCloseFloat();
const [confirmClose, setConfirmClose] = useState(false);
const [viewing, setViewing] = useState(false);
const isHolder = me.data?.member_id === f.holder_member_id;
return (
<Drawer open onOpenChange={(o) => !o && onClose()} header={t('drawer.title', { code: f.code, name: f.holder_name })}>
<div className="grid gap-4">
<dl className="grid grid-cols-3 gap-2 text-sm">
<div><dt className="text-muted">{t('cols.cash')}</dt><dd className="font-medium"><MoneyText amount={f.cash_balance} currency={f.currency} /></dd></div>
<div><dt className="text-muted">{t('cols.committed')}</dt><dd><MoneyText amount={f.committed} currency={f.currency} /></dd></div>
<div><dt className="text-muted">{t('cols.available')}</dt><dd className="font-medium"><MoneyText amount={f.available} currency={f.currency} /></dd></div>
</dl>
<p className="text-sm text-muted">
{t('drawer.opened', { date: formatDate(f.opened_on, locale) })}
{f.closed_on && ` · ${t('drawer.closed', { date: formatDate(f.closed_on, locale) })}`}
</p>
{f.refund_proof_document_id && (
<div className="grid gap-1">
<p className="text-sm font-medium">{t('drawer.refundProof')}</p>
<DocumentThumb documentId={f.refund_proof_document_id} onOpen={() => setViewing(true)} />
<DocumentViewer documentIds={[f.refund_proof_document_id]} index={0} open={viewing} onOpenChange={setViewing} />
</div>
)}
<div className="flex flex-wrap gap-2">
{isHolder && canDisburse && f.status === 'open' && (
<Button variant="outline" loading={replenish.isPending} title={t('drawer.replenishHint')}
onClick={() => replenish.mutate(f.id, {
onSuccess: (v) => toast.success(t('drawer.replenished', { reference: v.topup.reference, amount: formatMoney(v.topup.amount, v.topup.currency, locale) })),
onError: (e) => toast.error(problemMessage(e, t)),
})}>{t('drawer.replenish')}</Button>
)}
{canManage && f.status === 'open' && (
<Button variant="danger-outline" onClick={() => setConfirmClose(true)}>{t('drawer.close')}</Button>
)}
</div>
<h3 className="text-sm font-medium">{t('drawer.movements')}</h3>
{movements.isPending ? <Skeleton className="h-32 w-full" /> : (
<DataTable<FloatMovement>
data={movements.data ?? []}
empty={<EmptyState title={t('drawer.noMovements')} />}
columns={[
{ id: 'when', header: t('drawer.when'), cell: ({ row }) => formatDateTime(row.original.occurred_at, locale) },
{ id: 'kind', header: t('drawer.kind'), cell: ({ row }) => (
<span className="flex items-center gap-1">
<Tag tone={row.original.kind.endsWith('_reversal') ? 'muted' : 'neutral'}>{t(`kinds.${row.original.kind}`)}</Tag>
{row.original.reverses_movement_id && <span className="text-xs text-muted">{t('drawer.reversal')}</span>}
</span>) },
{ id: 'amount', header: t('drawer.amount'), cell: ({ row }) => (
<MoneyText amount={row.original.amount} currency={f.currency} sign="always" className="tabular-nums" />) },
]}
/>
)}
</div>
<ConfirmDialog open={confirmClose} onOpenChange={setConfirmClose} tone="danger"
title={t('drawer.closeTitle', { code: f.code })} description={t('drawer.closeBody')} confirmLabel={t('drawer.close')}
onConfirm={() => {
setConfirmClose(false);
close.mutate(f.id, { onSuccess: () => toast.success(t('drawer.closedToast')), onError: (e) => toast.error(problemMessage(e, t)) });
}} />
</Drawer>
);
}

The Close button is shown on every open float, not only on empty ones. Whether the float “never took money” is a history question the server answers (409 “close it out instead”). The confirm dialog tells the custodian that before they click.

web/src/features/pettycash/register/PendingCloseouts.tsx
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { Card, EmptyState, Skeleton } from '@/components/ui';
import { MoneyText } from '@/components/money';
import { useMemberNames } from '@/components/workflow';
import { usePendingCloseoutIds } from './api';
export function PendingCloseouts() {
const { t } = useTranslation('floatRegister');
const nameOf = useMemberNames();
const { canReadActivity, pending, isPending } = usePendingCloseoutIds();
return (
<Card className="grid gap-2">
<h2 className="text-base font-medium">{t('pending.title')}</h2>
{!canReadActivity && <p className="text-sm text-muted">{t('pending.noActivity')}</p>}
{canReadActivity && isPending && <Skeleton className="h-12 w-full" />}
{canReadActivity && !isPending && pending.length === 0 && <EmptyState title={t('pending.none')} />}
<ul className="grid gap-1">
{pending.map((v) => (
<li key={v.closeout.id} className="flex flex-wrap items-center gap-2 text-sm">
<code>{v.closeout.reference}</code>
<span>{nameOf(v.closeout.requested_by)}</span>
<MoneyText amount={v.closeout.amount} currency={v.closeout.currency} />
<Link to="/petty-cash/closeouts/$id" params={{ id: v.closeout.id }} className="ms-auto font-medium text-primary">{t('pending.review')}</Link>
</li>
))}
</ul>
</Card>
);
}
web/src/features/pettycash/register/FloatRegisterPage.tsx
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { Button, DataTable, EmptyState, PageHeader, Tabs, Tag } from '@/components/ui';
import { MoneyText } from '@/components/money';
import { useCan } from '@/app/auth';
import { formatDate } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import type { PettyCashFloat } from '@/lib/api/types';
import { useAllFloats } from './api';
import { FloatDrawer } from './FloatDrawer';
import { OpenFloatDialog } from './OpenFloatDialog';
import { PendingCloseouts } from './PendingCloseouts';
export type StatusTab = 'open' | 'closing' | 'closed' | 'all';
export interface RegisterSearch { status: StatusTab; float?: string }
const TONE = { open: 'green', closing: 'amber', closed: 'muted' } as const;
export function FloatRegisterPage({ search }: { search: RegisterSearch }) {
const { t } = useTranslation('floatRegister');
const [locale] = useLocale();
const navigate = useNavigate();
const canManage = useCan('pettycash.float.manage');
const floats = useAllFloats();
const [opening, setOpening] = useState(false);
const set = (patch: Partial<RegisterSearch>) => navigate({ to: '/petty-cash/floats', search: (s: RegisterSearch) => ({ ...s, ...patch }) });
const rows = (floats.data ?? []).filter((f) => search.status === 'all' || f.status === search.status);
const selected = floats.data?.find((f) => f.id === search.float);
return (
<div className="grid gap-4">
<PageHeader title={t('page.title')} subtitle={t('page.subtitle')}
actions={canManage ? <Button variant="primary" onClick={() => setOpening(true)}>{t('page.open')}</Button> : undefined} />
<PendingCloseouts />
<Tabs value={search.status} onValueChange={(v) => set({ status: v as StatusTab })}
items={(['open', 'closing', 'closed', 'all'] as const).map((k) => ({ value: k, label: t(`page.tabs.${k}`) }))} />
<DataTable<PettyCashFloat>
loading={floats.isPending}
data={rows}
onRowClick={(f) => set({ float: f.id })}
empty={<EmptyState title={t('page.empty')} />}
columns={[
{ id: 'code', header: t('cols.code'), cell: ({ row }) => <code>{row.original.code}</code> },
{ id: 'holder', header: t('cols.holder'), cell: ({ row }) => row.original.holder_name },
{ id: 'status', header: t('cols.status'), cell: ({ row }) => <Tag tone={TONE[row.original.status]}>{t(`status.${row.original.status}`)}</Tag> },
{ id: 'cash', header: t('cols.cash'), cell: ({ row }) => <MoneyText amount={row.original.cash_balance} currency={row.original.currency} /> },
{ id: 'committed', header: t('cols.committed'), cell: ({ row }) => <MoneyText amount={row.original.committed} currency={row.original.currency} /> },
{ id: 'available', header: t('cols.available'), cell: ({ row }) => <MoneyText amount={row.original.available} currency={row.original.currency} /> },
{ id: 'opened', header: t('cols.opened'), cell: ({ row }) => formatDate(row.original.opened_on, locale) },
]}
/>
{selected && <FloatDrawer key={selected.id} float={selected} onClose={() => set({ float: undefined })} />}
{opening && <OpenFloatDialog onClose={() => setOpening(false)} onOpened={(id) => { setOpening(false); set({ status: 'open', float: id }); }} />}
</div>
);
}

There is no cross-float total row. That would be a client-side money sum (F13), and the API offers none.

web/src/features/pettycash/register/CloseoutReviewPage.tsx
import { useState } from 'react';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { Card, EmptyState, PageHeader, Skeleton, toast } from '@/components/ui';
import { MoneyText } from '@/components/money';
import { DecisionTimeline, StatusChip, TransitionBar, useMemberNames } from '@/components/workflow';
import { DocumentThumb, DocumentViewer } from '@/components/upload';
import { api } from '@/lib/api/client';
import { problemMessage, unwrap } from '@/lib/api/problem';
import { useQuery } from '@tanstack/react-query';
import type { WorkflowSemantic } from '@/lib/api/types';
import { useCloseout, useCloseoutTransition } from './api';
export function CloseoutReviewPage({ id }: { id: string }) {
const { t } = useTranslation('floatRegister');
const nameOf = useMemberNames();
const view = useCloseout(id);
const move = useCloseoutTransition(id);
const [viewing, setViewing] = useState(false);
const floatId = view.data?.closeout.float_id;
const float = useQuery({
queryKey: ['pettycash', 'floats', floatId],
enabled: floatId !== undefined,
queryFn: async () => unwrap(await api.GET('/pettycash/floats/{id}', { params: { path: { id: floatId! } } })).float,
});
if (view.isPending) return <Skeleton className="h-64 w-full" />;
if (view.isError) return <EmptyState title={t('closeout.notFound')} />;
const { closeout: c, available_transitions, decisions } = view.data;
return (
<div className="grid gap-4">
<PageHeader title={t('closeout.title', { reference: c.reference })}
actions={<Link to="/petty-cash/floats" search={{ status: 'closing' }} className="text-sm text-primary">{t('closeout.back')}</Link>} />
<Card className="grid gap-3">
<StatusChip kind="float_closeout" state={c.state} semantic={c.semantic as WorkflowSemantic} />
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
<dt className="text-muted">{t('closeout.float')}</dt><dd>{float.data ? `${float.data.code} · ${float.data.holder_name}` : '…'}</dd>
<dt className="text-muted">{t('closeout.requestedBy')}</dt><dd>{nameOf(c.requested_by)}</dd>
<dt className="text-muted">{t('closeout.amount')}</dt>
<dd><MoneyText amount={c.amount} currency={c.currency} className="font-medium" /><p className="text-xs text-muted">{t('closeout.amountHint')}</p></dd>
</dl>
<div className="grid gap-1">
<p className="text-sm font-medium">{t('closeout.proof')}</p>
<DocumentThumb documentId={c.proof_document_id} onOpen={() => setViewing(true)} />
<DocumentViewer documentIds={[c.proof_document_id]} index={0} open={viewing} onOpenChange={setViewing} />
</div>
<TransitionBar transitions={available_transitions}
onTake={(tr, reason) => move.mutateAsync({ from: c.state, to: tr.to, reason }).then(
() => toast.success(t('closeout.done')),
(e) => { toast.error(problemMessage(e, t)); throw e; },
)} />
</Card>
<Card className="grid gap-2">
<h2 className="text-base font-medium">{t('closeout.history')}</h2>
<DecisionTimeline kind="float_closeout" version={1} currentState={c.state}
decisions={decisions.map((d) => ({ from: d.from, to: d.to, actor_member_id: d.actor_member_id, decided_at: d.decided_at, reason: d.reason || undefined, action_key: `${d.from}->${d.to}` }))} />
</Card>
</div>
);
}

PettyCashCloseout.semantic is a plain string in the schema, hence the cast to WorkflowSemantic. Verify against Task 9: DecisionTimeline requires the instance’s version, and PettyCashCloseoutView does not carry one (see API gaps). version={1} is right until float_closeout is republished. If Task 9’s version accepts undefined to mean “latest”, pass that instead. PettyCashDecision has no action_key, so the from->to string stands in for it.

web/src/features/pettycash/register/routes.tsx
import { createRoute } from '@tanstack/react-router';
import { z } from 'zod';
import { appRoute } from '@/app/layouts';
import { RequirePermission } from '@/app/auth';
import { FloatRegisterPage } from './FloatRegisterPage';
import { CloseoutReviewPage } from './CloseoutReviewPage';
const search = z.object({
status: z.enum(['open', 'closing', 'closed', 'all']).catch('open'),
float: z.string().optional().catch(undefined),
});
const registerRoute = createRoute({
getParentRoute: () => appRoute,
path: '/petty-cash/floats',
validateSearch: (s: Record<string, unknown>) => search.parse(s),
component: function Register() {
const s = registerRoute.useSearch();
return (
<RequirePermission permission={['pettycash.read_all', 'pettycash.float.manage']}>
<FloatRegisterPage search={s} />
</RequirePermission>
);
},
});
const closeoutRoute = createRoute({
getParentRoute: () => appRoute,
path: '/petty-cash/closeouts/$id',
component: function Closeout() {
const { id } = closeoutRoute.useParams();
return <RequirePermission permission="pettycash.read"><CloseoutReviewPage id={id} /></RequirePermission>;
},
});
export const routes = [registerRoute, closeoutRoute];

Append to app/routes.ts: import { routes as floatRegisterRoutes } from '@/features/pettycash/register/routes'; and ...floatRegisterRoutes,. Nav (import Vault):

{ group: 'manage', to: '/petty-cash/floats', labelKey: 'floatRegister:nav', icon: Vault, permission: ['pettycash.read_all', 'pettycash.float.manage'] },

Verify: that lucide-react in the installed version exports Vault. If not, use Archive.

  • Step 6: Run the tests and watch the holder check fail once
Terminal window
pnpm -C web test -- src/features/pettycash/register

Expected: PASS, 6 tests. Then replace isHolder && with true && in FloatDrawer. The “someone else’s float” test must fail after its presence assertion. Restore.

  • Step 7: Typecheck, lint, drift
Terminal window
pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test -- src/drift
  • Step 8: Runtime check (as the Owner, who is also the seeded custodian)
  1. Sidebar → Float register. The Open tab lists the seeded float(s) with the server’s cash, committed and available figures.
  2. Open a float → holder = the seeded Employee → Open float. The toast names the new PF-… code, and the drawer opens at RM 0.00 with No cash has moved. Close float → confirm → Float closed, and the float is under Closed.
  3. Open the Owner’s own float. The movements show top-ups and spend payouts with signed amounts. Request replenishment: either the toast shows the server-sized amount, or, with nothing to replenish, the 409 detail in a toast.
  4. As the Employee (second tab), return a float through Task 16’s Return float flow with a proof image. Back as the Owner: Close-outs awaiting a decision lists it → Review → the proof thumbnail opens in the viewer → Confirm → Close-out updated. The register shows that float as Closed, with a closeout movement.
  5. Withdraw appears disabled for the Owner, with the refusal tooltip from available_transitions.
  6. At 390, the register table becomes cards and the drawer is full-screen. Dark: the status tags stay distinguishable by text. zh: movement kinds are translated, and the holder names are the server’s legal names.
  • Step 9: Commit
Terminal window
git add web/src/features/pettycash/register web/src/lib/i18n/locales/*/floatRegister.json web/src/app/routes.ts web/src/app/shell/nav.ts web/src/lib/api/types.ts
git commit -m "$(cat <<'EOF'
web: petty-cash float register and close-out review (custodian view)
Every float with the server's cash, committed and available; open a float
for a member; per-float movement ledger; direct close of an untouched
float; holder-only replenishment; pending close-outs found through the
activity feed and decided with an Idempotency-Key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"

Task 27: Serving — Dockerfile, nginx, CSP, deploy notes

Section titled “Task 27: Serving — Dockerfile, nginx, CSP, deploy notes”

Spec §8, rulings F17 and F18. The web app ships as one unprivileged nginx image. It serves the built SPA with a history fallback and proxies /v1 to the api container, so the browser talks to one origin. That means no CORS, and the refresh cookie (Path=/v1/auth, Secure, SameSite=Lax, set in internal/modules/identity/handlers.go) is sent on refresh. The Content-Security-Policy and the other security headers are set here.

Facts this task builds on, read from the repo:

  • The api listens on PORT (default 3000, .env.example) and answers GET /v1/health.
  • Invitation links are PUBLIC_BASE_URL + /invitations/accept?token=… (internal/platform/identity/invite/invite.go). The token is in a query string, so the page must send no Referer anywhere (Referrer-Policy: no-referrer).
  • TRUSTED_PROXIES defaults to trust nobody. With nginx in front, the api’s TCP peer is nginx, so nginx’s address must be trusted, and nginx must overwrite X-Request-Id rather than pass a client’s through (DEPLOY_NOTES, Plan 2b §2: a trusted peer’s X-Request-Id lands in the audit trail).
  • The browser talks to the object store directly in three ways: presigned PUT uploads (Task 10, connect-src), thumbnails and receipts (img-src), and PDF receipts in the viewer (frame-src, if Task 10 uses an <iframe>/<object>). Report downloads are a top-level navigation, which CSP does not govern. F18 names only img-src. Uploads would fail under that policy, so this task widens it to connect-src and frame-src for the same single origin. See Part D notes.
  • The web bundle imports packages/contracts/vocab.ts from outside web/, so the build context is the repo root.

Files:

  • Create: web/Dockerfile
  • Create: web/nginx.conf (an nginx templates/ file, rendered by envsubst at start)
  • Create or append: .dockerignore at the repo root (see Step 2)
  • Create: web/docker/05-require-env.sh
  • Create: web/scripts/container-smoke.sh
  • Modify (append a job): .github/workflows/ci.yml
  • Modify (prepend a section): docs/DEPLOY_NOTES.md

Interfaces:

  • Consumes: pnpm -C web build → web/dist (Task 5), with hashed assets under web/dist/assets/ (Vite default). Also GET /v1/health.

  • Produces: the image mcom-web, with env API_UPSTREAM (default api:3000), OBJECT_STORE_PUBLIC_ORIGIN (required), NGINX_RESOLVER (default 127.0.0.11), listening on 8080 as uid 101.

  • Step 1: Write the smoke test first — web/scripts/container-smoke.sh

It is the failing test for this task. It builds the image and runs it on a private network next to a stub “api” (busybox httpd serving /v1/health), then asserts on real HTTP responses. Presence before absence: it proves the SPA fallback answers a deep link before proving /v1/ does not fall back to index.html.

#!/usr/bin/env bash
# Build the web image and check what it serves. Needs Docker and curl.
# Usage: web/scripts/container-smoke.sh (from the repo root)
set -euo pipefail
IMG=mcom-web:smoke
NET=mcom-web-smoke-$$
STORE=https://files.example.com.my
PORT=18080
fail() { echo "SMOKE FAIL: $*" >&2; exit 1; }
cleanup() { docker rm -f "$NET-web" "$NET-api" >/dev/null 2>&1 || true; docker network rm "$NET" >/dev/null 2>&1 || true; }
trap cleanup EXIT
docker build -f web/Dockerfile -t "$IMG" .
docker network create "$NET" >/dev/null
docker run -d --name "$NET-api" --network "$NET" --network-alias api busybox:1.36 \
sh -c 'mkdir -p /www/v1 && printf "{\"status\":\"ok\"}" > /www/v1/health && httpd -f -p 3000 -h /www' >/dev/null
docker run -d --name "$NET-web" --network "$NET" -p "$PORT:8080" \
-e OBJECT_STORE_PUBLIC_ORIGIN="$STORE" "$IMG" >/dev/null
for i in $(seq 1 30); do curl -fsS "http://localhost:$PORT/" >/dev/null 2>&1 && break; sleep 1; done
h() { curl -sS -D - -o /dev/null "http://localhost:$PORT$1"; }
b() { curl -sS "http://localhost:$PORT$1"; }
# 1. index.html: served, never cached, with the full header set
H=$(h /)
echo "$H" | grep -qi '^HTTP/1.1 200' || fail "/ is not 200"
echo "$H" | grep -qi '^cache-control: no-cache' || fail "/ is cacheable"
echo "$H" | grep -qi "^content-security-policy: default-src 'self'" || fail "no CSP"
echo "$H" | grep -i '^content-security-policy:' | grep -q "img-src 'self' data: blob: $STORE" || fail "img-src lacks the object store"
echo "$H" | grep -i '^content-security-policy:' | grep -q "connect-src 'self' $STORE" || fail "connect-src lacks the object store"
echo "$H" | grep -i '^content-security-policy:' | grep -q "frame-ancestors 'none'" || fail "framable"
echo "$H" | grep -qi '^x-content-type-options: nosniff' || fail "no nosniff"
echo "$H" | grep -qi '^referrer-policy: no-referrer' || fail "referrer leaks invitation tokens"
echo "$H" | grep -qi '^server: nginx/' && fail "server_tokens not off (version exposed)"
# 2. SPA fallback: presence first — a deep link returns the app shell…
b /claims/00000000-0000-4000-8000-000000000001 | grep -q '<div id="root">' || fail "deep link does not fall back to index.html"
# …then absence — an unknown /v1 path is the api's 404, not the app shell
OUT=$(mktemp)
CODE=$(curl -s -o "$OUT" -w '%{http_code}' "http://localhost:$PORT/v1/no-such-route")
[ "$CODE" = "404" ] || fail "/v1/no-such-route returned $CODE"
grep -q '<div id="root">' "$OUT" && fail "/v1 fell back to index.html"
# 3. the proxy reaches the api
b /v1/health | grep -q '"status":"ok"' || fail "/v1/health not proxied"
# 4. hashed assets: long-lived and immutable
ASSET=$(b / | grep -o '/assets/[^"]*\.js' | head -n1)
[ -n "$ASSET" ] || fail "no hashed JS asset referenced by index.html"
h "$ASSET" | grep -qi '^cache-control: public, max-age=31536000, immutable' || fail "asset not long-cached"
h "$ASSET" | grep -qi "^content-security-policy:" || fail "asset response lost the security headers"
# 5. unprivileged
[ "$(docker exec "$NET-web" id -u)" = "101" ] || fail "nginx does not run as uid 101"
echo "SMOKE OK"
Terminal window
chmod +x web/scripts/container-smoke.sh
web/scripts/container-smoke.sh

Expected: FAIL at docker build, with failed to read dockerfile: open web/Dockerfile: no such file.

  • Step 2: web/Dockerfile
1.7
# Build context: the repository root (the bundle imports packages/contracts).
# docker build -f web/Dockerfile -t mcom-web .
FROM node:24-alpine AS build
WORKDIR /src
RUN corepack enable
COPY web/package.json web/pnpm-lock.yaml web/
RUN pnpm -C web install --frozen-lockfile
COPY packages/contracts packages/contracts
COPY web web
RUN pnpm -C web build
FROM nginxinc/nginx-unprivileged:1.27-alpine
# The image's entrypoint renders /etc/nginx/templates/*.template with envsubst
# into /etc/nginx/conf.d at start. Only variables that are set in the
# environment are substituted, so nginx's own $uri, $host… are left alone.
ENV API_UPSTREAM=api:3000 \
NGINX_RESOLVER=127.0.0.11
COPY web/nginx.conf /etc/nginx/templates/default.conf.template
COPY --from=build /src/web/dist /usr/share/nginx/html
EXPOSE 8080
USER 101

OBJECT_STORE_PUBLIC_ORIGIN has no default on purpose. An unset value would render an empty origin into the CSP, and uploads would then fail in the browser, far from the cause. An entrypoint script (Step 3) therefore refuses to start without it.

Docker reads .dockerignore from the context root, which is the repo root. None exists there today (checked at 6d2f48e). Create it with:

**/node_modules
web/dist
web/test-results
web/playwright-report
.git
bin

If one has appeared since (for example an api image), append the web/ lines instead.

Verify: nginxinc/nginx-unprivileged:1.27-alpine still runs 20-envsubst-on-templates.sh as its entrypoint step and writes to /etc/nginx/conf.d as uid 101. Check with docker run --rm nginxinc/nginx-unprivileged:1.27-alpine ls /docker-entrypoint.d/.

  • Step 3: web/nginx.conf (a template) and the env guard
# Rendered by the nginx image's envsubst step. ${VARS} are env; $vars are nginx's.
# The security headers are set at server level. The ONE location that needs its own
# Cache-Control (/assets/) repeats all of them, because a single add_header in a
# location drops every inherited one. The smoke test checks an asset response still
# carries the CSP. Other locations use `expires`, which does not have that problem.
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
server_tokens off;
absolute_redirect off;
client_max_body_size 2m; # uploads go straight to the object store; API bodies are small
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# 'unsafe-inline' for style only: Radix's scroll lock (react-remove-scroll)
# and the toaster inject <style> elements. Scripts stay strictly 'self'.
# HSTS belongs to the TLS-terminating proxy in front of this container.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: ${OBJECT_STORE_PUBLIC_ORIGIN}; connect-src 'self' ${OBJECT_STORE_PUBLIC_ORIGIN}; frame-src ${OBJECT_STORE_PUBLIC_ORIGIN}; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Frame-Options "DENY" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
# The API. A variable in proxy_pass makes nginx resolve at request time,
# so the container starts even when the api is not up yet.
resolver ${NGINX_RESOLVER} valid=10s ipv6=off;
set $api_upstream http://${API_UPSTREAM};
location /v1/ {
proxy_pass $api_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
# Never forward a client's X-Request-Id: the api trusts this hop, and the
# id is written into the audit trail. nginx mints its own.
proxy_set_header X-Request-Id $request_id;
proxy_read_timeout 60s;
}
# Vite's content-hashed files: cache for a year, never revalidate.
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: ${OBJECT_STORE_PUBLIC_ORIGIN}; connect-src 'self' ${OBJECT_STORE_PUBLIC_ORIGIN}; frame-src ${OBJECT_STORE_PUBLIC_ORIGIN}; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Frame-Options "DENY" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
}
location = /index.html {
expires -1; # Cache-Control: no-cache
}
location / {
try_files $uri $uri/ /index.html;
}
}

The SPA fallback’s internal redirect to /index.html lands in the exact-match location, so deep links are never cached either.

web/docker/05-require-env.sh, copied into /docker-entrypoint.d/ so it runs before the image’s envsubst step (20-…). An unset variable would otherwise render an empty origin into the CSP, and uploads would fail in the browser, far from the cause. A container that refuses to start is the house style (PUBLIC_BASE_URL, DEPLOY_NOTES):

#!/bin/sh
# Refuse to start without the object store's public origin (see DEPLOY_NOTES, Plan 10 §3).
set -eu
case "${OBJECT_STORE_PUBLIC_ORIGIN:-}" in
http://*|https://*) ;;
*) echo "OBJECT_STORE_PUBLIC_ORIGIN must be set to the object store's public origin, e.g. https://files.example.com.my" >&2; exit 1 ;;
esac
case "$OBJECT_STORE_PUBLIC_ORIGIN" in
*/) echo "OBJECT_STORE_PUBLIC_ORIGIN must not end with /" >&2; exit 1 ;;
esac

Add to the Dockerfile’s final stage, before USER 101:

COPY --chmod=0755 web/docker/05-require-env.sh /docker-entrypoint.d/05-require-env.sh

Add to the smoke test, after the docker run of $NET-web and before the curl loop, a check that the guard works:

Terminal window
if docker run --rm "$IMG" >/dev/null 2>&1; then fail "started without OBJECT_STORE_PUBLIC_ORIGIN"; fi
  • Step 4: Run the smoke test to green
Terminal window
web/scripts/container-smoke.sh

Expected: SMOKE OK. Then watch it fail once: comment out the Referrer-Policy line in the server block, re-run, and see SMOKE FAIL: referrer leaks invitation tokens. Restore it.

  • Step 5: Runtime check of the real stack through the container

With the dev stack’s api on localhost:3000 (Task 8):

Terminal window
docker build -f web/Dockerfile -t mcom-web .
docker run --rm -p 8080:8080 --add-host=host.docker.internal:host-gateway \
-e API_UPSTREAM=host.docker.internal:3000 -e NGINX_RESOLVER=127.0.0.11 \
-e OBJECT_STORE_PUBLIC_ORIGIN=http://localhost:9000 mcom-web

Open http://localhost:8080 with preview_start (url). Log in as the Owner. Reload the page: the session survives, because the refresh cookie was sent to /v1/auth/refresh on the same origin. Open a claim with a receipt: the thumbnail loads from localhost:9000. Start a new claim and upload a receipt: the PUT succeeds. Open Workflows → expense_claim: the elkjs diagram draws. Then read_console_messages must show no Content-Security-Policy violations. This is the check that elkjs and the upload do not need unsafe-eval or another origin.

Verify: 127.0.0.11 resolves host.docker.internal inside the container on the default bridge network. If Docker’s embedded DNS is not available on the default bridge, run with --network set to a user-defined network, or set NGINX_RESOLVER to the host’s resolver.

  • Step 6: CI job — append to .github/workflows/ci.yml
web-image:
runs-on: ubuntu-latest
needs: web
steps:
- uses: actions/checkout@v4
- run: web/scripts/container-smoke.sh

needs: web refers to Task 5’s job. Verify its id in ci.yml.

  • Step 7: docs/DEPLOY_NOTES.md — new top section (insert right after the intro’s ---, above “## Plan 9 — reports, exports and River”)
## Plan 10 — the web app, served by its own nginx container
Plan 10 adds a browser app. It is **one more container**, `web`, built from
`web/Dockerfile` (context: the repository root). It serves the app **and proxies `/v1`
to the api**, so the browser sees one origin. The api has no CORS and never will, so
**point your TLS-terminating reverse proxy at `web:8080`, not at `api:3000`.** What breaks
if you don't: the app loads, but every `/v1` call goes to the wrong host, and login fails.
### 1. Compose
```yaml
services:
web:
image: mcom-web:<release> # docker build -f web/Dockerfile -t mcom-web:<release> .
environment:
API_UPSTREAM: api:3000 # the api service and its PORT
OBJECT_STORE_PUBLIC_ORIGIN: https://files.erp.example.com.my # required, see 3
depends_on: [api]
read_only: false # nginx renders its config at start
restart: unless-stopped
# no ports: — only your TLS proxy reaches it, on the compose network
api:
# unchanged, except TRUSTED_PROXIES (2) and PUBLIC_BASE_URL (4)
```
The container runs as uid 101 and listens on **8080**. It refuses to start without
`OBJECT_STORE_PUBLIC_ORIGIN`.
### 2. `TRUSTED_PROXIES` now has two hops
The api's TCP peer is now the `web` container, not your TLS proxy. Set `TRUSTED_PROXIES`
to the compose network's subnet, which covers both `web` and the TLS proxy:
```sh
TRUSTED_PROXIES=172.18.0.0/16 # docker network inspect <project>_default → IPAM.Config.Subnet
```
Left empty, every request looks as if it came from `web`. Every client then shares one
login-throttle bucket, and the audit trail records `web`'s address (the failure mode in
Plan 2b §2 above). `web` always overwrites `X-Request-Id` with its own id, so trusting it
does not let a client author the request id in the audit trail.
### 3. `OBJECT_STORE_PUBLIC_ORIGIN` and `OBJECT_STORE_ENDPOINT`
Receipts are uploaded by the **browser** straight to the object store with a presigned
PUT, and thumbnails and report downloads are fetched from it the same way. So:
- `OBJECT_STORE_ENDPOINT` (api and worker) must be an address **the user's browser can
reach**, because presigned URLs carry its host. The compose-internal
`http://minio:9000` produces URLs nobody outside the VPS can open. Put the store behind
your TLS proxy (for example `https://files.erp.example.com.my`), and use that.
- `OBJECT_STORE_PUBLIC_ORIGIN` (web) is the same scheme and host, with no path. The
Content-Security-Policy allows it for images, uploads (`connect-src`) and the receipt
viewer. A mismatch breaks uploads with a CSP error in the browser console, and nothing
in the server logs.
- The bucket needs a CORS rule allowing `PUT` and `GET` from the app's origin, because
the browser calls it cross-origin. For MinIO: `mc anonymous` is **not** it. Set
`MINIO_API_CORS_ALLOW_ORIGIN=https://erp.example.com.my` on the MinIO server, or the
equivalent for your store.
- The store is still in Malaysia (RUNBOOK §1.1). Exposing it publicly changes only who
can reach it with a signed URL, not where it is.
### 4. `PUBLIC_BASE_URL` and the refresh cookie
- `PUBLIC_BASE_URL` is the address of the **web** app as users see it
(`https://erp.example.com.my`). Invitation links are built from it and open the web
app's `/invitations/accept` page.
- The refresh cookie is `Path=/v1/auth; Secure; HttpOnly; SameSite=Lax`, host-only. It
only works when the app and `/v1` share an origin **and** that origin is HTTPS. Serving
the app on one host and the API on another, or mounting `/v1` under a different path at
the proxy, silently logs every user out on each page load.
- Mounting the app under a path (`https://example.com.my/erp`) is **not supported** by
this image. The cookie path and the SPA's absolute asset URLs both assume the root.
### 5. Security headers
`web` sets CSP (`default-src 'self'`, plus the object store as in 3), `nosniff`,
`Referrer-Policy: no-referrer` (invitation tokens travel in a query string),
`X-Frame-Options: DENY` and `frame-ancestors 'none'`, and COOP. **HSTS is your TLS
proxy's job.** Do not add a second CSP at the proxy: browsers enforce both, so the
stricter one wins and a proxy-level `default-src 'self'` would block the object store.
---
  • Step 8: Commit
Terminal window
git add web/Dockerfile web/nginx.conf web/docker/05-require-env.sh web/scripts/container-smoke.sh .dockerignore .github/workflows/ci.yml docs/DEPLOY_NOTES.md
git commit -m "$(cat <<'EOF'
web: serving image (nginx, SPA fallback, /v1 proxy, CSP) and deploy notes
Multi-stage pnpm build into unprivileged nginx on 8080; /v1 proxied with
request-time resolution and nginx-minted X-Request-Id; CSP self plus the
object store origin for img/connect/frame; long-cached hashed assets and
no-cache index.html; a container smoke test in CI; DEPLOY_NOTES for the
new container, TRUSTED_PROXIES, the public object store and the cookie.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"

Task 28: End to end (Playwright), roadmap, the one full run

Section titled “Task 28: End to end (Playwright), roadmap, the one full run”

Spec §7. Four Playwright flows run against the real API on the dev stack (Task 8), from a browser, through the same screens users use:

  1. Claim round trip. The Employee files a claim with a receipt and submits it. The Manager reviews and approves. The Owner verifies the category and pays. The Employee sees it Paid. The status chip and the timeline are checked at every step.
  2. Petty-cash spend round trip. The holder records a spend with a receipt, and the reviewer verifies and pays it from the float. The float’s movement ledger shows the payout.
  3. Workflow draft with a violation. Remove an approver, save, see the violation inline and Publish disabled. Fix it, save, and publish v(n+1).
  4. Trial-balance export. Request CSV, wait, download.

Then the roadmap ticks, and the one full run of every suite (user instruction: no full-suite churn before this point).

Most of the selectors below are server-provided labels: the seeded expense_claim edge and state labels (Submit, Mark reviewed, Approve, Verify category, Mark paid; Pending review, Reviewed, Approved, Category verified, Paid) come from DefaultExpenseClaim (internal/platform/workflow/seed.go) and cannot drift from the UI. The Part D screens’ labels are fixed in this part’s en bundles. Selectors for Tasks 11–13 and 16 screens use their en strings, and each is marked Verify: against that task’s bundle before the first run.

Files:

  • Create: web/playwright.config.ts
  • Create: web/e2e/support.ts
  • Create: web/e2e/fixtures/receipt.png (generated, Step 2)
  • Create: web/e2e/claim-flow.spec.ts, petty-cash.spec.ts, workflow-draft.spec.ts, trial-balance-export.spec.ts
  • Modify (append a job): .github/workflows/ci.yml
  • Modify: docs/ROADMAP.md
  • Dependency: pnpm -C web add -D @playwright/test; package.json script "e2e": "playwright test" (added with pnpm pkg set scripts.e2e="playwright test", the non-hand-edit route)

Interfaces:

  • Consumes: scripts/dev-stack.sh (Task 8: Postgres in Docker, migrate up, orgadmin create-org, web/e2e/seed.ts, then cmd/api on :3000 and cmd/worker); web/e2e/seed.ts exports DEMO_USERS: Record<'owner' | 'manager' | 'employee', { email: string; password: string; name: string }>, where employee’s superior and unit head is manager, owner holds the root seat, and owner holds an open float with cash (P6); the web launch config / pnpm -C web dev on :5173, proxying /v1 to :3000.
  • Produces: pnpm -C web e2e; the e2e CI job.

Verify before Step 1: open web/e2e/seed.ts and scripts/dev-stack.sh. Confirm: (a) the export name and keys of DEMO_USERS. The registry does not fix them; see Part D notes. Use Task 8’s names throughout, and update the registry if they differ. (b) The org structure the seed builds gives employee → manager as both superior and unit head, and owner holds the root seat, so the seeded grants route reviewed and approved to the manager, and category_verified and completed to the owner. (c) Which float the seed opens, and for whom. (d) Whether dev-stack.sh blocks in the foreground (Playwright webServer needs a command that keeps running) or starts things and exits. If it exits, wrap it as in Step 3’s note.

  • Step 1: Install Playwright
Terminal window
pnpm -C web add -D @playwright/test
pnpm -C web exec playwright install --with-deps chromium
pnpm -C web pkg set scripts.e2e="playwright test"
  • Step 2: Receipt fixture (a real, valid 1×1 PNG, written once and committed)
Terminal window
mkdir -p web/e2e/fixtures
node -e "require('fs').writeFileSync('web/e2e/fixtures/receipt.png', Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==','base64'))"
file web/e2e/fixtures/receipt.png # expect: PNG image data, 1 x 1
  • Step 3: web/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
const CI = process.env.CI === 'true';
export default defineConfig({
testDir: './e2e',
testMatch: /.*\.spec\.ts/,
fullyParallel: false, // the flows share one demo org; run them in order
workers: 1,
retries: CI ? 1 : 0,
timeout: 90_000,
expect: { timeout: 10_000 },
reporter: CI ? [['github'], ['html', { open: 'never' }]] : 'list',
use: {
baseURL: 'http://localhost:5173',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
locale: 'en-GB',
timezoneId: 'Asia/Kuala_Lumpur',
...devices['Desktop Chrome'],
},
webServer: [
{
command: 'bash ../scripts/dev-stack.sh',
url: 'http://localhost:3000/v1/health',
reuseExistingServer: !CI,
timeout: 240_000,
stdout: 'pipe',
},
{
command: 'pnpm dev --port 5173 --strictPort',
url: 'http://localhost:5173',
reuseExistingServer: !CI,
timeout: 60_000,
},
],
});

If dev-stack.sh exits after starting its processes (Verify (d)), set the first entry’s command to bash -c '../scripts/dev-stack.sh && tail -f /dev/null', so Playwright has a process to hold and kill.

  • Step 4: web/e2e/support.ts
import { expect, type Locator, type Page } from '@playwright/test';
import { DEMO_USERS } from './seed';
export type Who = keyof typeof DEMO_USERS;
/** Log in through the real login screen (Task 8). A fresh context per user keeps
* the refresh cookie and the in-memory token separate. */
export async function login(page: Page, who: Who) {
const u = DEMO_USERS[who];
await page.goto('/login');
await page.getByLabel('Email').fill(u.email);
await page.getByLabel('Password').fill(u.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('navigation')).toBeVisible();
}
/** A unique, English (record-language) title per run, so reruns never collide. */
export const runTag = () => `E2E ${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`;
/** The status chip inside a page, drawer or row: chips carry text (spec a11y floor). */
export const chip = (scope: Page | Locator, label: string) => scope.getByText(label, { exact: true }).first();

Verify: the login screen’s field labels and button name (Email, Password, Sign in) in Task 8’s auth bundle.

  • Step 5: web/e2e/workflow-draft.spec.ts (Part D’s own screen, so every label is known)
import { expect, test } from '@playwright/test';
import { login } from './support';
test('a draft with a violation shows it inline, blocks publish, and publishes once fixed', async ({ page }) => {
await login(page, 'owner');
await page.goto('/admin/workflows/expense_claim');
// Start from a clean draft: discard any left by an earlier run, then create one.
const discard = page.getByRole('button', { name: 'Discard draft' });
if (await discard.isVisible()) {
await discard.click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Discard draft' }).click();
}
await page.getByRole('button', { name: 'Edit draft' }).click();
await expect(page.getByText('No problems. This draft can be published.')).toBeVisible();
// Presence: the review edge is drawn and not flagged.
const reviewEdge = page.locator('[data-ref="pending_review->reviewed"]');
await expect(reviewEdge).toBeVisible();
await expect(reviewEdge).not.toHaveAttribute('data-invalid', 'true');
// Break it: remove the only approver on "Reviewed".
await page.getByRole('listitem', { name: 'Reviewed' }).getByRole('button', { name: /Reviewed/ }).first().click();
await page.getByRole('button', { name: 'Remove approver' }).click();
await page.getByRole('button', { name: 'Save draft' }).click();
const banner = page.getByRole('region', { name: /problems? blocks? publishing/ });
await expect(banner).toContainText('Rule 4');
await expect(reviewEdge).toHaveAttribute('data-invalid', 'true');
await expect(page.locator('[data-ref="pending_review->rejected"]')).toHaveAttribute('data-invalid', 'true');
await expect(page.getByRole('button', { name: 'Publish' })).toBeDisabled();
// Fix it: the requester's superior approves "Reviewed" again.
await page.getByRole('button', { name: 'Add approver' }).click();
await page.getByRole('button', { name: 'Save draft' }).click();
await expect(page.getByText('No problems. This draft can be published.')).toBeVisible();
await expect(reviewEdge).not.toHaveAttribute('data-invalid', 'true');
// Publish v(n+1).
await page.getByRole('button', { name: 'Publish' }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Publish' }).click();
await expect(page.getByText(/Published version \d+/)).toBeVisible();
await expect(page.getByText('You are looking at the published version. It cannot be changed.')).toBeVisible();
});

“Add approver” creates a superior grant on the selected state (addGrant’s default), which is exactly what was removed. The published graph equals the seeded one, so later specs are unaffected. Each run publishes one more version, which is harmless: instances pin their version.

  • Step 6: web/e2e/claim-flow.spec.ts
import path from 'node:path';
import { expect, test, type Browser, type Page } from '@playwright/test';
import { chip, login, runTag, type Who } from './support';
const RECEIPT = path.join(__dirname, 'fixtures', 'receipt.png');
async function as(browser: Browser, who: Who): Promise<Page> {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, who);
return page;
}
async function openClaim(page: Page, title: string, scope: 'mine' | 'to_act' | 'all') {
await page.goto(`/claims?scope=${scope}&q=${encodeURIComponent(title)}`);
await page.getByRole('row', { name: new RegExp(title) }).click();
await expect(page.getByRole('dialog')).toContainText(title);
}
async function take(page: Page, action: string, expectState: string) {
const drawer = page.getByRole('dialog');
await drawer.getByRole('button', { name: action, exact: true }).click();
await expect(chip(drawer, expectState)).toBeVisible();
}
test('file → submit → review → approve → verify → pay, with the chip and timeline at each step', async ({ browser }) => {
const title = `${runTag()} taxi to client`;
// 1. Employee files and submits (Task 13's wizard).
const emp = await as(browser, 'employee');
await emp.goto('/claims');
await emp.getByRole('button', { name: 'New claim' }).click(); // Verify: Task 13 en
const wiz = emp.getByRole('dialog');
await wiz.getByLabel('Title').fill(title); // Verify
await wiz.getByLabel('Category').click(); // Verify
await emp.getByRole('option').first().click();
await wiz.getByRole('button', { name: 'Next' }).click(); // Verify
await wiz.locator('input[type=file]').setInputFiles(RECEIPT);
await expect(wiz.getByText('receipt.png')).toBeVisible();
await wiz.getByLabel('Amount').fill('42.50'); // Verify
await wiz.getByRole('button', { name: 'Next' }).click();
await wiz.getByRole('radio').first().check(); // payout method card
await wiz.getByRole('button', { name: 'Submit', exact: true }).click(); // the edge label, from the API
await openClaim(emp, title, 'mine');
await expect(chip(emp, 'Pending review')).toBeVisible();
// 2. Manager reviews, then approves (superior, then unit head).
const mgr = await as(browser, 'manager');
await openClaim(mgr, title, 'to_act');
await take(mgr, 'Mark reviewed', 'Reviewed');
await take(mgr, 'Approve', 'Approved');
await expect(mgr.getByRole('dialog')).toContainText(/progress|进度/i); // Verify: Task 12 timeline heading
// 3. Owner verifies the category (accrual) and pays (payout).
const own = await as(browser, 'owner');
await openClaim(own, title, 'to_act');
await take(own, 'Verify category', 'Category verified');
await own.getByRole('dialog').getByRole('button', { name: 'Mark paid', exact: true }).click();
const pay = own.getByRole('dialog').last(); // Verify: Task 12's pay dialog
await pay.getByRole('radio').first().check();
await pay.getByRole('button', { name: /confirm|pay/i }).click();
await expect(chip(own, 'Paid')).toBeVisible();
// 4. The employee sees it Paid, with every step in the timeline, in order.
await emp.reload();
await openClaim(emp, title, 'mine');
const drawer = emp.getByRole('dialog');
await expect(chip(emp, 'Paid')).toBeVisible();
const text = await drawer.innerText();
const order = ['Pending review', 'Reviewed', 'Approved', 'Category verified', 'Paid'].map((s) => text.indexOf(s));
expect(order.every((i) => i >= 0)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order);
});
  • Step 7: web/e2e/petty-cash.spec.ts
import path from 'node:path';
import { expect, test } from '@playwright/test';
import { login, runTag } from './support';
const RECEIPT = path.join(__dirname, 'fixtures', 'receipt.png');
test('record a spend with a receipt, verify and pay it from the float, see the movement', async ({ browser }) => {
const what = `${runTag()} printer paper`;
// The holder records a spend (Task 16).
const holderCtx = await browser.newContext();
const holder = await holderCtx.newPage();
await login(holder, 'owner'); // Verify (c): the seeded float's holder
await holder.goto('/petty-cash');
await holder.getByRole('button', { name: 'Record a spend' }).click(); // Verify: Task 16 en
const dlg = holder.getByRole('dialog');
await dlg.getByLabel('Description').fill(what); // Verify
await dlg.getByLabel('Amount').fill('18.90'); // Verify
await dlg.locator('input[type=file]').setInputFiles(RECEIPT);
await expect(dlg.getByText('receipt.png')).toBeVisible();
await dlg.getByRole('button', { name: /save|record/i }).click(); // Verify
// The reviewer verifies and pays it. Buttons come from available_transitions.
const revCtx = await browser.newContext();
const rev = await revCtx.newPage();
await login(rev, 'manager'); // Verify: the seeded spend reviewer
await rev.goto('/approvals');
await rev.getByRole('row', { name: new RegExp(what) }).click();
const drawer = rev.getByRole('dialog');
await drawer.getByRole('button', { name: /verif/i }).click(); // Verify: the reviewer outcome control (Task 15/16)
await drawer.getByRole('button', { name: /pay/i }).click(); // the pay edge's server label
await expect(drawer.getByText(/paid/i).first()).toBeVisible();
// The float's movement ledger (Task 26) shows the payout of exactly that amount.
await holder.goto('/petty-cash/floats?status=open');
await holder.getByRole('row').filter({ hasText: 'PF-' }).first().click();
const ledger = holder.getByRole('dialog');
await expect(ledger.getByText('Spend paid').first()).toBeVisible();
await expect(ledger.getByText(/18\.90/).first()).toBeVisible();
});

Verify: the seeded petty-cash grants. If the Manager is not the reviewer of the Owner’s spends, log in as whoever the seed makes the reviewer, and fix the comment. Record the actual route in the task report.

  • Step 8: web/e2e/trial-balance-export.spec.ts
import { expect, test } from '@playwright/test';
import { login } from './support';
test('request a trial-balance CSV, wait for it, and download it', async ({ page }) => {
await login(page, 'owner');
await page.goto('/reports?report=trial_balance');
await expect(page.getByRole('row', { name: 'Totals' })).toBeVisible();
const download = page.waitForEvent('download', { timeout: 60_000 });
await page.getByRole('button', { name: 'Export' }).click();
await page.getByRole('menuitem', { name: 'CSV' }).click();
await expect(page.getByRole('status').filter({ hasText: /Preparing the file|Your download has started/ })).toBeVisible();
const file = await download;
expect(file.suggestedFilename()).toMatch(/\.csv$/);
const body = await (await file.createReadStream())?.toArray();
expect(Buffer.concat(body ?? []).toString('utf8')).toMatch(/\d/);
await expect(page.getByText('Ready').first()).toBeVisible(); // Recent exports
});

Verify: the presigned download answers with Content-Disposition: attachment (the worker’s upload or the presign’s response-content-disposition). If the object store serves the CSV inline, Playwright sees a navigation, not a download. In that case replace the download wait with page.waitForURL(/\.csv/) plus page.content() containing a comma, and record in Part D notes that the export is shown inline instead of saved. That is a Plan 9 presign detail, not a web one.

  • Step 9: Run the specs against the dev stack
Terminal window
pnpm -C web e2e

Expected: 4 passed. On a failure, open the trace: pnpm -C web exec playwright show-trace test-results/<spec>/trace.zip. Fix the screen or the selector, never the assertion. Watch one fail once: in workflow-draft.spec.ts, temporarily change the expected disabled state to toBeEnabled() and see it go red with the Publish button’s state in the message. Restore.

  • Step 10: CI job — append to .github/workflows/ci.yml
e2e:
runs-on: ubuntu-latest
needs: [test, web]
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
- uses: pnpm/action-setup@v4
with:
package_json_file: web/package.json
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- run: pnpm -C web install --frozen-lockfile
- run: pnpm -C web exec playwright install --with-deps chromium
- run: pnpm -C web e2e
env:
CI: 'true'
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: |
web/playwright-report
web/test-results

Verify: the ids of Task 5’s web job and the existing Go job (test), and that web/package.json has a packageManager field for pnpm/action-setup. Docker is available on ubuntu-latest, which dev-stack.sh needs for Postgres.

  • Step 11: docs/ROADMAP.md

First check the real state. Do not tick from memory:

Terminal window
git log --oneline main..HEAD | head -50
grep -n "Plan 10\|Phase 1 —" docs/ROADMAP.md

Replace the Plan 10 block (currently lines 249–254) with:

- [x] **Plan 10 — Frontend** (§13)
Plan: [`2026-09-23-plan-10-frontend.md`](superpowers/plans/2026-09-23-plan-10-frontend.md) (28 tasks)
Design: [`2026-09-23-plan-10-frontend-design.md`](superpowers/specs/2026-09-23-plan-10-frontend-design.md) (rulings F1–F21)
Deploy: [`DEPLOY_NOTES.md`](DEPLOY_NOTES.md) — a new `web` container fronts the api; `TRUSTED_PROXIES` and the object store's public origin change
- [x] Shell, org switcher, i18n (`en` / `ms` / `zh`) — `ms` is statutory (§11A.3); the line said `en` / `zh` until F2
- [x] Claims list and detail from the Figma design
- [x] **Workflow-driven UI**: status chips, filters and action buttons all from the API
- [x] Screens missing from Figma, in priority order (§13.5) — **workflow definition
editor first**, then payout channels, chart of accounts, journal entries, fiscal
periods and close, reports and exports, advance acquittal, float register
- Seams added for the UI: `/me`, `/org/members`, roles API (`org.roles.manage`, `00170`),
claim list filters and summary, petty-cash `to_act`, `/advances/summary`
- Not built, no backend: claim comments (F3), global search, notifications
- Close-outs have no list endpoint; the float register finds pending ones through the
activity feed (Part D notes, API gaps)

Tick each sub-item only if its task merged and its runtime check was recorded. Leave any that did not unticked, with a one-line reason.

Then, under the Phase 1 intro paragraph (after “…the logic ends up inside claims again. (§16)”), add a status line that reflects the ticks as they stand at that moment. For example, if Plans 3, 7, 8 and 9 and Wave 3 are still unticked:

**Status (2026-MM-DD):** Plans 2, 2b, 4, 5, 6 and 10 done. Phase 1 closes when Wave 3
(which ticks Plan 3's Ruling 17 follow-up), Plan 7's reminders, Plan 8's float
invariants and Plan 9's aged-advances and float-register reports land.

Write the plans actually outstanding, read from the file, with today’s date.

  • Step 12: The one full run (every suite, once, in this order; each must be green)
Terminal window
go test -p 2 -count=1 ./...
make lint
make generate && git status --porcelain -- internal/gen packages/contracts internal/kernel/vocab web/src/lib/api/schema.d.ts
pnpm -C web lint && pnpm -C web typecheck && pnpm -C web test && pnpm -C web build
web/scripts/container-smoke.sh
pnpm -C web e2e

Expected: Go tests pass. make lint prints nothing. git status --porcelain after make generate prints nothing (no drift). Vitest passes, including the three drift tests. The build succeeds. SMOKE OK. Playwright 4 passed.

The command is pnpm -C web lint && … typecheck && …, one script at a time. pnpm -C web lint typecheck test build as a single invocation runs only lint, and passes the other words to it as arguments.

Verify: that make generate includes the schema.d.ts step Task 5 added. If it does not, the drift check above is checking nothing for the web client. Run Task 5’s generator by hand, and fix the Makefile in this commit.

If anything fails, fix it at its source task’s files, re-run only the failing suite until green, then repeat Step 12 once from the top.

  • Step 13: Commit
Terminal window
git add web/playwright.config.ts web/e2e web/package.json web/pnpm-lock.yaml .github/workflows/ci.yml docs/ROADMAP.md
git commit -m "$(cat <<'EOF'
web: Playwright end-to-end flows, CI e2e job, roadmap for Plan 10
Four flows against the dev stack: claim round trip with chip and timeline
checks, petty-cash spend round trip, workflow draft violation then
publish, and a trial-balance CSV export. Roadmap: Plan 10 ticked, the
locale line corrected to en/ms/zh (F2), and the Phase 1 status recorded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
)"

Merging and pushing are the user’s (memory: merges and pushes are theirs).



These are the notes each writer left, kept for traceability. The Registry amendments section above resolves them, and where the two disagree the amendments win.

  1. Problem gains an optional code: string (Task 2, Step 12). The registry names the lockout codes, but the existing Problem schema has no code property, so the generated TS type would not carry it. ProblemError.code in lib/api/problem.ts depends on it. The change is additive. The value is written with apperr.Problem.With("code", …), which exists but has no production caller until this plan.
  2. AdvanceSummaryRow is a new component (Task 4). The registry says AdvanceSummary is “the same shape as ExpenseClaimSummary”; its rows are AdvanceSummaryRow, field-for-field ExpenseClaimSummaryRow. 2a. Both summaries gain kpis (the coordinator’s amendment): ExpenseClaimSummary.kpis: ExpenseClaimKpis and AdvanceSummary.kpis: AdvanceKpis. Each is {pending, approved, paid, submitted} of {count: integer, total: Money}, bucketed by semantic per design spec §5.2 and summed in SQL. The two KPI types are identical but declared per task (ExpenseClaimKpi[s], AdvanceKpi[s]), because Tasks 3 and 4 run in parallel and must not share a schema. Task 11’s KPI cards read kpis directly and never bucket rows themselves, and the registry’s claims-list line should say so. 2b. Task 4b additions:
    • listPettyCashCloseouts (GET /pettycash/closeouts?scope=mine|all|to_act&float_id=, returning PettyCashCloseoutList {closeouts})
    • OrgChartHolder.assignment_id (required)
    • PayoutChannel.names: PayoutNames (required)
  3. New request/response component names not in the registry: RoleNameInput (createRole/renameRole), RolePermissionsInput, MemberRolesInput, MemberRoles (the setMemberRoles response), and the responses RolesForbidden and RoleLockout.
  4. renameRole refuses seeded roles (system: true) with 422. The Owner role is identified by system AND name = 'Owner' in provisioning, onboarding, DEPLOY_NOTES and migration 00170, and a rename would silently break all of them, including the lockout guard. Task 18’s UI should disable rename when role.system. This is my ruling; the spec allows renaming any role.
  5. MeOrganization.base_currency and country are "", not invented, until the company profile exists. The registry types them string. If Task 6’s formatMoney cannot handle "", change them to string|null.
  6. Role.member_ids lists active holders only. ExpenseClaimSummary.rows and AdvanceSummary.rows are ordered by state key and hold only states with count ≥ 1. The UI orders chips by the definition’s display_order and treats a missing state as 0.
  7. Files not in the plan’s File Structure: internal/platform/identity/member/profile.go (Task 1; required because the email is on an RLS-exempt table modules may not name); internal/platform/expenses/filter.go, extras.go and summary.go (Task 3); internal/platform/pettycash/toact.go (Task 4); internal/platform/identity/role/migration_test.go (Task 2). The File Structure’s internal/modules/expenses/claim_handlers.go is joined by a new summary_handlers.go.
  • Every role edit stales every token in the organization. perm_version is per org, not per member. Each live session takes one 401 and one refresh, including the editor’s own session on its very next request. Task 6’s single-flight refresh-and-retry must be in place before Task 18 ships. Migration 00170 also bumps every Owner-bearing org once at deploy.
  • Liveness cache lag. With Redis, a cached positive liveness answer can outlive a bump by at most 5 s. That is a stale token, not a stale permission: httpx.Authorize re-reads permissions from the database on every request, so a revocation is never over-granted. Nothing calls Liveness.Invalidate on a role edit, matching the invitation-accept bump.
  • Retention ruling (Task 2, Step 11). Grant revocation is a DELETE on role_permission/member_role, with the evidence in audit_log’s before/after sets, not a dated close like member_position. This is my ruling, recorded in retention_test.go’s comment. An auditor wanting row-level history of role grants would challenge it.
  • The editor accepts only registered keys. A role holding a key no module registers (for example one granted by hand from constants.yaml’s unregistered expenses.claim.approve) cannot be round-tripped: the client must drop it or the PUT is 422. Owners seeded by provisioning hold only registered keys.
  • Owner detection is by name. A non-system role can be created with the name Owner only if the org renamed its seeded Owner first, and seeded roles can no longer be renamed. An organization that renamed its Owner before this plan (by SQL) is not found by 00170 or by the guard.
  • q is not index-backed. ILIKE '%…%' scans the scope’s rows. That is fine at the current scale, and the keyset order still uses expense_claim_by_created/by_claimant.
  • submitted_at is the first submission. A claim rejected, revised and resubmitted keeps its first date. The “submitted” column shows when it was first filed for approval, not the latest resubmission.
  • KPI scope. Each card counts the scope’s records, not the page’s. submitted includes rejected, cancelled and disputed records, as §5.2’s “everything except draft” says. A screen wanting “live submitted” must ask for a new bucket, not subtract. For advances, approved is accrual, which for an advance is acquitted (terminal). “Approved but not yet paid” advances sit in pending (state approved has semantic pending). The advances screen may want different card labels than claims.
  • to_act summaries run the queue first, then SQL. The ids from ToActFiltered/ToAct go back to the aggregate with ANY($n::uuid[]). At most 200 ids, so the second query is cheap. A record’s state could move between the two statements, but they run on one transaction, and the list is equally racy.
  • Close-out scope=all (Task 4b a) accepts pettycash.read_all OR pettycash.float.manage, like the spend and top-up lists, not read_all alone as the coordinator’s message said. The existing single-close-out read already admits a manager (openapi getPettyCashCloseout). Narrowing only the list would 403 a manager on a list whose members they can open. Change listCloseouts’ one condition if the narrower rule is wanted.
  • Close-out mine is “close-outs of floats I hold today”, read through petty_cash_float.holder_member_id, and rows also pass CanReadCloseout. A float whose holder changed after a close-out keeps that close-out under the new holder’s mine only if CanReadCloseout admits them. It admits the requester, read_all or actsOn, so a new holder may not see an old close-out. Floats do not change holder in any current code path.
  • PayoutChannel.names is required, but a channel inserted without translation rows would read {} and violate PayoutNames’ en/ms requirement. Every production path writes names (Task 4b, Step 17 has a verify step for this).
  • Queues. The to_act windows (1000 candidates, 200 results) and the per-candidate AvailableTransitions cost are inherited unchanged from claims and advances. The petty-cash queue adds two more such loops to the combined approvals page (Task 15).

Facts I could not verify (each has a Verify step in its task)

Section titled “Facts I could not verify (each has a Verify step in its task)”
  • That the tenant-predicate guard accepts roleSelect’s ARRAY(SELECT …) subqueries, ClaimExtras’s correlated subqueries and the spliced actableByOthers constant. It documents same-package constant splicing and transitive org_id joins, but I did not run it.
  • That pgx v5 binds *time.Time to $n::date and []uuid.UUID to $n::uuid[] / unnest, and scans an empty ARRAY() into a slice. There is precedent for []uuid.UUID → ANY($n) in reporting/generalledger.go, and the nil-guards cover the scan either way.
  • That goose.UpTo in v3.28.0 accepts goose.WithAllowMissing(). The signature is UpTo(db, dir, version, opts ...OptionsFunc), read from the module cache.
  • That CreateTopUp’s first state (requested) is non-draft and actable by the Owner (inferred from pettycashtest.Env.Fund), and that a recorded spend is actable by the holder’s superior (inferred from TestNobodyElseSeesASpend).
  • That no toActCeiling/toActCandidates exists in internal/platform/pettycash, and no tally/ScopeToAct exists in internal/platform/advances. A grep showed none in advances’ advance.go beyond its own toActCeiling/toActCandidates, which is a separate package.
  • That pgx binds an untyped nil interface argument as NULL for $n::uuid[] (the queue argument in both summaries), and that COALESCE(sum(numeric) FILTER (…), 0) scans into money.Amount as 0.0000. Each has a Verify step naming the fallback.
  • That CloseAssignment / DELETE /org/members/:id/positions/:pid on an assignment that started 2026-01-01 closes it with valid_to = today rather than withdrawing it. Either way it leaves the chart, which is all the test asserts. Also that pettycashtest.Env.Fund works for a float held by Cashier.
  • That the payout PATCH accepts a names-only body (Verify step in Task 4b, Step 13).
  • Task 4b (a) depends on Task 4’s toact.go helpers (actableByOthers, mayActNow, toActCeiling, toActCandidates). Merge Task 4 first.
  • I ran no code: every snippet was written against the source as read, and none has been compiled or tested.

Registry and plan changes I believe are needed

Section titled “Registry and plan changes I believe are needed”
  1. Task 7 cannot run in parallel with Task 6. It uses cn, the i18n namespaces, and the test setup’s initI18n, all of which Task 6 creates. Groups become C = 6, then 7, then D = 8, 9, 10. Tasks 9 and 10 also use Task 7’s components, so they could not start earlier either.
  2. Record the import path of every Task 8–10 export in the registry. Parallel tasks need to know where to import from, not only the names. Proposed:
    • @/features/auth/session: useMe, useCan, RequirePermission, useSwitchOrg, useSignOut, plus the additions meQueryOptions, hasPermission, safeNext, ForbiddenPanel.
    • @/app/shell/nav: navItems, NavItem, NavGroup, NAV_GROUPS, userMenuLinks.
    • @/components/workflow/<Name> for each workflow export. @/features/members/useMemberNames for both member hooks.
    • @/components/upload/<Name> for each upload export. uploadDocument.ts also exports UploadedDoc and UploadError.
  3. P1 needs the parent routes named. Code-based TanStack routes need their parent when they are created. So the registry should add @/app/layouts → rootRoute, publicRoute, appRoute. A feature’s routes.tsx exports routes (children of appRoute) and, optionally, publicRoutes. app/routes.ts exports appRoutes and publicRoutes. Routes may set staticData.crumb (an i18n key) for breadcrumbs deeper than the nav item.
  4. userMenuLinks (append-only, in nav.ts). Task 14’s “my payout details” and “sessions” links go there, so the user menu never shows a link to a route that does not exist yet.
  5. Additions to lib/:
    • applyTokenResponse(t) in client.ts. It sets the token and remembers the organization, so a background refresh re-mints for the same org. Login and switch-org use it.
    • ProblemError.retryAfter, the ProblemError.kind getter, and toProblemError(body, response).
    • formatMonth(key, locale) and TIME_ZONE in date.ts.
    • isUiLocale, detectLocale, buildResources, LOCALE_STORAGE_KEY in lib/i18n.
  6. Additions to components/ui:
    • buttonVariants, for styling a router Link like a button. There is no asChild, which avoids depending on the Slot API.
    • type TagTone and Tag’s variant: 'outline' | 'soft'. Figma’s status chips are outline; its category pills are soft.
    • type Option (Select.tsx), TabPanel, and recentMonths (MonthPicker.tsx).
    • DataTable’s getRowId, selectedRowId, caption and renderCard, for the card layout below 768 px.
    • Spinner’s label.
  7. Widened workflow signatures:
    • TransitionBar gains optional kind and version. Without them it cannot learn a target state’s semantic, so it cannot apply the danger style the spec requires. Tasks 12, 15, 16 and 17 should pass kind.
    • DecisionTimeline.version becomes optional, and decisions[].action_key becomes optional. ExpenseClaim has no workflow version field, and ExpenseClaimDecision has no action_key (both checked in api/openapi.yaml), so the registry’s required form could not be met by a claim.
  8. The progress rule also excludes dispute. Spec §4 excludes only reject and cancel semantics from the main path. With the seeded graph, that would count disputed as a step, so a paid and received claim would show 7/8. I exclude rejected, cancelled and dispute.
  9. The schema drift test is in Vitest, not Go. This departs from spec §3.1 and F6. The Go CI job has no Node, and a Go test that skips without pnpm is a guard nobody can rely on. web/scripts/gen-schema.ts: generateSchema() is shared by make generate and web/src/drift/schema.test.ts, and the test runs in the CI web job. The skeleton’s File Structure line internal/gen/web_schema_test.go? should become web/src/drift/schema.test.ts.
  10. Permission is written … | (string & {}), not … | string. The two accept the same values, but this form keeps editor completion and avoids a lint on redundant union members.
  11. Test helpers are shared and append-only: @/test/render (renderWithQuery, createTestQueryClient), @/test/renderWithRouter, @/test/msw/handlers (apiPath, defaultHandlers) and @/test/msw/fixtures (meFixture, tokenFixture, ORG_ID, ORG2_ID, MEMBER_ID). Workflow fixtures live with Task 9 (components/workflow/__fixtures__/expenseClaim.ts), so later tasks import them rather than editing the shared file.
  12. The dev-only /__ui gallery (src/dev/ui.tsx) uses literal sample text. It is excluded from production builds. I treat it as exempt from the every-string-is-a-key rule. Say so in Global Constraints, or have Task 7 key it too.
  • workflow.definition.read gates every status chip. GET /workflow/definitions/{kind} requires it, and a freshly provisioned Employee role holds nothing. StatusChip falls back to the raw state key, toned by the record’s own semantic. The screen stays usable, but the labels are wrong. The seed grants it to every role. Either the F20 role presets must include it, or Task 1/2 should reconsider the gate (definitions reveal no record data).
  • A reload returns the user to their default organization. POST /auth/refresh mints for the default org (first by name) unless org_id is sent, and the chosen org is held only in memory (F7 / Global Constraints). Someone who switched orgs is back in the default org after a reload. The fix is a non-secret ui.org localStorage key, which is a Global Constraints change. I did not make it.
  • Semantic-keyed tones diverge from Figma. In the seeded graph, approved (已批准) and reviewed (已审核) have the pending semantic, so F9 renders them amber, while Figma shows green and blue. This follows from F9 and should be accepted or revisited deliberately.
  • Amber text is darker than Figma’s. --tone-amber-fg is #9a5b00, not Figma’s #f5a623, because #f5a623 on white is about 2:1 contrast and fails the accessibility floor. The accent (border and icon) keeps #f5a623. The dark-mode palette is estimated from the dark.png screenshot. Task 7’s Figma pass should confirm it with get_variable_defs on 477:16079.
  • CSP (Task 27). The browser PUTs to the object store and loads <object> PDFs from it. Spec §8 lists only img-src; the policy also needs connect-src and object-src (or frame-src) for the store’s public origin, or uploads and PDF previews break in production.
  • Field errors never map to form fields today. The server sends no errors[] or code extension (only Problem.With could, and no handler does). So applyProblemToForm returns false for every current 422, and forms fall back to a toast of detail.
  • TransitionBar toasts its own failures. Callers must not toast them again.
  • Router typing is loose. With route children typed AnyRoute[], <Link to> is loosely typed. Each page still reads typed search params through its own route object (P7).
  • ARIA: DataTable puts aria-selected on rows of a plain table, as the registry asks. ARIA defines it only for grid and treegrid rows, so some screen readers ignore it. The selected row is also visually highlighted.

Unverified facts (each has a “Verify:” step in its task)

Section titled “Unverified facts (each has a “Verify:” step in its task)”
  • pnpm -C web test -- <file> filters to one file under pnpm 11 (Task 5, Step 14). The fallback is pnpm -C web exec vitest run <file>.
  • pnpm 10+ blocks dependency build scripts (esbuild, @tailwindcss/oxide) until they are approved.
  • The exact Intl output for ms-MY and zh-CN in Node 24’s ICU (28 Ogo 2026, 2026年8月28日 14:15, 5分钟前).
  • The signature of openapi-fetch’s fetch option ((input: Request) => Promise<Response>).
  • The namespace export names of the radix-ui umbrella package.
  • ESLint ≥ 9.22 for defineConfig / globalIgnores from eslint/config.
  • Group A’s actual schema names: Me, MemberDirectoryEntry, GET /org/roles → { roles }, GET /org/permissions → { permissions: [{ key, module }] }, PUT /org/roles/{id}/permissions. The seed and fixtures assume the registry’s names.
  • Whether the root unit’s head is the OWNER seat after provisioning. The demo claim’s reviewed → approved step depends on it.
  • MINIO_API_CORS_ALLOW_ORIGIN as MinIO’s CORS setting for presigned PUTs.
  • That Chromium keeps the Secure refresh cookie on http://localhost (it should; other browsers may not).
  • That jsdom’s FileReader ArrayBuffer is accepted by Node’s WebCrypto. There is a one-line fallback if it is not.

Registry changes needed (each amends “Interface registry” in its owning task’s commit)

Section titled “Registry changes needed (each amends “Interface registry” in its owning task’s commit)”

R1 — test helper renderWithApp (owner: Task 8). Every screen test in Part C renders the real route tree through it; without it each feature would re-build the router its own way. Proposed file web/src/test/renderWithApp.tsx:

import { render, type RenderResult } from '@testing-library/react';
import userEvent, { type UserEvent } from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createMemoryHistory, createRouter, RouterProvider, type AnyRouter } from '@tanstack/react-router';
import { http, HttpResponse } from 'msw';
import { buildRouteTree } from '@/app/router';
import { setAccessToken } from '@/lib/api/client';
import { server } from '@/test/msw/server';
export const TEST_MEMBER_ID = '00000000-0000-4000-8000-000000000001';
export interface RenderAppOptions {
path: string;
permissions: string[];
members?: { member_id: string; name: string | null; active: boolean }[];
shell?: boolean; // default false: the page without the AppShell (and without its polling)
}
export async function renderWithApp(o: RenderAppOptions): Promise<RenderResult & { router: AnyRouter; user: UserEvent }> {
server.use(
http.get('*/v1/me', () =>
HttpResponse.json({
user_id: '00000000-0000-4000-8000-0000000000a1',
email: 'alex@example.com', name: 'Alex Tan', member_id: TEST_MEMBER_ID,
organization: { id: '00000000-0000-4000-8000-00000000a000', name: 'Demo Sdn Bhd', base_currency: 'MYR', country: 'MY' },
organizations: [{ id: '00000000-0000-4000-8000-00000000a000', name: 'Demo Sdn Bhd' }],
permissions: o.permissions,
})),
http.get('*/v1/org/members', () => HttpResponse.json({ members: o.members ?? [{ member_id: TEST_MEMBER_ID, name: 'Alex Tan', active: true }] })),
);
setAccessToken('test-token'); // skips the page-load refresh; the auth guard sees a session
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
const router = createRouter({
routeTree: buildRouteTree({ shell: o.shell ?? false }),
history: createMemoryHistory({ initialEntries: [o.path] }),
context: { queryClient },
});
await router.load();
const r = render(<QueryClientProvider client={queryClient}><RouterProvider router={router} /></QueryClientProvider>);
return { ...r, router, user: userEvent.setup() };
}

It needs i18n initialised in en in src/test/setup.ts (Task 5/6).

R2 — routing seams (owner: Task 8). web/src/app/router.tsx exports appRoute (the signed-in layout route: auth guard, plus AppShell when shell is on) and buildRouteTree(opts: { shell: boolean }), which is rootRoute.addChildren([...public routes, appRoute.addChildren([...featureRoutes])]) over the array in app/routes.ts. Each feature’s routes.tsx exports routes as a tuple (as const) of routes that already carry their children, so app/routes.ts only spreads them and TanStack keeps the literal paths for typed to. Inside claims, features/claims/childRoutes.ts is append-only for Tasks 12 and 13 (C0).

R3 — useClaimTransition variables (owner: Task 11). The registry says { to, reason?, revision }. The transitions endpoint takes no If-Match; it takes from and a required Idempotency-Key, and reads payout_channel_id / proof_document_id on a move into a payout state. Replace with { from: string; to: string; reason?: string; payout_channel_id?: string | null; proof_document_id?: string | null; idempotencyKey?: string }.

R4 — DecisionTimeline input (owner: Task 9). decisions[].action_key is required in the registry, but ExpenseClaimDecision, PettyCashDecision and AdvanceDecision have no action_key (only WorkflowDecision does). Make it optional. Also version: number is required while no record row carries its workflow version (G4); Part C passes the latest definition’s version.

R5 — KPI buckets on the summaries (owner: Tasks 3 and 4). ExpenseClaimSummary and AdvanceSummary gain kpis: { pending, approved, paid, submitted }, each { count: integer, total: Money }, bucketed server-side with the spec §5.2 mapping (pending = pending + custom; approved = accrual; paid = payout + acknowledged; submitted = everything but draft). Without it the KPI totals are a client-side sum of rows[].total (the seed alone has three pending-semantic claim states), which F13 forbids. Part C’s Tasks 11 and 17 stop at Step 0 if it is missing.

R6 — exports and query keys other tasks consume.

  • ClaimDrawer({ id, items, onNavigate, onClose }) — Task 12, consumed by Task 15.
  • FieldSchemaForm + FieldSchemaField — Task 14 (features/settings/payout/FieldSchemaForm.tsx), consumed by Task 21’s live preview.
  • useApprovalsCount() — Task 15, the nav badge.
  • pettyCashKeys — Task 16, consumed by Task 26.
  • Shared keys: ['approvals', 'claims'|'advances'|'spends'|'topups'] (every transition in 11, 15, 16, 17 invalidates ['approvals']); ['advances','detail',id]; ['pettycash','spend',id], ['pettycash','topup',id]; ['payout','me','channels'], ['payout','me','profiles']; ['expenses','categories'], ['expenses','groups',{active}]; ['org','chart'], ['org','readiness'], ['org','roles'].

R7 — DropdownMenu in components/ui (owner: Task 7). The claims row ⋮ menu needs one and the list has none; Task 11 uses @radix-ui/react-dropdown-menu directly, token-styled. Add a DropdownMenu primitive and switch Task 11 to it.

R8 — UploadedDoc type export (owner: Task 10). The registry uses UploadedDoc in DocumentUploader’s props but never exports or defines it; Part C imports type UploadedDoc from @/components/upload and assumes { id, filename, size, content_type } (the uploadDocument return shape).

R9 — components/ui props. The registry names the primitives but not their props; C1 lists the props Part C assumes. Task 7 should publish them in the registry.

Ordered by how much they hurt a user.

  • G1 — workflow definitions are gated by workflow.definition.read. GET /workflow/definitions/{kind} requires it, and every status chip, filter chip, timeline and the semantic-based Submit selection read the definition. A seeded Employee has no grants, so an employee granted only the claim permissions sees broken chips and no Submit. Proposed: readable by anyone who may read a record of that kind (or [no-permission] with the S2 argument — labels and state names of the caller’s own organization). Part C’s Employee preset ticks workflow.definition.read as a stop-gap (Task 18).
  • G7 — petty-cash close-outs cannot be listed. There is GET /pettycash/closeouts/{id} but no list (by float, mine, or to_act). After a reload the holder cannot find their return to see its status or withdraw it, and the custodian cannot find it to confirm — the close-out workflow is unreachable from the UI. Proposed: GET /pettycash/closeouts with scope (mine|all|to_act) and float_id, like spends and top-ups; then Task 15 adds it to the queue and Task 16 to the page.
  • G8 — a holder cannot pick a spend’s expense account. PettyCashSpendInput requires expense_account_id; listing accounts needs accounting.ledger.read. Task 16 offers the expense categories mapped to an account and sends that account. Proposed: accept category_id on a spend (the server resolves the account), or a narrow expense-account list for pettycash.float.disburse.
  • G9 — assignments cannot be ended from the UI. DELETE /org/members/{id}/positions/{pid} needs the assignment id; OrgChartHolder has only member_id and name, and there is no list of a member’s assignments. Proposed: OrgChartHolder gains assignment_id, is_primary, valid_from, valid_to (additive). Also useful for the Members tab’s “primary position” (spec §5.8), which Part C cannot show exactly (it shows every seat held today).
  • G10 — inviting needs roles the inviter may not list. issueInvitation needs a role_id and org.members.invite; listRoles needs org.roles.manage. An inviter without the latter cannot choose a role, and the invitation list shows raw role ids. Proposed: let GET /org/roles answer {id, name} (without permissions or members) to org.members.invite too, or add role_name to Invitation.
  • G2 — KPI totals (R5).
  • G4 — record rows carry no workflow version. ExpenseClaim, Advance, PettyCashSpend, PettyCashTopUp have state but not the definition version their instance runs on; after a republish a chip or timeline may read labels from a newer version. Proposed: workflow_version: integer on each.
  • G17 — payout channel names depend on Accept-Language. MyPayoutChannel has only label (no names), in “the request’s locale”. Task 6’s client must send Accept-Language = the UI locale (and refetch on switch), or the wizard’s payout cards stay in whatever locale the server defaulted to. Categories are fine (they carry names).
  • G3 — claim list row. No first-line supplier_name (the Figma subline) and no line currency/amount (the two-line “CNY 333.00 / RM 210.55”). Additive fields on ExpenseClaim would fix both.
  • G5 — listAdvances has no state, q or month (claims got them in S3), so the advances page has KPI cards but no state chips; and Advance has no created_at, so no “this month” subline.
  • G6 — petty cash figures. No summary endpoint (pending count and total — Part C shows a count only), no committed/balance ratio (Figma’s bar and “可用 56.4%”), and FloatMovement has no reference, description or status (Part C joins them from the spends and top-ups on the page; close-out and claim-settlement movements show their kind only).
  • G18 — who must act now. ExpenseClaimView does not carry the current step’s resolved eligible approvers, so the timeline cannot say “等待 X 审批” (spec §4); Figma’s “当前由您处理” is derived from available_transitions instead.
  • G19 — no submitted/created time on advances, spends and top-ups in list rows, so the approvals queue cannot sort or show age across kinds.
  • G16 — Problem in the OpenAPI declares no code. Task 2’s lock-out refusals rely on a code extension member; the generated TS type will not have it. Declare it (optional) on Problem, so ProblemError.code is typed from the schema rather than read loosely.
  • G11 — no “default payout method” (Figma’s 设为默认收款方式 / 设为默认退还方式); not rendered.
  • G12 — no claim or line note field (Figma’s 备注); replaced by the supplier field.
  • G13 — top-up request has no draft, project, expected date, on-behalf or attachment (Figma’s 申请备用金 fields); only amount and purpose are rendered. “On behalf” would also contradict Ruling R20 (“nobody files for anybody else”).
  • G14 — no claims export endpoint; the Figma 导出 button is not rendered (F4).
  • G15 — the member directory has no email, so Team → Members cannot show it.
  • Route typing. Spreading feature tuples into appRoute.addChildren must keep literal paths or every typed navigate({ to }) in Part C fails to compile. R2 fixes the shape; verify with pnpm -C web typecheck after the first feature merges.
  • Component props are guessed (C1/R9). Step 0 of each task corrects them, but a wholesale mismatch (e.g. Select taking onValueChange) touches many lines.
  • Radix Select in jsdom needs hasPointerCapture/scrollIntoView polyfills in test/setup.ts; without them every pick() helper fails.
  • Nav badge hook order: badge: useApprovalsCount is a hook called by the Sidebar; it must be called inside a per-item component (Task 15 Step 0).
  • Group F shared fixtures: Tasks 16 re-exports Task 15’s test fixtures; if 16 is built first it copies them (noted in Task 16 Step 1).
  • Sequencing inside group E (C0): 11 → 12 → 13 must be serial; Task 13’s link to /settings/payout needs Task 14 merged first (noted in Task 13 Step 6).
  • Permission changes and the token. After a role edit the affected member sees the change on their next token refresh; Part C invalidates ['me'], which shows it at once only if /me reads live grants (Task 1 decides).
  • The seed must provide the states the runtime checks name (a pending claim, a category_verified claim, a received advance, a recorded spend, a requested top-up, a bank payout profile, an Employee without a primary position). If Task 8’s seed does not, the runtime checks of 12, 15, 16, 17, 19 cannot be run as written.

Unverified facts (assumed; each task’s Step 0 or a runtime step checks it)

Section titled “Unverified facts (assumed; each task’s Step 0 or a runtime step checks it)”
  • formatMoney renders RM 1,000.45, and with sign: 'always' -RM 650.00 / +RM 500.00 (Task 16’s test strings follow that assumption).
  • TransitionBar names buttons by pickLabel(labels), disables a refused edge with its refusal.detail as tooltip, and opens ReasonDialog for requires_reason; StateFilterChips renders buttons whose accessible name contains the label and count; DecisionTimeline shows actor names through useMemberNames.
  • unwrap of a 204 (DELETE) — Part C guards with if (r.error) unwrap(r).
  • The payout file field’s value is a document id (Task 14 Step 0).
  • Renaming a system: true role — Part C hides Rename for system roles (Task 18 Step 0).
  • createRole returns the Role itself.
  • Task 3’s summary accepts scope=to_act; Task 4’s summarizeAdvances accepts scope.
  • The no-float drift test does not flag Number.parseInt (Task 18’s ttl_hours).
  • The seeded provisioning step keys are the twelve listed in Task 19’s bundles (company_profile, roles, org_structure, chart_of_accounts, journals, tax_codes, fiscal_year, analytic_axes, workflow_definitions, payout_registries, payout_channels, expense_categories) — read from the Go source, not from a running server.
  • The zod major version (3 vs 4) — Task 13 Step 4 says what to change for 4.

Registry changes needed (each with its reason)

Section titled “Registry changes needed (each with its reason)”
  1. appRoute (Task 8): export the authenticated layout route from its own module (proposed web/src/app/appRoute.tsx), not from router.tsx. Every feature’s routes.tsx must name its parent, and importing it from router.tsx makes a cycle, because router.tsx imports routes.ts, which imports the features. Also fix the name of the array in web/src/app/routes.ts (this part assumes featureRoutes), and the feature contract export const routes: AnyRoute[].
  2. renderWithProviders(ui, { permissions?, locale?, memberId? }) (Task 6, web/src/test/render.tsx): returns RTL’s result plus user (userEvent). It seeds ['me'] with the given permissions (default me.organization.base_currency: 'MYR') and provides a fresh QueryClient, i18n and a memory router. Every Part D component test depends on it. The registry names none.
  3. Where hooks live. The registry fixes signatures but not modules. Part D imports useMe, useCan and RequirePermission from @/app/auth; semanticTone, StatusChip, TransitionBar, ReasonDialog, DecisionTimeline, useMemberNames and useMemberDirectory from @/components/workflow; DocumentThumb and DocumentViewer from @/components/upload; and the UI primitives from @/components/ui (a barrel). The registry lists useMemberNames under features/members/useMemberNames.ts, so one of the two must change. Fix the paths in the registry.
  4. Nav labels across namespaces: NavItem.labelKey is resolved with the i18next namespace prefix ('workflowEditor:nav'), so no task edits another’s bundle.
  5. subtractMoney accepts negative operands (Task 6). F16’s fold in Task 23 needs subtractMoney('-1.5', '-2') === '0.5'. The signature is unchanged. State it in the registry, and give it a unit test there.
  6. DEMO_USERS (Task 8, web/e2e/seed.ts): fix the export name, the keys (owner | manager | employee), the org structure the E2E flows rely on (employee → manager as superior and unit head; owner on the root seat and holding a float), and who reviews petty-cash spends.
  7. ProblemError extension members (optional): add body?: unknown so the reconciliation member of a 409 ReconciliationProblem is readable. Task 24 works without it by re-running the checks, so this is optional.
  8. DecisionTimeline.version optional (Task 9): petty-cash and close-out views carry no instance version (see API gaps). Task 26 passes 1 until then.
  9. Group G ordering: 23, 24 and 25 depend on 22’s features/accounting/queries.ts. 22 must merge first; 20, 21 and 26 are independent. Task 21 reads the chart and the journals under the same query keys, so it does not wait for 22.
  10. Type alias ownership (append-only types.ts): T20 workflow graph types and OrgPosition; T21 payout types; T22 Account, AccountType, Journal, FiscalYear, FiscalPeriod; T23 journal entry types and AnalyticAxis; T24 reconciliation; T25 reports; T26 petty-cash register (after a grep for Task 16’s). Two parallel appends of the same name are a compile error.
  • No list of close-outs. GET /pettycash/closeouts?float_id=&scope=to_act does not exist. Task 26 finds pending close-outs through GET /activity?subject=float_closeout, which needs activity.read and reads up to 50 feed items. A custodian without activity.read can only open a close-out by its link. Approvals (Task 15) does not cover close-outs either.
  • PayoutChannel does not return names. Only name and label come back, while PATCH replaces names whole (en and ms required). So the editor cannot prefill ms and zh. Renaming means re-entering both, and a stored zh name is lost on any rename that leaves it blank. A read of names (additive) would fix it.
  • Petty-cash views have no workflow version (PettyCashCloseoutView, PettyCashTopUpView), and PettyCashDecision has no action_key. The timeline cannot pin the right graph once a kind is republished.
  • POST /accounting/entries has no Idempotency-Key. A double submit could post twice. The UI disables the button while the request is in flight, which does not help across a network retry.
  • The reversal has no reason field. The reason becomes the reversal’s description (record text, so English or Malay).
  • The manual journal’s analytic split. The API accepts several analytic accounts per axis with percentages. The form sends one account per axis at "100". Splits are not built.
  • JournalEntryHeader carries no amount, so the entry list shows none.
  • allowed_claim_types is untyped. The editor offers expense_claim, advance and advance_topup, read from the settlement code, not from a vocabulary.
  • activity.read and org.structure.read are real permission keys absent from constants.yaml and packages/contracts/vocab.ts. The web relies on Permission being widened with | string.

Deviations from the spec (flag for review)

Section titled “Deviations from the spec (flag for review)”
  • CSP (F18) widened. connect-src and frame-src also allow the object store’s origin, not only img-src. Presigned PUT uploads would otherwise be blocked. style-src includes 'unsafe-inline' for Radix’s and the toaster’s injected <style> tags. Scripts stay 'self'.
  • The workflow draft’s “If-Match” is the body revision. The drafts API takes the revision in the body (PUT and publish) and in the query (DELETE), not in a header. Task 20 sends it there, and a 409 opens the reload prompt.
  • Deployment: OBJECT_STORE_ENDPOINT must become browser-reachable (presigned URLs carry its host). The bucket needs CORS for the app origin. TRUSTED_PROXIES must cover the new web hop. All of this is in the new DEPLOY_NOTES section.
  • elkjs under CSP. elk.bundled.js is GWT-compiled. If it uses eval or new Function, script-src 'self' blocks the diagram. Task 27 Step 5’s console check is the gate. The fallback is to lay out in a Web Worker served from 'self' (still no eval) or to allow 'wasm-unsafe-eval'. Never 'unsafe-eval'.
  • Task 9 reads definitions through GET /workflow/definitions/{kind}, which needs workflow.definition.read. A custodian or employee without it gets unlabelled chips on close-outs and claims. That is a Task 9 or seed concern.
  • Radix primitive props. Every Part D call site assumes prop names for Task 7’s wrappers (Select.onValueChange/options, Checkbox.label/description, ConfirmDialog.tone/confirmLabel, Tabs.items, Drawer.header/size, DataTable.columns/onRowClick/empty/loading, LoadMore.label/loading). A mismatch is a compile error, not a silent bug, but it will need a pass on each task.
  • The E2E claim and petty-cash specs drive Tasks 12, 13, 15 and 16’s screens, whose strings this part could not read. They are marked Verify, and the first Playwright run will need selector fixes.
  • Presigned export downloads may be served inline, depending on Content-Disposition. Task 28 Step 8 has the fallback.
  • The workflow E2E publishes a new version on every run. This is harmless, because instances pin their version, but the dev org’s version number grows.

Unverified facts (each has a Verify step in its task)

Section titled “Unverified facts (each has a Verify step in its task)”

The Tailwind token class names; the Task 7 prop names; whether the nginx-unprivileged entrypoint envsubst runs as uid 101; whether Docker’s DNS 127.0.0.11 resolves on the default bridge; how TanStack Router parses the closing=false search value; whether total_equity includes the earnings lines; the float_closeout state keys and labels; whether lucide-react exports Vault; whether MinIO’s presigned GET sets Content-Disposition; and the ReasonDialog confirm button’s name.