212 lines
8.5 KiB
JavaScript
212 lines
8.5 KiB
JavaScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import {
|
|
CheckCircle2, Loader2, Link2, Search, Plus,
|
|
} from 'lucide-react';
|
|
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';
|
|
|
|
export function transactionDate(tx) {
|
|
return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || '—';
|
|
}
|
|
|
|
export function transactionTitle(tx) {
|
|
return tx?.payee || tx?.description || tx?.memo || 'Transaction';
|
|
}
|
|
|
|
export 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)}`;
|
|
}
|
|
|
|
export function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading, onCreateBill }) {
|
|
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 (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Match Transaction</DialogTitle>
|
|
<DialogDescription>
|
|
Choose the bill this transaction paid. Nothing changes until you confirm.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{transaction && (
|
|
<div className="rounded-lg border border-border/60 bg-muted/25 p-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-medium">{transactionTitle(transaction)}</p>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
{transactionDate(transaction)} · {transaction.source_label || transaction.source_type_label || 'Transaction'}
|
|
</p>
|
|
</div>
|
|
<p className={cn(
|
|
'text-sm font-semibold tabular-nums',
|
|
Number(transaction.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
|
|
)}>
|
|
{formatTransactionAmount(transaction.amount, transaction.currency)}
|
|
</p>
|
|
</div>
|
|
{transaction.description && transaction.description !== transactionTitle(transaction) && (
|
|
<p className="mt-2 text-xs text-muted-foreground">{transaction.description}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
<label className="space-y-1.5">
|
|
<span className="text-xs font-medium text-muted-foreground">Find bill</span>
|
|
<div className="relative">
|
|
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
placeholder="Search bills"
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
</label>
|
|
|
|
<div className="max-h-72 overflow-y-auto rounded-lg border border-border/60">
|
|
{filteredBills.length === 0 ? (
|
|
<div className="flex flex-col items-center gap-3 px-4 py-8 text-center">
|
|
<p className="text-sm text-muted-foreground">No bills found.</p>
|
|
{onCreateBill && (() => {
|
|
const af = transaction?.advisory_filter;
|
|
const label = query.trim()
|
|
? `Create "${query.trim()}" as a new bill`
|
|
: 'Create a new bill';
|
|
if (af?.confidence === 'high') {
|
|
return (
|
|
<span className="text-xs text-muted-foreground">
|
|
Probably not a bill ·{' '}
|
|
<button
|
|
type="button"
|
|
className="underline hover:text-foreground transition-colors"
|
|
onClick={() => { onOpenChange(false); onCreateBill(transaction, query.trim() || undefined); }}
|
|
>
|
|
{label}
|
|
</button>
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => { onOpenChange(false); onCreateBill(transaction, query.trim() || undefined); }}
|
|
className={cn(
|
|
'flex items-center gap-1.5 text-xs hover:underline',
|
|
af?.confidence === 'medium' ? 'text-muted-foreground' : 'text-primary',
|
|
)}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
{label}
|
|
</button>
|
|
);
|
|
})()}
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-border/40">
|
|
{filteredBills.map(bill => (
|
|
<button
|
|
key={bill.id}
|
|
type="button"
|
|
onClick={() => setSelectedBillId(String(bill.id))}
|
|
className={cn(
|
|
'flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/30',
|
|
String(selectedBillId) === String(bill.id) && 'bg-primary/5',
|
|
)}
|
|
>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-medium">{bill.name}</p>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
Due day {bill.due_day ?? '—'} · expected ${Number(bill.expected_amount || 0).toFixed(2)}
|
|
</p>
|
|
</div>
|
|
{String(selectedBillId) === String(bill.id) && (
|
|
<CheckCircle2 className="h-4 w-4 shrink-0 text-primary" />
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2">
|
|
{onCreateBill && (() => {
|
|
const af = transaction?.advisory_filter;
|
|
if (af?.confidence === 'high') {
|
|
return (
|
|
<span className="mr-auto flex items-center gap-2 text-xs text-muted-foreground">
|
|
Probably not a bill
|
|
<button
|
|
type="button"
|
|
className="underline hover:text-foreground transition-colors"
|
|
onClick={() => { onOpenChange(false); onCreateBill(transaction); }}
|
|
>
|
|
create anyway
|
|
</button>
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
className={cn(
|
|
'mr-auto text-xs',
|
|
af?.confidence === 'medium'
|
|
? 'text-muted-foreground/60 hover:text-muted-foreground'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
onClick={() => { onOpenChange(false); onCreateBill(transaction); }}
|
|
>
|
|
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
|
Create Bill
|
|
</Button>
|
|
);
|
|
})()}
|
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
disabled={!selectedBill || loading}
|
|
onClick={() => selectedBill && onConfirm(selectedBill.id)}
|
|
>
|
|
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Matching…</> : <><Link2 className="h-3.5 w-3.5 mr-1.5" />Confirm Match</>}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|