From a0a8579a081c41c9153b5069fe6d7784e0063512 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 21:18:18 -0500 Subject: [PATCH] =?UTF-8?q?refactor(tracker):=20shared=20useTogglePaid=20m?= =?UTF-8?q?utation=20hook=20=E2=80=94=20finish=20Tracker=20mutations=20(F1?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggle paid/unpaid was duplicated across the desktop and mobile rows (optimistic flip + Undo/paid toasts + refresh). Extracted into a shared useTogglePaid() React Query mutation hook: the caller keeps the instant local optimistic flip, the hook rolls it back on error and invalidates the tracker/badge caches on settle. isPending replaces the desktop row's local loading state (badge spinner + un-pay confirm dialog). With useQuickPay (P3), both core Tracker write actions are now idiomatic useMutation hooks living in one place. Co-Authored-By: Claude Opus 4.8 --- .../components/tracker/MobileTrackerRow.jsx | 37 ++------------ client/components/tracker/TrackerRow.jsx | 49 +++---------------- client/hooks/usePaymentActions.js | 48 ++++++++++++++++++ 3 files changed, 60 insertions(+), 74 deletions(-) diff --git a/client/components/tracker/MobileTrackerRow.jsx b/client/components/tracker/MobileTrackerRow.jsx index a4b24d4..3799874 100644 --- a/client/components/tracker/MobileTrackerRow.jsx +++ b/client/components/tracker/MobileTrackerRow.jsx @@ -12,7 +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 { useQuickPay, useTogglePaid } from '@/hooks/usePaymentActions'; import { StatusBadge } from '@/components/tracker/StatusBadge'; import { PaymentProgress } from '@/components/tracker/PaymentProgress'; import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton'; @@ -47,6 +47,7 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, const [confirmUnpay, setConfirmUnpay] = useState(false); const [suggestionLoading, setSuggestionLoading] = useState(false); const quickPay = useQuickPay(); + const togglePaid = useTogglePaid(year, month); // Optimistic paid/unpaid flip — instant on tap, rolled back on error, cleared // when fresh server data arrives (effect below). const [optimisticPaid, setOptimisticPaid] = useState(undefined); @@ -82,38 +83,8 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, quickPay.run(row, val, defaultPaymentDate); } - async function performTogglePaid() { - const wasPaid = isPaid; - setOptimisticPaid(!wasPaid); // instant flip; effect/rollback reconciles - try { - const result = await api.togglePaid(row.id, { - amount: wasPaid ? undefined : threshold, - year: year, - month: month, - }); - if (wasPaid && result.paymentId) { - toast.success('Payment moved to recovery', { - action: { - label: 'Undo', - onClick: async () => { - try { - await api.restorePayment(result.paymentId); - toast.success('Payment restored'); - refresh(); - } catch (err) { - toast.error(err.message || 'Failed to restore payment'); - } - }, - }, - }); - } else if (!wasPaid) { - toast.success(`${row.name} — ${fmt(threshold)} paid`); - } - refresh(); - } catch (err) { - setOptimisticPaid(undefined); // roll back the instant flip - toast.error(err.message || 'Failed to toggle payment status'); - } + function performTogglePaid() { + togglePaid.run(row, { wasPaid: isPaid, threshold, setOptimisticPaid }); } function handleTogglePaid() { diff --git a/client/components/tracker/TrackerRow.jsx b/client/components/tracker/TrackerRow.jsx index 51fc0c8..fed866c 100644 --- a/client/components/tracker/TrackerRow.jsx +++ b/client/components/tracker/TrackerRow.jsx @@ -16,7 +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 { useQuickPay, useTogglePaid } from '@/hooks/usePaymentActions'; import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns'; import { StatusBadge } from '@/components/tracker/StatusBadge'; import { PaymentProgress } from '@/components/tracker/PaymentProgress'; @@ -30,7 +30,6 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false); const [showMbs, setShowMbs] = useState(false); const [confirmUnpay, setConfirmUnpay] = useState(false); - const [loading, setLoading] = useState(false); const [suggestionLoading, setSuggestionLoading] = useState(false); const [optimisticActual, setOptimisticActual] = useState(undefined); // Optimistic paid/unpaid flip: the row updates instantly on pay/skip and rolls @@ -40,6 +39,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC const [nudgeAmount, setNudgeAmount] = useState(null); const [, startTransition] = useTransition(); const quickPay = useQuickPay(); + const togglePaid = useTogglePaid(year, month); const visibleColumnSet = new Set(visibleColumns); const showColumn = key => visibleColumnSet.has(key); @@ -87,41 +87,8 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC quickPay.run(row, val, defaultPaymentDate); } - async function performTogglePaid() { - const wasPaid = isPaid; - setOptimisticPaid(!wasPaid); // instant flip; effect/rollback reconciles - setLoading?.(true); - try { - const result = await api.togglePaid(row.id, { - amount: wasPaid ? undefined : threshold, - year: year, - month: month, - }); - if (wasPaid && result.paymentId) { - toast.success('Payment moved to recovery', { - action: { - label: 'Undo', - onClick: async () => { - try { - await api.restorePayment(result.paymentId); - toast.success('Payment restored'); - refresh?.(); - } catch (err) { - toast.error(err.message || 'Failed to restore payment'); - } - }, - }, - }); - } else if (!wasPaid) { - toast.success(`${row.name} — ${fmt(threshold)} paid`); - } - refresh?.(); - } catch (err) { - setOptimisticPaid(undefined); // roll back the instant flip - toast.error(err.message || 'Failed to toggle payment status'); - } finally { - setLoading?.(false); - } + function performTogglePaid() { + togglePaid.run(row, { wasPaid: isPaid, threshold, setOptimisticPaid }); } function handleTogglePaid() { @@ -629,7 +596,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC if (effectiveStatus === 'skipped') return; handleTogglePaid(); }} - loading={loading} + loading={togglePaid.isPending} /> )} @@ -743,13 +710,13 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC - Cancel + Cancel - {loading ? 'Removing...' : 'Remove Payment'} + {togglePaid.isPending ? 'Removing...' : 'Remove Payment'} diff --git a/client/hooks/usePaymentActions.js b/client/hooks/usePaymentActions.js index fa8f617..575ff29 100644 --- a/client/hooks/usePaymentActions.js +++ b/client/hooks/usePaymentActions.js @@ -40,3 +40,51 @@ export function useQuickPay() { return { run, isPending: mutation.isPending }; } + +// Toggle a tracker row paid/unpaid. The caller owns the instant optimistic flip +// (its local `setOptimisticPaid`) so the row updates immediately; this hook rolls +// it back on error, invalidates the tracker/badge caches on settle, and shows the +// right toast (an Undo when un-paying, a specific "paid" message otherwise). +// Shared by the desktop and mobile tracker rows. +export function useTogglePaid(year, month) { + const invalidate = useInvalidateTrackerData(); + const mutation = useMutation({ + mutationFn: ({ rowId, amount }) => api.togglePaid(rowId, { amount, year, month }), + onSettled: () => invalidate(), + }); + + const run = (row, { wasPaid, threshold, setOptimisticPaid }) => { + setOptimisticPaid(!wasPaid); // instant flip; effect/rollback reconciles + mutation.mutate( + { rowId: row.id, amount: wasPaid ? undefined : threshold }, + { + onSuccess: (result) => { + if (wasPaid && result.paymentId) { + toast.success('Payment moved to recovery', { + action: { + label: 'Undo', + onClick: async () => { + try { + await api.restorePayment(result.paymentId); + toast.success('Payment restored'); + invalidate(); + } catch (err) { + toast.error(err.message || 'Failed to restore payment'); + } + }, + }, + }); + } else if (!wasPaid) { + toast.success(`${row.name} — ${fmt(threshold)} paid`); + } + }, + onError: (err) => { + setOptimisticPaid(undefined); // roll back the instant flip + toast.error(err.message || 'Failed to toggle payment status'); + }, + }, + ); + }; + + return { run, isPending: mutation.isPending }; +}