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.
+
Retry
);
- 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}
}
-
-
-
-
-
-
-
- {syncing ? 'Syncing…' : 'Sync QBO'}
-
-
+
+ {/* ── Header controls row ─────────────────────────────── */}
+
+ {lastSync && (
+ Last sync {lastSync}
+ )}
+
+
+
+
+
+ {syncing ? 'Syncing…' : 'Sync QBO'}
+
+
+ {/* ── 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' : ''}
-
-
-
- ))}
-
-
- )}
-
- {/* ── Revenue KPIs ────────────────────────────────────── */}
-
-
-
-
{fmt$(summary.paid_mtd)}
-
-
-
-
{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 (
-
- );
- })}
+ {isEmpty ? (
+ /* ── Empty state ──────────────────────────────────── */
+ <>
+ {/* KPI grid still renders when AR is zero — informative even at zero */}
+
+
-
- )}
-
- {/* ── Invoice drill-down (collapsible) ────────────────── */}
-
-
setInvoicesOpen(o => !o)}
- className="w-full flex items-center justify-between px-4 py-3.5 hover:bg-accent transition-colors">
-
-
-
Open Invoices
-
{open_invoices.length}
+ >
+ ) : (
+ <>
+ {/* ── KPI 2×2 grid ──────────────────────────────── */}
+
+
+
+
+
- {invoicesOpen ?
:
}
-
- {invoicesOpen && (
- <>
-
- {(['overdue', 'open'] as const).map(t => (
- 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})`}
-
- ))}
-
-
- {(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$(inv.balance)}
-
-
-
- ))}
-
- >
- )}
-
-
- {/* ── Recent payments (collapsible) ───────────────────── */}
-
-
setPaymentsOpen(o => !o)}
- className="w-full flex items-center justify-between px-4 py-3.5 hover:bg-accent transition-colors">
-
-
- Recent Payments
-
- {paymentsOpen ? : }
-
- {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) => (
+ 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})`}
+
+ ))}
+
+
+ {(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' : ''}
+
+
+ );
+ })}
+
+
+ )}
+ >
+ )}
);
}