Modified by www.SourceFiles.app
This commit is contained in:
@@ -0,0 +1,402 @@
|
|||||||
|
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 { money } from "@/lib/reports";
|
||||||
|
import { Plus, Receipt, Eye, CheckCircle2, Loader2, AlertTriangle } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/billing")({
|
||||||
|
head: () => ({ meta: [{ title: "Billing — School Portal" }] }),
|
||||||
|
component: BillingPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
type PreviewRow = {
|
||||||
|
student_id: string;
|
||||||
|
student_name: string;
|
||||||
|
campus_name: string;
|
||||||
|
scheduled_days: number;
|
||||||
|
subtotal_cents: number;
|
||||||
|
total_cents: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Monday–Sunday of the current week, as ISO dates. */
|
||||||
|
function currentWeek(): { from: string; to: string } {
|
||||||
|
const now = new Date();
|
||||||
|
const day = (now.getDay() + 6) % 7; // 0 = Monday
|
||||||
|
const mon = new Date(now);
|
||||||
|
mon.setDate(now.getDate() - day);
|
||||||
|
const sun = new Date(mon);
|
||||||
|
sun.setDate(mon.getDate() + 6);
|
||||||
|
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
|
return { from: iso(mon), to: iso(sun) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function BillingPage() {
|
||||||
|
const { user, roles } = useAuth();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const allowed = canBill(roles);
|
||||||
|
|
||||||
|
const { data: campuses } = useQuery({
|
||||||
|
queryKey: ["campuses"],
|
||||||
|
queryFn: async () =>
|
||||||
|
(await supabase.from("campuses").select("id, name").order("name")).data ?? [],
|
||||||
|
});
|
||||||
|
const { data: tiers } = useQuery({
|
||||||
|
queryKey: ["tuition-tiers"],
|
||||||
|
queryFn: async () =>
|
||||||
|
(await supabase.from("tuition_tiers").select("id, name").order("sort_order")).data ?? [],
|
||||||
|
});
|
||||||
|
const { data: rates } = useQuery({
|
||||||
|
queryKey: ["tuition-rates"],
|
||||||
|
enabled: allowed,
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await supabase
|
||||||
|
.from("tuition_rates")
|
||||||
|
.select(
|
||||||
|
"id, amount_cents, rate_basis, attendance_basis, is_active, campuses(name), tuition_tiers(name)",
|
||||||
|
)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
).data ?? [],
|
||||||
|
});
|
||||||
|
const { data: batches } = useQuery({
|
||||||
|
queryKey: ["invoice-batches"],
|
||||||
|
enabled: allowed,
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await supabase
|
||||||
|
.from("invoice_batches")
|
||||||
|
.select(
|
||||||
|
"id, billing_period_start, billing_period_end, status, student_count, total_cents, confirmed_at",
|
||||||
|
)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(10)
|
||||||
|
).data ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- rate form ----
|
||||||
|
const [rCampus, setRCampus] = useState("");
|
||||||
|
const [rTier, setRTier] = useState("");
|
||||||
|
const [rBasis, setRBasis] = useState("per_day");
|
||||||
|
const [rAttendance, setRAttendance] = useState("part_time");
|
||||||
|
const [rAmount, setRAmount] = useState("");
|
||||||
|
|
||||||
|
const addRate = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const dollars = Number(rAmount);
|
||||||
|
if (!Number.isFinite(dollars) || dollars < 0) throw new Error("Enter a valid amount");
|
||||||
|
const { error } = await supabase.from("tuition_rates").insert({
|
||||||
|
campus_id: rCampus || null,
|
||||||
|
tier_id: rTier || null,
|
||||||
|
rate_basis: rBasis,
|
||||||
|
attendance_basis: rAttendance || null,
|
||||||
|
amount_cents: Math.round(dollars * 100),
|
||||||
|
priority: 200,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setRAmount("");
|
||||||
|
qc.invalidateQueries({ queryKey: ["tuition-rates"] });
|
||||||
|
toast.success("Rate added");
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- batch flow ----
|
||||||
|
const wk = currentWeek();
|
||||||
|
const [from, setFrom] = useState(wk.from);
|
||||||
|
const [to, setTo] = useState(wk.to);
|
||||||
|
const [preview, setPreview] = useState<{ batchId: string; rows: PreviewRow[] } | null>(null);
|
||||||
|
|
||||||
|
const createBatch = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("invoice_batches")
|
||||||
|
.insert({
|
||||||
|
billing_period_start: from,
|
||||||
|
billing_period_end: to,
|
||||||
|
criteria: {},
|
||||||
|
created_by: user?.id ?? null,
|
||||||
|
})
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data.id as string;
|
||||||
|
},
|
||||||
|
onSuccess: (id) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["invoice-batches"] });
|
||||||
|
toast.success("Batch created — preview it before issuing");
|
||||||
|
runPreview.mutate(id);
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const runPreview = useMutation({
|
||||||
|
mutationFn: async (batchId: string) => {
|
||||||
|
const { data, error } = await supabase.rpc("preview_invoice_batch", { _batch: batchId });
|
||||||
|
if (error) throw error;
|
||||||
|
return { batchId, rows: (data ?? []) as PreviewRow[] };
|
||||||
|
},
|
||||||
|
onSuccess: (r) => {
|
||||||
|
setPreview(r);
|
||||||
|
qc.invalidateQueries({ queryKey: ["invoice-batches"] });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The spec requires explicit administrative confirmation before bulk
|
||||||
|
// issuance; generate_invoice_batch() refuses without confirmed_at set.
|
||||||
|
const confirmAndIssue = useMutation({
|
||||||
|
mutationFn: async (batchId: string) => {
|
||||||
|
const { error: upErr } = await supabase
|
||||||
|
.from("invoice_batches")
|
||||||
|
.update({ confirmed_at: new Date().toISOString(), confirmed_by: user?.id ?? null })
|
||||||
|
.eq("id", batchId);
|
||||||
|
if (upErr) throw upErr;
|
||||||
|
const { data, error } = await supabase.rpc("generate_invoice_batch", { _batch: batchId });
|
||||||
|
if (error) throw error;
|
||||||
|
return data as number;
|
||||||
|
},
|
||||||
|
onSuccess: (n) => {
|
||||||
|
setPreview(null);
|
||||||
|
qc.invalidateQueries({ queryKey: ["invoice-batches"] });
|
||||||
|
toast.success(`${n} invoice${n === 1 ? "" : "s"} issued`);
|
||||||
|
},
|
||||||
|
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">Billing</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">You don't have billing access.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewTotal = preview?.rows.reduce((s, r) => s + Number(r.total_cents), 0) ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-5xl">
|
||||||
|
<h1 className="text-2xl font-semibold mb-1">Billing</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mb-6">Tuition rates and weekly invoice runs.</p>
|
||||||
|
|
||||||
|
{/* ---------- rates ---------- */}
|
||||||
|
<div className="bg-card border rounded-lg p-4 mb-6">
|
||||||
|
<div className="font-medium text-sm mb-3">Tuition rates</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-6 gap-2 mb-4">
|
||||||
|
<Select value={rCampus} onValueChange={setRCampus}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Campus" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(campuses ?? []).map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={rTier} onValueChange={setRTier}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Tier" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(tiers ?? []).map((t) => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={rAttendance} onValueChange={setRAttendance}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="part_time">Part-time</SelectItem>
|
||||||
|
<SelectItem value="full_time">Full-time</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={rBasis} onValueChange={setRBasis}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="per_day">Per day</SelectItem>
|
||||||
|
<SelectItem value="per_week">Per week</SelectItem>
|
||||||
|
<SelectItem value="per_month">Per month</SelectItem>
|
||||||
|
<SelectItem value="flat">Flat</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
placeholder="Amount (e.g. 50)"
|
||||||
|
value={rAmount}
|
||||||
|
onChange={(e) => setRAmount(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => addRate.mutate()} disabled={!rAmount || addRate.isPending}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" /> Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded divide-y text-sm">
|
||||||
|
{(rates ?? []).map((r) => (
|
||||||
|
<div key={r.id} className="flex items-center justify-between p-2">
|
||||||
|
<span>
|
||||||
|
{r.campuses?.name ?? "All campuses"} · {r.tuition_tiers?.name ?? "All tiers"} ·{" "}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{r.attendance_basis ?? "any"} / {r.rate_basis}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{money(r.amount_cents)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(rates ?? []).length === 0 && (
|
||||||
|
<div className="p-3 text-muted-foreground">
|
||||||
|
No rates yet. Invoices cannot be calculated until at least one exists.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------- batch run ---------- */}
|
||||||
|
<div className="bg-card border rounded-lg p-4 mb-6">
|
||||||
|
<div className="font-medium text-sm mb-3">Weekly invoice run</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 items-end">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Period start</Label>
|
||||||
|
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Period end</Label>
|
||||||
|
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => createBatch.mutate()}
|
||||||
|
disabled={createBatch.isPending || runPreview.isPending}
|
||||||
|
>
|
||||||
|
{createBatch.isPending || runPreview.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
Preview run
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------- preview ---------- */}
|
||||||
|
{preview && (
|
||||||
|
<div className="bg-card border rounded-lg p-4 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="font-medium text-sm">
|
||||||
|
Preview — {preview.rows.length} student{preview.rows.length === 1 ? "" : "s"}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
Total <span className="font-semibold">{money(previewTotal)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{preview.rows.length === 0 ? (
|
||||||
|
<div className="flex gap-2 text-sm text-muted-foreground border rounded p-3">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-amber-600" />
|
||||||
|
<div>
|
||||||
|
No eligible students. A student is only picked up when
|
||||||
|
<code className="mx-1">enrollment_status</code>is <code>enrolled</code> and they
|
||||||
|
have a campus schedule row covering this period. Newly added columns default to
|
||||||
|
<code className="mx-1">prospective</code>, so existing students need updating first.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-muted-foreground border-b">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left font-normal py-1">Student</th>
|
||||||
|
<th className="text-left font-normal">Campus</th>
|
||||||
|
<th className="text-right font-normal">Days</th>
|
||||||
|
<th className="text-right font-normal">Subtotal</th>
|
||||||
|
<th className="text-right font-normal">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{preview.rows.map((r) => (
|
||||||
|
<tr key={r.student_id} className="border-b last:border-0">
|
||||||
|
<td className="py-1.5">{r.student_name}</td>
|
||||||
|
<td className="text-muted-foreground">{r.campus_name ?? "—"}</td>
|
||||||
|
<td className="text-right">{Number(r.scheduled_days).toFixed(1)}</td>
|
||||||
|
<td className="text-right">{money(Number(r.subtotal_cents))}</td>
|
||||||
|
<td className="text-right font-medium">{money(Number(r.total_cents))}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
|
<Button variant="ghost" onClick={() => setPreview(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => confirmAndIssue.mutate(preview.batchId)}
|
||||||
|
disabled={confirmAndIssue.isPending}
|
||||||
|
>
|
||||||
|
{confirmAndIssue.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
Confirm and issue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---------- history ---------- */}
|
||||||
|
<div className="bg-card border rounded-lg p-4">
|
||||||
|
<div className="font-medium text-sm mb-3">Recent runs</div>
|
||||||
|
<div className="border rounded divide-y text-sm">
|
||||||
|
{(batches ?? []).map((b) => (
|
||||||
|
<div key={b.id} className="flex items-center justify-between p-2">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Receipt className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{b.billing_period_start} → {b.billing_period_end}
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-muted capitalize">
|
||||||
|
{b.status}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{b.student_count} invoice{b.student_count === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{money(b.total_cents)}</span>
|
||||||
|
{b.status === "previewed" && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => runPreview.mutate(b.id)}>
|
||||||
|
Re-preview
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(batches ?? []).length === 0 && (
|
||||||
|
<div className="p-3 text-muted-foreground">No runs yet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user