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