import { useState, useEffect, useCallback, useRef } from 'react'; import { ChevronLeft, ChevronRight, Pencil, TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2 } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api.js'; import BillModal from '@/components/BillModal'; import { cn, fmt, fmtDate, todayStr } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell, } from '@/components/ui/table'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; const MONTHS = [ 'January','February','March','April','May','June', 'July','August','September','October','November','December', ]; // Sentinel for the "no method" select option — empty string crashes Radix Select const METHOD_NONE = 'none'; function paymentDateForTrackerMonth(year, month, dueDay) { const now = new Date(); if (year === now.getFullYear() && month === now.getMonth() + 1) { return todayStr(); } const daysInMonth = new Date(year, month, 0).getDate(); const day = Number.isInteger(Number(dueDay)) ? Math.min(Math.max(Number(dueDay), 1), daysInMonth) : 1; return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; } const ROW_STATUS_CLS = { paid: 'bg-emerald-500/[0.04]', autodraft: 'bg-sky-500/[0.04]', upcoming: '', due_soon: 'bg-amber-400/[0.07]', late: 'bg-orange-400/[0.08]', missed: 'bg-red-400/[0.08]', }; const STATUS_META = { paid: { label: 'Paid', cls: 'bg-emerald-500/15 text-emerald-500 border border-emerald-500/30' }, upcoming: { label: 'Upcoming', cls: 'bg-secondary text-muted-foreground border border-border' }, due_soon: { label: 'Due Soon', cls: 'bg-amber-400/15 text-amber-500 border border-amber-400/30' }, late: { label: 'Late', cls: 'bg-orange-400/15 text-orange-500 border border-orange-400/30' }, missed: { label: 'Missed', cls: 'bg-red-400/15 text-red-500 border border-red-400/30' }, autodraft: { label: 'Autodraft', cls: 'bg-sky-400/15 text-sky-500 border border-sky-400/30' }, skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' }, }; // ── Summary cards ────────────────────────────────────────────────────────── const CARD_DEFS = { expected: { label: 'Total Expected', icon: TrendingUp, bar: 'from-slate-400 to-slate-300', glow: '', valueClass: 'text-foreground', activateWhen: () => true, }, paid: { label: 'Total Paid', icon: CheckCircle2, bar: 'from-emerald-500 to-emerald-300', glow: 'shadow-[0_4px_20px_rgba(16,185,129,0.15)]', borderActive: 'border-emerald-400/40', valueClass: 'text-emerald-500', activateWhen: (v) => v > 0, }, remaining: { label: 'Remaining', icon: Clock, bar: 'from-blue-400 to-indigo-300', glow: '', valueClass: 'text-foreground', activateWhen: () => true, }, overdue: { label: 'Overdue', icon: AlertCircle, bar: 'from-rose-500 to-orange-400', glow: 'shadow-[0_4px_20px_rgba(239,68,68,0.12)]', borderActive: 'border-red-400/40', valueClass: 'text-red-500', activateWhen: (v) => v > 0, }, }; function SummaryCard({ type, value }) { const def = CARD_DEFS[type]; const isActive = def.activateWhen(value || 0); const Icon = def.icon; return (

{def.label}

