diff --git a/src/components/reports.tsx b/src/components/reports.tsx new file mode 100644 index 0000000..b0e1a08 --- /dev/null +++ b/src/components/reports.tsx @@ -0,0 +1,460 @@ +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { useEffectiveConfig } from "@/components/gradebook"; +import { studentPercent, letterFor, fmtPct, type Assn, type Grd } from "@/lib/grades"; +import { SCHOOL_NAME, summarizeAttendance } from "@/lib/reports"; +import { GOAL_STATUS_LABEL, PLAN_TYPE_LABEL, formatDate } from "@/lib/plans"; +import { Loader2 } from "lucide-react"; + +// Documents avoid filled backgrounds and rely on rules instead — grey fills eat +// toner and read muddy on paper. +export function DocHeader({ title, subtitle }: { title: string; subtitle?: string }) { + return ( +
+
+ +
+
{SCHOOL_NAME}
+
{subtitle}
+
+
+
+
{title}
+
+ Printed {new Date().toLocaleDateString()} +
+
+
+ ); +} + +function InfoGrid({ rows }: { rows: [string, string][] }) { + return ( +
+ {rows.map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+ ); +} + +export function SignatureLines({ labels }: { labels: string[] }) { + return ( +
+ {labels.map((l) => ( +
+
+
{l}
+
+ ))} +
+ ); +} + +function Loading() { + return ( +
+ Loading… +
+ ); +} + +// ── Report card / academic progress report ────────────────────────────────── +export function ReportCardDoc({ + studentId, + variant, + from, + to, +}: { + studentId: string; + variant: "report_card" | "progress"; + from: string; + to: string; +}) { + const { data: student } = useQuery({ + queryKey: ["report-student", studentId], + queryFn: async () => + ( + await supabase + .from("students") + .select("id, first_name, last_name, grade_level, class_id, classes(name)") + .eq("id", studentId) + .single() + ).data, + }); + + const classId = (student?.class_id as string | null) ?? ""; + const cfg = useEffectiveConfig(classId); + + const { data: assignments } = useQuery({ + queryKey: ["report-assignments", classId], + enabled: !!classId, + queryFn: async () => + ( + await supabase + .from("assignments") + .select("*") + .eq("class_id", classId) + .order("date", { ascending: true, nullsFirst: true }) + ).data ?? [], + }); + + // Undated assignments are kept: they belong to the class's body of work and + // excluding them would silently distort the overall percentage. + const inRange = (assignments ?? []).filter((a) => !a.date || (a.date >= from && a.date <= to)); + const aIds = inRange.map((a) => a.id); + + const { data: grades } = useQuery({ + queryKey: ["report-grades", studentId, aIds.length, from, to], + enabled: aIds.length > 0, + queryFn: async () => + ( + await supabase + .from("grades") + .select("assignment_id, student_id, points") + .eq("student_id", studentId) + .in("assignment_id", aIds) + ).data ?? [], + }); + + const { data: attendance } = useQuery({ + queryKey: ["report-attendance", studentId, from, to], + queryFn: async () => + ( + await supabase + .from("attendance") + .select("status, date") + .eq("student_id", studentId) + .gte("date", from) + .lte("date", to) + ).data ?? [], + }); + + if (!student) return ; + + const gr = (grades ?? []) as Grd[]; + const overall = studentPercent(studentId, inRange as Assn[], gr, cfg.categories); + const att = summarizeAttendance(attendance ?? [], from, to); + const title = variant === "report_card" ? "Report Card" : "Progress Report"; + + return ( +
+ + + + + {!classId ? ( +

+ This student isn't assigned to a class, so no grades can be reported. +

+ ) : ( +
+

+ Academic performance +

+ + + + + + + + + + + {cfg.categories.map((cat) => { + const items = inRange.filter((a) => a.category_id === cat.id); + let earned = 0; + let possible = 0; + for (const a of items) { + const g = gr.find((x) => x.assignment_id === a.id && x.points != null); + if (!g) continue; + earned += Number(g.points); + possible += Number(a.max_points); + } + const pct = possible === 0 ? null : (earned / possible) * 100; + return ( + + + + + + + ); + })} + + + + + + +
CategoryPointsPercentWeight
{cat.name} + {possible === 0 ? "—" : `${earned} / ${possible}`} + {fmtPct(pct)}{Number(cat.weight)}%
Overall + {fmtPct(overall)}{letterFor(overall, cfg.scale)}
+ + {/* A mid-term progress report is only actionable with the underlying + work listed, so the detail is included for that variant only. */} + {variant === "progress" && inRange.length > 0 && ( +
+

+ Assignment detail +

+ + + {cfg.categories.flatMap((cat) => { + const items = inRange.filter((a) => a.category_id === cat.id); + if (!items.length) return []; + return [ + + + , + ...items.map((a) => { + const g = gr.find((x) => x.assignment_id === a.id); + return ( + + + + + + ); + }), + ]; + })} + +
+ {cat.name} +
{a.name} + {a.date ? formatDate(a.date) : "—"} + + {g?.points != null + ? `${Number(g.points)} / ${Number(a.max_points)}` + : "—"} +
+
+ )} +
+ )} + +
+

Attendance

+
+ {[ + ["Present", att.present], + ["Late", att.late], + ["Absent", att.absent], + ["Excused", att.excused], + ["Days recorded", att.recorded], + ].map(([label, n]) => ( +
+
{n as number}
+
{label as string}
+
+ ))} +
+ {att.attendedPct !== null && ( +

+ Attended {att.attendedPct.toFixed(1)}% of recorded days. +

+ )} +
+ +
+

Comments

+
+
+ + +
+ ); +} + +// ── 504 / IEP goal progress report ────────────────────────────────────────── +export function IepProgressDoc({ + studentId, + from, + to, +}: { + studentId: string; + from: string; + to: string; +}) { + const { data: student } = useQuery({ + queryKey: ["report-student", studentId], + queryFn: async () => + ( + await supabase + .from("students") + .select("id, first_name, last_name, grade_level, class_id, classes(name)") + .eq("id", studentId) + .single() + ).data, + }); + + const { data: plans } = useQuery({ + queryKey: ["report-plans", studentId], + queryFn: async () => + ( + await supabase + .from("student_plans") + .select("*") + .eq("student_id", studentId) + .neq("status", "archived") + ).data ?? [], + }); + + const planIds = (plans ?? []).map((p) => p.id); + + const { data: goals } = useQuery({ + queryKey: ["report-goals", planIds.join(",")], + enabled: planIds.length > 0, + queryFn: async () => + (await supabase.from("plan_goals").select("*").in("plan_id", planIds).order("sort_order")) + .data ?? [], + }); + + const goalIds = (goals ?? []).map((g) => g.id); + + const { data: progress } = useQuery({ + queryKey: ["report-progress", goalIds.join(","), from, to], + enabled: goalIds.length > 0, + queryFn: async () => + ( + await supabase + .from("plan_goal_progress") + .select("*") + .in("goal_id", goalIds) + .gte("date", from) + .lte("date", to) + .order("date") + ).data ?? [], + }); + + if (!student) return ; + + if ((plans ?? []).length === 0) { + return ( +
+ +

