feat: QBO sync schedules (2AM + 4PM) + Sync QBO button on mobile finance page

- DB: inserted qbo-sync-2am (0 2 * * *) and qbo-sync-4pm (0 16 * * *) schedules
- Mobile finance: 'Sync QBO' button triggers POST /api/qbo/sync incremental,
  polls /api/qbo/sync GET until lastSync timestamp changes (max 90s),
  then reloads finance data
- Shows last sync timestamp below page title
- Separate refresh-only button (↻) for quick display refresh without re-syncing
- Sync status message shown during polling
This commit is contained in:
lorentz 2026-03-23 12:36:02 -04:00
parent 8cd8a94412
commit 44847ccf21

View file

@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { RefreshCw, TrendingUp, AlertTriangle, CheckCircle2, ChevronDown, ChevronRight } from 'lucide-react';
import { RefreshCw, TrendingUp, AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, CloudDownload } from 'lucide-react';
interface AgingBucket { balance: number; count: number; }
interface FinanceData {
@ -32,6 +32,9 @@ export default function MobileFinance() {
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);
@ -43,7 +46,50 @@ export default function MobileFinance() {
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
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('Sync failed'); setSyncing(false); 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);
} catch (e) {
setSyncMsg('Error: ' + String(e));
} finally {
setSyncing(false);
}
};
useEffect(() => { load(); loadLastSync(); }, []);
if (loading) return (
<div className="flex items-center justify-center h-64">
@ -62,11 +108,26 @@ export default function MobileFinance() {
return (
<div className="p-4 space-y-5">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">Finance</h1>
<button onClick={load} className="p-2 rounded-full hover:bg-accent">
<RefreshCw className="w-4 h-4" />
</button>
<div>
<h1 className="text-xl font-bold">Finance</h1>
{lastSync && <p className="text-[11px] text-muted-foreground mt-0.5">Last sync {lastSync}</p>}
</div>
<div className="flex items-center gap-2">
<button onClick={load} disabled={loading} className="p-2 rounded-full hover:bg-accent disabled:opacity-40" title="Refresh display">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
<button onClick={syncAndRefresh} disabled={syncing} className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-primary text-primary-foreground text-xs font-medium disabled:opacity-50 hover:bg-primary/90 transition-colors" title="Sync from QuickBooks then refresh">
<CloudDownload className={`w-3.5 h-3.5 ${syncing ? 'animate-pulse' : ''}`} />
{syncing ? 'Syncing…' : 'Sync QBO'}
</button>
</div>
</div>
{syncMsg && (
<div className="flex items-center gap-2 px-3 py-2 rounded-xl bg-muted text-xs text-muted-foreground">
<RefreshCw className="w-3 h-3 animate-spin shrink-0" />
{syncMsg}
</div>
)}
{/* ── AR Hero ─────────────────────────────────────────── */}
<div className="rounded-2xl border bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-5">