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:
parent
88874f3f3d
commit
0abe861928
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue