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
This commit is contained in:
lorentz 2026-05-03 19:56:36 -04:00
parent 1ce80371d1
commit a9a5a987f4

View file

@ -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 (
<div className="flex items-center justify-center h-64">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
if (loading && !data) return <FinanceSkeleton />;
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>
);
if (error) return <div className="p-4 text-sm text-destructive">{error}</div>;
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 (
<div className="p-4 space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Finance</h1>
{lastSync && <p className="text-[11px] text-muted-foreground mt-0.5">Last sync {lastSync}</p>}
</div>
<div className="flex items-center gap-2">
<button onClick={load} disabled={loading} className="p-2 rounded-full hover:bg-accent disabled:opacity-40" title="Refresh display">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
<button onClick={syncAndRefresh} disabled={syncing} className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-primary text-primary-foreground text-xs font-medium disabled:opacity-50 hover:bg-primary/90 transition-colors" title="Sync from QuickBooks then refresh">
<CloudDownload className={`w-3.5 h-3.5 ${syncing ? 'animate-pulse' : ''}`} />
{syncing ? 'Syncing…' : 'Sync QBO'}
</button>
</div>
<div className="pb-4 space-y-6">
{/* ── Header controls row ─────────────────────────────── */}
<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>
{/* ── Sync progress banner ────────────────────────────── */}
{syncMsg && (
<div className="flex items-center gap-2 px-3 py-2 rounded-xl bg-muted text-xs text-muted-foreground">
<RefreshCw className="w-3 h-3 animate-spin shrink-0" />
<div className="flex items-center gap-2 bg-muted text-xs text-muted-foreground rounded-xl px-3 py-2 mx-4">
<RefreshCw className="h-3 w-3 animate-spin shrink-0" />
{syncMsg}
</div>
)}
{/* ── AR Hero ─────────────────────────────────────────── */}
<div className="rounded-2xl border bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-5">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">Total AR Outstanding</p>
<p className="text-4xl font-bold tabular-nums">{fmt$(summary.total_ar)}</p>
<p className="text-sm text-muted-foreground mt-1">{summary.total_ar_count} open invoices</p>
{/* Current vs Overdue split bar */}
<div className="mt-4 h-2 rounded-full bg-blue-100 overflow-hidden">
<div className="h-full rounded-full bg-red-500 transition-all"
style={{ width: `${overduePercent}%` }} />
</div>
<div className="flex justify-between mt-1.5">
<span className="text-xs text-blue-600 font-medium">
Current {fmt$(summary.current_balance)} ({summary.current_count})
</span>
<span className="text-xs text-red-600 font-medium">
Overdue {fmt$(summary.overdue_balance)} ({summary.overdue_count})
</span>
</div>
</div>
{/* ── Aging buckets ───────────────────────────────────── */}
{summary.overdue_balance > 0 && (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Overdue Aging</p>
<div className="grid grid-cols-3 gap-2">
{[
{ label: '130 days', bucket: aging.days_1_30, color: 'text-yellow-600 bg-yellow-50 border-yellow-200' },
{ label: '3160 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 }) => (
<div key={label} className={`rounded-xl border p-3 ${color}`}>
<p className="text-base font-bold">{fmt$(bucket.balance)}</p>
<p className="text-[10px] mt-0.5">{label}</p>
<p className="text-[10px] opacity-70">{bucket.count} inv</p>
</div>
))}
</div>
</div>
)}
{/* ── Top customers with AR ───────────────────────────── */}
{top_customers.length > 0 && (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Top AR by Customer</p>
<div className="rounded-xl border divide-y overflow-hidden">
{top_customers.map(c => (
<div key={c.customer_ref_name} className="flex items-center px-4 py-3 gap-3">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{c.customer_ref_name}</p>
<p className="text-xs text-muted-foreground">{c.invoice_count} invoice{c.invoice_count !== 1 ? 's' : ''}</p>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-bold">{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"
style={{ width: `${Math.round((c.balance / summary.total_ar) * 100)}%` }} />
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* ── Revenue KPIs ────────────────────────────────────── */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-2xl border bg-green-500/5 p-4">
<div className="flex items-center gap-2 mb-1">
<CheckCircle2 className="w-4 h-4 text-green-600" />
<p className="text-xs text-muted-foreground">Collected MTD</p>
</div>
<p className="text-2xl font-bold text-green-600">{fmt$(summary.paid_mtd)}</p>
</div>
<div className="rounded-2xl border bg-blue-500/5 p-4">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-blue-600" />
<p className="text-xs text-muted-foreground">Revenue YTD</p>
</div>
<p className="text-2xl font-bold text-blue-600">{fmt$(summary.paid_ytd)}</p>
</div>
</div>
{/* ── Revenue chart ───────────────────────────────────── */}
{monthly_revenue.length > 0 && (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Revenue last 12 months</p>
<div className="rounded-xl border p-4">
<div className="flex items-end gap-1" style={{ height: '96px' }}>
{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 (
<div key={m.month} className="flex-1 flex flex-col items-center justify-end gap-1 h-full group">
<div className="w-full rounded-t bg-primary/70 group-hover:bg-primary transition-colors"
style={{ height: `${h}%` }} title={`${mo}: ${fmt$(m.revenue)}`} />
<p className="text-[9px] text-muted-foreground shrink-0">{mo}</p>
</div>
);
})}
{isEmpty ? (
/* ── Empty state ──────────────────────────────────── */
<>
{/* KPI grid still renders when AR is zero — informative even at zero */}
<section className="px-4">
<div className="grid grid-cols-2 gap-3">
<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>
</section>
<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>
</div>
)}
{/* ── Invoice drill-down (collapsible) ────────────────── */}
<div className="rounded-xl border overflow-hidden">
<button onClick={() => setInvoicesOpen(o => !o)}
className="w-full flex items-center justify-between px-4 py-3.5 hover:bg-accent transition-colors">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold">Open Invoices</span>
<span className="text-xs bg-muted rounded-full px-2 py-0.5">{open_invoices.length}</span>
</>
) : (
<>
{/* ── KPI 2×2 grid ──────────────────────────────── */}
<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>
{invoicesOpen ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}
</button>
{invoicesOpen && (
<>
<div className="flex border-t border-b">
{(['overdue', 'open'] as const).map(t => (
<button key={t} onClick={() => setTab(t)}
className={`flex-1 text-xs py-2 font-medium border-b-2 transition-colors ${
tab === t ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}>
{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 => (
<div key={inv.id} className="px-4 py-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{inv.customer_ref_name}</p>
<p className="text-xs text-muted-foreground">
#{inv.doc_number} · Due {fmtDate(inv.due_date)}
{inv.days_overdue > 0 && <span className="text-red-500 ml-1">({inv.days_overdue}d)</span>}
</p>
{/* ── Overdue Aging row ──────────────────────────── */}
{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: '130 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: '3160 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>
)}
{/* ── Top AR by Customer ─────────────────────────── */}
{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>
<p className={`text-sm font-bold shrink-0 ${inv.status === 'Overdue' ? 'text-red-600' : ''}`}>
{fmt$(inv.balance)}
</p>
</div>
</div>
))}
</div>
</>
)}
</div>
{/* ── Recent payments (collapsible) ───────────────────── */}
<div className="rounded-xl border overflow-hidden">
<button onClick={() => setPaymentsOpen(o => !o)}
className="w-full flex items-center justify-between px-4 py-3.5 hover:bg-accent transition-colors">
<div className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold">Recent Payments</span>
</div>
{paymentsOpen ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}
</button>
{paymentsOpen && (
<div className="divide-y border-t max-h-64 overflow-y-auto">
{recent_payments.map(p => (
<div key={p.id} className="px-4 py-3 flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{p.customer_ref_name}</p>
<p className="text-xs text-muted-foreground">{fmtDate(p.txn_date)}</p>
</div>
<p className="text-sm font-bold text-green-600 shrink-0">{fmt$(p.total_amt)}</p>
);
})}
</div>
))}
</div>
)}
</div>
</section>
)}
{/* ── Open Invoices Collapsible ──────────────────── */}
<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>
{/* ── 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>
{/* ── Monthly Revenue stacked list ───────────────── */}
{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>
)}
</>
)}
</div>
);
}