wulf-pulse/app/mobile/finance/page.tsx
lorentz 672f17b7f9 chore: check in pending work — queue preferences, QBO AR diagnostics, mobile engagement fixes, ops scripts
Bundles several in-progress efforts that were sitting uncommitted:
- User queue-preferences (migration 087, API route, popover component)
- QBO invoice soft-delete (migration 088) and AR diagnostics route
- Dashboard/mobile engagement route and page adjustments
- Docker Compose log-rotation config
- One-off ticket/RMM investigation scripts (scripts/)
- Planning docs: phase verification/pattern notes, mobile shell design spec
- .gitignore: exclude local scratch financial/inventory data and Claude Code
  worktree/local-settings runtime state (never meant for version control)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
2026-07-18 06:34:57 -04:00

398 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';
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<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', 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 <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.unapplied_credits && summary.unapplied_credits > 0
? `${summary.total_ar_count} open · ${fmt$(summary.unapplied_credits)} credits`
: `${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, tz)}
{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, tz)}
/>
))
)}
</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', timeZone: tz });
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>
);
}