import React, { useState, useEffect, useCallback, useRef, useMemo, useTransition } from 'react'; import { useSearchParams } from 'react-router-dom'; import { ChevronLeft, ChevronRight, Pencil, TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2, Loader2, Plus, Search, X, RefreshCw } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api.js'; import { useTracker } from '@/hooks/useQueries'; import BillModal from '@/components/BillModal'; import { makeBillDraft } from '@/lib/billDrafts'; import { cn, fmt, fmtDate, todayStr } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Skeleton } from '@/components/ui/Skeleton'; import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell, } from '@/components/ui/table'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, } from '@/components/ui/alert-dialog'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog'; import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog'; import PaymentModal from '@/components/tracker/PaymentModal'; const MONTHS = [ 'January','February','March','April','May','June', 'July','August','September','October','November','December', ]; const FILTER_ALL = 'all'; // 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] dark:bg-emerald-400/[0.02]', autodraft: 'bg-sky-500/[0.04] dark:bg-sky-400/[0.018]', upcoming: '', due_soon: 'bg-amber-400/[0.07] dark:bg-amber-300/[0.016]', late: 'border-l-4 border-l-orange-400 bg-orange-500/[0.16] ring-1 ring-inset ring-orange-400/25 dark:bg-orange-400/[0.11] dark:ring-orange-300/25', missed: 'border-l-4 border-l-rose-400 bg-rose-500/[0.18] ring-1 ring-inset ring-rose-400/30 dark:bg-rose-400/[0.13] dark:ring-rose-300/30', }; const STATUS_META = { paid: { label: 'Paid', cls: 'bg-emerald-500/15 text-emerald-500 border border-emerald-500/30 dark:bg-emerald-300/10 dark:text-emerald-200 dark:border-emerald-300/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 dark:bg-amber-300/10 dark:text-amber-200 dark:border-amber-300/28' }, late: { label: 'Late', cls: 'bg-orange-500/30 text-orange-800 border border-orange-500/60 shadow-sm shadow-orange-950/10 dark:bg-orange-400/25 dark:text-orange-100 dark:border-orange-300/60' }, missed: { label: 'Missed', cls: 'bg-rose-500/30 text-rose-800 border border-rose-500/70 shadow-sm shadow-rose-950/10 dark:bg-rose-400/25 dark:text-rose-100 dark:border-rose-300/60' }, autodraft: { label: 'Autodraft', cls: 'bg-sky-400/15 text-sky-500 border border-sky-400/30 dark:bg-sky-300/10 dark:text-sky-200 dark:border-sky-300/28' }, skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' }, }; function amountSearchText(...values) { return values .filter(value => value !== null && value !== undefined && Number.isFinite(Number(value))) .flatMap(value => { const num = Number(value); return [String(num), num.toFixed(2), `$${num.toFixed(2)}`]; }) .join(' '); } function rowThreshold(row) { return row.actual_amount != null ? row.actual_amount : row.expected_amount; } function rowEffectiveStatus(row) { if (row.is_skipped) return 'skipped'; const threshold = rowThreshold(row); const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold; return (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') ? 'paid' : row.status; } function rowIsPaid(row) { const status = rowEffectiveStatus(row); if (row.autopay_suggestion && status === 'autodraft') return false; return status === 'paid' || status === 'autodraft'; } function rowIsDebt(row) { const category = String(row.category_name || '').toLowerCase(); return Number(row.current_balance) > 0 || row.minimum_payment != null || ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token)); } function FilterChip({ active, children, onClick }) { return ( ); } // ── Summary cards ────────────────────────────────────────────────────────── const CARD_DEFS = { starting: { label: 'Starting', 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-600 dark:text-emerald-200', 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-400 to-orange-300', glow: 'shadow-[0_4px_20px_rgba(251,113,133,0.10)]', borderActive: 'border-rose-400/35', valueClass: 'text-red-500 dark:text-rose-200', activateWhen: (v) => v > 0, }, }; function TrendIndicator({ trend }) { if (!trend) return null; const { direction, percent_change } = trend; let icon, color, text; switch (direction) { case 'up': icon = '↑'; color = 'text-emerald-500'; text = `${icon} ${percent_change}%`; break; case 'down': icon = '↓'; color = 'text-red-500'; text = `${icon} ${Math.abs(percent_change)}%`; break; default: icon = '→'; color = 'text-muted-foreground'; text = `${icon} ${percent_change}%`; } return (
{text} vs 3-mo avg
); } function SummaryCard({ type, value, onEdit, hint, label }) { const def = CARD_DEFS[type]; const isActive = def.activateWhen(value || 0); const Icon = def.icon; const displayLabel = label || def.label; return (

{displayLabel}

{type === 'starting' && onEdit && ( )}

{fmt(value)}

{hint &&

{hint}

}
); } function TrendCard({ trend }) { if (!trend) return null; return (

3-Month Trend

); } // ── Status badge ─────────────────────────────────────────────────────────── const StatusBadge = React.memo(function StatusBadge({ status, clickable, onClick, loading }) { const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]); const isSkipped = status === 'skipped'; const isUrgent = status === 'late' || status === 'missed'; const canClick = clickable && !isSkipped && !loading; return ( ); }); function AutopaySuggestionActions({ row, loading, onConfirm, onDismiss, compact = false }) { const suggestion = row.autopay_suggestion; if (!suggestion) return null; const title = `${fmt(suggestion.amount)} due ${fmtDate(suggestion.paid_date)}`; return (
{compact ? `Suggested ${fmt(suggestion.amount)}` : 'Suggested'}
); } // ── 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} ); } function paymentSummary(row, threshold) { const target = Number(threshold) || 0; const paid = Number(row.total_paid) || 0; const paidTowardDue = Number.isFinite(Number(row.paid_toward_due)) ? Number(row.paid_toward_due) : Math.min(paid, target); const overpaid = Number.isFinite(Number(row.overpaid_amount)) ? Number(row.overpaid_amount) : Math.max(paid - target, 0); const remaining = Math.max(target - paidTowardDue, 0); const percent = target > 0 ? Math.min(100, Math.round((paidTowardDue / target) * 100)) : 0; return { target, paid, paidTowardDue, overpaid, remaining, percent, count: Array.isArray(row.payments) ? row.payments.length : 0, partial: paid > 0 && remaining > 0, }; } function PaymentProgress({ row, threshold, onOpen, onMarkFullAmount, compact = false }) { const summary = paymentSummary(row, threshold); const barTone = summary.remaining === 0 ? 'bg-emerald-500' : summary.paid > 0 ? 'bg-amber-500' : 'bg-muted-foreground/40'; const amountLabel = (() => { if (summary.paid === 0) return '—'; if (summary.overpaid > 0) return `${fmt(summary.paidTowardDue)} · overpaid`; if (summary.remaining > 0) return `${fmt(summary.paidTowardDue)} paid`; return fmt(summary.paidTowardDue); })(); const showQuickFix = onMarkFullAmount && summary.partial && summary.paid > 0; return (
{showQuickFix && ( )}
); } function LowerThisMonthButton({ row, year, month, refresh, compact = false }) { const threshold = rowThreshold(row); const summary = paymentSummary(row, threshold); const [saving, setSaving] = useState(false); if (row.is_skipped || !summary.partial) return null; async function handleClick() { setSaving(true); try { await api.saveBillMonthlyState(row.id, { year, month, actual_amount: summary.paid, notes: row.monthly_notes || null, is_skipped: row.is_skipped, }); toast.success(`${MONTHS[month - 1]} amount set to ${fmt(summary.paid)}`); refresh?.(); } catch (err) { toast.error(err.message || 'Failed to update monthly amount'); } finally { setSaving(false); } } return ( ); } function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymentDate, onClose, onSaved }) { const summary = paymentSummary(row, threshold); const [amount, setAmount] = useState(String(summary.remaining || summary.target || '')); const [date, setDate] = useState(defaultPaymentDate); const [method, setMethod] = useState(METHOD_NONE); const [notes, setNotes] = useState(''); const [busy, setBusy] = useState(false); const [editPayment, setEditPayment] = useState(null); const payments = [...(row.payments || [])].sort((a, b) => String(b.paid_date).localeCompare(String(a.paid_date))); async function handleAdd(e) { e.preventDefault(); const parsedAmount = parseFloat(amount); if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { toast.error('Enter a positive payment amount'); return; } if (!date) { toast.error('Choose a payment date'); return; } setBusy(true); try { await api.createPayment({ bill_id: row.id, amount: parsedAmount, paid_date: date, method: method === METHOD_NONE ? null : method, notes: notes || null, }); toast.success('Partial payment added'); onSaved?.(); onClose?.(); } catch (err) { toast.error(err.message || 'Failed to add payment'); } finally { setBusy(false); } } return ( <> { if (!value) onClose(); }}> {row.name} Payments
{}} />

