import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { ArrowDown, ArrowDownUp, ArrowUp, Building2, CheckCircle2, ChevronLeft, ChevronRight, Clock3, Landmark, Link2, RefreshCw, Search, 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { cn, fmt, fmtDate } from '@/lib/utils'; const PAGE_SIZE = 50; 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: '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 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}

}
); } function TransactionMobileCard({ tx }) { const cents = Number(tx.amount || 0); const isCredit = cents > 0; return (

{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} )}
); } 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); // 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]); // 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 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)); // 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); // 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}
)}
{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" />
Date Merchant Account Status Amount {loading && transactions.length === 0 && ( Loading transactions... )} {!loading && transactions.length === 0 && ( No transactions found. )} {transactions.map(tx => { const cents = Number(tx.amount || 0); const isCredit = cents > 0; return ( {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'}

{tx.matched_bill_name && ( {tx.matched_bill_name} )}
{formatCents(cents, { signed: true })}
); })}
{loading && transactions.length === 0 && (
Loading transactions...
)} {!loading && transactions.length === 0 && (
No transactions found.
)} {transactions.map(tx => )}

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

{page} / {totalPages}
); }