BillTracker/client/components/transactions/MatchBillDialog.tsx

264 lines
9.6 KiB
TypeScript

import { useState, useEffect, useMemo } from 'react';
import { CheckCircle2, Loader2, Link2, Search, Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatCentsUSD } from '@/lib/money';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { BankTransaction, Bill } from '@/types';
export function transactionDate(tx: BankTransaction | null | undefined): string {
return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || '—';
}
export function transactionTitle(tx: BankTransaction | null | undefined): string {
return tx?.payee || tx?.description || tx?.memo || 'Transaction';
}
export function formatTransactionAmount(
amount: Parameters<typeof formatCentsUSD>[0],
currency = 'USD',
) {
return formatCentsUSD(amount, { signed: true, currency });
}
interface MatchBillDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
transaction: BankTransaction | null;
bills: Bill[];
onConfirm: (billId: number | string) => void;
loading?: boolean;
onCreateBill?: (transaction: BankTransaction | null, query?: string) => void;
}
export function MatchBillDialog({
open,
onOpenChange,
transaction,
bills,
onConfirm,
loading,
onCreateBill,
}: MatchBillDialogProps) {
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));
const af = transaction?.advisory_filter as { confidence?: string } | undefined;
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 ?? undefined)}
</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 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 &&
(() => {
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>
);
}