BillTracker/client/components/bill-modal/UnmatchDialogs.jsx

262 lines
12 KiB
JavaScript

import { Link2Off, Layers } from 'lucide-react';
import { cn, fmtDate } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog';
import {
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from '@/components/ui/alert-dialog';
import { transactionTitle, transactionDate, fmtTransactionAmount } from '@/components/bill-modal/transactionDisplay';
// The three unmatch flows for a linked bank transaction: the choice dialog
// (single vs. review-similar), the single-unmatch confirm, and the bulk review
// dialog (with optional merchant-rule removal). Presentational — the parent owns
// the unmatch state and the confirm/bulk handlers.
export default function UnmatchDialogs({
unmatchTarget,
bulkUnmatch, setBulkUnmatch,
unmatchConfirmOpen, setUnmatchConfirmOpen,
transactionBusyId,
bulkBusy,
closeUnmatch,
onSingleUnmatch,
onOpenBulkUnmatch,
onBulkConfirm,
}) {
return (
<>
{/* ── Unmatch choice dialog ─────────────────────────────────── */}
<Dialog
open={!!unmatchTarget && !bulkUnmatch}
onOpenChange={open => { if (!open) closeUnmatch(); }}
>
<DialogContent className="sm:max-w-md border-border/60 bg-card/95 backdrop-blur-xl">
<DialogHeader>
<DialogTitle className="text-base font-semibold">Unmatch transaction</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
How would you like to proceed?
</DialogDescription>
</DialogHeader>
{unmatchTarget && (
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2.5">
<p className="text-sm font-medium text-foreground">{transactionTitle(unmatchTarget)}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{transactionDate(unmatchTarget) ? fmtDate(transactionDate(unmatchTarget)) : 'No date'}
{' · '}
<span className="font-mono">{fmtTransactionAmount(unmatchTarget.amount, unmatchTarget.currency)}</span>
</p>
</div>
)}
<div className="grid gap-2.5">
<button
type="button"
onClick={() => setUnmatchConfirmOpen(true)}
className="flex items-start gap-3 rounded-lg border border-border/60 bg-background/50 p-3 text-left transition-colors hover:border-primary/40 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Link2Off className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Unmatch this payment only</p>
<p className="mt-0.5 text-xs text-muted-foreground">Remove just this one transaction from the bill.</p>
</div>
</button>
<button
type="button"
onClick={onOpenBulkUnmatch}
className="flex items-start gap-3 rounded-lg border border-border/60 bg-background/50 p-3 text-left transition-colors hover:border-primary/40 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Layers className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Review all similar matches</p>
<p className="mt-0.5 text-xs text-muted-foreground">
See all transactions with a similar payee name and manage them together. Optionally remove a merchant rule too.
</p>
</div>
</button>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={closeUnmatch} className="text-xs">
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Single unmatch confirm ────────────────────────────────── */}
<AlertDialog
open={!!unmatchTarget && unmatchConfirmOpen}
onOpenChange={open => { if (!open) setUnmatchConfirmOpen(false); }}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unmatch this transaction?</AlertDialogTitle>
<AlertDialogDescription>
{unmatchTarget && (
<>
<span className="font-medium text-foreground">{transactionTitle(unmatchTarget)}</span>
{' '}will be unlinked from this bill.
{unmatchTarget.linked_payment?.payment_source === 'provider_sync'
? ' The payment record will be removed and the balance restored.'
: ' The payment record will be removed.'}
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={!!transactionBusyId} onClick={() => setUnmatchConfirmOpen(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={!!transactionBusyId}
onClick={onSingleUnmatch}
>
{transactionBusyId ? 'Unmatching…' : 'Confirm Unmatch'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ── Bulk unmatch dialog ───────────────────────────────────── */}
<Dialog
open={!!unmatchTarget && !!bulkUnmatch}
onOpenChange={open => { if (!open) closeUnmatch(); }}
>
<DialogContent className="sm:max-w-lg border-border/60 bg-card/95 backdrop-blur-xl">
<DialogHeader>
<DialogTitle className="text-base font-semibold">Review similar matches</DialogTitle>
{unmatchTarget && (
<DialogDescription className="text-sm text-muted-foreground">
Transactions similar to <span className="font-medium text-foreground">{transactionTitle(unmatchTarget)}</span>.
Uncheck any you want to keep matched.
</DialogDescription>
)}
</DialogHeader>
{bulkUnmatch && (
<>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Quick select:</span>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 px-2 text-xs"
onClick={() => setBulkUnmatch(p => ({ ...p, checkedIds: new Set(p.similar.map(t => t.id)) }))}
>
All
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 px-2 text-xs"
onClick={() => setBulkUnmatch(p => ({ ...p, checkedIds: new Set() }))}
>
None
</Button>
<span className="ml-auto text-xs text-muted-foreground">
{bulkUnmatch.checkedIds.size} of {bulkUnmatch.similar.length} selected
</span>
</div>
<div className="max-h-60 space-y-2 overflow-y-auto pr-1">
{bulkUnmatch.similar.map(tx => (
<label
key={tx.id}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 transition-colors',
bulkUnmatch.checkedIds.has(tx.id)
? 'border-primary/30 bg-primary/5'
: 'border-border/60 bg-background/35 opacity-60',
)}
>
<Checkbox
checked={bulkUnmatch.checkedIds.has(tx.id)}
onCheckedChange={checked => {
setBulkUnmatch(p => {
const next = new Set(p.checkedIds);
checked ? next.add(tx.id) : next.delete(tx.id);
return { ...p, checkedIds: next };
});
}}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{transactionTitle(tx)}</p>
<p className="text-xs text-muted-foreground">
{transactionDate(tx) ? fmtDate(transactionDate(tx)) : 'No date'}
{tx.account_name && ` · ${tx.account_name}`}
</p>
</div>
<p className={cn(
'shrink-0 font-mono text-sm font-semibold tabular-nums',
Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
)}>
{fmtTransactionAmount(tx.amount, tx.currency)}
</p>
</label>
))}
</div>
{bulkUnmatch.rules.length > 0 && (
<div className="rounded-lg border border-amber-500/25 bg-amber-500/5 px-3 py-2.5">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400">
Merchant rules
</p>
{bulkUnmatch.rules.map(rule => (
<label key={rule.id} className="flex cursor-pointer items-start gap-2.5">
<Checkbox
className="mt-0.5"
checked={bulkUnmatch.removeRuleId === rule.id}
onCheckedChange={checked =>
setBulkUnmatch(p => ({ ...p, removeRuleId: checked ? rule.id : null }))
}
/>
<div>
<p className="text-sm">
Remove rule <span className="font-mono font-semibold">"{rule.merchant}"</span>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Future syncs won't auto-match this pattern to this bill.
</p>
</div>
</label>
))}
</div>
)}
</>
)}
<DialogFooter className="gap-2">
<Button type="button" variant="ghost" onClick={closeUnmatch} className="text-xs">
Cancel
</Button>
<Button
type="button"
variant="outline"
onClick={() => setBulkUnmatch(null)}
className="text-xs"
>
Back
</Button>
<Button
type="button"
disabled={bulkBusy || !bulkUnmatch || bulkUnmatch.checkedIds.size === 0}
onClick={onBulkConfirm}
className="text-xs"
>
{bulkBusy
? 'Unmatching'
: `Unmatch ${bulkUnmatch?.checkedIds?.size ?? 0} transaction${bulkUnmatch?.checkedIds?.size !== 1 ? 's' : ''}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}