diff --git a/client/components/BillModal.jsx b/client/components/BillModal.jsx index 037785b..9837b1c 100644 --- a/client/components/BillModal.jsx +++ b/client/components/BillModal.jsx @@ -1,6 +1,6 @@ import { useActionState, useEffect, useState } from 'react'; -import { Copy, Layers, Link2, Link2Off, Loader2, RefreshCw } from 'lucide-react'; -import { formatCentsUSD, validateNonNegativeMoney } from '@/lib/money'; +import { Copy, Link2, Link2Off, Loader2, RefreshCw } from 'lucide-react'; +import { validateNonNegativeMoney } from '@/lib/money'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -8,7 +8,6 @@ import { Label } from '@/components/ui/label'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; -import { Checkbox } from '@/components/ui/checkbox'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, @@ -23,6 +22,8 @@ import DebtDetailsSection from '@/components/bill-modal/DebtDetailsSection'; import AutopayTrustIndicator from '@/components/bill-modal/AutopayTrustIndicator'; import PaymentHistoryList from '@/components/bill-modal/PaymentHistoryList'; 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 { BILLING_SCHEDULE_OPTIONS, billingCycleForSchedule, @@ -57,18 +58,6 @@ const SUBSCRIPTION_TYPES = [ ['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) { if (!catId || catId === CAT_NONE) return false; 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; } -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 }) { const isNew = !bill; const sourceBill = bill || initialBill || null; @@ -1198,234 +1175,19 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa - {/* ── Unmatch choice dialog ─────────────────────────────────── */} - { if (!open) closeUnmatch(); }} - > - - - Unmatch transaction - - How would you like to proceed? - - - - {unmatchTarget && ( - - {transactionTitle(unmatchTarget)} - - {transactionDate(unmatchTarget) ? fmtDate(transactionDate(unmatchTarget)) : 'No date'} - {' · '} - {fmtTransactionAmount(unmatchTarget.amount, unmatchTarget.currency)} - - - )} - - - 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" - > - - - Unmatch this payment only - Remove just this one transaction from the bill. - - - - - - Review all similar matches - - See all transactions with a similar payee name and manage them together. Optionally remove a merchant rule too. - - - - - - - - Cancel - - - - - - {/* ── Single unmatch confirm ────────────────────────────────── */} - { if (!open) setUnmatchConfirmOpen(false); }} - > - - - Unmatch this transaction? - - {unmatchTarget && ( - <> - {transactionTitle(unmatchTarget)} - {' '}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.'} - > - )} - - - - setUnmatchConfirmOpen(false)}> - Cancel - - - {transactionBusyId ? 'Unmatching…' : 'Confirm Unmatch'} - - - - - - {/* ── Bulk unmatch dialog ───────────────────────────────────── */} - { if (!open) closeUnmatch(); }} - > - - - Review similar matches - {unmatchTarget && ( - - Transactions similar to {transactionTitle(unmatchTarget)}. - Uncheck any you want to keep matched. - - )} - - - {bulkUnmatch && ( - <> - - Quick select: - setBulkUnmatch(p => ({ ...p, checkedIds: new Set(p.similar.map(t => t.id)) }))} - > - All - - setBulkUnmatch(p => ({ ...p, checkedIds: new Set() }))} - > - None - - - {bulkUnmatch.checkedIds.size} of {bulkUnmatch.similar.length} selected - - - - - {bulkUnmatch.similar.map(tx => ( - - { - setBulkUnmatch(p => { - const next = new Set(p.checkedIds); - checked ? next.add(tx.id) : next.delete(tx.id); - return { ...p, checkedIds: next }; - }); - }} - /> - - {transactionTitle(tx)} - - {transactionDate(tx) ? fmtDate(transactionDate(tx)) : 'No date'} - {tx.account_name && ` · ${tx.account_name}`} - - - - {fmtTransactionAmount(tx.amount, tx.currency)} - - - ))} - - - {bulkUnmatch.rules.length > 0 && ( - - - Merchant rules - - {bulkUnmatch.rules.map(rule => ( - - - setBulkUnmatch(p => ({ ...p, removeRuleId: checked ? rule.id : null })) - } - /> - - - Remove rule "{rule.merchant}" - - - Future syncs won't auto-match this pattern to this bill. - - - - ))} - - )} - > - )} - - - - Cancel - - setBulkUnmatch(null)} - className="text-xs" - > - Back - - - {bulkBusy - ? 'Unmatching…' - : `Unmatch ${bulkUnmatch?.checkedIds?.size ?? 0} transaction${bulkUnmatch?.checkedIds?.size !== 1 ? 's' : ''}`} - - - - + ); diff --git a/client/components/bill-modal/UnmatchDialogs.jsx b/client/components/bill-modal/UnmatchDialogs.jsx new file mode 100644 index 0000000..1945f1d --- /dev/null +++ b/client/components/bill-modal/UnmatchDialogs.jsx @@ -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 ─────────────────────────────────── */} + { if (!open) closeUnmatch(); }} + > + + + Unmatch transaction + + How would you like to proceed? + + + + {unmatchTarget && ( + + {transactionTitle(unmatchTarget)} + + {transactionDate(unmatchTarget) ? fmtDate(transactionDate(unmatchTarget)) : 'No date'} + {' · '} + {fmtTransactionAmount(unmatchTarget.amount, unmatchTarget.currency)} + + + )} + + + 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" + > + + + Unmatch this payment only + Remove just this one transaction from the bill. + + + + + + Review all similar matches + + See all transactions with a similar payee name and manage them together. Optionally remove a merchant rule too. + + + + + + + + Cancel + + + + + + {/* ── Single unmatch confirm ────────────────────────────────── */} + { if (!open) setUnmatchConfirmOpen(false); }} + > + + + Unmatch this transaction? + + {unmatchTarget && ( + <> + {transactionTitle(unmatchTarget)} + {' '}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.'} + > + )} + + + + setUnmatchConfirmOpen(false)}> + Cancel + + + {transactionBusyId ? 'Unmatching…' : 'Confirm Unmatch'} + + + + + + {/* ── Bulk unmatch dialog ───────────────────────────────────── */} + { if (!open) closeUnmatch(); }} + > + + + Review similar matches + {unmatchTarget && ( + + Transactions similar to {transactionTitle(unmatchTarget)}. + Uncheck any you want to keep matched. + + )} + + + {bulkUnmatch && ( + <> + + Quick select: + setBulkUnmatch(p => ({ ...p, checkedIds: new Set(p.similar.map(t => t.id)) }))} + > + All + + setBulkUnmatch(p => ({ ...p, checkedIds: new Set() }))} + > + None + + + {bulkUnmatch.checkedIds.size} of {bulkUnmatch.similar.length} selected + + + + + {bulkUnmatch.similar.map(tx => ( + + { + setBulkUnmatch(p => { + const next = new Set(p.checkedIds); + checked ? next.add(tx.id) : next.delete(tx.id); + return { ...p, checkedIds: next }; + }); + }} + /> + + {transactionTitle(tx)} + + {transactionDate(tx) ? fmtDate(transactionDate(tx)) : 'No date'} + {tx.account_name && ` · ${tx.account_name}`} + + + + {fmtTransactionAmount(tx.amount, tx.currency)} + + + ))} + + + {bulkUnmatch.rules.length > 0 && ( + + + Merchant rules + + {bulkUnmatch.rules.map(rule => ( + + + setBulkUnmatch(p => ({ ...p, removeRuleId: checked ? rule.id : null })) + } + /> + + + Remove rule "{rule.merchant}" + + + Future syncs won't auto-match this pattern to this bill. + + + + ))} + + )} + > + )} + + + + Cancel + + setBulkUnmatch(null)} + className="text-xs" + > + Back + + + {bulkBusy + ? 'Unmatching…' + : `Unmatch ${bulkUnmatch?.checkedIds?.size ?? 0} transaction${bulkUnmatch?.checkedIds?.size !== 1 ? 's' : ''}`} + + + + + > + ); +} diff --git a/client/components/bill-modal/transactionDisplay.js b/client/components/bill-modal/transactionDisplay.js new file mode 100644 index 0000000..9c24b92 --- /dev/null +++ b/client/components/bill-modal/transactionDisplay.js @@ -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); +}
{transactionTitle(unmatchTarget)}
- {transactionDate(unmatchTarget) ? fmtDate(transactionDate(unmatchTarget)) : 'No date'} - {' · '} - {fmtTransactionAmount(unmatchTarget.amount, unmatchTarget.currency)} -
Unmatch this payment only
Remove just this one transaction from the bill.
Review all similar matches
- See all transactions with a similar payee name and manage them together. Optionally remove a merchant rule too. -
{transactionTitle(tx)}
- {transactionDate(tx) ? fmtDate(transactionDate(tx)) : 'No date'} - {tx.account_name && ` · ${tx.account_name}`} -
- {fmtTransactionAmount(tx.amount, tx.currency)} -
- Merchant rules -
- Remove rule "{rule.merchant}" -
- Future syncs won't auto-match this pattern to this bill. -
+ {transactionDate(unmatchTarget) ? fmtDate(transactionDate(unmatchTarget)) : 'No date'} + {' · '} + {fmtTransactionAmount(unmatchTarget.amount, unmatchTarget.currency)} +
+ See all transactions with a similar payee name and manage them together. Optionally remove a merchant rule too. +
+ {transactionDate(tx) ? fmtDate(transactionDate(tx)) : 'No date'} + {tx.account_name && ` · ${tx.account_name}`} +
+ {fmtTransactionAmount(tx.amount, tx.currency)} +
+ Merchant rules +
+ Remove rule "{rule.merchant}" +
+ Future syncs won't auto-match this pattern to this bill. +