BillTracker/client/components/data/BankSyncSection.jsx

964 lines
43 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, useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import {
AlertTriangle, Building2, ChevronDown, ChevronRight,
Eye, EyeOff, ExternalLink, History, Landmark, Link2Off, Loader2, RefreshCw, Unlink,
} 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 { Switch } from '@/components/ui/switch';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { SectionCard } from './dataShared';
import AutoMatchReview from './AutoMatchReview';
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>
);
}
function parseUtc(str) {
if (!str) return null;
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
return new Date(normalized);
}
function fmtDate(iso) {
if (!iso) return '—';
return parseUtc(iso).toLocaleString(undefined, {
month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
function fmtShortDate(date) {
if (!date) return '—';
const d = new Date(`${date}T00:00:00`);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
function fmtDollars(cents) {
if (cents == null) return '—';
const abs = Math.abs(cents) / 100;
const sign = cents < 0 ? '-' : '';
return `${sign}$${abs.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function MatchBadge({ status, billName }) {
if (status === 'matched') {
return (
<span className="text-[10px] font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded-full max-w-[120px] truncate inline-block" title={billName || 'matched'}>
{billName || 'matched'}
</span>
);
}
if (status === 'ignored') {
return <span className="text-[10px] font-medium text-amber-600 dark:text-amber-400 bg-amber-500/10 px-1.5 py-0.5 rounded-full">ignored</span>;
}
return <span className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded-full">unmatched</span>;
}
function BillPickerDialog({ open, onClose, transaction, bills, onConfirm, busy }) {
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState(null);
useEffect(() => { if (open) { setSearch(''); setSelectedId(null); } }, [open]);
const filtered = useMemo(() => {
const q = search.toLowerCase();
return (bills || []).filter(b => !q || b.name.toLowerCase().includes(q));
}, [bills, search]);
const txDate = transaction?.posted_date || transaction?.transacted_at?.slice(0, 10);
const txLabel = transaction?.payee || transaction?.description || '—';
const txAmt = transaction ? fmtDollars(transaction.amount) : '';
return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Match to bill</DialogTitle>
<p className="text-sm text-muted-foreground mt-1">
<span className="font-medium text-foreground">{txLabel}</span>
{txDate && <span className="ml-2 text-xs">{fmtShortDate(txDate)}</span>}
<span className="ml-2 font-medium text-destructive/80">{txAmt}</span>
</p>
<p className="text-xs text-muted-foreground">
A payment record will be created for the selected bill using this transaction's amount and date.
</p>
</DialogHeader>
<Input
autoFocus
placeholder="Search bills…"
value={search}
onChange={e => setSearch(e.target.value)}
className="text-sm"
/>
<div className="max-h-56 overflow-y-auto rounded-lg border border-border/60 divide-y divide-border/40">
{filtered.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground text-center">No bills found.</p>
) : filtered.map(bill => (
<button
key={bill.id}
type="button"
onClick={() => setSelectedId(bill.id)}
className={cn(
'w-full flex items-center justify-between px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/40',
selectedId === bill.id && 'bg-primary/10 text-primary',
)}
>
<span className="truncate font-medium">{bill.name}</span>
<span className="shrink-0 ml-3 text-xs text-muted-foreground tabular-nums">
${(bill.expected_amount ?? 0).toFixed(2)}/mo
</span>
</button>
))}
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy}>Cancel</Button>
<Button onClick={() => onConfirm(selectedId)} disabled={!selectedId || busy}>
{busy ? <><Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />Matching…</> : 'Confirm match'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonitored, toggling, bills, onMatch, onUnmatch, matchingTxId }) {
const txDate = account.transactions?.[0]?.posted_date || account.transactions?.[0]?.transacted_at?.slice(0, 10);
return (
<div className="border-t border-border/40 first:border-t-0">
<div
className={cn('flex items-center gap-3 px-4 py-2.5', account.monitored && 'hover:bg-muted/20 cursor-pointer')}
onClick={account.monitored ? onToggleExpand : undefined}
>
<span className="text-muted-foreground shrink-0 w-3.5" onClick={e => e.stopPropagation()}>
{account.monitored && (expanded
? <ChevronDown className="h-3.5 w-3.5" />
: <ChevronRight className="h-3.5 w-3.5" />)}
</span>
{/* Monitored toggle */}
<div onClick={e => e.stopPropagation()}>
<Switch
checked={account.monitored}
onCheckedChange={v => onToggleMonitored(account.id, v)}
disabled={toggling}
aria-label={`Monitor ${account.name}`}
/>
</div>
{/* Account name */}
<div className="flex-1 min-w-0">
<p className={cn('text-sm font-medium truncate', !account.monitored && 'text-muted-foreground')}>
{account.name}
</p>
{account.org_name && (
<p className="text-xs text-muted-foreground truncate">{account.org_name}</p>
)}
</div>
{/* Balance */}
<div className="text-right shrink-0 hidden sm:block">
<p className={cn('text-xs font-medium tabular-nums', !account.monitored && 'text-muted-foreground')}>
{fmtDollars(account.balance)}
</p>
{txDate && (
<p className="text-[10px] text-muted-foreground">last tx {fmtShortDate(txDate)}</p>
)}
</div>
{/* Tx count */}
<div className="shrink-0 text-right">
<p className="text-xs text-muted-foreground tabular-nums">
{account.transaction_count} tx
</p>
{!account.monitored && (
<p className="text-[10px] text-muted-foreground/60">skipped</p>
)}
</div>
</div>
{expanded && account.monitored && (
<div className="border-t border-border/30 bg-muted/10">
{account.transactions.length === 0 ? (
<p className="px-6 py-3 text-xs text-muted-foreground italic">No transactions synced for this account.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border/30">
<th className="px-4 py-2 text-left font-medium text-muted-foreground whitespace-nowrap">Date</th>
<th className="px-4 py-2 text-left font-medium text-muted-foreground">Payee / Description</th>
<th className="px-4 py-2 text-right font-medium text-muted-foreground whitespace-nowrap">Amount</th>
<th className="px-4 py-2 text-right font-medium text-muted-foreground whitespace-nowrap">Bill</th>
</tr>
</thead>
<tbody>
{account.transactions.map(tx => (
<tr key={tx.id} className="border-b border-border/20 last:border-b-0 hover:bg-muted/20">
<td className="px-4 py-1.5 text-muted-foreground whitespace-nowrap">
{fmtShortDate(tx.posted_date || tx.transacted_at?.slice(0, 10))}
</td>
<td className="px-4 py-1.5 max-w-xs">
<p className="truncate font-medium">{tx.payee || tx.description || ''}</p>
{tx.payee && tx.description && tx.payee !== tx.description && (
<p className="truncate text-muted-foreground">{tx.description}</p>
)}
</td>
<td className={cn('px-4 py-1.5 text-right tabular-nums whitespace-nowrap font-medium', tx.amount < 0 ? 'text-destructive/80' : 'text-emerald-600 dark:text-emerald-400')}>
{fmtDollars(tx.amount)}
</td>
<td className="px-4 py-1.5 text-right whitespace-nowrap">
{tx.match_status === 'matched' ? (
<div className="flex items-center justify-end gap-1.5">
<MatchBadge status="matched" billName={tx.matched_bill_name} />
<button
type="button"
title="Remove match"
disabled={matchingTxId === tx.id}
onClick={() => onUnmatch(tx)}
className="text-muted-foreground hover:text-destructive disabled:opacity-40 transition-colors"
>
{matchingTxId === tx.id
? <Loader2 className="h-3 w-3 animate-spin" />
: <Unlink className="h-3 w-3" />}
</button>
</div>
) : tx.match_status === 'ignored' ? (
<MatchBadge status="ignored" />
) : (
<button
type="button"
disabled={matchingTxId === tx.id}
onClick={() => onMatch(tx)}
className="text-[10px] font-medium text-primary/70 hover:text-primary bg-primary/5 hover:bg-primary/10 px-1.5 py-0.5 rounded-full transition-colors disabled:opacity-40"
>
{matchingTxId === tx.id ? <Loader2 className="h-3 w-3 animate-spin inline" /> : '+ match'}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}
export default function BankSyncSection({ onConnectionChange, cardProps = {} }) {
const [enabled, setEnabled] = useState(null);
const [syncDays, setSyncDays] = useState(30);
const [seedDays, setSeedDays] = useState(44);
const [serverTz, setServerTz] = useState(null);
const [connections, setConnections] = useState([]);
const [accountsBySource, setAccountsBySource] = useState({});
const [accountsLoading, setAccountsLoading] = useState({});
const [accountsErrorBySource, setAccountsError] = useState({});
const [loadError, setLoadError] = useState('');
const [setupToken, setSetupToken] = useState('');
const [connecting, setConnecting] = useState(false);
const [syncing, setSyncing] = useState(null);
const [backfilling, setBackfilling] = useState(null);
const [disconnectTarget, setDisconnectTarget] = useState(null);
const [disconnecting, setDisconnecting] = useState(false);
const [expandedAccount, setExpandedAccount] = useState(null);
const [togglingAccount, setTogglingAccount] = useState(null);
const [bills, setBills] = useState([]);
const [matchTarget, setMatchTarget] = useState(null); // { sourceId, tx }
const [matchingTxId, setMatchingTxId] = useState(null);
const [autoMatchRefreshKey, setAutoMatchRefreshKey] = useState(0);
// Bank tracking state
const [btEnabled, setBtEnabled] = useState(false);
const [btAccountId, setBtAccountId] = useState('');
const [btPendingDays, setBtPendingDays] = useState(3);
const [btLateGraceDays, setBtLateGraceDays] = useState(0);
const [btAccounts, setBtAccounts] = useState([]);
const [btSaving, setBtSaving] = useState(false);
const loadAccounts = useCallback(async (conns) => {
for (const conn of conns) {
setAccountsLoading(prev => ({ ...prev, [conn.id]: true }));
setAccountsError(prev => ({ ...prev, [conn.id]: '' }));
try {
const accounts = await api.dataSourceAccounts(conn.id);
setAccountsBySource(prev => ({ ...prev, [conn.id]: accounts }));
} catch (err) {
setAccountsError(prev => ({ ...prev, [conn.id]: err.message || 'Failed to load accounts' }));
} finally {
setAccountsLoading(prev => ({ ...prev, [conn.id]: false }));
}
}
}, []);
// Load bank tracking settings and available accounts
const loadBankTracking = useCallback(async () => {
try {
const [settings, accounts] = await Promise.all([
api.settings(),
api.allFinancialAccounts().catch(() => []),
]);
setBtEnabled(settings.bank_tracking_enabled === 'true');
setBtAccountId(settings.bank_tracking_account_id || '');
setBtPendingDays(parseInt(settings.bank_tracking_pending_days, 10) || 3);
setBtLateGraceDays(parseInt(settings.bank_late_attribution_days, 10) || 0);
setBtAccounts(Array.isArray(accounts) ? accounts : []);
} catch {
// non-fatal — bank tracking section just won't populate
}
}, []);
const handleBtSave = useCallback(async (patch) => {
setBtSaving(true);
try {
const next = {
bank_tracking_enabled: String(patch.enabled ?? btEnabled),
bank_tracking_account_id: String(patch.accountId ?? btAccountId),
bank_tracking_pending_days: String(patch.pendingDays ?? btPendingDays),
bank_late_attribution_days: String(patch.lateGraceDays ?? btLateGraceDays),
};
await api.saveSettings(next);
if (patch.enabled !== undefined) setBtEnabled(patch.enabled);
if (patch.accountId !== undefined) setBtAccountId(patch.accountId);
if (patch.pendingDays !== undefined) setBtPendingDays(patch.pendingDays);
if (patch.lateGraceDays !== undefined) setBtLateGraceDays(patch.lateGraceDays);
toast.success('Bank tracking settings saved');
} catch (err) {
toast.error(err.message || 'Failed to save bank tracking settings');
} finally {
setBtSaving(false);
}
}, [btEnabled, btAccountId, btPendingDays]);
const load = useCallback(async () => {
setLoadError('');
try {
const [status, sources] = await Promise.all([
api.simplefinStatus(),
api.dataSources({ type: 'provider_sync' }),
]);
setEnabled(status.enabled);
setSyncDays(status.sync_days ?? 30);
setSeedDays(status.seed_days ?? 44);
setServerTz(status.timezone || null);
const conns = Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : [];
setConnections(conns);
onConnectionChange?.(conns[0] || null);
if (conns.length > 0) loadAccounts(conns);
} catch (err) {
setEnabled(false);
setLoadError(err.message || 'Failed to load bank sync status');
}
}, [onConnectionChange, loadAccounts]);
useEffect(() => { load(); }, [load]);
useEffect(() => { loadBankTracking(); }, [loadBankTracking]);
// Load bills once when connections become available (for the match picker)
useEffect(() => {
if (connections.length > 0 && bills.length === 0) {
api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(err => console.error('[BankSyncSection] failed to load bills', err));
}
}, [connections, bills.length]);
function updateTxInState(sourceId, txId, updates) {
setAccountsBySource(prev => ({
...prev,
[sourceId]: (prev[sourceId] || []).map(acc => ({
...acc,
transactions: acc.transactions.map(tx => tx.id === txId ? { ...tx, ...updates } : tx),
})),
}));
}
const handleMatch = (sourceId, tx) => setMatchTarget({ sourceId, tx });
const handleConfirmMatch = async (billId) => {
if (!matchTarget || !billId) return;
const { sourceId, tx } = matchTarget;
setMatchingTxId(tx.id);
try {
const { transaction } = await api.confirmTransactionMatch(tx.id, billId);
updateTxInState(sourceId, tx.id, {
match_status: transaction.match_status,
matched_bill_id: transaction.matched_bill_id,
matched_bill_name: transaction.matched_bill_name,
});
setMatchTarget(null);
toast.success(`Matched to "${transaction.matched_bill_name}" — payment recorded for ${fmtShortDate(tx.posted_date || tx.transacted_at?.slice(0, 10))}.`);
} catch (err) {
toast.error(err.message || 'Failed to match transaction.');
} finally {
setMatchingTxId(null);
}
};
const handleUnmatch = async (sourceId, tx) => {
setMatchingTxId(tx.id);
try {
await api.unmatchTransaction(tx.id);
updateTxInState(sourceId, tx.id, { match_status: 'unmatched', matched_bill_id: null, matched_bill_name: null });
toast.success('Match removed.');
} catch (err) {
toast.error(err.message || 'Failed to remove match.');
} finally {
setMatchingTxId(null);
}
};
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);
if (result.errlist) {
toast.warning(`Synced ${result.transactionsNew} new transaction(s), but some connections need attention: ${result.errlist}`);
} else {
toast.success(`Synced — ${result.transactionsNew} new transaction(s).`);
}
setAutoMatchRefreshKey(k => k + 1);
await load();
} catch (err) {
toast.error(err.message || 'Sync failed');
await load();
} finally {
setSyncing(null);
}
};
const handleBackfill = async (id) => {
setBackfilling(id);
try {
const result = await api.backfillDataSource(id);
if (result.errlist) {
toast.warning(`Backfill complete — ${result.transactionsNew} new transaction(s). Some connections need attention: ${result.errlist}`);
} else {
toast.success(`Backfill complete — ${result.transactionsNew} new transaction(s) pulled from the last ${seedDays} days.`);
}
setAutoMatchRefreshKey(k => k + 1);
await load();
} catch (err) {
toast.error(err.message || 'Backfill failed');
await load();
} finally {
setBackfilling(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);
}
};
const handleToggleMonitored = async (sourceId, accountId, monitored) => {
setTogglingAccount(accountId);
// Optimistic update
setAccountsBySource(prev => ({
...prev,
[sourceId]: (prev[sourceId] || []).map(a =>
a.id === accountId ? { ...a, monitored } : a
),
}));
try {
await api.setAccountMonitored(sourceId, accountId, monitored);
} catch (err) {
// Revert on failure
setAccountsBySource(prev => ({
...prev,
[sourceId]: (prev[sourceId] || []).map(a =>
a.id === accountId ? { ...a, monitored: !monitored } : a
),
}));
toast.error(err.message || 'Failed to update account');
} finally {
setTogglingAccount(null);
}
};
function connWarning(conn) {
if (!conn.last_error) return null;
if (conn.status === 'error') return { kind: 'error', label: 'Sync error' };
// Partial errlist: sync succeeded but some bank connections need attention
return { kind: 'partial', label: 'Some connections need attention' };
}
if (enabled === null) {
return (
<SectionCard title="Bank Sync" {...cardProps}>
<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." {...cardProps}>
{!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 => {
const accounts = accountsBySource[conn.id] || [];
const accsLoading = accountsLoading[conn.id];
const accsError = accountsErrorBySource[conn.id];
const monitoredCount = accounts.filter(a => a.monitored).length;
const warning = connWarning(conn);
return (
<div key={conn.id} className="px-6 py-4 space-y-3">
{warning && (
<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">{warning.label}</span>
<span className="ml-1 font-normal opacity-90"> {conn.last_error}</span>
{warning.kind === 'partial' && (
<span className="block text-xs mt-0.5 opacity-75">
Re-authenticate the affected institutions in your SimpleFIN Bridge account to restore full sync.
</span>
)}
{warning.kind === 'error' && 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>
)}
{/* Header row */}
<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' : ''}
{accounts.length > 0 && ` · ${monitoredCount} monitored`}
</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 || backfilling === 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="outline"
onClick={() => handleBackfill(conn.id)}
disabled={syncing === conn.id || backfilling === conn.id}
className="h-8 text-xs gap-1.5 text-muted-foreground"
title={`Pull up to ${seedDays} days of transaction history`}
>
{backfilling === conn.id
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Backfilling</>
: <><History className="h-3.5 w-3.5" />{seedDays}d Backfill</>}
</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>
{/* Sync status grid */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 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 className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
<p className="text-muted-foreground">History window</p>
<p className="font-medium mt-0.5">{syncDays} days</p>
</div>
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
<p className="text-muted-foreground">Server timezone</p>
<p className="font-medium mt-0.5 truncate" title={serverTz || ''}>{serverTz || '—'}</p>
</div>
</div>
{/* Accounts section */}
<div className="rounded-lg border border-border/60 overflow-hidden">
<div className="px-4 py-2 bg-muted/20 border-b border-border/40 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Accounts
</p>
<p className="text-[10px] text-muted-foreground">
Toggle to include / exclude from bill matching
</p>
</div>
{accsLoading ? (
<div className="flex items-center gap-2 px-4 py-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading accounts
</div>
) : accsError ? (
<p className="px-4 py-3 text-xs text-destructive">{accsError}</p>
) : accounts.length === 0 ? (
<p className="px-4 py-3 text-xs text-muted-foreground italic">No accounts found.</p>
) : (
accounts.map(account => (
<AccountRow
key={account.id}
account={account}
sourceId={conn.id}
expanded={expandedAccount === account.id}
onToggleExpand={() => setExpandedAccount(prev => prev === account.id ? null : account.id)}
onToggleMonitored={(accountId, monitored) => handleToggleMonitored(conn.id, accountId, monitored)}
toggling={togglingAccount === account.id}
bills={bills}
onMatch={tx => handleMatch(conn.id, tx)}
onUnmatch={tx => handleUnmatch(conn.id, tx)}
matchingTxId={matchingTxId}
/>
))
)}
</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>
{/* ── Auto-match review panel ── */}
{enabled && connections.length > 0 && (
<AutoMatchReview refreshKey={autoMatchRefreshKey} />
)}
{/* ── Bank Budget Tracking ── */}
{enabled && connections.length > 0 && (
<SectionCard
title="Bank Budget Tracking"
subtitle="Use your live bank balance as the starting point for your monthly budget instead of manually-entered amounts."
{...cardProps}
>
<div className="px-6 py-5 space-y-5">
{/* Toggle */}
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor="bt-toggle" className="text-sm font-medium">
Use bank balance for budget
</Label>
<p className="text-xs text-muted-foreground">
Replaces manual starting amounts. Remaining&nbsp;= bank balance pending payments unpaid bills.
</p>
</div>
<Switch
id="bt-toggle"
checked={btEnabled}
disabled={btSaving}
onCheckedChange={v => handleBtSave({ enabled: v })}
/>
</div>
{btEnabled && (
<>
{/* Account picker */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Tracking account
</Label>
{btAccounts.length === 0 ? (
<p className="text-xs text-muted-foreground">No bank accounts found. Sync your SimpleFIN connection first.</p>
) : (
<Select
value={btAccountId}
onValueChange={v => handleBtSave({ accountId: v })}
disabled={btSaving}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select a checking account…" />
</SelectTrigger>
<SelectContent>
{btAccounts.map(acc => (
<SelectItem key={acc.id} value={String(acc.id)}>
<div className="flex items-center gap-2">
<Landmark className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>{acc.org_name ? `${acc.org_name}` : ''}{acc.name}</span>
{acc.balance_dollars !== null && (
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
${acc.balance_dollars.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Pending window */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Pending payment window
</Label>
<p className="text-xs text-muted-foreground">
Payments you mark as paid within this many days are shown as <em>pending</em>
the money may not have cleared your bank yet, so they're subtracted from your effective balance.
</p>
<Select
value={String(btPendingDays)}
onValueChange={v => handleBtSave({ pendingDays: parseInt(v, 10) })}
disabled={btSaving}
>
<SelectTrigger className="h-9 w-40 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">No pending (instant)</SelectItem>
<SelectItem value="1">1 day</SelectItem>
<SelectItem value="2">2 days</SelectItem>
<SelectItem value="3">3 days (recommended)</SelectItem>
<SelectItem value="5">5 days</SelectItem>
<SelectItem value="7">7 days</SelectItem>
</SelectContent>
</Select>
</div>
{/* Late payment grace window */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Late payment grace window
</Label>
<p className="text-xs text-muted-foreground">
If a payment posts in the first N days of a new month but the bill was due in the prior month,
automatically count it for the prior month — no prompt needed.
</p>
<Select
value={String(btLateGraceDays)}
onValueChange={v => handleBtSave({ lateGraceDays: parseInt(v, 10) })}
disabled={btSaving}
>
<SelectTrigger className="h-9 w-48 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">Off — prompt me each time</SelectItem>
<SelectItem value="1">1 day</SelectItem>
<SelectItem value="2">2 days</SelectItem>
<SelectItem value="3">3 days (recommended)</SelectItem>
<SelectItem value="5">5 days</SelectItem>
</SelectContent>
</Select>
{btLateGraceDays > 0 && (
<p className="text-[11px] text-emerald-600 dark:text-emerald-400">
Any payment posting on the 1st{btLateGraceDays}{btLateGraceDays === 1 ? 'st' : btLateGraceDays === 2 ? 'nd' : btLateGraceDays === 3 ? 'rd' : 'th'} will automatically count for the prior month if the bill was due then.
</p>
)}
</div>
{/* Info callout */}
<div className="rounded-lg border border-border/60 bg-muted/30 px-4 py-3 text-xs text-muted-foreground space-y-1">
<p><span className="font-semibold text-foreground">How it works:</span> Your live bank balance is fetched every time your data syncs. Bills you've already marked paid are not double-counted your bank balance reflects them. Only unpaid bills still due this month are subtracted.</p>
<p>Bills marked paid within the pending window show a <span className="font-semibold">Pending</span> badge in the tracker, since the bank may not have processed them yet.</p>
</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>
<BillPickerDialog
open={!!matchTarget}
onClose={() => setMatchTarget(null)}
transaction={matchTarget?.tx}
bills={bills}
onConfirm={handleConfirmMatch}
busy={!!matchingTxId}
/>
</>
);
}