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/
|
||||
|
||||
# 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