Terminal
This commit is contained in:
@@ -1,361 +0,0 @@
|
|||||||
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 [dateKey, setDateKey] = useState(0); // bump to remount the uncontrolled date input after adding
|
|
||||||
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: "" }); setDateKey((k) => k + 1); 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 key={dateKey} type="date" defaultValue={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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user