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
+288
View File
@@ -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<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>
);
}
+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>
);
+191
View File
@@ -0,0 +1,191 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } 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 { FileText, Printer } from "lucide-react";
import { useState } from "react";
import { IepProgressDoc, ReportCardDoc } from "@/components/reports";
import { REPORT_TYPES, monthToDateRange, schoolYearRange, type ReportType } from "@/lib/reports";
export const Route = createFileRoute("/_authenticated/reports")({
head: () => ({ meta: [{ title: "Reports — School Portal" }] }),
component: ReportsPage,
});
const ALL = "__all__";
function ReportsPage() {
const { user, roles, loading } = useAuth();
const isAdmin = roles.includes("admin");
const [type, setType] = useState<ReportType>("report_card");
const [classId, setClassId] = useState("");
const [studentId, setStudentId] = useState(ALL);
const year = schoolYearRange();
const [from, setFrom] = useState(year.from);
const [to, setTo] = useState(year.to);
const { data: classes } = useQuery({
queryKey: ["my-classes", user?.id, isAdmin],
queryFn: async () => {
let q = supabase.from("classes").select("id, name, teacher_id");
if (!isAdmin) q = q.eq("teacher_id", user!.id);
return (await q.order("name")).data ?? [];
},
enabled: !!user && !loading,
});
const { data: students } = useQuery({
queryKey: ["report-class-students", classId],
enabled: !!classId,
queryFn: async () =>
(
await supabase
.from("students")
.select("id, first_name, last_name")
.eq("class_id", classId)
.order("last_name")
).data ?? [],
});
const selected =
studentId === ALL ? (students ?? []) : (students ?? []).filter((s) => s.id === studentId);
const applyPreset = (preset: "year" | "month") => {
const r = preset === "year" ? schoolYearRange() : monthToDateRange();
setFrom(r.from);
setTo(r.to);
};
return (
<div className="p-6 md:p-8 max-w-4xl">
<div className="no-print">
<h1 className="text-2xl font-semibold flex items-center gap-2">
<FileText className="h-6 w-6 text-primary" /> Reports
</h1>
<p className="text-muted-foreground text-sm mt-1">
Build report cards, academic progress reports, and 504 / IEP goal progress reports. Print
or save as PDF from your browser — each student starts on a new page.
</p>
<div className="bg-card border rounded-lg p-4 mt-6 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<Label className="text-xs">Report</Label>
<Select value={type} onValueChange={(v) => setType(v as ReportType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_TYPES.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Class</Label>
<Select
value={classId}
onValueChange={(v) => {
setClassId(v);
setStudentId(ALL);
}}
>
<SelectTrigger>
<SelectValue placeholder="Choose class" />
</SelectTrigger>
<SelectContent>
{(classes ?? []).map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Student</Label>
<Select value={studentId} onValueChange={setStudentId} disabled={!classId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All students in class</SelectItem>
{(students ?? []).map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.last_name}, {s.first_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 items-end">
<div>
<Label className="text-xs">From</Label>
{/* Uncontrolled + keyed so Safari's native picker isn't reset by React. */}
<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>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => applyPreset("year")}>
School year
</Button>
<Button size="sm" variant="outline" onClick={() => applyPreset("month")}>
Month to date
</Button>
</div>
</div>
<div className="flex items-center justify-between pt-1">
<span className="text-xs text-muted-foreground">
{classId
? `${selected.length} document${selected.length === 1 ? "" : "s"} ready`
: "Select a class to begin."}
</span>
<Button size="sm" onClick={() => window.print()} disabled={selected.length === 0}>
<Printer className="h-4 w-4 mr-1" /> Print
</Button>
</div>
</div>
</div>
<div className="space-y-6 mt-6">
{selected.map((s) =>
type === "iep" ? (
<IepProgressDoc key={s.id} studentId={s.id} from={from} to={to} />
) : (
<ReportCardDoc key={s.id} studentId={s.id} variant={type} from={from} to={to} />
),
)}
</div>
</div>
);
}
+3 -2
View File
@@ -4,7 +4,7 @@ import { supabase } from "@/integrations/supabase/client";
import { Button } from "@/components/ui/button";
import {
GraduationCap, LayoutDashboard, Users, ClipboardCheck, Receipt,
MessageSquare, FileText, CalendarDays, Settings, LogOut, Loader2, BookOpen, ClipboardList
MessageSquare, FileText, CalendarDays, Settings, LogOut, Loader2, BookOpen, ClipboardList, Printer
} from "lucide-react";
import { useEffect } from "react";
@@ -35,6 +35,7 @@ function ProtectedLayout() {
{ to: "/classes", label: "Classes", icon: BookOpen, show: isAdmin || roles.includes("teacher") },
{ to: "/attendance", label: "Attendance", icon: ClipboardCheck, show: isAdmin || roles.includes("teacher") },
{ to: "/plans", label: "504 / IEP", icon: ClipboardList, show: isAdmin || roles.includes("teacher") },
{ to: "/reports", label: "Reports", icon: Printer, show: isAdmin || roles.includes("teacher") },
{ to: "/ledger", label: "Tuition", icon: Receipt, show: true },
{ to: "/messages", label: "Messages", icon: MessageSquare, show: true },
{ to: "/forms", label: "Forms", icon: FileText, show: true },
@@ -49,7 +50,7 @@ function ProtectedLayout() {
return (
<div className="min-h-screen bg-muted/20 flex">
<aside className="w-60 bg-card border-r flex flex-col">
<aside className="w-60 bg-card border-r flex flex-col no-print">
<div className="h-16 border-b flex items-center px-4">
<img src="/bayside-logo.png" alt="Bayside Academy" className="h-9 w-auto" />
</div>