docs(05): create phase plan
This commit is contained in:
parent
6a4a55cb9c
commit
a0ccd14044
3 changed files with 989 additions and 2 deletions
|
|
@ -99,7 +99,9 @@ Decimal phases appear between their surrounding integers in numeric order.
|
|||
**Success Criteria** (what must be TRUE):
|
||||
1. `/mobile/finance` adopts the new Card and typography scale — no horizontal overflow, spacing legible on small phones
|
||||
2. Sections that previously rendered wide tables on phone widths now render as stacked lists (no new sections, no new data sources)
|
||||
**Plans**: TBD
|
||||
**Plans**: 2 plans
|
||||
- [ ] 05-01-PLAN.md — FinanceRow + FinanceSkeleton helper components (FIN-01, FIN-02)
|
||||
- [ ] 05-02-PLAN.md — Rewrite app/mobile/finance/page.tsx to KPI grid + stacked lists + shadcn Collapsibles (FIN-01, FIN-02)
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 6: Analyzer Feed (NEW)
|
||||
|
|
@ -150,7 +152,7 @@ Phases execute in numeric order. Phase 2 unblocks Phases 3–7 (any order, paral
|
|||
| 2. Mobile Shell + More Drawer | 0/TBD | Not started | - |
|
||||
| 3. Dashboard Restyle | 0/2 | Not started | - |
|
||||
| 4. Tickets Restyle | 0/3 | Not started | - |
|
||||
| 5. Finance Restyle | 0/TBD | Not started | - |
|
||||
| 5. Finance Restyle | 0/2 | Not started | - |
|
||||
| 6. Analyzer Feed | 0/TBD | Not started | - |
|
||||
| 7. Engagement Overview | 0/TBD | Not started | - |
|
||||
| 8. Engagement User Profile | 0/TBD | Not started | - |
|
||||
|
|
|
|||
309
.planning/phases/05-finance-restyle/05-01-PLAN.md
Normal file
309
.planning/phases/05-finance-restyle/05-01-PLAN.md
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
---
|
||||
phase: 05-finance-restyle
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- components/mobile/FinanceRow.tsx
|
||||
- components/mobile/FinanceSkeleton.tsx
|
||||
autonomous: true
|
||||
requirements: [FIN-01, FIN-02]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A 2-line stacked row (customer + amount on line 1, secondary metadata on line 2) is rendered by a reusable FinanceRow component, used by both invoice rows and payment rows in Plan 02"
|
||||
- "An initial-load skeleton (4 KPI tiles + 1 aging row + 3 list-row skeletons) is rendered by a FinanceSkeleton component, ready for Plan 02 to drop into the loading branch"
|
||||
artifacts:
|
||||
- path: "components/mobile/FinanceRow.tsx"
|
||||
provides: "Reusable 2-line stacked row for invoice and payment lists (D-06)"
|
||||
exports: ["FinanceRow", "FinanceRowProps"]
|
||||
- path: "components/mobile/FinanceSkeleton.tsx"
|
||||
provides: "Initial-load skeleton matching final page layout (D-17)"
|
||||
exports: ["FinanceSkeleton"]
|
||||
key_links:
|
||||
- from: "components/mobile/FinanceRow.tsx"
|
||||
to: "components/ui/skeleton.tsx (NOT used here — pure presentational)"
|
||||
via: "(no DB or fetch — pure presentational)"
|
||||
pattern: "export function FinanceRow"
|
||||
- from: "components/mobile/FinanceSkeleton.tsx"
|
||||
to: "components/ui/skeleton.tsx"
|
||||
via: "import { Skeleton }"
|
||||
pattern: "from '@/components/ui/skeleton'"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Extract two pure-presentational helper components for the Phase 5 Finance page rewrite:
|
||||
`FinanceRow` (the 2-line stacked card row used by both invoice and payment lists per
|
||||
UI-SPEC §"Invoice / Payment List Rows"), and `FinanceSkeleton` (the initial-load
|
||||
placeholder per UI-SPEC §"Skeleton Loading State", D-17).
|
||||
|
||||
Purpose: Plan 02 consumes both directly. Extracting first means Plan 02 can focus on
|
||||
composition (data plumbing, Collapsibles, KPI tiles, empty/error states) without
|
||||
inline duplication of the row JSX or a sprawling skeleton block. Both components are
|
||||
internal helpers — no public app-level export needed.
|
||||
|
||||
Output: Two new files in `components/mobile/`, no changes anywhere else.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/05-finance-restyle/05-CONTEXT.md
|
||||
@.planning/phases/05-finance-restyle/05-UI-SPEC.md
|
||||
@.planning/phases/04-tickets-restyle/04-01-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
<!-- shadcn primitives available — no new deps -->
|
||||
|
||||
From components/ui/skeleton.tsx:
|
||||
```typescript
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">): JSX.Element
|
||||
// Renders: <div className="bg-accent animate-pulse rounded-md ..." />
|
||||
export { Skeleton }
|
||||
```
|
||||
|
||||
From components/mobile/KpiCardMobile.tsx (Phase 3 — reference only, NOT modified here):
|
||||
```typescript
|
||||
export type KpiTone = 'default' | 'attention';
|
||||
interface KpiCardMobileProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
caption?: string;
|
||||
tone?: KpiTone;
|
||||
}
|
||||
export function KpiCardMobile(props: KpiCardMobileProps): JSX.Element
|
||||
```
|
||||
|
||||
Phase comment block convention (Phase 3/4 pattern — apply to both new files):
|
||||
```typescript
|
||||
/* ComponentName — phase 05 (FIN-NN).
|
||||
* Purpose: one-line description.
|
||||
* Props: ... */
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create FinanceRow component</name>
|
||||
<files>components/mobile/FinanceRow.tsx</files>
|
||||
<read_first>
|
||||
- .planning/phases/05-finance-restyle/05-UI-SPEC.md (sections: "Invoice / Payment List Rows — Stacked Card Rows [D-06]", "Typography", "Color")
|
||||
- .planning/phases/05-finance-restyle/05-CONTEXT.md (D-06)
|
||||
- components/mobile/KpiCardMobile.tsx (phase comment block convention)
|
||||
- app/mobile/finance/page.tsx (lines 262–303, current invoice and payment row JSX — extract this shape)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `components/mobile/FinanceRow.tsx` as a 'use client' file (matching `KpiCardMobile.tsx` pattern). Pure presentational — no fetch, no state.
|
||||
|
||||
Open with the phase comment block per Phase 3/4 convention:
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
/* FinanceRow — phase 05 (FIN-02).
|
||||
* Purpose: 2-line stacked row used by Open Invoices and Recent Payments lists.
|
||||
* Line 1: primary label (left, text-sm font-semibold truncate) + amount (right, text-sm font-semibold).
|
||||
* Line 2: secondary metadata (left, text-xs text-muted-foreground) + optional date (right, text-xs text-muted-foreground).
|
||||
* Pure presentational — parent owns the data.
|
||||
* Props: see FinanceRowProps. */
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
```
|
||||
|
||||
Export the props interface and component:
|
||||
|
||||
```typescript
|
||||
export interface FinanceRowProps {
|
||||
/** Primary label on line 1 (e.g., customer name) */
|
||||
primary: string;
|
||||
/** Amount string already formatted (e.g., "$1,234") — caller passes fmt$(value) */
|
||||
amount: string;
|
||||
/** Secondary metadata on line 2 (e.g., "#1234 · Due May 1" or just a date) */
|
||||
secondary?: React.ReactNode;
|
||||
/** Optional right-side date on line 2 (used by payment rows; invoice rows put date in `secondary`) */
|
||||
rightSecondary?: React.ReactNode;
|
||||
/** When true, amount renders in destructive color (overdue invoices). */
|
||||
amountTone?: 'default' | 'destructive' | 'positive';
|
||||
}
|
||||
|
||||
export function FinanceRow({ primary, amount, secondary, rightSecondary, amountTone = 'default' }: FinanceRowProps) {
|
||||
const amountClass = cn(
|
||||
'text-sm font-semibold shrink-0',
|
||||
amountTone === 'destructive' && 'text-destructive',
|
||||
amountTone === 'positive' && 'text-emerald-600'
|
||||
);
|
||||
return (
|
||||
<div className="px-4 py-3 flex items-start justify-between gap-2 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold truncate">{primary}</p>
|
||||
{secondary && <p className="text-xs text-muted-foreground mt-0.5">{secondary}</p>}
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className={amountClass}>{amount}</p>
|
||||
{rightSecondary && <p className="text-xs text-muted-foreground mt-0.5">{rightSecondary}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Hard rules from UI-SPEC (do NOT deviate):
|
||||
- Line 1 weight: `text-sm font-semibold` on BOTH primary and amount (per Typography table)
|
||||
- Line 2 weight: `text-xs font-normal text-muted-foreground` (per Typography table)
|
||||
- Container: `px-4 py-3 flex items-start justify-between gap-2`
|
||||
- Hover: `hover:bg-muted/50 transition-colors` (per Color §"Secondary surface")
|
||||
- Amount tones: only `text-destructive` (overdue) and `text-emerald-600` (payment positive) — NO other colors per D-06 "Invoice Status Colors" table
|
||||
- ONLY two font weights used: `font-normal` (default on `<p>`) and `font-semibold` — NO `font-medium` per D-03
|
||||
|
||||
Do NOT add:
|
||||
- A priority left-stripe (Finance has no priority taxonomy per UI-SPEC §"Invoice / Payment List Rows")
|
||||
- A rounded card border on the row itself — the parent `divide-y` block owns the border (per UI-SPEC §"Container: divide-y block inside CollapsibleContent")
|
||||
- Any onClick / Link wrapping — rows are read-only per D-22
|
||||
|
||||
Per CLAUDE.md: kebab-case applies to file names broadly; the established convention in `components/mobile/` (Phase 2/3/4) is PascalCase filenames matching the exported component (`KpiCardMobile.tsx`, `TicketFilterStrip.tsx`, etc.). Match that convention — file is `FinanceRow.tsx`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "FinanceRow|components/mobile/FinanceRow" || echo "OK: no FinanceRow type errors"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File exists: `test -f components/mobile/FinanceRow.tsx`
|
||||
- Exports: `grep -E "^export (interface FinanceRowProps|function FinanceRow)" components/mobile/FinanceRow.tsx | wc -l` returns `2`
|
||||
- Phase comment block present: `grep -q "FinanceRow — phase 05" components/mobile/FinanceRow.tsx`
|
||||
- Uses only the two locked font weights: `grep -E "font-medium|font-bold" components/mobile/FinanceRow.tsx` returns nothing (per D-03; `font-semibold` is allowed, `font-normal` is the default and need not appear literally)
|
||||
- Hover class present: `grep -q "hover:bg-muted/50" components/mobile/FinanceRow.tsx`
|
||||
- No priority stripe colors leaked from Phase 4: `grep -E "border-red-500|border-orange-400|border-amber-400|border-slate-300" components/mobile/FinanceRow.tsx` returns nothing
|
||||
- No onClick / Link / router import: `grep -E "onClick|next/link|useRouter" components/mobile/FinanceRow.tsx` returns nothing
|
||||
- Container padding matches D-11 / UI-SPEC: `grep -q "px-4 py-3" components/mobile/FinanceRow.tsx`
|
||||
- TypeScript clean for this file: `npx tsc --noEmit --pretty 2>&1 | grep -c "components/mobile/FinanceRow.tsx"` returns `0`
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
`FinanceRow.tsx` exists, type-checks, exports `FinanceRow` + `FinanceRowProps`, renders the 2-line shape from UI-SPEC §"Invoice / Payment List Rows" with correct typography (text-sm font-semibold on line 1, text-xs text-muted-foreground on line 2), supports `amountTone` for destructive/positive variants, no priority stripe, no interactive handlers.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create FinanceSkeleton component</name>
|
||||
<files>components/mobile/FinanceSkeleton.tsx</files>
|
||||
<read_first>
|
||||
- .planning/phases/05-finance-restyle/05-UI-SPEC.md (section: "Skeleton Loading State [D-17]")
|
||||
- .planning/phases/05-finance-restyle/05-CONTEXT.md (D-10, D-11, D-17)
|
||||
- components/ui/skeleton.tsx (Skeleton primitive)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `components/mobile/FinanceSkeleton.tsx` as a 'use client' file. Pure presentational placeholder matching the final page's spacing.
|
||||
|
||||
Open with the phase comment block:
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
/* FinanceSkeleton — phase 05 (FIN-01).
|
||||
* Purpose: initial-load placeholder for /mobile/finance.
|
||||
* Layout: 4 KPI tile skeletons (2×2 grid) + 1 aging row (3 cells) + 2 list-row skeleton blocks (3 rows each).
|
||||
* Pure presentational — no props.
|
||||
* UI-SPEC §"Skeleton Loading State [D-17]". */
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
```
|
||||
|
||||
Export a single `FinanceSkeleton` function component (no props):
|
||||
|
||||
Top-level wrapper matches page container per D-10: `<div className="px-4 py-4 space-y-6">`
|
||||
|
||||
Three blocks inside, separated by the parent `space-y-6`:
|
||||
|
||||
1. **KPI grid skeleton** — `grid grid-cols-2 gap-3` containing four `<Skeleton className="h-20 rounded-xl" />` blocks. (UI-SPEC §"Skeleton Loading State" item 1: "4 KPI tile skeletons: grid grid-cols-2 gap-3 px-4 pt-4 — each a Skeleton h-20 rounded-xl"; the parent provides px-4 already, drop the inner px-4/pt-4 to avoid double padding.)
|
||||
|
||||
2. **Aging row skeleton** — `grid grid-cols-3 gap-2` containing three `<Skeleton className="h-16 rounded-xl" />` blocks. (UI-SPEC item 2.)
|
||||
|
||||
3. **Two list-row blocks** — render the same 3-row skeleton block twice (one for invoices, one for payments). Each block is a `<div className="space-y-3">` containing 3 rows. Each row matches UI-SPEC item 3:
|
||||
```tsx
|
||||
<div className="px-4 py-3 flex justify-between gap-2">
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
```
|
||||
|
||||
Hard rules from UI-SPEC:
|
||||
- ONLY use `<Skeleton>` from `@/components/ui/skeleton` — do not roll a custom pulse div per D-17
|
||||
- Spacing: outer `space-y-6` per D-10, inner block gaps `space-y-3` per D-11, KPI grid `gap-3` per D-11, aging grid `gap-2` per Spacing Scale "sm"
|
||||
- No labels, no text content — pure shapes
|
||||
- Heights: `h-20` for KPI tiles, `h-16` for aging cells, `h-4` for row text lines (matches Skeleton item heights from UI-SPEC literally)
|
||||
- Page-level `px-4 py-4` per D-10 — owned by the wrapper, not nested
|
||||
|
||||
Do NOT add:
|
||||
- A wrapper Card around any block (KPI tiles are skeletons of `KpiCardMobile`'s outer shape, but in skeleton form a plain rounded-xl Skeleton block IS the placeholder per UI-SPEC item 1 — do not add `<Card>`)
|
||||
- Animation other than what `<Skeleton>` already provides (`animate-pulse` is in the primitive)
|
||||
- Variants or props — caller does not parameterize this skeleton
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "FinanceSkeleton|components/mobile/FinanceSkeleton" || echo "OK: no FinanceSkeleton type errors"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File exists: `test -f components/mobile/FinanceSkeleton.tsx`
|
||||
- Exports `FinanceSkeleton`: `grep -q "^export function FinanceSkeleton" components/mobile/FinanceSkeleton.tsx`
|
||||
- Imports Skeleton primitive: `grep -q "from '@/components/ui/skeleton'" components/mobile/FinanceSkeleton.tsx`
|
||||
- Phase comment block present: `grep -q "FinanceSkeleton — phase 05" components/mobile/FinanceSkeleton.tsx`
|
||||
- Uses 2×2 KPI grid: `grep -q "grid-cols-2" components/mobile/FinanceSkeleton.tsx`
|
||||
- Uses 3-cell aging grid: `grep -q "grid-cols-3" components/mobile/FinanceSkeleton.tsx`
|
||||
- Outer spacing per D-10: `grep -q "space-y-6" components/mobile/FinanceSkeleton.tsx`
|
||||
- Row spacing per D-11: `grep -q "space-y-3" components/mobile/FinanceSkeleton.tsx`
|
||||
- Page padding per D-10: `grep -q "px-4 py-4" components/mobile/FinanceSkeleton.tsx`
|
||||
- Exact KPI height: `grep -q "h-20" components/mobile/FinanceSkeleton.tsx`
|
||||
- Exact aging height: `grep -q "h-16" components/mobile/FinanceSkeleton.tsx`
|
||||
- No Card import (per UI-SPEC item 1): `grep -E "from '@/components/ui/card'" components/mobile/FinanceSkeleton.tsx` returns nothing
|
||||
- No props on the component: `grep -E "function FinanceSkeleton\(\s*\{" components/mobile/FinanceSkeleton.tsx` returns nothing (the regex matches only if props destructuring is present — its absence indicates a no-prop component)
|
||||
- TypeScript clean for this file: `npx tsc --noEmit --pretty 2>&1 | grep -c "components/mobile/FinanceSkeleton.tsx"` returns `0`
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
`FinanceSkeleton.tsx` exists, type-checks, exports a no-prop `FinanceSkeleton` function, renders the exact layout from UI-SPEC §"Skeleton Loading State" (2×2 KPI grid + 3-cell aging row + two 3-row list-skeleton blocks) with the locked spacing tokens (D-10/D-11) and the official `Skeleton` primitive.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| (none introduced) | Both new files are pure presentational React components with no I/O, no fetch, no DOM event handlers, no auth surface, no schema change |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-05-01 | Tampering | components/mobile/FinanceRow.tsx props | accept | Caller passes already-formatted strings (`amount`); no innerHTML, no `dangerouslySetInnerHTML`, React JSX text escaping covers XSS. The `secondary` and `rightSecondary` props are typed `React.ReactNode` so the parent (Plan 02) controls what is rendered — risk is identical to existing TicketRow patterns from Phase 4. |
|
||||
| T-05-02 | Information Disclosure | components/mobile/FinanceSkeleton.tsx | accept | No props, no data flow. Component renders only static class strings and the `Skeleton` primitive — no path for sensitive data to reach DOM. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Phase-level checks (run after both tasks):
|
||||
- `npx tsc --noEmit --pretty` — no errors related to either new file
|
||||
- `grep -l "FinanceRow\|FinanceSkeleton" components/mobile/` — both files indexed
|
||||
- No edit to any file outside `components/mobile/`: `git diff --name-only HEAD | grep -v "^components/mobile/Finance" | grep -v "^.planning/"` returns nothing (excluding planning artifacts)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Both files compile under `npx tsc --noEmit --pretty`
|
||||
- `FinanceRow` renders the 2-line stacked-row shape from UI-SPEC §"Invoice / Payment List Rows" with correct typography (`text-sm font-semibold` line 1, `text-xs text-muted-foreground` line 2) and supports `amountTone="destructive" | "positive" | "default"`
|
||||
- `FinanceSkeleton` renders the exact layout from UI-SPEC §"Skeleton Loading State" with `Skeleton` primitive, locked spacing (`px-4 py-4 space-y-6`, `gap-3`, `gap-2`, `space-y-3`), and the locked heights (`h-20` KPI, `h-16` aging, `h-4` row lines)
|
||||
- No file outside `components/mobile/` is modified
|
||||
- Both files use the Phase 3/4 phase-comment-block convention
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/05-finance-restyle/05-01-SUMMARY.md` per the GSD summary template, including the exported interface signatures Plan 02 will import:
|
||||
|
||||
```typescript
|
||||
import { FinanceRow, type FinanceRowProps } from '@/components/mobile/FinanceRow';
|
||||
import { FinanceSkeleton } from '@/components/mobile/FinanceSkeleton';
|
||||
```
|
||||
</output>
|
||||
676
.planning/phases/05-finance-restyle/05-02-PLAN.md
Normal file
676
.planning/phases/05-finance-restyle/05-02-PLAN.md
Normal file
|
|
@ -0,0 +1,676 @@
|
|||
---
|
||||
phase: 05-finance-restyle
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [05-01]
|
||||
files_modified:
|
||||
- app/mobile/finance/page.tsx
|
||||
autonomous: false
|
||||
requirements: [FIN-01, FIN-02]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "/mobile/finance opens directly to the four KPI tiles (Total AR / Current / Overdue / Paid MTD) rendered as a 2×2 grid using KpiCardMobile (no Finance H1, since the shell HeaderBar owns the brand mark)"
|
||||
- "Overdue Aging renders as a 3-cell row (1–30 / 31–60 / 60+) with the locked semantic color tones — visible only when summary.overdue_balance > 0"
|
||||
- "Open Invoices and Recent Payments collapsibles use shadcn Collapsible (not bare buttons), and both lists render via FinanceRow with the 2-line stacked layout"
|
||||
- "Top AR by Customer renders as a stacked list (no table) — name + invoice count on the left, balance + proportion bar on the right"
|
||||
- "Monthly revenue renders as a stacked list (no chart) — month → revenue → count rows in a divide-y card"
|
||||
- "Initial load shows FinanceSkeleton; load failure shows a destructive-tinted retry card; sync errors fire toast.error; sync success fires toast.success"
|
||||
- "Empty state ('No outstanding AR') renders when summary.total_ar === 0 AND open_invoices.length === 0"
|
||||
- "No horizontal overflow at 360px viewport width"
|
||||
artifacts:
|
||||
- path: "app/mobile/finance/page.tsx"
|
||||
provides: "Restyled mobile Finance page (FIN-01, FIN-02)"
|
||||
contains: "KpiCardMobile, FinanceRow, FinanceSkeleton, Collapsible"
|
||||
key_links:
|
||||
- from: "app/mobile/finance/page.tsx"
|
||||
to: "components/mobile/KpiCardMobile.tsx"
|
||||
via: "import { KpiCardMobile }"
|
||||
pattern: "from '@/components/mobile/KpiCardMobile'"
|
||||
- from: "app/mobile/finance/page.tsx"
|
||||
to: "components/mobile/FinanceRow.tsx"
|
||||
via: "import { FinanceRow }"
|
||||
pattern: "from '@/components/mobile/FinanceRow'"
|
||||
- from: "app/mobile/finance/page.tsx"
|
||||
to: "components/mobile/FinanceSkeleton.tsx"
|
||||
via: "import { FinanceSkeleton }"
|
||||
pattern: "from '@/components/mobile/FinanceSkeleton'"
|
||||
- from: "app/mobile/finance/page.tsx"
|
||||
to: "components/ui/collapsible.tsx"
|
||||
via: "import { Collapsible, CollapsibleTrigger, CollapsibleContent }"
|
||||
pattern: "from '@/components/ui/collapsible'"
|
||||
- from: "app/mobile/finance/page.tsx"
|
||||
to: "/api/mobile/finance"
|
||||
via: "fetch in load()"
|
||||
pattern: "fetch\\(['\"]/api/mobile/finance"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Rewrite `app/mobile/finance/page.tsx` end-to-end to the Phase 5 visual contract:
|
||||
2×2 KPI grid (KpiCardMobile), 3-cell aging row, two shadcn Collapsibles for invoices
|
||||
and payments (each rendering FinanceRow rows from Plan 01), Top Customers stacked
|
||||
list, Monthly Revenue stacked list (no chart), restyled header controls (icon
|
||||
Refresh + Sync QBO with proper aria-labels), shadcn-styled invoice tab toggle,
|
||||
FinanceSkeleton loading state, destructive retry card error state, sonner toasts
|
||||
on sync, and the D-19 empty state.
|
||||
|
||||
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).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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.tsx
|
||||
|
||||
<interfaces>
|
||||
<!-- Contracts the executor consumes — do not re-derive from the codebase -->
|
||||
|
||||
From components/mobile/KpiCardMobile.tsx:
|
||||
```typescript
|
||||
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):
|
||||
```typescript
|
||||
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):
|
||||
```typescript
|
||||
export function FinanceSkeleton(): JSX.Element // no props
|
||||
```
|
||||
|
||||
From components/ui/collapsible.tsx (already installed, used in Phase 4):
|
||||
```typescript
|
||||
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:
|
||||
```typescript
|
||||
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 "<Card>".
|
||||
|
||||
From components/ui/button.tsx:
|
||||
```typescript
|
||||
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):
|
||||
```typescript
|
||||
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:
|
||||
```typescript
|
||||
function fmt$(n: number): string // Intl.NumberFormat USD, maximumFractionDigits: 0
|
||||
function fmtDate(ts: string): string // 'short month day year'
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Rewrite app/mobile/finance/page.tsx to the Phase 5 visual contract</name>
|
||||
<files>app/mobile/finance/page.tsx</files>
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
<action>
|
||||
Open with `'use client';` and the imports below. Use `import type` only where appropriate.
|
||||
|
||||
```typescript
|
||||
'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 page
|
||||
- `function fmt$(n: number) { ... }` — same body, zero decimals via maximumFractionDigits: 0
|
||||
- `function 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):
|
||||
|
||||
1. **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 `lastSync` timestamp to render as `text-[10px] text-muted-foreground` inline beside the Refresh button, NOT under a heading.
|
||||
|
||||
2. **D-14 — header controls row** at the top of the page (above KPI grid):
|
||||
```tsx
|
||||
<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-3` on Refresh meets the 44 px touch target rule from UI-SPEC §"Spacing Scale".
|
||||
|
||||
3. **D-17 — sync progress banner** (when `syncMsg` is non-null): render in `bg-muted text-xs text-muted-foreground rounded-xl px-3 py-2 mx-4` with a `RefreshCw h-3 w-3 animate-spin shrink-0` icon. 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 (after `setSyncMsg(null)`) fire `toast.success("QuickBooks sync complete")` per UI-SPEC. When sync fails (HTTP non-OK that isn't 409, or thrown error in catch block) fire `toast.error("Sync failed — check QBO connection")` per UI-SPEC.
|
||||
|
||||
4. **D-01, D-02 — KPI grid** (replaces the existing AR Hero gradient and the separate Collected MTD / Revenue YTD cards):
|
||||
```tsx
|
||||
<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 `KpiCardMobile`s above.
|
||||
|
||||
5. **D-08 — aging row** (visible only when `summary.overdue_balance > 0`) — locked colors from UI-SPEC §"Aging Bucket Status Colors":
|
||||
```tsx
|
||||
{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-destructive` token, NOT a raw `text-red-*`. Replace the existing `text-yellow-600 bg-yellow-50 ...` and `text-red-600 bg-red-50 ...` classes (current lines 159–161) with the locked palette from the UI-SPEC table.
|
||||
|
||||
6. **D-07 — Top AR by Customer stacked list** (visible only when `top_customers.length > 0`):
|
||||
```tsx
|
||||
{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) becomes `text-sm font-semibold` per D-03.
|
||||
|
||||
7. **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:
|
||||
```tsx
|
||||
{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.
|
||||
|
||||
8. **D-15, D-16 — Open Invoices Collapsible with shadcn primitive + restyled tab toggle**:
|
||||
Replace the bare `<button>` collapsible (current lines 238–281) with:
|
||||
```tsx
|
||||
<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) — NO `font-medium` per D-03
|
||||
- Replace `text-red-500`/`text-red-600` from existing lines 269/272 with `text-destructive` token per Color contract
|
||||
- Count badge classes literally `text-xs text-muted-foreground bg-muted rounded-full px-2 py-1 font-mono` per UI-SPEC §"Collapsible Section Triggers"
|
||||
- Tab toggle gets `aria-pressed={tab === t}` per UI-SPEC §"Accessibility"
|
||||
|
||||
9. **D-16 — Recent Payments Collapsible**:
|
||||
```tsx
|
||||
<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.
|
||||
|
||||
10. **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 on `overdue_balance > 0`). Suggested:
|
||||
```tsx
|
||||
const isEmpty = summary.total_ar === 0 && open_invoices.length === 0;
|
||||
```
|
||||
Use `isEmpty` to 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.
|
||||
|
||||
11. **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":
|
||||
```tsx
|
||||
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>
|
||||
);
|
||||
```
|
||||
|
||||
12. **D-17 — loading state**:
|
||||
```tsx
|
||||
if (loading && !data) return <FinanceSkeleton />;
|
||||
```
|
||||
Note the `&& !data` — once data is loaded, subsequent Refresh clicks should NOT flash the skeleton (`loading` flips to `true` again briefly during refresh). The Refresh button's spinning icon already indicates progress.
|
||||
|
||||
13. **D-10, D-11 — page container spacing**:
|
||||
Top-level wrapper: `<div className="pb-4 space-y-6">` (no `px-4` on the outer wrapper — each section owns its own `px-4` since the Refresh banner needs to align tightly with section content while the header controls row already has its own `px-4 pt-4`). The `pb-4` provides bottom padding (the shell layout owns the bottom-nav offset). Sections are separated by `space-y-6` per D-10.
|
||||
|
||||
14. **D-21 — toast.error in catch blocks** (Phase 4 D-21 precedent):
|
||||
- In `load()` catch block: keep `setError(String(e))` AND fire `toast.error("Sync failed — check QBO connection")`? No — `load()` is for initial/refresh page load, not sync. Use a generic `toast.error("Failed to refresh finance data")` here.
|
||||
- In `syncAndRefresh()` catch block: keep `setSyncMsg('Error: …')` AND fire `toast.error("Sync failed — check QBO connection")` per UI-SPEC Copywriting Contract.
|
||||
- On successful sync completion (after `setSyncMsg(null)` resolves): fire `toast.success("QuickBooks sync complete")`.
|
||||
|
||||
**Hard prohibitions** (do NOT introduce):
|
||||
- `font-medium` anywhere (D-03)
|
||||
- `font-bold` anywhere — UI-SPEC locks two weights only and `font-semibold` is the heavy one
|
||||
- `text-red-500`, `text-red-600`, `text-yellow-600`, `bg-red-50`, `bg-yellow-50` etc. — Aging uses the locked `text-amber-*` / `text-orange-*` / `text-destructive` palette per UI-SPEC §"Aging Bucket Status Colors"; everything else uses `text-destructive` token
|
||||
- `text-green-600` for success amounts — UI-SPEC §"Invoice Status Colors" locks `text-emerald-600` (FinanceRow handles this via `amountTone="positive"`)
|
||||
- A separate "Revenue YTD" card — folded into Paid MTD caption per D-02
|
||||
- `recharts` or 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 `state` libraries — match existing `useState` + `fetch` per CLAUDE.md
|
||||
- A wrapper around `<main>` (the shell `app/mobile/layout.tsx` from 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`. If `tsc --noEmit` surfaces unrelated errors, leave them alone — they belong to other phases.
|
||||
|
||||
**Final structure** (top-down ordering):
|
||||
1. Header controls row (lastSync caption + Refresh + Sync QBO)
|
||||
2. Sync progress banner (conditional)
|
||||
3. Empty-state card OR sections 4–9 below
|
||||
4. KPI 2×2 grid
|
||||
5. Aging row (gated on `overdue_balance > 0`)
|
||||
6. Top AR by Customer (gated on `top_customers.length > 0`)
|
||||
7. Open Invoices Collapsible
|
||||
8. Recent Payments Collapsible
|
||||
9. Monthly Revenue list (gated on `monthly_revenue.length > 0`)
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/finance/page\.tsx" || echo "OK: no type errors in finance page"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File compiles: `npx tsc --noEmit --pretty 2>&1 | grep -c "app/mobile/finance/page.tsx"` returns `0`
|
||||
- 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.tsx` returns `4`
|
||||
- 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.tsx` returns at least `2`
|
||||
- No `font-medium`: `grep -E "font-medium" app/mobile/finance/page.tsx` returns nothing (D-03)
|
||||
- No `font-bold`: `grep -E "font-bold" app/mobile/finance/page.tsx` returns 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.tsx` returns nothing (use `text-destructive` token)
|
||||
- No raw yellow Tailwind palette: `grep -E "text-yellow-[0-9]+|bg-yellow-[0-9]+" app/mobile/finance/page.tsx` returns 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.tsx` returns nothing
|
||||
- Aging uses locked classes: `grep -q "text-amber-600" app/mobile/finance/page.tsx` AND `grep -q "text-orange-600" app/mobile/finance/page.tsx` AND `grep -q "text-destructive" app/mobile/finance/page.tsx`
|
||||
- No recharts / chart library import: `grep -E "from 'recharts'" app/mobile/finance/page.tsx` returns nothing
|
||||
- No bar-chart visualization remnants: `grep -E "items-end|h-full group" app/mobile/finance/page.tsx` returns nothing
|
||||
- No "Finance" page H1: `grep -E "<h1[^>]*>Finance" app/mobile/finance/page.tsx` returns 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.tsx` AND `grep -q "<CollapsibleContent" app/mobile/finance/page.tsx`
|
||||
- FinanceData interface and helpers preserved: `grep -q "interface FinanceData" app/mobile/finance/page.tsx` AND `grep -q "function fmt\\$" app/mobile/finance/page.tsx` AND `grep -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 -l` returns `0`
|
||||
- Layout untouched: `git diff --name-only HEAD app/mobile/layout.tsx | wc -l` returns `0` (D-23)
|
||||
- KpiCardMobile untouched: `git diff --name-only HEAD components/mobile/KpiCardMobile.tsx | wc -l` returns `0` (D-01 says reuse, not modify)
|
||||
- Page-level container has `space-y-6` per D-10: `grep -q "space-y-6" app/mobile/finance/page.tsx`
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
`app/mobile/finance/page.tsx` is rewritten to consume `KpiCardMobile` (4×), `FinanceRow` (≥2×), `FinanceSkeleton`, and shadcn `Collapsible`; the AR Hero gradient block, the standalone Revenue YTD card, and the bar chart are gone; aging uses locked color classes; tab toggle uses `font-semibold`/`font-normal` only; toast.success and toast.error fire on sync outcomes; D-19 empty state renders when `total_ar === 0 && open_invoices.length === 0`; D-18 retry card replaces the inline error; the page passes `npx tsc --noEmit --pretty`; no other file in the repo is modified.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 2: Human verification — visual + interaction sweep</name>
|
||||
<files>app/mobile/finance/page.tsx (verifying — not modifying)</files>
|
||||
<action>Human verification only — see <how-to-verify> below for the 14-step checklist. No code changes. Pause execution and wait for the user to confirm the new finance page behaves per spec on a phone-width viewport.</action>
|
||||
<verify><automated>echo "Manual checkpoint — see resume-signal"</automated></verify>
|
||||
<done>User confirms all 14 checklist items pass on a phone-width viewport (DevTools iPhone 15 Pro emulation at 393 px and 360 px), or describes precisely which step failed and why.</done>
|
||||
<what-built>
|
||||
A complete visual and interaction restyle of `/mobile/finance` per the Phase 5 UI-SPEC.
|
||||
The shell HeaderBar + BottomNav (Phase 2) are unchanged and frame the page. The page body
|
||||
is fully rewritten with: 2×2 KpiCardMobile grid (with Overdue showing the destructive
|
||||
left border), 3-cell aging row using the locked amber/orange/destructive palette,
|
||||
Top Customers stacked list with proportion bars, two shadcn Collapsibles (Open Invoices,
|
||||
Recent Payments) rendering FinanceRow rows, Monthly Revenue stacked list (no chart),
|
||||
restyled Refresh + Sync QBO header controls with aria-labels, FinanceSkeleton initial
|
||||
load, destructive retry card on load failure, and sonner toasts on sync outcomes.
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
Run the app: `npm run dev` (port 3100). Sign in if needed. Then on a phone-sized
|
||||
viewport (DevTools → Device toolbar → iPhone 15 Pro / 393 × 852 — and also test 360px
|
||||
width) load `http://localhost:3100/mobile/finance` and verify:
|
||||
|
||||
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.)
|
||||
</how-to-verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<resume-signal>Type "approved" to mark the phase complete, or describe any issues seen and the executor will course-correct before continuing.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<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>
|
||||
|
||||
<verification>
|
||||
Phase-level checks:
|
||||
- `npx tsc --noEmit --pretty` — no errors
|
||||
- Run dev server, smoke-test the human-verify checkpoint (Task 2)
|
||||
- `git diff --name-only HEAD` shows ONLY `app/mobile/finance/page.tsx` changed (Plan 01's two new files were committed in Wave 1)
|
||||
- Visual cross-check against `.planning/phases/05-finance-restyle/05-UI-SPEC.md` — Checker Sign-Off section dimensions 1–6 should pass
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- FIN-01: `/mobile/finance` adopts 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>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/05-finance-restyle/05-02-SUMMARY.md` per the GSD summary template, including:
|
||||
- The before/after line counts (current file is 309 lines)
|
||||
- Decision coverage matrix (D-01 through D-23 → which section in the rewrite delivers each)
|
||||
- Confirmation that no file outside `app/mobile/finance/page.tsx` was modified in this plan
|
||||
</output>
|
||||
Loading…
Add table
Add a link
Reference in a new issue