Terminal
This commit is contained in:
@@ -1,460 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user