BillTracker/client/pages/TrackerPage.jsx

915 lines
35 KiB
JavaScript
Raw Permalink 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 { 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>
);
}