import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { toast } from 'sonner'; import { ArrowDown, ArrowDownUp, ArrowUp, Building2, Check, CheckCircle2, ChevronLeft, ChevronRight, Clock3, Eye, EyeOff, Landmark, Link2, Link2Off, MoreVertical, RefreshCw, Search, Sparkles, TrendingDown, TrendingUp, WalletCards, } from 'lucide-react'; import { api } from '@/api'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Skeleton } from '@/components/ui/Skeleton'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Checkbox } from '@/components/ui/checkbox'; import { CategoryPicker } from '@/components/transactions/CategoryPicker'; import { MatchBillDialog } from '@/components/transactions/MatchBillDialog'; import BillModal from '@/components/BillModal'; import { cn, fmt, fmtDate, categoryColor, localDateString } from '@/lib/utils'; const PAGE_SIZE = 50; const TABLE_COLUMN_COUNT = 8; const flowOptions = [ { value: 'all', label: 'All' }, { value: 'in', label: 'Money in' }, { value: 'out', label: 'Money out' }, { value: 'pending', label: 'Pending' }, { value: 'unmatched', label: 'Needs review' }, { value: 'uncategorized', label: 'Needs category' }, { value: 'matched', label: 'Matched' }, { value: 'ignored', label: 'Ignored' }, ]; const sortOptions = [ { value: 'date', label: 'Date' }, { value: 'amount', label: 'Amount' }, { value: 'merchant', label: 'Merchant' }, { value: 'account', label: 'Account' }, { value: 'status', label: 'Status' }, ]; function formatCents(value, { signed = false } = {}) { const cents = Number(value || 0); const amount = fmt(Math.abs(cents) / 100); if (!signed || cents === 0) return amount; return `${cents > 0 ? '+' : '-'}${amount}`; } function formatSyncTime(value) { if (!value) return 'Not synced yet'; const normalized = String(value).includes('T') ? String(value) : String(value).replace(' ', 'T'); const date = new Date(normalized); if (Number.isNaN(date.getTime())) return String(value); return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', }).format(date); } function transactionTitle(tx) { return tx.payee || tx.description || tx.memo || 'Transaction'; } function transactionDate(tx) { return tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : null); } function dateGroupLabel(dateStr) { if (!dateStr) return 'Unknown date'; const today = localDateString(); const yesterdayDate = new Date(); yesterdayDate.setDate(yesterdayDate.getDate() - 1); const yesterday = localDateString(yesterdayDate); if (dateStr === today) return 'Today'; if (dateStr === yesterday) return 'Yesterday'; return fmtDate(dateStr); } // Groups already-sorted transactions into date sections for display. // Returns null when the list isn't sorted by date (grouping wouldn't make sense). function groupByDate(transactions, sortBy) { if (sortBy !== 'date') return null; const groups = []; let current = null; for (const tx of transactions) { const date = transactionDate(tx); if (!current || current.date !== date) { current = { date, label: dateGroupLabel(date), items: [] }; groups.push(current); } current.items.push(tx); } return groups; } function accountLabel(account) { if (!account) return 'Unknown account'; return [account.org_name, account.name].filter(Boolean).join(' - ') || account.name || 'Account'; } function StatusBadge({ tx }) { if (tx.pending) { return ( Pending ); } if (tx.ignored || tx.match_status === 'ignored') { return Ignored; } if (tx.match_status === 'matched') { return ( Matched ); } return Review; } function SummaryTile({ icon: Icon, label, value, tone, detail }) { const tones = { emerald: 'border-emerald-300/40 bg-emerald-400/10 text-emerald-700 dark:text-emerald-200', rose: 'border-rose-300/40 bg-rose-400/10 text-rose-700 dark:text-rose-200', sky: 'border-sky-300/40 bg-sky-400/10 text-sky-700 dark:text-sky-200', amber: 'border-amber-300/40 bg-amber-400/10 text-amber-700 dark:text-amber-200', }; return (

{label}

{value}

{detail &&

{detail}

}
); } // Small circular initial badge, colored by the transaction's category — gives // the table/cards a quick visual anchor for scanning by category. function MerchantAvatar({ tx }) { const tone = categoryColor(tx.spending_category_name || 'Uncategorized'); const initial = transactionTitle(tx).trim().charAt(0).toUpperCase() || '?'; return ( {initial} ); } function SuggestionBadge({ tx, onApply, applying }) { if (!tx.suggested_match) return null; return (
{tx.suggested_match.display_name} · {tx.suggested_match.category}
); } function RowActionsMenu({ tx, onMatch, onUnmatch, onIgnore, onUnignore }) { const isMatched = tx.match_status === 'matched'; const isIgnored = tx.ignored || tx.match_status === 'ignored'; return ( onMatch(tx)}> {isMatched ? 'Change bill match…' : 'Match to bill…'} {isMatched && ( onUnmatch(tx)}> Unmatch )} {isIgnored ? ( onUnignore(tx)}> Unignore ) : ( onIgnore(tx)}> Ignore )} ); } function TransactionMobileCard({ tx, categories, onCategorize, onApplySuggestion, applyingSuggestionId, onMatch, onUnmatch, onIgnore, onUnignore, selected, onToggleSelected }) { const cents = Number(tx.amount || 0); const isCredit = cents > 0; return (
onToggleSelected(tx.id)} aria-label="Select transaction" />

{transactionTitle(tx)}

{[tx.account_org_name, tx.account_name].filter(Boolean).join(' - ') || 'Account'}

{formatCents(cents, { signed: true })}

{fmtDate(transactionDate(tx))} {tx.matched_bill_name && ( {tx.matched_bill_name} )}
onCategorize(tx, categoryId)} />
); } export default function BankTransactionsPage() { const [ledger, setLedger] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [search, setSearch] = useState(''); const [query, setQuery] = useState(''); const [accountId, setAccountId] = useState('all'); const [flow, setFlow] = useState('all'); const [sortBy, setSortBy] = useState('date'); const [sortDir, setSortDir] = useState('desc'); const [page, setPage] = useState(1); const [categories, setCategories] = useState([]); const [bills, setBills] = useState([]); const [matchTarget, setMatchTarget] = useState(null); const [matchSubmitting, setMatchSubmitting] = useState(false); const [applyingSuggestionId, setApplyingSuggestionId] = useState(null); const [autoCategorizing, setAutoCategorizing] = useState(false); const [autoCategorizePreview, setAutoCategorizePreview] = useState(null); const [createBillSourceTx, setCreateBillSourceTx] = useState(null); const [selectedIds, setSelectedIds] = useState(() => new Set()); const [bulkActing, setBulkActing] = useState(false); // Monotonic request id: a response only lands if it belongs to the newest // request, so a slow Refresh can never overwrite fresher filter results. const requestSeq = useRef(0); useEffect(() => { const id = window.setTimeout(() => { setQuery(search.trim()); setPage(1); }, 250); return () => window.clearTimeout(id); }, [search]); useEffect(() => { setPage(1); }, [accountId, flow, sortBy, sortDir]); useEffect(() => { setSelectedIds(new Set()); }, [accountId, flow, sortBy, sortDir, query, page]); useEffect(() => { api.categories().then(data => setCategories(data || [])).catch(() => setCategories([])); api.bills().then(data => setBills(data || [])).catch(() => setBills([])); }, []); // Single fetch path — used by both the load effect and the Refresh button. const loadLedger = useCallback(async () => { const seq = ++requestSeq.current; setLoading(true); setError(''); try { const data = await api.bankTransactionsLedger({ limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, q: query, account_id: accountId === 'all' ? undefined : accountId, flow, sort_by: sortBy, sort_dir: sortDir, }); if (seq === requestSeq.current) setLedger(data); } catch (err) { if (seq === requestSeq.current) setError(err.message || 'Unable to load bank transactions'); } finally { if (seq === requestSeq.current) setLoading(false); } }, [accountId, flow, page, query, sortBy, sortDir]); useEffect(() => { loadLedger(); }, [loadLedger]); const updateTransaction = useCallback((id, patch) => { setLedger(prev => { if (!prev) return prev; return { ...prev, transactions: prev.transactions.map(tx => (tx.id === id ? { ...tx, ...patch } : tx)), }; }); }, []); const handleCategorize = useCallback(async (tx, categoryId) => { try { await api.categorizeTransaction(tx.id, { category_id: categoryId, save_rule: false }); const categoryName = categories.find(c => c.id === categoryId)?.name ?? null; updateTransaction(tx.id, { spending_category_id: categoryId, spending_category_name: categoryName, suggested_match: null }); } catch (err) { toast.error(err.message || 'Failed to categorize transaction'); } }, [categories, updateTransaction]); const handleApplySuggestion = useCallback(async (tx) => { setApplyingSuggestionId(tx.id); try { const result = await api.applyTransactionMerchantMatch(tx.id); if (result.matched) { updateTransaction(tx.id, { spending_category_id: result.category.id, spending_category_name: result.category.name, suggested_match: null, }); toast.success(`Categorized as ${result.category.name}`); } } catch (err) { toast.error(err.message || 'Failed to apply suggestion'); } finally { setApplyingSuggestionId(null); } }, [updateTransaction]); const handleAutoCategorize = useCallback(async () => { setAutoCategorizing(true); try { const preview = await api.autoCategorizeTransactions({ dry_run: true }); if (!preview?.changes?.length) { toast.success('No new matches found'); return; } setAutoCategorizePreview(preview); } catch (err) { toast.error(err.message || 'Failed to preview auto-categorize'); } finally { setAutoCategorizing(false); } }, []); const handleUndoAutoCategorize = useCallback(async (changes) => { try { await Promise.all(changes.map(c => api.categorizeTransaction(c.transaction_id, { category_id: null, save_rule: false }))); toast.success('Auto-categorize undone'); } catch (err) { toast.error(err.message || 'Failed to undo auto-categorize'); } finally { loadLedger(); } }, [loadLedger]); const handleConfirmAutoCategorize = useCallback(async () => { setAutoCategorizing(true); try { const result = await api.autoCategorizeTransactions(); const changes = result?.changes || []; setAutoCategorizePreview(null); await Promise.all([ loadLedger(), api.categories().then(data => setCategories(data || [])).catch(() => {}), ]); const count = changes.length; toast.success(`Categorized ${count} transaction${count === 1 ? '' : 's'}`, { action: { label: 'Undo', onClick: () => handleUndoAutoCategorize(changes) }, }); } catch (err) { toast.error(err.message || 'Failed to auto-categorize transactions'); } finally { setAutoCategorizing(false); } }, [loadLedger, handleUndoAutoCategorize]); const handleConfirmMatch = useCallback(async (billId) => { if (!matchTarget) return; setMatchSubmitting(true); try { const result = await api.matchTransaction(matchTarget.id, billId); updateTransaction(matchTarget.id, result.transaction); setMatchTarget(null); toast.success('Transaction matched to bill'); } catch (err) { toast.error(err.message || 'Failed to match transaction'); } finally { setMatchSubmitting(false); } }, [matchTarget, updateTransaction]); const openCreateBill = useCallback((tx, nameOverride) => { const amount = Math.abs(Number(tx.amount || 0)) / 100; const dateStr = transactionDate(tx); const day = dateStr ? parseInt(dateStr.slice(8, 10), 10) : 1; setCreateBillSourceTx({ tx, initialBill: { name: nameOverride || transactionTitle(tx), expected_amount: amount || 0, due_day: day >= 1 && day <= 31 ? day : 1, billing_cycle: 'monthly', cycle_type: 'monthly', cycle_day: '1', active: 1, }, }); }, []); const handleBillCreated = useCallback(async (newBill) => { const tx = createBillSourceTx?.tx; setCreateBillSourceTx(null); setMatchTarget(null); if (tx && newBill?.id) { try { const result = await api.matchTransaction(tx.id, newBill.id); updateTransaction(tx.id, result.transaction); toast.success('Bill created and matched to transaction'); } catch (err) { toast.error(err.message || 'Bill created but match failed'); } } api.bills().then(data => setBills(data || [])).catch(() => {}); }, [createBillSourceTx, updateTransaction]); const handleUnmatch = useCallback(async (tx) => { try { const result = await api.unmatchTransaction(tx.id); updateTransaction(tx.id, result.transaction); toast.success('Transaction unmatched'); } catch (err) { toast.error(err.message || 'Failed to unmatch transaction'); loadLedger(); } }, [updateTransaction, loadLedger]); const handleIgnore = useCallback(async (tx) => { try { const transaction = await api.ignoreTransaction(tx.id); updateTransaction(tx.id, transaction); } catch (err) { toast.error(err.message || 'Failed to ignore transaction'); loadLedger(); } }, [updateTransaction, loadLedger]); const handleUnignore = useCallback(async (tx) => { try { const transaction = await api.unignoreTransaction(tx.id); updateTransaction(tx.id, transaction); } catch (err) { toast.error(err.message || 'Failed to unignore transaction'); loadLedger(); } }, [updateTransaction, loadLedger]); const accounts = ledger?.accounts || []; const transactions = ledger?.transactions || []; const summary = ledger?.summary || {}; const total = Number(ledger?.total || 0); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const categoryBreakdown = summary.category_breakdown || []; const maxCategoryTotal = categoryBreakdown.reduce((max, c) => Math.max(max, Number(c.total || 0)), 0) || 1; const dateGroups = useMemo(() => groupByDate(transactions, sortBy), [transactions, sortBy]); // If a filter shrinks the result set below the current page, snap back to // the last real page instead of stranding the user on an empty one. useEffect(() => { if (!loading && page > totalPages) setPage(totalPages); }, [loading, page, totalPages]); const latestSync = useMemo(() => { const times = (ledger?.sources || []).map(source => source.last_sync_at).filter(Boolean).sort(); return times[times.length - 1] || null; }, [ledger?.sources]); const connected = Boolean(ledger?.enabled && ledger?.has_connections); const selectedTransactions = useMemo( () => transactions.filter(tx => selectedIds.has(tx.id)), [transactions, selectedIds], ); const allOnPageSelected = transactions.length > 0 && transactions.every(tx => selectedIds.has(tx.id)); const toggleSelected = useCallback((id) => { setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const toggleSelectAll = useCallback(() => { setSelectedIds(prev => { if (transactions.length > 0 && transactions.every(tx => prev.has(tx.id))) return new Set(); return new Set(transactions.map(tx => tx.id)); }); }, [transactions]); const handleBulkApplySuggestions = useCallback(async () => { const targets = selectedTransactions.filter(tx => tx.suggested_match && !tx.spending_category_id); if (targets.length === 0) return; setBulkActing(true); try { const results = await Promise.allSettled(targets.map(tx => api.applyTransactionMerchantMatch(tx.id))); let applied = 0; results.forEach((r, i) => { if (r.status === 'fulfilled' && r.value?.matched) { updateTransaction(targets[i].id, { spending_category_id: r.value.category.id, spending_category_name: r.value.category.name, suggested_match: null, }); applied++; } }); toast.success(`Applied suggestions to ${applied} of ${targets.length}`); if (applied < targets.length) loadLedger(); } finally { setBulkActing(false); setSelectedIds(new Set()); } }, [selectedTransactions, updateTransaction, loadLedger]); const handleBulkCategorize = useCallback(async (categoryId) => { const targets = selectedTransactions; if (targets.length === 0) return; const categoryName = categories.find(c => c.id === categoryId)?.name ?? null; setBulkActing(true); try { const results = await Promise.allSettled( targets.map(tx => api.categorizeTransaction(tx.id, { category_id: categoryId, save_rule: false })), ); let applied = 0; results.forEach((r, i) => { if (r.status === 'fulfilled') { updateTransaction(targets[i].id, { spending_category_id: categoryId, spending_category_name: categoryName, suggested_match: null }); applied++; } }); toast.success(`Categorized ${applied} of ${targets.length}`); if (applied < targets.length) loadLedger(); } finally { setBulkActing(false); setSelectedIds(new Set()); } }, [selectedTransactions, categories, updateTransaction, loadLedger]); const handleBulkIgnoreToggle = useCallback(async (ignore) => { const targets = selectedTransactions; if (targets.length === 0) return; setBulkActing(true); try { const results = await Promise.allSettled( targets.map(tx => (ignore ? api.ignoreTransaction(tx.id) : api.unignoreTransaction(tx.id))), ); let applied = 0; results.forEach((r, i) => { if (r.status === 'fulfilled') { updateTransaction(targets[i].id, r.value); applied++; } }); toast.success(`${ignore ? 'Ignored' : 'Unignored'} ${applied} of ${targets.length}`); if (applied < targets.length) loadLedger(); } finally { setBulkActing(false); setSelectedIds(new Set()); } }, [selectedTransactions, updateTransaction, loadLedger]); // A failed request must never masquerade as "not connected" — without this, // a transient API error told users with working bank sync to go connect it. if (!loading && error && !ledger) { return (

Bank Transactions

{error}

); } if (!loading && !connected) { return (

Bank Transactions

SimpleFIN bridge required

); } return (

Bank Transactions

{accounts.length} monitored {accounts.length === 1 ? 'account' : 'accounts'} synced through SimpleFIN

{connected ? 'Connected' : 'Loading'}
Last sync {formatSyncTime(latestSync)}
{error && (
{error}
)}
{categoryBreakdown.length > 0 && (
{categoryBreakdown.map(cat => { const tone = categoryColor(cat.name); const pct = Math.max(4, Math.round((Number(cat.total || 0) / maxCategoryTotal) * 100)); const isUncategorized = cat.name === 'Uncategorized'; const active = isUncategorized ? flow === 'uncategorized' : search.trim().toLowerCase() === cat.name.toLowerCase(); return ( ); })}
)} {accounts.length > 0 && (
{accounts.map(account => (

{accountLabel(account)}

{[account.account_type, account.currency].filter(Boolean).join(' - ') || 'Bank account'}

Balance

{formatCents(account.balance)}

Available

{account.available_balance == null ? '—' : formatCents(account.available_balance)}

{Number(account.transaction_count || 0)} transactions {account.last_transaction_date ? fmtDate(account.last_transaction_date) : 'No activity'}
))}
)}
setSearch(event.target.value)} placeholder="Search merchant, memo, category" className="pl-9" />
{selectedIds.size > 0 && (
{selectedIds.size} selected handleBulkCategorize(categoryId)} />
)}
Date Merchant Account Category Status Amount {loading && transactions.length === 0 && ( Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: TABLE_COLUMN_COUNT }).map((__, j) => ( ))} )) )} {!loading && transactions.length === 0 && ( No transactions found. )} {(dateGroups ?? [{ label: null, items: transactions }]).map((group, gi) => ( {group.label && ( {group.label} )} {group.items.map(tx => { const cents = Number(tx.amount || 0); const isCredit = cents > 0; return ( toggleSelected(tx.id)} aria-label="Select transaction" /> {fmtDate(transactionDate(tx))}

{transactionTitle(tx)}

{[tx.memo, tx.category].filter(Boolean).join(' - ') || tx.description || 'SimpleFIN'}

{[tx.account_org_name, tx.account_name].filter(Boolean).join(' - ') || 'Account'}

{tx.account_type || tx.currency || 'Bank'}

handleCategorize(tx, categoryId)} />
{tx.matched_bill_name && ( {tx.matched_bill_name} )}
{formatCents(cents, { signed: true })}
); })}
))}
{loading && transactions.length === 0 && ( Array.from({ length: 4 }).map((_, i) => ) )} {!loading && transactions.length === 0 && (
No transactions found.
)} {(dateGroups ?? [{ label: null, items: transactions }]).map((group, gi) => (
{group.label && (

{group.label}

)} {group.items.map(tx => ( ))}
))}

{total === 0 ? '0 transactions' : `${(page - 1) * PAGE_SIZE + 1}-${Math.min(page * PAGE_SIZE, total)} of ${total} transactions`}

{page} / {totalPages}
{ if (!open) setMatchTarget(null); }} transaction={matchTarget} bills={bills} loading={matchSubmitting} onConfirm={handleConfirmMatch} onCreateBill={openCreateBill} /> {createBillSourceTx && ( setCreateBillSourceTx(null)} onSave={handleBillCreated} /> )} { if (!open) setAutoCategorizePreview(null); }}> Auto-categorize transactions {autoCategorizePreview?.changes?.length} transaction{autoCategorizePreview?.changes?.length === 1 ? '' : 's'} will be categorized based on the merchant/store reference list. This also saves merchant rules, so future transactions from these merchants will be categorized automatically too.
{(autoCategorizePreview?.categories || []).map(cat => { const tone = categoryColor(cat.name); return ( {cat.name} · {cat.count} ); })}
); }