2026-05-31 15:06:10 -05:00
|
|
|
import { useState } from 'react';
|
2026-06-07 15:14:09 -05:00
|
|
|
import { LayoutGroup } from 'framer-motion';
|
2026-06-07 00:41:07 -05:00
|
|
|
import { ArrowDown, ArrowUp } from 'lucide-react';
|
2026-05-31 15:06:10 -05:00
|
|
|
import { cn, fmt } from '@/lib/utils';
|
|
|
|
|
import {
|
|
|
|
|
Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
|
|
|
|
|
} from '@/components/ui/table';
|
2026-06-07 00:41:07 -05:00
|
|
|
import { TRACKER_SORT_ASC, TRACKER_SORT_DEFAULT, moveInArray } from '@/lib/trackerUtils';
|
2026-05-31 15:06:10 -05:00
|
|
|
import { TrackerRow as Row } from '@/components/tracker/TrackerRow';
|
|
|
|
|
import { MobileTrackerRow } from '@/components/tracker/MobileTrackerRow';
|
|
|
|
|
|
2026-06-07 00:41:07 -05:00
|
|
|
function SortableHead({ sortKey, activeSortKey, sortDir, onSort, children, className }) {
|
|
|
|
|
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('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-xs font-medium uppercase tracking-wider 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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 14:49:39 -05:00
|
|
|
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() }) {
|
2026-05-31 15:06:10 -05:00
|
|
|
const [draggingId, setDraggingId] = useState(null);
|
|
|
|
|
const [dropTargetId, setDropTargetId] = useState(null);
|
|
|
|
|
// 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;
|
|
|
|
|
|
|
|
|
|
function reorderByIndex(fromIndex, toIndex) {
|
|
|
|
|
if (!reorderEnabled || fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return;
|
|
|
|
|
onReorderRows?.(moveInArray(rows, fromIndex, toIndex));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dragPropsFor(row, index) {
|
|
|
|
|
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, index) {
|
|
|
|
|
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="rounded-xl border border-border/80 overflow-hidden bg-card/95 shadow-sm shadow-black/15">
|
|
|
|
|
|
|
|
|
|
{/* Bucket header */}
|
2026-06-07 14:58:37 -05:00
|
|
|
<div className="flex flex-col gap-2 bg-muted/35 px-3 py-3 border-b border-border/80 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}
|
2026-05-31 15:06:10 -05:00
|
|
|
</span>
|
2026-06-07 14:58:37 -05:00
|
|
|
{skippedCount > 0 && (
|
|
|
|
|
<span className="shrink-0 text-[10px] text-muted-foreground/60">
|
|
|
|
|
({skippedCount} skipped)
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex min-w-[7rem] items-center gap-2">
|
|
|
|
|
<div className="h-1.5 w-16 rounded-full bg-border overflow-hidden sm:w-24">
|
2026-05-31 15:06:10 -05:00
|
|
|
<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>
|
2026-06-07 14:58:37 -05:00
|
|
|
<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="whitespace-nowrap">
|
2026-05-31 15:06:10 -05:00
|
|
|
<span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}>
|
|
|
|
|
{fmt(totalPaidTowardDue)}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-muted-foreground/50 mx-1">/</span>
|
|
|
|
|
{fmt(totalThreshold)}
|
|
|
|
|
{totalOverpaid > 0 && (
|
|
|
|
|
<span className="ml-1 text-emerald-500">+{fmt(totalOverpaid)}</span>
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
{!allPaid && totalRemaining > 0 && (
|
|
|
|
|
<span className="text-[11px] text-muted-foreground/70">{fmt(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>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-07 15:14:09 -05:00
|
|
|
<LayoutGroup id={`tracker-bucket-mobile-${label}`}>
|
2026-05-31 15:06:10 -05:00
|
|
|
<div className="grid gap-3 p-3 lg:hidden" aria-busy={loading ? 'true' : 'false'}>
|
|
|
|
|
{loading ? (
|
|
|
|
|
Array.from({ length: 3 }).map((_, i) => (
|
|
|
|
|
<div key={i} className="rounded-lg border border-border/60 bg-background/60 p-3 animate-pulse">
|
|
|
|
|
<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 ? (
|
2026-06-07 15:14:09 -05:00
|
|
|
<div className="rounded-lg bg-muted/15 px-4 py-8 text-center text-sm text-muted-foreground">
|
|
|
|
|
No bills match — try adjusting your filters.
|
2026-05-31 15:06:10 -05:00
|
|
|
</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)}
|
2026-06-07 14:49:39 -05:00
|
|
|
isDrifted={driftedIds.has(r.id)}
|
2026-05-31 15:06:10 -05:00
|
|
|
/>
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-06-07 15:14:09 -05:00
|
|
|
</LayoutGroup>
|
2026-05-31 15:06:10 -05:00
|
|
|
|
|
|
|
|
<div className="hidden lg:block" aria-busy={loading ? 'true' : 'false'}>
|
|
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
<Table className="min-w-[1120px]">
|
|
|
|
|
<TableHeader>
|
|
|
|
|
<TableRow className="border-border/80 bg-background/30 hover:bg-background/30">
|
2026-06-07 00:41:07 -05:00
|
|
|
<SortableHead sortKey="name" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[18%]">Bill</SortableHead>
|
|
|
|
|
<SortableHead sortKey="due" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Due</SortableHead>
|
|
|
|
|
<SortableHead sortKey="expected" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-right">Expected</SortableHead>
|
|
|
|
|
<SortableHead sortKey="previous" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-right">Last Month</SortableHead>
|
2026-06-07 16:44:40 -05:00
|
|
|
<SortableHead sortKey="paid" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Paid</SortableHead>
|
2026-06-07 00:41:07 -05:00
|
|
|
<SortableHead sortKey="paid_date" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Paid Date</SortableHead>
|
2026-06-07 16:44:40 -05:00
|
|
|
<SortableHead sortKey="status" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[9%] text-center">Status</SortableHead>
|
|
|
|
|
<TableHead className="w-[10%] py-2.5 text-center text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
|
|
|
Action
|
|
|
|
|
</TableHead>
|
2026-06-04 00:50:50 -05:00
|
|
|
<TableHead className="w-[23%] py-2.5 text-xs font-medium uppercase tracking-wider text-muted-foreground border-l border-border/80 pl-4">
|
2026-05-31 15:06:10 -05:00
|
|
|
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>
|
|
|
|
|
<TableCell className="w-[10%] py-3"><div className="h-3 w-20 rounded-md bg-muted" /></TableCell>
|
|
|
|
|
<TableCell className="w-[10%] py-3 text-right"><div className="h-3 w-20 ml-auto rounded-md bg-muted" /></TableCell>
|
|
|
|
|
<TableCell className="w-[10%] py-3 text-right"><div className="h-3 w-20 ml-auto rounded-md bg-muted" /></TableCell>
|
2026-06-07 16:44:40 -05:00
|
|
|
<TableCell className="w-[10%] py-3"><div className="mx-auto h-7 w-24 rounded-md bg-muted" /></TableCell>
|
2026-05-31 15:06:10 -05:00
|
|
|
<TableCell className="w-[10%] py-3"><div className="h-7 w-24 rounded-md bg-muted" /></TableCell>
|
2026-06-07 16:44:40 -05:00
|
|
|
<TableCell className="w-[9%] py-3"><div className="mx-auto h-5 w-20 rounded-md bg-muted" /></TableCell>
|
|
|
|
|
<TableCell className="w-[10%] py-3 text-center">
|
|
|
|
|
<div className="flex items-center justify-center gap-1">
|
2026-05-31 15:06:10 -05:00
|
|
|
<div className="h-7 w-20 rounded-md bg-muted" />
|
|
|
|
|
<div className="h-7 w-7 rounded-md bg-muted" />
|
|
|
|
|
</div>
|
|
|
|
|
</TableCell>
|
|
|
|
|
<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={9} className="py-10 text-center text-sm text-muted-foreground">
|
2026-06-07 15:14:09 -05:00
|
|
|
No bills match — try adjusting your filters.
|
2026-05-31 15:06:10 -05:00
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
) : (
|
2026-06-07 15:14:09 -05:00
|
|
|
<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)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</LayoutGroup>
|
2026-05-31 15:06:10 -05:00
|
|
|
)}
|
|
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|