feat(tracker): Pay-all-due per bucket + reversible quick-pay with specific toasts (T4)

- Each bucket header gains a 'Pay all due (N)' action: one bulkPay for every
  unpaid gated bill in the bucket, behind a confirm (count + total) and a single
  Undo toast that deletes the created payments.
- Quick-pay and mark-paid now show a specific toast ('Rent - $1,200 paid');
  quick-pay gained an Undo action to match un-pay.
- Per-bill snooze already existed in the Overdue Command Center.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 18:43:01 -05:00
parent 995f635d35
commit 55b515c401
4 changed files with 132 additions and 10 deletions

View File

@ -9,6 +9,10 @@
- **[Tracker] Killed the getTracker N+1 (was ~23 DB round-trips × N bills every home-page load)** — inside `bills.map`, `getTracker` ran a payments query per bill (`fetchPaymentsForBillCycle`) plus `computeAmountSuggestion` per bill, and the suggestion alone fired up to 12 queries per bill (6 months × 2) — roughly 70450 queries for a 35-bill account on every Tracker load. Now one query fetches all bills' cycle payments (grouped in JS by each bill's own range) and two queries compute all amount suggestions (`computeAmountSuggestionsBatch`), replacing the per-bill loops. Behavior-preserving — `tests/amountSuggestionService.test.js` pins the batched suggestion to be byte-identical to the per-bill function, and the `trackerService` tests still pass unchanged. (Tracker P1)
### ✨ Tracker features & toast quality
- **[Tracker] "Pay all due" per bucket + reversible quick-pay with specific toasts** — each bucket header now has a **Pay all due (N)** action that records a payment for every unpaid, occurrence-gated bill in that bucket in one `bulkPay` call, behind a confirm dialog (showing the count + total) and a single **Undo** toast that deletes the just-created payments. Quick-pay (the inline "Add" on a row) and mark-paid now show a **specific** toast ("Rent — $1,200 paid") and quick-pay gained an **Undo** action to match un-pay — it was the only reversible action without one. (Per-bill snooze on overdue rows already exists in the Overdue Command Center.) (Tracker T4)
### 🧹 Tracker cleanup
- **[Tracker] Consolidated the "paid or autodraft = done" check + tidied a few spots** — the settled-status test (`status === 'paid' || status === 'autodraft'`) was copy-pasted across the server and client; added a single `isPaidStatus(status)` (+ `PAID_STATUSES`) to `services/statusService.js` and a matching `isPaidStatus` to `client/lib/trackerUtils.js`, and routed the unambiguous call sites through it (`trackerService`, `StatusBadge`, `CalendarPage`, and `rowIsPaid`) — the intentionally paid-*only* counts (`count_paid`, `count_autodraft`) are left distinct. Also replaced two inline `Math.max(r.balance || 0, 0)` sums in `getTracker` with the existing `rowOutstanding` helper, and gave the Tracker's display-settings load a quiet toast on failure instead of a silent swallow. Behavior-preserving; full server + client suites green. (Tracker T5)

View File

