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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user