39 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 05-finance-restyle | 02 | execute | 2 |
|
|
false |
|
|
Purpose: Deliver FIN-01 (Card + typography scale, no overflow at small phones) and FIN-02 (wide tables → stacked lists, no new sections, no new data sources).
Output: A single rewritten page file consuming the unchanged /api/mobile/finance
route. No API change, no schema change, no new state library. The shell HeaderBar
- BottomNav from Phase 2 remain untouched (D-23).
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/phases/05-finance-restyle/05-CONTEXT.md @.planning/phases/05-finance-restyle/05-UI-SPEC.md @.planning/phases/05-finance-restyle/05-01-SUMMARY.md @.planning/phases/04-tickets-restyle/04-02-SUMMARY.md @CLAUDE.md @app/mobile/finance/page.tsx @app/api/mobile/finance/route.ts @components/mobile/KpiCardMobile.tsxFrom components/mobile/KpiCardMobile.tsx:
export type KpiTone = 'default' | 'attention';
interface KpiCardMobileProps {
label: string;
value: number | string; // accepts pre-formatted currency string (UI-SPEC note)
caption?: string;
tone?: KpiTone; // 'attention' adds destructive left border
}
export function KpiCardMobile(props: KpiCardMobileProps): JSX.Element
From components/mobile/FinanceRow.tsx (created in Plan 01):
export interface FinanceRowProps {
primary: string;
amount: string; // pre-formatted (caller passes fmt$())
secondary?: React.ReactNode;
rightSecondary?: React.ReactNode;
amountTone?: 'default' | 'destructive' | 'positive';
}
export function FinanceRow(props: FinanceRowProps): JSX.Element
From components/mobile/FinanceSkeleton.tsx (created in Plan 01):
export function FinanceSkeleton(): JSX.Element // no props
From components/ui/collapsible.tsx (already installed, used in Phase 4):
export function Collapsible(props: { open?: boolean; onOpenChange?: (o: boolean) => void; children: ReactNode }): JSX.Element
export function CollapsibleTrigger(props: ComponentProps): JSX.Element // wraps the click target
export function CollapsibleContent(props: ComponentProps): JSX.Element // shows when open
From components/ui/card.tsx:
export function Card(props: ComponentProps<"div">): JSX.Element // bg-card border rounded-xl py-6 shadow-sm
export function CardContent(props: ComponentProps<"div">): JSX.Element // px-6
Note: Card has built-in py-6 px-0 and CardContent has px-6. For tightly-padded
inline content (the divide-y row lists), prefer a plain <div className="rounded-xl border overflow-hidden"> wrapper rather than fighting Card's internal padding. Use <Card> only where the UI-SPEC explicitly says "".
From components/ui/button.tsx:
export function Button(props: { variant?: "default" | "outline" | "ghost" | ...; size?: "sm" | "default" | "lg"; ... }): JSX.Element
FinanceData shape (kept unchanged per D-21 — already inline in the existing page):
interface AgingBucket { balance: number; count: number; }
interface FinanceData {
summary: {
total_ar: number; total_ar_count: number;
current_balance: number; current_count: number;
overdue_balance: number; overdue_count: number;
paid_mtd: number; paid_ytd: number;
};
aging: { days_1_30: AgingBucket; days_31_60: AgingBucket; days_60_plus: AgingBucket };
top_customers: { customer_ref_name: string; balance: number; invoice_count: number }[];
open_invoices: { id: string; doc_number: string; txn_date: string; due_date: string; customer_ref_name: string; total_amt: number; balance: number; status: string; days_overdue: number }[];
recent_payments: { id: string; txn_date: string; customer_ref_name: string; total_amt: number }[];
monthly_revenue: { month: string; revenue: number; count: number }[];
}
Helpers — preserved unchanged per D-05, D-21:
function fmt$(n: number): string // Intl.NumberFormat USD, maximumFractionDigits: 0
function fmtDate(ts: string): string // 'short month day year'
Task 1: Rewrite app/mobile/finance/page.tsx to the Phase 5 visual contract
app/mobile/finance/page.tsx
- .planning/phases/05-finance-restyle/05-UI-SPEC.md (full file — every section is load-bearing)
- .planning/phases/05-finance-restyle/05-CONTEXT.md (D-01 through D-23)
- .planning/phases/05-finance-restyle/05-01-SUMMARY.md (FinanceRow + FinanceSkeleton import paths and prop shapes)
- .planning/phases/04-tickets-restyle/04-02-SUMMARY.md (precedent for toast.error in catch blocks D-21, no horizontal-scroll on small viewports)
- app/mobile/finance/page.tsx (current 309-line file being rewritten — preserve fmt$, fmtDate, syncAndRefresh, loadLastSync, FinanceData interface, all useState shapes verbatim)
- components/mobile/KpiCardMobile.tsx (reuse — do NOT modify)
- components/mobile/FinanceRow.tsx (Plan 01 output)
- components/mobile/FinanceSkeleton.tsx (Plan 01 output)
Open with `'use client';` and the imports below. Use `import type` only where appropriate.
'use client';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import {
RefreshCw,
AlertTriangle,
CheckCircle2,
ChevronDown,
ChevronRight,
CloudDownload,
} from 'lucide-react';
import { KpiCardMobile } from '@/components/mobile/KpiCardMobile';
import { FinanceRow } from '@/components/mobile/FinanceRow';
import { FinanceSkeleton } from '@/components/mobile/FinanceSkeleton';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Button } from '@/components/ui/button';
Preserve verbatim (per D-05, D-20, D-21):
interface AgingBucket { balance: number; count: number; }interface FinanceData { ... }— the entire inline interface from the current pagefunction fmt$(n: number) { ... }— same body, zero decimals via maximumFractionDigits: 0function fmtDate(ts: string) { ... }— same body- All useState shapes:
data,loading,error,invoicesOpen,paymentsOpen,tab('open' | 'overdue', default'overdue'per D-15),syncing,syncMsg,lastSync load(),loadLastSync(),syncAndRefresh()poll loop bodies — keep the existing logic; only the surface changes
Behavioral changes from the existing page (these are the locked CONTEXT decisions — apply each):
-
D-23 — drop the page H1 ("Finance" heading at line 112). The shell HeaderBar owns branding. The page opens directly with the KPI grid. Move the
lastSynctimestamp to render astext-[10px] text-muted-foregroundinline beside the Refresh button, NOT under a heading. -
D-14 — header controls row at the top of the page (above KPI grid):
<div className="flex items-center justify-end gap-2 px-4 pt-4"> {lastSync && ( <span className="text-[10px] text-muted-foreground mr-auto">Last sync {lastSync}</span> )} <button onClick={load} disabled={loading} aria-label="Refresh finance data" className="p-3 rounded-full hover:bg-muted/50 transition-colors disabled:opacity-40" > <RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} /> </button> <button onClick={syncAndRefresh} disabled={syncing} aria-label="Sync from QuickBooks" className="flex items-center gap-2 px-3 py-2 rounded-xl bg-primary text-primary-foreground text-xs font-semibold disabled:opacity-50 hover:bg-primary/90 transition-colors" > <CloudDownload className={`h-3.5 w-3.5 ${syncing ? 'animate-pulse' : ''}`} /> {syncing ? 'Syncing…' : 'Sync QBO'} </button> </div>Note
p-3on Refresh meets the 44 px touch target rule from UI-SPEC §"Spacing Scale". -
D-17 — sync progress banner (when
syncMsgis non-null): render inbg-muted text-xs text-muted-foreground rounded-xl px-3 py-2 mx-4with aRefreshCw h-3 w-3 animate-spin shrink-0icon. Same copy table as before — the table in UI-SPEC §"Copywriting Contract" governs ("Starting sync…", "Syncing with QuickBooks…", "Sync already in progress — refreshing data…", "Refreshing data…"). When sync completes successfully (aftersetSyncMsg(null)) firetoast.success("QuickBooks sync complete")per UI-SPEC. When sync fails (HTTP non-OK that isn't 409, or thrown error in catch block) firetoast.error("Sync failed — check QBO connection")per UI-SPEC. -
D-01, D-02 — KPI grid (replaces the existing AR Hero gradient and the separate Collected MTD / Revenue YTD cards):
<div className="grid grid-cols-2 gap-3 px-4"> <KpiCardMobile label="TOTAL AR" value={fmt$(summary.total_ar)} caption={`${summary.total_ar_count} open invoices`} /> <KpiCardMobile label="CURRENT" value={fmt$(summary.current_balance)} caption={`${summary.current_count} invoices`} /> <KpiCardMobile label="OVERDUE" value={fmt$(summary.overdue_balance)} caption={`${summary.overdue_count} invoices`} tone="attention" /> <KpiCardMobile label="PAID MTD" value={fmt$(summary.paid_mtd)} caption={`YTD: ${fmt$(summary.paid_ytd)}`} /> </div>The "Revenue YTD" card the existing page rendered separately is folded into the Paid MTD caption per D-02 / UI-SPEC Copywriting Contract row "Paid MTD caption". DELETE the separate AR Hero block (lines 132–151) and the Revenue KPIs grid (lines 197–213) entirely — they are replaced by the four
KpiCardMobiles above. -
D-08 — aging row (visible only when
summary.overdue_balance > 0) — locked colors from UI-SPEC §"Aging Bucket Status Colors":{summary.overdue_balance > 0 && ( <section className="px-4"> <h2 className="text-sm font-semibold mb-2">Overdue Aging</h2> <div className="grid grid-cols-3 gap-2"> {([ { label: '1–30 days', bucket: aging.days_1_30, classes: 'text-amber-600 bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800' }, { label: '31–60 days', bucket: aging.days_31_60, classes: 'text-orange-600 bg-orange-50 dark:bg-orange-950/30 border-orange-200 dark:border-orange-800' }, { label: '60+ days', bucket: aging.days_60_plus, classes: 'text-destructive bg-destructive/10 border-destructive/30' }, ] as const).map(({ label, bucket, classes }) => ( <div key={label} className={`rounded-xl border p-3 ${classes}`}> <p className="text-sm font-semibold">{fmt$(bucket.balance)}</p> <p className="text-[10px] mt-0.5">{label}</p> <p className="text-[10px] font-mono opacity-70">{bucket.count} inv</p> </div> ))} </div> </section> )}Note the 60+ uses
text-destructivetoken, NOT a rawtext-red-*. Replace the existingtext-yellow-600 bg-yellow-50 ...andtext-red-600 bg-red-50 ...classes (current lines 159–161) with the locked palette from the UI-SPEC table. -
D-07 — Top AR by Customer stacked list (visible only when
top_customers.length > 0):{top_customers.length > 0 && ( <section className="px-4"> <h2 className="text-sm font-semibold mb-2">Top AR by Customer</h2> <div className="rounded-xl border divide-y overflow-hidden"> {top_customers.map((c) => { const pct = summary.total_ar > 0 ? Math.round((c.balance / summary.total_ar) * 100) : 0; return ( <div key={c.customer_ref_name} className="px-4 py-3 flex items-center gap-3"> <div className="flex-1 min-w-0"> <p className="text-sm font-semibold truncate">{c.customer_ref_name}</p> <p className="text-xs text-muted-foreground mt-0.5"> {c.invoice_count} invoice{c.invoice_count !== 1 ? 's' : ''} </p> </div> <div className="shrink-0 text-right"> <p className="text-sm font-semibold">{fmt$(c.balance)}</p> <div className="w-16 h-1 rounded-full bg-muted overflow-hidden mt-1"> <div className="h-full rounded-full bg-primary/70" style={{ width: `${pct}%` }} /> </div> </div> </div> ); })} </div> </section> )}Per UI-SPEC §"Top Customers List" — same 2-line shape, but with the proportion bar on the right. This row diverges enough from FinanceRow that we keep it inline (FinanceRow has no proportion-bar slot — extending it for one consumer hurts more than it helps). The previous
text-sm font-medium(line 181) becomestext-sm font-semiboldper D-03. -
D-09 — Monthly revenue stacked list, NO chart (visible only when
monthly_revenue.length > 0): DELETE the entire bar-chart block (current lines 215–235 — flex/items-end/h-full visualization). Replace with:{monthly_revenue.length > 0 && ( <section className="px-4"> <h2 className="text-sm font-semibold mb-2">Revenue — last 12 months</h2> <div className="rounded-xl border divide-y overflow-hidden"> {monthly_revenue.map((m) => { const monthLabel = new Date(m.month).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); return ( <div key={m.month} className="px-4 py-3 flex items-center justify-between gap-2"> <p className="text-sm font-semibold">{monthLabel}</p> <div className="text-right shrink-0"> <p className="text-sm font-semibold">{fmt$(m.revenue)}</p> <p className="text-xs text-muted-foreground">{m.count} invoice{m.count !== 1 ? 's' : ''}</p> </div> </div> ); })} </div> </section> )}This is the DASH-04 precedent applied to Phase 5 — no recharts on mobile.
-
D-15, D-16 — Open Invoices Collapsible with shadcn primitive + restyled tab toggle: Replace the bare
<button>collapsible (current lines 238–281) with:<section className="px-4"> <Collapsible open={invoicesOpen} onOpenChange={setInvoicesOpen}> <div className="rounded-xl border overflow-hidden"> <CollapsibleTrigger className="w-full flex items-center justify-between px-4 py-4 hover:bg-muted/50 transition-colors"> <span className="flex items-center gap-2"> <AlertTriangle className="h-4 w-4 text-muted-foreground" /> <span className="text-sm font-semibold">Open Invoices</span> <span className="text-xs text-muted-foreground bg-muted rounded-full px-2 py-1 font-mono">{open_invoices.length}</span> </span> {invoicesOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />} </CollapsibleTrigger> <CollapsibleContent> <div className="flex border-t border-b"> {(['overdue', 'open'] as const).map((t) => ( <button key={t} onClick={() => setTab(t)} aria-pressed={tab === t} className={`flex-1 text-xs py-2 border-b-2 transition-colors ${ tab === t ? 'border-primary text-primary font-semibold' : 'border-transparent text-muted-foreground font-normal' }`} > {t === 'overdue' ? `Overdue (${overdueInvoices.length})` : `Current (${currentInvoices.length})`} </button> ))} </div> <div className="divide-y max-h-80 overflow-y-auto"> {(tab === 'overdue' ? overdueInvoices : currentInvoices).map((inv) => ( <FinanceRow key={inv.id} primary={inv.customer_ref_name} amount={fmt$(inv.balance)} amountTone={inv.status === 'Overdue' ? 'destructive' : 'default'} secondary={ <> #{inv.doc_number} · Due {fmtDate(inv.due_date)} {inv.days_overdue > 0 && ( <span className="text-destructive ml-1">({inv.days_overdue}d overdue)</span> )} </> } /> ))} </div> </CollapsibleContent> </div> </Collapsible> </section>Hard rules:
- Tab buttons use ONLY
font-semibold(active) /font-normal(inactive) — NOfont-mediumper D-03 - Replace
text-red-500/text-red-600from existing lines 269/272 withtext-destructivetoken per Color contract - Count badge classes literally
text-xs text-muted-foreground bg-muted rounded-full px-2 py-1 font-monoper UI-SPEC §"Collapsible Section Triggers" - Tab toggle gets
aria-pressed={tab === t}per UI-SPEC §"Accessibility"
- Tab buttons use ONLY
-
D-16 — Recent Payments Collapsible:
<section className="px-4"> <Collapsible open={paymentsOpen} onOpenChange={setPaymentsOpen}> <div className="rounded-xl border overflow-hidden"> <CollapsibleTrigger className="w-full flex items-center justify-between px-4 py-4 hover:bg-muted/50 transition-colors"> <span className="flex items-center gap-2"> <CheckCircle2 className="h-4 w-4 text-muted-foreground" /> <span className="text-sm font-semibold">Recent Payments</span> <span className="text-xs text-muted-foreground bg-muted rounded-full px-2 py-1 font-mono">{recent_payments.length}</span> </span> {paymentsOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />} </CollapsibleTrigger> <CollapsibleContent> <div className="divide-y border-t max-h-64 overflow-y-auto"> {recent_payments.length === 0 ? ( <div className="px-4 py-3 text-sm text-muted-foreground">—</div> ) : ( recent_payments.map((p) => ( <FinanceRow key={p.id} primary={p.customer_ref_name} amount={fmt$(p.total_amt)} amountTone="positive" secondary={fmtDate(p.txn_date)} /> )) )} </div> </CollapsibleContent> </div> </Collapsible> </section>Empty payments collapsible body shows a literal em-dash "—" per UI-SPEC Copywriting Contract row "Empty payments (in collapsible)" / D-19.
-
D-19 — empty state when
summary.total_ar === 0 && open_invoices.length === 0. Render in place of the Open Invoices collapsible and the aging section (which is already gated onoverdue_balance > 0). Suggested:const isEmpty = summary.total_ar === 0 && open_invoices.length === 0;Use
isEmptyto conditionally render a<div className="rounded-xl border bg-muted/30 px-4 py-8 mx-4 text-center"><p className="text-sm text-muted-foreground">No outstanding AR</p></div>instead of the invoices collapsible. KPI grid + Paid MTD KPI still render (they're informative even at zero AR). Top customers and monthly revenue sections hide naturally when their arrays are empty. -
D-18 — error state (initial load failure): replace the current
<div className="p-4 text-sm text-destructive">{error}</div>with the destructive-tinted retry card from UI-SPEC §"Error State":if (error) return ( <div className="rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-6 text-center mx-4 my-4"> <p className="text-sm font-semibold text-destructive">Failed to load finance data</p> <p className="text-xs text-muted-foreground mt-1">Check your connection and try again.</p> <Button variant="outline" size="sm" className="mt-4" onClick={load}>Retry</Button> </div> ); -
D-17 — loading state:
if (loading && !data) return <FinanceSkeleton />;Note the
&& !data— once data is loaded, subsequent Refresh clicks should NOT flash the skeleton (loadingflips totrueagain briefly during refresh). The Refresh button's spinning icon already indicates progress. -
D-10, D-11 — page container spacing: Top-level wrapper:
<div className="pb-4 space-y-6">(nopx-4on the outer wrapper — each section owns its ownpx-4since the Refresh banner needs to align tightly with section content while the header controls row already has its ownpx-4 pt-4). Thepb-4provides bottom padding (the shell layout owns the bottom-nav offset). Sections are separated byspace-y-6per D-10. -
D-21 — toast.error in catch blocks (Phase 4 D-21 precedent):
- In
load()catch block: keepsetError(String(e))AND firetoast.error("Sync failed — check QBO connection")? No —load()is for initial/refresh page load, not sync. Use a generictoast.error("Failed to refresh finance data")here. - In
syncAndRefresh()catch block: keepsetSyncMsg('Error: …')AND firetoast.error("Sync failed — check QBO connection")per UI-SPEC Copywriting Contract. - On successful sync completion (after
setSyncMsg(null)resolves): firetoast.success("QuickBooks sync complete").
- In
Hard prohibitions (do NOT introduce):
font-mediumanywhere (D-03)font-boldanywhere — UI-SPEC locks two weights only andfont-semiboldis the heavy onetext-red-500,text-red-600,text-yellow-600,bg-red-50,bg-yellow-50etc. — Aging uses the lockedtext-amber-*/text-orange-*/text-destructivepalette per UI-SPEC §"Aging Bucket Status Colors"; everything else usestext-destructivetokentext-green-600for success amounts — UI-SPEC §"Invoice Status Colors" lockstext-emerald-600(FinanceRow handles this viaamountTone="positive")- A separate "Revenue YTD" card — folded into Paid MTD caption per D-02
rechartsor any chart component — D-09 / DASH-04 precedent- New API routes, new endpoints, new fetched data sources (D-20, D-21, D-22)
- New top-level
statelibraries — match existinguseState+fetchper CLAUDE.md - A wrapper around
<main>(the shellapp/mobile/layout.tsxfrom Phase 2 owns it — D-23) - Any change to
app/api/mobile/finance/route.ts(D-20) - A page H1 ("Finance" heading) — UI-SPEC §"Typography" Heading note explicitly removes it
- Cherry-pick lesson from Phase 4: do NOT touch any file outside
app/mobile/finance/page.tsx. Iftsc --noEmitsurfaces unrelated errors, leave them alone — they belong to other phases.
Final structure (top-down ordering):
- Header controls row (lastSync caption + Refresh + Sync QBO)
- Sync progress banner (conditional)
- Empty-state card OR sections 4–9 below
- KPI 2×2 grid
- Aging row (gated on
overdue_balance > 0) - Top AR by Customer (gated on
top_customers.length > 0) - Open Invoices Collapsible
- Recent Payments Collapsible
- Monthly Revenue list (gated on
monthly_revenue.length > 0) npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/finance/page.tsx" || echo "OK: no type errors in finance page" <acceptance_criteria>- File compiles:
npx tsc --noEmit --pretty 2>&1 | grep -c "app/mobile/finance/page.tsx"returns0 - Imports KpiCardMobile:
grep -q "from '@/components/mobile/KpiCardMobile'" app/mobile/finance/page.tsx - Imports FinanceRow:
grep -q "from '@/components/mobile/FinanceRow'" app/mobile/finance/page.tsx - Imports FinanceSkeleton:
grep -q "from '@/components/mobile/FinanceSkeleton'" app/mobile/finance/page.tsx - Imports shadcn Collapsible:
grep -q "from '@/components/ui/collapsible'" app/mobile/finance/page.tsx - Imports sonner toast:
grep -q "from 'sonner'" app/mobile/finance/page.tsx - Renders 4 KpiCardMobile usages:
grep -c "<KpiCardMobile" app/mobile/finance/page.tsxreturns4 - At least one KpiCardMobile uses
tone="attention"(the Overdue tile):grep -q 'tone="attention"' app/mobile/finance/page.tsx - Renders FinanceRow at least twice (invoice + payment lists):
grep -c "<FinanceRow" app/mobile/finance/page.tsxreturns at least2 - No
font-medium:grep -E "font-medium" app/mobile/finance/page.tsxreturns nothing (D-03) - No
font-bold:grep -E "font-bold" app/mobile/finance/page.tsxreturns nothing (D-03 — semibold is the heavy weight) - No raw red Tailwind palette in body:
grep -E "text-red-[0-9]+|bg-red-[0-9]+" app/mobile/finance/page.tsxreturns nothing (usetext-destructivetoken) - No raw yellow Tailwind palette:
grep -E "text-yellow-[0-9]+|bg-yellow-[0-9]+" app/mobile/finance/page.tsxreturns nothing (aging uses amber per UI-SPEC) - No green-600 (replaced by emerald via FinanceRow):
grep -E "text-green-[0-9]+|bg-green-[0-9]+" app/mobile/finance/page.tsxreturns nothing - Aging uses locked classes:
grep -q "text-amber-600" app/mobile/finance/page.tsxANDgrep -q "text-orange-600" app/mobile/finance/page.tsxANDgrep -q "text-destructive" app/mobile/finance/page.tsx - No recharts / chart library import:
grep -E "from 'recharts'" app/mobile/finance/page.tsxreturns nothing - No bar-chart visualization remnants:
grep -E "items-end|h-full group" app/mobile/finance/page.tsxreturns nothing - No "Finance" page H1:
grep -E "<h1[^>]*>Finance" app/mobile/finance/page.tsxreturns nothing - Refresh aria-label present:
grep -q 'aria-label="Refresh finance data"' app/mobile/finance/page.tsx - Sync QBO aria-label present:
grep -q 'aria-label="Sync from QuickBooks"' app/mobile/finance/page.tsx - toast.success used:
grep -q "toast.success" app/mobile/finance/page.tsx - toast.error used:
grep -q "toast.error" app/mobile/finance/page.tsx - Shadcn Collapsible used (not bare button collapse):
grep -q "<CollapsibleTrigger" app/mobile/finance/page.tsxANDgrep -q "<CollapsibleContent" app/mobile/finance/page.tsx - FinanceData interface and helpers preserved:
grep -q "interface FinanceData" app/mobile/finance/page.tsxANDgrep -q "function fmt\\$" app/mobile/finance/page.tsxANDgrep -q "function fmtDate" app/mobile/finance/page.tsx - useState shapes preserved (sample):
grep -q "useState<'open' | 'overdue'>('overdue')" app/mobile/finance/page.tsx(D-15) - API route untouched:
git diff --name-only HEAD app/api/mobile/finance/route.ts | wc -lreturns0 - Layout untouched:
git diff --name-only HEAD app/mobile/layout.tsx | wc -lreturns0(D-23) - KpiCardMobile untouched:
git diff --name-only HEAD components/mobile/KpiCardMobile.tsx | wc -lreturns0(D-01 says reuse, not modify) - Page-level container has
space-y-6per D-10:grep -q "space-y-6" app/mobile/finance/page.tsx</acceptance_criteria>app/mobile/finance/page.tsxis rewritten to consumeKpiCardMobile(4×),FinanceRow(≥2×),FinanceSkeleton, and shadcnCollapsible; the AR Hero gradient block, the standalone Revenue YTD card, and the bar chart are gone; aging uses locked color classes; tab toggle usesfont-semibold/font-normalonly; toast.success and toast.error fire on sync outcomes; D-19 empty state renders whentotal_ar === 0 && open_invoices.length === 0; D-18 retry card replaces the inline error; the page passesnpx tsc --noEmit --pretty; no other file in the repo is modified.
- File compiles:
1. **Initial load** — FinanceSkeleton renders for the loading window: 2×2 KPI tile
skeletons + 3-cell aging-row skeleton + two 3-row list skeletons. No flash of
unstyled content. (D-17)
2. **No page H1** — there is NO "Finance" heading at the top of the body. The shell
HeaderBar (Wulf mark + Bell + avatar) is the only chrome above the KPI grid.
(UI-SPEC §"Typography" Heading note)
3. **Header controls row** — Refresh icon button (44 px tap target via `p-3`), Sync QBO
button with cloud icon, and `Last sync ...` caption render in a single row aligned
to the right. Refresh has `aria-label="Refresh finance data"` (inspect element to
confirm). Sync QBO has `aria-label="Sync from QuickBooks"`. (D-14)
4. **KPI grid** — 2×2 with Total AR / Current / Overdue (red left-border via
`tone="attention"`) / Paid MTD. The Paid MTD tile shows "YTD: $X,XXX" caption.
Currency renders with no decimals (zero `fmt$`). (D-01, D-02, D-05)
5. **Aging row** — visible only when overdue exists. Three cells with amber / orange /
destructive tones. No horizontal scroll. (D-08)
6. **Top AR by Customer** — stacked list (NOT a table), each row shows customer name,
invoice count, balance, and a small proportion bar. (D-07)
7. **Open Invoices Collapsible** — collapsed by default. Tapping the chevron opens it.
Inside: a tab toggle (Overdue (N) / Current (N)) using border-bottom + primary
color for active. Tap each tab — list filters live, no refetch. Each row shows
customer + amount on line 1, `#docNumber · Due May 1 (3d overdue)` style on line 2
in `text-xs text-muted-foreground`. Overdue amounts render in `text-destructive`.
(D-06, D-15, D-16)
8. **Recent Payments Collapsible** — collapsed by default. Tap to open. Each row shows
customer + amount on line 1 (amount in `text-emerald-600`) and txn date on line 2.
Empty list shows a single em-dash "—" row. (D-06, D-19)
9. **Monthly Revenue** — stacked list, NO chart. Each row: month label on left,
revenue + invoice count on right. No bars. (D-09 / DASH-04 precedent)
10. **Sync flow** — tap "Sync QBO". Banner appears below the controls with copy from
the UI-SPEC Copywriting Contract ("Starting sync…" → "Syncing with QuickBooks…" →
"Refreshing data…"). On success, sonner toast pops "QuickBooks sync complete".
Disconnect from network and tap again — banner disappears, sonner toast pops
"Sync failed — check QBO connection". (D-14, D-17, D-18)
11. **Error state** — temporarily break the API URL in the page (or stop the dev DB)
and reload. The destructive retry card renders inline with "Failed to load
finance data" / "Check your connection and try again." / "Retry" button. Tapping
Retry re-runs `load()`. (D-18) Restore the URL/DB before approving.
12. **Empty state** — only verifiable if your dev data has no outstanding AR. If so,
confirm "No outstanding AR" message renders instead of empty tables. (D-19)
Skippable if data forces invoices to exist.
13. **No horizontal overflow at 360px** — drag DevTools width to 360 px. No section
overflows the viewport. Tabs, aging row, customer rows, list rows all wrap or
truncate gracefully. (D-12)
14. **Bottom nav active tab** — the Finance icon in the bottom nav uses
`text-primary`. (Pre-existing Phase 2 behavior — sanity check it still works.)
- All 14 verification steps PASS (or are explicitly waived with reason)
- No console errors in the browser DevTools console
- No TypeScript errors: re-running `npx tsc --noEmit --pretty` is clean
Type "approved" to mark the phase complete, or describe any issues seen and the executor will course-correct before continuing.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Browser → /api/mobile/finance | Authenticated session cookie (no change from existing route — Better Auth + middleware) |
| Browser → /api/qbo/sync | Authenticated session cookie (existing endpoint, unchanged behavior) |
No new boundary is introduced. The page is read-only from the user's perspective and the only state-changing call is POST /api/qbo/sync which already exists and is preserved verbatim.
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-05-03 | Information Disclosure | Page rendering of customer names + balances | accept | Same data the existing page already renders. Auth required by middleware to reach /mobile/*. No new exposure surface. |
| T-05-04 | Tampering | Tab toggle / collapsible URL state | accept | Tab and collapsible state is useState only — NOT URL-synced (D-16). No URL parsing, no untrusted input flowing to render. |
| T-05-05 | Denial of Service | Sync poll loop | accept | Pre-existing 90-second cap on the poll loop is preserved verbatim. No new loop introduced. |
| T-05-06 | Injection (XSS) | FinanceRow secondary / rightSecondary ReactNode props |
mitigate | All values flow as React text nodes (JSX) — no dangerouslySetInnerHTML anywhere. Customer names / doc numbers come from the trusted DB layer (no user-controlled writes pass through qbo_invoices / qbo_payments — these are QBO-sourced). React's escaping covers any future surprise. |
| </threat_model> |
<success_criteria>
- FIN-01:
/mobile/financeadopts the new Card and typography scale; no horizontal overflow at 360 px viewport; spacing legible on small phones (verified by checkpoint Task 2) - FIN-02: Sections that previously rendered wide tables on phone widths now render as stacked lists; no new sections, no new data sources (verified by acceptance grep checks + checkpoint)
- All 23 locked decisions (D-01 through D-23) are observable in the rendered page or in the file's class strings / behavior
- The shell HeaderBar + BottomNav (Phase 2) and the API route (
app/api/mobile/finance/route.ts) are byte-identical to before this plan ran - Page passes
npx tsc --noEmit --pretty</success_criteria>