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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 21:04:43 -05:00
parent e941f05cd6
commit ad9fcbd56f
3 changed files with 54 additions and 52 deletions

View File

@ -12,6 +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 { 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';
@ -45,7 +46,7 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false); const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
const [confirmUnpay, setConfirmUnpay] = useState(false); const [confirmUnpay, setConfirmUnpay] = useState(false);
const [suggestionLoading, setSuggestionLoading] = 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 // 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);
@ -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 remaining = Math.max((threshold || 0) - (row.total_paid || 0), 0);
const summary = paymentSummary(row, threshold); const summary = paymentSummary(row, threshold);
async function handleQuickPay() { function handleQuickPay() {
if (quickPaySaving) return; if (quickPay.isPending) return;
const val = parseFloat(amountRef.current?.value); const val = parseFloat(amountRef.current?.value);
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; } if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
setQuickPaySaving(true); quickPay.run(row, val, defaultPaymentDate);
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);
}
} }
async function performTogglePaid() { 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" className="tracker-number h-8 w-24 text-right text-sm font-medium bg-background/70 border-border/60"
title="Payment amount" title="Payment amount"
aria-label={`${row.name} payment amount`} aria-label={`${row.name} payment amount`}
disabled={quickPaySaving} disabled={quickPay.isPending}
/> />
<Button <Button
size="sm" variant="default" size="sm" variant="default"
onClick={handleQuickPay} onClick={handleQuickPay}
disabled={quickPaySaving} disabled={quickPay.isPending}
className="h-8 px-3 text-xs font-semibold" className="h-8 px-3 text-xs font-semibold"
> >
{quickPaySaving ? 'Adding...' : 'Add'} {quickPay.isPending ? 'Adding...' : 'Add'}
</Button> </Button>
</div> </div>
)} )}

View File

@ -16,6 +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 { 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';
@ -38,6 +39,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
const [showUpdateNudge, setShowUpdateNudge] = useState(false); const [showUpdateNudge, setShowUpdateNudge] = useState(false);
const [nudgeAmount, setNudgeAmount] = useState(null); const [nudgeAmount, setNudgeAmount] = useState(null);
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const quickPay = useQuickPay();
const visibleColumnSet = new Set(visibleColumns); const visibleColumnSet = new Set(visibleColumns);
const showColumn = key => visibleColumnSet.has(key); 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] || ''); const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
async function handleQuickPay() { function handleQuickPay() {
const val = parseFloat(amountRef.current?.value); const val = parseFloat(amountRef.current?.value);
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; } if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
try { quickPay.run(row, val, defaultPaymentDate);
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.');
}
} }
async function performTogglePaid() { async function performTogglePaid() {

View File

@ -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 };
}