'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'; import { useUserTimezone } from '@/lib/hooks/use-user-timezone'; interface AgingBucket { balance: number; count: number; } interface FinanceData { summary: { total_ar: number; total_ar_count: number; total_ar_gross?: number; unapplied_credits?: 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, tz: string) { return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: tz }); } export default function MobileFinance() { const tz = useUserTimezone(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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(null); const [lastSync, setLastSync] = useState(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', timeZone: tz }) : 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 ; if (error) return (

Failed to load finance data

Check your connection and try again.

); 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 (
{/* ── Header controls row ─────────────────────────────── */}
{lastSync && ( Last sync {lastSync} )}
{/* ── Sync progress banner ────────────────────────────── */} {syncMsg && (
{syncMsg}
)} {isEmpty ? ( /* ── Empty state ──────────────────────────────────── */ <> {/* KPI grid still renders when AR is zero — informative even at zero */}
0 ? `${summary.total_ar_count} open · −${fmt$(summary.unapplied_credits)} credits` : `${summary.total_ar_count} open invoices` } />

No outstanding AR

) : ( <> {/* ── KPI 2×2 grid ──────────────────────────────── */}
{/* ── 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)}

); })}
)} {/* ── 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, tz)} {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', timeZone: tz }); return (

{monthLabel}

{fmt$(m.revenue)}

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

); })}
)} )}
); }