2026-07-03 15:02:36 -05:00
|
|
|
|
import { useState } from 'react';
|
|
|
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
|
import { Landmark, RefreshCw, Loader2, AlertTriangle, RotateCcw, Upload, Plus } from 'lucide-react';
|
|
|
|
|
|
import { api } from '@/api';
|
|
|
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
|
|
|
|
|
|
|
// Relative "2h ago" / "3d ago"; returns null for empty input.
|
|
|
|
|
|
function relativeTime(iso) {
|
|
|
|
|
|
if (!iso) return null;
|
|
|
|
|
|
const then = new Date(iso).getTime();
|
|
|
|
|
|
if (Number.isNaN(then)) return null;
|
|
|
|
|
|
const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
|
|
|
|
|
|
if (secs < 60) return 'just now';
|
|
|
|
|
|
const mins = Math.round(secs / 60);
|
|
|
|
|
|
if (mins < 60) return `${mins}m ago`;
|
|
|
|
|
|
const hrs = Math.round(mins / 60);
|
|
|
|
|
|
if (hrs < 24) return `${hrs}h ago`;
|
|
|
|
|
|
const days = Math.round(hrs / 24);
|
|
|
|
|
|
return `${days}d ago`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function HeroShell({ tone = 'default', icon: Icon, children }) {
|
|
|
|
|
|
const toneRing = {
|
|
|
|
|
|
default: 'border-border/60',
|
|
|
|
|
|
good: 'border-emerald-500/30',
|
|
|
|
|
|
warn: 'border-amber-500/40',
|
|
|
|
|
|
error: 'border-rose-500/30',
|
|
|
|
|
|
}[tone];
|
|
|
|
|
|
const toneChip = {
|
|
|
|
|
|
default: 'bg-muted/50 text-muted-foreground',
|
|
|
|
|
|
good: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
|
|
|
|
|
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
|
|
|
|
|
|
error: 'bg-rose-500/10 text-rose-600 dark:text-rose-400',
|
|
|
|
|
|
}[tone];
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className={cn('surface flex flex-col gap-3 p-5 sm:flex-row sm:items-center sm:gap-4', toneRing)}>
|
|
|
|
|
|
<span className={cn('grid h-11 w-11 shrink-0 place-items-center rounded-xl', toneChip)}>
|
|
|
|
|
|
<Icon className="h-5 w-5" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The Data page's connection status hero. Five states, so a network blip is never
|
|
|
|
|
|
* mistaken for "not connected", and a server without bank sync never gets a dead
|
|
|
|
|
|
* Connect button:
|
|
|
|
|
|
* loading · disabled · error · not-connected · connected (± needs-attention)
|
|
|
|
|
|
*/
|
|
|
|
|
|
export default function ConnectionHero({
|
|
|
|
|
|
loading,
|
|
|
|
|
|
error, // truthy when the status/summary fetch failed
|
|
|
|
|
|
enabled, // status.enabled (server feature flag)
|
|
|
|
|
|
hasConnections, // status.has_connections
|
|
|
|
|
|
conn, // the simplefin data_source (name, last_sync_at, last_error) or null
|
2026-07-03 15:05:25 -05:00
|
|
|
|
txnTotal, // total synced transactions (at-a-glance), or null
|
2026-07-03 15:02:36 -05:00
|
|
|
|
onRetry,
|
|
|
|
|
|
onGoTo, // (sectionId) => void
|
|
|
|
|
|
onSynced, // () => void — refresh after a successful sync
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [syncing, setSyncing] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
async function handleSyncNow() {
|
|
|
|
|
|
setSyncing(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await api.syncAllSources();
|
|
|
|
|
|
const errs = Array.isArray(r?.errors) ? r.errors : [];
|
|
|
|
|
|
if (errs.length) {
|
|
|
|
|
|
toast.warning(`Synced, but ${errs.length} connection${errs.length === 1 ? '' : 's'} need attention.`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const n = r?.transactions_new ?? 0;
|
|
|
|
|
|
toast.success(`Synced — ${n} new transaction${n === 1 ? '' : 's'}.`);
|
|
|
|
|
|
}
|
|
|
|
|
|
onSynced?.();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (err?.status === 429) toast.error('Please wait a moment before syncing again.');
|
|
|
|
|
|
else toast.error(err?.message || 'Sync failed.');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSyncing(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── loading ──
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="surface flex items-center gap-4 p-5">
|
|
|
|
|
|
<span className="h-11 w-11 shrink-0 animate-pulse rounded-xl bg-muted/50" />
|
|
|
|
|
|
<div className="flex-1 space-y-2">
|
|
|
|
|
|
<div className="h-4 w-48 animate-pulse rounded bg-muted/50" />
|
|
|
|
|
|
<div className="h-3 w-32 animate-pulse rounded bg-muted/40" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── disabled (server has no bank sync) ──
|
|
|
|
|
|
if (!enabled) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<HeroShell icon={Landmark}>
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
|
<p className="text-sm font-semibold text-foreground">Automatic bank sync isn’t enabled</p>
|
|
|
|
|
|
<p className="mt-0.5 text-sm text-muted-foreground">
|
|
|
|
|
|
This server doesn’t have SimpleFIN configured — you can still import and export your data.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button variant="outline" className="shrink-0 gap-2" onClick={() => onGoTo?.('import')}>
|
|
|
|
|
|
<Upload className="h-4 w-4" /> Import data
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</HeroShell>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── error (couldn't check) ──
|
|
|
|
|
|
if (error) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<HeroShell tone="error" icon={AlertTriangle}>
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
|
<p className="text-sm font-semibold text-foreground">Couldn’t check your bank connection</p>
|
|
|
|
|
|
<p className="mt-0.5 text-sm text-muted-foreground">Something went wrong loading your sync status.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button variant="outline" className="shrink-0 gap-2" onClick={onRetry}>
|
|
|
|
|
|
<RotateCcw className="h-4 w-4" /> Retry
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</HeroShell>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── not connected ──
|
|
|
|
|
|
if (!hasConnections || !conn) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<HeroShell icon={Landmark}>
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
|
<p className="text-sm font-semibold text-foreground">Connect your bank to get started</p>
|
|
|
|
|
|
<p className="mt-0.5 text-sm text-muted-foreground">
|
|
|
|
|
|
Sync transactions automatically, or import your existing history.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex shrink-0 flex-wrap gap-2">
|
|
|
|
|
|
<Button className="gap-2" onClick={() => onGoTo?.('bank-sync')}>
|
|
|
|
|
|
<Plus className="h-4 w-4" /> Connect your bank
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button variant="outline" className="gap-2" onClick={() => onGoTo?.('import')}>
|
|
|
|
|
|
<Upload className="h-4 w-4" /> Import data
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</HeroShell>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── connected (± needs attention) ──
|
|
|
|
|
|
const needsAttention = Boolean(conn.last_error);
|
|
|
|
|
|
const synced = relativeTime(conn.last_sync_at);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<HeroShell tone={needsAttention ? 'warn' : 'good'} icon={needsAttention ? AlertTriangle : Landmark}>
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
|
<p className="text-sm font-semibold text-foreground">
|
|
|
|
|
|
{needsAttention ? 'Your bank connection needs attention' : 'Your bank is connected'}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="mt-0.5 truncate text-sm text-muted-foreground">
|
|
|
|
|
|
{needsAttention
|
|
|
|
|
|
? conn.last_error
|
2026-07-03 15:05:25 -05:00
|
|
|
|
: (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{conn.name || 'SimpleFIN'}
|
|
|
|
|
|
{Number(txnTotal) > 0 ? <> · {Number(txnTotal).toLocaleString('en-US')} transactions</> : null}
|
|
|
|
|
|
{synced ? <> · synced {synced}</> : null}
|
|
|
|
|
|
{' · syncs automatically'}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-07-03 15:02:36 -05:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex shrink-0 flex-wrap gap-2">
|
|
|
|
|
|
{needsAttention && (
|
|
|
|
|
|
<Button variant="outline" className="gap-2" onClick={() => onGoTo?.('bank-sync')}>
|
|
|
|
|
|
<RotateCcw className="h-4 w-4" /> Reconnect
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Button className="gap-2" onClick={handleSyncNow} disabled={syncing}>
|
|
|
|
|
|
{syncing ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
|
|
|
|
|
{syncing ? 'Syncing…' : 'Sync now'}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</HeroShell>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|