{fmt(value)}

); } // ── Status badge ─────────────────────────────────────────────────────────── function StatusBadge({ status }) { const meta = STATUS_META[status] || STATUS_META.upcoming; return ( {meta.label} ); } // ── Inline-editable payment cell ─────────────────────────────────────────── // `threshold` = actual_amount ?? expected_amount for this bill/month function EditableCell({ row, field, threshold, defaultPaymentDate, refresh }) { const [editing, setEditing] = useState(false); const [value, setValue] = useState(''); const inputRef = useRef(null); const displayVal = field === 'amount' ? (row.total_paid > 0 ? fmt(row.total_paid) : '—') : (row.last_paid_date ? fmtDate(row.last_paid_date) : '—'); const isEmpty = field === 'amount' ? row.total_paid <= 0 : !row.last_paid_date; // Mismatch when paid amount differs from the effective threshold for this month const mismatch = field === 'amount' && row.total_paid > 0 && row.total_paid !== threshold; function startEdit() { if (editing) return; setValue(field === 'amount' ? (row.total_paid > 0 ? String(row.total_paid) : '') : (row.last_paid_date || '')); setEditing(true); setTimeout(() => { inputRef.current?.focus(); inputRef.current?.select(); }, 0); } async function commit() { setEditing(false); const val = value.trim(); if (!val) return; try { if (row.payments && row.payments.length > 0) { const update = {}; if (field === 'amount') update.amount = parseFloat(val); if (field === 'date') update.paid_date = val; await api.updatePayment(row.payments[0].id, update); } else { await api.createPayment({ bill_id: row.id, amount: field === 'amount' ? parseFloat(val) : threshold, paid_date: field === 'date' ? val : defaultPaymentDate, }); } toast.success('Saved'); refresh(); } catch (err) { toast.error(err.message); } } function onKeyDown(e) { if (e.key === 'Enter') inputRef.current?.blur(); if (e.key === 'Escape') { setValue(''); setEditing(false); } } if (editing) { return ( setValue(e.target.value)} onBlur={commit} onKeyDown={onKeyDown} className="h-7 w-28 text-right font-mono text-sm bg-background/80 border-border/60" /> ); } return ( {displayVal} ); } // ── Notes cell (payment-level notes) ────────────────────────────────────── function NotesCell({ row, refresh }) { const payment = row.payments?.[0]; const savedNote = payment?.notes || ''; const [value, setValue] = useState(savedNote); const [saving, setSaving] = useState(false); async function handleBlur() { const trimmed = value.trim(); if (trimmed === savedNote) return; if (!payment) { toast.error('Pay this bill first before adding a note'); setValue(''); return; } setSaving(true); try { await api.updatePayment(payment.id, { notes: trimmed || null }); refresh(); } catch (err) { toast.error(err.message); setValue(savedNote); } finally { setSaving(false); } } return ( setValue(e.target.value)} onBlur={handleBlur} onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); }} placeholder={payment ? 'Add a note…' : '—'} disabled={!payment || saving} className={cn( 'w-full bg-transparent text-sm placeholder:text-muted-foreground/40', 'border-0 outline-none ring-0', 'text-muted-foreground focus:text-foreground', 'transition-colors duration-150', 'disabled:cursor-not-allowed disabled:opacity-40', value && 'text-foreground/80', )} /> ); } // ── Monthly state dialog ─────────────────────────────────────────────────── // Edits actual_amount, monthly notes, and is_skipped for a specific bill+month. // Changes are isolated to the selected month — other months are not affected. function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }) { const [actualAmount, setActualAmount] = useState(''); const [notes, setNotes] = useState(''); const [isSkipped, setIsSkipped] = useState(false); const [saving, setSaving] = useState(false); // Populate from current row state when dialog opens useEffect(() => { if (open) { setActualAmount(row.actual_amount != null ? String(row.actual_amount) : ''); setNotes(row.monthly_notes || ''); setIsSkipped(!!row.is_skipped); } }, [open, row]); async function handleSave(e) { e.preventDefault(); const amt = actualAmount.trim() ? parseFloat(actualAmount) : null; if (amt !== null && (isNaN(amt) || amt < 0)) { toast.error('Amount must be a positive number or empty'); return; } setSaving(true); try { await api.saveBillMonthlyState(row.id, { year, month, actual_amount: amt, notes: notes.trim() || null, is_skipped: isSkipped, }); toast.success(`${MONTHS[month - 1]} state saved`); onSaved(); onOpenChange(false); } catch (err) { toast.error(err.message); } finally { setSaving(false); } } return ( {row.name} {MONTHS[month - 1]} {year}

Monthly overrides — changes only affect {MONTHS[month - 1]}

{/* Actual amount this month */}
setActualAmount(e.target.value)} className="font-mono bg-background/50 border-border/60" />

Leave blank to use the template default ({fmt(row.expected_amount)}).