Payment History

{payments.length > 0 ? (
{payments.map(payment => (

{fmt(payment.amount)}

{fmtDate(payment.paid_date)} {payment.method ? ` · ${payment.method}` : ''}

{payment.notes && (

{payment.notes}

)}
))}
) : (

No payments recorded for this month.

)}

Add Partial Payment

setAmount(e.target.value)} className="font-mono bg-background/70 border-border/60" />
setDate(e.target.value)} className="font-mono bg-background/70 border-border/60" />
setNotes(e.target.value)} className="bg-background/70 border-border/60" />
{editPayment && ( setEditPayment(null)} onSave={() => { onSaved?.(); setEditPayment(null); }} /> )} ); } // ── Notes cell (monthly state notes) ───────────────────────────────────── // Shows the monthly state notes for this bill in the current month. // Notes are per-month, not per-bill - each month has its own notes field. function NotesCell({ row, refresh }) { // Monthly notes - the per-month notes stored in monthly_bill_state const savedNote = row.monthly_notes || ''; const [value, setValue] = useState(savedNote); const [saving, setSaving] = useState(false); async function handleBlur() { const trimmed = value.trim(); if (trimmed === savedNote) return; // Need year and month to save to monthly_bill_state // These should be passed via row props from the parent const year = row.year; const month = row.month; if (!year || !month) { toast.error('Cannot save notes without year/month context'); setValue(savedNote); return; } setSaving(true); try { await api.saveBillMonthlyState(row.id, { year, month, notes: trimmed || null, is_skipped: row.is_skipped, actual_amount: row.actual_amount, }); 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='Add monthly notes…' disabled={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', )} /> ); } // ── Table row ────────────────────────────────────────────────────────────── function Row({ row, year, month, refresh, index, onEditBill }) { const amountRef = useRef(null); const [editPayment, setEditPayment] = useState(null); const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false); const [showMbs, setShowMbs] = useState(false); const [confirmUnpay, setConfirmUnpay] = useState(false); const [loading, setLoading] = useState(false); const [suggestionLoading, setSuggestionLoading] = useState(false); const [optimisticActual, setOptimisticActual] = useState(undefined); const [showUpdateNudge, setShowUpdateNudge] = useState(false); const [nudgeAmount, setNudgeAmount] = useState(null); const [, startTransition] = useTransition(); const [editingExpected, setEditingExpected] = useState(false); const [expectedDraft, setExpectedDraft] = useState(''); const [editingDue, setEditingDue] = useState(false); const [dueDraft, setDueDraft] = useState(''); // Effective amount threshold: optimistic override → monthly override → template default. const effectiveActual = optimisticActual !== undefined ? optimisticActual : row.actual_amount; const threshold = effectiveActual != null ? effectiveActual : row.expected_amount; const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day); const isSkipped = !!row.is_skipped; const hasAutopaySuggestion = !!row.autopay_suggestion && !isSkipped; // Paid when total payments >= effective threshold const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold; const isPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold; const summary = paymentSummary(row, threshold); // 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('Payment added'); refresh(); } catch (err) { toast.error(err.message); } } async function performTogglePaid() { setLoading?.(true); try { const result = await api.togglePaid(row.id, { amount: isPaid ? undefined : threshold, year: year, month: month, }); if (isPaid && result.paymentId) { toast.success('Payment moved to recovery', { action: { label: 'Undo', onClick: async () => { try { await api.restorePayment(result.paymentId); toast.success('Payment restored'); refresh?.(); } catch (err) { toast.error(err.message || 'Failed to restore payment'); } }, }, }); } else { toast.success('Payment recorded'); } refresh?.(); } catch (err) { toast.error(err.message || 'Failed to toggle payment status'); } finally { setLoading?.(false); } } function handleTogglePaid() { if (isPaid) { setConfirmUnpay(true); return; } performTogglePaid(); } async function handleMarkFullAmount() { const newActual = summary.paidTowardDue; setOptimisticActual(newActual); try { await api.saveBillMonthlyState(row.id, { year, month, actual_amount: newActual, notes: row.monthly_notes || null, is_skipped: row.is_skipped, }); setNudgeAmount(newActual); setShowUpdateNudge(true); refresh?.(); } catch (err) { setOptimisticActual(undefined); toast.error(err.message || 'Failed to update amount'); } } function handleUpdateTemplate() { const amount = nudgeAmount; setShowUpdateNudge(false); startTransition(async () => { try { await api.updateBill(row.id, { name: row.name, due_day: row.due_day, expected_amount: amount }); toast.success(`Default updated to ${fmt(amount)}`); refresh?.(); } catch (err) { toast.error(err.message || 'Failed to update default'); } }); } async function handleApplySuggestion(amount) { setOptimisticActual(amount); try { await api.saveBillMonthlyState(row.id, { year, month, actual_amount: amount, notes: row.monthly_notes || null, is_skipped: row.is_skipped, }); refresh?.(); } catch (err) { setOptimisticActual(undefined); toast.error(err.message || 'Failed to apply suggestion'); } } async function handleSaveExpected() { setEditingExpected(false); const val = parseFloat(expectedDraft); if (!isFinite(val) || val < 0) return; const current = effectiveActual ?? row.expected_amount; if (val === current) return; if (effectiveActual != null) { setOptimisticActual(val); try { await api.saveBillMonthlyState(row.id, { year, month, actual_amount: val, notes: row.monthly_notes || null, is_skipped: row.is_skipped, }); refresh?.(); } catch (err) { setOptimisticActual(undefined); toast.error(err.message || 'Failed to update amount'); } } else { try { await api.updateBill(row.id, { name: row.name, due_day: row.due_day, expected_amount: val }); refresh?.(); } catch (err) { toast.error(err.message || 'Failed to update expected amount'); } } } async function handleSaveDue() { setEditingDue(false); const day = parseInt(dueDraft, 10); if (!isFinite(day) || day < 1 || day > 31) return; if (day === row.due_day) return; try { await api.updateBill(row.id, { name: row.name, due_day: day, expected_amount: row.expected_amount }); refresh?.(); } catch (err) { toast.error(err.message || 'Failed to update due date'); } } async function handleConfirmSuggestion() { setSuggestionLoading(true); try { const result = await api.confirmAutopaySuggestion(row.id, { year, month }); toast.success(result.created ? 'Autopay payment confirmed' : 'Autopay already recorded'); refresh?.(); } catch (err) { toast.error(err.message || 'Failed to confirm autopay suggestion'); } finally { setSuggestionLoading(false); } } async function handleDismissSuggestion() { setSuggestionLoading(true); try { await api.dismissAutopaySuggestion(row.id, { year, month }); toast.success('Autopay suggestion dismissed'); refresh?.(); } catch (err) { toast.error(err.message || 'Failed to dismiss autopay suggestion'); } finally { setSuggestionLoading(false); } } return ( <> {/* Bill name + category + monthly notes (if set) */}
{row.website ? ( {row.name} ) : ( {row.name} )} {row.autopay_enabled && ( AP )}
{row.category_name && (

{row.category_name}

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

{row.monthly_notes}

)}
{/* Due */} {editingDue ? ( setDueDraft(e.target.value)} onBlur={handleSaveDue} onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') { setEditingDue(false); } }} className="tracker-number w-12 rounded border border-border bg-transparent px-1 py-0.5 text-sm font-medium text-foreground outline-none focus:ring-[2px] focus:ring-ring/50" title="Day of month (1–31)" /> ) : ( )} {/* Expected / Actual — shows actual_amount in amber when it overrides the template */} {editingExpected ? ( setExpectedDraft(e.target.value)} onBlur={handleSaveExpected} onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') { setEditingExpected(false); } }} className="tracker-number w-24 rounded border border-border bg-transparent px-1 py-0.5 text-right text-sm font-semibold text-foreground outline-none focus:ring-[2px] focus:ring-ring/50" /> ) : effectiveActual != null ? ( ) : (
{row.amount_suggestion?.suggestion != null && Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && ( )}
)}
{/* Previous month paid */} {row.previous_month_paid > 0 ? fmt(row.previous_month_paid) : '—'} {/* Amount paid — mismatch now compares against threshold */} setPaymentLedgerOpen(true)} onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined} /> {/* Paid date */} {/* Status — uses effectiveStatus (accounts for skipped + threshold) */} { if (effectiveStatus === 'skipped') return; handleTogglePaid(); }} loading={loading} /> {/* Actions */}
{showUpdateNudge ? (
Update default?
) : ( <> {hasAutopaySuggestion && ( )} {/* Quick pay — hidden for skipped/paid bills */} {!isPaid && !isSkipped && !hasAutopaySuggestion && (
)} )}
{/* Notes cell (monthly state notes) */}
{editPayment && ( setEditPayment(null)} onSave={refresh} /> )} {paymentLedgerOpen && ( setPaymentLedgerOpen(false)} onSaved={refresh} /> )} {showMbs && ( )} Mark this bill unpaid? This removes the current payment record for this month and moves it into recovery. Cancel {loading ? 'Removing...' : 'Remove Payment'} ); } function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) { const amountRef = useRef(null); const [editPayment, setEditPayment] = useState(null); const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false); const [showMbs, setShowMbs] = useState(false); const [confirmUnpay, setConfirmUnpay] = useState(false); const [suggestionLoading, setSuggestionLoading] = useState(false); const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount; const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day); const isSkipped = !!row.is_skipped; const hasAutopaySuggestion = !!row.autopay_suggestion && !isSkipped; const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold; const isPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold; const effectiveStatus = isSkipped ? 'skipped' : (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') ? 'paid' : row.status; const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || ''); const remaining = Math.max((threshold || 0) - (row.total_paid || 0), 0); const summary = paymentSummary(row, threshold); 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('Payment added'); refresh(); } catch (err) { toast.error(err.message); } } async function performTogglePaid() { try { const result = await api.togglePaid(row.id, { amount: isPaid ? undefined : threshold, year: year, month: month, }); if (isPaid && result.paymentId) { toast.success('Payment moved to recovery', { action: { label: 'Undo', onClick: async () => { try { await api.restorePayment(result.paymentId); toast.success('Payment restored'); refresh(); } catch (err) { toast.error(err.message || 'Failed to restore payment'); } }, }, }); } else { toast.success('Payment recorded'); } refresh(); } catch (err) { toast.error(err.message || 'Failed to toggle payment status'); } } function handleTogglePaid() { if (isPaid) { setConfirmUnpay(true); return; } performTogglePaid(); } async function handleConfirmSuggestion() { setSuggestionLoading(true); try { const result = await api.confirmAutopaySuggestion(row.id, { year, month }); toast.success(result.created ? 'Autopay payment confirmed' : 'Autopay already recorded'); refresh(); } catch (err) { toast.error(err.message || 'Failed to confirm autopay suggestion'); } finally { setSuggestionLoading(false); } } async function handleDismissSuggestion() { setSuggestionLoading(true); try { await api.dismissAutopaySuggestion(row.id, { year, month }); toast.success('Autopay suggestion dismissed'); refresh(); } catch (err) { toast.error(err.message || 'Failed to dismiss autopay suggestion'); } finally { setSuggestionLoading(false); } } return ( <>
{row.website ? ( {row.name} ) : ( {row.name} )} {row.autopay_enabled && ( AP )}
{row.monthly_notes && (

{row.monthly_notes}

)}

Due

{fmtDate(row.due_date)}

Category

{row.category_name || 'Uncategorized'}

Expected

{fmt(threshold)}

Last Month

{fmt(row.previous_month_paid)}

Remaining

0 ? 'text-foreground' : 'text-emerald-300')}> {fmt(remaining)}

