2026-06-04 02:05:15 -05:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect } from 'react';
|
|
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
import { CheckCircle2, Circle, Loader2, AlertTriangle, CalendarDays } from 'lucide-react';
|
|
|
|
|
import { api } from '@/api';
|
|
|
|
|
import { cn, fmt, fmtDate } from '@/lib/utils';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import {
|
|
|
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
|
|
|
|
} from '@/components/ui/dialog';
|
|
|
|
|
|
|
|
|
|
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 },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function StatusChip({ candidate }) {
|
|
|
|
|
const meta = STATUS_META[candidate.status] ?? 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 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export default function BillHistoricalImportDialog({ billId, billName, open, onClose, onImported }) {
|
|
|
|
|
const [step, setStep] = useState('choice'); // 'choice' | 'pick'
|
|
|
|
|
const [candidates, setCandidates] = useState([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [selected, setSelected] = useState(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(data => {
|
|
|
|
|
// Pre-select importable candidates (not already a payment for this bill)
|
|
|
|
|
const importable = (data.candidates || []).filter(c => c.status !== 'payment_exists' && c.status !== 'matched_this_bill');
|
|
|
|
|
setCandidates(data.candidates || []);
|
|
|
|
|
setSelected(new Set(importable.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) {
|
|
|
|
|
if (ids.length === 0) { onClose(); return; }
|
|
|
|
|
setImporting(true);
|
|
|
|
|
try {
|
|
|
|
|
const result = await api.importHistoricalPayments(billId, ids);
|
|
|
|
|
toast.success(`${result.imported} payment${result.imported === 1 ? '' : 's'} imported for ${billName}`);
|
|
|
|
|
onImported?.(result);
|
|
|
|
|
onClose();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err.message || 'Import failed');
|
|
|
|
|
} finally {
|
|
|
|
|
setImporting(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleAll(checked) {
|
|
|
|
|
setSelected(checked ? new Set(importable.map(c => c.id)) : new Set());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggle(id) {
|
|
|
|
|
setSelected(prev => {
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
next.has(id) ? next.delete(id) : 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>
|
2026-06-06 23:17:08 -05:00
|
|
|
<DialogTitle>
|
|
|
|
|
{!loading && importable.length === 0 && alreadyDone.length === 0
|
|
|
|
|
? 'No past payments found'
|
|
|
|
|
: !loading && importable.length === 0
|
|
|
|
|
? 'Already up to date'
|
|
|
|
|
: 'Import past payments'}
|
|
|
|
|
</DialogTitle>
|
2026-06-04 02:05:15 -05:00
|
|
|
<DialogDescription>
|
|
|
|
|
{loading
|
|
|
|
|
? 'Searching your bank history…'
|
2026-06-06 23:17:08 -05:00
|
|
|
: 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}.`
|
2026-06-04 02:05:15 -05:00
|
|
|
: `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>
|
|
|
|
|
);
|
|
|
|
|
}
|