import { useState, useEffect, useCallback, useRef } from 'react'; import { toast } from 'sonner'; import { ChevronLeft, ChevronRight, MoreHorizontal, ReceiptText } from 'lucide-react'; import { api } from '@/api.js'; import { cn, fmt, fmtDate, todayStr } from '@/lib/utils'; import { Button, buttonVariants } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogFooter, AlertDialogTitle, AlertDialogDescription, AlertDialogCancel, AlertDialogAction, } from '@/components/ui/alert-dialog'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, } from '@/components/ui/dropdown-menu'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell, } from '@/components/ui/table'; // ─── Constants ──────────────────────────────────────────────────────────────── const MONTH_NAMES = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; const PAYMENT_METHODS = [ { value: 'bank_transfer', label: 'Bank Transfer' }, { value: 'card', label: 'Card' }, { value: 'autopay', label: 'Autopay' }, { value: 'check', label: 'Check' }, { value: 'cash', label: 'Cash' }, { value: 'other', label: 'Other' }, ]; // ─── Status Config ──────────────────────────────────────────────────────────── const STATUS = { paid: { label: 'Paid', dot: 'bg-emerald-400', chip: 'bg-emerald-500/10 text-emerald-500' }, upcoming: { label: 'Upcoming', dot: 'bg-slate-400', chip: 'bg-slate-500/10 text-slate-500 dark:text-slate-400' }, due_soon: { label: 'Due Soon', dot: 'bg-amber-400', chip: 'bg-amber-500/10 text-amber-600 dark:text-amber-400' }, late: { label: 'Late', dot: 'bg-orange-400', chip: 'bg-orange-500/10 text-orange-600 dark:text-orange-400' }, missed: { label: 'Missed', dot: 'bg-red-400', chip: 'bg-red-500/10 text-red-500' }, autodraft: { label: 'Autodraft', dot: 'bg-violet-400', chip: 'bg-violet-500/10 text-violet-500' }, }; // ─── Status Chip ────────────────────────────────────────────────────────────── function StatusChip({ status }) { const cfg = STATUS[status] ?? { label: status, dot: 'bg-slate-400', chip: 'bg-slate-500/10 text-slate-500' }; return ( {cfg.label} ); } // ─── Payment Dialog ─────────────────────────────────────────────────────────── function PaymentDialog({ open, onOpenChange, bill, payment, onSuccess }) { const isEdit = !!payment; const [form, setForm] = useState({ amount: '', paid_date: todayStr(), method: '', notes: '', }); const [saving, setSaving] = useState(false); useEffect(() => { if (!open) return; if (isEdit) { setForm({ amount: String(payment.amount ?? ''), paid_date: payment.paid_date ?? todayStr(), method: payment.method ?? '', notes: payment.notes ?? '', }); } else { setForm({ amount: String(bill?.expected_amount ?? ''), paid_date: todayStr(), method: '', notes: '', }); } }, [open, isEdit, payment, bill]); const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value })); async function handleSubmit() { if (!form.amount || !form.paid_date) { toast.error('Amount and date are required'); return; } const amount = parseFloat(form.amount); if (isNaN(amount) || amount <= 0) { toast.error('Enter a valid amount'); return; } setSaving(true); try { const payload = { amount, paid_date: form.paid_date, method: form.method || null, notes: form.notes || null, }; if (isEdit) { await api.updatePayment(payment.id, payload); toast.success('Payment updated'); } else { await api.quickPay({ bill_id: bill.id, ...payload }); toast.success(`Paid ${fmt(amount)} for ${bill.name}`); } onOpenChange(false); onSuccess?.(); } catch (err) { toast.error(err.message); } finally { setSaving(false); } } const title = isEdit ? `Edit Payment — ${bill?.name ?? ''}` : `Record Payment — ${bill?.name ?? ''}`; return ( {title}
{/* Amount */}
$
{/* Date Paid */}
{/* Method */}
{/* Notes */}
); } // ─── Remove Payment Alert Dialog ────────────────────────────────────────────── function RemovePaymentDialog({ open, onOpenChange, bill, paymentId, onSuccess }) { const [removing, setRemoving] = useState(false); async function handleConfirm() { setRemoving(true); try { await api.deletePayment(paymentId); toast.success('Payment removed'); onOpenChange(false); onSuccess?.(); } catch (err) { toast.error(err.message); } finally { setRemoving(false); } } return ( Remove payment? Removing this payment will mark{' '} {bill?.name}{' '} as unpaid. The bill itself will not be deleted. Cancel {removing ? 'Removing…' : 'Remove Payment'} ); } // ─── Bill Form stub ─────────────────────────────────────────────────────────── function openBillForm(bill) { // TODO: replace with shared BillFormDialog extracted from BillsPage console.log('[BillFormDialog] Edit bill:', bill); } // ─── Row Actions Dropdown ───────────────────────────────────────────────────── function RowActions({ row, payInputRef, onRefresh }) { const [payDialogOpen, setPayDialogOpen] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false); const [removeDialogOpen, setRemoveDialogOpen] = useState(false); const isPaid = row.status === 'paid' || row.status === 'autodraft'; const latestPayment = row.payments?.length ? [...row.payments].sort((a, b) => b.paid_date.localeCompare(a.paid_date))[0] : null; async function handleQuickPay() { const raw = payInputRef?.current?.value ?? String(row.expected_amount ?? ''); const amount = parseFloat(raw); if (isNaN(amount) || amount <= 0) { toast.error('Enter a valid amount'); return; } try { await api.quickPay({ bill_id: row.id, amount, paid_date: todayStr() }); toast.success(`Paid ${fmt(amount)} for ${row.name}`); onRefresh(); } catch (err) { toast.error(err.message); } } return ( <> {!isPaid ? ( <> Quick Pay setPayDialogOpen(true)}> Record Payment… ) : ( <> setEditDialogOpen(true)}> Edit Payment… setRemoveDialogOpen(true)} > Remove Payment… )} openBillForm(row)}> Edit Bill… {/* Record Payment dialog (unpaid) */} {/* Edit Payment dialog (paid) */} {/* Remove Payment alert dialog */} ); } // ─── Bill Row ───────────────────────────────────────────────────────────────── function BillRow({ row, onRefresh }) { const payInputRef = useRef(null); const isPaid = row.status === 'paid' || row.status === 'autodraft'; return ( {/* Bill name + category */}
{row.autopay_enabled && ( )}

