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:
parent
e941f05cd6
commit
ad9fcbd56f
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue