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:
parent
995f635d35
commit
55b515c401
|
|
@ -9,6 +9,10 @@
|
|||
|
||||
- **[Tracker] Killed the getTracker N+1 (was ~2–3 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 70–450 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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue