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 {
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 { formatUSD, formatCentsUSD } from '@/lib/money';
import { cn, errMessage } from '@/lib/utils';
import { formatUSD, formatCentsUSD, type Cents, type Dollars } from '@/lib/money';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
@ -19,10 +19,62 @@ import {
} 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 { SectionCard, type SectionCardProps } from './dataShared';
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 tail = value.slice(-4);
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;
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, {
function fmtDate(iso: string | null | undefined): string {
const d = parseUtc(iso);
if (!d) return '—';
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
function fmtShortDate(date) {
function fmtShortDate(date: string | null | undefined): string {
if (!date) return '—';
const d = new Date(`${date}T00:00:00`);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
function fmtDollars(cents) {
function fmtDollars(cents: Cents): string {
return formatCentsUSD(cents, { dash: true });
}
function MatchBadge({ status, billName }) {
function MatchBadge({ status, billName }: { status: string; billName?: string | null }) {
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'}>
@ -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>;
}
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 [selectedId, setSelectedId] = useState(null);
const [selectedId, setSelectedId] = useState<number | string | null>(null);
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);
return (
@ -183,7 +254,7 @@ function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonit
{/* Monitored toggle */}
<div onClick={e => e.stopPropagation()}>
<Switch
checked={account.monitored}
checked={!!account.monitored}
onCheckedChange={v => onToggleMonitored(account.id, v)}
disabled={toggling}
aria-label={`Monitor ${account.name}`}
@ -292,27 +363,30 @@ function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonit
);
}
export default function BankSyncSection({ onConnectionChange, cardProps = {} }) {
const [enabled, setEnabled] = useState(null);
export default function BankSyncSection({ onConnectionChange, cardProps = {} }: {
onConnectionChange?: (conn: Connection | null) => void;
cardProps?: Partial<SectionCardProps>;
}) {
const [enabled, setEnabled] = useState<boolean | null>(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 [serverTz, setServerTz] = useState<string | null>(null);
const [connections, setConnections] = useState<Connection[]>([]);
const [accountsBySource, setAccountsBySource] = useState<Record<string, BankAccount[]>>({});
const [accountsLoading, setAccountsLoading] = useState<Record<string, boolean>>({});
const [accountsErrorBySource, setAccountsError] = useState<Record<string, string>>({});
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 [syncing, setSyncing] = useState<number | string | null>(null);
const [backfilling, setBackfilling] = useState<number | string | null>(null);
const [disconnectTarget, setDisconnectTarget] = useState<Connection | null>(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 [expandedAccount, setExpandedAccount] = useState<number | string | null>(null);
const [togglingAccount, setTogglingAccount] = useState<number | string | null>(null);
const [bills, setBills] = useState<Bill[]>([]);
const [matchTarget, setMatchTarget] = useState<{ sourceId: number | string; tx: BankTx } | null>(null);
const [matchingTxId, setMatchingTxId] = useState<number | string | null>(null);
const [autoMatchRefreshKey, setAutoMatchRefreshKey] = useState(0);
// Bank tracking state
@ -320,22 +394,22 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
const [btAccountId, setBtAccountId] = useState('');
const [btPendingDays, setBtPendingDays] = useState(3);
const [btLateGraceDays, setBtLateGraceDays] = useState(0);
const [btAccounts, setBtAccounts] = useState([]);
const [btAccounts, setBtAccounts] = useState<FinAccount[]>([]);
const [btSaving, setBtSaving] = useState(false);
// Auto-categorization state
const [acmEnabled, setAcmEnabled] = useState(true);
const [acmSaving, setAcmSaving] = useState(false);
const loadAccounts = useCallback(async (conns) => {
const loadAccounts = useCallback(async (conns: Connection[]) => {
for (const conn of conns) {
setAccountsLoading(prev => ({ ...prev, [conn.id]: true }));
setAccountsError(prev => ({ ...prev, [conn.id]: '' }));
try {
const accounts = await api.dataSourceAccounts(conn.id);
const accounts = await api.dataSourceAccounts(conn.id) as BankAccount[];
setAccountsBySource(prev => ({ ...prev, [conn.id]: accounts }));
} 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 {
setAccountsLoading(prev => ({ ...prev, [conn.id]: false }));
}
@ -346,21 +420,21 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
const loadBankTracking = useCallback(async () => {
try {
const [settings, accounts] = await Promise.all([
api.settings(),
api.settings() as Promise<Record<string, string>>,
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 : []);
setBtPendingDays(parseInt(settings.bank_tracking_pending_days || '', 10) || 3);
setBtLateGraceDays(parseInt(settings.bank_late_attribution_days || '', 10) || 0);
setBtAccounts(Array.isArray(accounts) ? accounts as FinAccount[] : []);
setAcmEnabled(settings.bank_auto_categorize_merchants !== 'false');
} catch {
// non-fatal — bank tracking section just won't populate
}
}, []);
const handleBtSave = useCallback(async (patch) => {
const handleBtSave = useCallback(async (patch: BtPatch) => {
setBtSaving(true);
try {
const next = {
@ -376,20 +450,20 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
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');
toast.error(errMessage(err, 'Failed to save bank tracking settings'));
} finally {
setBtSaving(false);
}
}, [btEnabled, btAccountId, btPendingDays, btLateGraceDays]);
const handleAcmSave = useCallback(async (next) => {
const handleAcmSave = useCallback(async (next: boolean) => {
setAcmSaving(true);
try {
await api.saveSettings({ bank_auto_categorize_merchants: String(next) });
setAcmEnabled(next);
toast.success('Auto-categorization setting saved');
} catch (err) {
toast.error(err.message || 'Failed to save auto-categorization setting');
toast.error(errMessage(err, 'Failed to save auto-categorization setting'));
} finally {
setAcmSaving(false);
}
@ -399,20 +473,20 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
setLoadError('');
try {
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' }),
]);
setEnabled(status.enabled);
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') : [];
const conns = Array.isArray(sources) ? (sources as Connection[]).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');
setLoadError(errMessage(err, 'Failed to load bank sync status'));
}
}, [onConnectionChange, loadAccounts]);
@ -426,7 +500,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
}
}, [connections, bills.length]);
function updateTxInState(sourceId, txId, updates) {
function updateTxInState(sourceId: number | string, txId: number | string, updates: Partial<BankTx>) {
setAccountsBySource(prev => ({
...prev,
[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;
const { sourceId, tx } = matchTarget;
setMatchingTxId(tx.id);
try {
const { transaction } = await api.confirmTransactionMatch(tx.id, billId);
const { transaction } = await api.confirmTransactionMatch(tx.id, billId) as { transaction: BankTx };
updateTxInState(sourceId, tx.id, {
match_status: transaction.match_status,
matched_bill_id: transaction.matched_bill_id,
@ -452,20 +526,20 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
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.');
toast.error(errMessage(err, 'Failed to match transaction.'));
} finally {
setMatchingTxId(null);
}
};
const handleUnmatch = async (sourceId, tx) => {
const handleUnmatch = async (sourceId: number | string, tx: BankTx) => {
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.');
toast.error(errMessage(err, 'Failed to remove match.'));
} finally {
setMatchingTxId(null);
}
@ -476,21 +550,21 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
if (!token) { toast.error('Paste your SimpleFIN setup token first.'); return; }
setConnecting(true);
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).`);
setSetupToken('');
await load();
} catch (err) {
toast.error(err.message || 'Failed to connect SimpleFIN');
toast.error(errMessage(err, 'Failed to connect SimpleFIN'));
} finally {
setConnecting(false);
}
};
const handleSync = async (id) => {
const handleSync = async (id: number | string) => {
setSyncing(id);
try {
const result = await api.syncDataSource(id);
const result = await api.syncDataSource(id) as { errlist?: string; transactionsNew?: number };
if (result.errlist) {
toast.warning(`Synced ${result.transactionsNew} new transaction(s), but some connections need attention: ${result.errlist}`);
} else {
@ -499,17 +573,17 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
setAutoMatchRefreshKey(k => k + 1);
await load();
} catch (err) {
toast.error(err.message || 'Sync failed');
toast.error(errMessage(err, 'Sync failed'));
await load();
} finally {
setSyncing(null);
}
};
const handleBackfill = async (id) => {
const handleBackfill = async (id: number | string) => {
setBackfilling(id);
try {
const result = await api.backfillDataSource(id);
const result = await api.backfillDataSource(id) as { errlist?: string; transactionsNew?: number };
if (result.errlist) {
toast.warning(`Backfill complete — ${result.transactionsNew} new transaction(s). Some connections need attention: ${result.errlist}`);
} else {
@ -518,7 +592,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
setAutoMatchRefreshKey(k => k + 1);
await load();
} catch (err) {
toast.error(err.message || 'Backfill failed');
toast.error(errMessage(err, 'Backfill failed'));
await load();
} finally {
setBackfilling(null);
@ -534,13 +608,13 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
setDisconnectTarget(null);
await load();
} catch (err) {
toast.error(err.message || 'Failed to disconnect');
toast.error(errMessage(err, 'Failed to disconnect'));
} finally {
setDisconnecting(false);
}
};
const handleToggleMonitored = async (sourceId, accountId, monitored) => {
const handleToggleMonitored = async (sourceId: number | string, accountId: number | string, monitored: boolean) => {
setTogglingAccount(accountId);
// Optimistic update
setAccountsBySource(prev => ({
@ -559,13 +633,13 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
a.id === accountId ? { ...a, monitored: !monitored } : a
),
}));
toast.error(err.message || 'Failed to update account');
toast.error(errMessage(err, 'Failed to update account'));
} finally {
setTogglingAccount(null);
}
};
function connWarning(conn) {
function connWarning(conn: Connection): { kind: string; label: string } | null {
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
@ -729,12 +803,10 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
<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}
@ -863,7 +935,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
<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 && (
{acc.balance_dollars != null && (
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{formatUSD(acc.balance_dollars)}
</span>