BillTracker/client/components/data/TransactionMatchingSection.jsx

696 lines
28 KiB
JavaScript

import React, { useState, useEffect, useMemo, useRef } from 'react';
import { toast } from 'sonner';
import {
Sparkles, CheckCircle2, Loader2, RefreshCw, Link2, Link2Off,
XCircle, Eye, EyeOff, Search, Clock, ChevronLeft, ChevronRight,
ArrowUp, ArrowDown, ArrowUpDown,
} from 'lucide-react';
import { api } from '@/api';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SectionCard } from './dataShared';
import BillModal from '@/components/BillModal';
import {
MatchBillDialog,
transactionTitle,
transactionDate,
formatTransactionAmount,
} from '@/components/transactions/MatchBillDialog';
const TRANSACTION_FILTERS = [
{ id: 'open', label: 'Open', params: { ignored: 'false' } },
{ id: 'unmatched', label: 'Unmatched', params: { match_status: 'unmatched', ignored: 'false' } },
{ id: 'matched', label: 'Matched', params: { match_status: 'matched', ignored: 'false' } },
{ id: 'ignored', label: 'Ignored', params: { match_status: 'ignored', ignored: 'true' } },
{ id: 'all', label: 'All', params: { ignored: 'all' } },
];
function transactionStatus(tx) {
if (tx?.ignored) return 'ignored';
return tx?.match_status || 'unmatched';
}
function TransactionStatusBadge({ tx }) {
const status = transactionStatus(tx);
const styles = {
matched: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600',
ignored: 'border-muted-foreground/30 bg-muted/40 text-muted-foreground',
unmatched: 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400',
};
return (
<span className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase',
styles[status] || styles.unmatched,
)}>
{status}
</span>
);
}
function matchScoreTone(score) {
const value = Number(score) || 0;
if (value >= 80) return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400';
if (value >= 55) return 'border-sky-500/30 bg-sky-500/10 text-sky-600 dark:text-sky-400';
return 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400';
}
function SuggestedMatchesPanel({ suggestions, loading, actionId, onAccept, onReject }) {
return (
<div className="rounded-lg border border-sky-500/20 bg-sky-500/[0.035]">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-sky-500/10 px-4 py-3">
<div className="flex items-center gap-2">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-sky-500/20 bg-sky-500/10 text-sky-600 dark:text-sky-400">
<Sparkles className="h-4 w-4" />
</span>
<div>
<p className="text-sm font-semibold">Suggested matches</p>
<p className="text-xs text-muted-foreground">{loading ? 'Checking transactions' : `${suggestions.length} ready for review`}</p>
</div>
</div>
</div>
{loading ? (
<div className="px-4 py-6 text-sm text-muted-foreground">
<Loader2 className="mr-2 inline h-4 w-4 animate-spin" />
Finding likely bill matches...
</div>
) : suggestions.length === 0 ? (
<div className="px-4 py-6 text-sm text-muted-foreground">
No suggested matches right now.
</div>
) : (
<div className="grid gap-2 p-3 xl:grid-cols-2">
{suggestions.map(suggestion => {
const tx = suggestion.transaction || {};
const bill = suggestion.bill || {};
const acceptBusy = actionId === `suggestion-match:${suggestion.id}`;
const rejectBusy = actionId === `suggestion-reject:${suggestion.id}`;
const busy = acceptBusy || rejectBusy;
return (
<div
key={suggestion.id}
className="rounded-lg border border-border/60 bg-background/80 p-3 shadow-sm transition-colors hover:border-sky-500/30"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className={cn('rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase', matchScoreTone(suggestion.score))}>
{suggestion.score}
</span>
<p className="truncate text-sm font-medium">{transactionTitle(tx)}</p>
</div>
<p className="truncate text-xs text-muted-foreground">
{transactionDate(tx)} · {tx.source_label || tx.source_type_label || 'Transaction'}
</p>
</div>
<p className={cn(
'shrink-0 text-sm font-semibold tabular-nums',
Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
)}>
{formatTransactionAmount(tx.amount, tx.currency)}
</p>
</div>
<div className="mt-3 flex items-center gap-2 rounded-md border border-emerald-500/15 bg-emerald-500/[0.045] px-2.5 py-2">
<Link2 className="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
<div className="min-w-0">
<p className="truncate text-sm font-medium">{bill.name || `Bill ${suggestion.billId}`}</p>
<p className="text-xs text-muted-foreground">
Expected ${Number(bill.expected_amount || 0).toFixed(2)}
</p>
</div>
</div>
{suggestion.reasons?.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{suggestion.reasons.slice(0, 4).map(reason => (
<span key={reason} className="rounded-full border border-border/60 bg-muted/30 px-2 py-0.5 text-[10px] text-muted-foreground">
{reason}
</span>
))}
</div>
)}
<div className="mt-3 flex justify-end gap-2">
<Button
type="button"
size="sm"
variant="ghost"
disabled={busy}
onClick={() => onReject(suggestion)}
className="h-8 text-xs text-muted-foreground hover:text-destructive"
>
{rejectBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <XCircle className="h-3.5 w-3.5" />}
<span className="ml-1.5">Reject</span>
</Button>
<Button
type="button"
size="sm"
disabled={busy}
onClick={() => onAccept(suggestion)}
className="h-8 gap-1.5 text-xs"
>
{acceptBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
Match
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function parseUtc(str) {
if (!str) return null;
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
return new Date(normalized);
}
function timeAgo(iso) {
if (!iso) return null;
const secs = Math.floor((Date.now() - parseUtc(iso).getTime()) / 1000);
if (secs < 60) return 'just now';
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`;
return `${Math.floor(secs / 86400)}d ago`;
}
export default function TransactionMatchingSection({ refreshKey, simplefinConn, cardProps = {} }) {
const [transactions, setTransactions] = useState([]);
const [suggestions, setSuggestions] = useState([]);
const [bills, setBills] = useState([]);
const [filter, setFilter] = useState('open');
const [loading, setLoading] = useState(true);
const [suggestionsLoading, setSuggestionsLoading] = useState(true);
const [billsLoading, setBillsLoading] = useState(true);
const [actionId, setActionId] = useState(null);
const [matchOpen, setMatchOpen] = useState(false);
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 (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 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([]);
} finally {
setLoading(false);
}
};
const loadSuggestions = async () => {
setSuggestionsLoading(true);
try {
const data = await api.matchSuggestions({ limit: 8 });
setSuggestions(data || []);
} catch (err) {
toast.error(err.message || 'Failed to load match suggestions.');
setSuggestions([]);
} finally {
setSuggestionsLoading(false);
}
};
const refreshTransactionWorkbench = async () => {
await Promise.all([loadTransactions(), loadSuggestions()]);
};
const loadBills = async () => {
setBillsLoading(true);
try {
const data = await api.bills();
setBills(data || []);
} catch (err) {
setBills([]);
toast.error(err.message || 'Failed to load bills for matching.');
} finally {
setBillsLoading(false);
}
};
useEffect(() => { loadBills(); }, []);
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);
if (!bills.length && !billsLoading) loadBills();
};
const openCreateBill = (tx, nameOverride) => {
const amount = Math.abs(Number(tx.amount || 0)) / 100;
const dateStr = transactionDate(tx);
const day = dateStr ? parseInt(dateStr.slice(8, 10), 10) : 1;
setCreateBillSourceTx({
tx,
initialBill: {
name: nameOverride || transactionTitle(tx),
expected_amount: amount || 0,
due_day: day >= 1 && day <= 31 ? day : 1,
billing_cycle: 'monthly',
cycle_type: 'monthly',
cycle_day: '1',
active: 1,
},
});
};
const handleBillCreated = async (newBill) => {
const tx = createBillSourceTx?.tx;
setCreateBillSourceTx(null);
if (tx && newBill?.id) {
try {
await api.matchTransaction(tx.id, newBill.id);
toast.success('Bill created and matched to transaction.');
} catch (err) {
toast.error(err.message || 'Bill created but match failed.');
}
}
await Promise.all([loadBills(), refreshTransactionWorkbench()]);
};
const runTransactionAction = async (tx, action) => {
setActionId(`${action}:${tx.id}`);
try {
if (action === 'unmatch') {
await api.unmatchTransaction(tx.id);
toast.success('Transaction unmatched.');
} else if (action === 'ignore') {
await api.ignoreTransaction(tx.id);
toast.success('Transaction ignored.');
} else if (action === 'unignore') {
await api.unignoreTransaction(tx.id);
toast.success('Transaction restored.');
}
await refreshTransactionWorkbench();
} catch (err) {
toast.error(err.message || 'Transaction action failed.');
} finally {
setActionId(null);
}
};
const confirmMatch = async (billId) => {
if (!matchTransaction) return;
setActionId(`match:${matchTransaction.id}`);
try {
await api.matchTransaction(matchTransaction.id, billId);
toast.success('Transaction matched to bill.');
setMatchOpen(false);
setMatchTransaction(null);
await refreshTransactionWorkbench();
} catch (err) {
toast.error(err.message || 'Transaction match failed.');
} finally {
setActionId(null);
}
};
const acceptSuggestion = async (suggestion) => {
setActionId(`suggestion-match:${suggestion.id}`);
try {
await api.matchTransaction(suggestion.transactionId, suggestion.billId);
toast.success('Suggested match confirmed.');
await refreshTransactionWorkbench();
} catch (err) {
toast.error(err.message || 'Suggested match failed.');
} finally {
setActionId(null);
}
};
const rejectSuggestion = async (suggestion) => {
setActionId(`suggestion-reject:${suggestion.id}`);
try {
await api.rejectMatchSuggestion(suggestion.id);
toast.success('Suggestion rejected.');
await loadSuggestions();
} catch (err) {
toast.error(err.message || 'Suggestion could not be rejected.');
} finally {
setActionId(null);
}
};
const [quickSyncing, setQuickSyncing] = useState(false);
const handleQuickSync = async () => {
if (!simplefinConn) return;
setQuickSyncing(true);
try {
await api.syncDataSource(simplefinConn.id);
await refreshTransactionWorkbench();
} catch (err) {
toast.error(err.message || 'Sync failed');
} finally {
setQuickSyncing(false);
}
};
return (
<SectionCard
title="Transactions"
subtitle="Review imported or manual transactions and confirm matches to bills."
{...cardProps}
>
{simplefinConn && (
<div className="px-6 py-2 flex items-center justify-between gap-3 border-b border-border/50 bg-muted/20 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className={cn(
'h-1.5 w-1.5 rounded-full shrink-0',
simplefinConn.last_error ? 'bg-destructive' : 'bg-emerald-500',
)} />
SimpleFIN
{simplefinConn.last_sync_at && (
<span>· synced {timeAgo(simplefinConn.last_sync_at)}</span>
)}
{simplefinConn.last_error && (
<span className="text-destructive">· {simplefinConn.last_error}</span>
)}
</span>
<button
type="button"
onClick={handleQuickSync}
disabled={quickSyncing}
className="flex items-center gap-1 hover:text-foreground transition-colors disabled:opacity-50"
>
{quickSyncing
? <><Loader2 className="h-3 w-3 animate-spin" />Syncing</>
: <><RefreshCw className="h-3 w-3" />Sync Now</>}
</button>
</div>
)}
<div className="px-6 py-5 space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap gap-2">
{TRANSACTION_FILTERS.map(item => (
<button
key={item.id}
type="button"
onClick={() => setFilter(item.id)}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
filter === item.id
? 'border-primary/40 bg-primary/10 text-primary'
: 'border-border/60 bg-muted/20 text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
{item.label}
</button>
))}
</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}>
{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
suggestions={suggestions}
loading={suggestionsLoading}
actionId={actionId}
onAccept={acceptSuggestion}
onReject={rejectSuggestion}
/>
<div className="overflow-x-auto rounded-lg border border-border/60">
{loading ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">Loading transactions</div>
) : transactions.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No transactions found for this filter.
</div>
) : (
<table className="w-full min-w-[860px] table-fixed text-sm">
<colgroup>
<col className="w-[104px]" />
<col />
<col className="w-[200px]" />
<col className="w-[130px]" />
<col className="w-[96px]" />
</colgroup>
<thead>
<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">
{transactions.map(tx => {
const status = transactionStatus(tx);
const busy = actionId?.endsWith(`:${tx.id}`);
return (
<tr key={tx.id} className="hover:bg-muted/20">
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{transactionDate(tx)}
</td>
<td className="max-w-0 px-4 py-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{transactionTitle(tx)}</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{[tx.description, tx.account_name, tx.source_label].filter(Boolean).join(' · ') || '—'}
</p>
</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-1.5">
<TransactionStatusBadge tx={tx} />
{tx.pending ? (
<span className="inline-flex items-center gap-1 rounded-full border border-sky-500/30 bg-sky-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase text-sky-600 dark:text-sky-400">
<Clock className="h-2.5 w-2.5" />
Pending
</span>
) : null}
</div>
{tx.matched_bill_name ? (
<span className="truncate text-xs text-foreground">{tx.matched_bill_name}</span>
) : tx.advisory_filter?.confidence === 'high' ? (
<span className="text-xs text-muted-foreground italic">Probably not a bill</span>
) : (
<span className="text-xs text-muted-foreground">No bill linked</span>
)}
</div>
</td>
<td className={cn(
'px-4 py-3 text-right font-semibold tabular-nums whitespace-nowrap',
Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
)}>
{formatTransactionAmount(tx.amount, tx.currency)}
</td>
<td className="px-3 py-3">
<div className="flex justify-end gap-1.5 whitespace-nowrap">
{status === 'ignored' ? (
<Button
size="icon"
variant="outline"
type="button"
disabled={busy}
onClick={() => runTransactionAction(tx, 'unignore')}
className="h-8 w-8 shrink-0"
aria-label="Unignore transaction"
title="Unignore transaction"
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Eye className="h-3.5 w-3.5" />}
<span className="sr-only">Unignore</span>
</Button>
) : (
<>
{status === 'matched' ? (
<Button
size="icon"
variant="outline"
type="button"
disabled={busy}
onClick={() => runTransactionAction(tx, 'unmatch')}
className="h-8 w-8 shrink-0"
aria-label="Unmatch transaction"
title="Unmatch transaction"
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Link2Off className="h-3.5 w-3.5" />}
<span className="sr-only">Unmatch</span>
</Button>
) : (
<Button
size="icon"
type="button"
disabled={busy || billsLoading}
onClick={() => openMatchDialog(tx)}
className="h-8 w-8 shrink-0"
aria-label="Match transaction"
title="Match transaction"
>
<Link2 className="h-3.5 w-3.5" />
<span className="sr-only">Match</span>
</Button>
)}
<Button
size="icon"
variant="ghost"
type="button"
disabled={busy}
onClick={() => runTransactionAction(tx, 'ignore')}
className="h-8 w-8 shrink-0"
aria-label="Ignore transaction"
title="Ignore transaction"
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <EyeOff className="h-3.5 w-3.5" />}
<span className="sr-only">Ignore</span>
</Button>
</>
)}
</div>
</td>
</tr>
);
})}
</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>
<MatchBillDialog
open={matchOpen}
onOpenChange={setMatchOpen}
transaction={matchTransaction}
bills={bills}
loading={actionId === `match:${matchTransaction?.id}`}
onConfirm={confirmMatch}
onCreateBill={openCreateBill}
/>
{createBillSourceTx && (
<BillModal
key={`create-from-tx-${createBillSourceTx.tx.id}`}
initialBill={createBillSourceTx.initialBill}
categories={categories}
onClose={() => setCreateBillSourceTx(null)}
onSave={handleBillCreated}
/>
)}
</SectionCard>
);
}