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>
This commit is contained in:
2026-07-26 09:45:46 -04:00
co-authored by Claude Opus 5
parent 85d87d1c62
commit 9360bf3055
8 changed files with 1145 additions and 14 deletions
+48 -12
View File
@@ -1,7 +1,9 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ChevronRight, FileText } from "lucide-react";
import { money } from "@/lib/reports";
export const Route = createFileRoute("/_authenticated/ledger")({
head: () => ({ meta: [{ title: "Tuition — School Portal" }] }),
@@ -12,11 +14,16 @@ function LedgerListPage() {
const { data: students } = useQuery({
queryKey: ["students-with-balance"],
queryFn: async () => {
const { data: studs } = await supabase.from("students").select("id, first_name, last_name");
const { data: entries } = await supabase.from("ledger_entries").select("student_id, amount_cents, kind");
const { data: studs } = await supabase
.from("students")
.select("id, first_name, last_name, daily_tuition_cents");
const { data: entries } = await supabase
.from("ledger_entries")
.select("student_id, amount_cents, kind");
const balances: Record<string, number> = {};
(entries ?? []).forEach((e) => {
balances[e.student_id] = (balances[e.student_id] ?? 0) + (e.kind === "charge" ? e.amount_cents : -e.amount_cents);
balances[e.student_id] =
(balances[e.student_id] ?? 0) + (e.kind === "charge" ? e.amount_cents : -e.amount_cents);
});
return (studs ?? []).map((s) => ({ ...s, balance: balances[s.id] ?? 0 }));
},
@@ -25,18 +32,47 @@ function LedgerListPage() {
return (
<div className="p-8 max-w-4xl">
<h1 className="text-2xl font-semibold mb-1">Tuition ledger</h1>
<p className="text-muted-foreground text-sm mb-6">Click a student to view and manage their ledger.</p>
<p className="text-muted-foreground text-sm mb-6">
Click a student to view and manage their ledger, or build an invoice. Students with a daily
rate are charged automatically when marked present or late.
</p>
<div className="bg-card border rounded-lg divide-y">
{(students ?? []).map((s) => (
<Link key={s.id} to="/students/$id" params={{ id: s.id }} className="flex justify-between items-center p-3 hover:bg-muted/50">
<span className="font-medium">{s.last_name}, {s.first_name}</span>
<span className="flex items-center gap-3">
<span className={`text-sm font-medium ${s.balance > 0 ? "text-destructive" : "text-green-700"}`}>${(s.balance / 100).toFixed(2)}</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
<div key={s.id} className="flex items-center gap-3 p-3 hover:bg-muted/50">
<Link
to="/students/$id"
params={{ id: s.id }}
className="flex-1 min-w-0 flex items-center gap-2"
>
<span className="font-medium truncate">
{s.last_name}, {s.first_name}
</span>
{s.daily_tuition_cents ? (
<span className="text-xs text-muted-foreground shrink-0">
{money(s.daily_tuition_cents)}/day
</span>
) : (
<span className="text-xs text-muted-foreground shrink-0">no rate</span>
)}
</Link>
<span
className={`text-sm font-medium shrink-0 ${s.balance > 0 ? "text-destructive" : "text-green-700"}`}
>
{money(s.balance)}
</span>
</Link>
<Link to="/invoice/$id" params={{ id: s.id }} className="shrink-0">
<Button size="sm" variant="outline">
<FileText className="h-4 w-4 mr-1" /> Invoice
</Button>
</Link>
<Link to="/students/$id" params={{ id: s.id }} className="shrink-0">
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</Link>
</div>
))}
{students?.length === 0 && <div className="p-6 text-center text-sm text-muted-foreground">No students.</div>}
{students?.length === 0 && (
<div className="p-6 text-center text-sm text-muted-foreground">No students.</div>
)}
</div>
</div>
);