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>
);
}
+2 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import type { User } from "@supabase/supabase-js";
import { supabase } from "@/integrations/supabase/client";
export type Role = "admin" | "teacher" | "parent";
export type Role = "admin" | "teacher" | "parent" | "student";
export interface AuthState {
user: User | null;
@@ -52,5 +52,6 @@ export function highestRole(roles: Role[]): Role | null {
if (roles.includes("admin")) return "admin";
if (roles.includes("teacher")) return "teacher";
if (roles.includes("parent")) return "parent";
if (roles.includes("student")) return "student";
return null;
}
+164 -2
View File
@@ -39,6 +39,54 @@ export type Database = {
}
public: {
Tables: {
assignments: {
Row: {
category_id: string | null
class_id: string
created_at: string
created_by: string | null
date: string | null
id: string
max_points: number
name: string
}
Insert: {
category_id?: string | null
class_id: string
created_at?: string
created_by?: string | null
date?: string | null
id?: string
max_points?: number
name: string
}
Update: {
category_id?: string | null
class_id?: string
created_at?: string
created_by?: string | null
date?: string | null
id?: string
max_points?: number
name?: string
}
Relationships: [
{
foreignKeyName: "assignments_category_id_fkey"
columns: ["category_id"]
isOneToOne: false
referencedRelation: "grade_categories"
referencedColumns: ["id"]
},
{
foreignKeyName: "assignments_class_id_fkey"
columns: ["class_id"]
isOneToOne: false
referencedRelation: "classes"
referencedColumns: ["id"]
},
]
}
attendance: {
Row: {
created_at: string
@@ -282,6 +330,115 @@ export type Database = {
}
Relationships: []
}
grade_categories: {
Row: {
class_id: string | null
created_at: string
id: string
name: string
sort_order: number
weight: number
}
Insert: {
class_id?: string | null
created_at?: string
id?: string
name: string
sort_order?: number
weight?: number
}
Update: {
class_id?: string | null
created_at?: string
id?: string
name?: string
sort_order?: number
weight?: number
}
Relationships: [
{
foreignKeyName: "grade_categories_class_id_fkey"
columns: ["class_id"]
isOneToOne: false
referencedRelation: "classes"
referencedColumns: ["id"]
},
]
}
grade_scale: {
Row: {
class_id: string | null
created_at: string
id: string
letter: string
min_percent: number
}
Insert: {
class_id?: string | null
created_at?: string
id?: string
letter: string
min_percent: number
}
Update: {
class_id?: string | null
created_at?: string
id?: string
letter?: string
min_percent?: number
}
Relationships: [
{
foreignKeyName: "grade_scale_class_id_fkey"
columns: ["class_id"]
isOneToOne: false
referencedRelation: "classes"
referencedColumns: ["id"]
},
]
}
grades: {
Row: {
assignment_id: string
created_at: string
id: string
points: number | null
student_id: string
updated_at: string
}
Insert: {
assignment_id: string
created_at?: string
id?: string
points?: number | null
student_id: string
updated_at?: string
}
Update: {
assignment_id?: string
created_at?: string
id?: string
points?: number | null
student_id?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "grades_assignment_id_fkey"
columns: ["assignment_id"]
isOneToOne: false
referencedRelation: "assignments"
referencedColumns: ["id"]
},
{
foreignKeyName: "grades_student_id_fkey"
columns: ["student_id"]
isOneToOne: false
referencedRelation: "students"
referencedColumns: ["id"]
},
]
}
ledger_entries: {
Row: {
amount_cents: number
@@ -581,6 +738,7 @@ export type Database = {
primary_physician: string | null
special_needs: string | null
updated_at: string
user_id: string | null
}
Insert: {
agreement_signed_by?: string | null
@@ -613,6 +771,7 @@ export type Database = {
primary_physician?: string | null
special_needs?: string | null
updated_at?: string
user_id?: string | null
}
Update: {
agreement_signed_by?: string | null
@@ -645,6 +804,7 @@ export type Database = {
primary_physician?: string | null
special_needs?: string | null
updated_at?: string
user_id?: string | null
}
Relationships: [
{
@@ -720,11 +880,13 @@ export type Database = {
Returns: boolean
}
is_parent_of: { Args: { _student: string }; Returns: boolean }
is_student_account: { Args: { _student: string }; Returns: boolean }
is_thread_participant: { Args: { _thread: string }; Returns: boolean }
teaches_class: { Args: { _class: string }; Returns: boolean }
teaches_student: { Args: { _student: string }; Returns: boolean }
}
Enums: {
app_role: "admin" | "teacher" | "parent"
app_role: "admin" | "teacher" | "parent" | "student"
attendance_status: "present" | "absent" | "late" | "excused"
ledger_category: "tuition" | "late_pickup" | "activity" | "other"
ledger_kind: "charge" | "payment"
@@ -858,7 +1020,7 @@ export const Constants = {
},
public: {
Enums: {
app_role: ["admin", "teacher", "parent"],
app_role: ["admin", "teacher", "parent", "student"],
attendance_status: ["present", "absent", "late", "excused"],
ledger_category: ["tuition", "late_pickup", "activity", "other"],
ledger_kind: ["charge", "payment"],
+36
View File
@@ -0,0 +1,36 @@
// Weighted-category grade calculation shared by the gradebook and the grade views.
export type Cat = { id: string; name: string; weight: number };
export type Scale = { letter: string; min_percent: number };
export type Assn = { id: string; category_id: string | null; max_points: number };
export type Grd = { assignment_id: string; student_id: string; points: number | null };
// Overall percent for a student: weighted average across categories that have graded work.
export function studentPercent(studentId: string, assignments: Assn[], grades: Grd[], categories: Cat[]): number | null {
const byCat: Record<string, { earned: number; possible: number }> = {};
for (const a of assignments) {
if (!a.category_id) continue;
const g = grades.find((x) => x.assignment_id === a.id && x.student_id === studentId && x.points != null);
if (!g) continue;
const b = byCat[a.category_id] ?? { earned: 0, possible: 0 };
b.earned += Number(g.points);
b.possible += Number(a.max_points);
byCat[a.category_id] = b;
}
let weighted = 0, totalW = 0;
for (const c of categories) {
const b = byCat[c.id];
if (!b || b.possible === 0) continue;
weighted += (b.earned / b.possible) * 100 * Number(c.weight);
totalW += Number(c.weight);
}
return totalW === 0 ? null : weighted / totalW;
}
export function letterFor(pct: number | null, scale: Scale[]): string {
if (pct == null) return "—";
const s = [...scale].sort((a, b) => Number(b.min_percent) - Number(a.min_percent));
for (const r of s) if (pct >= Number(r.min_percent)) return r.letter;
return s.length ? s[s.length - 1].letter : "—";
}
export const fmtPct = (pct: number | null) => (pct == null ? "—" : `${pct.toFixed(1)}%`);
+6 -1
View File
@@ -1,7 +1,7 @@
import { createServerFn } from "@tanstack/react-start";
import { requireSupabaseAuth } from "@/integrations/supabase/auth-middleware";
type Role = "admin" | "teacher" | "parent";
type Role = "admin" | "teacher" | "parent" | "student";
// Admin-only: create a user account (service-role, server-side), set its role,
// and optionally link it to a student (for parent hand-off).
@@ -42,6 +42,11 @@ export const createUserFn = createServerFn({ method: "POST" })
.from("parent_students").upsert({ parent_id: uid, student_id: data.linkStudentId }, { onConflict: "parent_id,student_id" });
if (lErr) throw new Error(lErr.message);
}
if (data.linkStudentId && data.role === "student") {
const { error: sErr } = await supabaseAdmin
.from("students").update({ user_id: uid }).eq("id", data.linkStudentId);
if (sErr) throw new Error(sErr.message);
}
return { id: uid, email };
});
+71
View File
@@ -17,12 +17,15 @@ import { Route as AuthenticatedMessagesRouteImport } from './routes/_authenticat
import { Route as AuthenticatedLedgerRouteImport } from './routes/_authenticated/ledger'
import { Route as AuthenticatedFormsRouteImport } from './routes/_authenticated/forms'
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
import { Route as AuthenticatedClassesRouteImport } from './routes/_authenticated/classes'
import { Route as AuthenticatedCalendarRouteImport } from './routes/_authenticated/calendar'
import { Route as AuthenticatedAttendanceRouteImport } from './routes/_authenticated/attendance'
import { Route as AuthenticatedAdminRouteImport } from './routes/_authenticated/admin'
import { Route as AuthenticatedStudentsIndexRouteImport } from './routes/_authenticated/students.index'
import { Route as AuthenticatedClassesIndexRouteImport } from './routes/_authenticated/classes.index'
import { Route as AuthenticatedStudentsNewRouteImport } from './routes/_authenticated/students.new'
import { Route as AuthenticatedStudentsIdRouteImport } from './routes/_authenticated/students.$id'
import { Route as AuthenticatedClassesIdRouteImport } from './routes/_authenticated/classes.$id'
const AuthRoute = AuthRouteImport.update({
id: '/auth',
@@ -63,6 +66,11 @@ const AuthenticatedDashboardRoute = AuthenticatedDashboardRouteImport.update({
path: '/dashboard',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedClassesRoute = AuthenticatedClassesRouteImport.update({
id: '/classes',
path: '/classes',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedCalendarRoute = AuthenticatedCalendarRouteImport.update({
id: '/calendar',
path: '/calendar',
@@ -84,6 +92,12 @@ const AuthenticatedStudentsIndexRoute =
path: '/',
getParentRoute: () => AuthenticatedStudentsRoute,
} as any)
const AuthenticatedClassesIndexRoute =
AuthenticatedClassesIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthenticatedClassesRoute,
} as any)
const AuthenticatedStudentsNewRoute =
AuthenticatedStudentsNewRouteImport.update({
id: '/new',
@@ -95,6 +109,11 @@ const AuthenticatedStudentsIdRoute = AuthenticatedStudentsIdRouteImport.update({
path: '/$id',
getParentRoute: () => AuthenticatedStudentsRoute,
} as any)
const AuthenticatedClassesIdRoute = AuthenticatedClassesIdRouteImport.update({
id: '/$id',
path: '/$id',
getParentRoute: () => AuthenticatedClassesRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -102,13 +121,16 @@ export interface FileRoutesByFullPath {
'/admin': typeof AuthenticatedAdminRoute
'/attendance': typeof AuthenticatedAttendanceRoute
'/calendar': typeof AuthenticatedCalendarRoute
'/classes': typeof AuthenticatedClassesRouteWithChildren
'/dashboard': typeof AuthenticatedDashboardRoute
'/forms': typeof AuthenticatedFormsRoute
'/ledger': typeof AuthenticatedLedgerRoute
'/messages': typeof AuthenticatedMessagesRoute
'/students': typeof AuthenticatedStudentsRouteWithChildren
'/classes/$id': typeof AuthenticatedClassesIdRoute
'/students/$id': typeof AuthenticatedStudentsIdRoute
'/students/new': typeof AuthenticatedStudentsNewRoute
'/classes/': typeof AuthenticatedClassesIndexRoute
'/students/': typeof AuthenticatedStudentsIndexRoute
}
export interface FileRoutesByTo {
@@ -121,8 +143,10 @@ export interface FileRoutesByTo {
'/forms': typeof AuthenticatedFormsRoute
'/ledger': typeof AuthenticatedLedgerRoute
'/messages': typeof AuthenticatedMessagesRoute
'/classes/$id': typeof AuthenticatedClassesIdRoute
'/students/$id': typeof AuthenticatedStudentsIdRoute
'/students/new': typeof AuthenticatedStudentsNewRoute
'/classes': typeof AuthenticatedClassesIndexRoute
'/students': typeof AuthenticatedStudentsIndexRoute
}
export interface FileRoutesById {
@@ -133,13 +157,16 @@ export interface FileRoutesById {
'/_authenticated/admin': typeof AuthenticatedAdminRoute
'/_authenticated/attendance': typeof AuthenticatedAttendanceRoute
'/_authenticated/calendar': typeof AuthenticatedCalendarRoute
'/_authenticated/classes': typeof AuthenticatedClassesRouteWithChildren
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
'/_authenticated/forms': typeof AuthenticatedFormsRoute
'/_authenticated/ledger': typeof AuthenticatedLedgerRoute
'/_authenticated/messages': typeof AuthenticatedMessagesRoute
'/_authenticated/students': typeof AuthenticatedStudentsRouteWithChildren
'/_authenticated/classes/$id': typeof AuthenticatedClassesIdRoute
'/_authenticated/students/$id': typeof AuthenticatedStudentsIdRoute
'/_authenticated/students/new': typeof AuthenticatedStudentsNewRoute
'/_authenticated/classes/': typeof AuthenticatedClassesIndexRoute
'/_authenticated/students/': typeof AuthenticatedStudentsIndexRoute
}
export interface FileRouteTypes {
@@ -150,13 +177,16 @@ export interface FileRouteTypes {
| '/admin'
| '/attendance'
| '/calendar'
| '/classes'
| '/dashboard'
| '/forms'
| '/ledger'
| '/messages'
| '/students'
| '/classes/$id'
| '/students/$id'
| '/students/new'
| '/classes/'
| '/students/'
fileRoutesByTo: FileRoutesByTo
to:
@@ -169,8 +199,10 @@ export interface FileRouteTypes {
| '/forms'
| '/ledger'
| '/messages'
| '/classes/$id'
| '/students/$id'
| '/students/new'
| '/classes'
| '/students'
id:
| '__root__'
@@ -180,13 +212,16 @@ export interface FileRouteTypes {
| '/_authenticated/admin'
| '/_authenticated/attendance'
| '/_authenticated/calendar'
| '/_authenticated/classes'
| '/_authenticated/dashboard'
| '/_authenticated/forms'
| '/_authenticated/ledger'
| '/_authenticated/messages'
| '/_authenticated/students'
| '/_authenticated/classes/$id'
| '/_authenticated/students/$id'
| '/_authenticated/students/new'
| '/_authenticated/classes/'
| '/_authenticated/students/'
fileRoutesById: FileRoutesById
}
@@ -254,6 +289,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedDashboardRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/classes': {
id: '/_authenticated/classes'
path: '/classes'
fullPath: '/classes'
preLoaderRoute: typeof AuthenticatedClassesRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/calendar': {
id: '/_authenticated/calendar'
path: '/calendar'
@@ -282,6 +324,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedStudentsIndexRouteImport
parentRoute: typeof AuthenticatedStudentsRoute
}
'/_authenticated/classes/': {
id: '/_authenticated/classes/'
path: '/'
fullPath: '/classes/'
preLoaderRoute: typeof AuthenticatedClassesIndexRouteImport
parentRoute: typeof AuthenticatedClassesRoute
}
'/_authenticated/students/new': {
id: '/_authenticated/students/new'
path: '/new'
@@ -296,8 +345,28 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedStudentsIdRouteImport
parentRoute: typeof AuthenticatedStudentsRoute
}
'/_authenticated/classes/$id': {
id: '/_authenticated/classes/$id'
path: '/$id'
fullPath: '/classes/$id'
preLoaderRoute: typeof AuthenticatedClassesIdRouteImport
parentRoute: typeof AuthenticatedClassesRoute
}
}
}
interface AuthenticatedClassesRouteChildren {
AuthenticatedClassesIdRoute: typeof AuthenticatedClassesIdRoute
AuthenticatedClassesIndexRoute: typeof AuthenticatedClassesIndexRoute
}
const AuthenticatedClassesRouteChildren: AuthenticatedClassesRouteChildren = {
AuthenticatedClassesIdRoute: AuthenticatedClassesIdRoute,
AuthenticatedClassesIndexRoute: AuthenticatedClassesIndexRoute,
}
const AuthenticatedClassesRouteWithChildren =
AuthenticatedClassesRoute._addFileChildren(AuthenticatedClassesRouteChildren)
interface AuthenticatedStudentsRouteChildren {
AuthenticatedStudentsIdRoute: typeof AuthenticatedStudentsIdRoute
@@ -320,6 +389,7 @@ interface AuthenticatedRouteRouteChildren {
AuthenticatedAdminRoute: typeof AuthenticatedAdminRoute
AuthenticatedAttendanceRoute: typeof AuthenticatedAttendanceRoute
AuthenticatedCalendarRoute: typeof AuthenticatedCalendarRoute
AuthenticatedClassesRoute: typeof AuthenticatedClassesRouteWithChildren
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
AuthenticatedFormsRoute: typeof AuthenticatedFormsRoute
AuthenticatedLedgerRoute: typeof AuthenticatedLedgerRoute
@@ -331,6 +401,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
AuthenticatedAdminRoute: AuthenticatedAdminRoute,
AuthenticatedAttendanceRoute: AuthenticatedAttendanceRoute,
AuthenticatedCalendarRoute: AuthenticatedCalendarRoute,
AuthenticatedClassesRoute: AuthenticatedClassesRouteWithChildren,
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
AuthenticatedFormsRoute: AuthenticatedFormsRoute,
AuthenticatedLedgerRoute: AuthenticatedLedgerRoute,
+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>
@@ -0,0 +1,90 @@
-- Gradebook: weighted-category grading, per-class assignments & grades, student role.
-- (Applied to the remote via the Supabase MCP; kept here as the source of record.)
ALTER TYPE public.app_role ADD VALUE IF NOT EXISTS 'student';
ALTER TABLE public.students ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_students_user ON public.students(user_id);
CREATE OR REPLACE FUNCTION public.teaches_class(_class UUID)
RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$
SELECT EXISTS (SELECT 1 FROM public.classes c WHERE c.id = _class AND c.teacher_id = auth.uid())
$$;
CREATE OR REPLACE FUNCTION public.is_student_account(_student UUID)
RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$
SELECT EXISTS (SELECT 1 FROM public.students s WHERE s.id = _student AND s.user_id = auth.uid())
$$;
CREATE TABLE IF NOT EXISTS public.grade_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
class_id UUID REFERENCES public.classes(id) ON DELETE CASCADE,
name TEXT NOT NULL, weight NUMERIC NOT NULL DEFAULT 0, sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.grade_categories ENABLE ROW LEVEL SECURITY;
CREATE TABLE IF NOT EXISTS public.grade_scale (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
class_id UUID REFERENCES public.classes(id) ON DELETE CASCADE,
letter TEXT NOT NULL, min_percent NUMERIC NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.grade_scale ENABLE ROW LEVEL SECURITY;
CREATE TABLE IF NOT EXISTS public.assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
class_id UUID NOT NULL REFERENCES public.classes(id) ON DELETE CASCADE,
category_id UUID REFERENCES public.grade_categories(id) ON DELETE SET NULL,
name TEXT NOT NULL, max_points NUMERIC NOT NULL DEFAULT 100, date DATE,
created_by UUID REFERENCES auth.users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_assignments_class ON public.assignments(class_id);
ALTER TABLE public.assignments ENABLE ROW LEVEL SECURITY;
CREATE TABLE IF NOT EXISTS public.grades (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
assignment_id UUID NOT NULL REFERENCES public.assignments(id) ON DELETE CASCADE,
student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE,
points NUMERIC, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (assignment_id, student_id)
);
CREATE INDEX IF NOT EXISTS idx_grades_student ON public.grades(student_id);
ALTER TABLE public.grades ENABLE ROW LEVEL SECURITY;
CREATE POLICY "grade_categories read" ON public.grade_categories FOR SELECT TO authenticated USING (TRUE);
CREATE POLICY "grade_categories admin" ON public.grade_categories FOR ALL TO authenticated
USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin'));
CREATE POLICY "grade_categories teacher" ON public.grade_categories FOR ALL TO authenticated
USING (class_id IS NOT NULL AND public.teaches_class(class_id)) WITH CHECK (class_id IS NOT NULL AND public.teaches_class(class_id));
CREATE POLICY "grade_scale read" ON public.grade_scale FOR SELECT TO authenticated USING (TRUE);
CREATE POLICY "grade_scale admin" ON public.grade_scale FOR ALL TO authenticated
USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin'));
CREATE POLICY "grade_scale teacher" ON public.grade_scale FOR ALL TO authenticated
USING (class_id IS NOT NULL AND public.teaches_class(class_id)) WITH CHECK (class_id IS NOT NULL AND public.teaches_class(class_id));
CREATE POLICY "assignments read" ON public.assignments FOR SELECT TO authenticated USING (
public.current_user_has_role('admin') OR public.teaches_class(class_id)
OR EXISTS (SELECT 1 FROM public.students s WHERE s.class_id = assignments.class_id AND (public.is_parent_of(s.id) OR public.is_student_account(s.id)))
);
CREATE POLICY "assignments write" ON public.assignments FOR ALL TO authenticated
USING (public.current_user_has_role('admin') OR public.teaches_class(class_id))
WITH CHECK (public.current_user_has_role('admin') OR public.teaches_class(class_id));
CREATE POLICY "grades read" ON public.grades FOR SELECT TO authenticated USING (
public.current_user_has_role('admin') OR public.is_parent_of(student_id) OR public.is_student_account(student_id)
OR EXISTS (SELECT 1 FROM public.assignments a WHERE a.id = grades.assignment_id AND public.teaches_class(a.class_id))
);
CREATE POLICY "grades write" ON public.grades FOR ALL TO authenticated
USING (public.current_user_has_role('admin') OR EXISTS (SELECT 1 FROM public.assignments a WHERE a.id = grades.assignment_id AND public.teaches_class(a.class_id)))
WITH CHECK (public.current_user_has_role('admin') OR EXISTS (SELECT 1 FROM public.assignments a WHERE a.id = grades.assignment_id AND public.teaches_class(a.class_id)));
INSERT INTO public.grade_categories (class_id, name, weight, sort_order)
SELECT v.class_id, v.name, v.weight, v.sort_order
FROM (VALUES (NULL::UUID, 'Homework', 20::NUMERIC, 0), (NULL, 'Quizzes', 30, 1), (NULL, 'Tests', 50, 2)) AS v(class_id, name, weight, sort_order)
WHERE NOT EXISTS (SELECT 1 FROM public.grade_categories WHERE class_id IS NULL);
INSERT INTO public.grade_scale (class_id, letter, min_percent)
SELECT v.class_id, v.letter, v.min_percent
FROM (VALUES (NULL::UUID, 'A', 90::NUMERIC), (NULL, 'B', 80), (NULL, 'C', 70), (NULL, 'D', 60), (NULL, 'F', 0)) AS v(class_id, letter, min_percent)
WHERE NOT EXISTS (SELECT 1 FROM public.grade_scale WHERE class_id IS NULL);