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:
parent
c86acd7d75
commit
a0a8579a08
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -743,13 +710,13 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
|||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={togglePaid.isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={loading}
|
||||
disabled={togglePaid.isPending}
|
||||
onClick={performTogglePaid}
|
||||
>
|
||||
{loading ? 'Removing...' : 'Remove Payment'}
|
||||
{togglePaid.isPending ? 'Removing...' : 'Remove Payment'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue