BillTracker/client/components/tracker/TrackerBucket.tsx

610 lines
22 KiB
TypeScript

import { useState, type ReactNode } from 'react';
import { LayoutGroup } from 'framer-motion';
import { ArrowDown, ArrowUp, CheckCircle2, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { cn, fmt, errMessage } from '@/lib/utils';
import { asDollars } from '@/lib/money';
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from '@/components/ui/table';
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,
type DragProps,
type MoveControls,
type RowSelection,
} from '@/components/tracker/MobileTrackerRow';
import type { Payment, TrackerRow } from '@/types';
interface SortableHeadProps {
sortKey: string;
activeSortKey?: string;
sortDir?: string;
onSort?: (key: string) => void;
children?: ReactNode;
className?: string;
}
function SortableHead({
sortKey,
activeSortKey,
sortDir,
onSort,
children,
className,
}: SortableHeadProps) {
const active = activeSortKey === sortKey;
const Icon = sortDir === TRACKER_SORT_ASC ? ArrowUp : ArrowDown;
return (
<TableHead
aria-sort={active ? (sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending') : 'none'}
className={cn('h-11 py-2.5', className)}
>
<button
type="button"
onClick={() => onSort?.(sortKey)}
className={cn(
'group inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-[11px] font-semibold uppercase tracking-[0.08em] transition-all',
'hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60',
active ? 'bg-accent/70 text-foreground shadow-sm' : 'text-muted-foreground',
)}
>
<span>{children}</span>
<Icon
className={cn(
'h-3.5 w-3.5 transition-opacity',
active ? 'opacity-100' : 'opacity-0 group-hover:opacity-55',
)}
aria-hidden="true"
/>
</button>
</TableHead>
);
}
interface TrackerBucketProps {
label: string;
rows: TrackerRow[];
year: number;
month: number;
refresh: () => void;
onEditBill?: (row: TrackerRow) => void;
loading?: boolean;
onReorderRows?: (rows: TrackerRow[]) => void;
reorderEnabled?: boolean;
movingBillId?: number | null;
sortKey?: string;
sortDir?: string;
onSort?: (key: string) => void;
driftedIds?: Set<number>;
visibleColumns?: string[];
selectedIds?: Set<number>;
onToggleSelect?: (id: number, next: boolean) => void;
}
export function TrackerBucket({
label,
rows,
year,
month,
refresh,
onEditBill,
loading,
onReorderRows,
reorderEnabled,
movingBillId,
sortKey = TRACKER_SORT_DEFAULT,
sortDir = TRACKER_SORT_ASC,
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;
// Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals
const activeRows = rows.filter((r) => !r.is_skipped);
const totalThreshold = activeRows.reduce(
(s, r) => s + (r.actual_amount ?? r.expected_amount ?? 0),
0,
);
const totalPaid = activeRows.reduce((s, r) => s + (r.total_paid || 0), 0);
const totalPaidTowardDue = activeRows.reduce((s, r) => {
const threshold = Number(r.actual_amount ?? r.expected_amount ?? 0) || 0;
const cappedPaid = Number(r.paid_toward_due);
return (
s +
(Number.isFinite(cappedPaid) ? cappedPaid : Math.min(Number(r.total_paid) || 0, threshold))
);
}, 0);
const totalOverpaid = Math.max(totalPaid - totalPaidTowardDue, 0);
const totalRemaining = Math.max(totalThreshold - totalPaidTowardDue, 0);
const skippedCount = rows.length - activeRows.length;
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))) as {
created?: Payment[];
};
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(asDollars(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(errMessage(err, 'Failed to undo payments.'));
}
},
},
},
);
}
refresh?.();
} catch (err) {
toast.error(errMessage(err, 'Failed to pay bills.'));
} finally {
setPayingAll(false);
}
}
function reorderByIndex(fromIndex: number, toIndex: number) {
if (!reorderEnabled || fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return;
onReorderRows?.(moveInArray(rows, fromIndex, toIndex));
}
function dragPropsFor(row: TrackerRow, index: number): DragProps {
if (!reorderEnabled) return { draggable: false };
return {
draggable: true,
isDragging: draggingId === row.id,
isDropTarget: dropTargetId === row.id && draggingId !== row.id,
onDragStart: (event) => {
setDraggingId(row.id);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(row.id));
},
onDragEnter: () => {
if (draggingId && draggingId !== row.id) setDropTargetId(row.id);
},
onDragOver: (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
if (draggingId && draggingId !== row.id) setDropTargetId(row.id);
},
onDrop: (event) => {
event.preventDefault();
const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId);
const fromIndex = rows.findIndex((item) => item.id === sourceId);
reorderByIndex(fromIndex, index);
setDraggingId(null);
setDropTargetId(null);
},
onDragEnd: () => {
setDraggingId(null);
setDropTargetId(null);
},
};
}
function moveControlsFor(row: TrackerRow, index: number): MoveControls {
return {
enabled: !!reorderEnabled,
moving: movingBillId === row.id,
canMoveUp: index > 0,
canMoveDown: index < rows.length - 1,
onMoveUp: () => reorderByIndex(index, index - 1),
onMoveDown: () => reorderByIndex(index, index + 1),
};
}
return (
<div className="table-surface overflow-hidden">
{/* Bucket header */}
<div className="flex flex-col gap-2 border-b border-border/70 bg-card/60 px-3 py-3 sm:px-5 md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1.5">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
{label}
</span>
{skippedCount > 0 && (
<span className="shrink-0 text-[10px] text-muted-foreground/60">
({skippedCount} skipped)
</span>
)}
</div>
<div className="flex min-w-28 items-center gap-2">
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-border sm:w-24">
<div
className={cn(
'h-full rounded-full transition-all duration-700',
allPaid ? 'bg-emerald-500' : 'bg-emerald-400/70',
)}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-[11px] font-mono text-muted-foreground/70">
{Math.round(pct)}%
</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-muted-foreground md:justify-end">
<span className="tracker-number whitespace-nowrap">
<span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}>
{fmt(asDollars(totalPaidTowardDue))}
</span>
<span className="text-muted-foreground/50 mx-1">/</span>
{fmt(asDollars(totalThreshold))}
{totalOverpaid > 0 && (
<span className="ml-1 text-emerald-500">+{fmt(asDollars(totalOverpaid))}</span>
)}
</span>
{!allPaid && totalRemaining > 0 && (
<span className="text-[11px] text-muted-foreground/70">
{fmt(asDollars(totalRemaining))} left
</span>
)}
{allPaid && <span className="text-[11px] text-emerald-500">Done</span>}
{!reorderEnabled && rows.length > 1 && (
<span className="hidden text-[11px] text-muted-foreground/60 xl:inline">
Clear filters to reorder
</span>
)}
{payAllItems.length > 0 && !loading && (
<Button
size="sm"
variant="outline"
className="h-7 gap-1.5 text-[11px] font-sans"
disabled={payingAll}
onClick={() => setPayAllOpen(true)}
>
{payingAll ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Paying
</>
) : (
<>
<CheckCircle2 className="h-3.5 w-3.5" />
Pay all due ({payAllItems.length})
</>
)}
</Button>
)}
</div>
</div>
<AlertDialog open={payAllOpen} onOpenChange={setPayAllOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Pay all due in {label}?</AlertDialogTitle>
<AlertDialogDescription>
This records a payment for {payAllItems.length} unpaid bill
{payAllItems.length === 1 ? '' : 's'} in this bucket, totalling{' '}
{fmt(asDollars(payAllTotal))}. You can undo it right after.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={payingAll}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handlePayAllDue();
}}
disabled={payingAll}
>
{payingAll ? 'Paying…' : `Pay ${fmt(asDollars(payAllTotal))}`}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<LayoutGroup id={`tracker-bucket-mobile-${label}`}>
<div
className="grid gap-2.5 bg-background/20 p-2.5 lg:hidden"
aria-busy={loading ? 'true' : 'false'}
>
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="animate-pulse rounded-lg border border-border/60 bg-card/65 p-3"
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<div className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted" />
<div className="h-4 w-32 rounded-md bg-muted" />
</div>
</div>
<div className="h-5 w-20 rounded-md bg-muted" />
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs text-muted-foreground mt-2">
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Expected</p>
<div className="h-3 w-20 rounded-md bg-muted mt-0.5" />
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Remaining</p>
<div className="h-3 w-20 rounded-md bg-muted mt-0.5" />
</div>
</div>
</div>
))
) : rows.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/70 bg-muted/15 px-4 py-8 text-center text-sm text-muted-foreground">
No bills match try adjusting your filters.
</div>
) : (
rows.map((r, i) => (
<MobileTrackerRow
key={r.id}
row={r}
year={year}
month={month}
refresh={refresh}
index={i}
onEditBill={onEditBill}
moveControls={moveControlsFor(r, i)}
dragProps={dragPropsFor(r, i)}
isDrifted={driftedIds.has(r.id)}
selection={selectionFor(r)}
/>
))
)}
</div>
</LayoutGroup>
<div className="hidden lg:block" aria-busy={loading ? 'true' : 'false'}>
<div className="overflow-x-auto">
<Table className={cn(visibleColumns.length <= 4 ? 'min-w-[720px]' : 'min-w-[1120px]')}>
<TableHeader>
<TableRow className="border-border/80 bg-muted/20 hover:bg-muted/20">
<SortableHead
sortKey="name"
activeSortKey={sortKey}
sortDir={sortDir}
onSort={onSort}
className="w-[18%]"
>
Bill
</SortableHead>
{showColumn('due') && (
<SortableHead
sortKey="due"
activeSortKey={sortKey}
sortDir={sortDir}
onSort={onSort}
className="w-[10%]"
>
Due
</SortableHead>
)}
{showColumn('expected') && (
<SortableHead
sortKey="expected"
activeSortKey={sortKey}
sortDir={sortDir}
onSort={onSort}
className="w-[10%] text-center"
>
Expected
</SortableHead>
)}
{showColumn('previous') && (
<SortableHead
sortKey="previous"
activeSortKey={sortKey}
sortDir={sortDir}
onSort={onSort}
className="w-[10%] text-center"
>
Last Month
</SortableHead>
)}
{showColumn('paid') && (
<SortableHead
sortKey="paid"
activeSortKey={sortKey}
sortDir={sortDir}
onSort={onSort}
className="w-[10%] text-center"
>
Paid
</SortableHead>
)}
{showColumn('paid_date') && (
<SortableHead
sortKey="paid_date"
activeSortKey={sortKey}
sortDir={sortDir}
onSort={onSort}
className="w-[10%]"
>
Paid Date
</SortableHead>
)}
{showColumn('status') && (
<SortableHead
sortKey="status"
activeSortKey={sortKey}
sortDir={sortDir}
onSort={onSort}
className="w-[9%] text-center"
>
Status
</SortableHead>
)}
{showColumn('action') && (
<TableHead className="h-11 w-[10%] py-2.5 text-center text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
Action
</TableHead>
)}
{showColumn('notes') && (
<TableHead className="h-11 w-[23%] py-2.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground border-l border-border/80 pl-4">
Notes
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i} className="border-border/50">
<TableCell className="w-[18%] py-3">
<div className="flex items-center gap-2.5">
<div className="h-1.5 w-1.5 rounded-full bg-muted" />
<div className="h-4 w-48 rounded-md bg-muted" />
</div>
</TableCell>
{showColumn('due') && (
<TableCell className="w-[10%] py-3">
<div className="h-3 w-20 rounded-md bg-muted" />
</TableCell>
)}
{showColumn('expected') && (
<TableCell className="w-[10%] py-3 text-center">
<div className="mx-auto h-3 w-20 rounded-md bg-muted" />
</TableCell>
)}
{showColumn('previous') && (
<TableCell className="w-[10%] py-3 text-center">
<div className="mx-auto h-3 w-20 rounded-md bg-muted" />
</TableCell>
)}
{showColumn('paid') && (
<TableCell className="w-[10%] py-3">
<div className="mx-auto h-7 w-24 rounded-md bg-muted" />
</TableCell>
)}
{showColumn('paid_date') && (
<TableCell className="w-[10%] py-3">
<div className="h-7 w-24 rounded-md bg-muted" />
</TableCell>
)}
{showColumn('status') && (
<TableCell className="w-[9%] py-3">
<div className="mx-auto h-5 w-20 rounded-md bg-muted" />
</TableCell>
)}
{showColumn('action') && (
<TableCell className="w-[10%] py-3 text-center">
<div className="flex items-center justify-center gap-1">
<div className="h-7 w-20 rounded-md bg-muted" />
<div className="h-7 w-7 rounded-md bg-muted" />
</div>
</TableCell>
)}
{showColumn('notes') && (
<TableCell className="w-[23%] py-3 border-l border-border pl-4">
<div className="h-4 w-full rounded-md bg-muted" />
</TableCell>
)}
</TableRow>
))
) : rows.length === 0 ? (
<TableRow className="border-border/50">
<TableCell
colSpan={tableColSpan}
className="py-10 text-center text-sm text-muted-foreground"
>
No bills match try adjusting your filters.
</TableCell>
</TableRow>
) : (
<LayoutGroup id={`tracker-bucket-table-${label}`}>
{rows.map((r, i) => (
<Row
key={r.id}
row={r}
year={year}
month={month}
refresh={refresh}
index={i}
onEditBill={onEditBill}
moveControls={moveControlsFor(r, i)}
dragProps={dragPropsFor(r, i)}
isDrifted={driftedIds.has(r.id)}
visibleColumns={visibleColumns}
selection={selectionFor(r)}
/>
))}
</LayoutGroup>
)}
</TableBody>
</Table>
</div>
</div>
</div>
);
}