124 lines
4.6 KiB
React
124 lines
4.6 KiB
React
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
||
|
|
import { Undo2, ChevronDown, ChevronRight } from 'lucide-react';
|
||
|
|
import { toast } from 'sonner';
|
||
|
|
import { api } from '@/api';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import { cn } from '@/lib/utils';
|
||
|
|
|
||
|
|
function fmtAmt(dollars) {
|
||
|
|
if (dollars == null) return '—';
|
||
|
|
return `$${Number(dollars).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function fmtDate(iso) {
|
||
|
|
if (!iso) return '';
|
||
|
|
const d = new Date(`${iso}T00:00:00`);
|
||
|
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||
|
|
}
|
||
|
|
|
||
|
|
function payeeLabel(row) {
|
||
|
|
return row.payee || row.description || `Transaction #${row.transaction_id}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function AutoMatchReview({ refreshKey }) {
|
||
|
|
const [items, setItems] = useState([]);
|
||
|
|
const [loading, setLoading] = useState(false);
|
||
|
|
const [open, setOpen] = useState(true);
|
||
|
|
const [undoing, setUndoing] = useState(null);
|
||
|
|
|
||
|
|
const load = useCallback(async () => {
|
||
|
|
setLoading(true);
|
||
|
|
try {
|
||
|
|
const rows = await api.recentAutoMatched();
|
||
|
|
setItems(Array.isArray(rows) ? rows : []);
|
||
|
|
} catch {
|
||
|
|
// Non-blocking — don't surface errors for a review panel
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => { load(); }, [load, refreshKey]);
|
||
|
|
|
||
|
|
async function handleUndo(item) {
|
||
|
|
setUndoing(item.id);
|
||
|
|
try {
|
||
|
|
await api.undoAutoMatch(item.id);
|
||
|
|
setItems(prev => prev.filter(p => p.id !== item.id));
|
||
|
|
toast.success(`Undone — ${payeeLabel(item)} unlinked from "${item.bill_name}"`);
|
||
|
|
} catch (err) {
|
||
|
|
toast.error(err.message || 'Failed to undo auto-match');
|
||
|
|
} finally {
|
||
|
|
setUndoing(null);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!loading && items.length === 0) return null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="rounded-lg border border-border/60 overflow-hidden">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => setOpen(v => !v)}
|
||
|
|
className="w-full flex items-center justify-between px-4 py-2.5 bg-muted/20 border-b border-border/40 hover:bg-muted/30 transition-colors text-left"
|
||
|
|
>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||
|
|
Auto-matched — review
|
||
|
|
</span>
|
||
|
|
{items.length > 0 && (
|
||
|
|
<span className="text-[10px] font-medium bg-primary/15 text-primary px-1.5 py-0.5 rounded-full">
|
||
|
|
{items.length}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
{open
|
||
|
|
? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||
|
|
: <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
|
||
|
|
</button>
|
||
|
|
|
||
|
|
{open && (
|
||
|
|
<div className="divide-y divide-border/40">
|
||
|
|
{loading && items.length === 0 ? (
|
||
|
|
<p className="px-4 py-3 text-xs text-muted-foreground italic">Loading…</p>
|
||
|
|
) : items.map(item => (
|
||
|
|
<div key={item.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||
|
|
<div className="min-w-0 flex-1">
|
||
|
|
<div className="flex items-center gap-2 flex-wrap">
|
||
|
|
<span className="text-xs font-medium truncate">{payeeLabel(item)}</span>
|
||
|
|
{item.paid_date && (
|
||
|
|
<span className="text-[10px] text-muted-foreground shrink-0">{fmtDate(item.paid_date)}</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<p className="text-[11px] text-muted-foreground mt-0.5">
|
||
|
|
<span className="font-medium text-foreground">{fmtAmt(item.amount)}</span>
|
||
|
|
{' → '}
|
||
|
|
<span className="truncate">{item.bill_name}</span>
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="ghost"
|
||
|
|
disabled={undoing === item.id}
|
||
|
|
onClick={() => handleUndo(item)}
|
||
|
|
className={cn(
|
||
|
|
'shrink-0 h-7 text-xs gap-1 text-muted-foreground hover:text-destructive hover:bg-destructive/8',
|
||
|
|
undoing === item.id && 'opacity-50',
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<Undo2 className="h-3 w-3" />
|
||
|
|
{undoing === item.id ? 'Undoing…' : 'Undo'}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
<div className="px-4 py-2 bg-muted/10">
|
||
|
|
<p className="text-[10px] text-muted-foreground">
|
||
|
|
Payments auto-created from merchant rules in the last 7 days. Undo removes the payment and restores the bill balance.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|