BillTracker/client/pages/TrackerPage-bk.jsx

728 lines
25 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 { 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>
);
}