BillTracker/client/components/tracker/TrackerBucket.jsx

291 lines
14 KiB
JavaScript

import { useState } from 'react';
import { LayoutGroup } from 'framer-motion';
import { ArrowDown, ArrowUp } from 'lucide-react';
import { cn, fmt } from '@/lib/utils';
import {
Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
} from '@/components/ui/table';
import { TRACKER_SORT_ASC, TRACKER_SORT_DEFAULT, moveInArray } from '@/lib/trackerUtils';
import { TrackerRow as Row } from '@/components/tracker/TrackerRow';
import { MobileTrackerRow } from '@/components/tracker/MobileTrackerRow';
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>
);
}
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() }) {
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 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}
</span>
{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">
<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="whitespace-nowrap">
<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>
<LayoutGroup id={`tracker-bucket-mobile-${label}`}>
<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 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)}
/>
))
)}
</div>
</LayoutGroup>
<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">
<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>
<SortableHead sortKey="paid" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Paid</SortableHead>
<SortableHead sortKey="paid_date" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Paid Date</SortableHead>
<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>
<TableHead className="w-[23%] py-2.5 text-xs font-medium uppercase tracking-wider 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>
<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="mx-auto h-7 w-24 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="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">
<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 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)}
/>
))}
</LayoutGroup>
)}
</TableBody>
</Table>
</div>
</div>
</div>
);
}