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';
|
} 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() {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue