BillTracker/client/pages/TrackerPage.jsx

2567 lines
99 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
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]',
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' },
};
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 (
<button
type="button"
onClick={onClick}
className={cn(
'h-8 rounded-full border px-3 text-xs font-medium transition-colors',
active
? 'border-primary/50 bg-primary/15 text-primary'
: 'border-border/70 bg-card/70 text-muted-foreground hover:bg-accent hover:text-foreground',
)}
>
{children}
</button>
);
}
// ── 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-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 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 (
<div className="flex items-center gap-1.5">
<span className={`text-lg font-bold ${color}`}>
{text}
</span>
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
vs 3-mo avg
</span>
</div>
);
}
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 (
<div className={cn(
'flex-1 min-w-0 relative overflow-hidden rounded-xl border border-border',
'bg-card px-5 py-4 transition-all duration-300',
isActive && def.glow,
isActive && def.borderActive,
)}>
<div className={cn(
'absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r',
def.bar,
!isActive && (type === 'paid' || type === 'overdue') && 'opacity-30',
)} />
<div className="flex items-center gap-2 mb-3">
<Icon className={cn('h-4 w-4', isActive ? def.valueClass : 'text-muted-foreground')} />
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{displayLabel}
</p>
{type === 'starting' && onEdit && (
<button
onClick={onEdit}
className="ml-auto h-4 w-4 text-muted-foreground hover:text-foreground transition-colors"
title="Edit monthly starting amounts"
aria-label="Edit monthly starting amounts"
>
<Settings2 className="h-4 w-4" />
</button>
)}
</div>
<p className={cn(
'text-[1.75rem] font-bold tracking-tight font-mono leading-none',
isActive ? def.valueClass : 'text-foreground',
)}>
{fmt(value)}
</p>
{hint && <p className="mt-2 text-[11px] text-muted-foreground">{hint}</p>}
</div>
);
}
function TrendCard({ trend }) {
if (!trend) return null;
return (
<div className="flex-1 min-w-0 relative overflow-hidden rounded-xl border border-border bg-card px-5 py-4 transition-all duration-300">
<div className="absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r from-purple-500 to-indigo-400" />
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="h-4 w-4 text-foreground" />
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
3-Month Trend
</p>
</div>
<div className="flex items-center justify-center h-10">
<TrendIndicator trend={trend} />
</div>
</div>
);
}
// ── 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 (
<button
type="button"
disabled={!canClick || loading}
onClick={onClick}
className={cn(
'inline-flex items-center px-2.5 py-0.5 text-[11px] rounded-md font-semibold',
'uppercase tracking-wide whitespace-nowrap',
'transition-all duration-150',
canClick && 'cursor-pointer hover:scale-105 hover:shadow-sm',
canClick && status === 'paid' && 'hover:bg-red-500/20 hover:text-red-600 hover:border-red-500/40',
canClick && status !== 'paid' && 'hover:bg-emerald-500/20 hover:text-emerald-600 hover:border-emerald-500/40',
loading && 'opacity-60 cursor-wait',
meta.cls,
)}
title={canClick ? (status === 'paid' || status === 'autodraft' ? 'Click to mark unpaid' : 'Click to mark paid') : undefined}
>
{loading ? (
<>
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
{meta.label}
</>
) : (
meta.label
)}
</button>
);
});
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 (
<div className={cn(
'flex items-center gap-1.5',
compact ? 'w-full flex-wrap justify-end sm:w-auto' : 'justify-end',
)}>
<span
className={cn(
'inline-flex min-w-0 items-center gap-1.5 rounded-md border border-sky-500/20 bg-sky-500/10',
'px-2 py-1 text-xs font-medium text-sky-600 dark:text-sky-300',
compact ? 'mr-auto' : 'max-w-32',
)}
title={`Autopay suggested: ${title}`}
>
<Clock className="h-3 w-3 shrink-0" />
<span className="truncate">{compact ? `Suggested ${fmt(suggestion.amount)}` : 'Suggested'}</span>
</span>
<Button
size="icon"
variant="ghost"
disabled={loading}
onClick={onDismiss}
className="h-8 w-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
title={`Dismiss suggested autopay payment for ${title}`}
aria-label={`Dismiss suggested autopay payment for ${row.name}`}
>
<X className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="default"
disabled={loading}
onClick={onConfirm}
className="h-8 w-8"
title={`Confirm suggested autopay payment for ${title}`}
aria-label={`Confirm suggested autopay payment for ${row.name}`}
>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
</Button>
</div>
);
}
// ── 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 (
<Input
ref={inputRef}
type={field === 'date' ? 'date' : 'number'}
step={field === 'amount' ? '0.01' : undefined}
min={field === 'amount' ? '0' : undefined}
value={value}
onChange={e => 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 (
<span
onClick={startEdit}
title={`Click to edit ${field === 'amount' ? 'payment amount' : 'paid date'}`}
className={cn(
'cursor-pointer rounded-md px-1.5 py-0.5 text-sm font-mono',
'transition-all duration-150 hover:bg-accent hover:ring-1 hover:ring-border',
isEmpty && 'text-muted-foreground',
mismatch && 'text-amber-500',
!isEmpty && !mismatch && 'text-emerald-500',
)}
>
{displayVal}
</span>
);
}
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 (
<div>
<button
type="button"
onClick={onOpen}
className={cn(
'w-full rounded-md text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
compact ? 'p-2' : 'px-2 py-1.5',
)}
title="View payment history"
>
<div className="flex items-center justify-between gap-2 text-xs">
<span className={cn('font-mono', summary.paid > 0 ? 'text-emerald-500' : 'text-muted-foreground')}>
{amountLabel}
</span>
{summary.count > 1 && (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{summary.count}×
</span>
)}
</div>
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={cn('h-full rounded-full transition-all', barTone)}
style={{ width: `${summary.percent}%` }}
/>
</div>
</button>
{showQuickFix && (
<button
type="button"
onClick={onMarkFullAmount}
className="mt-0.5 w-full rounded px-2 py-0.5 text-left text-[10px] text-muted-foreground/60 transition-colors hover:bg-accent/50 hover:text-foreground"
title={`Set ${fmt(summary.paidTowardDue)} as the full amount due this month`}
>
{fmt(summary.paidTowardDue)} is the full amount
</button>
)}
</div>
);
}
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 (
<Button
type="button"
size="sm"
variant="ghost"
disabled={saving}
onClick={handleClick}
className={cn(
'h-7 px-2.5 text-xs font-semibold text-amber-600 hover:bg-amber-500/10 hover:text-amber-700',
'dark:text-amber-400 dark:hover:text-amber-300',
compact && 'h-8',
)}
title={`Set ${MONTHS[month - 1]} amount to ${fmt(summary.paid)} because this bill was lower this month`}
>
{saving ? 'Saving...' : 'Bill was lower'}
</Button>
);
}
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 (
<>
<Dialog open onOpenChange={value => { if (!value) onClose(); }}>
<DialogContent className="max-h-[92svh] overflow-y-auto border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-base font-semibold tracking-tight">{row.name} Payments</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg border border-border/60 bg-muted/25 p-3">
<PaymentProgress row={row} threshold={threshold} onOpen={() => {}} />
<div className="mt-2 flex justify-end">
<LowerThisMonthButton
row={row}
year={year}
month={month}
refresh={onSaved}
/>
</div>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_1fr]">
<div className="rounded-lg border border-border/60 bg-background/45 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Payment History</p>
{payments.length > 0 ? (
<div className="space-y-2">
{payments.map(payment => (
<div key={payment.id} className="flex items-center justify-between gap-3 rounded-md border border-border/50 bg-card/60 px-3 py-2">
<div className="min-w-0">
<p className="font-mono text-sm font-semibold text-foreground">{fmt(payment.amount)}</p>
<p className="truncate text-xs text-muted-foreground">
{fmtDate(payment.paid_date)}
{payment.method ? ` · ${payment.method}` : ''}
</p>
{payment.notes && (
<p className="mt-0.5 truncate text-xs text-muted-foreground/80">{payment.notes}</p>
)}
</div>
<Button type="button" size="sm" variant="ghost" className="h-8 px-2.5 text-xs" onClick={() => setEditPayment(payment)}>
Edit
</Button>
</div>
))}
</div>
) : (
<p className="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-sm text-muted-foreground">
No payments recorded for this month.
</p>
)}
</div>
<form onSubmit={handleAdd} className="rounded-lg border border-border/60 bg-background/45 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Add Partial Payment</p>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor={`partial-amount-${row.id}`} className="text-xs uppercase tracking-wider text-muted-foreground">Amount</Label>
<Input
id={`partial-amount-${row.id}`}
type="number"
min="0"
step="0.01"
value={amount}
onChange={e => setAmount(e.target.value)}
className="font-mono bg-background/70 border-border/60"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`partial-date-${row.id}`} className="text-xs uppercase tracking-wider text-muted-foreground">Paid Date</Label>
<Input
id={`partial-date-${row.id}`}
type="date"
value={date}
onChange={e => setDate(e.target.value)}
className="font-mono bg-background/70 border-border/60"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Method</Label>
<Select value={method} onValueChange={setMethod}>
<SelectTrigger className="bg-background/70 border-border/60">
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
<SelectItem value={METHOD_NONE}></SelectItem>
<SelectItem value="bank">Bank Transfer</SelectItem>
<SelectItem value="card">Card</SelectItem>
<SelectItem value="autopay">Autopay</SelectItem>
<SelectItem value="check">Check</SelectItem>
<SelectItem value="cash">Cash</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor={`partial-notes-${row.id}`} className="text-xs uppercase tracking-wider text-muted-foreground">Notes</Label>
<Input
id={`partial-notes-${row.id}`}
value={notes}
onChange={e => setNotes(e.target.value)}
className="bg-background/70 border-border/60"
/>
</div>
<Button type="submit" disabled={busy} className="w-full gap-2">
<Plus className="h-4 w-4" />
{busy ? 'Adding...' : 'Add Payment'}
</Button>
</div>
</form>
</div>
</div>
</DialogContent>
</Dialog>
{editPayment && (
<PaymentModal
payment={editPayment}
onClose={() => 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 (
<input
type="text"
value={value}
onChange={e => 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',
)}
/>
);
}
// ── 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm border-border/60 bg-card/95 backdrop-blur-xl">
<DialogHeader>
<DialogTitle className="text-base font-semibold tracking-tight">
{row.name}
<span className="text-muted-foreground font-normal ml-2">
{MONTHS[month - 1]} {year}
</span>
</DialogTitle>
<p className="text-[11px] text-muted-foreground">
Monthly overrides changes only affect {MONTHS[month - 1]}
</p>
</DialogHeader>
<form id="mbs-form" onSubmit={handleSave} className="space-y-4 py-1">
{/* Actual amount this month */}
<div className="space-y-1.5">
<Label htmlFor="mbs-amount" className="text-xs uppercase tracking-wider text-muted-foreground">
Actual Amount ($)
</Label>
<Input
id="mbs-amount"
type="number" min="0" step="0.01"
placeholder={String(row.expected_amount)}
value={actualAmount}
onChange={e => setActualAmount(e.target.value)}
className="font-mono bg-background/50 border-border/60"
/>
<p className="text-[11px] text-muted-foreground">
Leave blank to use the template default ({fmt(row.expected_amount)}).
</p>
</div>
{/* Monthly notes */}
<div className="space-y-1.5">
<Label htmlFor="mbs-notes" className="text-xs uppercase tracking-wider text-muted-foreground">
Notes (this month only)
</Label>
<Input
id="mbs-notes"
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="e.g. higher than usual, double-billed…"
className="bg-background/50 border-border/60"
/>
</div>
{/* Skip this month */}
<label className="flex items-start gap-3 cursor-pointer select-none">
<input
type="checkbox"
checked={isSkipped}
onChange={e => setIsSkipped(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-border accent-primary"
/>
<div>
<p className="text-sm font-medium leading-tight">Skip this month</p>
<p className="text-[11px] text-muted-foreground mt-0.5">
Excludes this bill from {MONTHS[month - 1]} totals. Other months are unchanged.
</p>
</div>
</label>
</form>
<DialogFooter className="mt-2">
<Button type="button" variant="ghost" disabled={saving} onClick={() => onOpenChange(false)} className="text-xs">
Cancel
</Button>
<Button type="submit" form="mbs-form" disabled={saving} className="text-xs">
{saving ? 'Saving…' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [firstAmount, setFirstAmount] = useState('0');
const [fifteenthAmount, setFifteenthAmount] = useState('0');
const [otherAmount, setOtherAmount] = useState('0');
const [preview, setPreview] = useState(null);
const monthName = `${MONTHS[month - 1]} ${year}`;
const localFirst = Number(firstAmount) || 0;
const localFifteenth = Number(fifteenthAmount) || 0;
const localOther = Number(otherAmount) || 0;
const totalStarting = localFirst + localFifteenth + localOther;
const paidSoFar = Number(preview?.paid_total || 0);
const firstRemaining = localFirst - Number(preview?.paid_from_first || 0);
const fifteenthRemaining = localFifteenth - Number(preview?.paid_from_fifteenth || 0);
const totalRemaining = totalStarting - paidSoFar;
useEffect(() => {
let alive = true;
async function loadStartingAmounts() {
if (!open) return;
setLoading(true);
setError('');
try {
const result = await api.getMonthlyStartingAmounts(year, month);
if (!alive) return;
setPreview(result);
setFirstAmount(String(result.first_amount ?? 0));
setFifteenthAmount(String(result.fifteenth_amount ?? 0));
setOtherAmount(String(result.other_amount ?? 0));
} catch (err) {
if (!alive) return;
setError(err.message || 'Monthly starting amounts could not be loaded.');
} finally {
if (alive) setLoading(false);
}
}
loadStartingAmounts();
return () => { alive = false; };
}, [open, year, month]);
async function handleSave(e) {
e.preventDefault();
const first = Number(firstAmount);
const fifteenth = Number(fifteenthAmount);
const other = Number(otherAmount);
if (![first, fifteenth, other].every(value => Number.isFinite(value) && value >= 0)) {
setError('Starting amounts must be non-negative numbers.');
return;
}
setSaving(true);
setError('');
try {
await api.updateMonthlyStartingAmounts({
year,
month,
first_amount: first,
fifteenth_amount: fifteenth,
other_amount: other,
});
toast.success('Monthly starting amounts saved.');
onSave();
} catch (err) {
setError(err.message || 'Monthly starting amounts could not be saved.');
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={value => { if (!value) onClose(); }}>
<DialogContent className="max-h-[92vh] overflow-y-auto border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-semibold tracking-tight">Monthly Starting Amounts</DialogTitle>
<p className="text-sm text-muted-foreground">{monthName}</p>
</DialogHeader>
<form id="starting-amounts-form" onSubmit={handleSave} className="space-y-5">
{error && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="grid gap-3 sm:grid-cols-3">
<label className="space-y-1.5">
<Label htmlFor="starting-first" className="text-xs uppercase tracking-wider text-muted-foreground">
1st
</Label>
<Input
id="starting-first"
type="number"
min="0"
step="0.01"
value={firstAmount}
disabled={loading || saving}
onChange={e => setFirstAmount(e.target.value)}
className="font-mono bg-background/50 border-border/60"
/>
</label>
<label className="space-y-1.5">
<Label htmlFor="starting-fifteenth" className="text-xs uppercase tracking-wider text-muted-foreground">
15th
</Label>
<Input
id="starting-fifteenth"
type="number"
min="0"
step="0.01"
value={fifteenthAmount}
disabled={loading || saving}
onChange={e => setFifteenthAmount(e.target.value)}
className="font-mono bg-background/50 border-border/60"
/>
</label>
<label className="space-y-1.5">
<Label htmlFor="starting-other" className="text-xs uppercase tracking-wider text-muted-foreground">
Other
</Label>
<Input
id="starting-other"
type="number"
min="0"
step="0.01"
value={otherAmount}
disabled={loading || saving}
onChange={e => setOtherAmount(e.target.value)}
className="font-mono bg-background/50 border-border/60"
/>
</label>
</div>
<div className="rounded-xl border border-border/60 bg-muted/35 p-4">
<div className="grid gap-3 sm:grid-cols-3">
<div>
<p className="text-xs font-medium text-muted-foreground">Total starting</p>
<p className="mt-1 font-mono text-lg font-bold">{fmt(totalStarting)}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Paid so far</p>
<p className="mt-1 font-mono text-lg font-bold text-emerald-500">{fmt(paidSoFar)}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Total remaining</p>
<p className="mt-1 font-mono text-lg font-bold">{fmt(totalRemaining)}</p>
</div>
</div>
<div className="mt-4 grid gap-2 border-t border-border/60 pt-3 text-sm sm:grid-cols-3">
<div className="flex justify-between gap-3 sm:block">
<span className="text-muted-foreground">1st remaining</span>
<span className="font-mono font-semibold sm:block">{fmt(firstRemaining)}</span>
</div>
<div className="flex justify-between gap-3 sm:block">
<span className="text-muted-foreground">15th remaining</span>
<span className="font-mono font-semibold sm:block">{fmt(fifteenthRemaining)}</span>
</div>
<div className="flex justify-between gap-3 sm:block">
<span className="text-muted-foreground">Other</span>
<span className="font-mono font-semibold sm:block">{fmt(localOther)}</span>
</div>
</div>
</div>
</form>
<DialogFooter className="mt-2">
<Button type="button" variant="ghost" disabled={saving} onClick={onClose} className="text-xs">
Cancel
</Button>
<Button type="submit" form="starting-amounts-form" disabled={loading || saving} className="text-xs">
{saving ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── 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);
const [confirmDelete, setConfirmDelete] = 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 moved to recovery. Bill is now marked as unpaid.', {
action: {
label: 'Undo',
onClick: async () => {
try {
await api.restorePayment(payment.id);
toast.success('Payment restored');
onSave();
} catch (err) {
toast.error(err.message || 'Failed to restore payment');
}
},
},
});
onSave(); onClose();
} catch (err) {
toast.error(err.message);
} finally { setBusy(false); }
}
return (
<>
<Dialog open onOpenChange={v => { if (!v) onClose(); }}>
<DialogContent className="sm:max-w-sm border-border/60 bg-card/95 backdrop-blur-xl">
<DialogHeader>
<DialogTitle className="text-base font-semibold tracking-tight">Edit Payment</DialogTitle>
</DialogHeader>
<form id="payment-modal-form" onSubmit={handleSave} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="pm-amount" className="text-xs uppercase tracking-wider text-muted-foreground">Amount ($) *</Label>
<Input id="pm-amount" type="number" min="0" step="0.01" required
value={amount} onChange={e => setAmount(e.target.value)}
className="font-mono bg-background/50 border-border/60" />
</div>
<div className="space-y-1.5">
<Label htmlFor="pm-date" className="text-xs uppercase tracking-wider text-muted-foreground">Paid Date *</Label>
<Input id="pm-date" type="date" required
value={date} onChange={e => setDate(e.target.value)}
className="font-mono bg-background/50 border-border/60" />
</div>
<div className="space-y-1.5">
<Label htmlFor="pm-method" className="text-xs uppercase tracking-wider text-muted-foreground">Method</Label>
<Select value={method} onValueChange={setMethod}>
<SelectTrigger id="pm-method" className="bg-background/50 border-border/60">
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
<SelectItem value={METHOD_NONE}></SelectItem>
<SelectItem value="bank">Bank Transfer</SelectItem>
<SelectItem value="card">Card</SelectItem>
<SelectItem value="autopay">Autopay</SelectItem>
<SelectItem value="check">Check</SelectItem>
<SelectItem value="cash">Cash</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="pm-notes" className="text-xs uppercase tracking-wider text-muted-foreground">Notes</Label>
<Input id="pm-notes" value={notes} onChange={e => setNotes(e.target.value)}
className="bg-background/50 border-border/60" />
</div>
</form>
<DialogFooter className="flex-row gap-2 sm:justify-between mt-2">
<Button
type="button" variant="destructive" disabled={busy} onClick={() => setConfirmDelete(true)}
className="text-xs"
title="Removes this payment record. The bill itself is NOT deleted."
>
Remove Payment
</Button>
<div className="flex gap-2">
<Button type="button" variant="ghost" disabled={busy} onClick={onClose} className="text-xs">Cancel</Button>
<Button type="submit" form="payment-modal-form" disabled={busy} className="text-xs">Save</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove this payment?</AlertDialogTitle>
<AlertDialogDescription>
This marks the payment as removed and reverses any debt balance update. You can undo it from the toast.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={busy}
onClick={handleDelete}
>
{busy ? 'Removing...' : 'Remove Payment'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
// ── 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, { 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 (
<>
<TableRow
className={cn(
'group border-border/50 transition-colors duration-150',
isSkipped ? 'opacity-40' : 'hover:bg-accent/50',
rowBg,
)}
style={{ animationDelay: `${index * 40}ms` }}
>
{/* Bill name + category + monthly notes (if set) */}
<TableCell className="w-[18%] py-3">
<div className="flex items-center gap-2.5">
{row.autopay_enabled && (
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" title="Autopay" />
)}
<div>
<div className="flex items-center gap-1">
{row.website ? (
<a
href={row.website}
target="_blank"
rel="noreferrer"
className={cn(
'font-medium text-sm leading-tight transition-colors',
'hover:underline decoration-muted-foreground/50 underline-offset-2',
isSkipped && 'line-through',
)}
>
{row.name}
</a>
) : (
<span className={cn('font-medium text-sm leading-tight', isSkipped && 'line-through')}>
{row.name}
</span>
)}
<Button
size="icon" variant="ghost"
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
title="Edit bill"
onClick={() => onEditBill?.(row)}
>
<Pencil className="h-3 w-3" />
</Button>
</div>
{row.category_name && (
<p className="text-[11px] text-muted-foreground mt-0.5">{row.category_name}</p>
)}
{/* Monthly notes shown inline under the bill name */}
{row.monthly_notes && (
<p className="text-[11px] text-amber-500/80 mt-0.5 italic truncate max-w-[140px]"
title={row.monthly_notes}>
{row.monthly_notes}
</p>
)}
</div>
</div>
</TableCell>
{/* Due */}
<TableCell className="w-[10%] py-3 text-sm font-mono text-muted-foreground">
{editingDue ? (
<input
type="number"
min="1" max="31"
value={dueDraft}
autoFocus
onChange={e => setDueDraft(e.target.value)}
onBlur={handleSaveDue}
onKeyDown={e => {
if (e.key === 'Enter') e.currentTarget.blur();
if (e.key === 'Escape') { setEditingDue(false); }
}}
className="w-12 rounded border border-border bg-transparent px-1 py-0.5 text-sm font-mono text-foreground outline-none focus:ring-[2px] focus:ring-ring/50"
title="Day of month (131)"
/>
) : (
<button
type="button"
onClick={() => { setDueDraft(String(row.due_day)); setEditingDue(true); }}
className="rounded px-1 py-0.5 transition-colors hover:bg-accent hover:text-foreground"
title="Click to edit due day"
>
{fmtDate(row.due_date)}
</button>
)}
</TableCell>
{/* Expected / Actual — shows actual_amount in amber when it overrides the template */}
<TableCell className="w-[10%] py-3 text-right font-mono text-sm">
{editingExpected ? (
<input
type="number"
min="0" step="0.01"
value={expectedDraft}
autoFocus
onChange={e => setExpectedDraft(e.target.value)}
onBlur={handleSaveExpected}
onKeyDown={e => {
if (e.key === 'Enter') e.currentTarget.blur();
if (e.key === 'Escape') { setEditingExpected(false); }
}}
className="w-24 rounded border border-border bg-transparent px-1 py-0.5 text-right text-sm font-mono text-foreground outline-none focus:ring-[2px] focus:ring-ring/50"
/>
) : effectiveActual != null ? (
<button
type="button"
onClick={() => { setExpectedDraft(String(effectiveActual)); setEditingExpected(true); }}
className="rounded px-1 py-0.5 text-amber-500 transition-colors hover:bg-accent"
title={`Monthly override — click to edit. Template default: ${fmt(row.expected_amount)}`}
>
{fmt(effectiveActual)}
</button>
) : (
<div className="flex flex-col items-end gap-0.5">
<button
type="button"
onClick={() => { setExpectedDraft(String(row.expected_amount)); setEditingExpected(true); }}
className="rounded px-1 py-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Click to edit expected amount"
>
{fmt(row.expected_amount)}
</button>
{row.amount_suggestion?.suggestion != null &&
Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && (
<button
type="button"
onClick={() => handleApplySuggestion(row.amount_suggestion.suggestion)}
className="text-[10px] text-muted-foreground/50 transition-colors hover:text-muted-foreground"
title={`Based on last ${row.amount_suggestion.months_used} months`}
>
~{fmt(row.amount_suggestion.suggestion)}
</button>
)}
</div>
)}
</TableCell>
{/* Previous month paid */}
<TableCell className="w-[10%] py-3 text-right font-mono text-sm text-muted-foreground/70">
{row.previous_month_paid > 0 ? fmt(row.previous_month_paid) : '—'}
</TableCell>
{/* Amount paid — mismatch now compares against threshold */}
<TableCell className="w-[10%] py-3 text-right">
<PaymentProgress
row={row}
threshold={threshold}
onOpen={() => setPaymentLedgerOpen(true)}
onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined}
/>
</TableCell>
{/* Paid date */}
<TableCell className="w-[10%] py-3 text-sm text-muted-foreground">
<button
type="button"
onClick={() => setPaymentLedgerOpen(true)}
className="rounded-md px-1.5 py-0.5 font-mono transition-colors hover:bg-accent hover:text-foreground"
title="View payment history"
>
{row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}
{summary.count > 1 && <span className="ml-1 text-[10px] text-muted-foreground">({summary.count})</span>}
</button>
</TableCell>
{/* Status — uses effectiveStatus (accounts for skipped + threshold) */}
<TableCell className="w-[9%] py-3">
<StatusBadge
status={effectiveStatus}
clickable
onClick={() => {
if (effectiveStatus === 'skipped') return;
handleTogglePaid();
}}
loading={loading}
/>
</TableCell>
{/* Actions */}
<TableCell className="w-[10%] py-3 text-right">
<div className="flex items-center justify-end gap-1">
{showUpdateNudge ? (
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-1 duration-200">
<span className="text-[10px] text-muted-foreground">Update default?</span>
<Button
size="sm" variant="ghost"
onClick={handleUpdateTemplate}
className="h-6 px-2 text-[10px] font-semibold text-emerald-600 hover:bg-emerald-500/10 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300"
>
{fmt(nudgeAmount)}
</Button>
<button
type="button"
onClick={() => setShowUpdateNudge(false)}
className="text-muted-foreground/40 transition-colors hover:text-muted-foreground"
title="Dismiss"
>
<X className="h-3 w-3" />
</button>
</div>
) : (
<>
{hasAutopaySuggestion && (
<AutopaySuggestionActions
row={row}
loading={suggestionLoading}
onConfirm={handleConfirmSuggestion}
onDismiss={handleDismissSuggestion}
/>
)}
{/* Quick pay — hidden for skipped/paid bills */}
{!isPaid && !isSkipped && !hasAutopaySuggestion && (
<div className="flex items-center gap-1">
<Input
ref={amountRef}
type="number" min="0" step="0.01"
defaultValue={summary.remaining || threshold}
className="h-7 w-20 text-right font-mono text-sm bg-background/50 border-border/50"
title="Payment amount"
/>
<Button
size="sm" variant="ghost"
onClick={handleQuickPay}
className="h-7 px-2.5 text-xs font-semibold text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:text-emerald-300 dark:hover:bg-emerald-500/10"
>
Add
</Button>
</div>
)}
</>
)}
</div>
</TableCell>
{/* Notes cell (monthly state notes) */}
<TableCell className="w-[23%] py-3 border-l border-border pl-4">
<NotesCell row={{ ...row, year, month }} refresh={refresh} />
</TableCell>
</TableRow>
{editPayment && (
<PaymentModal
payment={editPayment}
onClose={() => setEditPayment(null)}
onSave={refresh}
/>
)}
{paymentLedgerOpen && (
<PaymentLedgerDialog
row={row}
year={year}
month={month}
threshold={threshold}
defaultPaymentDate={defaultPaymentDate}
onClose={() => setPaymentLedgerOpen(false)}
onSaved={refresh}
/>
)}
{showMbs && (
<MonthlyStateDialog
row={row}
year={year}
month={month}
open={showMbs}
onOpenChange={setShowMbs}
onSaved={refresh}
/>
)}
<AlertDialog open={confirmUnpay} onOpenChange={setConfirmUnpay}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Mark this bill unpaid?</AlertDialogTitle>
<AlertDialogDescription>
This removes the current payment record for this month and moves it into recovery.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={loading}
onClick={performTogglePaid}
>
{loading ? 'Removing...' : 'Remove Payment'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
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 (
<>
<div
className={cn(
'rounded-lg border border-border/60 bg-background/60 p-3 shadow-sm',
'space-y-3 transition-colors',
isSkipped ? 'opacity-55' : rowBg,
)}
style={{ animationDelay: `${index * 40}ms` }}
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
{row.autopay_enabled && (
<span
className="inline-flex shrink-0 rounded bg-sky-500/15 px-1.5 py-0.5 text-[10px] font-semibold text-sky-500"
title="Autopay"
>
AP
</span>
)}
{row.website ? (
<a
href={row.website}
target="_blank"
rel="noreferrer"
className={cn(
'min-w-0 truncate text-sm font-semibold leading-tight text-foreground',
'underline-offset-2 transition-colors hover:text-primary hover:underline',
isSkipped && 'line-through',
)}
>
{row.name}
</a>
) : (
<span className={cn('min-w-0 truncate text-sm font-semibold leading-tight text-foreground', isSkipped && 'line-through')}>
{row.name}
</span>
)}
<Button
size="icon" variant="ghost"
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
title="Edit bill"
onClick={() => onEditBill?.(row)}
>
<Pencil className="h-3 w-3" />
</Button>
</div>
{row.monthly_notes && (
<p className="mt-1 line-clamp-2 text-xs italic text-amber-500/80" title={row.monthly_notes}>
{row.monthly_notes}
</p>
)}
</div>
<StatusBadge status={effectiveStatus} clickable={!isSkipped} onClick={handleTogglePaid} />
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs text-muted-foreground sm:grid-cols-4">
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Due</p>
<p className="mt-0.5 font-mono text-sm text-foreground">{fmtDate(row.due_date)}</p>
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Category</p>
<p className="mt-0.5 truncate text-sm text-foreground">{row.category_name || 'Uncategorized'}</p>
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Expected</p>
<p className={cn('mt-0.5 font-mono text-sm', row.actual_amount != null ? 'text-amber-500' : 'text-foreground')}>
{fmt(threshold)}
</p>
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Last Month</p>
<p className="mt-0.5 font-mono text-sm text-muted-foreground/70">
{fmt(row.previous_month_paid)}
</p>
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Remaining</p>
<p className={cn('mt-0.5 font-mono text-sm', remaining > 0 ? 'text-foreground' : 'text-emerald-500')}>
{fmt(remaining)}
</p>
</div>
</div>
<div className="rounded-md border border-border/50 bg-muted/20">
<PaymentProgress row={row} threshold={threshold} onOpen={() => setPaymentLedgerOpen(true)} compact />
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="grid grid-cols-2 gap-2 text-xs sm:flex sm:items-center">
<div className="rounded-md bg-muted/45 px-2 py-1.5">
<span className="text-muted-foreground">Paid </span>
<span className="font-mono text-emerald-500">{row.total_paid > 0 ? fmt(row.total_paid) : '—'}</span>
</div>
<div className="rounded-md bg-muted/45 px-2 py-1.5">
<span className="text-muted-foreground">Date </span>
<button
type="button"
onClick={() => setPaymentLedgerOpen(true)}
className="rounded font-mono text-foreground underline-offset-2 hover:underline"
>
{row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}
{summary.count > 1 && <span className="ml-1 text-[10px] text-muted-foreground">({summary.count})</span>}
</button>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-1.5">
{hasAutopaySuggestion && (
<AutopaySuggestionActions
row={row}
loading={suggestionLoading}
onConfirm={handleConfirmSuggestion}
onDismiss={handleDismissSuggestion}
compact
/>
)}
{!isPaid && !isSkipped && !hasAutopaySuggestion && (
<div className="flex items-center gap-1.5">
<Input
ref={amountRef}
type="number" min="0" step="0.01"
defaultValue={summary.remaining || threshold}
className="h-8 w-24 text-right font-mono text-sm bg-background/70 border-border/60"
title="Payment amount"
aria-label={`${row.name} payment amount`}
/>
<Button
size="sm" variant="default"
onClick={handleQuickPay}
className="h-8 px-3 text-xs font-semibold"
>
Add
</Button>
</div>
)}
<LowerThisMonthButton
row={row}
year={year}
month={month}
refresh={refresh}
compact
/>
</div>
</div>
<div className="rounded-md border border-border/50 bg-muted/25 px-2 py-1.5">
<NotesCell row={{ ...row, year, month }} refresh={refresh} />
</div>
</div>
{editPayment && (
<PaymentModal
payment={editPayment}
onClose={() => setEditPayment(null)}
onSave={refresh}
/>
)}
{paymentLedgerOpen && (
<PaymentLedgerDialog
row={row}
year={year}
month={month}
threshold={threshold}
defaultPaymentDate={defaultPaymentDate}
onClose={() => setPaymentLedgerOpen(false)}
onSaved={refresh}
/>
)}
{showMbs && (
<MonthlyStateDialog
row={row}
year={year}
month={month}
open={showMbs}
onOpenChange={setShowMbs}
onSaved={refresh}
/>
)}
<AlertDialog open={confirmUnpay} onOpenChange={setConfirmUnpay}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Mark this bill unpaid?</AlertDialogTitle>
<AlertDialogDescription>
This removes the current payment record for this month and moves it into recovery.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={performTogglePaid}
>
Remove Payment
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
// ── 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 (
<div className="rounded-xl border border-border overflow-hidden bg-card">
{/* Bucket header */}
<div className="flex items-center justify-between px-5 py-3 bg-muted/30 border-b border-border">
<div className="flex items-center gap-3">
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
{label}
</span>
{skippedCount > 0 && (
<span className="text-[10px] text-muted-foreground/60">
({skippedCount} skipped)
</span>
)}
<div className="flex items-center gap-2">
<div className="h-1.5 w-24 rounded-full bg-border overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-700',
allPaid ? 'bg-emerald-500' : 'bg-emerald-400/70',
)}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-[11px] font-mono text-muted-foreground/70">
{Math.round(pct)}%
</span>
</div>
</div>
<span className="text-xs font-mono text-muted-foreground">
<span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}>
{fmt(totalPaidTowardDue)}
</span>
<span className="text-muted-foreground/50 mx-1">/</span>
{fmt(totalThreshold)}
{totalOverpaid > 0 && (
<span className="ml-1 text-emerald-500">+{fmt(totalOverpaid)}</span>
)}
</span>
</div>
<div className="grid gap-3 p-3 lg:hidden" aria-busy={loading ? 'true' : 'false'}>
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-lg border border-border/60 bg-background/60 p-3 animate-pulse">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<div className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted" />
<div className="h-4 w-32 rounded-md bg-muted" />
</div>
</div>
<div className="h-5 w-20 rounded-md bg-muted" />
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs text-muted-foreground mt-2">
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Expected</p>
<div className="h-3 w-20 rounded-md bg-muted mt-0.5" />
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Remaining</p>
<div className="h-3 w-20 rounded-md bg-muted mt-0.5" />
</div>
</div>
</div>
))
) : rows.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/70 bg-background/40 px-4 py-8 text-center text-sm text-muted-foreground">
No bills match this bucket and filter set.
</div>
) : (
rows.map((r, i) => (
<MobileTrackerRow
key={r.id}
row={r}
year={year}
month={month}
refresh={refresh}
index={i}
onEditBill={onEditBill}
/>
))
)}
</div>
<div className="hidden lg:block" aria-busy={loading ? 'true' : 'false'}>
<div className="overflow-x-auto">
<Table className="min-w-[1120px]">
<TableHeader>
<TableRow className="border-border hover:bg-transparent">
<TableHead className="w-[18%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Bill</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Due</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-right">Expected</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-right text-muted-foreground/70">Last Month</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-right">Paid</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Paid Date</TableHead>
<TableHead className="w-[9%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Status</TableHead>
<TableHead className="w-[10%] py-2.5" />
<TableHead className="w-[23%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground border-l border-border pl-4">
Notes
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i} className="border-border/50">
<TableCell className="w-[18%] py-3">
<div className="flex items-center gap-2.5">
<div className="h-1.5 w-1.5 rounded-full bg-muted" />
<div className="h-4 w-48 rounded-md bg-muted" />
</div>
</TableCell>
<TableCell className="w-[10%] py-3"><div className="h-3 w-20 rounded-md bg-muted" /></TableCell>
<TableCell className="w-[10%] py-3 text-right"><div className="h-3 w-20 ml-auto rounded-md bg-muted" /></TableCell>
<TableCell className="w-[10%] py-3 text-right"><div className="h-3 w-20 ml-auto rounded-md bg-muted" /></TableCell>
<TableCell className="w-[10%] py-3"><div className="h-7 w-24 ml-auto rounded-md bg-muted" /></TableCell>
<TableCell className="w-[10%] py-3"><div className="h-7 w-24 rounded-md bg-muted" /></TableCell>
<TableCell className="w-[9%] py-3"><div className="h-5 w-20 rounded-md bg-muted" /></TableCell>
<TableCell className="w-[10%] py-3 text-right">
<div className="flex items-center justify-end gap-1">
<div className="h-7 w-20 rounded-md bg-muted" />
<div className="h-7 w-7 rounded-md bg-muted" />
</div>
</TableCell>
<TableCell className="w-[23%] py-3 border-l border-border pl-4">
<div className="h-4 w-full rounded-md bg-muted" />
</TableCell>
</TableRow>
))
) : rows.length === 0 ? (
<TableRow className="border-border/50">
<TableCell colSpan={9} className="py-10 text-center text-sm text-muted-foreground">
No bills match this bucket and filter set.
</TableCell>
</TableRow>
) : (
rows.map((r, i) => (
<Row
key={r.id}
row={r}
year={year}
month={month}
refresh={refresh}
index={i}
onEditBill={onEditBill}
/>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
);
}
// ── 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 (
<div className="space-y-5">
{/* ── Header ── */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-0.5">
Monthly Overview
</p>
<h1 className="text-2xl font-bold tracking-tight">
{MONTHS[month - 1]}
<span className="text-muted-foreground font-normal ml-2 text-xl">{year}</span>
</h1>
<p className="text-xs text-muted-foreground mt-0.5">
{rows.length} {rows.length === 1 ? 'bill' : 'bills'}
</p>
</div>
<div className="flex items-center gap-1 bg-muted/50 border border-border rounded-lg p-1">
<Button
size="icon" variant="ghost"
onClick={() => navigate(-1)}
className="h-7 w-7 hover:bg-white/5"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
size="sm" variant="ghost"
onClick={goToday}
className="h-7 px-3 text-xs font-medium hover:bg-white/5"
>
Today
</Button>
<Button
size="icon" variant="ghost"
onClick={() => navigate(1)}
className="h-7 w-7 hover:bg-white/5"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<div className="rounded-xl border border-border bg-card p-4 space-y-3">
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_220px_180px_auto] lg:items-center">
<label className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search this month by bill, category, notes, or amount"
className="h-10 pl-9"
/>
</label>
<Select value={filters.category} onValueChange={value => setFilterValue('category', value)}>
<SelectTrigger className="h-10">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_ALL}>All categories</SelectItem>
{categoryOptions.map(category => (
<SelectItem key={category.id} value={category.id}>{category.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filters.cycle} onValueChange={value => setFilterValue('cycle', value)}>
<SelectTrigger className="h-10 capitalize">
<SelectValue placeholder="Billing cycle" />
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_ALL}>All cycles</SelectItem>
{cycleOptions.map(cycle => (
<SelectItem key={cycle} value={cycle}>{cycle.replace(/_/g, ' ')}</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
disabled={!hasFilters}
onClick={resetFilters}
className="h-10 justify-center gap-2 text-xs"
>
<X className="h-3.5 w-3.5" />
Clear
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<FilterChip active={filters.unpaid} onClick={() => toggleFilter('unpaid')}>Unpaid</FilterChip>
<FilterChip active={filters.overdue} onClick={() => toggleFilter('overdue')}>Overdue</FilterChip>
<FilterChip active={filters.autopay} onClick={() => toggleFilter('autopay')}>Autopay</FilterChip>
<FilterChip active={filters.firstBucket} onClick={() => toggleFilter('firstBucket')}>1st bucket</FilterChip>
<FilterChip active={filters.fifteenthBucket} onClick={() => toggleFilter('fifteenthBucket')}>15th bucket</FilterChip>
<FilterChip active={filters.debt} onClick={() => toggleFilter('debt')}>Debt</FilterChip>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{filteredRows.length} of {rows.length} shown
</span>
</div>
</div>
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
{loading ? (
<div className="grid grid-cols-2 gap-3 lg:flex" aria-busy="true">
<Skeleton variant="card" className="h-32" />
<Skeleton variant="card" className="h-32" />
<Skeleton variant="card" className="h-32" />
<Skeleton variant="card" className="h-32" />
<Skeleton variant="card" className="h-32" />
{summary.trend && <Skeleton variant="card" className="h-32" />}
</div>
) : (
<div className="grid grid-cols-2 gap-3 lg:flex">
<SummaryCard
type="starting"
value={summary.total_starting}
hint={!summary.has_starting_amounts ? 'Set monthly starting cash' : ''}
onEdit={() => setEditStartingOpen(true)}
/>
<SummaryCard type="paid" value={summary.total_paid} />
<SummaryCard
type="remaining"
value={summary.remaining}
label={summary.remaining_label || 'Remaining'}
hint={summary.remaining_hint}
/>
<SummaryCard type="overdue" value={summary.overdue} />
<SummaryCard type="paid" value={summary.previous_month_total} hint="Previous month"/>
{summary.trend && <TrendCard trend={summary.trend} />}
</div>
)}
{/* ── Fetch error state ── */}
{isError && (
<div className="flex flex-col items-center justify-center py-20 text-center rounded-xl border border-destructive/20 bg-destructive/5">
<div className="h-10 w-10 rounded-full bg-destructive/10 flex items-center justify-center mb-3">
<AlertCircle className="h-5 w-5 text-destructive" />
</div>
<p className="text-sm font-medium text-foreground">Failed to load tracker data</p>
<p className="mt-1 text-xs text-muted-foreground">{error?.message || 'An unexpected error occurred.'}</p>
<Button
size="sm"
variant="outline"
onClick={() => refetch()}
className="mt-4 gap-1.5 text-xs border-destructive/30 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50"
>
<RefreshCw className="h-3 w-3" />
Try again
</Button>
</div>
)}
{/* ── Empty state ── */}
{!isError && rows.length === 0 && data !== null && (
<div className="flex flex-col items-center justify-center py-20 text-center rounded-xl border border-border bg-muted/20">
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center mb-3">
<CheckCircle2 className="h-5 w-5 text-muted-foreground" />
</div>
<p className="text-sm font-medium text-muted-foreground">No bills this month</p>
<a href="/bills" className="mt-1.5 text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground transition-colors">
Add a bill
</a>
</div>
)}
{/* ── Buckets — year and month passed so Row can open the correct monthly state dialog ── */}
{!isError && loading && (
<div className="space-y-5" aria-busy="true">
<div className="rounded-xl border border-border overflow-hidden bg-card p-4">
<div className="flex items-center justify-between mb-4">
<div className="h-4 w-32 rounded-md bg-muted" />
<div className="h-4 w-16 rounded-md bg-muted" />
</div>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 animate-pulse">
<div className="h-1.5 w-1.5 rounded-full bg-muted" />
<div className="h-4 w-64 rounded-md bg-muted" />
</div>
))}
</div>
</div>
<div className="rounded-xl border border-border overflow-hidden bg-card p-4">
<div className="flex items-center justify-between mb-4">
<div className="h-4 w-32 rounded-md bg-muted" />
<div className="h-4 w-16 rounded-md bg-muted" />
</div>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 animate-pulse">
<div className="h-1.5 w-1.5 rounded-full bg-muted" />
<div className="h-4 w-64 rounded-md bg-muted" />
</div>
))}
</div>
</div>
</div>
)}
{!isError && first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} />}
{!isError && second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} />}
{/* Edit Bill modal — opened by clicking a bill name in any tracker row */}
{editBillData && (
<BillModal
key={editBillData.bill?.id ? `edit-${editBillData.bill.id}` : `new-${editBillData.initialBill?.name || 'blank'}`}
bill={editBillData.bill}
initialBill={editBillData.initialBill}
categories={editBillData.categories}
onClose={() => 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 */}
<StartingAmountsEditDialog
open={editStartingOpen}
onClose={() => setEditStartingOpen(false)}
year={year}
month={month}
onSave={() => { setEditStartingOpen(false); refetch(); }}
/>
</div>
);
}