wulf-pulse/app/mobile/finance/page.tsx
lorentz a9a5a987f4 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
2026-05-03 19:56:36 -04:00

391 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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';
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 }[];
}
function fmt$(n: number) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n);
}
function fmtDate(ts: string) {
return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
export default function MobileFinance() {
const [data, setData] = useState<FinanceData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [invoicesOpen, setInvoicesOpen] = useState(false);
const [paymentsOpen, setPaymentsOpen] = useState(false);
const [tab, setTab] = useState<'open' | 'overdue'>('overdue');
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState<string | null>(null);
const [lastSync, setLastSync] = useState<string | null>(null);
const load = async () => {
setLoading(true); setError(null);
try {
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));
toast.error('Failed to refresh finance data');
} finally { setLoading(false); }
};
const loadLastSync = async () => {
try {
const r = await fetch('/api/qbo/sync');
if (r.ok) {
const d = await r.json();
const ts = d.lastSync?.invoices;
setLastSync(ts ? new Date(ts).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : null);
}
} catch {}
};
const syncAndRefresh = async () => {
if (syncing) return;
setSyncing(true);
setSyncMsg('Starting sync…');
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(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();
let lastTs: string | null = null;
while (Date.now() - start < 90_000) {
await new Promise(res => setTimeout(res, 4000));
const s = await fetch('/api/qbo/sync');
if (s.ok) {
const sd = await s.json();
const ts = sd.lastSync?.invoices;
if (ts && ts !== lastTs) { lastTs = ts; break; }
}
}
setSyncMsg('Refreshing data…');
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);
}
};
useEffect(() => { load(); loadLastSync(); }, []);
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 (!data) return null;
const { summary, aging, top_customers, open_invoices, recent_payments, monthly_revenue } = data;
const overdueInvoices = open_invoices.filter(i => i.status === 'Overdue');
const currentInvoices = open_invoices.filter(i => i.status === 'Open');
const isEmpty = summary.total_ar === 0 && open_invoices.length === 0;
return (
<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 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>
)}
{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>
</>
) : (
<>
{/* ── 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>
{/* ── 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>
);
})}
</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>
);
}