BillTracker/client/components/admin/BankSyncAdminCard.jsx

258 lines
10 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 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 145 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 &amp; backfill
</p>
<span className="font-mono text-sm font-bold">44 days</span>
</div>
<p className="text-xs text-muted-foreground">
The first sync (and any manual backfill) always fetches the maximum 44 days of history
to build a complete transaction picture. This is fixed SimpleFIN Bridge enforces a
strict <strong>45-day hard limit</strong> and will return an error for any request beyond it.
</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>730 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&apos;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>
);
}