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
1340 lines
49 KiB
TypeScript
1340 lines
49 KiB
TypeScript
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>
|
||
);
|
||
}
|