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