setPaymentLedgerOpen(true)} compact />
Paid {row.total_paid > 0 ? fmt(row.total_paid) : '—'}
Date
{hasAutopaySuggestion && ( )} {!isPaid && !isSkipped && !hasAutopaySuggestion && (
)}
{editPayment && ( setEditPayment(null)} onSave={refresh} /> )} {paymentLedgerOpen && ( setPaymentLedgerOpen(false)} onSaved={refresh} /> )} {showMbs && ( )} Mark this bill unpaid? This removes the current payment record for this month and moves it into recovery. Cancel Remove Payment ); } // ── Bucket ───────────────────────────────────────────────────────────────── function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) { // 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 skippedCount = rows.length - activeRows.length; const pct = totalThreshold > 0 ? Math.min((totalPaidTowardDue / totalThreshold) * 100, 100) : 0; const allPaid = pct >= 100; return (
{/* Bucket header */}
{label} {skippedCount > 0 && ( ({skippedCount} skipped) )}
{Math.round(pct)}%
{fmt(totalPaidTowardDue)} / {fmt(totalThreshold)} {totalOverpaid > 0 && ( +{fmt(totalOverpaid)} )}
{loading ? ( Array.from({ length: 3 }).map((_, i) => (

Expected

Remaining

)) ) : rows.length === 0 ? (
No bills match this bucket and filter set.
) : ( rows.map((r, i) => ( )) )}
Bill Due Expected Last Month Paid Paid Date Status Notes {loading ? ( Array.from({ length: 5 }).map((_, i) => (
)) ) : rows.length === 0 ? ( No bills match this bucket and filter set. ) : ( rows.map((r, i) => ( )) )}
); } // ── Main page ────────────────────────────────────────────────────────────── export default function TrackerPage() { const [searchParams] = useSearchParams(); const now = new Date(); const [year, setYear] = useState(now.getFullYear()); const [month, setMonth] = useState(now.getMonth() + 1); // Edit Bill modal: { bill, categories } when open, null when closed const [editBillData, setEditBillData] = useState(null); // Edit Starting Amounts modal: true when open, false when closed const [editStartingOpen, setEditStartingOpen] = useState(false); const [search, setSearch] = useState(''); const [filters, setFilters] = useState({ category: FILTER_ALL, cycle: FILTER_ALL, autopay: false, firstBucket: false, fifteenthBucket: false, unpaid: false, overdue: false, debt: false, }); // Use React Query for data fetching const { data, isLoading: loading, isError, error, refetch } = useTracker(year, month); useEffect(() => { const querySearch = searchParams.get('search') || ''; if (querySearch) setSearch(querySearch); }, [searchParams]); 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 toggleFilter = (key) => setFilters(prev => ({ ...prev, [key]: !prev[key] })); const setFilterValue = (key, value) => setFilters(prev => ({ ...prev, [key]: value })); const hasFilters = !!( search.trim() || filters.category !== FILTER_ALL || filters.cycle !== FILTER_ALL || filters.autopay || filters.firstBucket || filters.fifteenthBucket || filters.unpaid || filters.overdue || filters.debt ); const resetFilters = () => { setSearch(''); setFilters({ category: FILTER_ALL, cycle: FILTER_ALL, autopay: false, firstBucket: false, fifteenthBucket: false, unpaid: false, overdue: false, debt: false, }); }; const categoryOptions = useMemo(() => { const map = new Map(); rows.forEach(row => { if (row.category_id && row.category_name) map.set(String(row.category_id), row.category_name); }); return Array.from(map, ([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name)); }, [rows]); const cycleOptions = useMemo(() => ( Array.from(new Set(rows.map(row => row.billing_cycle || 'monthly'))).sort() ), [rows]); const filteredRows = useMemo(() => { const q = search.trim().toLowerCase(); return rows.filter(row => { const effectiveStatus = rowEffectiveStatus(row); if (filters.category !== FILTER_ALL && String(row.category_id ?? '') !== filters.category) return false; if (filters.cycle !== FILTER_ALL && String(row.billing_cycle || 'monthly') !== filters.cycle) return false; if (filters.autopay && !row.autopay_enabled) return false; if (filters.debt && !rowIsDebt(row)) return false; if (filters.unpaid && (row.is_skipped || rowIsPaid(row))) return false; if (filters.overdue && !(effectiveStatus === 'late' || effectiveStatus === 'missed')) return false; if (filters.firstBucket && !filters.fifteenthBucket && row.bucket !== '1st') return false; if (filters.fifteenthBucket && !filters.firstBucket && row.bucket !== '15th') return false; if (!q) return true; const haystack = [ row.name, row.category_name, row.notes, row.monthly_notes, row.billing_cycle, row.bucket, row.status, amountSearchText(row.expected_amount, row.actual_amount, row.total_paid, row.balance, row.current_balance, row.minimum_payment), ].filter(Boolean).join(' ').toLowerCase(); return haystack.includes(q); }); }, [filters, rows, search]); const first = filteredRows.filter(r => r.bucket === '1st'); const second = filteredRows.filter(r => r.bucket === '15th'); return (
{/* ── Header ── */}

