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