From 8fe6ad00f497ebb3cd1b4f58420257f7a70db583 Mon Sep 17 00:00:00 2001 From: renee-png Date: Sun, 19 Jul 2026 16:57:27 -0400 Subject: [PATCH] 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 --- src/components/gradebook.tsx | 360 ++++++++++++++++++ src/hooks/use-auth.ts | 3 +- src/integrations/supabase/types.ts | 166 +++++++- src/lib/grades.ts | 36 ++ src/lib/user-admin.functions.ts | 7 +- src/routeTree.gen.ts | 71 ++++ src/routes/_authenticated/classes.$id.tsx | 46 +++ src/routes/_authenticated/classes.index.tsx | 87 +++++ src/routes/_authenticated/classes.tsx | 6 + src/routes/_authenticated/route.tsx | 5 +- src/routes/_authenticated/students.$id.tsx | 44 ++- .../migrations/20260719204512_gradebook.sql | 90 +++++ 12 files changed, 901 insertions(+), 20 deletions(-) create mode 100644 src/components/gradebook.tsx create mode 100644 src/lib/grades.ts create mode 100644 src/routes/_authenticated/classes.$id.tsx create mode 100644 src/routes/_authenticated/classes.index.tsx create mode 100644 src/routes/_authenticated/classes.tsx create mode 100644 supabase/migrations/20260719204512_gradebook.sql diff --git a/src/components/gradebook.tsx b/src/components/gradebook.tsx new file mode 100644 index 0000000..245dc6d --- /dev/null +++ b/src/components/gradebook.tsx @@ -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 ( +
+
+
+
+

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 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 ( +
+ {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.

} +
+ ); +} diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 7c5788a..3b220c0 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -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; } diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 9d993cd..70ca977 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -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"], diff --git a/src/lib/grades.ts b/src/lib/grades.ts new file mode 100644 index 0000000..9cc31c9 --- /dev/null +++ b/src/lib/grades.ts @@ -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 = {}; + 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)}%`); diff --git a/src/lib/user-admin.functions.ts b/src/lib/user-admin.functions.ts index f991492..cf75349 100644 --- a/src/lib/user-admin.functions.ts +++ b/src/lib/user-admin.functions.ts @@ -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 }; }); diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 83d2a1c..f245f78 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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,9 +345,29 @@ 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 AuthenticatedStudentsNewRoute: typeof AuthenticatedStudentsNewRoute @@ -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, diff --git a/src/routes/_authenticated/classes.$id.tsx b/src/routes/_authenticated/classes.$id.tsx new file mode 100644 index 0000000..7f20572 --- /dev/null +++ b/src/routes/_authenticated/classes.$id.tsx @@ -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
; + if (!canEdit) return

Not available

You don't have access to this class.

; + + return ( +
+ All classes +

{cls?.name}

+ + + + Gradebook + Roster + Grading setup + + + + + +
+ ); +} diff --git a/src/routes/_authenticated/classes.index.tsx b/src/routes/_authenticated/classes.index.tsx new file mode 100644 index 0000000..b6ca955 --- /dev/null +++ b/src/routes/_authenticated/classes.index.tsx @@ -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 ( +
+

Classes

+

Rosters and gradebooks.

+ + {isAdmin && ( +
+
New class
+
+ setName(e.target.value)} /> + + +
+
+ )} + +
+ {visible.map((c) => ( + + {c.name} + + + ))} + {visible.length === 0 &&
{isAdmin ? "No classes yet — create one above." : "You have no classes assigned."}
} +
+ + {isAdmin && ( +
+ +

Applied to any class that hasn't set its own categories/scale.

+ {showDefaults &&
} +
+ )} +
+ ); +} diff --git a/src/routes/_authenticated/classes.tsx b/src/routes/_authenticated/classes.tsx new file mode 100644 index 0000000..a88b9a6 --- /dev/null +++ b/src/routes/_authenticated/classes.tsx @@ -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: () => , +}); diff --git a/src/routes/_authenticated/route.tsx b/src/routes/_authenticated/route.tsx index 4a92014..e240b94 100644 --- a/src/routes/_authenticated/route.tsx +++ b/src/routes/_authenticated/route.tsx @@ -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 }, diff --git a/src/routes/_authenticated/students.$id.tsx b/src/routes/_authenticated/students.$id.tsx index 1374bb4..846d31a 100644 --- a/src/routes/_authenticated/students.$id.tsx +++ b/src/routes/_authenticated/students.$id.tsx @@ -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() { Profile Family & pickup Academics + Grades Attendance Tuition {isAdmin && Contracts} @@ -95,6 +97,7 @@ function StudentDetail() { + {isAdmin && } @@ -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 (
-

Parent portal access

Create a login so a parent can sign in and fill out this profile.

+

Portal access

Create logins so a parent (can edit this profile) or the student (view-only grades) can sign in.

- {(parents ?? []).map((p) =>
{p.full_name || "—"}
{p.email}
)} - {parents?.length === 0 &&
No parent accounts linked yet.
} + {studentLogin &&
{studentLogin.full_name || "Student"} Student
{studentLogin.email}
} + {(parents ?? []).map((p) =>
{p.full_name || "—"} Parent
{p.email}
)} + {!studentLogin && parents?.length === 0 &&
No logins yet.
}
-
- setNp({ ...np, fullName: e.target.value })} /> - setNp({ ...np, email: e.target.value })} /> - +
+ + setNp({ ...np, fullName: e.target.value })} /> + setNp({ ...np, email: e.target.value })} /> +
{created && (
-
Login created — give these to the parent:
+
Login created — give these to the {created.role}:
{created.email} {created.password}
-

They sign in at the portal, open this student, and hit Edit to complete the profile.

+

They sign in at the portal to {created.role === "student" ? "view grades" : "complete this profile"}.

)}
diff --git a/supabase/migrations/20260719204512_gradebook.sql b/supabase/migrations/20260719204512_gradebook.sql new file mode 100644 index 0000000..386b3bf --- /dev/null +++ b/supabase/migrations/20260719204512_gradebook.sql @@ -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);