import { createFileRoute, Link } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; import { supabase } from "@/integrations/supabase/client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ArrowLeft, Loader2, Mail, Printer } from "lucide-react"; import { useState } from "react"; import { DocHeader, SignatureLines } from "@/components/reports"; import { SCHOOL_NAME, money, monthToDateRange, schoolYearRange } from "@/lib/reports"; import { formatDate } from "@/lib/plans"; export const Route = createFileRoute("/_authenticated/invoice/$id")({ head: () => ({ meta: [{ title: "Invoice — School Portal" }] }), component: InvoicePage, }); const CATEGORY_LABEL: Record = { tuition: "Tuition", late_pickup: "Late pickup", activity: "Activity", other: "Other", }; function InvoicePage() { const { id } = Route.useParams(); const initial = monthToDateRange(); const [from, setFrom] = useState(initial.from); const [to, setTo] = useState(initial.to); const { data: student } = useQuery({ queryKey: ["invoice-student", id], queryFn: async () => ( await supabase .from("students") .select("id, first_name, last_name, grade_level, daily_tuition_cents, classes(name)") .eq("id", id) .single() ).data, }); // Every entry, so the opening balance can be derived from activity before `from`. const { data: entries } = useQuery({ queryKey: ["invoice-entries", id], queryFn: async () => ( await supabase .from("ledger_entries") .select("id, date, kind, category, amount_cents, note, attendance_id") .eq("student_id", id) .order("date") ).data ?? [], }); // Guardians from the intake form, plus any linked parent login accounts. const { data: recipients } = useQuery({ queryKey: ["invoice-recipients", id], queryFn: async () => { const [guardians, links] = await Promise.all([ supabase.from("student_guardians").select("full_name, email").eq("student_id", id), supabase.from("parent_students").select("parent_id").eq("student_id", id), ]); const emails = new Map(); for (const g of guardians.data ?? []) if (g.email) emails.set(g.email.toLowerCase(), g.full_name ?? g.email); const ids = (links.data ?? []).map((l) => l.parent_id); if (ids.length) { // profiles is admin-readable only; non-admins simply get fewer recipients. const { data: profs } = await supabase .from("profiles") .select("full_name, email") .in("id", ids); for (const p of profs ?? []) if (p.email) emails.set(p.email.toLowerCase(), p.full_name ?? p.email); } return [...emails.entries()].map(([email, name]) => ({ email, name })); }, }); if (!student) { return (
Loading invoice…
); } const all = entries ?? []; const signed = (e: { kind: string; amount_cents: number }) => e.kind === "charge" ? e.amount_cents : -e.amount_cents; const opening = all.filter((e) => e.date < from).reduce((n, e) => n + signed(e), 0); const period = all.filter((e) => e.date >= from && e.date <= to); const charges = period.filter((e) => e.kind === "charge").reduce((n, e) => n + e.amount_cents, 0); const payments = period .filter((e) => e.kind === "payment") .reduce((n, e) => n + e.amount_cents, 0); const balanceDue = opening + charges - payments; const name = `${student.first_name} ${student.last_name}`; const lines = period .map( (e) => `${formatDate(e.date)} ${CATEGORY_LABEL[e.category] ?? e.category}${e.note ? ` — ${e.note}` : ""} ${ e.kind === "charge" ? money(e.amount_cents) : `(${money(e.amount_cents)})` }`, ) .join("\n"); // mailto: is the same mechanism the intake-link share uses — it opens the // staff member's own mail client with a prefilled draft. It cannot attach a // file, so the itemisation goes in the body as text. const mailto = `mailto:${(recipients ?? []).map((r) => r.email).join(",")}` + `?subject=${encodeURIComponent(`${SCHOOL_NAME} — invoice for ${name}`)}` + `&body=${encodeURIComponent( `Hello,\n\nHere is the tuition statement for ${name} covering ${formatDate(from)} to ${formatDate(to)}.\n\n` + `Balance carried forward: ${money(opening)}\n` + `${lines ? `${lines}\n\n` : "\n"}` + `Charges this period: ${money(charges)}\n` + `Payments this period: ${money(payments)}\n` + `Balance due: ${money(balanceDue)}\n\n` + `Please reply to this email with any questions.\n\nThank you,\n${SCHOOL_NAME}`, )}`; return (
Tuition ledger
setFrom(e.target.value)} />
setTo(e.target.value)} />
{(recipients ?? []).length > 0 ? ( ) : ( )}
{(recipients ?? []).length > 0 && (

Will draft to: {(recipients ?? []).map((r) => r.email).join(", ")}

)}
{[ ["Student", name], ["Grade level", student.grade_level || "—"], ["Class", (student.classes as { name: string } | null)?.name ?? "—"], [ "Daily rate", student.daily_tuition_cents ? money(student.daily_tuition_cents) : "Not set", ], ].map(([k, v]) => (
{k} {v}
))}

Account activity

{period.map((e) => ( ))} {period.length === 0 && ( )}
Date Description Charge Payment
{formatDate(from)} Balance carried forward {opening !== 0 ? money(opening) : "—"} —
{formatDate(e.date)} {CATEGORY_LABEL[e.category] ?? e.category} {e.note ? — {e.note} : null} {e.kind === "charge" ? money(e.amount_cents) : "—"} {e.kind === "payment" ? money(e.amount_cents) : "—"}
No activity in this period.
Charges this period {money(charges)}
Payments this period {money(payments)}
Balance due {money(balanceDue)}
{balanceDue < 0 && (

A negative balance is a credit on the account.

)}
); }