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 (
{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'}
))}
)}
{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}
/>
)}
);
}