Monthly Overview

{MONTHS[month - 1]} {year}

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

toggleFilter('unpaid')}>Unpaid toggleFilter('overdue')}>Overdue toggleFilter('autopay')}>Autopay toggleFilter('firstBucket')}>1st bucket toggleFilter('fifteenthBucket')}>15th bucket toggleFilter('debt')}>Debt {filteredRows.length} of {rows.length} shown
{/* ── Summary cards (backend already excludes skipped from totals) ── */} {loading ? (
{summary.trend && }
) : (
setEditStartingOpen(true)} /> {summary.trend && }
)} {/* ── Fetch error state ── */} {isError && (

Failed to load tracker data

{error?.message || 'An unexpected error occurred.'}

)} {/* ── Empty state ── */} {!isError && 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 ── */} {!isError && loading && (
{Array.from({ length: 3 }).map((_, i) => (
))}
{Array.from({ length: 3 }).map((_, i) => (
))}
)} {!isError && first.length > 0 && } {!isError && second.length > 0 && } {/* Edit Bill modal — opened by clicking a bill name in any tracker row */} {editBillData && ( setEditBillData(null)} onSave={() => { setEditBillData(null); refetch(); }} onDuplicate={bill => setEditBillData({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }), categories: editBillData.categories, })} /> )} {/* Edit Starting Amounts modal */} setEditStartingOpen(false)} year={year} month={month} onSave={() => { setEditStartingOpen(false); refetch(); }} />
); }