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
+46
View File
@@ -0,0 +1,46 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { ArrowLeft, Lock, Loader2 } from "lucide-react";
import { RosterManager, GradebookGrid, GradingConfigEditor } from "@/components/gradebook";
export const Route = createFileRoute("/_authenticated/classes/$id")({
head: () => ({ meta: [{ title: "Class — School Portal" }] }),
component: ClassDetail,
});
function ClassDetail() {
const { id } = Route.useParams();
const { user, roles } = useAuth();
const isAdmin = roles.includes("admin");
const { data: cls, isLoading } = useQuery({
queryKey: ["class", id],
queryFn: async () => (await supabase.from("classes").select("*").eq("id", id).single()).data,
});
const isTeacher = !!cls && cls.teacher_id === user?.id;
const canEdit = isAdmin || isTeacher;
if (isLoading) return <div className="p-8"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>;
if (!canEdit) return <div className="p-8"><Lock className="h-6 w-6 text-muted-foreground" /><h1 className="mt-2 text-xl font-semibold">Not available</h1><p className="text-sm text-muted-foreground">You don't have access to this class.</p></div>;
return (
<div className="p-6 md:p-8 max-w-5xl">
<Link to="/classes" className="text-sm text-muted-foreground inline-flex items-center gap-1 mb-3"><ArrowLeft className="h-4 w-4" /> All classes</Link>
<h1 className="text-2xl font-semibold">{cls?.name}</h1>
<Tabs defaultValue="gradebook" className="mt-6">
<TabsList>
<TabsTrigger value="gradebook">Gradebook</TabsTrigger>
<TabsTrigger value="roster">Roster</TabsTrigger>
<TabsTrigger value="grading">Grading setup</TabsTrigger>
</TabsList>
<TabsContent value="gradebook" className="mt-4"><GradebookGrid classId={id} canEdit={canEdit} /></TabsContent>
<TabsContent value="roster" className="mt-4"><RosterManager classId={id} canAssign={isAdmin} /></TabsContent>
<TabsContent value="grading" className="mt-4"><GradingConfigEditor classId={id} /></TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,87 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { GradingConfigEditor } from "@/components/gradebook";
import { BookOpen, ChevronRight, Plus, SlidersHorizontal } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/_authenticated/classes/")({
head: () => ({ meta: [{ title: "Classes — School Portal" }] }),
component: ClassesIndex,
});
function ClassesIndex() {
const { user, roles } = useAuth();
const isAdmin = roles.includes("admin");
const qc = useQueryClient();
const { data: classes } = useQuery({
queryKey: ["classes-list"],
queryFn: async () => (await supabase.from("classes").select("id, name, teacher_id").order("name")).data ?? [],
});
const visible = isAdmin ? (classes ?? []) : (classes ?? []).filter((c) => c.teacher_id === user?.id);
const { data: teachers } = useQuery({
queryKey: ["teachers"], enabled: isAdmin,
queryFn: async () => {
const { data: r } = await supabase.from("user_roles").select("user_id").eq("role", "teacher");
const ids = (r ?? []).map((x) => x.user_id);
if (!ids.length) return [];
return (await supabase.from("profiles").select("id, full_name, email").in("id", ids)).data ?? [];
},
});
const [name, setName] = useState("");
const [teacherId, setTeacherId] = useState("");
const create = useMutation({
mutationFn: async () => { const { error } = await supabase.from("classes").insert({ name: name.trim(), teacher_id: teacherId || null }); if (error) throw error; },
onSuccess: () => { setName(""); setTeacherId(""); qc.invalidateQueries({ queryKey: ["classes-list"] }); toast.success("Class created"); },
onError: (e: Error) => toast.error(e.message),
});
const [showDefaults, setShowDefaults] = useState(false);
return (
<div className="p-8 max-w-4xl">
<h1 className="text-2xl font-semibold mb-1">Classes</h1>
<p className="text-muted-foreground text-sm mb-6">Rosters and gradebooks.</p>
{isAdmin && (
<div className="bg-card border rounded-lg p-4 space-y-3 mb-4">
<div className="font-medium text-sm">New class</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
<Input placeholder="Class name" value={name} onChange={(e) => setName(e.target.value)} />
<Select value={teacherId} onValueChange={setTeacherId}>
<SelectTrigger><SelectValue placeholder="Teacher (optional)" /></SelectTrigger>
<SelectContent>{(teachers ?? []).map((t) => <SelectItem key={t.id} value={t.id}>{t.full_name || t.email}</SelectItem>)}</SelectContent>
</Select>
<Button onClick={() => create.mutate()} disabled={!name.trim() || create.isPending}><Plus className="h-4 w-4 mr-1" /> Add class</Button>
</div>
</div>
)}
<div className="bg-card border rounded-lg divide-y mb-6">
{visible.map((c) => (
<Link key={c.id} to="/classes/$id" params={{ id: c.id }} className="flex items-center justify-between p-3 hover:bg-muted/50">
<span className="flex items-center gap-2 font-medium"><BookOpen className="h-4 w-4 text-muted-foreground" /> {c.name}</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</Link>
))}
{visible.length === 0 && <div className="p-4 text-sm text-muted-foreground">{isAdmin ? "No classes yet — create one above." : "You have no classes assigned."}</div>}
</div>
{isAdmin && (
<div className="bg-card border rounded-lg p-4">
<button className="flex items-center gap-2 font-medium text-sm" onClick={() => setShowDefaults((s) => !s)}>
<SlidersHorizontal className="h-4 w-4" /> School grading defaults
</button>
<p className="text-xs text-muted-foreground mt-1">Applied to any class that hasn't set its own categories/scale.</p>
{showDefaults && <div className="mt-4"><GradingConfigEditor classId={null} /></div>}
</div>
)}
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { createFileRoute, Outlet } from "@tanstack/react-router";
// Layout route for /classes; list is in classes.index.tsx, detail in classes.$id.tsx.
export const Route = createFileRoute("/_authenticated/classes")({
component: () => <Outlet />,
});
+3 -2
View File
@@ -4,7 +4,7 @@ import { supabase } from "@/integrations/supabase/client";
import { Button } from "@/components/ui/button";
import {
GraduationCap, LayoutDashboard, Users, ClipboardCheck, Receipt,
MessageSquare, FileText, CalendarDays, Settings, LogOut, Loader2
MessageSquare, FileText, CalendarDays, Settings, LogOut, Loader2, BookOpen
} from "lucide-react";
import { useEffect } from "react";
@@ -32,7 +32,8 @@ function ProtectedLayout() {
const nav = [
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard, show: true },
{ to: "/students", label: "Students", icon: Users, show: true },
{ to: "/attendance", label: "Attendance", icon: ClipboardCheck, show: role !== "parent" },
{ to: "/classes", label: "Classes", icon: BookOpen, show: isAdmin || roles.includes("teacher") },
{ to: "/attendance", label: "Attendance", icon: ClipboardCheck, show: isAdmin || roles.includes("teacher") },
{ to: "/ledger", label: "Tuition", icon: Receipt, show: true },
{ to: "/messages", label: "Messages", icon: MessageSquare, show: true },
{ to: "/forms", label: "Forms", icon: FileText, show: true },
+30 -14
View File
@@ -15,6 +15,7 @@ import { useState } from "react";
import { toast } from "sonner";
import { createUserFn } from "@/lib/user-admin.functions";
import { genTempPassword } from "@/lib/temp-password";
import { StudentGradeReport } from "@/components/gradebook";
export const Route = createFileRoute("/_authenticated/students/$id")({
head: () => ({ meta: [{ title: "Student — School Portal" }] }),
@@ -88,6 +89,7 @@ function StudentDetail() {
<TabsTrigger value="profile">Profile</TabsTrigger>
<TabsTrigger value="family">Family & pickup</TabsTrigger>
<TabsTrigger value="academics">Academics</TabsTrigger>
<TabsTrigger value="grades">Grades</TabsTrigger>
<TabsTrigger value="attendance">Attendance</TabsTrigger>
<TabsTrigger value="ledger">Tuition</TabsTrigger>
{isAdmin && <TabsTrigger value="contracts">Contracts</TabsTrigger>}
@@ -95,6 +97,7 @@ function StudentDetail() {
<TabsContent value="profile"><ProfileTab studentId={id} canEdit={canEdit} isAdmin={isAdmin} /></TabsContent>
<TabsContent value="family"><FamilyTab studentId={id} canEdit={canEdit} isAdmin={isAdmin} /></TabsContent>
<TabsContent value="academics"><AcademicsTab studentId={id} canEdit={canEdit} /></TabsContent>
<TabsContent value="grades" className="mt-4"><StudentGradeReport studentId={id} classId={(student?.class_id as string | null) ?? null} /></TabsContent>
<TabsContent value="attendance"><AttendanceTab studentId={id} /></TabsContent>
<TabsContent value="ledger"><LedgerTab studentId={id} canEdit={isAdmin} /></TabsContent>
{isAdmin && <TabsContent value="contracts"><ContractsTab studentId={id} /></TabsContent>}
@@ -331,39 +334,52 @@ function ParentAccessSection({ studentId }: { studentId: string }) {
return (await supabase.from("profiles").select("id, full_name, email").in("id", ids)).data ?? [];
},
});
const [np, setNp] = useState({ fullName: "", email: "" });
const [created, setCreated] = useState<{ email: string; password: string } | null>(null);
const { data: studentLogin } = useQuery({
queryKey: ["student-login", studentId],
queryFn: async () => {
const { data: s } = await supabase.from("students").select("user_id").eq("id", studentId).single();
if (!s?.user_id) return null;
return (await supabase.from("profiles").select("id, full_name, email").eq("id", s.user_id).maybeSingle()).data;
},
});
const [np, setNp] = useState({ fullName: "", email: "", role: "parent" as "parent" | "student" });
const [created, setCreated] = useState<{ email: string; password: string; role: string } | null>(null);
const invite = useMutation({
mutationFn: async () => {
const password = genTempPassword();
const res = await createUserFn({ data: { fullName: np.fullName, email: np.email, password, role: "parent", linkStudentId: studentId } });
return { email: res.email, password };
const res = await createUserFn({ data: { fullName: np.fullName, email: np.email, password, role: np.role, linkStudentId: studentId } });
return { email: res.email, password, role: np.role };
},
onSuccess: (r) => { setCreated(r); setNp({ fullName: "", email: "" }); qc.invalidateQueries({ queryKey: ["student-parents", studentId] }); toast.success("Parent account created & linked"); },
onSuccess: (r) => { setCreated(r); setNp({ fullName: "", email: "", role: "parent" }); qc.invalidateQueries({ queryKey: ["student-parents", studentId] }); qc.invalidateQueries({ queryKey: ["student-login", studentId] }); toast.success(`${r.role === "student" ? "Student" : "Parent"} account created & linked`); },
onError: (e: Error) => toast.error(e.message),
});
return (
<div className="space-y-3 border-t pt-6">
<div><h3 className="font-semibold flex items-center gap-2"><UserPlus className="h-4 w-4" /> Parent portal access</h3><p className="text-xs text-muted-foreground">Create a login so a parent can sign in and fill out this profile.</p></div>
<div><h3 className="font-semibold flex items-center gap-2"><UserPlus className="h-4 w-4" /> Portal access</h3><p className="text-xs text-muted-foreground">Create logins so a parent (can edit this profile) or the student (view-only grades) can sign in.</p></div>
<div className="bg-card border rounded-lg divide-y">
{(parents ?? []).map((p) => <div key={p.id} className="p-3"><div className="font-medium text-sm">{p.full_name || "—"}</div><div className="text-xs text-muted-foreground">{p.email}</div></div>)}
{parents?.length === 0 && <div className="p-3 text-sm text-muted-foreground">No parent accounts linked yet.</div>}
{studentLogin && <div className="p-3"><div className="font-medium text-sm">{studentLogin.full_name || "Student"} <span className="text-[10px] uppercase tracking-wide bg-primary/10 text-primary rounded px-1.5 py-0.5">Student</span></div><div className="text-xs text-muted-foreground">{studentLogin.email}</div></div>}
{(parents ?? []).map((p) => <div key={p.id} className="p-3"><div className="font-medium text-sm">{p.full_name || "—"} <span className="text-[10px] uppercase tracking-wide bg-muted rounded px-1.5 py-0.5">Parent</span></div><div className="text-xs text-muted-foreground">{p.email}</div></div>)}
{!studentLogin && parents?.length === 0 && <div className="p-3 text-sm text-muted-foreground">No logins yet.</div>}
</div>
<div className="bg-card border rounded-lg p-4 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
<Input placeholder="Parent full name" value={np.fullName} onChange={(e) => setNp({ ...np, fullName: e.target.value })} />
<Input type="email" placeholder="Parent email" value={np.email} onChange={(e) => setNp({ ...np, email: e.target.value })} />
<Button onClick={() => invite.mutate()} disabled={!np.fullName || !np.email || invite.isPending}>{invite.isPending ? "Creating…" : "Create & link parent"}</Button>
<div className="grid grid-cols-1 md:grid-cols-4 gap-2">
<Select value={np.role} onValueChange={(v) => setNp({ ...np, role: v as "parent" | "student" })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="parent">Parent</SelectItem><SelectItem value="student">Student</SelectItem></SelectContent>
</Select>
<Input placeholder="Full name" value={np.fullName} onChange={(e) => setNp({ ...np, fullName: e.target.value })} />
<Input type="email" placeholder="Email" value={np.email} onChange={(e) => setNp({ ...np, email: e.target.value })} />
<Button onClick={() => invite.mutate()} disabled={!np.fullName || !np.email || invite.isPending}>{invite.isPending ? "Creating…" : "Create login"}</Button>
</div>
{created && (
<div className="rounded-md border border-primary/30 bg-primary/5 p-3 text-sm">
<div className="font-medium">Login created — give these to the parent:</div>
<div className="font-medium">Login created — give these to the {created.role}:</div>
<div className="mt-1 flex flex-wrap items-center gap-2 font-mono text-xs">
<span className="rounded bg-background border px-2 py-1">{created.email}</span>
<span className="rounded bg-background border px-2 py-1">{created.password}</span>
<Button size="icon" variant="ghost" onClick={() => { navigator.clipboard.writeText(`Sign in at ${window.location.origin}/auth\nEmail: ${created.email}\nPassword: ${created.password}`); toast.success("Copied"); }}><Copy className="h-4 w-4" /></Button>
</div>
<p className="text-xs text-muted-foreground mt-1">They sign in at the portal, open this student, and hit Edit to complete the profile.</p>
<p className="text-xs text-muted-foreground mt-1">They sign in at the portal to {created.role === "student" ? "view grades" : "complete this profile"}.</p>
</div>
)}
</div>