diff --git a/src/routes/_authenticated/invoice.$id.tsx b/src/routes/_authenticated/invoice.$id.tsx new file mode 100644 index 0000000..ca6054e --- /dev/null +++ b/src/routes/_authenticated/invoice.$id.tsx @@ -0,0 +1,288 @@ +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 && ( + + + + )} + + + + + + + + + + + + + + + +
DateDescriptionChargePayment
{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. +

+ )} +
+ + +
+
+ ); +}