import React, { useState, useEffect, useMemo } from 'react'; import { toast } from 'sonner'; import { Sparkles, CheckCircle2, Loader2, RefreshCw, Link2, Link2Off, XCircle, Eye, EyeOff, Search, } 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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { SectionCard } from './dataShared'; 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 ( {status} ); } function formatTransactionAmount(amount, currency = 'USD') { const value = Math.abs(Number(amount || 0)) / 100; const sign = Number(amount || 0) < 0 ? '-' : '+'; return `${sign}${new Intl.NumberFormat(undefined, { style: 'currency', currency: currency || 'USD', }).format(value)}`; } function transactionDate(tx) { return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || '—'; } function transactionTitle(tx) { return tx?.payee || tx?.description || tx?.memo || 'Transaction'; } 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 (

Suggested matches

{loading ? 'Checking transactions' : `${suggestions.length} ready for review`}

{loading ? (
Finding likely bill matches...
) : suggestions.length === 0 ? (
No suggested matches right now.
) : (
{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 (
{suggestion.score}

{transactionTitle(tx)}

{transactionDate(tx)} · {tx.source_label || tx.source_type_label || 'Transaction'}

{formatTransactionAmount(tx.amount, tx.currency)}

{bill.name || `Bill ${suggestion.billId}`}

Expected ${Number(bill.expected_amount || 0).toFixed(2)}

{suggestion.reasons?.length > 0 && (
{suggestion.reasons.slice(0, 4).map(reason => ( {reason} ))}
)}
); })}
)}
); } function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading }) { const [query, setQuery] = useState(''); const [selectedBillId, setSelectedBillId] = useState(''); useEffect(() => { if (open) { setQuery(''); setSelectedBillId(transaction?.matched_bill_id ? String(transaction.matched_bill_id) : ''); } }, [open, transaction?.id, transaction?.matched_bill_id]); const filteredBills = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return bills.slice(0, 40); return bills .filter(bill => String(bill.name || '').toLowerCase().includes(q)) .slice(0, 40); }, [bills, query]); const selectedBill = bills.find(bill => String(bill.id) === String(selectedBillId)); return ( Match Transaction Choose the bill this transaction paid. Nothing changes until you confirm. {transaction && (

{transactionTitle(transaction)}

{transactionDate(transaction)} · {transaction.source_label || transaction.source_type_label || 'Transaction'}

{formatTransactionAmount(transaction.amount, transaction.currency)}

{transaction.description && transaction.description !== transactionTitle(transaction) && (

{transaction.description}

)}
)}
{filteredBills.length === 0 ? (

No bills found.

) : (
{filteredBills.map(bill => ( ))}
)}
); } function timeAgo(iso) { if (!iso) return null; const secs = Math.floor((Date.now() - new Date(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 }) { 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 currentFilter = TRANSACTION_FILTERS.find(item => item.id === filter) || TRANSACTION_FILTERS[0]; const loadTransactions = async () => { setLoading(true); try { const data = await api.transactions({ limit: 100, ...currentFilter.params }); setTransactions(data || []); } 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 { setBills([]); } finally { setBillsLoading(false); } }; useEffect(() => { loadBills(); }, []); useEffect(() => { loadTransactions(); }, [filter, refreshKey]); useEffect(() => { loadSuggestions(); }, [refreshKey]); const openMatchDialog = (tx) => { setMatchTransaction(tx); setMatchOpen(true); if (!bills.length && !billsLoading) loadBills(); }; 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 ( {simplefinConn && (
SimpleFIN {simplefinConn.last_sync_at && ( · synced {timeAgo(simplefinConn.last_sync_at)} )} {simplefinConn.last_error && ( · {simplefinConn.last_error} )}
)}
{TRANSACTION_FILTERS.map(item => ( ))}
{loading ? (
Loading transactions…
) : transactions.length === 0 ? (
No transactions found for this filter.
) : ( {transactions.map(tx => { const status = transactionStatus(tx); const busy = actionId?.endsWith(`:${tx.id}`); return ( ); })}
Date Transaction Match Amount Actions
{transactionDate(tx)}

{transactionTitle(tx)}

{[tx.description, tx.account_name, tx.source_label].filter(Boolean).join(' · ') || '—'}

{tx.matched_bill_name ? ( {tx.matched_bill_name} ) : ( No bill linked )}
{formatTransactionAmount(tx.amount, tx.currency)}
{status === 'ignored' ? ( ) : ( <> {status === 'matched' ? ( ) : ( )} )}
)}
); }