From a9a5a987f4bd52f115d8495cf77cbb08b432f5e6 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 3 May 2026 19:56:36 -0400 Subject: [PATCH] feat(05-02): rewrite mobile finance page to Phase 5 visual contract - Replace AR Hero gradient + standalone Revenue YTD with 4 KpiCardMobile tiles (2x2 grid) - Drop bar chart; add monthly-revenue stacked list (D-09 / DASH-04 precedent) - Add shadcn Collapsible for Open Invoices and Recent Payments (D-15, D-16) - Aging row uses locked amber/orange/destructive palette (D-08) - Top AR by Customer rendered as stacked list with proportion bars (D-07) - toast.success + toast.error on sync outcomes (D-17, D-18) - FinanceSkeleton on initial load; destructive retry card on error (D-17, D-18) - D-19 empty state when total_ar===0 and open_invoices.length===0 - D-23: no page H1; header controls row with aria-labels (D-14) - No font-medium, no font-bold, no raw red/yellow/green Tailwind classes --- app/mobile/finance/page.tsx | 474 +++++++++++++++++++++--------------- 1 file changed, 278 insertions(+), 196 deletions(-) diff --git a/app/mobile/finance/page.tsx b/app/mobile/finance/page.tsx index b7b726b..7343a41 100644 --- a/app/mobile/finance/page.tsx +++ b/app/mobile/finance/page.tsx @@ -1,7 +1,25 @@ 'use client'; import { useEffect, useState } from 'react'; -import { RefreshCw, TrendingUp, AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, CloudDownload } from 'lucide-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'; interface AgingBucket { balance: number; count: number; } interface FinanceData { @@ -42,8 +60,10 @@ export default function MobileFinance() { const r = await fetch('/api/mobile/finance'); if (!r.ok) throw new Error('Failed to load'); setData(await r.json()); - } catch (e) { setError(String(e)); } - finally { setLoading(false); } + } catch (e) { + setError(String(e)); + toast.error('Failed to refresh finance data'); + } finally { setLoading(false); } }; const loadLastSync = async () => { @@ -64,7 +84,12 @@ export default function MobileFinance() { try { const r = await fetch('/api/qbo/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ syncType: 'incremental', triggeredBy: 'mobile-finance' }) }); if (r.status === 409) { setSyncMsg('Sync already in progress — refreshing data…'); } - else if (!r.ok) { setSyncMsg('Sync failed'); setSyncing(false); return; } + else if (!r.ok) { + setSyncMsg(null); + setSyncing(false); + toast.error('Sync failed — check QBO connection'); + return; + } else { setSyncMsg('Syncing with QuickBooks…'); } // Poll until sync completes (max 90s) const start = Date.now(); @@ -82,8 +107,10 @@ export default function MobileFinance() { await load(); await loadLastSync(); setSyncMsg(null); + toast.success('QuickBooks sync complete'); } catch (e) { setSyncMsg('Error: ' + String(e)); + toast.error('Sync failed — check QBO connection'); } finally { setSyncing(false); } @@ -91,219 +118,274 @@ export default function MobileFinance() { useEffect(() => { load(); loadLastSync(); }, []); - if (loading) return ( -
- + if (loading && !data) return ; + if (error) return ( +
+

Failed to load finance data

+

Check your connection and try again.

+
); - if (error) return
{error}
; if (!data) return null; const { summary, aging, top_customers, open_invoices, recent_payments, monthly_revenue } = data; - const maxRev = Math.max(...monthly_revenue.map(m => m.revenue), 1); const overdueInvoices = open_invoices.filter(i => i.status === 'Overdue'); const currentInvoices = open_invoices.filter(i => i.status === 'Open'); - const overduePercent = summary.total_ar > 0 ? Math.round((summary.overdue_balance / summary.total_ar) * 100) : 0; + const isEmpty = summary.total_ar === 0 && open_invoices.length === 0; return ( -
-
-
-

Finance

- {lastSync &&

Last sync {lastSync}

} -
-
- - -
+
+ {/* ── Header controls row ─────────────────────────────── */} +
+ {lastSync && ( + Last sync {lastSync} + )} + +
+ + {/* ── Sync progress banner ────────────────────────────── */} {syncMsg && ( -
- +
+ {syncMsg}
)} - {/* ── AR Hero ─────────────────────────────────────────── */} -
-

Total AR Outstanding

-

{fmt$(summary.total_ar)}

-

{summary.total_ar_count} open invoices

- - {/* Current vs Overdue split bar */} -
-
-
-
- - Current {fmt$(summary.current_balance)} ({summary.current_count}) - - - Overdue {fmt$(summary.overdue_balance)} ({summary.overdue_count}) - -
-
- - {/* ── Aging buckets ───────────────────────────────────── */} - {summary.overdue_balance > 0 && ( -
-

Overdue Aging

-
- {[ - { label: '1–30 days', bucket: aging.days_1_30, color: 'text-yellow-600 bg-yellow-50 border-yellow-200' }, - { label: '31–60 days', bucket: aging.days_31_60, color: 'text-orange-600 bg-orange-50 border-orange-200' }, - { label: '60+ days', bucket: aging.days_60_plus, color: 'text-red-600 bg-red-50 border-red-200' }, - ].map(({ label, bucket, color }) => ( -
-

{fmt$(bucket.balance)}

-

{label}

-

{bucket.count} inv

-
- ))} -
-
- )} - - {/* ── Top customers with AR ───────────────────────────── */} - {top_customers.length > 0 && ( -
-

Top AR by Customer

-
- {top_customers.map(c => ( -
-
-

{c.customer_ref_name}

-

{c.invoice_count} invoice{c.invoice_count !== 1 ? 's' : ''}

-
-
-

{fmt$(c.balance)}

-
-
-
-
-
- ))} -
-
- )} - - {/* ── Revenue KPIs ────────────────────────────────────── */} -
-
-
- -

Collected MTD

-
-

{fmt$(summary.paid_mtd)}

-
-
-
- -

Revenue YTD

-
-

{fmt$(summary.paid_ytd)}

-
-
- - {/* ── Revenue chart ───────────────────────────────────── */} - {monthly_revenue.length > 0 && ( -
-

Revenue — last 12 months

-
-
- {monthly_revenue.map(m => { - const h = Math.max(4, Math.round((m.revenue / maxRev) * 100)); - const mo = new Date(m.month).toLocaleDateString('en-US', { month: 'short' }); - return ( -
-
-

{mo}

-
- ); - })} + {isEmpty ? ( + /* ── Empty state ──────────────────────────────────── */ + <> + {/* KPI grid still renders when AR is zero — informative even at zero */} +
+
+ + + +
+
+
+

No outstanding AR

-
- )} - - {/* ── Invoice drill-down (collapsible) ────────────────── */} -
- - {invoicesOpen && ( - <> -
- {(['overdue', 'open'] as const).map(t => ( - - ))} -
-
- {(tab === 'overdue' ? overdueInvoices : currentInvoices).map(inv => ( -
-
-
-

{inv.customer_ref_name}

-

- #{inv.doc_number} · Due {fmtDate(inv.due_date)} - {inv.days_overdue > 0 && ({inv.days_overdue}d)} -

+ {/* ── Overdue Aging row ──────────────────────────── */} + {summary.overdue_balance > 0 && ( +
+

Overdue Aging

+
+ {([ + { 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 }) => ( +
+

{fmt$(bucket.balance)}

+

{label}

+

{bucket.count} inv

+
+ ))} +
+
+ )} + + {/* ── Top AR by Customer ─────────────────────────── */} + {top_customers.length > 0 && ( +
+

Top AR by Customer

+
+ {top_customers.map((c) => { + const pct = summary.total_ar > 0 ? Math.round((c.balance / summary.total_ar) * 100) : 0; + return ( +
+
+

{c.customer_ref_name}

+

+ {c.invoice_count} invoice{c.invoice_count !== 1 ? 's' : ''} +

+
+
+

{fmt$(c.balance)}

+
+
+
+
-

- {fmt$(inv.balance)} -

-
-
- ))} -
- - )} -
- - {/* ── Recent payments (collapsible) ───────────────────── */} -
- - {paymentsOpen && ( -
- {recent_payments.map(p => ( -
-
-

{p.customer_ref_name}

-

{fmtDate(p.txn_date)}

-
-

{fmt$(p.total_amt)}

+ ); + })}
- ))} -
- )} -
+ + )} + + {/* ── Open Invoices Collapsible ──────────────────── */} +
+ +
+ + + + Open Invoices + {open_invoices.length} + + {invoicesOpen + ? + : } + + +
+ {(['overdue', 'open'] as const).map((t) => ( + + ))} +
+
+ {(tab === 'overdue' ? overdueInvoices : currentInvoices).map((inv) => ( + + #{inv.doc_number} · Due {fmtDate(inv.due_date)} + {inv.days_overdue > 0 && ( + ({inv.days_overdue}d overdue) + )} + + } + /> + ))} +
+
+
+
+
+ + {/* ── Recent Payments Collapsible ────────────────── */} +
+ +
+ + + + Recent Payments + {recent_payments.length} + + {paymentsOpen + ? + : } + + +
+ {recent_payments.length === 0 ? ( +
+ ) : ( + recent_payments.map((p) => ( + + )) + )} +
+
+
+
+
+ + {/* ── Monthly Revenue stacked list ───────────────── */} + {monthly_revenue.length > 0 && ( +
+

Revenue — last 12 months

+
+ {monthly_revenue.map((m) => { + const monthLabel = new Date(m.month).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); + return ( +
+

{monthLabel}

+
+

{fmt$(m.revenue)}

+

{m.count} invoice{m.count !== 1 ? 's' : ''}

+
+
+ ); + })} +
+
+ )} + + )}
); }