BillTracker/client/components/data/BankSyncSection.jsx

317 lines
13 KiB
JavaScript

import React, { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import { AlertTriangle, Building2, Eye, EyeOff, ExternalLink, Link2Off, Loader2, RefreshCw } from 'lucide-react';
import { api } from '@/api';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { SectionCard } from './dataShared';
function TokenInput({ value, onChange, disabled }) {
const [show, setShow] = useState(false);
const tail = value.slice(-4);
return (
<div className="flex-1 space-y-1">
<div className="relative">
<Input
value={value}
onChange={onChange}
type={show ? 'text' : 'password'}
placeholder="Paste SimpleFIN setup token…"
className="font-mono text-xs pr-8"
disabled={disabled}
autoComplete="off"
/>
{value && (
<button
type="button"
tabIndex={-1}
onClick={() => setShow(v => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
{show ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
)}
</div>
{value && !show && (
<p className="text-[11px] text-muted-foreground font-mono pl-0.5 select-none">
···{tail}
</p>
)}
</div>
);
}
export default function BankSyncSection({ onConnectionChange }) {
const [enabled, setEnabled] = useState(null);
const [connections, setConnections] = useState([]);
const [loadError, setLoadError] = useState('');
const [setupToken, setSetupToken] = useState('');
const [connecting, setConnecting] = useState(false);
const [syncing, setSyncing] = useState(null);
const [disconnectTarget, setDisconnectTarget] = useState(null);
const [disconnecting, setDisconnecting] = useState(false);
const load = useCallback(async () => {
setLoadError('');
try {
const [status, sources] = await Promise.all([
api.simplefinStatus(),
api.dataSources({ type: 'provider_sync' }),
]);
setEnabled(status.enabled);
const conns = Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : [];
setConnections(conns);
onConnectionChange?.(conns[0] || null);
} catch (err) {
setEnabled(false);
setLoadError(err.message || 'Failed to load bank sync status');
}
}, [onConnectionChange]);
useEffect(() => { load(); }, [load]);
const handleConnect = async () => {
const token = setupToken.trim();
if (!token) { toast.error('Paste your SimpleFIN setup token first.'); return; }
setConnecting(true);
try {
const result = await api.connectSimplefin(token);
toast.success(`Connected — ${result.accountsUpserted} account(s), ${result.transactionsNew} new transaction(s).`);
setSetupToken('');
await load();
} catch (err) {
toast.error(err.message || 'Failed to connect SimpleFIN');
} finally {
setConnecting(false);
}
};
const handleSync = async (id) => {
setSyncing(id);
try {
const result = await api.syncDataSource(id);
toast.success(`Synced — ${result.transactionsNew} new transaction(s).`);
await load();
} catch (err) {
toast.error(err.message || 'Sync failed');
await load();
} finally {
setSyncing(null);
}
};
const handleDisconnect = async () => {
if (!disconnectTarget) return;
setDisconnecting(true);
try {
await api.deleteDataSource(disconnectTarget.id);
toast.success('SimpleFIN disconnected.');
setDisconnectTarget(null);
await load();
} catch (err) {
toast.error(err.message || 'Failed to disconnect');
} finally {
setDisconnecting(false);
}
};
function fmtDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function isStale(conn) {
if (!conn.last_error) return false;
if (!conn.last_sync_at) return true;
return Date.now() - new Date(conn.last_sync_at).getTime() > 24 * 60 * 60 * 1000;
}
if (enabled === null) {
return (
<SectionCard title="Bank Sync">
<div className="px-6 py-6 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading
</div>
</SectionCard>
);
}
return (
<>
<SectionCard title="Bank Sync" subtitle="Connect your SimpleFIN Bridge to sync read-only bank transactions.">
{!enabled ? (
<div className="px-6 py-5 text-sm text-muted-foreground">
Bank sync is not enabled on this server. Ask your administrator to enable it in the Admin panel.
</div>
) : (
<>
{loadError && (
<div className="px-6 py-3 text-sm text-destructive">{loadError}</div>
)}
{connections.length > 0 && connections.map(conn => (
<div key={conn.id} className="px-6 py-4 space-y-3">
{isStale(conn) && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-sm text-amber-700 dark:text-amber-400">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<span className="font-medium">Sync error</span>
{conn.last_error && (
<span className="ml-1 font-normal opacity-90"> {conn.last_error}</span>
)}
{conn.last_sync_at && (
<span className="block text-xs mt-0.5 opacity-75">
Last successful sync: {fmtDate(conn.last_sync_at)}
</span>
)}
</div>
<button
type="button"
onClick={() => handleSync(conn.id)}
disabled={syncing === conn.id}
className="shrink-0 text-xs font-medium underline-offset-2 hover:underline disabled:opacity-50"
>
{syncing === conn.id ? 'Syncing…' : 'Retry'}
</button>
</div>
)}
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<Building2 className="h-5 w-5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{conn.name}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{conn.account_count} account{conn.account_count !== 1 ? 's' : ''} ·{' '}
{conn.transaction_count} transaction{conn.transaction_count !== 1 ? 's' : ''}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
size="sm" variant="outline"
onClick={() => handleSync(conn.id)}
disabled={syncing === conn.id}
className="h-8 text-xs gap-1.5"
>
{syncing === conn.id
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Syncing</>
: <><RefreshCw className="h-3.5 w-3.5" />Sync Now</>}
</Button>
<Button
size="sm" variant="ghost"
onClick={() => setDisconnectTarget(conn)}
className="h-8 text-xs gap-1.5 text-muted-foreground hover:text-destructive"
>
<Link2Off className="h-3.5 w-3.5" />
Disconnect
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 text-xs">
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
<p className="text-muted-foreground">Last sync</p>
<p className="font-medium mt-0.5">{fmtDate(conn.last_sync_at)}</p>
</div>
<div className={cn(
'rounded-lg border px-3 py-2',
conn.last_error ? 'border-destructive/30 bg-destructive/5' : 'border-border/60 bg-muted/20',
)}>
<p className="text-muted-foreground">Status</p>
<p className={cn('font-medium mt-0.5', conn.last_error ? 'text-destructive' : '')}>
{conn.last_error ? conn.last_error : (conn.status === 'active' ? 'Active' : conn.status)}
</p>
</div>
</div>
</div>
))}
{connections.length === 0 && (
<div className="px-6 py-5 space-y-4">
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 text-sm text-muted-foreground">
<p className="font-medium text-foreground mb-1">Connect a SimpleFIN Bridge account</p>
<p>Paste your SimpleFIN setup token below. BillTracker only stores an encrypted access URL no bank credentials are saved.</p>
<p className="mt-2">
Need a token?{' '}
<a
href="https://beta-bridge.simplefin.org/"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 font-medium text-primary underline-offset-4 hover:underline"
>
Open SimpleFIN Bridge
<ExternalLink className="h-3 w-3" />
</a>
</p>
</div>
<div className="flex gap-2">
<TokenInput
value={setupToken}
onChange={e => setSetupToken(e.target.value)}
disabled={connecting}
/>
<Button onClick={handleConnect} disabled={connecting || !setupToken.trim()} className="shrink-0">
{connecting ? <><Loader2 className="h-4 w-4 animate-spin mr-1.5" />Connecting</> : 'Connect'}
</Button>
</div>
</div>
)}
{connections.length > 0 && (
<div className="px-6 py-4 border-t border-border/50 space-y-2">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs font-medium text-muted-foreground">Add another connection</p>
<a
href="https://beta-bridge.simplefin.org/"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline"
>
Get a SimpleFIN token
<ExternalLink className="h-3 w-3" />
</a>
</div>
<div className="flex gap-2">
<TokenInput
value={setupToken}
onChange={e => setSetupToken(e.target.value)}
disabled={connecting}
/>
<Button size="sm" onClick={handleConnect} disabled={connecting || !setupToken.trim()} className="shrink-0">
{connecting ? <><Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />Connecting</> : 'Connect'}
</Button>
</div>
</div>
)}
</>
)}
</SectionCard>
<AlertDialog open={!!disconnectTarget} onOpenChange={open => { if (!open) setDisconnectTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Disconnect SimpleFIN?</AlertDialogTitle>
<AlertDialogDescription>
This removes the connection and deletes synced accounts. Previously synced transactions
are kept but will no longer be associated with a data source.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={disconnecting}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDisconnect} disabled={disconnecting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{disconnecting ? 'Disconnecting…' : 'Disconnect'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}