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)} -

-
- )} - -
- - -
- - - - -
-
- - {/* ── 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: - - - - {bulkUnmatch.checkedIds.size} of {bulkUnmatch.similar.length} selected - -
- -
- {bulkUnmatch.similar.map(tx => ( - - ))} -
- - {bulkUnmatch.rules.length > 0 && ( -
-

- Merchant rules -

- {bulkUnmatch.rules.map(rule => ( - - ))} -
- )} - - )} - - - - - - -
-
+ ); 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)} +

+
+ )} + +
+ + +
+ + + + +
+
+ + {/* ── 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: + + + + {bulkUnmatch.checkedIds.size} of {bulkUnmatch.similar.length} selected + +
+ +
+ {bulkUnmatch.similar.map(tx => ( + + ))} +
+ + {bulkUnmatch.rules.length > 0 && ( +
+

+ Merchant rules +

+ {bulkUnmatch.rules.map(rule => ( + + ))} +
+ )} + + )} + + + + + + +
+
+ + ); +} 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); +}