249 lines
12 KiB
React
249 lines
12 KiB
React
|
|
import { useState } from 'react';
|
||
|
|
import { cn, fmt } from '@/lib/utils';
|
||
|
|
import {
|
||
|
|
Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
|
||
|
|
} from '@/components/ui/table';
|
||
|
|
import { moveInArray } from '@/lib/trackerUtils';
|
||
|
|
import { TrackerRow as Row } from '@/components/tracker/TrackerRow';
|
||
|
|
import { MobileTrackerRow } from '@/components/tracker/MobileTrackerRow';
|
||
|
|
|
||
|
|
export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, loading, onReorderRows, reorderEnabled, movingBillId }) {
|
||
|
|
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 */}
|
||
|
|
<div className="flex items-center justify-between px-5 py-3 bg-muted/35 border-b border-border/80">
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
||
|
|
{label}
|
||
|
|
</span>
|
||
|
|
{skippedCount > 0 && (
|
||
|
|
<span className="text-[10px] text-muted-foreground/60">
|
||
|
|
({skippedCount} skipped)
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<div className="h-1.5 w-24 rounded-full bg-border overflow-hidden">
|
||
|
|
<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 items-center gap-3 text-xs font-mono text-muted-foreground">
|
||
|
|
<span>
|
||
|
|
<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>
|
||
|
|
|
||
|
|
<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 ? (
|
||
|
|
<div className="rounded-lg border border-dashed border-border/70 bg-background/40 px-4 py-8 text-center text-sm text-muted-foreground">
|
||
|
|
No bills match this bucket and filter set.
|
||
|
|
</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)}
|
||
|
|
/>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<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">
|
||
|
|
<TableHead className="w-[18%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Bill</TableHead>
|
||
|
|
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Due</TableHead>
|
||
|
|
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90 text-right">Expected</TableHead>
|
||
|
|
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/80 text-right">Last Month</TableHead>
|
||
|
|
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90 text-right">Paid</TableHead>
|
||
|
|
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Paid Date</TableHead>
|
||
|
|
<TableHead className="w-[9%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Status</TableHead>
|
||
|
|
<TableHead className="w-[10%] py-2.5" />
|
||
|
|
<TableHead className="w-[23%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90 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>
|
||
|
|
<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>
|
||
|
|
<TableCell className="w-[10%] py-3"><div className="h-7 w-24 ml-auto rounded-md bg-muted" /></TableCell>
|
||
|
|
<TableCell className="w-[10%] py-3"><div className="h-7 w-24 rounded-md bg-muted" /></TableCell>
|
||
|
|
<TableCell className="w-[9%] py-3"><div className="h-5 w-20 rounded-md bg-muted" /></TableCell>
|
||
|
|
<TableCell className="w-[10%] py-3 text-right">
|
||
|
|
<div className="flex items-center justify-end gap-1">
|
||
|
|
<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">
|
||
|
|
No bills match this bucket and filter set.
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
) : (
|
||
|
|
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)}
|
||
|
|
/>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|