BillTracker/client/pages/TrackerPage.jsx

1567 lines
61 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 } from 'react';
import { ChevronLeft, ChevronRight, Pencil, TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { useTracker } from '@/hooks/useQueries';
import BillModal from '@/components/BillModal';
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { 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 {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
const MONTHS = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
];
// Sentinel for the "no method" select option — empty string crashes Radix Select
const METHOD_NONE = 'none';
function paymentDateForTrackerMonth(year, month, dueDay) {
const now = new Date();
if (year === now.getFullYear() && month === now.getMonth() + 1) {
return todayStr();
}
const daysInMonth = new Date(year, month, 0).getDate();
const day = Number.isInteger(Number(dueDay))
? Math.min(Math.max(Number(dueDay), 1), daysInMonth)
: 1;
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
const ROW_STATUS_CLS = {
paid: 'bg-emerald-500/[0.04]',
autodraft: 'bg-sky-500/[0.04]',
upcoming: '',
due_soon: 'bg-amber-400/[0.07]',
late: 'bg-orange-400/[0.08]',
missed: 'bg-red-400/[0.08]',
};
const STATUS_META = {
paid: { label: 'Paid', cls: 'bg-emerald-500/15 text-emerald-500 border border-emerald-500/30' },
upcoming: { label: 'Upcoming', cls: 'bg-secondary text-muted-foreground border border-border' },
due_soon: { label: 'Due Soon', cls: 'bg-amber-400/15 text-amber-500 border border-amber-400/30' },
late: { label: 'Late', cls: 'bg-orange-400/15 text-orange-500 border border-orange-400/30' },
missed: { label: 'Missed', cls: 'bg-red-400/15 text-red-500 border border-red-400/30' },
autodraft: { label: 'Autodraft', cls: 'bg-sky-400/15 text-sky-500 border border-sky-400/30' },
skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
};
// ── Summary cards ──────────────────────────────────────────────────────────
const CARD_DEFS = {
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 }) {
const def = CARD_DEFS[type];
const isActive = def.activateWhen(value || 0);
const Icon = def.icon;
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">
{def.label}
</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>
);
});
// ── 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>
);
}
// ── 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);
async function handleSave(e) {
e.preventDefault();
setBusy(true);
try {
await api.updatePayment(payment.id, {
amount: parseFloat(amount),
paid_date: date,
method: method === METHOD_NONE ? null : method,
notes: notes || null,
});
toast.success('Payment saved');
onSave(); onClose();
} catch (err) {
toast.error(err.message);
} finally { setBusy(false); }
}
async function handleDelete() {
setBusy(true);
try {
await api.deletePayment(payment.id);
toast.success('Payment removed. Bill is now marked as unpaid.');
onSave(); onClose();
} catch (err) {
toast.error(err.message);
} finally { setBusy(false); }
}
return (
<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={handleDelete}
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>
);
}
// ── Table row ──────────────────────────────────────────────────────────────
function Row({ row, year, month, refresh, index, onEditBill }) {
const amountRef = useRef(null);
const [editPayment, setEditPayment] = useState(null);
const [showMbs, setShowMbs] = useState(false);
const [loading, setLoading] = useState(false);
// Effective amount threshold for this bill this month:
// actual_amount (if set by monthly override) takes priority over the template expected_amount.
const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
// Paid when total payments >= effective threshold
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
const isPaid = row.status === 'paid' || row.status === 'autodraft' || isPaidByThreshold;
const isSkipped = !!row.is_skipped;
// Effective status to show:
// skipped > paid (threshold-based) > backend status
const effectiveStatus = isSkipped
? 'skipped'
: (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft')
? 'paid'
: row.status;
const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
async function handleQuickPay() {
const val = parseFloat(amountRef.current?.value);
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
try {
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
toast.success('Marked as paid');
refresh();
} catch (err) {
toast.error(err.message);
}
}
async function handleTogglePaid() {
setLoading?.(true);
try {
const result = await api.togglePaid(row.id, {
amount: isPaid ? undefined : threshold,
year: year,
month: month,
});
toast.success(isPaid ? 'Payment removed' : 'Payment recorded');
refresh?.();
} catch (err) {
toast.error(err.message || 'Failed to toggle payment status');
} finally {
setLoading?.(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>
<button
type="button"
onClick={() => onEditBill?.(row)}
className={cn(
'font-medium text-sm leading-tight text-left transition-colors',
'hover:underline decoration-muted-foreground/50 underline-offset-2',
isSkipped && 'line-through',
)}
title="Edit bill"
>
{row.name}
</button>
{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">
{fmtDate(row.due_date)}
</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">
{row.actual_amount != null ? (
<span
className="text-amber-500"
title={`Monthly override. Template default: ${fmt(row.expected_amount)}`}
>
{fmt(row.actual_amount)}
</span>
) : (
<span className="text-muted-foreground">{fmt(row.expected_amount)}</span>
)}
</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">
<EditableCell
row={row}
field="amount"
threshold={threshold}
defaultPaymentDate={defaultPaymentDate}
refresh={refresh}
/>
</TableCell>
{/* Paid date */}
<TableCell className="w-[10%] py-3">
<EditableCell
row={row}
field="date"
threshold={threshold}
defaultPaymentDate={defaultPaymentDate}
refresh={refresh}
/>
</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">
{/* Quick pay — hidden for skipped bills */}
{!isPaid && !isSkipped && (
<div className="flex items-center gap-1">
<Input
ref={amountRef}
type="number" min="0" step="0.01"
defaultValue={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"
>
Pay
</Button>
</div>
)}
{/* Edit payment (pencil) */}
{row.payments && row.payments.length > 0 && (
<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 payment"
onClick={() => setEditPayment(row.payments[0])}
>
<Pencil className="h-3 w-3" />
</Button>
)}
{/* Monthly state editor (gear icon) — always available */}
<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 ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
onClick={() => setShowMbs(true)}
>
<Settings2 className="h-3 w-3" />
</Button>
</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}
/>
)}
{showMbs && (
<MonthlyStateDialog
row={row}
year={year}
month={month}
open={showMbs}
onOpenChange={setShowMbs}
onSaved={refresh}
/>
)}
{/* Payment toggle confirmation dialog */}
</>
);
}
function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
const amountRef = useRef(null);
const [editPayment, setEditPayment] = useState(null);
const [showMbs, setShowMbs] = useState(false);
const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
const isPaid = row.status === 'paid' || row.status === 'autodraft' || isPaidByThreshold;
const isSkipped = !!row.is_skipped;
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);
async function handleQuickPay() {
const val = parseFloat(amountRef.current?.value);
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
try {
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
toast.success('Marked as paid');
refresh();
} catch (err) {
toast.error(err.message);
}
}
async function handleTogglePaid() {
try {
await api.togglePaid(row.id, {
amount: isPaid ? undefined : threshold,
year: year,
month: month,
});
toast.success(isPaid ? 'Payment removed' : 'Payment recorded');
refresh();
} catch (err) {
toast.error(err.message || 'Failed to toggle payment status');
}
}
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>
)}
<button
type="button"
onClick={() => onEditBill?.(row)}
className={cn(
'min-w-0 truncate text-left text-sm font-semibold leading-tight text-foreground',
'underline-offset-2 transition-colors hover:text-primary hover:underline',
isSkipped && 'line-through',
)}
title="Edit bill"
>
{row.name}
</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="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>
<span className="font-mono text-foreground">{row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}</span>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-1.5">
{!isPaid && !isSkipped && (
<div className="flex items-center gap-1.5">
<Input
ref={amountRef}
type="number" min="0" step="0.01"
defaultValue={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"
>
Pay
</Button>
</div>
)}
{row.payments && row.payments.length > 0 && (
<Button
size="sm" variant="ghost"
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
title="Edit payment"
onClick={() => setEditPayment(row.payments[0])}
>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Payment
</Button>
)}
<Button
size="sm" variant="ghost"
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
onClick={() => setShowMbs(true)}
>
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
Month
</Button>
</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}
/>
)}
{showMbs && (
<MonthlyStateDialog
row={row}
year={year}
month={month}
open={showMbs}
onOpenChange={setShowMbs}
onSaved={refresh}
/>
)}
</>
);
}
// ── 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 skippedCount = rows.length - activeRows.length;
const pct = totalThreshold > 0 ? Math.min((totalPaid / 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(totalPaid)}
</span>
<span className="text-muted-foreground/50 mx-1">/</span>
{fmt(totalThreshold)}
</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.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.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 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);
// Use React Query for data fetching
const { data, isLoading: loading, isError, error, refetch } = useTracker(year, month);
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);
}
// Handle errors from React Query (use ref to prevent duplicate toasts)
const errorShownRef = useRef(false);
useEffect(() => {
if (isError && !errorShownRef.current) {
toast.error(error?.message || 'Failed to load tracker data');
errorShownRef.current = true;
}
if (!isError) errorShownRef.current = false;
}, [isError, error]);
const rows = data?.rows || [];
const summary = data?.summary || {};
const first = rows.filter(r => r.bucket === '1st');
const second = rows.filter(r => r.bucket === '15th');
return (
<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>
{/* ── 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} />
<SummaryCard type="overdue" value={summary.overdue} />
<SummaryCard type="paid" value={summary.previous_month_total} hint="Previous month"/>
{summary.trend && <TrendCard trend={summary.trend} />}
</div>
)}
{/* ── Empty state ── */}
{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 ── */}
{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>
)}
{first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} />}
{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
bill={editBillData.bill}
categories={editBillData.categories}
onClose={() => setEditBillData(null)}
onSave={() => { setEditBillData(null); refetch(); }}
/>
)}
{/* Edit Starting Amounts modal */}
<StartingAmountsEditDialog
open={editStartingOpen}
onClose={() => setEditStartingOpen(false)}
year={year}
month={month}
onSave={() => { setEditStartingOpen(false); refetch(); }}
/>
</div>
);
}