diff --git a/client/components/data/TransactionMatchingSection.jsx b/client/components/data/TransactionMatchingSection.jsx
index 3639d82..a6f6a31 100644
--- a/client/components/data/TransactionMatchingSection.jsx
+++ b/client/components/data/TransactionMatchingSection.jsx
@@ -1,8 +1,9 @@
-import React, { useState, useEffect, useMemo } from 'react';
+import React, { useState, useEffect, useMemo, useRef } from 'react';
import { toast } from 'sonner';
import {
Sparkles, CheckCircle2, Loader2, RefreshCw, Link2, Link2Off,
- XCircle, Eye, EyeOff, Search, Plus, Clock,
+ XCircle, Eye, EyeOff, Search, Plus, Clock, ChevronLeft, ChevronRight,
+ ArrowUp, ArrowDown, ArrowUpDown,
} from 'lucide-react';
import { api } from '@/api';
import { cn } from '@/lib/utils';
@@ -391,14 +392,29 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
const [matchTransaction, setMatchTransaction] = useState(null);
const [categories, setCategories] = useState([]);
const [createBillSourceTx, setCreateBillSourceTx] = useState(null);
+ const [page, setPage] = useState(1);
+ const [totalCount, setTotalCount] = useState(0);
+ const [search, setSearch] = useState('');
+ const [sortBy, setSortBy] = useState('date');
+ const [sortDir, setSortDir] = useState('desc');
+ const searchTimerRef = useRef(null);
+ const PAGE_SIZE = 10;
+ const totalPages = Math.ceil(totalCount / PAGE_SIZE) || 1;
const currentFilter = TRANSACTION_FILTERS.find(item => item.id === filter) || TRANSACTION_FILTERS[0];
- const loadTransactions = async () => {
+ const loadTransactions = async (pageNum, searchOverride, sortByOverride, sortDirOverride) => {
+ const p = pageNum ?? page;
+ const q = searchOverride !== undefined ? searchOverride : search;
+ const sb = sortByOverride !== undefined ? sortByOverride : sortBy;
+ const sd = sortDirOverride !== undefined ? sortDirOverride : sortDir;
setLoading(true);
try {
- const data = await api.transactions({ limit: 100, ...currentFilter.params });
- setTransactions(data || []);
+ const params = { limit: PAGE_SIZE, offset: (p - 1) * PAGE_SIZE, sort_by: sb, sort_dir: sd, ...currentFilter.params };
+ if (q) params.q = q;
+ const resp = await api.transactions(params);
+ setTransactions(resp.transactions || []);
+ setTotalCount(resp.total || 0);
} catch (err) {
toast.error(err.message || 'Failed to load transactions.');
setTransactions([]);
@@ -438,12 +454,35 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
};
useEffect(() => { loadBills(); }, []);
- useEffect(() => { loadTransactions(); }, [filter, refreshKey]);
+ useEffect(() => { setSearch(''); setPage(1); loadTransactions(1, ''); }, [filter, refreshKey]);
useEffect(() => { loadSuggestions(); }, [refreshKey]);
useEffect(() => {
api.categories().then(data => setCategories(data || [])).catch(err => console.error('[TransactionMatchingSection] failed to load categories', err));
}, []);
+ const changePage = (newPage) => {
+ setPage(newPage);
+ loadTransactions(newPage);
+ };
+
+ const handleSearchChange = (e) => {
+ const value = e.target.value;
+ setSearch(value);
+ clearTimeout(searchTimerRef.current);
+ searchTimerRef.current = setTimeout(() => {
+ setPage(1);
+ loadTransactions(1, value);
+ }, 300);
+ };
+
+ const handleSortClick = (column) => {
+ const newDir = sortBy === column && sortDir === 'desc' ? 'asc' : 'desc';
+ setSortBy(column);
+ setSortDir(newDir);
+ setPage(1);
+ loadTransactions(1, undefined, column, newDir);
+ };
+
const openMatchDialog = (tx) => {
setMatchTransaction(tx);
setMatchOpen(true);
@@ -612,10 +651,22 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
))}
-
+
+
+
+
+
+
+
-
+
-
+
-
- | Date |
- Transaction |
- Match |
- Amount |
- Actions |
+
+ | handleSortClick('date')}
+ >
+
+ Date
+ {sortBy === 'date'
+ ? sortDir === 'desc'
+ ?
+ :
+ : }
+
+ |
+ Transaction |
+ Match |
+ handleSortClick('amount')}
+ >
+
+ {sortBy === 'amount'
+ ? sortDir === 'desc'
+ ?
+ :
+ : }
+ Amount
+
+ |
+ Actions |
@@ -763,6 +838,34 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
)}
+ {totalCount > 0 && (
+
+
{totalCount} transaction{totalCount !== 1 ? 's' : ''}
+ {totalPages > 1 && (
+
+
+ Page {page} of {totalPages}
+
+
+ )}
+
+ )}
diff --git a/routes/transactions.js b/routes/transactions.js
index 91bae2c..90af143 100644
--- a/routes/transactions.js
+++ b/routes/transactions.js
@@ -278,6 +278,14 @@ router.get('/', (req, res) => {
const page = parseLimitOffset(req.query);
if (page.error) return res.status(400).json(page.error);
+ const SORT_COLUMNS = {
+ date: "COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)",
+ amount: 't.amount',
+ };
+ const sortBy = SORT_COLUMNS[req.query.sort_by] ? req.query.sort_by : 'date';
+ const sortDir = req.query.sort_dir === 'asc' ? 'ASC' : 'DESC';
+ const orderBy = `${SORT_COLUMNS[sortBy]} ${sortDir}, t.id ${sortDir}`;
+
const where = [
't.user_id = ?',
'(t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)',
@@ -350,6 +358,16 @@ router.get('/', (req, res) => {
params.push(q, q, q, q);
}
+ const whereClause = where.join(' AND ');
+ const joins = `
+ FROM transactions t
+ LEFT JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id
+ LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
+ LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL
+ `;
+
+ const total = db.prepare(`SELECT COUNT(*) AS n ${joins} WHERE ${whereClause}`).get(...params).n;
+
const rows = db.prepare(`
SELECT
t.id, t.user_id, t.data_source_id, t.account_id, t.provider_transaction_id,
@@ -361,21 +379,23 @@ router.get('/', (req, res) => {
fa.name AS account_name, fa.org_name AS account_org_name,
fa.account_type AS account_type,
b.name AS matched_bill_name
- FROM transactions t
- LEFT JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id
- LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
- LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL
- WHERE ${where.join(' AND ')}
- ORDER BY COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at) DESC, t.id DESC
+ ${joins}
+ WHERE ${whereClause}
+ ORDER BY ${orderBy}
LIMIT ? OFFSET ?
`).all(...params, page.limit, page.offset);
- res.json(rows.map(row => {
- const decorated = decorateTransaction(row);
- const title = row.payee || row.description || row.memo || '';
- decorated.advisory_filter = advisoryCheck(title);
- return decorated;
- }));
+ res.json({
+ transactions: rows.map(row => {
+ const decorated = decorateTransaction(row);
+ const title = row.payee || row.description || row.memo || '';
+ decorated.advisory_filter = advisoryCheck(title);
+ return decorated;
+ }),
+ total,
+ limit: page.limit,
+ offset: page.offset,
+ });
});
// POST /api/transactions/manual