feat(tracker): multi-select bulk pay/skip (plan C3)

- 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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 14:56:34 -05:00
parent 88874f3f3d
commit 0abe861928
4 changed files with 184 additions and 6 deletions

View File

@ -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<HTMLInputElement>(null);
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
const [confirmUnpay, setConfirmUnpay] = useState(false);
@ -177,6 +186,15 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="flex min-w-0 gap-2">
{selection && (
<div className="flex shrink-0 items-center pt-0.5">
<Checkbox
checked={selection.selected}
onCheckedChange={(v) => selection.onToggle(v === true)}
aria-label={`Select ${row.name}`}
/>
</div>
)}
<div className="flex shrink-0 items-center gap-0.5 pt-0.5">
<GripVertical
className={cn(

View File

@ -18,7 +18,7 @@ import {
} from '@/lib/trackerUtils';
import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns';
import { TrackerRow as Row } from '@/components/tracker/TrackerRow';
import { MobileTrackerRow, type DragProps, type MoveControls } from '@/components/tracker/MobileTrackerRow';
import { MobileTrackerRow, type DragProps, type MoveControls, type RowSelection } from '@/components/tracker/MobileTrackerRow';
import type { Payment, TrackerRow } from '@/types';
interface SortableHeadProps {
@ -76,6 +76,8 @@ interface TrackerBucketProps {
onSort?: (key: string) => void;
driftedIds?: Set<number>;
visibleColumns?: string[];
selectedIds?: Set<number>;
onToggleSelect?: (id: number, next: boolean) => void;
}
export function TrackerBucket({
@ -94,9 +96,14 @@ export function TrackerBucket({
onSort,
driftedIds = new Set<number>(),
visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS,
selectedIds,
onToggleSelect,
}: TrackerBucketProps) {
const [draggingId, setDraggingId] = useState<number | null>(null);
const [dropTargetId, setDropTargetId] = useState<number | null>(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)}
/>
))}
</LayoutGroup>

View File

@ -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<HTMLInputElement>(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) */}
<TableCell className="w-[18%] py-2.5">
<div className="flex min-w-0 items-center gap-2.5">
{selection && (
<Checkbox
checked={selection.selected}
onCheckedChange={(v) => selection.onToggle(v === true)}
aria-label={`Select ${row.name}`}
className="shrink-0"
/>
)}
<div className="flex shrink-0 items-center gap-0.5">
<GripVertical
className={cn(

View File

@ -316,6 +316,10 @@ export default function TrackerPage() {
// Row to open in PaymentLedgerDialog via the overdue command center
const [commandCenterPayRow, setCommandCenterPayRow] = useState<TrackerRow | null>(null);
// Multi-select: bill ids checked for a bulk pay/skip action.
const [selectedIds, setSelectedIds] = useState<Set<number>>(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) && (
<div className="space-y-5">
{first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={invalidateData} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('1st', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />}
{second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={invalidateData} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('15th', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />}
{first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={invalidateData} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('1st', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} selectedIds={selectedIds} onToggleSelect={handleToggleSelect} />}
{second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={invalidateData} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('15th', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} selectedIds={selectedIds} onToggleSelect={handleToggleSelect} />}
</div>
)}
@ -1215,6 +1314,48 @@ export default function TrackerPage() {
/>
)}
{/* Multi-select action bar — sticky at the bottom while any bills are checked */}
{selectedRows.length > 0 && (
<div className="tracker-print-hide fixed inset-x-0 bottom-4 z-40 flex justify-center px-4">
<div
role="toolbar"
aria-label="Bulk actions for selected bills"
className="flex items-center gap-2 rounded-full border border-border bg-card/95 px-3 py-2 shadow-lg backdrop-blur supports-[backdrop-filter]:bg-card/80"
>
<span className="px-2 text-sm font-medium tabular-nums">{selectedRows.length} selected</span>
<Button
size="sm"
onClick={handlePaySelected}
disabled={bulkBusy || selectedRows.every(r => r.is_skipped || rowIsPaid(r))}
className="h-8 gap-1.5"
>
<CheckCircle2 className="h-4 w-4" />
Pay
</Button>
<Button
size="sm"
variant="outline"
onClick={handleSkipSelected}
disabled={bulkBusy || selectedRows.every(r => r.is_skipped)}
className="h-8 gap-1.5"
>
<EyeOff className="h-4 w-4" />
Skip
</Button>
<Button
size="sm"
variant="ghost"
onClick={clearSelection}
disabled={bulkBusy}
className="h-8 gap-1.5 text-muted-foreground"
aria-label="Clear selection"
>
Clear
</Button>
</div>
</div>
)}
</div>
);
}