BillTracker/client/pages/TrackerPage-bk.jsx

728 lines
25 KiB
React
Raw Normal View History

2026-05-03 19:51:57 -05:00
import { useState, useEffect, useCallback, useRef } from 'react';
import { toast } from 'sonner';
import { ChevronLeft, ChevronRight, MoreHorizontal, ReceiptText } from 'lucide-react';
import { api } from '@/api.js';
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
import { Button, buttonVariants } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogCancel,
AlertDialogAction,
} from '@/components/ui/alert-dialog';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/select';
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from '@/components/ui/table';
// ─── Constants ────────────────────────────────────────────────────────────────
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const PAYMENT_METHODS = [
{ value: 'bank_transfer', label: 'Bank Transfer' },
{ value: 'card', label: 'Card' },
{ value: 'autopay', label: 'Autopay' },
{ value: 'check', label: 'Check' },
{ value: 'cash', label: 'Cash' },
{ value: 'other', label: 'Other' },
];
// ─── Status Config ────────────────────────────────────────────────────────────
const STATUS = {
paid: { label: 'Paid', dot: 'bg-emerald-400', chip: 'bg-emerald-500/10 text-emerald-500' },
upcoming: { label: 'Upcoming', dot: 'bg-slate-400', chip: 'bg-slate-500/10 text-slate-500 dark:text-slate-400' },
due_soon: { label: 'Due Soon', dot: 'bg-amber-400', chip: 'bg-amber-500/10 text-amber-600 dark:text-amber-400' },
late: { label: 'Late', dot: 'bg-orange-400', chip: 'bg-orange-500/10 text-orange-600 dark:text-orange-400' },
missed: { label: 'Missed', dot: 'bg-red-400', chip: 'bg-red-500/10 text-red-500' },
autodraft: { label: 'Autodraft', dot: 'bg-violet-400', chip: 'bg-violet-500/10 text-violet-500' },
};
// ─── Status Chip ──────────────────────────────────────────────────────────────
function StatusChip({ status }) {
const cfg = STATUS[status] ?? { label: status, dot: 'bg-slate-400', chip: 'bg-slate-500/10 text-slate-500' };
return (
<span className={cn('inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium', cfg.chip)}>
<span className={cn('h-1.5 w-1.5 rounded-full', cfg.dot)} />
{cfg.label}
</span>
);
}
// ─── Payment Dialog ───────────────────────────────────────────────────────────
function PaymentDialog({ open, onOpenChange, bill, payment, onSuccess }) {
const isEdit = !!payment;
const [form, setForm] = useState({
amount: '',
paid_date: todayStr(),
method: '',
notes: '',
});
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
if (isEdit) {
setForm({
amount: String(payment.amount ?? ''),
paid_date: payment.paid_date ?? todayStr(),
method: payment.method ?? '',
notes: payment.notes ?? '',
});
} else {
setForm({
amount: String(bill?.expected_amount ?? ''),
paid_date: todayStr(),
method: '',
notes: '',
});
}
}, [open, isEdit, payment, bill]);
const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }));
async function handleSubmit() {
if (!form.amount || !form.paid_date) {
toast.error('Amount and date are required');
return;
}
const amount = parseFloat(form.amount);
if (isNaN(amount) || amount <= 0) {
toast.error('Enter a valid amount');
return;
}
setSaving(true);
try {
const payload = {
amount,
paid_date: form.paid_date,
method: form.method || null,
notes: form.notes || null,
};
if (isEdit) {
await api.updatePayment(payment.id, payload);
toast.success('Payment updated');
} else {
await api.quickPay({ bill_id: bill.id, ...payload });
toast.success(`Paid ${fmt(amount)} for ${bill.name}`);
}
onOpenChange(false);
onSuccess?.();
} catch (err) {
toast.error(err.message);
} finally {
setSaving(false);
}
}
const title = isEdit
? `Edit Payment — ${bill?.name ?? ''}`
: `Record Payment — ${bill?.name ?? ''}`;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-1">
{/* Amount */}
<div className="space-y-1.5">
<Label htmlFor="pd-amount">Amount</Label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
id="pd-amount"
type="number"
step="0.01"
min="0"
placeholder="0.00"
className="pl-7 font-mono"
value={form.amount}
onChange={set('amount')}
/>
</div>
</div>
{/* Date Paid */}
<div className="space-y-1.5">
<Label htmlFor="pd-date">Date Paid</Label>
<Input
id="pd-date"
type="date"
value={form.paid_date}
onChange={set('paid_date')}
/>
</div>
{/* Method */}
<div className="space-y-1.5">
<Label htmlFor="pd-method">Method</Label>
<Select value={form.method} onValueChange={(v) => setForm((f) => ({ ...f, method: v }))}>
<SelectTrigger id="pd-method">
<SelectValue placeholder="Select method…" />
</SelectTrigger>
<SelectContent>
{PAYMENT_METHODS.map((m) => (
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Notes */}
<div className="space-y-1.5">
<Label htmlFor="pd-notes">Notes</Label>
<Input
id="pd-notes"
placeholder="Add a note…"
value={form.notes}
onChange={set('notes')}
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={saving}>
{saving ? 'Saving…' : isEdit ? 'Save Changes' : 'Record Payment'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─── Remove Payment Alert Dialog ──────────────────────────────────────────────
function RemovePaymentDialog({ open, onOpenChange, bill, paymentId, onSuccess }) {
const [removing, setRemoving] = useState(false);
async function handleConfirm() {
setRemoving(true);
try {
await api.deletePayment(paymentId);
toast.success('Payment removed');
onOpenChange(false);
onSuccess?.();
} catch (err) {
toast.error(err.message);
} finally {
setRemoving(false);
}
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove payment?</AlertDialogTitle>
<AlertDialogDescription>
Removing this payment will mark{' '}
<strong className="text-foreground">{bill?.name}</strong>{' '}
as unpaid. The bill itself will not be deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={removing}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: 'destructive' })}
onClick={handleConfirm}
disabled={removing}
>
{removing ? 'Removing…' : 'Remove Payment'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// ─── Bill Form stub ───────────────────────────────────────────────────────────
function openBillForm(bill) {
// TODO: replace with shared BillFormDialog extracted from BillsPage
console.log('[BillFormDialog] Edit bill:', bill);
}
// ─── Row Actions Dropdown ─────────────────────────────────────────────────────
function RowActions({ row, payInputRef, onRefresh }) {
const [payDialogOpen, setPayDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [removeDialogOpen, setRemoveDialogOpen] = useState(false);
const isPaid = row.status === 'paid' || row.status === 'autodraft';
const latestPayment = row.payments?.length
? [...row.payments].sort((a, b) => b.paid_date.localeCompare(a.paid_date))[0]
: null;
async function handleQuickPay() {
const raw = payInputRef?.current?.value ?? String(row.expected_amount ?? '');
const amount = parseFloat(raw);
if (isNaN(amount) || amount <= 0) {
toast.error('Enter a valid amount');
return;
}
try {
await api.quickPay({ bill_id: row.id, amount, paid_date: todayStr() });
toast.success(`Paid ${fmt(amount)} for ${row.name}`);
onRefresh();
} catch (err) {
toast.error(err.message);
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Row actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!isPaid ? (
<>
<DropdownMenuItem onSelect={handleQuickPay}>
Quick Pay
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setPayDialogOpen(true)}>
Record Payment
</DropdownMenuItem>
</>
) : (
<>
<DropdownMenuItem onSelect={() => setEditDialogOpen(true)}>
Edit Payment
</DropdownMenuItem>
<DropdownMenuItem
destructive
onSelect={() => setRemoveDialogOpen(true)}
>
Remove Payment
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => openBillForm(row)}>
Edit Bill
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Record Payment dialog (unpaid) */}
<PaymentDialog
open={payDialogOpen}
onOpenChange={setPayDialogOpen}
bill={row}
payment={null}
onSuccess={onRefresh}
/>
{/* Edit Payment dialog (paid) */}
<PaymentDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
bill={row}
payment={latestPayment}
onSuccess={onRefresh}
/>
{/* Remove Payment alert dialog */}
<RemovePaymentDialog
open={removeDialogOpen}
onOpenChange={setRemoveDialogOpen}
bill={row}
paymentId={latestPayment?.id}
onSuccess={onRefresh}
/>
</>
);
}
// ─── Bill Row ─────────────────────────────────────────────────────────────────
function BillRow({ row, onRefresh }) {
const payInputRef = useRef(null);
const isPaid = row.status === 'paid' || row.status === 'autodraft';
return (
<TableRow className="group border-b border-border/50 last:border-0 hover:bg-muted/30 transition-colors duration-150">
{/* Bill name + category */}
<TableCell className="px-6 py-4">
<div className="flex items-center gap-2.5">
{row.autopay_enabled && (
<span
className="h-1.5 w-1.5 rounded-full bg-amber-400 shrink-0"
title="Autopay"
/>
)}
<div>
<p className="font-medium text-sm">{row.name}</p>
{row.category_name && (
<p className="text-xs text-muted-foreground mt-0.5">{row.category_name}</p>
)}
</div>
</div>
</TableCell>
{/* Due date */}
<TableCell className="px-6 py-4 w-24">
<span className="text-sm text-muted-foreground">{fmtDate(row.due_date)}</span>
</TableCell>
{/* Expected */}
<TableCell className="px-6 py-4 w-28 text-right">
<span className="font-mono text-sm tabular-nums text-muted-foreground">
{fmt(row.expected_amount)}
</span>
</TableCell>
{/* Paid Date */}
<TableCell className="px-6 py-4 w-28">
{row.last_paid_date ? (
<span className="text-sm tabular-nums text-emerald-500">
{fmtDate(row.last_paid_date)}
</span>
) : (
<span className="text-sm text-muted-foreground/40"></span>
)}
</TableCell>
{/* Paid — input for unpaid, amount for paid */}
<TableCell className="px-6 py-4 w-32 text-right">
{isPaid ? (
<span className="font-mono text-sm tabular-nums text-emerald-500 font-medium">
{fmt(row.total_paid)}
</span>
) : (
<Input
ref={payInputRef}
type="number"
step="0.01"
min="0"
className="h-8 w-24 text-right font-mono text-sm ml-auto"
defaultValue={row.expected_amount}
/>
)}
</TableCell>
{/* Status */}
<TableCell className="px-6 py-4 w-32">
<StatusChip status={row.status} />
</TableCell>
{/* Actions */}
<TableCell className="px-6 py-4 w-12 text-right">
<RowActions row={row} payInputRef={payInputRef} onRefresh={onRefresh} />
</TableCell>
</TableRow>
);
}
// ─── Bucket Section ───────────────────────────────────────────────────────────
function BucketSection({ title, rows, paidAmount, totalAmount, loading, onRefresh }) {
return (
<div className="mb-3 mt-10 first:mt-0">
{/* Floating bucket header — no card wrapper */}
<div className="flex items-baseline justify-between mb-3">
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{title}
</h2>
{!loading && rows.length > 0 && (
<span className="text-xs tabular-nums text-muted-foreground">
{fmt(paidAmount)} paid of {fmt(totalAmount)}
</span>
)}
</div>
{/* Table wrapped in card */}
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden mb-6">
<Table>
<TableHeader className="bg-muted/40 border-b border-border">
<TableRow className="hover:bg-transparent border-0">
<TableHead className="px-6 py-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Bill
</TableHead>
<TableHead className="px-6 py-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground w-24">
Due
</TableHead>
<TableHead className="px-6 py-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground w-28 text-right">
Expected
</TableHead>
<TableHead className="px-6 py-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground w-28">
Paid Date
</TableHead>
<TableHead className="px-6 py-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground w-32 text-right">
Paid
</TableHead>
<TableHead className="px-6 py-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground w-32">
Status
</TableHead>
<TableHead className="px-6 py-3 w-12" />
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
[1, 2, 3].map((i) => (
<TableRow key={i} className="border-b border-border/50">
<TableCell className="px-6 py-4" colSpan={7}>
<div
className="h-4 rounded-md bg-muted/60 animate-pulse"
style={{ width: `${60 + i * 10}%` }}
/>
</TableCell>
</TableRow>
))
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-16 text-center">
<div className="flex flex-col items-center gap-2">
<ReceiptText className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No bills for this period</p>
<a href="/bills" className="text-xs text-primary hover:underline">
Add a bill
</a>
</div>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<BillRow key={row.id} row={row} onRefresh={onRefresh} />
))
)}
</TableBody>
</Table>
</div>
</div>
);
}
// ─── TrackerPage ──────────────────────────────────────────────────────────────
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);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await api.tracker(year, month);
setData(res);
} catch (err) {
toast.error(err.message);
} finally {
setLoading(false);
}
}, [year, month]);
useEffect(() => { load(); }, [load]);
function navigate(direction) {
if (direction === -1) {
if (month === 1) { setYear((y) => y - 1); setMonth(12); }
else setMonth((m) => m - 1);
} else {
if (month === 12) { setYear((y) => y + 1); setMonth(1); }
else setMonth((m) => m + 1);
}
}
function goToToday() {
const t = new Date();
setYear(t.getFullYear());
setMonth(t.getMonth() + 1);
}
const rows = data?.rows ?? [];
const summary = data?.summary ?? {};
const isCurrentMonth = year === now.getFullYear() && month === now.getMonth() + 1;
const paidCount = rows.filter((r) => r.status === 'paid' || r.status === 'autodraft').length;
const overdueCount = rows.filter((r) => r.status === 'late' || r.status === 'missed').length;
const allPaid = rows.length > 0 && paidCount === rows.length;
const bucket1 = rows.filter((r) => r.bucket === '1st');
const bucket15 = rows.filter((r) => r.bucket === '15th');
function bucketPaid(bucket) {
return bucket.reduce((sum, r) => {
const isPaid = r.status === 'paid' || r.status === 'autodraft';
return sum + (isPaid ? (r.total_paid || 0) : 0);
}, 0);
}
function bucketTotal(bucket) {
return bucket.reduce((sum, r) => sum + (r.expected_amount || 0), 0);
}
return (
<div>
{/* ── Page Header — floats on page background, no card ── */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{MONTH_NAMES[month - 1]} {year}
</h1>
<p className="text-sm text-muted-foreground mt-0.5">Monthly overview</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => navigate(-1)}
aria-label="Previous month"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => navigate(1)}
aria-label="Next month"
>
<ChevronRight className="h-4 w-4" />
</Button>
{!isCurrentMonth && (
<Button variant="outline" size="sm" onClick={goToToday}>
Today
</Button>
)}
</div>
</div>
{/* ── Summary Stat Tiles ── */}
<div className="grid grid-cols-4 gap-4 mb-8">
{/* Total Expected */}
<div className="bg-card rounded-xl border border-border p-6 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">
Total Expected
</p>
<p className="text-3xl font-bold tracking-tight tabular-nums">
{fmt(summary.total_expected)}
</p>
<p className="text-sm text-muted-foreground mt-1.5">
{rows.length} bill{rows.length !== 1 ? 's' : ''} this month
</p>
</div>
{/* Paid */}
<div className="bg-card rounded-xl border border-border p-6 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">
Paid
</p>
<p className={cn('text-3xl font-bold tracking-tight tabular-nums', allPaid && 'text-emerald-500')}>
{fmt(summary.total_paid)}
</p>
<p className="text-sm text-muted-foreground mt-1.5">
{paidCount} of {rows.length} paid
</p>
</div>
{/* Remaining */}
<div className="bg-card rounded-xl border border-border p-6 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">
Remaining
</p>
<p className="text-3xl font-bold tracking-tight tabular-nums">
{fmt(summary.remaining)}
</p>
<p className={cn('text-sm mt-1.5', overdueCount > 0 ? 'text-orange-400' : 'text-muted-foreground')}>
{overdueCount > 0
? `includes ${fmt(summary.overdue)} overdue`
: `${rows.length - paidCount} unpaid`}
</p>
</div>
{/* Overdue */}
<div className="bg-card rounded-xl border border-border p-6 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">
Overdue
</p>
<p className={cn('text-3xl font-bold tracking-tight tabular-nums', overdueCount > 0 ? 'text-red-500' : 'text-muted-foreground')}>
{fmt(summary.overdue)}
</p>
<p className="text-sm text-muted-foreground mt-1.5">
{overdueCount > 0
? `${overdueCount} bill${overdueCount !== 1 ? 's' : ''} overdue`
: 'All clear'}
</p>
</div>
</div>
{/* ── Bucket Sections ── */}
<BucketSection
title="1st 14th"
rows={bucket1}
paidAmount={bucketPaid(bucket1)}
totalAmount={bucketTotal(bucket1)}
loading={loading}
onRefresh={load}
/>
<BucketSection
title="15th 31st"
rows={bucket15}
paidAmount={bucketPaid(bucket15)}
totalAmount={bucketTotal(bucket15)}
loading={loading}
onRefresh={load}
/>
</div>
);
}