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:
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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)}%`);
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />,
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user