Terminal
This commit is contained in:
@@ -1,423 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth, canBill } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { money } from "@/lib/reports";
|
||||
import { Banknote, Loader2, RotateCcw } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/receivables")({
|
||||
head: () => ({ meta: [{ title: "Receivables — School Portal" }] }),
|
||||
component: ReceivablesPage,
|
||||
});
|
||||
|
||||
const METHODS = ["cash", "check", "ach", "card", "scholarship", "third_party", "other"];
|
||||
|
||||
function ReceivablesPage() {
|
||||
const { user, roles } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
const allowed = canBill(roles);
|
||||
|
||||
const [campusFilter, setCampusFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState("outstanding");
|
||||
const [payFor, setPayFor] = useState<{ studentId: string; name: string } | null>(null);
|
||||
const [amount, setAmount] = useState("");
|
||||
const [method, setMethod] = useState("cash");
|
||||
const [reference, setReference] = useState("");
|
||||
const [reverseFor, setReverseFor] = useState<{ id: string; label: string } | null>(null);
|
||||
const [reverseReason, setReverseReason] = useState("");
|
||||
|
||||
const { data: campuses } = useQuery({
|
||||
queryKey: ["campuses"],
|
||||
queryFn: async () =>
|
||||
(await supabase.from("campuses").select("id, name").order("name")).data ?? [],
|
||||
});
|
||||
|
||||
// Read through v_billing_detail rather than invoices + students(...). A
|
||||
// billing admin can see every invoice but no student rows, so the nested
|
||||
// select returned a null student on every row; the view resolves the name
|
||||
// through a helper that re-checks entitlement instead. It also excludes void
|
||||
// invoices and computes is_past_due from status, balance and due date
|
||||
// together, which is stricter than comparing due_date alone.
|
||||
const { data: invoices } = useQuery({
|
||||
queryKey: ["receivables-invoices"],
|
||||
enabled: allowed,
|
||||
queryFn: async () =>
|
||||
(
|
||||
await supabase
|
||||
.from("v_billing_detail")
|
||||
.select(
|
||||
"invoice_id, invoice_number, student_id, student_name, campus_id, billing_period_start, billing_period_end, due_date, total_cents, amount_paid_cents, balance_due_cents, status, is_past_due, days_overdue",
|
||||
)
|
||||
.order("due_date")
|
||||
).data ?? [],
|
||||
});
|
||||
|
||||
const { data: payments } = useQuery({
|
||||
queryKey: ["recent-payments"],
|
||||
enabled: allowed,
|
||||
queryFn: async () =>
|
||||
(
|
||||
await supabase
|
||||
.from("v_payment_detail")
|
||||
.select(
|
||||
"payment_id, amount_cents, method, kind, status, reference_number, effective_date, student_id, void_reason, student_name",
|
||||
)
|
||||
.order("received_at", { ascending: false })
|
||||
.limit(15)
|
||||
).data ?? [],
|
||||
});
|
||||
|
||||
// Aggregated in the client: invoice volumes here are small, and it keeps the
|
||||
// tiles and the table reading from exactly the same rows.
|
||||
const rows = useMemo(() => {
|
||||
let r = invoices ?? [];
|
||||
if (campusFilter !== "all") r = r.filter((i) => i.campus_id === campusFilter);
|
||||
if (statusFilter === "outstanding") r = r.filter((i) => (i.balance_due_cents ?? 0) > 0);
|
||||
if (statusFilter === "pastdue") r = r.filter((i) => i.is_past_due);
|
||||
if (statusFilter === "paid") r = r.filter((i) => (i.balance_due_cents ?? 0) <= 0);
|
||||
return r;
|
||||
}, [invoices, campusFilter, statusFilter]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const scope =
|
||||
campusFilter === "all"
|
||||
? (invoices ?? [])
|
||||
: (invoices ?? []).filter((i) => i.campus_id === campusFilter);
|
||||
const invoiced = scope.reduce((s, i) => s + (i.total_cents ?? 0), 0);
|
||||
const collected = scope.reduce((s, i) => s + (i.amount_paid_cents ?? 0), 0);
|
||||
const unpaid = scope.reduce((s, i) => s + Math.max(i.balance_due_cents ?? 0, 0), 0);
|
||||
const overdue = scope.filter((i) => i.is_past_due);
|
||||
const pastDue = overdue.reduce((s, i) => s + (i.balance_due_cents ?? 0), 0);
|
||||
const delinquent = new Set(overdue.map((i) => i.student_id)).size;
|
||||
return { invoiced, collected, unpaid, pastDue, delinquent };
|
||||
}, [invoices, campusFilter]);
|
||||
|
||||
const record = useMutation({
|
||||
mutationFn: async () => {
|
||||
const dollars = Number(amount);
|
||||
if (!Number.isFinite(dollars) || dollars <= 0) throw new Error("Enter a valid amount");
|
||||
const { data, error } = await supabase
|
||||
.from("payments")
|
||||
.insert({
|
||||
student_id: payFor!.studentId,
|
||||
method,
|
||||
amount_cents: Math.round(dollars * 100),
|
||||
reference_number: reference || null,
|
||||
received_by: user?.id ?? null,
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
if (error) throw error;
|
||||
|
||||
// Oldest balance first — the spec's default when no explicit split is given.
|
||||
const { data: applied, error: aErr } = await supabase.rpc("allocate_payment_oldest_first", {
|
||||
_payment: data.id,
|
||||
});
|
||||
if (aErr) throw aErr;
|
||||
return applied as number;
|
||||
},
|
||||
onSuccess: (applied) => {
|
||||
setPayFor(null);
|
||||
setAmount("");
|
||||
setReference("");
|
||||
qc.invalidateQueries({ queryKey: ["receivables-invoices"] });
|
||||
qc.invalidateQueries({ queryKey: ["recent-payments"] });
|
||||
toast.success(`Payment recorded — ${money(applied ?? 0)} applied to invoices`);
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const reverse = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { error } = await supabase.rpc("reverse_payment", {
|
||||
_payment: reverseFor!.id,
|
||||
_reason: reverseReason.trim(),
|
||||
});
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setReverseFor(null);
|
||||
setReverseReason("");
|
||||
qc.invalidateQueries({ queryKey: ["receivables-invoices"] });
|
||||
qc.invalidateQueries({ queryKey: ["recent-payments"] });
|
||||
toast.success("Payment reversed");
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
if (!allowed) {
|
||||
return (
|
||||
<div className="p-8 max-w-2xl">
|
||||
<h1 className="text-2xl font-semibold mb-1">Receivables</h1>
|
||||
<p className="text-muted-foreground text-sm">You don't have billing access.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tiles = [
|
||||
{ label: "Invoiced", value: totals.invoiced },
|
||||
{ label: "Collected", value: totals.collected },
|
||||
{ label: "Unpaid", value: totals.unpaid },
|
||||
{ label: "Past due", value: totals.pastDue, warn: totals.pastDue > 0 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 max-w-6xl">
|
||||
<div className="flex flex-wrap items-end gap-3 mb-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Receivables</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{totals.delinquent} delinquent account{totals.delinquent === 1 ? "" : "s"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Select value={campusFilter} onValueChange={setCampusFilter}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All campuses</SelectItem>
|
||||
{(campuses ?? []).map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="outstanding">Outstanding</SelectItem>
|
||||
<SelectItem value="pastdue">Past due</SelectItem>
|
||||
<SelectItem value="paid">Paid</SelectItem>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
{tiles.map((t) => (
|
||||
<div key={t.label} className="bg-card border rounded-lg p-4">
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-wide">{t.label}</div>
|
||||
<div
|
||||
className={`text-2xl font-semibold tabular-nums mt-1 ${
|
||||
t.warn ? "text-rose-600" : ""
|
||||
}`}
|
||||
>
|
||||
{money(t.value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-card border rounded-lg overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm min-w-[46rem]">
|
||||
<thead className="text-muted-foreground border-b">
|
||||
<tr>
|
||||
<th className="text-left font-normal p-2.5">Invoice</th>
|
||||
<th className="text-left font-normal">Student</th>
|
||||
<th className="text-left font-normal">Period</th>
|
||||
<th className="text-left font-normal">Due</th>
|
||||
<th className="text-right font-normal">Total</th>
|
||||
<th className="text-right font-normal">Paid</th>
|
||||
<th className="text-right font-normal">Balance</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((i) => {
|
||||
const overdue = i.is_past_due;
|
||||
const name = i.student_name ?? "—";
|
||||
// Every column of a view is nullable in the generated types, so the
|
||||
// id is pulled into a const the closure below can narrow on.
|
||||
const studentId = i.student_id;
|
||||
return (
|
||||
<tr key={i.invoice_id} className="border-b last:border-0">
|
||||
<td className="p-2.5 font-medium">{i.invoice_number}</td>
|
||||
<td>{name}</td>
|
||||
<td className="text-muted-foreground text-xs">
|
||||
{i.billing_period_start} → {i.billing_period_end}
|
||||
</td>
|
||||
<td className={overdue ? "text-rose-600 font-medium" : ""}>{i.due_date}</td>
|
||||
<td className="text-right tabular-nums">{money(i.total_cents ?? 0)}</td>
|
||||
<td className="text-right tabular-nums">{money(i.amount_paid_cents ?? 0)}</td>
|
||||
<td className="text-right tabular-nums font-medium">
|
||||
{money(i.balance_due_cents ?? 0)}
|
||||
</td>
|
||||
<td className="pr-2 text-right">
|
||||
{(i.balance_due_cents ?? 0) > 0 && studentId && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setPayFor({ studentId, name });
|
||||
setAmount(((i.balance_due_cents ?? 0) / 100).toFixed(2));
|
||||
}}
|
||||
>
|
||||
<Banknote className="h-4 w-4 mr-1" /> Pay
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="p-4 text-center text-muted-foreground">
|
||||
Nothing matches these filters.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border rounded-lg p-4">
|
||||
<div className="font-medium text-sm mb-3">Recent payments</div>
|
||||
<div className="border rounded divide-y text-sm">
|
||||
{(payments ?? []).map((p) => {
|
||||
const paymentId = p.payment_id;
|
||||
const amount = p.amount_cents ?? 0;
|
||||
return (
|
||||
<div key={paymentId} className="flex items-center justify-between p-2.5 gap-3">
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium">{p.student_name ?? "—"}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
· {p.method} · {p.effective_date}
|
||||
{p.reference_number && ` · ${p.reference_number}`}
|
||||
</span>
|
||||
{p.status !== "posted" && (
|
||||
<span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-muted">{p.status}</span>
|
||||
)}
|
||||
{p.kind !== "payment" && (
|
||||
<span className="ml-1 text-xs px-1.5 py-0.5 rounded bg-muted">{p.kind}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-3 shrink-0">
|
||||
<span className="tabular-nums font-medium">{money(amount)}</span>
|
||||
{p.status === "posted" && p.kind === "payment" && paymentId && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
title="Reverse this payment"
|
||||
onClick={() =>
|
||||
setReverseFor({
|
||||
id: paymentId,
|
||||
label: `${money(amount)} ${p.method}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(payments ?? []).length === 0 && (
|
||||
<div className="p-3 text-muted-foreground">No payments recorded yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={!!payFor} onOpenChange={(o) => !o && setPayFor(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Record payment</DialogTitle>
|
||||
<DialogDescription>
|
||||
{payFor && `For ${payFor.name}. Applied to the oldest outstanding balance first.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">Amount</Label>
|
||||
<Input value={amount} onChange={(e) => setAmount(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Method</Label>
|
||||
<Select value={method} onValueChange={setMethod}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{METHODS.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m.replace("_", " ")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Reference (cheque no., transaction id)</Label>
|
||||
<Input value={reference} onChange={(e) => setReference(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setPayFor(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => record.mutate()} disabled={record.isPending}>
|
||||
{record.isPending && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
|
||||
Record payment
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!reverseFor} onOpenChange={(o) => !o && setReverseFor(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reverse payment</DialogTitle>
|
||||
<DialogDescription>
|
||||
{reverseFor &&
|
||||
`Reversing ${reverseFor.label}. The original stays on the ledger and a matching reversal is written against your name.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Reason — e.g. cheque returned NSF"
|
||||
value={reverseReason}
|
||||
onChange={(e) => setReverseReason(e.target.value)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setReverseFor(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!reverseReason.trim() || reverse.isPending}
|
||||
onClick={() => reverse.mutate()}
|
||||
>
|
||||
Reverse
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user