fix(api): transaction matching logic improvements (batch 0.37.5)

This commit is contained in:
null 2026-06-08 21:07:42 -05:00
parent ca514e5f26
commit f3bcf6cdec
2 changed files with 153 additions and 30 deletions

View File

@ -1,8 +1,9 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo, useRef } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
Sparkles, CheckCircle2, Loader2, RefreshCw, Link2, Link2Off, 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'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -391,14 +392,29 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
const [matchTransaction, setMatchTransaction] = useState(null); const [matchTransaction, setMatchTransaction] = useState(null);
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [createBillSourceTx, setCreateBillSourceTx] = useState(null); 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 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); setLoading(true);
try { try {
const data = await api.transactions({ limit: 100, ...currentFilter.params }); const params = { limit: PAGE_SIZE, offset: (p - 1) * PAGE_SIZE, sort_by: sb, sort_dir: sd, ...currentFilter.params };
setTransactions(data || []); if (q) params.q = q;
const resp = await api.transactions(params);
setTransactions(resp.transactions || []);
setTotalCount(resp.total || 0);
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to load transactions.'); toast.error(err.message || 'Failed to load transactions.');
setTransactions([]); setTransactions([]);
@ -438,12 +454,35 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
}; };
useEffect(() => { loadBills(); }, []); useEffect(() => { loadBills(); }, []);
useEffect(() => { loadTransactions(); }, [filter, refreshKey]); useEffect(() => { setSearch(''); setPage(1); loadTransactions(1, ''); }, [filter, refreshKey]);
useEffect(() => { loadSuggestions(); }, [refreshKey]); useEffect(() => { loadSuggestions(); }, [refreshKey]);
useEffect(() => { useEffect(() => {
api.categories().then(data => setCategories(data || [])).catch(err => console.error('[TransactionMatchingSection] failed to load categories', err)); 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) => { const openMatchDialog = (tx) => {
setMatchTransaction(tx); setMatchTransaction(tx);
setMatchOpen(true); setMatchOpen(true);
@ -612,11 +651,23 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
</button> </button>
))} ))}
</div> </div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search transactions…"
value={search}
onChange={handleSearchChange}
className="h-8 w-48 pl-8 text-sm"
/>
</div>
<Button size="sm" variant="outline" type="button" onClick={refreshTransactionWorkbench} disabled={loading || suggestionsLoading}> <Button size="sm" variant="outline" type="button" onClick={refreshTransactionWorkbench} disabled={loading || suggestionsLoading}>
{loading ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 mr-1.5" />} {loading ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 mr-1.5" />}
Refresh Refresh
</Button> </Button>
</div> </div>
</div>
<SuggestedMatchesPanel <SuggestedMatchesPanel
suggestions={suggestions} suggestions={suggestions}
@ -636,19 +687,43 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
) : ( ) : (
<table className="w-full min-w-[860px] table-fixed text-sm"> <table className="w-full min-w-[860px] table-fixed text-sm">
<colgroup> <colgroup>
<col className="w-[92px]" /> <col className="w-[104px]" />
<col /> <col />
<col className="w-[200px]" /> <col className="w-[200px]" />
<col className="w-[120px]" /> <col className="w-[130px]" />
<col className="w-[96px]" /> <col className="w-[96px]" />
</colgroup> </colgroup>
<thead> <thead>
<tr className="border-b border-border/50 bg-muted/30 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"> <tr className="border-b border-border/50 bg-muted/40 text-[11px] font-medium text-muted-foreground">
<th className="px-4 py-2 text-left">Date</th> <th
<th className="px-4 py-2 text-left">Transaction</th> className="cursor-pointer select-none px-4 py-2.5 text-left transition-colors hover:text-foreground"
<th className="px-4 py-2 text-left">Match</th> onClick={() => handleSortClick('date')}
<th className="px-4 py-2 text-right">Amount</th> >
<th className="px-4 py-2 text-right">Actions</th> <span className="flex items-center gap-1">
Date
{sortBy === 'date'
? sortDir === 'desc'
? <ArrowDown className="h-3 w-3 text-primary" />
: <ArrowUp className="h-3 w-3 text-primary" />
: <ArrowUpDown className="h-3 w-3 opacity-30" />}
</span>
</th>
<th className="px-4 py-2.5 text-left">Transaction</th>
<th className="px-4 py-2.5 text-left">Match</th>
<th
className="cursor-pointer select-none px-4 py-2.5 text-right transition-colors hover:text-foreground"
onClick={() => handleSortClick('amount')}
>
<span className="flex items-center justify-end gap-1">
{sortBy === 'amount'
? sortDir === 'desc'
? <ArrowDown className="h-3 w-3 text-primary" />
: <ArrowUp className="h-3 w-3 text-primary" />
: <ArrowUpDown className="h-3 w-3 opacity-30" />}
Amount
</span>
</th>
<th className="px-4 py-2.5 text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border/30"> <tbody className="divide-y divide-border/30">
@ -763,6 +838,34 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
</tbody> </tbody>
</table> </table>
)} )}
{totalCount > 0 && (
<div className="flex items-center justify-between border-t border-border/50 px-4 py-3 text-sm text-muted-foreground">
<span>{totalCount} transaction{totalCount !== 1 ? 's' : ''}</span>
{totalPages > 1 && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => changePage(page - 1)}
disabled={page === 1}
className="rounded border border-border/60 p-1 hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Previous page"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span>Page {page} of {totalPages}</span>
<button
type="button"
onClick={() => changePage(page + 1)}
disabled={page === totalPages}
className="rounded border border-border/60 p-1 hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Next page"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
)}
</div>
)}
</div> </div>
</div> </div>

View File

@ -278,6 +278,14 @@ router.get('/', (req, res) => {
const page = parseLimitOffset(req.query); const page = parseLimitOffset(req.query);
if (page.error) return res.status(400).json(page.error); 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 = [ const where = [
't.user_id = ?', 't.user_id = ?',
'(t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)', '(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); 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(` const rows = db.prepare(`
SELECT SELECT
t.id, t.user_id, t.data_source_id, t.account_id, t.provider_transaction_id, 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.name AS account_name, fa.org_name AS account_org_name,
fa.account_type AS account_type, fa.account_type AS account_type,
b.name AS matched_bill_name b.name AS matched_bill_name
FROM transactions t ${joins}
LEFT JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id WHERE ${whereClause}
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id ORDER BY ${orderBy}
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
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`).all(...params, page.limit, page.offset); `).all(...params, page.limit, page.offset);
res.json(rows.map(row => { res.json({
transactions: rows.map(row => {
const decorated = decorateTransaction(row); const decorated = decorateTransaction(row);
const title = row.payee || row.description || row.memo || ''; const title = row.payee || row.description || row.memo || '';
decorated.advisory_filter = advisoryCheck(title); decorated.advisory_filter = advisoryCheck(title);
return decorated; return decorated;
})); }),
total,
limit: page.limit,
offset: page.offset,
});
}); });
// POST /api/transactions/manual // POST /api/transactions/manual