diff --git a/HISTORY.md b/HISTORY.md index 2dfa624..dc9ebab 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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) diff --git a/client/components/tracker/MobileTrackerRow.jsx b/client/components/tracker/MobileTrackerRow.jsx index d11d378..0ce8061 100644 --- a/client/components/tracker/MobileTrackerRow.jsx +++ b/client/components/tracker/MobileTrackerRow.jsx @@ -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) { diff --git a/client/components/tracker/TrackerBucket.jsx b/client/components/tracker/TrackerBucket.jsx index 0d62bf2..0f39560 100644 --- a/client/components/tracker/TrackerBucket.jsx +++ b/client/components/tracker/TrackerBucket.jsx @@ -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 && ( Clear filters to reorder )} + {payAllItems.length > 0 && !loading && ( + + )} + + + + Pay all due in {label}? + + 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. + + + + Cancel + { e.preventDefault(); handlePayAllDue(); }} disabled={payingAll}> + {payingAll ? 'Paying…' : `Pay ${fmt(payAllTotal)}`} + + + + +
{loading ? ( diff --git a/client/components/tracker/TrackerRow.jsx b/client/components/tracker/TrackerRow.jsx index 78d6279..1127604 100644 --- a/client/components/tracker/TrackerRow.jsx +++ b/client/components/tracker/TrackerRow.jsx @@ -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) {