From 1cb4a889fe2679d9f99a54b86d33c793a014ed4d Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 7 Aug 2026 04:14:35 +0000 Subject: [PATCH] Modified by www.SourceFiles.app --- src/components/gradebook.tsx | 361 +++++++++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 src/components/gradebook.tsx diff --git a/src/components/gradebook.tsx b/src/components/gradebook.tsx new file mode 100644 index 0000000..00af8ec --- /dev/null +++ b/src/components/gradebook.tsx @@ -0,0 +1,361 @@ +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 ( +
+
+
+
+

Categories & weights

+ {usingDefaults ?

Using school defaults. Customize to override for this class.

+ :

Weights total {totalWeight}% {totalWeight !== 100 ? "(should be 100%)" : ""}

} +
+ {usingDefaults ? + : } +
+ {(usingDefaults ? (defCatsQ.data ?? []) : cats).map((c) => ( + + ))} +
+ +
+
+

Letter grade scale

{usingDefaults &&

Using school defaults.

}
+ {!usingDefaults && } +
+
+ {(usingDefaults ? [] : scale).map((r) => )} + {usingDefaults &&

Customize categories above to also override the scale.

} +
+
+
+ ); +} + +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
{c.name}{Number(c.weight)}%
; + return ( +
+ setName(e.target.value)} onBlur={() => save.mutate()} /> +
setWeight(e.target.value)} onBlur={() => save.mutate()} />%
+ +
+ ); +} + +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 ( +
+ setLetter(e.target.value)} onBlur={() => save.mutate()} /> + ≥ + setMin(e.target.value)} onBlur={() => save.mutate()} /> + +
+ ); +} + +// ── 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 ( +
+ {canAssign && ( +
+
+ +
+ +
+ )} +
+ {(roster ?? []).map((s) => ( +
+ {s.last_name}, {s.first_name} + {canAssign && } +
+ ))} + {roster?.length === 0 &&
No students in this class yet.
} +
+
+ ); +} + +// ── 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 [dateKey, setDateKey] = useState(0); // bump to remount the uncontrolled date input after adding + 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: "" }); setDateKey((k) => k + 1); 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 ( +
+ {canEdit && ( +
+
New assignment
+
+ setNa({ ...na, name: e.target.value })} /> + + setNa({ ...na, max_points: e.target.value })} /> + setNa({ ...na, date: e.target.value })} /> + +
+
+ )} + +
+ + + + + {(assignments ?? []).map((a) => ( + + ))} + + + + + {(roster ?? []).map((s) => { + const pct = studentPercent(s.id, (assignments ?? []) as Assn[], (grades ?? []) as Grd[], cfg.categories); + return ( + + + {(assignments ?? []).map((a) => ( + + ))} + + + ); + })} + {roster?.length === 0 && } + +
Student +
{a.name}
+
{catName(a.category_id)} · /{Number(a.max_points)}
+ {canEdit && } +
Overall
{s.last_name}, {s.first_name} + + {fmtPct(pct)} {letterFor(pct, cfg.scale)}
No students in this class. Add them in the Roster tab.
+
+
+ ); +} + +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 {existing?.points != null ? Number(existing.points) : "—"}; + return 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

This student isn't assigned to a class yet.

; + const gr = (grades ?? []) as Grd[]; + const pct = studentPercent(studentId, (assignments ?? []) as Assn[], gr, cfg.categories); + + return ( +
+
+
Overall grade
+
{fmtPct(pct)} {letterFor(pct, cfg.scale)}
+
+ {cfg.categories.map((cat) => { + const items = (assignments ?? []).filter((a) => a.category_id === cat.id); + if (items.length === 0) return null; + return ( +
+
{cat.name}{Number(cat.weight)}%
+ + {items.map((a) => { + const g = gr.find((x) => x.assignment_id === a.id); + return ; + })} +
{a.name}{g?.points != null ? `${Number(g.points)} / ${Number(a.max_points)}` : "—"}
+
+ ); + })} + {(assignments ?? []).length === 0 &&

No assignments yet.

} +
+ ); +}