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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user