chore: remove stale tracked backup/DB files; add /bills.db to gitignore
This commit is contained in:
parent
9a2a7ecdee
commit
04f5f922b7
|
|
@ -23,3 +23,6 @@ docs/SIMPLEFIN_CONSUMER_GUARDRAILS.md
|
||||||
|
|
||||||
# MkDocs docs site (auto-generated, not part of app source)
|
# MkDocs docs site (auto-generated, not part of app source)
|
||||||
mkdocs/
|
mkdocs/
|
||||||
|
|
||||||
|
# Root bill tracker DB (empty artifact, never commit)
|
||||||
|
/bills.db
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,727 +0,0 @@
|
||||||
import React, { 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue