BillTracker/client/components/BillHistoricalImportDialog.tsx

373 lines
13 KiB
TypeScript

'use client';
import { useState, useEffect, type ComponentType } from 'react';
import { toast } from 'sonner';
import { CheckCircle2, Circle, Loader2, AlertTriangle, CalendarDays } from 'lucide-react';
import { api } from '@/api';
import { cn, fmt, fmtDate, errMessage } from '@/lib/utils';
import type { Dollars } from '@/lib/money';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog';
interface StatusMeta {
label: string | null;
className: string;
icon: ComponentType<{ className?: string }> | null;
}
interface Candidate {
id: number | string;
status?: string;
payee?: string | null;
paid_date?: string | null;
amount: Dollars;
matched_bill_name?: string | null;
}
const STATUS_META = {
unmatched: { label: 'Unmatched', className: 'text-muted-foreground', icon: null },
matched_this_bill: {
label: 'Already linked',
className: 'text-emerald-600 dark:text-emerald-400',
icon: CheckCircle2,
},
matched_other_bill: {
label: null,
className: 'text-amber-600 dark:text-amber-400',
icon: AlertTriangle,
},
payment_exists: {
label: 'Payment exists',
className: 'text-emerald-600 dark:text-emerald-400',
icon: CheckCircle2,
},
} satisfies Record<string, StatusMeta>;
function StatusChip({ candidate }: { candidate: Candidate }) {
const meta: StatusMeta =
STATUS_META[candidate.status as keyof typeof STATUS_META] ?? STATUS_META.unmatched;
const Icon = meta.icon;
const label =
candidate.status === 'matched_other_bill'
? `Matched to ${candidate.matched_bill_name || 'another bill'}`
: meta.label;
if (!label) return null;
return (
<span className={cn('text-[10px] font-medium', meta.className)}>
{Icon && <Icon className="inline h-3 w-3 mr-0.5 -mt-px" />}
{label}
</span>
);
}
// ── Main dialog ───────────────────────────────────────────────────────────────
interface BillHistoricalImportDialogProps {
billId: number | string;
billName: string;
open: boolean;
onClose: () => void;
onImported?: (result: { imported?: number }) => void;
}
export default function BillHistoricalImportDialog({
billId,
billName,
open,
onClose,
onImported,
}: BillHistoricalImportDialogProps) {
const [step, setStep] = useState<'choice' | 'pick'>('choice');
const [candidates, setCandidates] = useState<Candidate[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Set<number | string>>(new Set());
const [importing, setImporting] = useState(false);
// Load candidates whenever the dialog opens
useEffect(() => {
if (!open || !billId) return;
setStep('choice');
setSelected(new Set());
setLoading(true);
api
.merchantRuleCandidates(billId)
.then((raw) => {
const data = raw as { candidates?: Candidate[] };
// Pre-select importable candidates (not already a payment for this bill)
const importableList = (data.candidates || []).filter(
(c) => c.status !== 'payment_exists' && c.status !== 'matched_this_bill',
);
setCandidates(data.candidates || []);
setSelected(new Set(importableList.map((c) => c.id)));
})
.catch(() => setCandidates([]))
.finally(() => setLoading(false));
}, [open, billId]);
const importable = candidates.filter(
(c) => c.status !== 'payment_exists' && c.status !== 'matched_this_bill',
);
const alreadyDone = candidates.filter(
(c) => c.status === 'payment_exists' || c.status === 'matched_this_bill',
);
async function doImport(ids: (number | string)[]) {
if (ids.length === 0) {
onClose();
return;
}
setImporting(true);
try {
const result = (await api.importHistoricalPayments(billId, ids)) as { imported?: number };
toast.success(
`${result.imported} payment${result.imported === 1 ? '' : 's'} imported for ${billName}`,
);
onImported?.(result);
onClose();
} catch (err) {
toast.error(errMessage(err, 'Import failed'));
} finally {
setImporting(false);
}
}
function toggleAll(checked: boolean) {
setSelected(checked ? new Set(importable.map((c) => c.id)) : new Set());
}
function toggle(id: number | string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
const allSelected = importable.length > 0 && importable.every((c) => selected.has(c.id));
// ── Choice step ──────────────────────────────────────────────────────────────
if (step === 'choice') {
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!v) onClose();
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{!loading && importable.length === 0 && alreadyDone.length === 0
? 'No past payments found'
: !loading && importable.length === 0
? 'Already up to date'
: 'Import past payments'}
</DialogTitle>
<DialogDescription>
{loading
? 'Searching your bank history…'
: importable.length === 0 && alreadyDone.length > 0
? `All matching transactions for ${billName} are already linked — nothing left to import.`
: importable.length === 0
? `No past bank transactions found matching ${billName}.`
: `Found ${importable.length} past transaction${importable.length === 1 ? '' : 's'} matching ${billName}. What would you like to do?`}
</DialogDescription>
</DialogHeader>
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{!loading && importable.length > 0 && (
<div className="space-y-2.5">
{/* Populate all */}
<button
type="button"
onClick={() => doImport(importable.map((c) => c.id))}
disabled={importing}
className="w-full rounded-xl border border-primary/30 bg-primary/5 p-3.5 text-left transition-colors hover:bg-primary/10 disabled:opacity-50"
>
<p className="text-sm font-semibold">Import all {importable.length} payments</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Treat your bank as the source of truth import every matching transaction as a
payment.
</p>
</button>
{/* Pick one by one */}
<button
type="button"
onClick={() => setStep('pick')}
disabled={importing}
className="w-full rounded-xl border border-border/60 bg-muted/20 p-3.5 text-left transition-colors hover:bg-muted/40 disabled:opacity-50"
>
<p className="text-sm font-semibold">Choose which ones</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Review the list and select exactly which transactions to import.
</p>
</button>
{/* Skip */}
<button
type="button"
onClick={onClose}
disabled={importing}
className="w-full rounded-xl border border-border/60 bg-background p-3.5 text-left transition-colors hover:bg-muted/20 disabled:opacity-50"
>
<p className="text-sm font-semibold">Skip future only</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Only import new transactions going forward. Past history stays as-is.
</p>
</button>
</div>
)}
{!loading && importable.length === 0 && alreadyDone.length > 0 && (
<p className="text-sm text-muted-foreground">
All matching transactions are already linked or have payments. Nothing to import.
</p>
)}
{importing && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Importing
</div>
)}
{(!loading || importing) && (
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={importing}>
Cancel
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}
// ── Pick step ────────────────────────────────────────────────────────────────
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!v) onClose();
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Choose transactions to import</DialogTitle>
<DialogDescription>
Select which past transactions to import as payments for {billName}.
</DialogDescription>
</DialogHeader>
{/* Select all toggle */}
<div className="flex items-center justify-between border-b border-border/50 pb-2">
<button
type="button"
onClick={() => toggleAll(!allSelected)}
className="flex items-center gap-2 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{allSelected ? (
<CheckCircle2 className="h-4 w-4 text-primary" />
) : (
<Circle className="h-4 w-4" />
)}
{allSelected ? 'Deselect all' : 'Select all'}
</button>
<span className="text-xs text-muted-foreground">{selected.size} selected</span>
</div>
{/* Transaction list */}
<div className="max-h-64 overflow-y-auto space-y-1">
{importable.map((c) => (
<button
key={c.id}
type="button"
onClick={() => toggle(c.id)}
className={cn(
'w-full flex items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors',
selected.has(c.id)
? 'border-primary/30 bg-primary/5'
: 'border-border/50 bg-background hover:bg-muted/30',
)}
>
{selected.has(c.id) ? (
<CheckCircle2 className="h-4 w-4 shrink-0 text-primary" />
) : (
<Circle className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{c.payee}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground flex items-center gap-1">
<CalendarDays className="h-3 w-3" />
{fmtDate(c.paid_date)}
</span>
<StatusChip candidate={c} />
</div>
</div>
<span className="shrink-0 font-mono text-sm font-semibold tabular-nums">
{fmt(c.amount)}
</span>
</button>
))}
{/* Already-done items (dimmed, informational) */}
{alreadyDone.length > 0 && (
<div className="pt-2 border-t border-border/40">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/60 px-1 mb-1">
Already handled
</p>
{alreadyDone.map((c) => (
<div
key={c.id}
className="flex items-center gap-3 rounded-lg border border-border/30 bg-muted/10 px-3 py-2 opacity-50"
>
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-500" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{c.payee}</p>
<StatusChip candidate={c} />
</div>
<span className="shrink-0 font-mono text-sm tabular-nums">{fmt(c.amount)}</span>
</div>
))}
</div>
)}
</div>
<DialogFooter className="gap-2">
<Button variant="ghost" onClick={() => setStep('choice')} disabled={importing}>
Back
</Button>
<Button
onClick={() => doImport([...selected])}
disabled={importing || selected.size === 0}
>
{importing ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
Importing
</>
) : (
`Import ${selected.size} payment${selected.size === 1 ? '' : 's'}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}