From ad9fcbd56f5b89b351a4b7c040af13627364acef Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 21:04:43 -0500 Subject: [PATCH] refactor(tracker): shared useQuickPay mutation hook (P3) Quick-pay was duplicated verbatim in TrackerRow and MobileTrackerRow (create payment + Undo toast + refresh, with a manual busy flag on mobile). Extracted into a shared useQuickPay() React Query mutation hook: isPending comes for free (replaces the quickPaySaving state), the tracker/badge caches invalidate on settle, and the flow lives in one place. Behavior identical. (togglePaid keeps its local optimistic flip as-is; other mutations can adopt the same useMutation pattern incrementally.) Co-Authored-By: Claude Opus 4.8 --- .../components/tracker/MobileTrackerRow.jsx | 37 ++++------------ client/components/tracker/TrackerRow.jsx | 27 ++---------- client/hooks/usePaymentActions.js | 42 +++++++++++++++++++ 3 files changed, 54 insertions(+), 52 deletions(-) create mode 100644 client/hooks/usePaymentActions.js diff --git a/client/components/tracker/MobileTrackerRow.jsx b/client/components/tracker/MobileTrackerRow.jsx index 02bbeae..a4b24d4 100644 --- a/client/components/tracker/MobileTrackerRow.jsx +++ b/client/components/tracker/MobileTrackerRow.jsx @@ -12,6 +12,7 @@ import { } from '@/components/ui/alert-dialog'; import PaymentModal from '@/components/tracker/PaymentModal'; import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils'; +import { useQuickPay } from '@/hooks/usePaymentActions'; import { StatusBadge } from '@/components/tracker/StatusBadge'; import { PaymentProgress } from '@/components/tracker/PaymentProgress'; import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton'; @@ -45,7 +46,7 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false); const [confirmUnpay, setConfirmUnpay] = useState(false); const [suggestionLoading, setSuggestionLoading] = useState(false); - const [quickPaySaving, setQuickPaySaving] = useState(false); + const quickPay = useQuickPay(); // Optimistic paid/unpaid flip — instant on tap, rolled back on error, cleared // when fresh server data arrives (effect below). const [optimisticPaid, setOptimisticPaid] = useState(undefined); @@ -74,33 +75,11 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, const remaining = Math.max((threshold || 0) - (row.total_paid || 0), 0); const summary = paymentSummary(row, threshold); - async function handleQuickPay() { - if (quickPaySaving) return; + function handleQuickPay() { + if (quickPay.isPending) return; const val = parseFloat(amountRef.current?.value); if (!val || val <= 0) { toast.error('Enter a payment amount'); return; } - setQuickPaySaving(true); - try { - const payment = await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate }); - toast.success(`${row.name} — ${fmt(val)} paid`, { - action: { - label: 'Undo', - onClick: async () => { - try { - await api.deletePayment(payment.id); - toast.success('Payment removed'); - refresh?.(); - } catch (err) { - toast.error(err.message || 'Failed to undo payment.'); - } - }, - }, - }); - refresh(); - } catch (err) { - toast.error(err.message || 'Failed to add payment.'); - } finally { - setQuickPaySaving(false); - } + quickPay.run(row, val, defaultPaymentDate); } async function performTogglePaid() { @@ -364,15 +343,15 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, className="tracker-number h-8 w-24 text-right text-sm font-medium bg-background/70 border-border/60" title="Payment amount" aria-label={`${row.name} payment amount`} - disabled={quickPaySaving} + disabled={quickPay.isPending} /> )} diff --git a/client/components/tracker/TrackerRow.jsx b/client/components/tracker/TrackerRow.jsx index 47eef48..51fc0c8 100644 --- a/client/components/tracker/TrackerRow.jsx +++ b/client/components/tracker/TrackerRow.jsx @@ -16,6 +16,7 @@ import { import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog'; import PaymentModal from '@/components/tracker/PaymentModal'; import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils'; +import { useQuickPay } from '@/hooks/usePaymentActions'; import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns'; import { StatusBadge } from '@/components/tracker/StatusBadge'; import { PaymentProgress } from '@/components/tracker/PaymentProgress'; @@ -38,6 +39,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC const [showUpdateNudge, setShowUpdateNudge] = useState(false); const [nudgeAmount, setNudgeAmount] = useState(null); const [, startTransition] = useTransition(); + const quickPay = useQuickPay(); const visibleColumnSet = new Set(visibleColumns); const showColumn = key => visibleColumnSet.has(key); @@ -79,31 +81,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || ''); - async function handleQuickPay() { + function handleQuickPay() { const val = parseFloat(amountRef.current?.value); if (!val || val <= 0) { toast.error('Enter a payment amount'); return; } - try { - const payment = await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate }); - // Specific message + Undo, matching the un-pay affordance (quick-pay is - // just as reversible — deleting the payment we just created). - toast.success(`${row.name} — ${fmt(val)} paid`, { - action: { - label: 'Undo', - onClick: async () => { - try { - await api.deletePayment(payment.id); - toast.success('Payment removed'); - refresh?.(); - } catch (err) { - toast.error(err.message || 'Failed to undo payment.'); - } - }, - }, - }); - refresh(); - } catch (err) { - toast.error(err.message || 'Failed to add payment.'); - } + quickPay.run(row, val, defaultPaymentDate); } async function performTogglePaid() { diff --git a/client/hooks/usePaymentActions.js b/client/hooks/usePaymentActions.js new file mode 100644 index 0000000..fa8f617 --- /dev/null +++ b/client/hooks/usePaymentActions.js @@ -0,0 +1,42 @@ +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { api } from '@/api.js'; +import { fmt } from '@/lib/utils'; +import { useInvalidateTrackerData } from '@/hooks/useQueries'; + +// Quick-pay a tracker row (record a payment for `val` on `paidDate`) with a +// reversible Undo toast. A React Query mutation: `isPending` comes for free and +// the tracker/badge caches are invalidated on settle. Shared by the desktop and +// mobile tracker rows so the flow lives in one place. +export function useQuickPay() { + const invalidate = useInvalidateTrackerData(); + const mutation = useMutation({ + mutationFn: (payload) => api.quickPay(payload), + onSettled: () => invalidate(), + }); + + const run = (row, val, paidDate) => mutation.mutate( + { bill_id: row.id, amount: val, paid_date: paidDate }, + { + onSuccess: (payment) => { + toast.success(`${row.name} — ${fmt(val)} paid`, { + action: { + label: 'Undo', + onClick: async () => { + try { + await api.deletePayment(payment.id); + toast.success('Payment removed'); + invalidate(); + } catch (err) { + toast.error(err.message || 'Failed to undo payment.'); + } + }, + }, + }); + }, + onError: (err) => toast.error(err.message || 'Failed to add payment.'), + }, + ); + + return { run, isPending: mutation.isPending }; +}