BillTracker/client/components/admin/BankSyncAdminCard.jsx

210 lines
7.5 KiB
JavaScript

import React, { useState, useEffect } from '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 > 730) {
toast.error('Transaction history must be between 1 and 730 days.');
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>
{/* Transaction history lookback */}
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Transaction history</p>
<p className="text-xs text-muted-foreground mt-0.5">
How far back to fetch on first connect. Re-syncs only fetch recent activity.
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Input
type="number"
min="1"
max="730"
step="1"
value={syncDays}
onChange={e => setSyncDays(e.target.value)}
className="w-20 text-sm text-right"
/>
<span className="text-sm text-muted-foreground">days</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>
);
}