refactor(ts): convert BankSyncSection to TypeScript

SimpleFIN connect/sync/backfill/accounts + bank-budget-tracking + auto-categorize.
Branded Cents on account/tx amounts, Dollars on financial-account balances.
Dropped unused sourceId/bills props on AccountRow. typecheck 0, build green.
This commit is contained in:
null 2026-07-04 21:46:55 -05:00
parent 5a2e37fd61
commit 0cc5cbd957
1 changed files with 141 additions and 69 deletions

View File

@ -1,12 +1,12 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
AlertTriangle, Building2, ChevronDown, ChevronRight, AlertTriangle, Building2, ChevronDown, ChevronRight,
Eye, EyeOff, ExternalLink, History, Landmark, Link2Off, Loader2, RefreshCw, Unlink, Eye, EyeOff, ExternalLink, History, Landmark, Link2Off, Loader2, RefreshCw, Unlink,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { cn } from '@/lib/utils'; import { cn, errMessage } from '@/lib/utils';
import { formatUSD, formatCentsUSD } from '@/lib/money'; import { formatUSD, formatCentsUSD, type Cents, type Dollars } from '@/lib/money';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
@ -19,10 +19,62 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { SectionCard } from './dataShared'; import { SectionCard, type SectionCardProps } from './dataShared';
import AutoMatchReview from './AutoMatchReview'; import AutoMatchReview from './AutoMatchReview';
import type { Bill } from '@/types';
function TokenInput({ value, onChange, disabled }) { interface BankTx {
id: number | string;
posted_date?: string | null;
transacted_at?: string | null;
payee?: string | null;
description?: string | null;
amount: Cents;
match_status?: string | null;
matched_bill_id?: number | null;
matched_bill_name?: string | null;
}
interface BankAccount {
id: number | string;
name: string;
org_name?: string | null;
monitored?: boolean;
balance: Cents;
transaction_count?: number;
transactions: BankTx[];
}
interface Connection {
id: number | string;
name?: string;
provider?: string;
status?: string;
last_error?: string | null;
last_sync_at?: string | null;
account_count?: number;
transaction_count?: number;
}
interface FinAccount {
id: number | string;
name: string;
org_name?: string | null;
balance_dollars?: Dollars | null;
}
interface BtPatch {
enabled?: boolean;
accountId?: string;
pendingDays?: number;
lateGraceDays?: number;
}
function TokenInput({ value, onChange, disabled }: {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
disabled?: boolean;
}) {
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
const tail = value.slice(-4); const tail = value.slice(-4);
return ( return (
@ -57,31 +109,32 @@ function TokenInput({ value, onChange, disabled }) {
); );
} }
function parseUtc(str) { function parseUtc(str: string | null | undefined): Date | null {
if (!str) return null; if (!str) return null;
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z'; const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
return new Date(normalized); return new Date(normalized);
} }
function fmtDate(iso) { function fmtDate(iso: string | null | undefined): string {
if (!iso) return '—'; const d = parseUtc(iso);
return parseUtc(iso).toLocaleString(undefined, { if (!d) return '—';
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric', year: 'numeric', month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit', hour: '2-digit', minute: '2-digit',
}); });
} }
function fmtShortDate(date) { function fmtShortDate(date: string | null | undefined): string {
if (!date) return '—'; if (!date) return '—';
const d = new Date(`${date}T00:00:00`); const d = new Date(`${date}T00:00:00`);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
} }
function fmtDollars(cents) { function fmtDollars(cents: Cents): string {
return formatCentsUSD(cents, { dash: true }); return formatCentsUSD(cents, { dash: true });
} }
function MatchBadge({ status, billName }) { function MatchBadge({ status, billName }: { status: string; billName?: string | null }) {
if (status === 'matched') { if (status === 'matched') {
return ( 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'}> <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'}>
@ -95,9 +148,16 @@ function MatchBadge({ status, billName }) {
return <span className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded-full">unmatched</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 }) { function BillPickerDialog({ open, onClose, transaction, bills, onConfirm, busy }: {
open: boolean;
onClose: () => void;
transaction?: BankTx | null;
bills: Bill[];
onConfirm: (billId: number | string | null) => void;
busy: boolean;
}) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState(null); const [selectedId, setSelectedId] = useState<number | string | null>(null);
useEffect(() => { if (open) { setSearch(''); setSelectedId(null); } }, [open]); useEffect(() => { if (open) { setSearch(''); setSelectedId(null); } }, [open]);
@ -165,7 +225,18 @@ function BillPickerDialog({ open, onClose, transaction, bills, onConfirm, busy }
); );
} }
function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonitored, toggling, bills, onMatch, onUnmatch, matchingTxId }) { interface AccountRowProps {
account: BankAccount;
expanded: boolean;
onToggleExpand: () => void;
onToggleMonitored: (accountId: number | string, monitored: boolean) => void;
toggling: boolean;
onMatch: (tx: BankTx) => void;
onUnmatch: (tx: BankTx) => void;
matchingTxId: number | string | null;
}
function AccountRow({ account, expanded, onToggleExpand, onToggleMonitored, toggling, onMatch, onUnmatch, matchingTxId }: AccountRowProps) {
const txDate = account.transactions?.[0]?.posted_date || account.transactions?.[0]?.transacted_at?.slice(0, 10); const txDate = account.transactions?.[0]?.posted_date || account.transactions?.[0]?.transacted_at?.slice(0, 10);
return ( return (
@ -183,7 +254,7 @@ function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonit
{/* Monitored toggle */} {/* Monitored toggle */}
<div onClick={e => e.stopPropagation()}> <div onClick={e => e.stopPropagation()}>
<Switch <Switch
checked={account.monitored} checked={!!account.monitored}
onCheckedChange={v => onToggleMonitored(account.id, v)} onCheckedChange={v => onToggleMonitored(account.id, v)}
disabled={toggling} disabled={toggling}
aria-label={`Monitor ${account.name}`} aria-label={`Monitor ${account.name}`}
@ -292,27 +363,30 @@ function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonit
); );
} }
export default function BankSyncSection({ onConnectionChange, cardProps = {} }) { export default function BankSyncSection({ onConnectionChange, cardProps = {} }: {
const [enabled, setEnabled] = useState(null); onConnectionChange?: (conn: Connection | null) => void;
cardProps?: Partial<SectionCardProps>;
}) {
const [enabled, setEnabled] = useState<boolean | null>(null);
const [syncDays, setSyncDays] = useState(30); const [syncDays, setSyncDays] = useState(30);
const [seedDays, setSeedDays] = useState(44); const [seedDays, setSeedDays] = useState(44);
const [serverTz, setServerTz] = useState(null); const [serverTz, setServerTz] = useState<string | null>(null);
const [connections, setConnections] = useState([]); const [connections, setConnections] = useState<Connection[]>([]);
const [accountsBySource, setAccountsBySource] = useState({}); const [accountsBySource, setAccountsBySource] = useState<Record<string, BankAccount[]>>({});
const [accountsLoading, setAccountsLoading] = useState({}); const [accountsLoading, setAccountsLoading] = useState<Record<string, boolean>>({});
const [accountsErrorBySource, setAccountsError] = useState({}); const [accountsErrorBySource, setAccountsError] = useState<Record<string, string>>({});
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [setupToken, setSetupToken] = useState(''); const [setupToken, setSetupToken] = useState('');
const [connecting, setConnecting] = useState(false); const [connecting, setConnecting] = useState(false);
const [syncing, setSyncing] = useState(null); const [syncing, setSyncing] = useState<number | string | null>(null);
const [backfilling, setBackfilling] = useState(null); const [backfilling, setBackfilling] = useState<number | string | null>(null);
const [disconnectTarget, setDisconnectTarget] = useState(null); const [disconnectTarget, setDisconnectTarget] = useState<Connection | null>(null);
const [disconnecting, setDisconnecting] = useState(false); const [disconnecting, setDisconnecting] = useState(false);
const [expandedAccount, setExpandedAccount] = useState(null); const [expandedAccount, setExpandedAccount] = useState<number | string | null>(null);
const [togglingAccount, setTogglingAccount] = useState(null); const [togglingAccount, setTogglingAccount] = useState<number | string | null>(null);
const [bills, setBills] = useState([]); const [bills, setBills] = useState<Bill[]>([]);
const [matchTarget, setMatchTarget] = useState(null); // { sourceId, tx } const [matchTarget, setMatchTarget] = useState<{ sourceId: number | string; tx: BankTx } | null>(null);
const [matchingTxId, setMatchingTxId] = useState(null); const [matchingTxId, setMatchingTxId] = useState<number | string | null>(null);
const [autoMatchRefreshKey, setAutoMatchRefreshKey] = useState(0); const [autoMatchRefreshKey, setAutoMatchRefreshKey] = useState(0);
// Bank tracking state // Bank tracking state
@ -320,22 +394,22 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
const [btAccountId, setBtAccountId] = useState(''); const [btAccountId, setBtAccountId] = useState('');
const [btPendingDays, setBtPendingDays] = useState(3); const [btPendingDays, setBtPendingDays] = useState(3);
const [btLateGraceDays, setBtLateGraceDays] = useState(0); const [btLateGraceDays, setBtLateGraceDays] = useState(0);
const [btAccounts, setBtAccounts] = useState([]); const [btAccounts, setBtAccounts] = useState<FinAccount[]>([]);
const [btSaving, setBtSaving] = useState(false); const [btSaving, setBtSaving] = useState(false);
// Auto-categorization state // Auto-categorization state
const [acmEnabled, setAcmEnabled] = useState(true); const [acmEnabled, setAcmEnabled] = useState(true);
const [acmSaving, setAcmSaving] = useState(false); const [acmSaving, setAcmSaving] = useState(false);
const loadAccounts = useCallback(async (conns) => { const loadAccounts = useCallback(async (conns: Connection[]) => {
for (const conn of conns) { for (const conn of conns) {
setAccountsLoading(prev => ({ ...prev, [conn.id]: true })); setAccountsLoading(prev => ({ ...prev, [conn.id]: true }));
setAccountsError(prev => ({ ...prev, [conn.id]: '' })); setAccountsError(prev => ({ ...prev, [conn.id]: '' }));
try { try {
const accounts = await api.dataSourceAccounts(conn.id); const accounts = await api.dataSourceAccounts(conn.id) as BankAccount[];
setAccountsBySource(prev => ({ ...prev, [conn.id]: accounts })); setAccountsBySource(prev => ({ ...prev, [conn.id]: accounts }));
} catch (err) { } catch (err) {
setAccountsError(prev => ({ ...prev, [conn.id]: err.message || 'Failed to load accounts' })); setAccountsError(prev => ({ ...prev, [conn.id]: errMessage(err, 'Failed to load accounts') }));
} finally { } finally {
setAccountsLoading(prev => ({ ...prev, [conn.id]: false })); setAccountsLoading(prev => ({ ...prev, [conn.id]: false }));
} }
@ -346,21 +420,21 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
const loadBankTracking = useCallback(async () => { const loadBankTracking = useCallback(async () => {
try { try {
const [settings, accounts] = await Promise.all([ const [settings, accounts] = await Promise.all([
api.settings(), api.settings() as Promise<Record<string, string>>,
api.allFinancialAccounts().catch(() => []), api.allFinancialAccounts().catch(() => []),
]); ]);
setBtEnabled(settings.bank_tracking_enabled === 'true'); setBtEnabled(settings.bank_tracking_enabled === 'true');
setBtAccountId(settings.bank_tracking_account_id || ''); setBtAccountId(settings.bank_tracking_account_id || '');
setBtPendingDays(parseInt(settings.bank_tracking_pending_days, 10) || 3); setBtPendingDays(parseInt(settings.bank_tracking_pending_days || '', 10) || 3);
setBtLateGraceDays(parseInt(settings.bank_late_attribution_days, 10) || 0); setBtLateGraceDays(parseInt(settings.bank_late_attribution_days || '', 10) || 0);
setBtAccounts(Array.isArray(accounts) ? accounts : []); setBtAccounts(Array.isArray(accounts) ? accounts as FinAccount[] : []);
setAcmEnabled(settings.bank_auto_categorize_merchants !== 'false'); setAcmEnabled(settings.bank_auto_categorize_merchants !== 'false');
} catch { } catch {
// non-fatal — bank tracking section just won't populate // non-fatal — bank tracking section just won't populate
} }
}, []); }, []);
const handleBtSave = useCallback(async (patch) => { const handleBtSave = useCallback(async (patch: BtPatch) => {
setBtSaving(true); setBtSaving(true);
try { try {
const next = { const next = {
@ -376,20 +450,20 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
if (patch.lateGraceDays !== undefined) setBtLateGraceDays(patch.lateGraceDays); if (patch.lateGraceDays !== undefined) setBtLateGraceDays(patch.lateGraceDays);
toast.success('Bank tracking settings saved'); toast.success('Bank tracking settings saved');
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to save bank tracking settings'); toast.error(errMessage(err, 'Failed to save bank tracking settings'));
} finally { } finally {
setBtSaving(false); setBtSaving(false);
} }
}, [btEnabled, btAccountId, btPendingDays, btLateGraceDays]); }, [btEnabled, btAccountId, btPendingDays, btLateGraceDays]);
const handleAcmSave = useCallback(async (next) => { const handleAcmSave = useCallback(async (next: boolean) => {
setAcmSaving(true); setAcmSaving(true);
try { try {
await api.saveSettings({ bank_auto_categorize_merchants: String(next) }); await api.saveSettings({ bank_auto_categorize_merchants: String(next) });
setAcmEnabled(next); setAcmEnabled(next);
toast.success('Auto-categorization setting saved'); toast.success('Auto-categorization setting saved');
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to save auto-categorization setting'); toast.error(errMessage(err, 'Failed to save auto-categorization setting'));
} finally { } finally {
setAcmSaving(false); setAcmSaving(false);
} }
@ -399,20 +473,20 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
setLoadError(''); setLoadError('');
try { try {
const [status, sources] = await Promise.all([ const [status, sources] = await Promise.all([
api.simplefinStatus(), api.simplefinStatus() as Promise<{ enabled?: boolean; sync_days?: number; seed_days?: number; timezone?: string | null }>,
api.dataSources({ type: 'provider_sync' }), api.dataSources({ type: 'provider_sync' }),
]); ]);
setEnabled(status.enabled); setEnabled(!!status.enabled);
setSyncDays(status.sync_days ?? 30); setSyncDays(status.sync_days ?? 30);
setSeedDays(status.seed_days ?? 44); setSeedDays(status.seed_days ?? 44);
setServerTz(status.timezone || null); setServerTz(status.timezone || null);
const conns = Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : []; const conns = Array.isArray(sources) ? (sources as Connection[]).filter(s => s.provider === 'simplefin') : [];
setConnections(conns); setConnections(conns);
onConnectionChange?.(conns[0] || null); onConnectionChange?.(conns[0] || null);
if (conns.length > 0) loadAccounts(conns); if (conns.length > 0) loadAccounts(conns);
} catch (err) { } catch (err) {
setEnabled(false); setEnabled(false);
setLoadError(err.message || 'Failed to load bank sync status'); setLoadError(errMessage(err, 'Failed to load bank sync status'));
} }
}, [onConnectionChange, loadAccounts]); }, [onConnectionChange, loadAccounts]);
@ -426,7 +500,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
} }
}, [connections, bills.length]); }, [connections, bills.length]);
function updateTxInState(sourceId, txId, updates) { function updateTxInState(sourceId: number | string, txId: number | string, updates: Partial<BankTx>) {
setAccountsBySource(prev => ({ setAccountsBySource(prev => ({
...prev, ...prev,
[sourceId]: (prev[sourceId] || []).map(acc => ({ [sourceId]: (prev[sourceId] || []).map(acc => ({
@ -436,14 +510,14 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
})); }));
} }
const handleMatch = (sourceId, tx) => setMatchTarget({ sourceId, tx }); const handleMatch = (sourceId: number | string, tx: BankTx) => setMatchTarget({ sourceId, tx });
const handleConfirmMatch = async (billId) => { const handleConfirmMatch = async (billId: number | string | null) => {
if (!matchTarget || !billId) return; if (!matchTarget || !billId) return;
const { sourceId, tx } = matchTarget; const { sourceId, tx } = matchTarget;
setMatchingTxId(tx.id); setMatchingTxId(tx.id);
try { try {
const { transaction } = await api.confirmTransactionMatch(tx.id, billId); const { transaction } = await api.confirmTransactionMatch(tx.id, billId) as { transaction: BankTx };
updateTxInState(sourceId, tx.id, { updateTxInState(sourceId, tx.id, {
match_status: transaction.match_status, match_status: transaction.match_status,
matched_bill_id: transaction.matched_bill_id, matched_bill_id: transaction.matched_bill_id,
@ -452,20 +526,20 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
setMatchTarget(null); setMatchTarget(null);
toast.success(`Matched to "${transaction.matched_bill_name}" — payment recorded for ${fmtShortDate(tx.posted_date || tx.transacted_at?.slice(0, 10))}.`); toast.success(`Matched to "${transaction.matched_bill_name}" — payment recorded for ${fmtShortDate(tx.posted_date || tx.transacted_at?.slice(0, 10))}.`);
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to match transaction.'); toast.error(errMessage(err, 'Failed to match transaction.'));
} finally { } finally {
setMatchingTxId(null); setMatchingTxId(null);
} }
}; };
const handleUnmatch = async (sourceId, tx) => { const handleUnmatch = async (sourceId: number | string, tx: BankTx) => {
setMatchingTxId(tx.id); setMatchingTxId(tx.id);
try { try {
await api.unmatchTransaction(tx.id); await api.unmatchTransaction(tx.id);
updateTxInState(sourceId, tx.id, { match_status: 'unmatched', matched_bill_id: null, matched_bill_name: null }); updateTxInState(sourceId, tx.id, { match_status: 'unmatched', matched_bill_id: null, matched_bill_name: null });
toast.success('Match removed.'); toast.success('Match removed.');
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to remove match.'); toast.error(errMessage(err, 'Failed to remove match.'));
} finally { } finally {
setMatchingTxId(null); setMatchingTxId(null);
} }
@ -476,21 +550,21 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
if (!token) { toast.error('Paste your SimpleFIN setup token first.'); return; } if (!token) { toast.error('Paste your SimpleFIN setup token first.'); return; }
setConnecting(true); setConnecting(true);
try { try {
const result = await api.connectSimplefin(token); const result = await api.connectSimplefin(token) as { accountsUpserted?: number; transactionsNew?: number };
toast.success(`Connected — ${result.accountsUpserted} account(s), ${result.transactionsNew} new transaction(s).`); toast.success(`Connected — ${result.accountsUpserted} account(s), ${result.transactionsNew} new transaction(s).`);
setSetupToken(''); setSetupToken('');
await load(); await load();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to connect SimpleFIN'); toast.error(errMessage(err, 'Failed to connect SimpleFIN'));
} finally { } finally {
setConnecting(false); setConnecting(false);
} }
}; };
const handleSync = async (id) => { const handleSync = async (id: number | string) => {
setSyncing(id); setSyncing(id);
try { try {
const result = await api.syncDataSource(id); const result = await api.syncDataSource(id) as { errlist?: string; transactionsNew?: number };
if (result.errlist) { if (result.errlist) {
toast.warning(`Synced ${result.transactionsNew} new transaction(s), but some connections need attention: ${result.errlist}`); toast.warning(`Synced ${result.transactionsNew} new transaction(s), but some connections need attention: ${result.errlist}`);
} else { } else {
@ -499,17 +573,17 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
setAutoMatchRefreshKey(k => k + 1); setAutoMatchRefreshKey(k => k + 1);
await load(); await load();
} catch (err) { } catch (err) {
toast.error(err.message || 'Sync failed'); toast.error(errMessage(err, 'Sync failed'));
await load(); await load();
} finally { } finally {
setSyncing(null); setSyncing(null);
} }
}; };
const handleBackfill = async (id) => { const handleBackfill = async (id: number | string) => {
setBackfilling(id); setBackfilling(id);
try { try {
const result = await api.backfillDataSource(id); const result = await api.backfillDataSource(id) as { errlist?: string; transactionsNew?: number };
if (result.errlist) { if (result.errlist) {
toast.warning(`Backfill complete — ${result.transactionsNew} new transaction(s). Some connections need attention: ${result.errlist}`); toast.warning(`Backfill complete — ${result.transactionsNew} new transaction(s). Some connections need attention: ${result.errlist}`);
} else { } else {
@ -518,7 +592,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
setAutoMatchRefreshKey(k => k + 1); setAutoMatchRefreshKey(k => k + 1);
await load(); await load();
} catch (err) { } catch (err) {
toast.error(err.message || 'Backfill failed'); toast.error(errMessage(err, 'Backfill failed'));
await load(); await load();
} finally { } finally {
setBackfilling(null); setBackfilling(null);
@ -534,13 +608,13 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
setDisconnectTarget(null); setDisconnectTarget(null);
await load(); await load();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to disconnect'); toast.error(errMessage(err, 'Failed to disconnect'));
} finally { } finally {
setDisconnecting(false); setDisconnecting(false);
} }
}; };
const handleToggleMonitored = async (sourceId, accountId, monitored) => { const handleToggleMonitored = async (sourceId: number | string, accountId: number | string, monitored: boolean) => {
setTogglingAccount(accountId); setTogglingAccount(accountId);
// Optimistic update // Optimistic update
setAccountsBySource(prev => ({ setAccountsBySource(prev => ({
@ -559,13 +633,13 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
a.id === accountId ? { ...a, monitored: !monitored } : a a.id === accountId ? { ...a, monitored: !monitored } : a
), ),
})); }));
toast.error(err.message || 'Failed to update account'); toast.error(errMessage(err, 'Failed to update account'));
} finally { } finally {
setTogglingAccount(null); setTogglingAccount(null);
} }
}; };
function connWarning(conn) { function connWarning(conn: Connection): { kind: string; label: string } | null {
if (!conn.last_error) return null; if (!conn.last_error) return null;
if (conn.status === 'error') return { kind: 'error', label: 'Sync error' }; if (conn.status === 'error') return { kind: 'error', label: 'Sync error' };
// Partial errlist: sync succeeded but some bank connections need attention // Partial errlist: sync succeeded but some bank connections need attention
@ -729,12 +803,10 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
<AccountRow <AccountRow
key={account.id} key={account.id}
account={account} account={account}
sourceId={conn.id}
expanded={expandedAccount === account.id} expanded={expandedAccount === account.id}
onToggleExpand={() => setExpandedAccount(prev => prev === account.id ? null : account.id)} onToggleExpand={() => setExpandedAccount(prev => prev === account.id ? null : account.id)}
onToggleMonitored={(accountId, monitored) => handleToggleMonitored(conn.id, accountId, monitored)} onToggleMonitored={(accountId, monitored) => handleToggleMonitored(conn.id, accountId, monitored)}
toggling={togglingAccount === account.id} toggling={togglingAccount === account.id}
bills={bills}
onMatch={tx => handleMatch(conn.id, tx)} onMatch={tx => handleMatch(conn.id, tx)}
onUnmatch={tx => handleUnmatch(conn.id, tx)} onUnmatch={tx => handleUnmatch(conn.id, tx)}
matchingTxId={matchingTxId} matchingTxId={matchingTxId}
@ -863,7 +935,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Landmark className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <Landmark className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>{acc.org_name ? `${acc.org_name}` : ''}{acc.name}</span> <span>{acc.org_name ? `${acc.org_name}` : ''}{acc.name}</span>
{acc.balance_dollars !== null && ( {acc.balance_dollars != null && (
<span className="ml-auto text-xs text-muted-foreground tabular-nums"> <span className="ml-auto text-xs text-muted-foreground tabular-nums">
{formatUSD(acc.balance_dollars)} {formatUSD(acc.balance_dollars)}
</span> </span>