@ -68,11 +68,24 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
setQuickPaySaving(true);
try {
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
toast.success('Payment added');
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);
toast.error(err.message || 'Failed to add payment.');
} finally {
setQuickPaySaving(false);
}
@ -101,7 +114,7 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
},
});
} else {
toast.success('Payment recorded');
toast.success(`${row.name}${fmt(threshold)} paid`);
}
refresh();
} catch (err) {

View File

@ -1,11 +1,20 @@
import { useState } from 'react';
import { LayoutGroup } from 'framer-motion';
import { ArrowDown, ArrowUp } from 'lucide-react';
import { ArrowDown, ArrowUp, CheckCircle2, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { cn, fmt } from '@/lib/utils';
import {
Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
} from '@/components/ui/table';
import { TRACKER_SORT_ASC, TRACKER_SORT_DEFAULT, moveInArray } from '@/lib/trackerUtils';
import {
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import {
TRACKER_SORT_ASC, TRACKER_SORT_DEFAULT, moveInArray, rowIsPaid, paymentDateForTrackerMonth,
} from '@/lib/trackerUtils';
import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns';
import { TrackerRow as Row } from '@/components/tracker/TrackerRow';
import { MobileTrackerRow } from '@/components/tracker/MobileTrackerRow';
@ -77,6 +86,56 @@ export function TrackerBucket({
const pct = totalThreshold > 0 ? Math.min((totalPaidTowardDue / totalThreshold) * 100, 100) : 0;
const allPaid = pct >= 100;
// "Pay all due" every non-skipped, not-yet-paid row in this bucket, paid for
// its remaining balance in one bulk call. Rows are already occurrence-gated by
// the server, so they're all genuinely due this month.
const [payAllOpen, setPayAllOpen] = useState(false);
const [payingAll, setPayingAll] = useState(false);
const unpaidRows = activeRows.filter(r => !rowIsPaid(r));
const payAllItems = unpaidRows.map(r => {
const threshold = Number(r.actual_amount ?? r.expected_amount ?? 0) || 0;
const remaining = Math.max(threshold - (Number(r.total_paid) || 0), 0);
return {
bill_id: r.id,
name: r.name,
amount: remaining > 0 ? remaining : threshold,
paid_date: paymentDateForTrackerMonth(year, month, r.due_day),
};
}).filter(item => item.amount > 0);
const payAllTotal = payAllItems.reduce((s, i) => s + i.amount, 0);
async function handlePayAllDue() {
setPayingAll(true);
try {
const result = await api.bulkPay(payAllItems.map(({ name, ...item }) => item));
const created = result.created || [];
setPayAllOpen(false);
if (created.length === 0) {
toast.info('Those bills were already paid.');
} else {
toast.success(`Paid ${created.length} bill${created.length === 1 ? '' : 's'}${fmt(payAllTotal)}`, {
action: {
label: 'Undo',
onClick: async () => {
try {
await Promise.all(created.map(p => api.deletePayment(p.id)));
toast.success('Payments removed');
refresh?.();
} catch (err) {
toast.error(err.message || 'Failed to undo payments.');
}
},
},
});
}
refresh?.();
} catch (err) {
toast.error(err.message || 'Failed to pay bills.');
} finally {
setPayingAll(false);
}
}
function reorderByIndex(fromIndex, toIndex) {
if (!reorderEnabled || fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return;
onReorderRows?.(moveInArray(rows, fromIndex, toIndex));
@ -178,9 +237,40 @@ export function TrackerBucket({
{!reorderEnabled && rows.length > 1 && (
<span className="hidden text-[11px] text-muted-foreground/60 xl:inline">Clear filters to reorder</span>
)}
{payAllItems.length > 0 && !loading && (
<Button
size="sm"
variant="outline"
className="h-7 gap-1.5 text-[11px] font-sans"
disabled={payingAll}
onClick={() => setPayAllOpen(true)}
>
{payingAll
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Paying</>
: <><CheckCircle2 className="h-3.5 w-3.5" />Pay all due ({payAllItems.length})</>}
</Button>
)}
</div>
</div>
<AlertDialog open={payAllOpen} onOpenChange={setPayAllOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Pay all due in {label}?</AlertDialogTitle>
<AlertDialogDescription>
This records a payment for {payAllItems.length} unpaid bill{payAllItems.length === 1 ? '' : 's'} in this
bucket, totalling {fmt(payAllTotal)}. You can undo it right after.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={payingAll}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={(e) => { e.preventDefault(); handlePayAllDue(); }} disabled={payingAll}>
{payingAll ? 'Paying…' : `Pay ${fmt(payAllTotal)}`}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<LayoutGroup id={`tracker-bucket-mobile-${label}`}>
<div className="grid gap-3 p-3 lg:hidden" aria-busy={loading ? 'true' : 'false'}>
{loading ? (

View File

@ -70,11 +70,26 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
const val = parseFloat(amountRef.current?.value);
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
try {
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
toast.success('Payment added');
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);
toast.error(err.message || 'Failed to add payment.');
}
}
@ -102,7 +117,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
},
});
} else {
toast.success('Payment recorded');
toast.success(`${row.name}${fmt(threshold)} paid`);
}
refresh?.();
} catch (err) {