fix(api): transaction matching logic improvements (batch 0.37.5)
This commit is contained in:
parent
ca514e5f26
commit
f3bcf6cdec
|
|
@ -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,
|
|||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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" />}
|
||||
Refresh
|
||||
</Button>
|
||||
<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}>
|
||||
{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
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SuggestedMatchesPanel
|
||||
|
|
@ -636,19 +687,43 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
|
|||
) : (
|
||||
<table className="w-full min-w-[860px] table-fixed text-sm">
|
||||
<colgroup>
|
||||
<col className="w-[92px]" />
|
||||
<col className="w-[104px]" />
|
||||
<col />
|
||||
<col className="w-[200px]" />
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[130px]" />
|
||||
<col className="w-[96px]" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="border-b border-border/50 bg-muted/30 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
<th className="px-4 py-2 text-left">Date</th>
|
||||
<th className="px-4 py-2 text-left">Transaction</th>
|
||||
<th className="px-4 py-2 text-left">Match</th>
|
||||
<th className="px-4 py-2 text-right">Amount</th>
|
||||
<th className="px-4 py-2 text-right">Actions</th>
|
||||
<tr className="border-b border-border/50 bg-muted/40 text-[11px] font-medium text-muted-foreground">
|
||||
<th
|
||||
className="cursor-pointer select-none px-4 py-2.5 text-left transition-colors hover:text-foreground"
|
||||
onClick={() => handleSortClick('date')}
|
||||
>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/30">
|
||||
|
|
@ -763,6 +838,34 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
|
|||
</tbody>
|
||||
</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue