import { useState } from 'react';
import { LayoutGroup } from 'framer-motion';
import { ArrowDown, ArrowUp, CheckCircle2, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { cn, fmt } from '@/lib/utils';
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 } 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 (
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'
)}
>
{children}
);
}
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(),
visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS,
}) {
const [draggingId, setDraggingId] = useState(null);
const [dropTargetId, setDropTargetId] = useState(null);
const visibleColumnSet = new Set(visibleColumns);
const showColumn = key => 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));
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(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(err.message || 'Failed to undo payments.');
}
},
},
});
}
refresh?.();
} catch (err) {
toast.error(err.message || 'Failed to pay bills.');
} finally {
setPayingAll(false);
}
}
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 (
{/* Bucket header */}
{label}
{skippedCount > 0 && (
({skippedCount} skipped)
)}
{fmt(totalPaidTowardDue)}
/
{fmt(totalThreshold)}
{totalOverpaid > 0 && (
+{fmt(totalOverpaid)}
)}
{!allPaid && totalRemaining > 0 && (
{fmt(totalRemaining)} left
)}
{allPaid && (
Done
)}
{!reorderEnabled && rows.length > 1 && (
Clear filters to reorder
)}
{payAllItems.length > 0 && !loading && (
setPayAllOpen(true)}
>
{payingAll
? <> Paying…>
: <> Pay all due ({payAllItems.length})>}
)}
Pay all due in {label}?
This records a payment for {payAllItems.length} unpaid bill{payAllItems.length === 1 ? '' : 's'} in this
bucket, totalling {fmt(payAllTotal)}. You can undo it right after.
Cancel
{ e.preventDefault(); handlePayAllDue(); }} disabled={payingAll}>
{payingAll ? 'Paying…' : `Pay ${fmt(payAllTotal)}`}
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
))
) : rows.length === 0 ? (
No bills match — try adjusting your filters.
) : (
rows.map((r, i) => (
))
)}
Bill
{showColumn('due') && (
Due
)}
{showColumn('expected') && (
Expected
)}
{showColumn('previous') && (
Last Month
)}
{showColumn('paid') && (
Paid
)}
{showColumn('paid_date') && (
Paid Date
)}
{showColumn('status') && (
Status
)}
{showColumn('action') && (
Action
)}
{showColumn('notes') && (
Notes
)}
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
{showColumn('due') &&
}
{showColumn('expected') &&
}
{showColumn('previous') &&
}
{showColumn('paid') &&
}
{showColumn('paid_date') &&
}
{showColumn('status') &&
}
{showColumn('action') && (
)}
{showColumn('notes') && (
)}
))
) : rows.length === 0 ? (
No bills match — try adjusting your filters.
) : (
{rows.map((r, i) => (
))}
)}
);
}