258 lines
10 KiB
JavaScript
258 lines
10 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
||
import { AlertTriangle, Info } from 'lucide-react';
|
||
import { toast } from 'sonner';
|
||
import { api } from '@/api';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||
import { Toggle } from './adminShared';
|
||
|
||
function timeAgo(iso) {
|
||
if (!iso) return null;
|
||
const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||
if (secs < 60) return 'just now';
|
||
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
|
||
if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`;
|
||
return `${Math.floor(secs / 86400)}d ago`;
|
||
}
|
||
|
||
function timeUntil(iso) {
|
||
if (!iso) return null;
|
||
const secs = Math.floor((new Date(iso).getTime() - Date.now()) / 1000);
|
||
if (secs <= 0) return 'soon';
|
||
if (secs < 60) return `${secs}s`;
|
||
if (secs < 3600) return `${Math.floor(secs / 60)}m`;
|
||
return `${Math.floor(secs / 3600)}h`;
|
||
}
|
||
|
||
export default function BankSyncAdminCard() {
|
||
const [config, setConfig] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [loadError, setLoadError] = useState('');
|
||
const [saving, setSaving] = useState(false);
|
||
const [enabled, setEnabled] = useState(false);
|
||
const [syncInterval, setSyncInterval] = useState(4);
|
||
const [syncDays, setSyncDays] = useState(90);
|
||
|
||
useEffect(() => {
|
||
api.bankSyncConfig()
|
||
.then(d => {
|
||
setConfig(d);
|
||
setEnabled(!!d.enabled);
|
||
setSyncInterval(d.sync_interval_hours ?? 4);
|
||
setSyncDays(d.sync_days ?? 90);
|
||
})
|
||
.catch(err => setLoadError(err.message || 'Failed to load bank sync config'))
|
||
.finally(() => setLoading(false));
|
||
}, []);
|
||
|
||
const handleSave = async () => {
|
||
const hours = parseFloat(syncInterval);
|
||
const days = parseInt(syncDays, 10);
|
||
if (!Number.isFinite(hours) || hours < 0.5 || hours > 168) {
|
||
toast.error('Sync interval must be between 0.5 and 168 hours.');
|
||
return;
|
||
}
|
||
if (!Number.isFinite(days) || days < 1 || days > 45) {
|
||
toast.error('Routine sync lookback must be 1–45 days. SimpleFIN Bridge enforces a 45-day hard limit — values above 45 return errors.');
|
||
return;
|
||
}
|
||
setSaving(true);
|
||
try {
|
||
const result = await api.setBankSyncConfig({ enabled, sync_interval_hours: hours, sync_days: days });
|
||
setConfig(result);
|
||
setEnabled(!!result.enabled);
|
||
setSyncInterval(result.sync_interval_hours ?? 4);
|
||
setSyncDays(result.sync_days ?? 90);
|
||
toast.success('Bank sync settings saved.');
|
||
} catch (err) {
|
||
toast.error(err.message || 'Failed to update bank sync setting.');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="py-8 text-center text-muted-foreground text-sm">
|
||
Loading…
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
if (loadError) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="py-8 text-center text-sm text-destructive">
|
||
{loadError}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
const changed = enabled !== !!config?.enabled
|
||
|| parseFloat(syncInterval) !== config?.sync_interval_hours
|
||
|| parseInt(syncDays, 10) !== config?.sync_days;
|
||
const worker = config?.worker;
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader className="pb-4">
|
||
<CardTitle>Bank Sync (SimpleFIN)</CardTitle>
|
||
<p className="text-sm text-muted-foreground mt-1">
|
||
Allow users to connect their own SimpleFIN Bridge account to sync
|
||
read-only bank transactions. Each user manages their own connection
|
||
from the Data page — no bank credentials are stored.
|
||
</p>
|
||
</CardHeader>
|
||
<CardContent className="space-y-5">
|
||
|
||
{/* Enable toggle */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium">Allow users to connect SimpleFIN</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">
|
||
When enabled, users see a Bank Sync section on their Data page.
|
||
</p>
|
||
</div>
|
||
<Toggle
|
||
checked={enabled}
|
||
onChange={v => setEnabled(v)}
|
||
label="Enable bank sync"
|
||
/>
|
||
</div>
|
||
|
||
{/* Sync interval */}
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div>
|
||
<p className="text-sm font-medium">Auto-sync interval</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">
|
||
How often the background worker checks for new transactions.
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<Input
|
||
type="number"
|
||
min="0.5"
|
||
max="168"
|
||
step="0.5"
|
||
value={syncInterval}
|
||
onChange={e => setSyncInterval(e.target.value)}
|
||
className="w-20 text-sm text-right"
|
||
/>
|
||
<span className="text-sm text-muted-foreground">hrs</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sync window — two-mode explainer */}
|
||
<div className="space-y-3">
|
||
<div>
|
||
<p className="text-sm font-medium">Sync lookback windows</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">
|
||
SimpleFIN uses two different windows depending on sync type.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Initial / backfill — read-only */}
|
||
<div className="rounded-lg border border-border/50 bg-muted/20 px-4 py-3 space-y-1">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||
Initial connect & backfill
|
||
</p>
|
||
<span className="font-mono text-sm font-bold">6 days</span>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
The first sync (and any manual backfill) always fetches the maximum 60 days of history
|
||
to build a complete transaction picture. This is fixed — SimpleFIN Bridge enforces a
|
||
strict <strong>60-day hard limit</strong> and will return possible errors.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Routine sync — editable */}
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium">Routine sync lookback</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">
|
||
How far back each auto-sync and manual "Sync Now" looks after the initial connect.
|
||
Recommended: <strong>7–30 days</strong>. Setting this near 45 increases request size
|
||
and duplicate-skip work with no benefit once history is established.
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<Input
|
||
type="number"
|
||
min="1"
|
||
max="45"
|
||
step="1"
|
||
value={syncDays}
|
||
onChange={e => setSyncDays(Math.min(45, Math.max(1, parseInt(e.target.value, 10) || 30)))}
|
||
className="w-20 text-sm text-right"
|
||
/>
|
||
<span className="text-sm text-muted-foreground">days</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Amber warning at the SimpleFIN limit */}
|
||
{parseInt(syncDays, 10) >= 45 && (
|
||
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/8 px-3 py-2.5">
|
||
<AlertTriangle className="h-3.5 w-3.5 text-amber-500 shrink-0 mt-0.5" />
|
||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||
45 days is SimpleFIN Bridge's maximum. Requests at this limit may occasionally
|
||
fail due to request latency — 30 days or less is recommended for reliable routine syncs.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Always-visible hard-limit note */}
|
||
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||
<Info className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
||
<span>
|
||
SimpleFIN Bridge enforces a <strong>45-day maximum</strong> on all requests.
|
||
Any value above 45 will cause sync errors for all users.
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Auto-sync worker status */}
|
||
{config?.enabled && worker && (
|
||
<div className="rounded-lg border border-border/60 bg-muted/20 px-4 py-3 space-y-2">
|
||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">Auto-sync worker</p>
|
||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm sm:grid-cols-3">
|
||
<div>
|
||
<p className="text-xs text-muted-foreground">Status</p>
|
||
<p className="font-medium flex items-center gap-1.5">
|
||
<span className={`h-1.5 w-1.5 rounded-full ${worker.running ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500'}`} />
|
||
{worker.running ? 'Running' : 'Idle'}
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-xs text-muted-foreground">Last run</p>
|
||
<p className="font-medium">{worker.last_run_at ? timeAgo(worker.last_run_at) : '—'}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-xs text-muted-foreground">Next run</p>
|
||
<p className="font-medium">{worker.next_run_at ? `in ${timeUntil(worker.next_run_at)}` : '—'}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Encryption note */}
|
||
<p className="text-xs text-muted-foreground border-t border-border/50 pt-3">
|
||
SimpleFIN credentials are encrypted with a key stored in your database.
|
||
Regular database backups preserve all user connections.
|
||
</p>
|
||
|
||
<div className="flex justify-end">
|
||
<Button onClick={handleSave} disabled={saving || !changed}>
|
||
{saving ? 'Saving…' : 'Save'}
|
||
</Button>
|
||
</div>
|
||
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|