{/* Monthly notes */}
setNotes(e.target.value)} placeholder="e.g. higher than usual, double-billed…" className="bg-background/50 border-border/60" />
{/* Skip this month */}
); } // ── Payment modal ────────────────────────────────────────────────────────── function PaymentModal({ payment, onClose, onSave }) { const [amount, setAmount] = useState(String(payment.amount)); const [date, setDate] = useState(payment.paid_date); // Use METHOD_NONE sentinel — empty string value crashes Radix Select const [method, setMethod] = useState(payment.method || METHOD_NONE); const [notes, setNotes] = useState(payment.notes || ''); const [busy, setBusy] = useState(false); async function handleSave(e) { e.preventDefault(); setBusy(true); try { await api.updatePayment(payment.id, { amount: parseFloat(amount), paid_date: date, method: method === METHOD_NONE ? null : method, notes: notes || null, }); toast.success('Payment saved'); onSave(); onClose(); } catch (err) { toast.error(err.message); } finally { setBusy(false); } } async function handleDelete() { setBusy(true); try { await api.deletePayment(payment.id); toast.success('Payment removed. Bill is now marked as unpaid.'); onSave(); onClose(); } catch (err) { toast.error(err.message); } finally { setBusy(false); } } return ( { if (!v) onClose(); }}> Edit Payment
setAmount(e.target.value)} className="font-mono bg-background/50 border-border/60" />
setDate(e.target.value)} className="font-mono bg-background/50 border-border/60" />
setNotes(e.target.value)} className="bg-background/50 border-border/60" />
); } // ── Table row ────────────────────────────────────────────────────────────── function Row({ row, year, month, refresh, index, onEditBill }) { const amountRef = useRef(null); const [editPayment, setEditPayment] = useState(null); const [showMbs, setShowMbs] = useState(false); // Effective amount threshold for this bill this month: // actual_amount (if set by monthly override) takes priority over the template expected_amount. const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount; const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day); // Paid when total payments >= effective threshold const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold; const isPaid = row.status === 'paid' || row.status === 'autodraft' || isPaidByThreshold; const isSkipped = !!row.is_skipped; // Effective status to show: // skipped > paid (threshold-based) > backend status const effectiveStatus = isSkipped ? 'skipped' : (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') ? 'paid' : row.status; const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || ''); async function handleQuickPay() { const val = parseFloat(amountRef.current?.value); if (!val || val <= 0) { toast.error('Enter a payment amount'); return; } try { await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate }); toast.success('Marked as paid'); refresh(); } catch (err) { toast.error(err.message); } } return ( <> {/* Bill name + category + monthly notes (if set) */}
{row.autopay_enabled && ( )}
{row.category_name && (

{row.category_name}

)} {/* Monthly notes shown inline under the bill name */} {row.monthly_notes && (

{row.monthly_notes}

)}
{/* Due */} {fmtDate(row.due_date)} {/* Expected / Actual — shows actual_amount in amber when it overrides the template */} {row.actual_amount != null ? ( {fmt(row.actual_amount)} ) : ( {fmt(row.expected_amount)} )} {/* Amount paid — mismatch now compares against threshold */} {/* Paid date */} {/* Status — uses effectiveStatus (accounts for skipped + threshold) */} {/* Actions */}
{/* Quick pay — hidden for skipped bills */} {!isPaid && !isSkipped && (
)} {/* Edit payment (pencil) */} {row.payments && row.payments.length > 0 && ( )} {/* Monthly state editor (gear icon) — always available */}
{/* Payment-level notes */}
{editPayment && ( setEditPayment(null)} onSave={refresh} /> )} {showMbs && ( )} ); } // ── Bucket ───────────────────────────────────────────────────────────────── function Bucket({ label, rows, year, month, refresh, onEditBill }) { // 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 skippedCount = rows.length - activeRows.length; const pct = totalThreshold > 0 ? Math.min((totalPaid / totalThreshold) * 100, 100) : 0; const allPaid = pct >= 100; return (
{/* Bucket header */}
{label} {skippedCount > 0 && ( ({skippedCount} skipped) )}
{Math.round(pct)}%
{fmt(totalPaid)} / {fmt(totalThreshold)}
Bill Due Expected Paid Paid Date Status Notes {rows.map((r, i) => ( ))}
); } // ── Main page ────────────────────────────────────────────────────────────── 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); // Edit Bill modal: { bill, categories } when open, null when closed const [editBillData, setEditBillData] = useState(null); const load = useCallback(async () => { try { const res = await api.tracker(year, month); setData(res); } catch (err) { toast.error(err.message); } }, [year, month]); useEffect(() => { load(); }, [load]); function navigate(delta) { setMonth(m => { const nm = m + delta; if (nm > 12) { setYear(y => y + 1); return 1; } if (nm < 1) { setYear(y => y - 1); return 12; } return nm; }); } async function handleOpenEditBill(row) { try { const [bill, categories] = await Promise.all([ api.bill(row.id), api.categories(), ]); setEditBillData({ bill, categories }); } catch (err) { toast.error(err.message); } } function goToday() { const n = new Date(); setYear(n.getFullYear()); setMonth(n.getMonth() + 1); } const rows = data?.rows || []; const summary = data?.summary || {}; const first = rows.filter(r => r.bucket === '1st'); const second = rows.filter(r => r.bucket === '15th'); return (
{/* ── Header ── */}

Monthly Overview

{MONTHS[month - 1]} {year}

{rows.length} {rows.length === 1 ? 'bill' : 'bills'}

{/* ── Summary cards (backend already excludes skipped from totals) ── */}
{/* ── Empty state ── */} {rows.length === 0 && data !== null && (

No bills this month

Add a bill
)} {/* ── Buckets — year and month passed so Row can open the correct monthly state dialog ── */} {first.length > 0 && } {second.length > 0 && } {/* Edit Bill modal — opened by clicking a bill name in any tracker row */} {editBillData && ( setEditBillData(null)} onSave={() => { setEditBillData(null); load(); }} /> )}
); }