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 { cn, fmt, fmtDate, errMessage } from '@/lib/utils';
|
||||||
import { asDollars } from '@/lib/money';
|
import { asDollars } from '@/lib/money';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
||||||
|
|
@ -63,6 +64,13 @@ export interface MoveControls {
|
||||||
moving?: boolean;
|
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 {
|
interface MobileTrackerRowProps {
|
||||||
row: TrackerRow;
|
row: TrackerRow;
|
||||||
year: number;
|
year: number;
|
||||||
|
|
@ -73,9 +81,10 @@ interface MobileTrackerRowProps {
|
||||||
moveControls?: MoveControls;
|
moveControls?: MoveControls;
|
||||||
dragProps?: DragProps;
|
dragProps?: DragProps;
|
||||||
isDrifted?: boolean;
|
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 amountRef = useRef<HTMLInputElement>(null);
|
||||||
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
||||||
const [confirmUnpay, setConfirmUnpay] = 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 items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 gap-2">
|
<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">
|
<div className="flex shrink-0 items-center gap-0.5 pt-0.5">
|
||||||
<GripVertical
|
<GripVertical
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import {
|
||||||
} from '@/lib/trackerUtils';
|
} from '@/lib/trackerUtils';
|
||||||
import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns';
|
import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns';
|
||||||
import { TrackerRow as Row } from '@/components/tracker/TrackerRow';
|
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';
|
import type { Payment, TrackerRow } from '@/types';
|
||||||
|
|
||||||
interface SortableHeadProps {
|
interface SortableHeadProps {
|
||||||
|
|
@ -76,6 +76,8 @@ interface TrackerBucketProps {
|
||||||
onSort?: (key: string) => void;
|
onSort?: (key: string) => void;
|
||||||
driftedIds?: Set<number>;
|
driftedIds?: Set<number>;
|
||||||
visibleColumns?: string[];
|
visibleColumns?: string[];
|
||||||
|
selectedIds?: Set<number>;
|
||||||
|
onToggleSelect?: (id: number, next: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TrackerBucket({
|
export function TrackerBucket({
|
||||||
|
|
@ -94,9 +96,14 @@ export function TrackerBucket({
|
||||||
onSort,
|
onSort,
|
||||||
driftedIds = new Set<number>(),
|
driftedIds = new Set<number>(),
|
||||||
visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS,
|
visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS,
|
||||||
|
selectedIds,
|
||||||
|
onToggleSelect,
|
||||||
}: TrackerBucketProps) {
|
}: TrackerBucketProps) {
|
||||||
const [draggingId, setDraggingId] = useState<number | null>(null);
|
const [draggingId, setDraggingId] = useState<number | null>(null);
|
||||||
const [dropTargetId, setDropTargetId] = 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 visibleColumnSet = new Set(visibleColumns);
|
||||||
const showColumn = (key: string) => visibleColumnSet.has(key);
|
const showColumn = (key: string) => visibleColumnSet.has(key);
|
||||||
const tableColSpan = 1 + visibleColumns.length;
|
const tableColSpan = 1 + visibleColumns.length;
|
||||||
|
|
@ -343,6 +350,7 @@ export function TrackerBucket({
|
||||||
moveControls={moveControlsFor(r, i)}
|
moveControls={moveControlsFor(r, i)}
|
||||||
dragProps={dragPropsFor(r, i)}
|
dragProps={dragPropsFor(r, i)}
|
||||||
isDrifted={driftedIds.has(r.id)}
|
isDrifted={driftedIds.has(r.id)}
|
||||||
|
selection={selectionFor(r)}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
@ -437,6 +445,7 @@ export function TrackerBucket({
|
||||||
dragProps={dragPropsFor(r, i)}
|
dragProps={dragPropsFor(r, i)}
|
||||||
isDrifted={driftedIds.has(r.id)}
|
isDrifted={driftedIds.has(r.id)}
|
||||||
visibleColumns={visibleColumns}
|
visibleColumns={visibleColumns}
|
||||||
|
selection={selectionFor(r)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</LayoutGroup>
|
</LayoutGroup>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { api } from '@/api';
|
||||||
import { cn, fmt, fmtDate, errMessage } from '@/lib/utils';
|
import { cn, fmt, fmtDate, errMessage } from '@/lib/utils';
|
||||||
import { asDollars, type Dollars } from '@/lib/money';
|
import { asDollars, type Dollars } from '@/lib/money';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
TableRow, TableCell,
|
TableRow, TableCell,
|
||||||
|
|
@ -23,7 +24,7 @@ import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
|
||||||
import { NotesCell } from '@/components/tracker/NotesCell';
|
import { NotesCell } from '@/components/tracker/NotesCell';
|
||||||
import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions';
|
import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions';
|
||||||
import type { TrackerRow as TrackerRowData } from '@/types';
|
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
|
// Cast type for the HTML5 drag handlers, which conflict with framer-motion's own
|
||||||
// onDrag* gesture types on the motion.tr-backed TableRow.
|
// onDrag* gesture types on the motion.tr-backed TableRow.
|
||||||
|
|
@ -40,9 +41,10 @@ interface TrackerRowProps {
|
||||||
dragProps?: DragProps;
|
dragProps?: DragProps;
|
||||||
isDrifted?: boolean;
|
isDrifted?: boolean;
|
||||||
visibleColumns?: string[];
|
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 amountRef = useRef<HTMLInputElement>(null);
|
||||||
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
||||||
const [confirmUnpay, setConfirmUnpay] = 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) */}
|
{/* Bill name + category + monthly notes (if set) */}
|
||||||
<TableCell className="w-[18%] py-2.5">
|
<TableCell className="w-[18%] py-2.5">
|
||||||
<div className="flex min-w-0 items-center gap-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">
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
<GripVertical
|
<GripVertical
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,10 @@ export default function TrackerPage() {
|
||||||
// Row to open in PaymentLedgerDialog via the overdue command center
|
// Row to open in PaymentLedgerDialog via the overdue command center
|
||||||
const [commandCenterPayRow, setCommandCenterPayRow] = useState<TrackerRow | null>(null);
|
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
|
// Use React Query for data fetching
|
||||||
const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month);
|
const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month);
|
||||||
const { data: driftDataRaw } = useDriftReport();
|
const { data: driftDataRaw } = useDriftReport();
|
||||||
|
|
@ -343,6 +347,10 @@ export default function TrackerPage() {
|
||||||
setMovingBillId(null);
|
setMovingBillId(null);
|
||||||
}, [dataUpdatedAt, year, month]);
|
}, [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).
|
// Surface a settings-load failure (React Query swallows it into isError).
|
||||||
// The page still renders with TRACKER_SETTING_DEFAULTS.
|
// The page still renders with TRACKER_SETTING_DEFAULTS.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -474,7 +482,98 @@ export default function TrackerPage() {
|
||||||
navigateTo(`/calendar?year=${year}&month=${month}`);
|
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 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 summary = (data?.summary ?? {}) as TrackerSummary;
|
||||||
const bankTracking = data?.bank_tracking;
|
const bankTracking = data?.bank_tracking;
|
||||||
const cashflow = data?.cashflow;
|
const cashflow = data?.cashflow;
|
||||||
|
|
@ -1135,8 +1234,8 @@ export default function TrackerPage() {
|
||||||
|
|
||||||
{!isError && (first.length > 0 || second.length > 0) && (
|
{!isError && (first.length > 0 || second.length > 0) && (
|
||||||
<div className="space-y-5">
|
<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} />}
|
{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} />}
|
{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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue