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
+66
View File
@@ -0,0 +1,66 @@
// Shared helpers for the printable documents: report cards, progress reports,
// and tuition invoices.
import { todayISO } from "@/lib/plans";
export const SCHOOL_NAME = "Bayside Academy";
export const money = (cents: number) =>
`${cents < 0 ? "-" : ""}$${(Math.abs(cents) / 100).toFixed(2)}`;
export type AttendanceRow = { status: string; date: string };
export type AttendanceCounts = {
present: number;
late: number;
absent: number;
excused: number;
recorded: number;
/** Share of recorded days the student actually attended (present or late). */
attendedPct: number | null;
};
export function summarizeAttendance(
rows: AttendanceRow[],
from: string,
to: string,
): AttendanceCounts {
const inRange = rows.filter((r) => r.date >= from && r.date <= to);
const count = (s: string) => inRange.filter((r) => r.status === s).length;
const present = count("present");
const late = count("late");
const absent = count("absent");
const excused = count("excused");
const recorded = inRange.length;
return {
present,
late,
absent,
excused,
recorded,
attendedPct: recorded === 0 ? null : ((present + late) / recorded) * 100,
};
}
// School year runs Aug 1 → Jul 31, so a report pulled in e.g. February still
// covers the year that began the previous August.
export function schoolYearRange(today = new Date()): { from: string; to: string } {
const startYear = today.getMonth() >= 7 ? today.getFullYear() : today.getFullYear() - 1;
return { from: `${startYear}-08-01`, to: `${startYear + 1}-07-31` };
}
export function monthToDateRange(today = new Date()): { from: string; to: string } {
const y = today.getFullYear();
const m = String(today.getMonth() + 1).padStart(2, "0");
return { from: `${y}-${m}-01`, to: todayISO() };
}
export const REPORT_TYPES = [
{ value: "report_card", label: "Report card" },
{ value: "progress", label: "Academic progress report" },
{ value: "iep", label: "504 / IEP progress report" },
] as const;
export type ReportType = (typeof REPORT_TYPES)[number]["value"];
export const reportTitle = (t: ReportType) =>
REPORT_TYPES.find((r) => r.value === t)?.label ?? "Report";