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