2026-03-17 15:31:56 -04:00
|
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
|
|
import { useEffect, useState } from 'react';
|
2026-05-03 19:56:36 -04:00
|
|
|
|
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';
|
2026-05-07 07:54:21 -04:00
|
|
|
|
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
|
2026-03-17 15:31:56 -04:00
|
|
|
|
|
|
|
|
|
|
interface AgingBucket { balance: number; count: number; }
|
|
|
|
|
|
interface FinanceData {
|
|
|
|
|
|
summary: {
|
|
|
|
|
|
total_ar: number; total_ar_count: number;
|
2026-07-18 06:34:57 -04:00
|
|
|
|
total_ar_gross?: number; unapplied_credits?: number;
|
2026-03-17 15:31:56 -04:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-05-07 07:54:21 -04:00
|
|
|
|
function fmtDate(ts: string, tz: string) {
|
|
|
|
|
|
return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: tz });
|
2026-03-17 15:31:56 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function MobileFinance() {
|
2026-05-07 07:54:21 -04:00
|
|
|
|
const tz = useUserTimezone();
|
2026-03-17 15:31:56 -04:00
|
|
|
|
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');
|
2026-03-23 12:36:02 -04:00
|
|
|
|
const [syncing, setSyncing] = useState(false);
|
|
|
|
|
|
const [syncMsg, setSyncMsg] = useState<string | null>(null);
|
|
|
|
|
|
const [lastSync, setLastSync] = useState<string | null>(null);
|
2026-03-17 15:31:56 -04:00
|
|
|
|
|
|
|
|
|
|
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());
|
2026-05-03 19:56:36 -04:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError(String(e));
|
|
|
|
|
|
toast.error('Failed to refresh finance data');
|
|
|
|
|
|
} finally { setLoading(false); }
|
2026-03-17 15:31:56 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-23 12:36:02 -04:00
|
|
|
|
const loadLastSync = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await fetch('/api/qbo/sync');
|
|
|
|
|
|
if (r.ok) {
|
|
|
|
|
|
const d = await r.json();
|
|
|
|
|
|
const ts = d.lastSync?.invoices;
|
2026-05-07 07:54:21 -04:00
|
|
|
|
setLastSync(ts ? new Date(ts).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz }) : null);
|
2026-03-23 12:36:02 -04:00
|
|
|
|
}
|
|
|
|
|
|
} 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…'); }
|
2026-05-03 19:56:36 -04:00
|
|
|
|
else if (!r.ok) {
|
|
|
|
|
|
setSyncMsg(null);
|
|
|
|
|
|
setSyncing(false);
|
|
|
|
|
|
toast.error('Sync failed — check QBO connection');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-23 12:36:02 -04:00
|
|
|
|
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);
|
2026-05-03 19:56:36 -04:00
|
|
|
|
toast.success('QuickBooks sync complete');
|
2026-03-23 12:36:02 -04:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setSyncMsg('Error: ' + String(e));
|
2026-05-03 19:56:36 -04:00
|
|
|
|
toast.error('Sync failed — check QBO connection');
|
2026-03-23 12:36:02 -04:00
|
|
|
|
} finally {
|
|
|
|
|
|
setSyncing(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { load(); loadLastSync(); }, []);
|
2026-03-17 15:31:56 -04:00
|
|
|
|
|
2026-05-03 19:56:36 -04:00
|
|
|
|
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>
|
2026-03-17 15:31:56 -04:00
|
|
|
|
</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');
|
2026-05-03 19:56:36 -04:00
|
|
|
|
const isEmpty = summary.total_ar === 0 && open_invoices.length === 0;
|
2026-03-17 15:31:56 -04:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-03 19:56:36 -04:00
|
|
|
|
<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>
|
2026-03-17 15:31:56 -04:00
|
|
|
|
</div>
|
2026-05-03 19:56:36 -04:00
|
|
|
|
|
|
|
|
|
|
{/* ── Sync progress banner ────────────────────────────── */}
|
2026-03-23 12:36:02 -04:00
|
|
|
|
{syncMsg && (
|
2026-05-03 19:56:36 -04:00
|
|
|
|
<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" />
|
2026-03-23 12:36:02 -04:00
|
|
|
|
{syncMsg}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-03-17 15:31:56 -04:00
|
|
|
|
|
2026-05-03 19:56:36 -04:00
|
|
|
|
{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)}
|
2026-07-18 06:34:57 -04:00
|
|
|
|
caption={
|
|
|
|
|
|
summary.unapplied_credits && summary.unapplied_credits > 0
|
|
|
|
|
|
? `${summary.total_ar_count} open · −${fmt$(summary.unapplied_credits)} credits`
|
|
|
|
|
|
: `${summary.total_ar_count} open invoices`
|
|
|
|
|
|
}
|
2026-05-03 19:56:36 -04:00
|
|
|
|
/>
|
|
|
|
|
|
<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)}`}
|
|
|
|
|
|
/>
|
2026-03-17 15:31:56 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-03 19:56:36 -04:00
|
|
|
|
{/* ── 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: '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 }) => (
|
|
|
|
|
|
<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>
|
2026-03-17 15:31:56 -04:00
|
|
|
|
</div>
|
2026-05-03 19:56:36 -04:00
|
|
|
|
))}
|
2026-03-17 15:31:56 -04:00
|
|
|
|
</div>
|
2026-05-03 19:56:36 -04:00
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
2026-03-17 15:31:56 -04:00
|
|
|
|
|
2026-05-03 19:56:36 -04:00
|
|
|
|
{/* ── 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>
|
|
|
|
|
|
)}
|
2026-03-17 15:31:56 -04:00
|
|
|
|
|
2026-05-03 19:56:36 -04:00
|
|
|
|
{/* ── 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>
|
|
|
|
|
|
))}
|
2026-03-17 15:31:56 -04:00
|
|
|
|
</div>
|
2026-05-03 19:56:36 -04:00
|
|
|
|
<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={
|
|
|
|
|
|
<>
|
2026-05-07 07:54:21 -04:00
|
|
|
|
#{inv.doc_number} · Due {fmtDate(inv.due_date, tz)}
|
2026-05-03 19:56:36 -04:00
|
|
|
|
{inv.days_overdue > 0 && (
|
|
|
|
|
|
<span className="text-destructive ml-1">({inv.days_overdue}d overdue)</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CollapsibleContent>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Collapsible>
|
|
|
|
|
|
</section>
|
2026-03-17 15:31:56 -04:00
|
|
|
|
|
2026-05-03 19:56:36 -04:00
|
|
|
|
{/* ── 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"
|
2026-05-07 07:54:21 -04:00
|
|
|
|
secondary={fmtDate(p.txn_date, tz)}
|
2026-05-03 19:56:36 -04:00
|
|
|
|
/>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
2026-03-17 15:31:56 -04:00
|
|
|
|
</div>
|
2026-05-03 19:56:36 -04:00
|
|
|
|
</CollapsibleContent>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Collapsible>
|
|
|
|
|
|
</section>
|
2026-03-17 15:31:56 -04:00
|
|
|
|
|
2026-05-03 19:56:36 -04:00
|
|
|
|
{/* ── 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) => {
|
2026-05-07 07:54:21 -04:00
|
|
|
|
const monthLabel = new Date(m.month).toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: tz });
|
2026-05-03 19:56:36 -04:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-03-17 15:31:56 -04:00
|
|
|
|
</div>
|
2026-05-03 19:56:36 -04:00
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-03-17 15:31:56 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|