refactor(tracker): shared useTogglePaid mutation hook — finish Tracker mutations (F1)

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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 21:18:18 -05:00
parent c86acd7d75
commit a0a8579a08
3 changed files with 60 additions and 74 deletions

View File

@ -12,7 +12,7 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import PaymentModal from '@/components/tracker/PaymentModal'; import PaymentModal from '@/components/tracker/PaymentModal';
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils'; 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 { StatusBadge } from '@/components/tracker/StatusBadge';
import { PaymentProgress } from '@/components/tracker/PaymentProgress'; import { PaymentProgress } from '@/components/tracker/PaymentProgress';
import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton'; 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 [confirmUnpay, setConfirmUnpay] = useState(false);
const [suggestionLoading, setSuggestionLoading] = useState(false); const [suggestionLoading, setSuggestionLoading] = useState(false);
const quickPay = useQuickPay(); const quickPay = useQuickPay();
const togglePaid = useTogglePaid(year, month);
// Optimistic paid/unpaid flip instant on tap, rolled back on error, cleared // Optimistic paid/unpaid flip instant on tap, rolled back on error, cleared
// when fresh server data arrives (effect below). // when fresh server data arrives (effect below).
const [optimisticPaid, setOptimisticPaid] = useState(undefined); const [optimisticPaid, setOptimisticPaid] = useState(undefined);
@ -82,38 +83,8 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
quickPay.run(row, val, defaultPaymentDate); quickPay.run(row, val, defaultPaymentDate);
} }
async function performTogglePaid() { function performTogglePaid() {
const wasPaid = isPaid; togglePaid.run(row, { wasPaid: isPaid, threshold, setOptimisticPaid });
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 handleTogglePaid() { function handleTogglePaid() {

View File

@ -16,7 +16,7 @@ import {
import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog'; import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
import PaymentModal from '@/components/tracker/PaymentModal'; import PaymentModal from '@/components/tracker/PaymentModal';
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils'; 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 { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns';
import { StatusBadge } from '@/components/tracker/StatusBadge'; import { StatusBadge } from '@/components/tracker/StatusBadge';
import { PaymentProgress } from '@/components/tracker/PaymentProgress'; 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 [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
const [showMbs, setShowMbs] = useState(false); const [showMbs, setShowMbs] = useState(false);
const [confirmUnpay, setConfirmUnpay] = useState(false); const [confirmUnpay, setConfirmUnpay] = useState(false);
const [loading, setLoading] = useState(false);
const [suggestionLoading, setSuggestionLoading] = useState(false); const [suggestionLoading, setSuggestionLoading] = useState(false);
const [optimisticActual, setOptimisticActual] = useState(undefined); const [optimisticActual, setOptimisticActual] = useState(undefined);
// Optimistic paid/unpaid flip: the row updates instantly on pay/skip and rolls // 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 [nudgeAmount, setNudgeAmount] = useState(null);
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const quickPay = useQuickPay(); const quickPay = useQuickPay();
const togglePaid = useTogglePaid(year, month);
const visibleColumnSet = new Set(visibleColumns); const visibleColumnSet = new Set(visibleColumns);
const showColumn = key => visibleColumnSet.has(key); 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); quickPay.run(row, val, defaultPaymentDate);
} }
async function performTogglePaid() { function performTogglePaid() {
const wasPaid = isPaid; togglePaid.run(row, { wasPaid: isPaid, threshold, setOptimisticPaid });
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 handleTogglePaid() { function handleTogglePaid() {
@ -629,7 +596,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
if (effectiveStatus === 'skipped') return; if (effectiveStatus === 'skipped') return;
handleTogglePaid(); handleTogglePaid();
}} }}
loading={loading} loading={togglePaid.isPending}
/> />
)} )}
</div> </div>
@ -743,13 +710,13 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel> <AlertDialogCancel disabled={togglePaid.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={loading} disabled={togglePaid.isPending}
onClick={performTogglePaid} onClick={performTogglePaid}
> >
{loading ? 'Removing...' : 'Remove Payment'} {togglePaid.isPending ? 'Removing...' : 'Remove Payment'}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View File

@ -40,3 +40,51 @@ export function useQuickPay() {
return { run, isPending: mutation.isPending }; 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 };
}