Files
full_encoach_platform/frontend/src/pages/ClassroomsPage.tsx
Yamen Ahmad 3b62075d7e feat(platform): course-plan student visibility, multi-voice TTS, OCR sources, branches
Backend (encoach_ai_course):
- workbook_attempt model + scoring + REST endpoints for student attempts
- dialogue_parser splits scripts by speaker, classifies gender, strips labels
- media_service: multi-voice TTS via Polly/ElevenLabs, ffmpeg concatenation,
  manual media upload endpoint (audio/image/video) with size validation
- source_indexer: OCR fallback (pytesseract + pdf2image) for scanned PDFs,
  page-streaming to stay under memory limit
- exercise_extractor + rag_context for RAG-grounded interactive workbooks
- course_plan_pipeline: v2 generator that grounds week material on indexed
  sources and persists grounded_on_json metadata
- security: access rules for new models

Backend (encoach_lms_api):
- branches model + controller (entity-scoped LMS branches)
- classroom_ext + course_ext (assignment + section workflow)
- classrooms controller: students/teachers/assign-course endpoints

Frontend:
- StudentDashboard: surface assigned AI course plans alongside enrollments;
  enrolled-courses stat now counts plans+enrollments
- InteractiveWorkbook + PlanReader components
- AdminCoursePlanDetail: media drawer with upload buttons (audio/image/video),
  hidden file inputs, upload mutation
- AdminBranches page + sidebar entry
- coursePlan/lms/classrooms services + types updated for new endpoints
- i18n: studentDash.myCoursePlans/noCoursePlans (en + ar)

Infra & docs:
- odoo.conf: bump memory limits to 4G/5G for OCR + sentence-transformers
- .gitignore: ignore *.tsbuildinfo
- docs/ASSIGNMENT_WORKFLOW.{md,pdf}
- smoke_*.py end-to-end tests for assignment workflow, entity isolation,
  course-plan RAG pipeline

Made-with: Cursor
2026-04-28 00:17:51 +04:00

