-
-
{transactionTitle(tx)}
-
- {[tx.account_org_name, tx.account_name].filter(Boolean).join(' - ') || 'Account'}
-
+
+
onToggleSelected(tx.id)} aria-label="Select transaction" />
+
+
+
{transactionTitle(tx)}
+
+ {[tx.account_org_name, tx.account_name].filter(Boolean).join(' - ') || 'Account'}
+
+
{formatCents(cents, { signed: true })}
@@ -163,6 +295,13 @@ function TransactionMobileCard({ tx }) {
)}
+
+
+ onCategorize(tx, categoryId)} />
+
+
+
+
);
}
@@ -178,6 +317,16 @@ export default function BankTransactionsPage() {
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);
@@ -194,6 +343,15 @@ export default function BankTransactionsPage() {
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;
@@ -219,11 +377,181 @@ export default function BankTransactionsPage() {
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.
@@ -236,6 +564,99 @@ export default function BankTransactionsPage() {
}, [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) {
@@ -314,6 +735,10 @@ export default function BankTransactionsPage() {
Last sync
{formatSyncTime(latestSync)}