Modified by www.SourceFiles.app

This commit is contained in:
2026-08-07 04:14:34 +00:00
parent 834519958b
commit 0f968f7c23
+460
View File
@@ -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 (
<div className="flex items-start justify-between border-b-2 border-foreground pb-3">
<div className="flex items-center gap-3">
<img src="/bayside-logo.png" alt="" className="h-10 w-auto" />
<div>
<div className="font-semibold leading-tight">{SCHOOL_NAME}</div>
<div className="text-xs text-muted-foreground">{subtitle}</div>
</div>
</div>
<div className="text-right">
<div className="text-lg font-semibold leading-tight">{title}</div>
<div className="text-xs text-muted-foreground">
Printed {new Date().toLocaleDateString()}
</div>
</div>
</div>
);
}
function InfoGrid({ rows }: { rows: [string, string][] }) {
return (
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-sm">
{rows.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>
);
}
export function SignatureLines({ labels }: { labels: string[] }) {
return (
<div className="grid grid-cols-2 gap-8 pt-8">
{labels.map((l) => (
<div key={l}>
<div className="border-b border-foreground h-8" />
<div className="text-xs text-muted-foreground mt-1">{l}</div>
</div>
))}
</div>
);
}
function Loading() {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-6">
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
</div>
);
}
// ── 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 <Loading />;
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 (
<div className="print-page print-doc bg-card border rounded-lg p-6 space-y-5">
<DocHeader title={title} subtitle={`${formatDate(from)} – ${formatDate(to)}`} />
<InfoGrid
rows={[
["Student", `${student.first_name} ${student.last_name}`],
["Grade level", student.grade_level || "—"],
["Class", (student.classes as { name: string } | null)?.name ?? "—"],
["Reporting period", `${formatDate(from)} – ${formatDate(to)}`],
]}
/>
{!classId ? (
<p className="text-sm text-muted-foreground">
This student isn't assigned to a class, so no grades can be reported.
</p>
) : (
<div>
<h3 className="text-sm font-semibold uppercase tracking-wide mb-2">
Academic performance
</h3>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-1 font-medium">Category</th>
<th className="py-1 font-medium text-right">Points</th>
<th className="py-1 font-medium text-right">Percent</th>
<th className="py-1 font-medium text-right">Weight</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={cat.id} className="border-b">
<td className="py-1">{cat.name}</td>
<td className="py-1 text-right">
{possible === 0 ? "—" : `${earned} / ${possible}`}
</td>
<td className="py-1 text-right">{fmtPct(pct)}</td>
<td className="py-1 text-right text-muted-foreground">{Number(cat.weight)}%</td>
</tr>
);
})}
<tr className="font-semibold">
<td className="py-2">Overall</td>
<td />
<td className="py-2 text-right">{fmtPct(overall)}</td>
<td className="py-2 text-right">{letterFor(overall, cfg.scale)}</td>
</tr>
</tbody>
</table>
{/* 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 && (
<div className="mt-4">
<h3 className="text-sm font-semibold uppercase tracking-wide mb-2">
Assignment detail
</h3>
<table className="w-full text-sm">
<tbody>
{cfg.categories.flatMap((cat) => {
const items = inRange.filter((a) => a.category_id === cat.id);
if (!items.length) return [];
return [
<tr key={cat.id}>
<td
colSpan={3}
className="pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{cat.name}
</td>
</tr>,
...items.map((a) => {
const g = gr.find((x) => x.assignment_id === a.id);
return (
<tr key={a.id} className="border-b">
<td className="py-1">{a.name}</td>
<td className="py-1 text-right text-muted-foreground">
{a.date ? formatDate(a.date) : "—"}
</td>
<td className="py-1 text-right">
{g?.points != null
? `${Number(g.points)} / ${Number(a.max_points)}`
: "—"}
</td>
</tr>
);
}),
];
})}
</tbody>
</table>
</div>
)}
</div>
)}
<div>
<h3 className="text-sm font-semibold uppercase tracking-wide mb-2">Attendance</h3>
<div className="grid grid-cols-5 gap-2 text-sm text-center">
{[
["Present", att.present],
["Late", att.late],
["Absent", att.absent],
["Excused", att.excused],
["Days recorded", att.recorded],
].map(([label, n]) => (
<div key={label as string} className="border rounded-md py-2">
<div className="text-lg font-semibold">{n as number}</div>
<div className="text-xs text-muted-foreground">{label as string}</div>
</div>
))}
</div>
{att.attendedPct !== null && (
<p className="text-xs text-muted-foreground mt-1">
Attended {att.attendedPct.toFixed(1)}% of recorded days.
</p>
)}
</div>
<div>
<h3 className="text-sm font-semibold uppercase tracking-wide mb-2">Comments</h3>
<div className="border rounded-md h-20" />
</div>
<SignatureLines labels={["Teacher signature", "Parent / guardian signature"]} />
</div>
);
}
// ── 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 <Loading />;
if ((plans ?? []).length === 0) {
return (
<div className="print-page print-doc bg-card border rounded-lg p-6 space-y-4">
<DocHeader
title="504 / IEP Progress Report"
subtitle={`${formatDate(from)} – ${formatDate(to)}`}
/>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
);
}
return (
<div className="print-page print-doc bg-card border rounded-lg p-6 space-y-5">
<DocHeader
title="504 / IEP Progress Report"
subtitle={`${formatDate(from)} – ${formatDate(to)}`}
/>
<InfoGrid
rows={[
["Student", `${student.first_name} ${student.last_name}`],
["Grade level", student.grade_level || "—"],
["Class", (student.classes as { name: string } | null)?.name ?? "—"],
["Reporting period", `${formatDate(from)} – ${formatDate(to)}`],
]}
/>
{(plans ?? []).map((plan) => {
const planGoals = (goals ?? []).filter((g) => g.plan_id === plan.id);
return (
<div key={plan.id} className="space-y-3">
<div className="text-sm font-semibold uppercase tracking-wide border-b pb-1">
{PLAN_TYPE_LABEL[plan.plan_type]} · effective {formatDate(plan.effective_date)}
</div>
{planGoals.length === 0 && (
<p className="text-sm text-muted-foreground">No goals recorded on this plan.</p>
)}
{planGoals.map((goal, i) => {
const entries = (progress ?? []).filter((p) => p.goal_id === goal.id);
const latest = entries[entries.length - 1];
return (
<div key={goal.id} className="border rounded-md p-3 space-y-2">
<div className="flex justify-between gap-3">
<div className="font-medium text-sm">
Goal {i + 1}
{goal.area ? ` — ${goal.area}` : ""}
</div>
<div className="text-xs">{GOAL_STATUS_LABEL[goal.status]}</div>
</div>
<p className="text-sm">{goal.statement}</p>
<div className="grid grid-cols-3 gap-3 text-xs text-muted-foreground">
<div>Baseline: {goal.baseline || "—"}</div>
<div>
Target:{" "}
{goal.target_value != null ? `${goal.target_value}${goal.unit ?? ""}` : "—"}
{goal.target_date ? ` by ${formatDate(goal.target_date)}` : ""}
</div>
<div>
Most recent:{" "}
{latest?.value != null
? `${latest.value}${goal.unit ?? ""}`
: latest
? "recorded"
: "—"}
</div>
</div>
{goal.target_criteria && (
<div className="text-xs text-muted-foreground">
Mastery criteria: {goal.target_criteria}
</div>
)}
<div>
<div className="text-xs font-semibold uppercase tracking-wide mb-1">
Progress this period
</div>
{entries.length === 0 ? (
<p className="text-xs text-muted-foreground">
No progress data recorded between {formatDate(from)} and {formatDate(to)}.
</p>
) : (
<table className="w-full text-xs">
<tbody>
{entries.map((e) => (
<tr key={e.id} className="border-b">
<td className="py-1 w-24">{formatDate(e.date)}</td>
<td className="py-1 w-20">
{e.value != null ? `${e.value}${goal.unit ?? ""}` : "—"}
</td>
<td className="py-1">{e.narrative || ""}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
})}
</div>
);
})}
<SignatureLines labels={["Case manager signature", "Parent / guardian signature"]} />
</div>
);
}