Files
info-share-spot/src/routes/_authenticated/invoice.$id.tsx
T
adminandClaude Opus 5 9360bf3055 Add printable report cards, progress reports and invoices
Three documents, all print-styled rather than generated server-side: no
PDF dependency is added, and "Save as PDF" in the browser produces the
file. @media print in styles.css strips the app chrome (.no-print) and
breaks each student onto its own sheet (.print-page).

- Report card: weighted category breakdown, overall percent and letter
  from the existing gradebook config, plus an attendance summary.
- Academic progress report: the same, plus assignment-level detail —
  a mid-term report is only actionable with the underlying work listed.
- 504 / IEP progress report: each goal's baseline, target, status and
  progress entries within the period, for the quarterly report IDEA
  requires. RLS keeps it to the case manager, admins and parents.
- Invoice, reachable per student from the tuition ledger: opening
  balance carried forward, itemised activity, and balance due.

Grade maths is reused from lib/grades and the class grading config from
useEffectiveConfig, so report cards cannot drift from the gradebook.

The "Email to parents" button opens a prefilled mailto: draft — the same
mechanism the intake-link share already uses. It gathers addresses from
intake guardians and linked parent accounts. mailto cannot attach a file,
so the itemisation goes in the body as text; sending a real attachment
would need a transactional email provider and an API key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 09:45:46 -04:00

289 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
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<string, string>();
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 (
<div className="p-8 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading invoice…
</div>
);
}
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 (
<div className="p-6 md:p-8 max-w-3xl">
<div className="no-print space-y-3">
<Link to="/ledger" className="text-sm text-muted-foreground inline-flex items-center gap-1">
<ArrowLeft className="h-4 w-4" /> Tuition ledger
</Link>
<div className="bg-card border rounded-lg p-4 flex flex-wrap gap-3 items-end">
<div>
<Label className="text-xs">From</Label>
<Input
type="date"
key={`f${from}`}
defaultValue={from}
onChange={(e) => setFrom(e.target.value)}
/>
</div>
<div>
<Label className="text-xs">To</Label>
<Input
type="date"
key={`t${to}`}
defaultValue={to}
onChange={(e) => setTo(e.target.value)}
/>
</div>
<Button
size="sm"
variant="outline"
onClick={() => {
const r = schoolYearRange();
setFrom(r.from);
setTo(r.to);
}}
>
School year
</Button>
<div className="flex-1" />
<Button size="sm" onClick={() => window.print()}>
<Printer className="h-4 w-4 mr-1" /> Print
</Button>
{(recipients ?? []).length > 0 ? (
<a href={mailto}>
<Button size="sm" variant="outline">
<Mail className="h-4 w-4 mr-1" /> Email to parents
</Button>
</a>
) : (
<Button
size="sm"
variant="outline"
disabled
title="No guardian or parent email on file"
>
<Mail className="h-4 w-4 mr-1" /> Email to parents
</Button>
)}
</div>
{(recipients ?? []).length > 0 && (
<p className="text-xs text-muted-foreground">
Will draft to: {(recipients ?? []).map((r) => r.email).join(", ")}
</p>
)}
</div>
<div className="print-doc bg-card border rounded-lg p-6 mt-6 space-y-5">
<DocHeader title="Invoice" subtitle={`${formatDate(from)} – ${formatDate(to)}`} />
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-sm">
{[
["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]) => (
<div key={k} className="flex justify-between border-b py-1">
<span className="text-muted-foreground">{k}</span>
<span className="font-medium text-right">{v}</span>
</div>
))}
</div>
<div>
<h3 className="text-sm font-semibold uppercase tracking-wide mb-2">Account activity</h3>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-1 font-medium">Date</th>
<th className="py-1 font-medium">Description</th>
<th className="py-1 font-medium text-right">Charge</th>
<th className="py-1 font-medium text-right">Payment</th>
</tr>
</thead>
<tbody>
<tr className="border-b">
<td className="py-1 text-muted-foreground">{formatDate(from)}</td>
<td className="py-1 text-muted-foreground">Balance carried forward</td>
<td className="py-1 text-right">{opening !== 0 ? money(opening) : "—"}</td>
<td className="py-1 text-right">—</td>
</tr>
{period.map((e) => (
<tr key={e.id} className="border-b">
<td className="py-1">{formatDate(e.date)}</td>
<td className="py-1">
{CATEGORY_LABEL[e.category] ?? e.category}
{e.note ? <span className="text-muted-foreground"> — {e.note}</span> : null}
</td>
<td className="py-1 text-right">
{e.kind === "charge" ? money(e.amount_cents) : "—"}
</td>
<td className="py-1 text-right">
{e.kind === "payment" ? money(e.amount_cents) : "—"}
</td>
</tr>
))}
{period.length === 0 && (
<tr>
<td colSpan={4} className="py-3 text-sm text-muted-foreground">
No activity in this period.
</td>
</tr>
)}
</tbody>
<tfoot>
<tr>
<td colSpan={2} className="pt-2 text-right text-muted-foreground">
Charges this period
</td>
<td className="pt-2 text-right">{money(charges)}</td>
<td />
</tr>
<tr>
<td colSpan={2} className="text-right text-muted-foreground">
Payments this period
</td>
<td />
<td className="text-right">{money(payments)}</td>
</tr>
<tr className="font-semibold text-base">
<td colSpan={2} className="pt-2 text-right">
Balance due
</td>
<td colSpan={2} className="pt-2 text-right">
{money(balanceDue)}
</td>
</tr>
</tfoot>
</table>
{balanceDue < 0 && (
<p className="text-xs text-muted-foreground mt-1">
A negative balance is a credit on the account.
</p>
)}
</div>
<SignatureLines labels={["Received by", "Date"]} />
</div>
</div>
);
}