diff --git a/client/pages/BankTransactionsPage.jsx b/client/pages/BankTransactionsPage.tsx similarity index 79% rename from client/pages/BankTransactionsPage.jsx rename to client/pages/BankTransactionsPage.tsx index 7c3b2cf..cec1127 100644 --- a/client/pages/BankTransactionsPage.jsx +++ b/client/pages/BankTransactionsPage.tsx @@ -1,30 +1,12 @@ -import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; +import { Fragment, useCallback, useEffect, useMemo, useState, type ComponentType } from 'react'; import { Link } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import { useBankLedger } from '@/hooks/useQueries'; 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, + 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'; @@ -32,44 +14,97 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Skeleton } from '@/components/ui/Skeleton'; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, + 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'; +import { cn, fmtDate, categoryColor, localDateString, errMessage } from '@/lib/utils'; +import { formatUSD, asDollars, type Cents } from '@/lib/money'; +import type { Bill, Category, BankTransaction } from '@/types'; + +function fmt(v: number | null | undefined): string { + return formatUSD(asDollars(Number(v) || 0)); +} const PAGE_SIZE = 50; const TABLE_COLUMN_COUNT = 8; +interface Tx { + id: number; + amount: Cents; + payee?: string | null; + description?: string | null; + memo?: string | null; + category?: string | null; + posted_date?: string | null; + transacted_at?: string | null; + pending?: boolean; + ignored?: boolean; + match_status?: string | null; + matched_bill_name?: string | null; + spending_category_id?: number | null; + spending_category_name?: string | null; + suggested_match?: { display_name?: string; category?: string } | null; + account_org_name?: string | null; + account_name?: string | null; + account_type?: string | null; + currency?: string | null; + [key: string]: unknown; +} + +interface Account { + id: number | string; + org_name?: string; + name?: string; + account_type?: string; + currency?: string; + balance?: number; + available_balance?: number | null; + transaction_count?: number; + last_transaction_date?: string; +} + +interface CategoryBreakdown { name: string; total?: number; count?: number } + +interface LedgerSummary { + inflow?: number; + outflow?: number; + net?: number; + matched?: number; + unmatched?: number; + pending?: number; + total?: number; + latest_date?: string; + category_breakdown?: CategoryBreakdown[]; +} + +interface Ledger { + accounts?: Account[]; + transactions?: Tx[]; + summary?: LedgerSummary; + total?: number; + sources?: { last_sync_at?: string | null }[]; + enabled?: boolean; + has_connections?: boolean; +} + +interface AutoCatChange { transaction_id: number } +interface AutoCatPreview { + changes?: AutoCatChange[]; + categories?: { name: string; count?: number }[]; +} + const flowOptions = [ { value: 'all', label: 'All' }, { value: 'in', label: 'Money in' }, @@ -89,35 +124,32 @@ const sortOptions = [ { value: 'status', label: 'Status' }, ]; -function formatCents(value, { signed = false } = {}) { +function formatCents(value: number | null | undefined, { signed = false }: { signed?: boolean } = {}): string { 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) { +function formatSyncTime(value: unknown): string { 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', + month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', }).format(date); } -function transactionTitle(tx) { +function transactionTitle(tx: Tx): string { return tx.payee || tx.description || tx.memo || 'Transaction'; } -function transactionDate(tx) { +function transactionDate(tx: Tx): string | null { return tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : null); } -function dateGroupLabel(dateStr) { +function dateGroupLabel(dateStr: string | null): string { if (!dateStr) return 'Unknown date'; const today = localDateString(); const yesterdayDate = new Date(); @@ -128,12 +160,12 @@ function dateGroupLabel(dateStr) { 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) { +interface DateGroup { date?: string | null; label: string | null; items: Tx[] } + +function groupByDate(transactions: Tx[], sortBy: string): DateGroup[] | null { if (sortBy !== 'date') return null; - const groups = []; - let current = null; + const groups: DateGroup[] = []; + let current: DateGroup | null = null; for (const tx of transactions) { const date = transactionDate(tx); if (!current || current.date !== date) { @@ -145,12 +177,12 @@ function groupByDate(transactions, sortBy) { return groups; } -function accountLabel(account) { +function accountLabel(account?: Account): string { if (!account) return 'Unknown account'; return [account.org_name, account.name].filter(Boolean).join(' - ') || account.name || 'Account'; } -function StatusBadge({ tx }) { +function StatusBadge({ tx }: { tx: Tx }) { if (tx.pending) { return ( @@ -173,8 +205,14 @@ function StatusBadge({ tx }) { return Review; } -function SummaryTile({ icon: Icon, label, value, tone, detail }) { - const tones = { +function SummaryTile({ icon: Icon, label, value, tone, detail }: { + icon: ComponentType<{ className?: string }>; + label: React.ReactNode; + value: React.ReactNode; + tone: string; + detail?: React.ReactNode; +}) { + const tones: Record = { 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', @@ -195,9 +233,7 @@ function SummaryTile({ icon: Icon, label, value, tone, 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 }) { +function MerchantAvatar({ tx }: { tx: Tx }) { const tone = categoryColor(tx.spending_category_name || 'Uncategorized'); const initial = transactionTitle(tx).trim().charAt(0).toUpperCase() || '?'; return ( @@ -207,7 +243,7 @@ function MerchantAvatar({ tx }) { ); } -function SuggestionBadge({ tx, onApply, applying }) { +function SuggestionBadge({ tx, onApply, applying }: { tx: Tx; onApply: (tx: Tx) => void; applying?: boolean }) { if (!tx.suggested_match) return null; return (
@@ -227,7 +263,13 @@ function SuggestionBadge({ tx, onApply, applying }) { ); } -function RowActionsMenu({ tx, onMatch, onUnmatch, onIgnore, onUnignore }) { +function RowActionsMenu({ tx, onMatch, onUnmatch, onIgnore, onUnignore }: { + tx: Tx; + onMatch: (tx: Tx) => void; + onUnmatch: (tx: Tx) => void; + onIgnore: (tx: Tx) => void; + onUnignore: (tx: Tx) => void; +}) { const isMatched = tx.match_status === 'matched'; const isIgnored = tx.ignored || tx.match_status === 'ignored'; @@ -266,7 +308,19 @@ function RowActionsMenu({ tx, onMatch, onUnmatch, onIgnore, onUnignore }) { ); } -function TransactionMobileCard({ tx, categories, onCategorize, onApplySuggestion, applyingSuggestionId, onMatch, onUnmatch, onIgnore, onUnignore, selected, onToggleSelected }) { +function TransactionMobileCard({ tx, categories, onCategorize, onApplySuggestion, applyingSuggestionId, onMatch, onUnmatch, onIgnore, onUnignore, selected, onToggleSelected }: { + tx: Tx; + categories: Category[]; + onCategorize: (tx: Tx, categoryId: number | string | null) => void; + onApplySuggestion: (tx: Tx) => void; + applyingSuggestionId: number | null; + onMatch: (tx: Tx) => void; + onUnmatch: (tx: Tx) => void; + onIgnore: (tx: Tx) => void; + onUnignore: (tx: Tx) => void; + selected: boolean; + onToggleSelected: (id: number) => void; +}) { const cents = Number(tx.amount || 0); const isCredit = cents > 0; @@ -317,24 +371,23 @@ 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 [categories, setCategories] = useState([]); + const [bills, setBills] = useState([]); + const [matchTarget, setMatchTarget] = useState(null); const [matchSubmitting, setMatchSubmitting] = useState(false); - const [applyingSuggestionId, setApplyingSuggestionId] = useState(null); + 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 [autoCategorizePreview, setAutoCategorizePreview] = useState(null); + const [createBillSourceTx, setCreateBillSourceTx] = useState<{ tx: Tx; initialBill: Partial } | null>(null); + const [selectedIds, setSelectedIds] = useState>(() => new Set()); const [bulkActing, setBulkActing] = useState(false); - // React Query keys the ledger on the filters/page, so it handles caching, - // dedup, cancellation, and out-of-order responses (no manual request id). - const { data: ledger = null, isPending: loading, isFetching, error: ledgerErr, refetch: loadLedger } = + // React Query keys the ledger on the filters/page. + const { data: ledgerRaw = null, isPending: loading, isFetching, error: ledgerErr, refetch: loadLedger } = useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize: PAGE_SIZE }); + const ledger = ledgerRaw as Ledger | null; const error = ledgerErr ? (ledgerErr.message || 'Unable to load bank transactions') : ''; - const setLedger = useCallback((u) => queryClient.setQueryData( - ['bank-ledger', accountId, flow, page, query, sortBy, sortDir], - prev => (typeof u === 'function' ? u(prev) : u)), [queryClient, accountId, flow, page, query, sortBy, sortDir]); + const setLedger = useCallback((u: (prev: Ledger | undefined) => Ledger | undefined) => queryClient.setQueryData( + ['bank-ledger', accountId, flow, page, query, sortBy, sortDir], u), [queryClient, accountId, flow, page, query, sortBy, sortDir]); useEffect(() => { const id = window.setTimeout(() => { @@ -353,36 +406,35 @@ export default function BankTransactionsPage() { }, [accountId, flow, sortBy, sortDir, query, page]); useEffect(() => { - api.categories().then(data => setCategories(data || [])).catch(() => setCategories([])); - api.bills().then(data => setBills(data || [])).catch(() => setBills([])); + api.categories().then(data => setCategories((data as Category[]) || [])).catch(() => setCategories([])); + api.bills().then(data => setBills((data as Bill[]) || [])).catch(() => setBills([])); }, []); - - const updateTransaction = useCallback((id, patch) => { + const updateTransaction = useCallback((id: number, patch: Partial) => { setLedger(prev => { if (!prev) return prev; return { ...prev, - transactions: prev.transactions.map(tx => (tx.id === id ? { ...tx, ...patch } : tx)), + transactions: (prev.transactions || []).map(tx => (tx.id === id ? { ...tx, ...patch } : tx)), }; }); }, [setLedger]); - const handleCategorize = useCallback(async (tx, categoryId) => { + const handleCategorize = useCallback(async (tx: Tx, categoryId: number | string | null) => { 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 }); + updateTransaction(tx.id, { spending_category_id: categoryId as number | null, spending_category_name: categoryName, suggested_match: null }); } catch (err) { - toast.error(err.message || 'Failed to categorize transaction'); + toast.error(errMessage(err, 'Failed to categorize transaction')); } }, [categories, updateTransaction]); - const handleApplySuggestion = useCallback(async (tx) => { + const handleApplySuggestion = useCallback(async (tx: Tx) => { setApplyingSuggestionId(tx.id); try { - const result = await api.applyTransactionMerchantMatch(tx.id); - if (result.matched) { + const result = await api.applyTransactionMerchantMatch(tx.id) as { matched?: boolean; category?: { id: number; name: string } }; + if (result.matched && result.category) { updateTransaction(tx.id, { spending_category_id: result.category.id, spending_category_name: result.category.name, @@ -391,7 +443,7 @@ export default function BankTransactionsPage() { toast.success(`Categorized as ${result.category.name}`); } } catch (err) { - toast.error(err.message || 'Failed to apply suggestion'); + toast.error(errMessage(err, 'Failed to apply suggestion')); } finally { setApplyingSuggestionId(null); } @@ -400,25 +452,25 @@ export default function BankTransactionsPage() { const handleAutoCategorize = useCallback(async () => { setAutoCategorizing(true); try { - const preview = await api.autoCategorizeTransactions({ dry_run: true }); + const preview = await api.autoCategorizeTransactions({ dry_run: true }) as AutoCatPreview; if (!preview?.changes?.length) { toast.success('No new matches found'); return; } setAutoCategorizePreview(preview); } catch (err) { - toast.error(err.message || 'Failed to preview auto-categorize'); + toast.error(errMessage(err, 'Failed to preview auto-categorize')); } finally { setAutoCategorizing(false); } }, []); - const handleUndoAutoCategorize = useCallback(async (changes) => { + const handleUndoAutoCategorize = useCallback(async (changes: AutoCatChange[]) => { 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'); + toast.error(errMessage(err, 'Failed to undo auto-categorize')); } finally { loadLedger(); } @@ -427,40 +479,42 @@ export default function BankTransactionsPage() { const handleConfirmAutoCategorize = useCallback(async () => { setAutoCategorizing(true); try { - const result = await api.autoCategorizeTransactions(); + const result = await api.autoCategorizeTransactions() as { changes?: AutoCatChange[] }; const changes = result?.changes || []; setAutoCategorizePreview(null); await Promise.all([ loadLedger(), - api.categories().then(data => setCategories(data || [])).catch(() => {}), + api.categories().then(data => setCategories((data as Category[]) || [])).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'); + toast.error(errMessage(err, 'Failed to auto-categorize transactions')); } finally { setAutoCategorizing(false); } }, [loadLedger, handleUndoAutoCategorize]); - const handleConfirmMatch = useCallback(async (billId) => { + const handleConfirmMatch = useCallback(async (billId: number | string) => { if (!matchTarget) return; setMatchSubmitting(true); try { - const result = await api.matchTransaction(matchTarget.id, billId); + const result = await api.matchTransaction(matchTarget.id, billId) as { transaction: Tx }; updateTransaction(matchTarget.id, result.transaction); setMatchTarget(null); toast.success('Transaction matched to bill'); } catch (err) { - toast.error(err.message || 'Failed to match transaction'); + toast.error(errMessage(err, 'Failed to match transaction')); } finally { setMatchSubmitting(false); } }, [matchTarget, updateTransaction]); - const openCreateBill = useCallback((tx, nameOverride) => { + const openCreateBill = useCallback((txArg: BankTransaction | null, nameOverride?: string) => { + const tx = txArg as Tx | null; + if (!tx) return; const amount = Math.abs(Number(tx.amount || 0)) / 100; const dateStr = transactionDate(tx); const day = dateStr ? parseInt(dateStr.slice(8, 10), 10) : 1; @@ -468,7 +522,7 @@ export default function BankTransactionsPage() { tx, initialBill: { name: nameOverride || transactionTitle(tx), - expected_amount: amount || 0, + expected_amount: asDollars(amount || 0), due_day: day >= 1 && day <= 31 ? day : 1, billing_cycle: 'monthly', cycle_type: 'monthly', @@ -478,49 +532,49 @@ export default function BankTransactionsPage() { }); }, []); - const handleBillCreated = useCallback(async (newBill) => { + const handleBillCreated = useCallback(async (newBill?: Bill) => { const tx = createBillSourceTx?.tx; setCreateBillSourceTx(null); setMatchTarget(null); if (tx && newBill?.id) { try { - const result = await api.matchTransaction(tx.id, newBill.id); + const result = await api.matchTransaction(tx.id, newBill.id) as { transaction: Tx }; updateTransaction(tx.id, result.transaction); toast.success('Bill created and matched to transaction'); } catch (err) { - toast.error(err.message || 'Bill created but match failed'); + toast.error(errMessage(err, 'Bill created but match failed')); } } - api.bills().then(data => setBills(data || [])).catch(() => {}); + api.bills().then(data => setBills((data as Bill[]) || [])).catch(() => {}); }, [createBillSourceTx, updateTransaction]); - const handleUnmatch = useCallback(async (tx) => { + const handleUnmatch = useCallback(async (tx: Tx) => { try { - const result = await api.unmatchTransaction(tx.id); + const result = await api.unmatchTransaction(tx.id) as { transaction: Tx }; updateTransaction(tx.id, result.transaction); toast.success('Transaction unmatched'); } catch (err) { - toast.error(err.message || 'Failed to unmatch transaction'); + toast.error(errMessage(err, 'Failed to unmatch transaction')); loadLedger(); } }, [updateTransaction, loadLedger]); - const handleIgnore = useCallback(async (tx) => { + const handleIgnore = useCallback(async (tx: Tx) => { try { - const transaction = await api.ignoreTransaction(tx.id); + const transaction = await api.ignoreTransaction(tx.id) as Partial; updateTransaction(tx.id, transaction); } catch (err) { - toast.error(err.message || 'Failed to ignore transaction'); + toast.error(errMessage(err, 'Failed to ignore transaction')); loadLedger(); } }, [updateTransaction, loadLedger]); - const handleUnignore = useCallback(async (tx) => { + const handleUnignore = useCallback(async (tx: Tx) => { try { - const transaction = await api.unignoreTransaction(tx.id); + const transaction = await api.unignoreTransaction(tx.id) as Partial; updateTransaction(tx.id, transaction); } catch (err) { - toast.error(err.message || 'Failed to unignore transaction'); + toast.error(errMessage(err, 'Failed to unignore transaction')); loadLedger(); } }, [updateTransaction, loadLedger]); @@ -534,8 +588,6 @@ export default function BankTransactionsPage() { 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]); @@ -551,7 +603,7 @@ export default function BankTransactionsPage() { ); const allOnPageSelected = transactions.length > 0 && transactions.every(tx => selectedIds.has(tx.id)); - const toggleSelected = useCallback((id) => { + const toggleSelected = useCallback((id: number) => { setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); @@ -574,10 +626,11 @@ export default function BankTransactionsPage() { 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, + const val = r.status === 'fulfilled' ? r.value as { matched?: boolean; category?: { id: number; name: string } } : null; + if (val?.matched && val.category) { + updateTransaction(targets[i]!.id, { + spending_category_id: val.category.id, + spending_category_name: val.category.name, suggested_match: null, }); applied++; @@ -591,7 +644,7 @@ export default function BankTransactionsPage() { } }, [selectedTransactions, updateTransaction, loadLedger]); - const handleBulkCategorize = useCallback(async (categoryId) => { + const handleBulkCategorize = useCallback(async (categoryId: number | string | null) => { const targets = selectedTransactions; if (targets.length === 0) return; const categoryName = categories.find(c => c.id === categoryId)?.name ?? null; @@ -603,7 +656,7 @@ export default function BankTransactionsPage() { 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 }); + updateTransaction(targets[i]!.id, { spending_category_id: categoryId as number | null, spending_category_name: categoryName, suggested_match: null }); applied++; } }); @@ -615,7 +668,7 @@ export default function BankTransactionsPage() { } }, [selectedTransactions, categories, updateTransaction, loadLedger]); - const handleBulkIgnoreToggle = useCallback(async (ignore) => { + const handleBulkIgnoreToggle = useCallback(async (ignore: boolean) => { const targets = selectedTransactions; if (targets.length === 0) return; setBulkActing(true); @@ -626,7 +679,7 @@ export default function BankTransactionsPage() { let applied = 0; results.forEach((r, i) => { if (r.status === 'fulfilled') { - updateTransaction(targets[i].id, r.value); + updateTransaction(targets[i]!.id, r.value as Partial); applied++; } }); @@ -638,8 +691,6 @@ export default function BankTransactionsPage() { } }, [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 (
@@ -649,7 +700,7 @@ export default function BankTransactionsPage() {

Bank Transactions

{error}

- @@ -720,7 +771,7 @@ export default function BankTransactionsPage() { Auto-categorize - @@ -735,34 +786,10 @@ export default function BankTransactionsPage() { )}
- - - - + + + +
{categoryBreakdown.length > 0 && ( @@ -846,18 +873,11 @@ export default function BankTransactionsPage() {
- setSearch(event.target.value)} - placeholder="Search merchant, memo, category" - className="pl-9" - /> + setSearch(event.target.value)} placeholder="Search merchant, memo, category" className="pl-9" />
- @@ -1060,24 +1071,14 @@ export default function BankTransactionsPage() { : `${(page - 1) * PAGE_SIZE + 1}-${Math.min(page * PAGE_SIZE, total)} of ${total} transactions`}

- {page} / {totalPages} -