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