Gradebook: classes, weighted grading, assignments, grades, student role

- Migration: student role, students.user_id, grade_categories/grade_scale
  (school defaults + per-class override), assignments, grades + RLS
- Classes area (nav): list/create classes, class page with Gradebook / Roster /
  Grading setup tabs; teachers see their classes, admins all
- Weighted-category grade calc -> letter scale (shared helper)
- Roster assignment (admin), assignment + grade entry (teacher/admin)
- Student profile: view-only Grades tab (parents/students)
- Portal access: create parent OR student logins linked to the student

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 16:57:27 -04:00
co-authored by Claude Opus 4.8
parent 73a4e70f0b
commit 8fe6ad00f4
12 changed files with 901 additions and 20 deletions
+360
View File
@@ -0,0 +1,360 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { studentPercent, letterFor, fmtPct, type Cat, type Scale, type Assn, type Grd } from "@/lib/grades";
// Effective config for a class = its own rows if any, otherwise the school defaults (class_id NULL).
export function useEffectiveConfig(classId: string) {
const { data } = useQuery({
queryKey: ["grading-config", classId],
queryFn: async () => {
const [ownC, defC, ownS, defS] = await Promise.all([
supabase.from("grade_categories").select("*").eq("class_id", classId).order("sort_order"),
supabase.from("grade_categories").select("*").is("class_id", null).order("sort_order"),
supabase.from("grade_scale").select("*").eq("class_id", classId).order("min_percent", { ascending: false }),
supabase.from("grade_scale").select("*").is("class_id", null).order("min_percent", { ascending: false }),
]);
return {
categories: ((ownC.data?.length ? ownC.data : defC.data) ?? []) as Cat[],
scale: ((ownS.data?.length ? ownS.data : defS.data) ?? []) as Scale[],
classCategories: !!ownC.data?.length,
classScale: !!ownS.data?.length,
};
},
});
return data ?? { categories: [] as Cat[], scale: [] as Scale[], classCategories: false, classScale: false };
}
// ── Grading config editor. classId = null edits the school defaults. ──────────
export function GradingConfigEditor({ classId }: { classId: string | null }) {
const qc = useQueryClient();
const key = classId ?? "default";
const catQ = useQuery({
queryKey: ["gc-cats", key],
queryFn: async () => {
const q = supabase.from("grade_categories").select("*").order("sort_order");
return ((classId ? await q.eq("class_id", classId) : await q.is("class_id", null)).data ?? []);
},
});
const defCatsQ = useQuery({
queryKey: ["gc-cats", "default"],
enabled: !!classId,
queryFn: async () => (await supabase.from("grade_categories").select("*").is("class_id", null).order("sort_order")).data ?? [],
});
const scaleQ = useQuery({
queryKey: ["gc-scale", key],
queryFn: async () => {
const q = supabase.from("grade_scale").select("*").order("min_percent", { ascending: false });
return ((classId ? await q.eq("class_id", classId) : await q.is("class_id", null)).data ?? []);
},
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["gc-cats", key] });
qc.invalidateQueries({ queryKey: ["gc-scale", key] });
if (classId) qc.invalidateQueries({ queryKey: ["grading-config", classId] });
};
const cats = catQ.data ?? [];
const scale = scaleQ.data ?? [];
const usingDefaults = !!classId && cats.length === 0;
const addCat = useMutation({
mutationFn: async () => { const { error } = await supabase.from("grade_categories").insert({ class_id: classId, name: "New category", weight: 0, sort_order: cats.length }); if (error) throw error; },
onSuccess: invalidate, onError: (e: Error) => toast.error(e.message),
});
const customize = useMutation({
mutationFn: async () => {
const rows = (defCatsQ.data ?? []).map((d, i) => ({ class_id: classId, name: d.name, weight: d.weight, sort_order: i }));
if (rows.length) { const { error } = await supabase.from("grade_categories").insert(rows); if (error) throw error; }
},
onSuccess: invalidate, onError: (e: Error) => toast.error(e.message),
});
const addScale = useMutation({
mutationFn: async () => { const { error } = await supabase.from("grade_scale").insert({ class_id: classId, letter: "?", min_percent: 0 }); if (error) throw error; },
onSuccess: invalidate, onError: (e: Error) => toast.error(e.message),
});
const totalWeight = cats.reduce((s, c) => s + Number(c.weight), 0);
return (
<div className="space-y-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">Categories & weights</h3>
{usingDefaults ? <p className="text-xs text-muted-foreground">Using school defaults. Customize to override for this class.</p>
: <p className={`text-xs ${totalWeight === 100 ? "text-muted-foreground" : "text-amber-600"}`}>Weights total {totalWeight}% {totalWeight !== 100 ? "(should be 100%)" : ""}</p>}
</div>
{usingDefaults ? <Button size="sm" variant="outline" onClick={() => customize.mutate()}>Customize for this class</Button>
: <Button size="sm" variant="outline" onClick={() => addCat.mutate()}><Plus className="h-4 w-4 mr-1" /> Category</Button>}
</div>
{(usingDefaults ? (defCatsQ.data ?? []) : cats).map((c) => (
<CategoryRow key={c.id} c={c} readOnly={usingDefaults} onChange={invalidate} />
))}
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div><h3 className="font-semibold">Letter grade scale</h3>{usingDefaults && <p className="text-xs text-muted-foreground">Using school defaults.</p>}</div>
{!usingDefaults && <Button size="sm" variant="outline" onClick={() => addScale.mutate()}><Plus className="h-4 w-4 mr-1" /> Grade</Button>}
</div>
<div className="flex flex-wrap gap-2">
{(usingDefaults ? [] : scale).map((r) => <ScaleRowEdit key={r.id} r={r} onChange={invalidate} />)}
{usingDefaults && <p className="text-sm text-muted-foreground">Customize categories above to also override the scale.</p>}
</div>
</div>
</div>
);
}
function CategoryRow({ c, readOnly, onChange }: { c: { id: string; name: string; weight: number }; readOnly: boolean; onChange: () => void }) {
const [name, setName] = useState(c.name);
const [weight, setWeight] = useState(String(c.weight));
const save = useMutation({
mutationFn: async () => { const { error } = await supabase.from("grade_categories").update({ name, weight: Number(weight) || 0 }).eq("id", c.id); if (error) throw error; },
onSuccess: onChange, onError: (e: Error) => toast.error(e.message),
});
const del = useMutation({ mutationFn: async () => { const { error } = await supabase.from("grade_categories").delete().eq("id", c.id); if (error) throw error; }, onSuccess: onChange });
if (readOnly) return <div className="flex items-center gap-3 text-sm"><span className="font-medium w-40">{c.name}</span><span className="text-muted-foreground">{Number(c.weight)}%</span></div>;
return (
<div className="flex items-center gap-2">
<Input className="w-48" value={name} onChange={(e) => setName(e.target.value)} onBlur={() => save.mutate()} />
<div className="flex items-center gap-1"><Input className="w-20" type="number" value={weight} onChange={(e) => setWeight(e.target.value)} onBlur={() => save.mutate()} /><span className="text-sm text-muted-foreground">%</span></div>
<Button size="icon" variant="ghost" onClick={() => del.mutate()}><Trash2 className="h-4 w-4" /></Button>
</div>
);
}
function ScaleRowEdit({ r, onChange }: { r: { id: string; letter: string; min_percent: number }; onChange: () => void }) {
const [letter, setLetter] = useState(r.letter);
const [min, setMin] = useState(String(r.min_percent));
const save = useMutation({
mutationFn: async () => { const { error } = await supabase.from("grade_scale").update({ letter, min_percent: Number(min) || 0 }).eq("id", r.id); if (error) throw error; },
onSuccess: onChange, onError: (e: Error) => toast.error(e.message),
});
const del = useMutation({ mutationFn: async () => { const { error } = await supabase.from("grade_scale").delete().eq("id", r.id); if (error) throw error; }, onSuccess: onChange });
return (
<div className="flex items-center gap-1 border rounded-md p-2">
<Input className="w-12 text-center" value={letter} onChange={(e) => setLetter(e.target.value)} onBlur={() => save.mutate()} />
<span className="text-xs text-muted-foreground">≥</span>
<Input className="w-16" type="number" value={min} onChange={(e) => setMin(e.target.value)} onBlur={() => save.mutate()} />
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => del.mutate()}><Trash2 className="h-3.5 w-3.5" /></Button>
</div>
);
}
// ── Roster: which students are in this class (admin assigns). ─────────────────
export function RosterManager({ classId, canAssign }: { classId: string; canAssign: boolean }) {
const qc = useQueryClient();
const { data: roster } = useQuery({
queryKey: ["roster", classId],
queryFn: async () => (await supabase.from("students").select("id, first_name, last_name").eq("class_id", classId).order("last_name")).data ?? [],
});
const { data: available } = useQuery({
queryKey: ["roster-available", classId],
enabled: canAssign,
queryFn: async () => (await supabase.from("students").select("id, first_name, last_name, class_id").order("last_name")).data ?? [],
});
const [pick, setPick] = useState("");
const assign = useMutation({
mutationFn: async (studentId: string) => { const { error } = await supabase.from("students").update({ class_id: classId }).eq("id", studentId); if (error) throw error; },
onSuccess: () => { setPick(""); qc.invalidateQueries({ queryKey: ["roster", classId] }); qc.invalidateQueries({ queryKey: ["roster-available", classId] }); qc.invalidateQueries({ queryKey: ["students"] }); },
onError: (e: Error) => toast.error(e.message),
});
const remove = useMutation({
mutationFn: async (studentId: string) => { const { error } = await supabase.from("students").update({ class_id: null }).eq("id", studentId); if (error) throw error; },
onSuccess: () => { qc.invalidateQueries({ queryKey: ["roster", classId] }); qc.invalidateQueries({ queryKey: ["roster-available", classId] }); },
onError: (e: Error) => toast.error(e.message),
});
const addable = (available ?? []).filter((s) => s.class_id !== classId);
return (
<div className="space-y-4 max-w-2xl">
{canAssign && (
<div className="bg-card border rounded-lg p-4 flex gap-2 items-end">
<div className="flex-1"><Label className="text-xs">Add a student to this class</Label>
<Select value={pick} onValueChange={setPick}>
<SelectTrigger><SelectValue placeholder="Choose student" /></SelectTrigger>
<SelectContent>{addable.map((s) => <SelectItem key={s.id} value={s.id}>{s.last_name}, {s.first_name}{s.class_id ? " (moving)" : ""}</SelectItem>)}</SelectContent>
</Select>
</div>
<Button disabled={!pick || assign.isPending} onClick={() => assign.mutate(pick)}>Add</Button>
</div>
)}
<div className="bg-card border rounded-lg divide-y">
{(roster ?? []).map((s) => (
<div key={s.id} className="flex items-center justify-between p-3 text-sm">
<span>{s.last_name}, {s.first_name}</span>
{canAssign && <Button size="icon" variant="ghost" onClick={() => remove.mutate(s.id)}><Trash2 className="h-4 w-4" /></Button>}
</div>
))}
{roster?.length === 0 && <div className="p-4 text-sm text-muted-foreground">No students in this class yet.</div>}
</div>
</div>
);
}
// ── Gradebook grid ────────────────────────────────────────────────────────────
export function GradebookGrid({ classId, canEdit }: { classId: string; canEdit: boolean }) {
const qc = useQueryClient();
const cfg = useEffectiveConfig(classId);
const { data: roster } = useQuery({
queryKey: ["roster", classId],
queryFn: async () => (await supabase.from("students").select("id, first_name, last_name").eq("class_id", classId).order("last_name")).data ?? [],
});
const { data: assignments } = useQuery({
queryKey: ["assignments", classId],
queryFn: async () => (await supabase.from("assignments").select("*").eq("class_id", classId).order("date", { ascending: true, nullsFirst: true })).data ?? [],
});
const aIds = (assignments ?? []).map((a) => a.id);
const { data: grades } = useQuery({
queryKey: ["grades", classId, aIds.length],
enabled: aIds.length >= 0,
queryFn: async () => aIds.length ? ((await supabase.from("grades").select("assignment_id, student_id, points").in("assignment_id", aIds)).data ?? []) : [],
});
const [na, setNa] = useState({ name: "", category_id: "", max_points: "100", date: "" });
const addAssignment = useMutation({
mutationFn: async () => {
if (!na.name.trim()) throw new Error("Assignment name required");
const { data: u } = await supabase.auth.getUser();
const { error } = await supabase.from("assignments").insert({
class_id: classId, name: na.name.trim(), category_id: na.category_id || null,
max_points: Number(na.max_points) || 100, date: na.date || null, created_by: u.user?.id,
});
if (error) throw error;
},
onSuccess: () => { setNa({ name: "", category_id: "", max_points: "100", date: "" }); qc.invalidateQueries({ queryKey: ["assignments", classId] }); toast.success("Assignment added"); },
onError: (e: Error) => toast.error(e.message),
});
const delAssignment = useMutation({
mutationFn: async (id: string) => { const { error } = await supabase.from("assignments").delete().eq("id", id); if (error) throw error; },
onSuccess: () => { qc.invalidateQueries({ queryKey: ["assignments", classId] }); qc.invalidateQueries({ queryKey: ["grades", classId] }); },
});
const catName = (id: string | null) => cfg.categories.find((c) => c.id === id)?.name ?? "—";
return (
<div className="space-y-4">
{canEdit && (
<div className="bg-card border rounded-lg p-4 space-y-3">
<div className="font-medium text-sm">New assignment</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
<Input placeholder="Name" value={na.name} onChange={(e) => setNa({ ...na, name: e.target.value })} />
<Select value={na.category_id} onValueChange={(v) => setNa({ ...na, category_id: v })}>
<SelectTrigger><SelectValue placeholder="Category" /></SelectTrigger>
<SelectContent>{cfg.categories.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}</SelectContent>
</Select>
<Input type="number" placeholder="Max pts" value={na.max_points} onChange={(e) => setNa({ ...na, max_points: e.target.value })} />
<Input type="date" value={na.date} onChange={(e) => setNa({ ...na, date: e.target.value })} />
<Button onClick={() => addAssignment.mutate()} disabled={addAssignment.isPending}><Plus className="h-4 w-4 mr-1" /> Add</Button>
</div>
</div>
)}
<div className="bg-card border rounded-lg overflow-x-auto">
<table className="text-sm min-w-full">
<thead className="bg-muted text-xs">
<tr>
<th className="text-left p-2 sticky left-0 bg-muted">Student</th>
{(assignments ?? []).map((a) => (
<th key={a.id} className="p-2 text-center whitespace-nowrap min-w-[80px]">
<div>{a.name}</div>
<div className="font-normal text-muted-foreground">{catName(a.category_id)} · /{Number(a.max_points)}</div>
{canEdit && <button className="text-muted-foreground hover:text-destructive" onClick={() => delAssignment.mutate(a.id)}><Trash2 className="h-3 w-3 inline" /></button>}
</th>
))}
<th className="p-2 text-center bg-muted">Overall</th>
</tr>
</thead>
<tbody className="divide-y">
{(roster ?? []).map((s) => {
const pct = studentPercent(s.id, (assignments ?? []) as Assn[], (grades ?? []) as Grd[], cfg.categories);
return (
<tr key={s.id}>
<td className="p-2 sticky left-0 bg-card whitespace-nowrap font-medium">{s.last_name}, {s.first_name}</td>
{(assignments ?? []).map((a) => (
<td key={a.id} className="p-1 text-center">
<GradeCell classId={classId} assignmentId={a.id} studentId={s.id} maxPoints={Number(a.max_points)} grades={(grades ?? []) as Grd[]} canEdit={canEdit} />
</td>
))}
<td className="p-2 text-center font-semibold whitespace-nowrap">{fmtPct(pct)} <span className="text-muted-foreground">{letterFor(pct, cfg.scale)}</span></td>
</tr>
);
})}
{roster?.length === 0 && <tr><td colSpan={(assignments?.length ?? 0) + 2} className="p-4 text-center text-muted-foreground">No students in this class. Add them in the Roster tab.</td></tr>}
</tbody>
</table>
</div>
</div>
);
}
function GradeCell({ classId, assignmentId, studentId, maxPoints, grades, canEdit }: { classId: string; assignmentId: string; studentId: string; maxPoints: number; grades: Grd[]; canEdit: boolean }) {
const qc = useQueryClient();
const existing = grades.find((g) => g.assignment_id === assignmentId && g.student_id === studentId);
const [v, setV] = useState(existing?.points != null ? String(existing.points) : "");
const save = useMutation({
mutationFn: async () => {
const points = v.trim() === "" ? null : Number(v);
const { error } = await supabase.from("grades").upsert({ assignment_id: assignmentId, student_id: studentId, points }, { onConflict: "assignment_id,student_id" });
if (error) throw error;
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["grades", classId] }),
onError: (e: Error) => toast.error(e.message),
});
if (!canEdit) return <span>{existing?.points != null ? Number(existing.points) : "—"}</span>;
return <Input className="w-14 h-8 text-center px-1" value={v} placeholder="—" onChange={(e) => setV(e.target.value)} onBlur={() => save.mutate()} title={`out of ${maxPoints}`} />;
}
// ── Read-only grade report for one student (parent/student/staff view). ───────
export function StudentGradeReport({ studentId, classId }: { studentId: string; classId: string | null }) {
const cfg = useEffectiveConfig(classId ?? "");
const { data: assignments } = useQuery({
queryKey: ["assignments", classId],
enabled: !!classId,
queryFn: async () => classId ? ((await supabase.from("assignments").select("*").eq("class_id", classId).order("date", { ascending: true, nullsFirst: true })).data ?? []) : [],
});
const aIds = (assignments ?? []).map((a) => a.id);
const { data: grades } = useQuery({
queryKey: ["grades-student", studentId, aIds.length],
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 ?? [],
});
if (!classId) return <p className="text-sm text-muted-foreground">This student isn't assigned to a class yet.</p>;
const gr = (grades ?? []) as Grd[];
const pct = studentPercent(studentId, (assignments ?? []) as Assn[], gr, cfg.categories);
return (
<div className="space-y-4 max-w-2xl">
<div className="bg-card border rounded-lg p-4 flex items-center justify-between">
<div className="text-sm text-muted-foreground">Overall grade</div>
<div className="text-2xl font-semibold">{fmtPct(pct)} <span className="text-muted-foreground">{letterFor(pct, cfg.scale)}</span></div>
</div>
{cfg.categories.map((cat) => {
const items = (assignments ?? []).filter((a) => a.category_id === cat.id);
if (items.length === 0) return null;
return (
<div key={cat.id} className="bg-card border rounded-lg overflow-hidden">
<div className="bg-muted px-3 py-2 text-xs font-semibold flex justify-between"><span>{cat.name}</span><span className="text-muted-foreground">{Number(cat.weight)}%</span></div>
<table className="w-full text-sm"><tbody className="divide-y">
{items.map((a) => {
const g = gr.find((x) => x.assignment_id === a.id);
return <tr key={a.id}><td className="p-2">{a.name}</td><td className="p-2 text-right text-muted-foreground">{g?.points != null ? `${Number(g.points)} / ${Number(a.max_points)}` : "—"}</td></tr>;
})}
</tbody></table>
</div>
);
})}
{(assignments ?? []).length === 0 && <p className="text-sm text-muted-foreground">No assignments yet.</p>}
</div>
);
}