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: 'bg-orange-400/[0.08] dark:bg-orange-300/[0.014]',
missed: 'bg-red-400/[0.08] dark:bg-rose-300/[0.01]',
};
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-400/15 text-orange-500 border border-orange-400/30 dark:bg-orange-300/10 dark:text-orange-200 dark:border-orange-300/26' },
missed: { label: 'Missed', cls: 'bg-red-400/15 text-red-500 border border-red-400/30 dark:bg-rose-300/10 dark:text-rose-200 dark:border-rose-300/26' },
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 (
);
}
// ── 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 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 (
<>
{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.autopay_enabled && (
)}
{row.website ? (
{row.name}
) : (
{row.name}
)}
{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.autopay_enabled && (
AP
)}
{row.website ? (
{row.name}
) : (
{row.name}
)}
{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)
)}
{fmt(totalPaidTowardDue)}
/
{fmt(totalThreshold)}
{totalOverpaid > 0 && (
+{fmt(totalOverpaid)}
)}
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
))
) : 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'}
{/* ── 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 && (
)}
{/* ── 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(); }}
/>
);
}