diff --git a/client/components/data/BankSyncSection.jsx b/client/components/data/BankSyncSection.tsx similarity index 86% rename from client/components/data/BankSyncSection.jsx rename to client/components/data/BankSyncSection.tsx index 3a5b0ef..7caf0d8 100644 --- a/client/components/data/BankSyncSection.jsx +++ b/client/components/data/BankSyncSection.tsx @@ -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) => 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 ( @@ -95,9 +148,16 @@ function MatchBadge({ status, billName }) { return unmatched; } -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(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 */}
e.stopPropagation()}> 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; +}) { + 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 [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 [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 [expandedAccount, setExpandedAccount] = useState(null); + const [togglingAccount, setTogglingAccount] = useState(null); + const [bills, setBills] = useState([]); + const [matchTarget, setMatchTarget] = useState<{ sourceId: number | string; tx: BankTx } | null>(null); + const [matchingTxId, setMatchingTxId] = useState(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([]); 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>, 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) { 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 = {} }) 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 = {} })
{acc.org_name ? `${acc.org_name} — ` : ''}{acc.name} - {acc.balance_dollars !== null && ( + {acc.balance_dollars != null && ( {formatUSD(acc.balance_dollars)}