{row.name}

{row.category_name && (

{row.category_name}

)}
{/* Due date */} {fmtDate(row.due_date)} {/* Expected */} {fmt(row.expected_amount)} {/* Paid Date */} {row.last_paid_date ? ( {fmtDate(row.last_paid_date)} ) : ( )} {/* Paid — input for unpaid, amount for paid */} {isPaid ? ( {fmt(row.total_paid)} ) : ( )} {/* Status */} {/* Actions */}
); } // ─── Bucket Section ─────────────────────────────────────────────────────────── function BucketSection({ title, rows, paidAmount, totalAmount, loading, onRefresh }) { return (
{/* Floating bucket header — no card wrapper */}

{title}

{!loading && rows.length > 0 && ( {fmt(paidAmount)} paid of {fmt(totalAmount)} )}
{/* Table wrapped in card */}
Bill Due Expected Paid Date Paid Status {loading ? ( [1, 2, 3].map((i) => (
)) ) : rows.length === 0 ? (

No bills for this period

Add a bill →
) : ( rows.map((row) => ( )) )}
); } // ─── TrackerPage ────────────────────────────────────────────────────────────── export default function TrackerPage() { const now = new Date(); const [year, setYear] = useState(now.getFullYear()); const [month, setMonth] = useState(now.getMonth() + 1); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const load = useCallback(async () => { setLoading(true); try { const res = await api.tracker(year, month); setData(res); } catch (err) { toast.error(err.message); } finally { setLoading(false); } }, [year, month]); useEffect(() => { load(); }, [load]); function navigate(direction) { if (direction === -1) { if (month === 1) { setYear((y) => y - 1); setMonth(12); } else setMonth((m) => m - 1); } else { if (month === 12) { setYear((y) => y + 1); setMonth(1); } else setMonth((m) => m + 1); } } function goToToday() { const t = new Date(); setYear(t.getFullYear()); setMonth(t.getMonth() + 1); } const rows = data?.rows ?? []; const summary = data?.summary ?? {}; const isCurrentMonth = year === now.getFullYear() && month === now.getMonth() + 1; const paidCount = rows.filter((r) => r.status === 'paid' || r.status === 'autodraft').length; const overdueCount = rows.filter((r) => r.status === 'late' || r.status === 'missed').length; const allPaid = rows.length > 0 && paidCount === rows.length; const bucket1 = rows.filter((r) => r.bucket === '1st'); const bucket15 = rows.filter((r) => r.bucket === '15th'); function bucketPaid(bucket) { return bucket.reduce((sum, r) => { const isPaid = r.status === 'paid' || r.status === 'autodraft'; return sum + (isPaid ? (r.total_paid || 0) : 0); }, 0); } function bucketTotal(bucket) { return bucket.reduce((sum, r) => sum + (r.expected_amount || 0), 0); } return (
{/* ── Page Header — floats on page background, no card ── */}

{MONTH_NAMES[month - 1]} {year}

Monthly overview

{!isCurrentMonth && ( )}
{/* ── Summary Stat Tiles ── */}
{/* Total Expected */}

Total Expected

{fmt(summary.total_expected)}

{rows.length} bill{rows.length !== 1 ? 's' : ''} this month

{/* Paid */}

Paid

{fmt(summary.total_paid)}

{paidCount} of {rows.length} paid

{/* Remaining */}

Remaining

{fmt(summary.remaining)}

0 ? 'text-orange-400' : 'text-muted-foreground')}> {overdueCount > 0 ? `includes ${fmt(summary.overdue)} overdue` : `${rows.length - paidCount} unpaid`}

{/* Overdue */}

Overdue

0 ? 'text-red-500' : 'text-muted-foreground')}> {fmt(summary.overdue)}

{overdueCount > 0 ? `${overdueCount} bill${overdueCount !== 1 ? 's' : ''} overdue` : 'All clear'}

{/* ── Bucket Sections ── */}
); }