+ No active 504 or IEP plan is on file for {student.first_name} {student.last_name} — or the + plan is restricted to its case manager, administrators, and the student's parents. +

+
+ ); + } + + return ( +
+ + + + + {(plans ?? []).map((plan) => { + const planGoals = (goals ?? []).filter((g) => g.plan_id === plan.id); + return ( +
+
+ {PLAN_TYPE_LABEL[plan.plan_type]} · effective {formatDate(plan.effective_date)} +
+ + {planGoals.length === 0 && ( +

No goals recorded on this plan.

+ )} + + {planGoals.map((goal, i) => { + const entries = (progress ?? []).filter((p) => p.goal_id === goal.id); + const latest = entries[entries.length - 1]; + return ( +
+
+
+ Goal {i + 1} + {goal.area ? ` — ${goal.area}` : ""} +
+
{GOAL_STATUS_LABEL[goal.status]}
+
+

{goal.statement}

+
+
Baseline: {goal.baseline || "—"}
+
+ Target:{" "} + {goal.target_value != null ? `${goal.target_value}${goal.unit ?? ""}` : "—"} + {goal.target_date ? ` by ${formatDate(goal.target_date)}` : ""} +
+
+ Most recent:{" "} + {latest?.value != null + ? `${latest.value}${goal.unit ?? ""}` + : latest + ? "recorded" + : "—"} +
+
+ {goal.target_criteria && ( +
+ Mastery criteria: {goal.target_criteria} +
+ )} + +
+
+ Progress this period +
+ {entries.length === 0 ? ( +

+ No progress data recorded between {formatDate(from)} and {formatDate(to)}. +

+ ) : ( + + + {entries.map((e) => ( + + + + + + ))} + +
{formatDate(e.date)} + {e.value != null ? `${e.value}${goal.unit ?? ""}` : "—"} + {e.narrative || ""}
+ )} +
+
+ ); + })} +
+ ); + })} + + +
+ ); +}