BillTracker/client/components/data/ConnectionHero.jsx

180 lines
6.7 KiB
JavaScript
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.

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
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 isnt enabled</p>
<p className="mt-0.5 text-sm text-muted-foreground">
This server doesnt 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">Couldnt 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
: <>{conn.name || 'SimpleFIN'}{synced ? <> · synced {synced}</> : null} · syncs automatically</>}
</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>
);
}