refactor(bill-modal): extract UnmatchDialogs + shared transactionDisplay (BM2, 5/n)
The three unmatch flows (choice dialog, single-unmatch confirm, bulk review with optional merchant-rule removal) move to UnmatchDialogs; the transaction display/ matching helpers (transactionTitle/Date, fmtTransactionAmount, isSimilarPayee) move to a shared transactionDisplay.js used by both the parent and the dialogs. Dropped now-unused imports (Layers, Checkbox, formatCentsUSD). Behavior- preserving — BillModal 1432 -> 1193 lines; build + client tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c61e3d84a5
commit
301bb152ef
|
|
@ -1,6 +1,6 @@
|
||||||
import { useActionState, useEffect, useState } from 'react';
|
import { useActionState, useEffect, useState } from 'react';
|
||||||
import { Copy, Layers, Link2, Link2Off, Loader2, RefreshCw } from 'lucide-react';
|
import { Copy, Link2, Link2Off, Loader2, RefreshCw } from 'lucide-react';
|
||||||
import { formatCentsUSD, validateNonNegativeMoney } from '@/lib/money';
|
import { validateNonNegativeMoney } from '@/lib/money';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
@ -8,7 +8,6 @@ import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter,
|
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import {
|
import {
|
||||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||||
|
|
@ -23,6 +22,8 @@ import DebtDetailsSection from '@/components/bill-modal/DebtDetailsSection';
|
||||||
import AutopayTrustIndicator from '@/components/bill-modal/AutopayTrustIndicator';
|
import AutopayTrustIndicator from '@/components/bill-modal/AutopayTrustIndicator';
|
||||||
import PaymentHistoryList from '@/components/bill-modal/PaymentHistoryList';
|
import PaymentHistoryList from '@/components/bill-modal/PaymentHistoryList';
|
||||||
import PaymentFormFields from '@/components/bill-modal/PaymentFormFields';
|
import PaymentFormFields from '@/components/bill-modal/PaymentFormFields';
|
||||||
|
import UnmatchDialogs from '@/components/bill-modal/UnmatchDialogs';
|
||||||
|
import { transactionTitle, transactionDate, fmtTransactionAmount, isSimilarPayee } from '@/components/bill-modal/transactionDisplay';
|
||||||
import {
|
import {
|
||||||
BILLING_SCHEDULE_OPTIONS,
|
BILLING_SCHEDULE_OPTIONS,
|
||||||
billingCycleForSchedule,
|
billingCycleForSchedule,
|
||||||
|
|
@ -57,18 +58,6 @@ const SUBSCRIPTION_TYPES = [
|
||||||
['other', 'Other'],
|
['other', 'Other'],
|
||||||
];
|
];
|
||||||
|
|
||||||
function fmtTransactionAmount(amount, currency = 'USD') {
|
|
||||||
return formatCentsUSD(amount, { signed: true, currency });
|
|
||||||
}
|
|
||||||
|
|
||||||
function transactionDate(tx) {
|
|
||||||
return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function transactionTitle(tx) {
|
|
||||||
return tx?.payee || tx?.description || tx?.memo || 'Transaction';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDebtCat(categories, catId) {
|
function isDebtCat(categories, catId) {
|
||||||
if (!catId || catId === CAT_NONE) return false;
|
if (!catId || catId === CAT_NONE) return false;
|
||||||
const cat = categories.find(c => String(c.id) === catId);
|
const cat = categories.find(c => String(c.id) === catId);
|
||||||
|
|
@ -80,18 +69,6 @@ function isSnowballCat(categories, catId) {
|
||||||
return cat ? SNOWBALL_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false;
|
return cat ? SNOWBALL_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePayee(s) {
|
|
||||||
return (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSimilarPayee(a, b) {
|
|
||||||
const na = normalizePayee(a);
|
|
||||||
const nb = normalizePayee(b);
|
|
||||||
const minLen = Math.min(na.length, nb.length);
|
|
||||||
if (minLen < 3) return false;
|
|
||||||
return na.startsWith(nb) || nb.startsWith(na);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BillModal({ bill, initialBill, categories, onClose, onSave, onDuplicate }) {
|
export default function BillModal({ bill, initialBill, categories, onClose, onSave, onDuplicate }) {
|
||||||
const isNew = !bill;
|
const isNew = !bill;
|
||||||
const sourceBill = bill || initialBill || null;
|
const sourceBill = bill || initialBill || null;
|
||||||
|
|
@ -1198,234 +1175,19 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* ── Unmatch choice dialog ─────────────────────────────────── */}
|
<UnmatchDialogs
|
||||||
<Dialog
|
unmatchTarget={unmatchTarget}
|
||||||
open={!!unmatchTarget && !bulkUnmatch}
|
bulkUnmatch={bulkUnmatch}
|
||||||
onOpenChange={open => { if (!open) closeUnmatch(); }}
|
setBulkUnmatch={setBulkUnmatch}
|
||||||
>
|
unmatchConfirmOpen={unmatchConfirmOpen}
|
||||||
<DialogContent className="sm:max-w-md border-border/60 bg-card/95 backdrop-blur-xl">
|
setUnmatchConfirmOpen={setUnmatchConfirmOpen}
|
||||||
<DialogHeader>
|
transactionBusyId={transactionBusyId}
|
||||||
<DialogTitle className="text-base font-semibold">Unmatch transaction</DialogTitle>
|
bulkBusy={bulkBusy}
|
||||||
<DialogDescription className="text-sm text-muted-foreground">
|
closeUnmatch={closeUnmatch}
|
||||||
How would you like to proceed?
|
onSingleUnmatch={handleSingleUnmatch}
|
||||||
</DialogDescription>
|
onOpenBulkUnmatch={handleOpenBulkUnmatch}
|
||||||
</DialogHeader>
|
onBulkConfirm={handleBulkConfirm}
|
||||||
|
/>
|
||||||
{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={handleOpenBulkUnmatch}
|
|
||||||
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={handleSingleUnmatch}
|
|
||||||
>
|
|
||||||
{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={handleBulkConfirm}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{bulkBusy
|
|
||||||
? 'Unmatching…'
|
|
||||||
: `Unmatch ${bulkUnmatch?.checkedIds?.size ?? 0} transaction${bulkUnmatch?.checkedIds?.size !== 1 ? 's' : ''}`}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,261 @@
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { formatCentsUSD } from '@/lib/money';
|
||||||
|
|
||||||
|
// Display + matching helpers for bank transactions in the bill modal. Shared by
|
||||||
|
// the linked-transaction list, the unmatch dialogs, and the bulk-unmatch payee
|
||||||
|
// grouping.
|
||||||
|
|
||||||
|
export function fmtTransactionAmount(amount, currency = 'USD') {
|
||||||
|
return formatCentsUSD(amount, { signed: true, currency });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transactionDate(tx) {
|
||||||
|
return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transactionTitle(tx) {
|
||||||
|
return tx?.payee || tx?.description || tx?.memo || 'Transaction';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePayee(s) {
|
||||||
|
return (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two payees are "similar" when one normalized name is a prefix of the other
|
||||||
|
// (min 3 chars) — the grouping used to find related matches to unmatch together.
|
||||||
|
export function isSimilarPayee(a, b) {
|
||||||
|
const na = normalizePayee(a);
|
||||||
|
const nb = normalizePayee(b);
|
||||||
|
const minLen = Math.min(na.length, nb.length);
|
||||||
|
if (minLen < 3) return false;
|
||||||
|
return na.startsWith(nb) || nb.startsWith(na);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue