From 0abe8619281adaff745490d6994398e9fecffac4 Mon Sep 17 00:00:00 2001 From: null Date: Sun, 5 Jul 2026 14:56:34 -0500 Subject: [PATCH] feat(tracker): multi-select bulk pay/skip (plan C3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a leading selection checkbox to each desktop + mobile row, threaded from the page through TrackerBucket via an optional RowSelection prop (renders only when the page supplies a toggle handler). - Page owns the selection Set; a sticky bottom action bar appears when ≥1 bill is checked: "N selected · Pay · Skip · Clear". - Pay selected reuses the bulkPay item shape + Undo from "Pay all due". - Skip selected fans out saveBillMonthlyState (no bulk endpoint; skip has no money effect) with Promise.allSettled, an aggregated "n skipped / m failed" toast, and an Undo that un-skips. Selection clears on month change. Co-Authored-By: Claude Opus 4.8 --- .../components/tracker/MobileTrackerRow.tsx | 20 ++- client/components/tracker/TrackerBucket.tsx | 11 +- client/components/tracker/TrackerRow.tsx | 14 +- client/pages/TrackerPage.tsx | 145 +++++++++++++++++- 4 files changed, 184 insertions(+), 6 deletions(-) diff --git a/client/components/tracker/MobileTrackerRow.tsx b/client/components/tracker/MobileTrackerRow.tsx index 7bf184b..028b659 100644 --- a/client/components/tracker/MobileTrackerRow.tsx +++ b/client/components/tracker/MobileTrackerRow.tsx @@ -6,6 +6,7 @@ import { api } from '@/api'; import { cn, fmt, fmtDate, errMessage } from '@/lib/utils'; import { asDollars } from '@/lib/money'; import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, @@ -63,6 +64,13 @@ export interface MoveControls { moving?: boolean; } +// Per-row multi-select state supplied by the bucket. When present, the row shows +// a leading checkbox that adds/removes it from the page-level selection. +export interface RowSelection { + selected: boolean; + onToggle: (next: boolean) => void; +} + interface MobileTrackerRowProps { row: TrackerRow; year: number; @@ -73,9 +81,10 @@ interface MobileTrackerRowProps { moveControls?: MoveControls; dragProps?: DragProps; isDrifted?: boolean; + selection?: RowSelection; } -export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted = false }: MobileTrackerRowProps) { +export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted = false, selection }: MobileTrackerRowProps) { const amountRef = useRef(null); const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false); const [confirmUnpay, setConfirmUnpay] = useState(false); @@ -177,6 +186,15 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, >
+ {selection && ( +
+ selection.onToggle(v === true)} + aria-label={`Select ${row.name}`} + /> +
+ )}
void; driftedIds?: Set; visibleColumns?: string[]; + selectedIds?: Set; + onToggleSelect?: (id: number, next: boolean) => void; } export function TrackerBucket({ @@ -94,9 +96,14 @@ export function TrackerBucket({ onSort, driftedIds = new Set(), visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS, + selectedIds, + onToggleSelect, }: TrackerBucketProps) { const [draggingId, setDraggingId] = useState(null); const [dropTargetId, setDropTargetId] = useState(null); + // Build the per-row selection prop only when the page provides a toggle handler. + const selectionFor = (r: TrackerRow): RowSelection | undefined => + onToggleSelect ? { selected: selectedIds?.has(r.id) ?? false, onToggle: (next) => onToggleSelect(r.id, next) } : undefined; const visibleColumnSet = new Set(visibleColumns); const showColumn = (key: string) => visibleColumnSet.has(key); const tableColSpan = 1 + visibleColumns.length; @@ -343,6 +350,7 @@ export function TrackerBucket({ moveControls={moveControlsFor(r, i)} dragProps={dragPropsFor(r, i)} isDrifted={driftedIds.has(r.id)} + selection={selectionFor(r)} /> )) )} @@ -437,6 +445,7 @@ export function TrackerBucket({ dragProps={dragPropsFor(r, i)} isDrifted={driftedIds.has(r.id)} visibleColumns={visibleColumns} + selection={selectionFor(r)} /> ))} diff --git a/client/components/tracker/TrackerRow.tsx b/client/components/tracker/TrackerRow.tsx index 1933041..2b43fcf 100644 --- a/client/components/tracker/TrackerRow.tsx +++ b/client/components/tracker/TrackerRow.tsx @@ -6,6 +6,7 @@ import { api } from '@/api'; import { cn, fmt, fmtDate, errMessage } from '@/lib/utils'; import { asDollars, type Dollars } from '@/lib/money'; import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { TableRow, TableCell, @@ -23,7 +24,7 @@ import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog'; import { NotesCell } from '@/components/tracker/NotesCell'; import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions'; import type { TrackerRow as TrackerRowData } from '@/types'; -import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow'; +import type { DragProps, MoveControls, RowSelection } from '@/components/tracker/MobileTrackerRow'; // Cast type for the HTML5 drag handlers, which conflict with framer-motion's own // onDrag* gesture types on the motion.tr-backed TableRow. @@ -40,9 +41,10 @@ interface TrackerRowProps { dragProps?: DragProps; isDrifted?: boolean; visibleColumns?: string[]; + selection?: RowSelection; } -export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted, visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS }: TrackerRowProps) { +export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted, visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS, selection }: TrackerRowProps) { const amountRef = useRef(null); const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false); const [confirmUnpay, setConfirmUnpay] = useState(false); @@ -306,6 +308,14 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC {/* Bill name + category + monthly notes (if set) */}
+ {selection && ( + selection.onToggle(v === true)} + aria-label={`Select ${row.name}`} + className="shrink-0" + /> + )}
(null); + // Multi-select: bill ids checked for a bulk pay/skip action. + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [bulkBusy, setBulkBusy] = useState(false); + // Use React Query for data fetching const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month); const { data: driftDataRaw } = useDriftReport(); @@ -343,6 +347,10 @@ export default function TrackerPage() { setMovingBillId(null); }, [dataUpdatedAt, year, month]); + // Clear a stale multi-selection when navigating months (survives background + // refetches within the same month). + useEffect(() => { setSelectedIds(new Set()); }, [year, month]); + // Surface a settings-load failure (React Query swallows it into isError). // The page still renders with TRACKER_SETTING_DEFAULTS. useEffect(() => { @@ -474,7 +482,98 @@ export default function TrackerPage() { navigateTo(`/calendar?year=${year}&month=${month}`); } + const handleToggleSelect = useCallback((id: number, next: boolean) => { + setSelectedIds(prev => { + const s = new Set(prev); + if (next) s.add(id); else s.delete(id); + return s; + }); + }, []); + const clearSelection = useCallback(() => setSelectedIds(new Set()), []); + + // Pay every selected bill that still has a balance, in one bulk call (reuses + // the same item shape + Undo as the per-bucket "Pay all due"). + async function handlePaySelected() { + const items = selectedRows + .filter(r => !r.is_skipped && !rowIsPaid(r)) + .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, amount: remaining > 0 ? remaining : threshold, paid_date: paymentDateForTrackerMonth(year, month, r.due_day) }; + }) + .filter(item => item.amount > 0); + if (items.length === 0) { toast.info('No unpaid bills selected.'); return; } + const total = items.reduce((s, i) => s + i.amount, 0); + setBulkBusy(true); + try { + const result = await api.bulkPay(items) as { created?: Array<{ id: number }> }; + const created = result.created || []; + if (created.length === 0) { + toast.info('Those bills were already paid.'); + } else { + toast.success(`Paid ${created.length} bill${created.length === 1 ? '' : 's'} — ${fmt(total)}`, { + action: { + label: 'Undo', + onClick: async () => { + try { + await Promise.all(created.map(p => api.deletePayment(p.id))); + toast.success('Payments removed'); + invalidateData(); + } catch (err) { + toast.error((err as { message?: string })?.message || 'Failed to undo payments.'); + } + }, + }, + }); + } + clearSelection(); + invalidateData(); + } catch (err) { + toast.error((err as { message?: string })?.message || 'Failed to pay bills.'); + } finally { + setBulkBusy(false); + } + } + + // Skip every selected bill for this month. No bulk endpoint exists (skip has no + // money effect), so fan out per-bill and aggregate the result; Undo un-skips. + async function handleSkipSelected() { + const toSkip = selectedRows.filter(r => !r.is_skipped); + if (toSkip.length === 0) { toast.info('No skippable bills selected.'); return; } + setBulkBusy(true); + try { + const results = await Promise.allSettled( + toSkip.map(r => api.saveBillMonthlyState(r.id, { year, month, is_skipped: true, actual_amount: r.actual_amount, notes: r.monthly_notes })), + ); + const skipped = toSkip.filter((_, i) => results[i]?.status === 'fulfilled'); + const failed = results.length - skipped.length; + if (skipped.length > 0) { + toast.success(`Skipped ${skipped.length} bill${skipped.length === 1 ? '' : 's'}${failed ? ` — ${failed} failed` : ''}`, { + action: { + label: 'Undo', + onClick: async () => { + try { + await Promise.all(skipped.map(r => api.saveBillMonthlyState(r.id, { year, month, is_skipped: false, actual_amount: r.actual_amount, notes: r.monthly_notes }))); + toast.success('Restored'); + invalidateData(); + } catch (err) { + toast.error((err as { message?: string })?.message || 'Failed to undo.'); + } + }, + }, + }); + } else { + toast.error('Failed to skip the selected bills.'); + } + clearSelection(); + invalidateData(); + } finally { + setBulkBusy(false); + } + } + const rows = useMemo(() => orderedRows || data?.rows || [], [orderedRows, data]); + const selectedRows = useMemo(() => rows.filter(r => selectedIds.has(r.id)), [rows, selectedIds]); const summary = (data?.summary ?? {}) as TrackerSummary; const bankTracking = data?.bank_tracking; const cashflow = data?.cashflow; @@ -1135,8 +1234,8 @@ export default function TrackerPage() { {!isError && (first.length > 0 || second.length > 0) && (
- {first.length > 0 && handleReorderBucket('1st', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />} - {second.length > 0 && handleReorderBucket('15th', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />} + {first.length > 0 && handleReorderBucket('1st', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} selectedIds={selectedIds} onToggleSelect={handleToggleSelect} />} + {second.length > 0 && handleReorderBucket('15th', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} selectedIds={selectedIds} onToggleSelect={handleToggleSelect} />}
)} @@ -1215,6 +1314,48 @@ export default function TrackerPage() { /> )} + {/* Multi-select action bar — sticky at the bottom while any bills are checked */} + {selectedRows.length > 0 && ( +
+
+ {selectedRows.length} selected + + + +
+
+ )} +
); }