BillTracker/client/pages/TrackerPage.jsx

915 lines
35 KiB
React
Raw Permalink Normal View History

2026-05-03 19:51:57 -05:00
import { useState, useEffect, useCallback, useRef } from 'react';
import { ChevronLeft, ChevronRight, Pencil, TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2 } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import BillModal from '@/components/BillModal';
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
} from '@/components/ui/table';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
const MONTHS = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
];
// Sentinel for the "no method" select option — empty string crashes Radix Select
const METHOD_NONE = 'none';
function paymentDateForTrackerMonth(year, month, dueDay) {
const now = new Date();
if (year === now.getFullYear() && month === now.getMonth() + 1) {
return todayStr();
}
const daysInMonth = new Date(year, month, 0).getDate();
const day = Number.isInteger(Number(dueDay))
? Math.min(Math.max(Number(dueDay), 1), daysInMonth)
: 1;
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
const ROW_STATUS_CLS = {
paid: 'bg-emerald-500/[0.04]',
autodraft: 'bg-sky-500/[0.04]',
upcoming: '',
due_soon: 'bg-amber-400/[0.07]',
late: 'bg-orange-400/[0.08]',
missed: 'bg-red-400/[0.08]',
};
const STATUS_META = {
paid: { label: 'Paid', cls: 'bg-emerald-500/15 text-emerald-500 border border-emerald-500/30' },
upcoming: { label: 'Upcoming', cls: 'bg-secondary text-muted-foreground border border-border' },
due_soon: { label: 'Due Soon', cls: 'bg-amber-400/15 text-amber-500 border border-amber-400/30' },
late: { label: 'Late', cls: 'bg-orange-400/15 text-orange-500 border border-orange-400/30' },
missed: { label: 'Missed', cls: 'bg-red-400/15 text-red-500 border border-red-400/30' },
autodraft: { label: 'Autodraft', cls: 'bg-sky-400/15 text-sky-500 border border-sky-400/30' },
skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
};
// ── Summary cards ──────────────────────────────────────────────────────────
const CARD_DEFS = {
expected: {
label: 'Total Expected',
icon: TrendingUp,
bar: 'from-slate-400 to-slate-300',
glow: '',
valueClass: 'text-foreground',
activateWhen: () => true,
},
paid: {
label: 'Total Paid',
icon: CheckCircle2,
bar: 'from-emerald-500 to-emerald-300',
glow: 'shadow-[0_4px_20px_rgba(16,185,129,0.15)]',
borderActive: 'border-emerald-400/40',
valueClass: 'text-emerald-500',
activateWhen: (v) => v > 0,
},
remaining: {
label: 'Remaining',
icon: Clock,
bar: 'from-blue-400 to-indigo-300',
glow: '',
valueClass: 'text-foreground',
activateWhen: () => true,
},
overdue: {
label: 'Overdue',
icon: AlertCircle,
bar: 'from-rose-500 to-orange-400',
glow: 'shadow-[0_4px_20px_rgba(239,68,68,0.12)]',
borderActive: 'border-red-400/40',
valueClass: 'text-red-500',
activateWhen: (v) => v > 0,
},
};
function SummaryCard({ type, value }) {
const def = CARD_DEFS[type];
const isActive = def.activateWhen(value || 0);
const Icon = def.icon;
return (
<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>
</div>
<p className={cn(
'text-[1.75rem] font-bold tracking-tight font-mono leading-none',
isActive ? def.valueClass : 'text-foreground',
)}>
{fmt(value)}
</p>
</div>
);
}
// ── Status badge ───────────────────────────────────────────────────────────
function StatusBadge({ status }) {
const meta = STATUS_META[status] || STATUS_META.upcoming;
return (
<span className={cn(
'inline-flex items-center px-2.5 py-0.5 text-[11px] rounded-md font-semibold',
'uppercase tracking-wide whitespace-nowrap',
meta.cls,
)}>
{meta.label}
</span>
);
}
// ── 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 (payment-level notes) ──────────────────────────────────────
function NotesCell({ row, refresh }) {
const payment = row.payments?.[0];
const savedNote = payment?.notes || '';
const [value, setValue] = useState(savedNote);
const [saving, setSaving] = useState(false);
async function handleBlur() {
const trimmed = value.trim();
if (trimmed === savedNote) return;
if (!payment) {
toast.error('Pay this bill first before adding a note');
setValue('');
return;
}
setSaving(true);
try {
await api.updatePayment(payment.id, { notes: trimmed || null });
refresh();
} catch (err) {
toast.error(err.message);
setValue(savedNote);
} finally { setSaving(false); }
}
return (
<input
type="text"
value={value}
onChange={e => setValue(e.target.value)}
onBlur={handleBlur}
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); }}
placeholder={payment ? 'Add a note…' : '—'}
disabled={!payment || saving}
className={cn(
'w-full bg-transparent text-sm placeholder:text-muted-foreground/40',
'border-0 outline-none ring-0',
'text-muted-foreground focus:text-foreground',
'transition-colors duration-150',
'disabled:cursor-not-allowed disabled:opacity-40',
value && 'text-foreground/80',
)}
/>
);
}
// ── Monthly state dialog ───────────────────────────────────────────────────
// Edits actual_amount, monthly notes, and is_skipped for a specific bill+month.
// Changes are isolated to the selected month — other months are not affected.
function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }) {
const [actualAmount, setActualAmount] = useState('');
const [notes, setNotes] = useState('');
const [isSkipped, setIsSkipped] = useState(false);
const [saving, setSaving] = useState(false);
// Populate from current row state when dialog opens
useEffect(() => {
if (open) {
setActualAmount(row.actual_amount != null ? String(row.actual_amount) : '');
setNotes(row.monthly_notes || '');
setIsSkipped(!!row.is_skipped);
}
}, [open, row]);
async function handleSave(e) {
e.preventDefault();
const amt = actualAmount.trim() ? parseFloat(actualAmount) : null;
if (amt !== null && (isNaN(amt) || amt < 0)) {
toast.error('Amount must be a positive number or empty');
return;
}
setSaving(true);
try {
await api.saveBillMonthlyState(row.id, {
year,
month,
actual_amount: amt,
notes: notes.trim() || null,
is_skipped: isSkipped,
});
toast.success(`${MONTHS[month - 1]} state saved`);
onSaved();
onOpenChange(false);
} catch (err) {
toast.error(err.message);
} finally {
setSaving(false);
}
}
return (
<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>
);
}
// ── 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);
// Effective amount threshold for this bill this month:
// actual_amount (if set by monthly override) takes priority over the template expected_amount.
const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
// Paid when total payments >= effective threshold
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
const isPaid = row.status === 'paid' || row.status === 'autodraft' || isPaidByThreshold;
const isSkipped = !!row.is_skipped;
// Effective status to show:
// skipped > paid (threshold-based) > backend status
const effectiveStatus = isSkipped
? 'skipped'
: (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft')
? 'paid'
: row.status;
const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
async function handleQuickPay() {
const val = parseFloat(amountRef.current?.value);
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
try {
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
toast.success('Marked as paid');
refresh();
} catch (err) {
toast.error(err.message);
}
}
return (
<>
<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>
{/* 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} />
</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-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent"
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-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent"
title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
onClick={() => setShowMbs(true)}
>
<Settings2 className="h-3 w-3" />
</Button>
</div>
</TableCell>
{/* Payment-level notes */}
<TableCell className="w-[23%] py-3 border-l border-border pl-4">
<NotesCell row={row} 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}
/>
)}
</>
);
}
// ── Bucket ─────────────────────────────────────────────────────────────────
function Bucket({ label, rows, year, month, refresh, onEditBill }) {
// Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals
const activeRows = rows.filter(r => !r.is_skipped);
const totalThreshold = activeRows.reduce((s, r) => s + (r.actual_amount ?? r.expected_amount ?? 0), 0);
const totalPaid = activeRows.reduce((s, r) => s + (r.total_paid || 0), 0);
const skippedCount = rows.length - activeRows.length;
const pct = totalThreshold > 0 ? Math.min((totalPaid / totalThreshold) * 100, 100) : 0;
const allPaid = pct >= 100;
return (
<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>
<Table>
<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">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>
{rows.map((r, i) => (
<Row
key={r.id}
row={r}
year={year}
month={month}
refresh={refresh}
index={i}
onEditBill={onEditBill}
/>
))}
</TableBody>
</Table>
</div>
);
}
// ── Main page ──────────────────────────────────────────────────────────────
export default function TrackerPage() {
const now = new Date();
const [year, setYear] = useState(now.getFullYear());
const [month, setMonth] = useState(now.getMonth() + 1);
const [data, setData] = useState(null);
// Edit Bill modal: { bill, categories } when open, null when closed
const [editBillData, setEditBillData] = useState(null);
const load = useCallback(async () => {
try {
const res = await api.tracker(year, month);
setData(res);
} catch (err) {
toast.error(err.message);
}
}, [year, month]);
useEffect(() => { load(); }, [load]);
function navigate(delta) {
setMonth(m => {
const nm = m + delta;
if (nm > 12) { setYear(y => y + 1); return 1; }
if (nm < 1) { setYear(y => y - 1); return 12; }
return nm;
});
}
async function handleOpenEditBill(row) {
try {
const [bill, categories] = await Promise.all([
api.bill(row.id),
api.categories(),
]);
setEditBillData({ bill, categories });
} catch (err) {
toast.error(err.message);
}
}
function goToday() {
const n = new Date();
setYear(n.getFullYear());
setMonth(n.getMonth() + 1);
}
const rows = data?.rows || [];
const summary = data?.summary || {};
const first = rows.filter(r => r.bucket === '1st');
const second = rows.filter(r => r.bucket === '15th');
return (
<div className="space-y-5">
{/* ── Header ── */}
<div className="flex items-end 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) ── */}
<div className="flex gap-3">
<SummaryCard type="expected" value={summary.total_expected} />
<SummaryCard type="paid" value={summary.total_paid} />
<SummaryCard type="remaining" value={summary.remaining} />
<SummaryCard type="overdue" value={summary.overdue} />
</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 ── */}
{first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={load} onEditBill={handleOpenEditBill} />}
{second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={load} onEditBill={handleOpenEditBill} />}
{/* 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); load(); }}
/>
)}
</div>
);
}