Modified by www.SourceFiles.app
This commit is contained in:
@@ -0,0 +1,421 @@
|
|||||||
|
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 ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: invoices } = useQuery({
|
||||||
|
queryKey: ["receivables-invoices"],
|
||||||
|
enabled: allowed,
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await supabase
|
||||||
|
.from("invoices")
|
||||||
|
.select(
|
||||||
|
"id, invoice_number, student_id, campus_id, billing_period_start, billing_period_end, due_date, total_cents, amount_paid_cents, balance_due_cents, status, students(first_name, last_name)",
|
||||||
|
)
|
||||||
|
.neq("status", "void")
|
||||||
|
.order("due_date")
|
||||||
|
).data ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: payments } = useQuery({
|
||||||
|
queryKey: ["recent-payments"],
|
||||||
|
enabled: allowed,
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await supabase
|
||||||
|
.from("payments")
|
||||||
|
.select(
|
||||||
|
"id, amount_cents, method, kind, status, reference_number, effective_date, student_id, void_reason, students(first_name, last_name)",
|
||||||
|
)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(15)
|
||||||
|
).data ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// 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.balance_due_cents ?? 0) > 0 && (i.due_date ?? "") < today);
|
||||||
|
if (statusFilter === "paid") r = r.filter((i) => (i.balance_due_cents ?? 0) <= 0);
|
||||||
|
return r;
|
||||||
|
}, [invoices, campusFilter, statusFilter, today]);
|
||||||
|
|
||||||
|
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 pastDue = scope
|
||||||
|
.filter((i) => (i.balance_due_cents ?? 0) > 0 && (i.due_date ?? "") < today)
|
||||||
|
.reduce((s, i) => s + (i.balance_due_cents ?? 0), 0);
|
||||||
|
const delinquent = new Set(
|
||||||
|
scope
|
||||||
|
.filter((i) => (i.balance_due_cents ?? 0) > 0 && (i.due_date ?? "") < today)
|
||||||
|
.map((i) => i.student_id),
|
||||||
|
).size;
|
||||||
|
return { invoiced, collected, unpaid, pastDue, delinquent };
|
||||||
|
}, [invoices, campusFilter, today]);
|
||||||
|
|
||||||
|
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.balance_due_cents ?? 0) > 0 && (i.due_date ?? "") < today;
|
||||||
|
const name = i.students ? `${i.students.first_name} ${i.students.last_name}` : "—";
|
||||||
|
return (
|
||||||
|
<tr key={i.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 && i.student_id && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setPayFor({ studentId: i.student_id, 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) => (
|
||||||
|
<div key={p.id} className="flex items-center justify-between p-2.5 gap-3">
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="font-medium">
|
||||||
|
{p.students ? `${p.students.first_name} ${p.students.last_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(p.amount_cents)}</span>
|
||||||
|
{p.status === "posted" && p.kind === "payment" && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
title="Reverse this payment"
|
||||||
|
onClick={() =>
|
||||||
|
setReverseFor({
|
||||||
|
id: p.id,
|
||||||
|
label: `${money(p.amount_cents)} ${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