1340 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Plus,
Search,
Trash2,
Pencil,
Users,
GraduationCap,
BookOpen,
Layers,
UserPlus,
UserMinus,
Loader2,
Sparkles,
CheckCircle2,
Circle,
Settings2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { classroomsService } from "@/services/classrooms.service";
import { useStudents, useTeachers, useCourses } from "@/hooks/queries";
import { lmsService } from "@/services";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/contexts/AuthContext";
import type { Classroom, LmsBranch } from "@/types";
type ClassroomForm = {
name: string;
code: string;
capacity: string;
notes: string;
branch_id: string;
active: boolean;
};
const EMPTY_FORM: ClassroomForm = {
name: "",
code: "",
capacity: "30",
notes: "",
branch_id: "",
active: true,
};
export default function ClassroomsPage() {
const { toast } = useToast();
const qc = useQueryClient();
const { selectedEntity } = useAuth();
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Classroom | null>(null);
const [form, setForm] = useState<ClassroomForm>(EMPTY_FORM);
const [activeId, setActiveId] = useState<number | null>(null);
// Reference data
const studentsQ = useStudents({ size: 500 });
const teachersQ = useTeachers({ size: 500 });
const coursesQ = useCourses({ size: 500 });
const branchesQ = useQuery({
queryKey: ["lms-branches", selectedEntity?.id ?? 0],
queryFn: async () => (await lmsService.listBranches({ size: 200, active: true })).items,
});
const classroomsQ = useQuery({
queryKey: ["lms-classrooms", selectedEntity?.id ?? 0, search],
queryFn: async () =>
(await classroomsService.list({ size: 200, search: search.trim() || undefined })).items,
});
const detailQ = useQuery({
queryKey: ["lms-classroom-detail", activeId],
queryFn: () => classroomsService.getById(activeId as number),
enabled: !!activeId,
});
// ── Mutations ──────────────────────────────────────────────────────
const createMut = useMutation({
mutationFn: () =>
classroomsService.create({
name: form.name,
code: form.code || undefined,
capacity: Number(form.capacity || 30),
notes: form.notes || undefined,
branch_id: form.branch_id ? Number(form.branch_id) : null,
active: form.active,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms-classrooms"] });
setCreateOpen(false);
setForm(EMPTY_FORM);
toast({ title: "Classroom created" });
},
onError: (e: Error) =>
toast({ title: "Error", description: e.message, variant: "destructive" }),
});
const updateMut = useMutation({
mutationFn: () =>
classroomsService.update(editing!.id, {
name: form.name,
code: form.code || undefined,
capacity: Number(form.capacity || 30),
notes: form.notes || undefined,
branch_id: form.branch_id ? Number(form.branch_id) : null,
active: form.active,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms-classrooms"] });
qc.invalidateQueries({ queryKey: ["lms-classroom-detail"] });
setEditing(null);
setForm(EMPTY_FORM);
toast({ title: "Classroom updated" });
},
onError: (e: Error) =>
toast({ title: "Error", description: e.message, variant: "destructive" }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => classroomsService.delete(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms-classrooms"] });
if (activeId) setActiveId(null);
toast({ title: "Classroom deleted" });
},
onError: (e: Error) =>
toast({ title: "Error", description: e.message, variant: "destructive" }),
});
const setStudentsMut = useMutation({
mutationFn: ({ id, ids }: { id: number; ids: number[] }) =>
classroomsService.setStudents(id, ids, "set"),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms-classrooms"] });
qc.invalidateQueries({ queryKey: ["lms-classroom-detail"] });
toast({ title: "Roster updated" });
},
});
const setTeachersMut = useMutation({
mutationFn: ({ id, ids }: { id: number; ids: number[] }) =>
classroomsService.setTeachers(id, ids, "set"),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms-classrooms"] });
qc.invalidateQueries({ queryKey: ["lms-classroom-detail"] });
toast({ title: "Homeroom teachers updated" });
},
});
const assignCourseMut = useMutation({
mutationFn: ({
id,
payload,
}: {
id: number;
payload: { course_id: number; section_id?: number; term_key?: string };
}) => classroomsService.assignCourse(id, payload),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ["lms-classrooms"] });
qc.invalidateQueries({ queryKey: ["lms-classroom-detail"] });
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
toast({
title: "Course assigned",
description: `Batch "${res.batch.name}" — ${res.batch.student_count} student(s) enrolled`,
});
},
onError: (e: Error) =>
toast({ title: "Error", description: e.message, variant: "destructive" }),
});
const detachCourseMut = useMutation({
mutationFn: ({ id, courseId }: { id: number; courseId: number }) =>
classroomsService.detachCourse(id, courseId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms-classroom-detail"] });
toast({ title: "Course detached", description: "Existing batch & enrollments are kept." });
},
});
// ── Derived data ──────────────────────────────────────────────────
const items = classroomsQ.data ?? [];
const branches = branchesQ.data ?? [];
const allStudents = studentsQ.data?.items ?? [];
const allTeachers = teachersQ.data?.items ?? [];
const allCourses = coursesQ.data?.items ?? [];
const detail = detailQ.data;
const filtered = useMemo(() => {
const q = search.toLowerCase();
return items.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.code.toLowerCase().includes(q) ||
(c.branch_name || "").toLowerCase().includes(q),
);
}, [items, search]);
function startEdit(c: Classroom) {
setEditing(c);
setForm({
name: c.name,
code: c.code,
capacity: String(c.capacity || 30),
notes: c.notes || "",
branch_id: c.branch_id ? String(c.branch_id) : "",
active: c.active,
});
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Classrooms</h1>
<p className="text-muted-foreground">
Homerooms assign students, teachers and courses. Assigning a course auto-creates a
batch and enrolls the classroom roster.
</p>
</div>
<Button size="sm" onClick={() => { setEditing(null); setForm(EMPTY_FORM); setCreateOpen(true); }}>
<Plus className="h-4 w-4 mr-1" /> New Classroom
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[360px_1fr] gap-4">
{/* Left list */}
<Card className="border-0 shadow-sm">
<CardContent className="p-3 space-y-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search classrooms…"
className="pl-9"
/>
</div>
{classroomsQ.isLoading ? (
<div className="py-10 flex justify-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : filtered.length === 0 ? (
<div className="py-10 text-center text-sm text-muted-foreground">
No classrooms yet.
</div>
) : (
<div className="space-y-1 max-h-[60vh] overflow-y-auto">
{filtered.map((c) => (
<button
key={c.id}
onClick={() => setActiveId(c.id)}
className={`w-full text-left p-3 rounded-md border transition-colors ${
activeId === c.id
? "border-primary bg-primary/5"
: "border-transparent hover:bg-muted/40"
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium truncate">{c.name}</span>
<Badge variant="secondary" className="ml-2 shrink-0">{c.code}</Badge>
</div>
<div className="text-xs text-muted-foreground mt-1 flex flex-wrap gap-2">
{c.branch_name && <span>· {c.branch_name}</span>}
<span>· {c.student_count} students</span>
<span>· {c.course_count} courses</span>
<span>· {c.batch_count} batches</span>
</div>
</button>
))}
</div>
)}
</CardContent>
</Card>
{/* Right detail */}
<Card className="border-0 shadow-sm">
<CardContent className="p-4">
{!activeId ? (
<div className="py-20 text-center text-sm text-muted-foreground">
Select a classroom on the left to manage its roster, teachers, courses and batches.
</div>
) : detailQ.isLoading || !detail ? (
<div className="py-20 flex justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-4">
<div className="flex items-start justify-between gap-2">
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold">{detail.name}</h2>
<Badge variant="secondary">{detail.code}</Badge>
{!detail.active && <Badge variant="outline">Inactive</Badge>}
</div>
<div className="text-sm text-muted-foreground mt-1">
{detail.branch_name ? `Branch: ${detail.branch_name} · ` : ""}
Capacity: {detail.capacity}
</div>
</div>
<div className="flex gap-1">
<Button variant="outline" size="sm" onClick={() => startEdit(detail)}>
<Pencil className="h-3.5 w-3.5 mr-1" /> Edit
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive"
onClick={() => {
if (window.confirm(`Delete "${detail.name}"?`))
deleteMut.mutate(detail.id);
}}
>
<Trash2 className="h-3.5 w-3.5 mr-1" /> Delete
</Button>
</div>
</div>
<WorkflowGuide
hasRoster={detail.student_count > 0}
hasTeachers={detail.teacher_count > 0}
hasCourses={detail.course_count > 0}
hasBatches={detail.batch_count > 0}
/>
<Tabs defaultValue="roster">
<TabsList>
<TabsTrigger value="roster">
<Users className="h-3.5 w-3.5 mr-1" /> 1. Roster
<Badge variant="secondary" className="ml-2">{detail.student_count}</Badge>
</TabsTrigger>
<TabsTrigger value="teachers">
<GraduationCap className="h-3.5 w-3.5 mr-1" /> 2. Faculty
<Badge variant="secondary" className="ml-2">{detail.teacher_count}</Badge>
</TabsTrigger>
<TabsTrigger value="courses">
<BookOpen className="h-3.5 w-3.5 mr-1" /> 3. Courses
<Badge variant="secondary" className="ml-2">{detail.course_count}</Badge>
</TabsTrigger>
<TabsTrigger value="batches">
<Layers className="h-3.5 w-3.5 mr-1" /> 4. Batches
<Badge variant="secondary" className="ml-2">{detail.batch_count}</Badge>
</TabsTrigger>
</TabsList>
{/* Roster ------------------------------------------ */}
<TabsContent value="roster" className="mt-4">
<RosterPanel
classroomId={detail.id}
currentIds={detail.student_ids}
allStudents={allStudents}
onSave={(ids) => setStudentsMut.mutate({ id: detail.id, ids })}
saving={setStudentsMut.isPending}
/>
</TabsContent>
{/* Faculty ----------------------------------------- */}
<TabsContent value="teachers" className="mt-4">
<FacultyPanel
classroomId={detail.id}
currentIds={detail.teacher_ids}
allTeachers={allTeachers}
onSave={(ids) => setTeachersMut.mutate({ id: detail.id, ids })}
saving={setTeachersMut.isPending}
/>
</TabsContent>
{/* Courses ----------------------------------------- */}
<TabsContent value="courses" className="mt-4">
<CoursesPanel
classroom={detail}
allCourses={allCourses}
onAssign={(payload) =>
assignCourseMut.mutate({ id: detail.id, payload })
}
onDetach={(courseId) =>
detachCourseMut.mutate({ id: detail.id, courseId })
}
assigning={assignCourseMut.isPending}
/>
</TabsContent>
{/* Batches ----------------------------------------- */}
<TabsContent value="batches" className="mt-4">
<BatchesPanel
batches={detail.batches ?? []}
classroomStudentIds={detail.student_ids}
classroomTeacherIds={detail.teacher_ids}
allStudents={allStudents}
allTeachers={allTeachers}
onChanged={() => {
qc.invalidateQueries({ queryKey: ["lms-classroom-detail"] });
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
}}
/>
</TabsContent>
</Tabs>
</div>
)}
</CardContent>
</Card>
</div>
{/* Create / Edit dialog */}
<Dialog
open={createOpen || !!editing}
onOpenChange={(v) => {
if (!v) {
setCreateOpen(false);
setEditing(null);
setForm(EMPTY_FORM);
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing ? "Edit Classroom" : "Create Classroom"}</DialogTitle>
<DialogDescription>
A classroom is a homeroom: a physical room hosting a group of students, with
homeroom teachers and the courses they study.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<Label>Name</Label>
<Input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="e.g. Grade 7-A"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label>Code</Label>
<Input
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
placeholder="G7A"
/>
</div>
<div className="space-y-1">
<Label>Capacity</Label>
<Input
type="number"
min={1}
value={form.capacity}
onChange={(e) => setForm((f) => ({ ...f, capacity: e.target.value }))}
/>
</div>
</div>
<div className="space-y-1">
<Label>Branch</Label>
<Select
value={form.branch_id || "none"}
onValueChange={(v) =>
setForm((f) => ({ ...f, branch_id: v === "none" ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder="No branch" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No branch</SelectItem>
{branches.map((b: LmsBranch) => (
<SelectItem key={b.id} value={String(b.id)}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Notes</Label>
<Textarea
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
rows={2}
/>
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={form.active}
onCheckedChange={(v) => setForm((f) => ({ ...f, active: !!v }))}
/>
Active
</label>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setCreateOpen(false);
setEditing(null);
}}
>
Cancel
</Button>
<Button
disabled={!form.name || createMut.isPending || updateMut.isPending}
onClick={() => (editing ? updateMut.mutate() : createMut.mutate())}
>
{(createMut.isPending || updateMut.isPending) && (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
)}
{editing ? "Save" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ════════════════════════════════════════════════════════════════════════
// Sub-panels
// ════════════════════════════════════════════════════════════════════════
interface RosterPanelProps {
classroomId: number;
currentIds: number[];
allStudents: { id: number; name: string; email: string }[];
onSave: (ids: number[]) => void;
saving: boolean;
}
function RosterPanel({ currentIds, allStudents, onSave, saving }: RosterPanelProps) {
const [picker, setPicker] = useState(false);
const [draft, setDraft] = useState<number[]>(currentIds);
const [q, setQ] = useState("");
const inRoster = allStudents.filter((s) => currentIds.includes(s.id));
const candidates = allStudents.filter((s) =>
`${s.name} ${s.email}`.toLowerCase().includes(q.toLowerCase()),
);
function openPicker() {
setDraft(currentIds);
setPicker(true);
}
function toggle(id: number) {
setDraft((d) => (d.includes(id) ? d.filter((x) => x !== id) : [...d, id]));
}
function remove(id: number) {
onSave(currentIds.filter((x) => x !== id));
}
return (
<div className="space-y-3">
<div className="flex justify-between">
<p className="text-sm text-muted-foreground">
Students in the homeroom roster. New batches auto-enroll everyone here.
</p>
<Button size="sm" onClick={openPicker}>
<UserPlus className="h-3.5 w-3.5 mr-1" /> Manage Roster
</Button>
</div>
{inRoster.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground border rounded-md border-dashed">
No students yet. Click <strong>Manage Roster</strong> to add some.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{inRoster.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell className="text-muted-foreground">{s.email}</TableCell>
<TableCell>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-destructive"
onClick={() => remove(s.id)}
>
<UserMinus className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Dialog open={picker} onOpenChange={setPicker}>
<DialogContent>
<DialogHeader>
<DialogTitle>Manage Roster</DialogTitle>
<DialogDescription>
Pick the students that belong to this classroom.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search…"
className="pl-9"
/>
</div>
<div className="max-h-[40vh] overflow-y-auto space-y-1">
{candidates.map((s) => (
<label
key={s.id}
className="flex items-center gap-2 p-2 rounded hover:bg-muted/40 cursor-pointer"
>
<Checkbox
checked={draft.includes(s.id)}
onCheckedChange={() => toggle(s.id)}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{s.name}</p>
<p className="text-xs text-muted-foreground">{s.email}</p>
</div>
</label>
))}
</div>
<p className="text-xs text-muted-foreground">{draft.length} selected</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPicker(false)}>Cancel</Button>
<Button
disabled={saving}
onClick={() => {
onSave(draft);
setPicker(false);
}}
>
{saving && <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />}
Save Roster
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
interface FacultyPanelProps {
classroomId: number;
currentIds: number[];
allTeachers: { id: number; name: string; email: string }[];
onSave: (ids: number[]) => void;
saving: boolean;
}
function FacultyPanel({ currentIds, allTeachers, onSave, saving }: FacultyPanelProps) {
const [picker, setPicker] = useState(false);
const [draft, setDraft] = useState<number[]>(currentIds);
const list = allTeachers.filter((t) => currentIds.includes(t.id));
function toggle(id: number) {
setDraft((d) => (d.includes(id) ? d.filter((x) => x !== id) : [...d, id]));
}
return (
<div className="space-y-3">
<div className="flex justify-between">
<p className="text-sm text-muted-foreground">
Homeroom teachers. They are the default teachers used when assigning a course.
Per-batch teachers can be different.
</p>
<Button
size="sm"
onClick={() => {
setDraft(currentIds);
setPicker(true);
}}
>
<UserPlus className="h-3.5 w-3.5 mr-1" /> Manage Faculty
</Button>
</div>
{list.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground border rounded-md border-dashed">
No homeroom teachers assigned yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell className="text-muted-foreground">{t.email}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Dialog open={picker} onOpenChange={setPicker}>
<DialogContent>
<DialogHeader>
<DialogTitle>Manage Faculty</DialogTitle>
</DialogHeader>
<div className="max-h-[40vh] overflow-y-auto space-y-1">
{allTeachers.map((t) => (
<label
key={t.id}
className="flex items-center gap-2 p-2 rounded hover:bg-muted/40 cursor-pointer"
>
<Checkbox checked={draft.includes(t.id)} onCheckedChange={() => toggle(t.id)} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{t.name}</p>
<p className="text-xs text-muted-foreground">{t.email}</p>
</div>
</label>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPicker(false)}>Cancel</Button>
<Button
disabled={saving}
onClick={() => {
onSave(draft);
setPicker(false);
}}
>
{saving && <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />}
Save Faculty
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
interface CoursesPanelProps {
classroom: Classroom;
allCourses: { id: number; title: string; code: string }[];
onAssign: (payload: { course_id: number; section_id?: number; term_key?: string }) => void;
onDetach: (courseId: number) => void;
assigning: boolean;
}
function CoursesPanel({ classroom, allCourses, onAssign, onDetach, assigning }: CoursesPanelProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const [picked, setPicked] = useState<string>("");
const [pickedSection, setPickedSection] = useState<string>("");
const [termKey, setTermKey] = useState("");
const assignedIds = new Set(classroom.course_ids);
const candidates = allCourses.filter((c) => !assignedIds.has(c.id));
const assigned = classroom.courses ?? [];
const sectionsQ = useQuery({
queryKey: ["course-sections", picked],
queryFn: () => lmsService.listCourseSections(Number(picked)),
enabled: !!picked,
});
const sections = sectionsQ.data?.items ?? [];
return (
<div className="space-y-3">
<div className="flex justify-between">
<p className="text-sm text-muted-foreground">
Assigning a course creates a batch and enrolls the {classroom.student_count} student(s)
on the roster automatically.
</p>
<Button
size="sm"
onClick={() => {
setPicked("");
setPickedSection("");
setTermKey("");
setPickerOpen(true);
}}
>
<Sparkles className="h-3.5 w-3.5 mr-1" /> Assign Course
</Button>
</div>
{assigned.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground border rounded-md border-dashed">
No courses assigned yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Course</TableHead>
<TableHead>Code</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{assigned.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell className="text-muted-foreground">{c.code}</TableCell>
<TableCell>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-destructive"
onClick={() => {
if (
window.confirm(
"Detach course from classroom? Existing batches & enrollments are kept.",
)
)
onDetach(c.id);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Assign Course</DialogTitle>
<DialogDescription>
The classroom roster ({classroom.student_count} students) will be auto-enrolled
into a new batch for the chosen course.
</DialogDescription>
</DialogHeader>
<Select value={picked} onValueChange={setPicked}>
<SelectTrigger>
<SelectValue placeholder="Select a course…" />
</SelectTrigger>
<SelectContent>
{candidates.length === 0 ? (
<div className="px-2 py-3 text-xs text-muted-foreground">
All courses already assigned.
</div>
) : (
candidates.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.title} {c.code ? `· ${c.code}` : ""}
</SelectItem>
))
)}
</SelectContent>
</Select>
<div className="space-y-1">
<Label>Section (optional)</Label>
<Select
value={pickedSection || "none"}
onValueChange={(v) => setPickedSection(v === "none" ? "" : v)}
disabled={!picked}
>
<SelectTrigger>
<SelectValue placeholder="Auto (whole course)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Auto (whole course)</SelectItem>
{sections.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name} {s.code ? `· ${s.code}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Term Key (optional)</Label>
<Input
value={termKey}
onChange={(e) => setTermKey(e.target.value)}
placeholder="e.g. 2026-T1"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPickerOpen(false)}>Cancel</Button>
<Button
disabled={!picked || assigning}
onClick={() => {
onAssign({
course_id: Number(picked),
section_id: pickedSection ? Number(pickedSection) : undefined,
term_key: termKey.trim() || undefined,
});
setPickerOpen(false);
}}
>
{assigning && <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />}
Assign + Cascade
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
interface BatchesPanelProps {
batches: NonNullable<Classroom["batches"]>;
classroomStudentIds: number[];
classroomTeacherIds: number[];
allStudents: { id: number; name: string; email: string }[];
allTeachers: { id: number; name: string; email: string }[];
onChanged: () => void;
}
function BatchesPanel({
batches,
classroomStudentIds,
classroomTeacherIds,
allStudents,
allTeachers,
onChanged,
}: BatchesPanelProps) {
const [managing, setManaging] = useState<NonNullable<Classroom["batches"]>[number] | null>(null);
if (!batches.length) {
return (
<div className="py-8 text-center text-sm text-muted-foreground border rounded-md border-dashed">
No batches yet. Assign a course in the <strong>Courses</strong> tab to create the first one.
</div>
);
}
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Each batch is one (course × section × term) for this classroom. The roster and homeroom
teachers cascade automatically use <strong>Manage</strong> to override per batch (e.g. a
single student in a different section, or a different teacher per section).
</p>
<Table>
<TableHeader>
<TableRow>
<TableHead>Batch</TableHead>
<TableHead>Course</TableHead>
<TableHead>Section</TableHead>
<TableHead>Students</TableHead>
<TableHead>Teachers</TableHead>
<TableHead>Dates</TableHead>
<TableHead className="w-[120px]" />
</TableRow>
</TableHeader>
<TableBody>
{batches.map((b) => (
<TableRow key={b.id}>
<TableCell>
<div className="font-medium">{b.name}</div>
<div className="text-xs text-muted-foreground">{b.code}</div>
</TableCell>
<TableCell>{b.course_name || "—"}</TableCell>
<TableCell>
{b.course_section_name
? `${b.course_section_name}${b.course_section_code ? ` (${b.course_section_code})` : ""}`
: "—"}
</TableCell>
<TableCell>{b.student_count}</TableCell>
<TableCell>{b.teacher_ids?.length ?? 0}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{b.start_date && b.end_date ? `${b.start_date}${b.end_date}` : "—"}
</TableCell>
<TableCell>
<Button size="sm" variant="outline" onClick={() => setManaging(b)}>
<Settings2 className="h-3.5 w-3.5 mr-1" /> Manage
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{managing && (
<BatchManagerDialog
batch={managing}
classroomStudentIds={classroomStudentIds}
classroomTeacherIds={classroomTeacherIds}
allStudents={allStudents}
allTeachers={allTeachers}
onClose={() => setManaging(null)}
onChanged={onChanged}
/>
)}
</div>
);
}
// ════════════════════════════════════════════════════════════════════════
// Per-batch manager: enroll specific students + assign per-batch teachers
// ════════════════════════════════════════════════════════════════════════
interface BatchManagerDialogProps {
batch: NonNullable<Classroom["batches"]>[number];
classroomStudentIds: number[];
classroomTeacherIds: number[];
allStudents: { id: number; name: string; email: string }[];
allTeachers: { id: number; name: string; email: string }[];
onClose: () => void;
onChanged: () => void;
}
function BatchManagerDialog({
batch,
classroomStudentIds,
classroomTeacherIds,
allStudents,
allTeachers,
onClose,
onChanged,
}: BatchManagerDialogProps) {
const { toast } = useToast();
const qc = useQueryClient();
const [tab, setTab] = useState<"students" | "teachers">("students");
// Live enrollments for this batch (students)
const enrolledQ = useQuery({
queryKey: ["batch-students", batch.id],
queryFn: () => lmsService.getBatchStudents(batch.id),
});
const teachersQ = useQuery({
queryKey: ["batch-teachers", batch.id],
queryFn: () => lmsService.getBatchTeachers(batch.id),
});
const enrolledIds = useMemo(
() => (enrolledQ.data?.data ?? []).map((s) => s.id),
[enrolledQ.data],
);
const enrolledTeacherIds = useMemo(
() => (teachersQ.data?.items ?? []).map((t) => t.id),
[teachersQ.data],
);
const [studentDraft, setStudentDraft] = useState<number[]>([]);
const [teacherDraft, setTeacherDraft] = useState<number[]>([]);
const [studentSearch, setStudentSearch] = useState("");
const [teacherSearch, setTeacherSearch] = useState("");
useEffect(() => setStudentDraft(enrolledIds), [enrolledIds]);
useEffect(() => setTeacherDraft(enrolledTeacherIds), [enrolledTeacherIds]);
const studentSaveMut = useMutation({
mutationFn: async () => {
const toAdd = studentDraft.filter((id) => !enrolledIds.includes(id));
const toRemove = enrolledIds.filter((id) => !studentDraft.includes(id));
if (toAdd.length) await lmsService.addStudentsToBatch(batch.id, toAdd);
if (toRemove.length) await lmsService.removeStudentsFromBatch(batch.id, toRemove);
return { added: toAdd.length, removed: toRemove.length };
},
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ["batch-students", batch.id] });
onChanged();
toast({
title: "Batch roster updated",
description: `${res.added} added, ${res.removed} removed`,
});
},
onError: (e: Error) =>
toast({ title: "Error", description: e.message, variant: "destructive" }),
});
const teacherSaveMut = useMutation({
mutationFn: () => lmsService.setBatchTeachers(batch.id, teacherDraft, "set"),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["batch-teachers", batch.id] });
onChanged();
toast({ title: "Batch teachers updated" });
},
onError: (e: Error) =>
toast({ title: "Error", description: e.message, variant: "destructive" }),
});
const studentCandidates = allStudents.filter((s) =>
`${s.name} ${s.email}`.toLowerCase().includes(studentSearch.toLowerCase()),
);
const teacherCandidates = allTeachers.filter((t) =>
`${t.name} ${t.email}`.toLowerCase().includes(teacherSearch.toLowerCase()),
);
function toggle<T extends number>(setter: (fn: (d: T[]) => T[]) => void) {
return (id: T) => setter((d) => (d.includes(id) ? d.filter((x) => x !== id) : [...d, id]));
}
return (
<Dialog open onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{batch.name}</DialogTitle>
<DialogDescription>
<span className="text-xs">
{batch.course_name || "—"}
{batch.course_section_name ? ` · Section ${batch.course_section_name}` : ""}
{batch.term_key ? ` · ${batch.term_key}` : ""}
</span>
</DialogDescription>
</DialogHeader>
<Tabs value={tab} onValueChange={(v) => setTab(v as "students" | "teachers")}>
<TabsList>
<TabsTrigger value="students">
<Users className="h-3.5 w-3.5 mr-1" /> Students
<Badge variant="secondary" className="ml-2">{studentDraft.length}</Badge>
</TabsTrigger>
<TabsTrigger value="teachers">
<GraduationCap className="h-3.5 w-3.5 mr-1" /> Teachers
<Badge variant="secondary" className="ml-2">{teacherDraft.length}</Badge>
</TabsTrigger>
</TabsList>
{/* Students -------------------------------------------------- */}
<TabsContent value="students" className="mt-3 space-y-2">
<div className="text-xs text-muted-foreground">
Pick the students enrolled in this batch. By default the homeroom roster cascades
here automatically; this override lets you (de)select per batch useful when a
section has only a subset of the classroom.
<Button
variant="link"
size="sm"
className="px-1 h-auto"
onClick={() => setStudentDraft(classroomStudentIds)}
>
reset to classroom roster
</Button>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={studentSearch}
onChange={(e) => setStudentSearch(e.target.value)}
placeholder="Search students…"
className="pl-9"
/>
</div>
<div className="max-h-[40vh] overflow-y-auto space-y-1 border rounded-md p-1">
{studentCandidates.map((s) => (
<label
key={s.id}
className="flex items-center gap-2 p-2 rounded hover:bg-muted/40 cursor-pointer"
>
<Checkbox
checked={studentDraft.includes(s.id)}
onCheckedChange={() => toggle<number>(setStudentDraft)(s.id)}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{s.name}</p>
<p className="text-xs text-muted-foreground">{s.email}</p>
</div>
{classroomStudentIds.includes(s.id) && (
<Badge variant="outline" className="text-[10px]">on roster</Badge>
)}
</label>
))}
</div>
<div className="flex justify-end">
<Button
size="sm"
disabled={studentSaveMut.isPending || enrolledQ.isLoading}
onClick={() => studentSaveMut.mutate()}
>
{studentSaveMut.isPending && (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
)}
Save Batch Students
</Button>
</div>
</TabsContent>
{/* Teachers -------------------------------------------------- */}
<TabsContent value="teachers" className="mt-3 space-y-2">
<div className="text-xs text-muted-foreground">
Pick the teachers actually teaching this batch. Defaults to the classroom's homeroom
teachers when the batch is created.
<Button
variant="link"
size="sm"
className="px-1 h-auto"
onClick={() => setTeacherDraft(classroomTeacherIds)}
>
reset to homeroom teachers
</Button>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={teacherSearch}
onChange={(e) => setTeacherSearch(e.target.value)}
placeholder="Search teachers…"
className="pl-9"
/>
</div>
<div className="max-h-[40vh] overflow-y-auto space-y-1 border rounded-md p-1">
{teacherCandidates.map((t) => (
<label
key={t.id}
className="flex items-center gap-2 p-2 rounded hover:bg-muted/40 cursor-pointer"
>
<Checkbox
checked={teacherDraft.includes(t.id)}
onCheckedChange={() => toggle<number>(setTeacherDraft)(t.id)}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{t.name}</p>
<p className="text-xs text-muted-foreground">{t.email}</p>
</div>
{classroomTeacherIds.includes(t.id) && (
<Badge variant="outline" className="text-[10px]">homeroom</Badge>
)}
</label>
))}
</div>
<div className="flex justify-end">
<Button
size="sm"
disabled={teacherSaveMut.isPending || teachersQ.isLoading}
onClick={() => teacherSaveMut.mutate()}
>
{teacherSaveMut.isPending && (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
)}
Save Batch Teachers
</Button>
</div>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ════════════════════════════════════════════════════════════════════════
// 4-step workflow guide (cascade order)
// ════════════════════════════════════════════════════════════════════════
interface WorkflowGuideProps {
hasRoster: boolean;
hasTeachers: boolean;
hasCourses: boolean;
hasBatches: boolean;
}
function WorkflowGuide({ hasRoster, hasTeachers, hasCourses, hasBatches }: WorkflowGuideProps) {
const steps: { label: string; done: boolean; hint: string }[] = [
{
label: "Add roster",
done: hasRoster,
hint: "Pick the students belonging to this homeroom",
},
{
label: "Add homeroom teachers",
done: hasTeachers,
hint: "Default teachers used when assigning a course",
},
{
label: "Assign course + section",
done: hasCourses,
hint: "Server creates a batch and auto-enrolls roster + teachers",
},
{
label: "Tune per-batch",
done: hasBatches,
hint: "Override which students/teachers actually attend each batch",
},
];
return (
<div className="rounded-md border bg-muted/30 p-3">
<div className="text-xs font-medium text-muted-foreground mb-2">
Assignment workflow each step cascades to the next:
</div>
<ol className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
{steps.map((s, i) => (
<li
key={s.label}
className="flex items-start gap-2 rounded-md border bg-background p-2"
>
{s.done ? (
<CheckCircle2 className="h-4 w-4 mt-0.5 text-emerald-600 shrink-0" />
) : (
<Circle className="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
)}
<div className="min-w-0">
<div className="text-xs font-medium">
{i + 1}. {s.label}
</div>
<div className="text-[11px] text-muted-foreground leading-snug">
{s.hint}
</div>
</div>
</li>
))}
</ol>
</div>
);
}