Files
full_encoach_platform/frontend/src/pages/admin/AdminCourses.tsx
Yamen Ahmad 98b9837a54 feat: institutional + support + training admin sections (backend + frontend)
Ship three fully-wired admin areas end-to-end with APIs, seeds, tests and docs.

Backend (new `encoach_lms_api` addon + existing addons):
- Institutional: academic years/terms, departments, admission registers & admissions,
  courses/batches, lessons, fees (terms + student fees + invoicing with income-account
  auto-wiring), gradebook (assignments/grades), library, facilities (encoach.asset),
  student leave, result templates + marksheets (incl. delete-with-cascade).
- Support: `encoach.ticket` model + CRUD/assignee routes; payment records derived
  from `op.student.fees.details` and `account.move`; platform settings backed by
  `encoach.code` and `ir.config_parameter` (packages + grading config).
- Training: `encoach.vocab.item` + `encoach.grammar.rule` (plus progress models)
  with CRUD, pagination, search/level filters, and upsert-style progress endpoints.
  Odoo 19 compatibility: `_sql_constraints` replaced with `@api.constrains`;
  `ValidationError`/`UserError` mapped to HTTP 400.

Frontend:
- Rewire institutional admin pages (Academic Year Manager, Admissions, Courses,
  Lessons, Fees, Gradebook, Library, Facilities, Student Leave, Marksheets,
  Taxonomy, Resources) to real APIs with React Query invalidation and dialogs.
- New typed services: `payments.service.ts`, `platformSettings.service.ts`,
  `training.service.ts`. Updated `fees/gradebook/lms/courseware/taxonomy/
  resources/student-progress/generation` services + related types.
- Rewrite `VocabularyPage`, `GrammarPage`, `PaymentRecordPage`, `SettingsPage`,
  `TicketsPage` to consume live data with search/filter/progress/CRUD flows.
- New shared components: `TaxonomyCascade`, `MaterialViewer`, `teacher/TeacherLibrary`.
- Favicons/branding assets and misc. UX polish across teacher/student pages.

Tooling & QA:
- Seeders: `seed_demo.py`, `seed_demo_data.py`, `seed_institutional.py` (idempotent,
  covers institutional + support + training fixtures incl. income-account wiring).
- API write-flow test suites: `test_write_flows.py` (institutional),
  `test_support_flows.py` (support), `test_training_flows.py` (training),
  `test_ai_full.py`. All suites pass end-to-end.
- Docs: add `docs/PROJECT_SUMMARY.md` with per-section scope, artifacts and QA.
- `.gitignore`: ignore `pgdata_bak_*/`, `frontend/.vite/`, `frontend/dist/`,
  `frontend/node_modules/`.

Made-with: Cursor
2026-04-19 03:13:23 +04:00

761 lines
28 KiB
TypeScript

import { useState, useEffect } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCourses, useCreateCourse, useStudents, useBulkEnroll, useBatches } from "@/hooks/queries";
import { lmsService, taxonomyService, resourcesService } from "@/services";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Search, Plus, Trash2, UserPlus, FileEdit, Pencil, BookOpen, Target, Loader2, Users, GraduationCap } from "lucide-react";
import { Link } from "react-router-dom";
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
import AiTipBanner from "@/components/ai/AiTipBanner";
import { useToast } from "@/hooks/use-toast";
import type { Course, CourseCreateRequest } from "@/types";
import type { ResourceTag } from "@/types/adaptive";
const DIFFICULTY_OPTIONS = [
{ value: "beginner", label: "Beginner" },
{ value: "intermediate", label: "Intermediate" },
{ value: "advanced", label: "Advanced" },
];
const CEFR_OPTIONS = [
{ value: "pre_a1", label: "Pre-A1" },
{ value: "a1", label: "A1" },
{ value: "a2", label: "A2" },
{ value: "b1", label: "B1" },
{ value: "b2", label: "B2" },
{ value: "c1", label: "C1" },
{ value: "c2", label: "C2" },
];
interface CourseFormData {
title: string;
code: string;
description: string;
max_capacity: number;
encoach_subject_id: number | null;
topic_ids: number[];
learning_objective_ids: number[];
tag_ids: number[];
difficulty_level: string;
cefr_level: string;
}
const emptyForm: CourseFormData = {
title: "",
code: "",
description: "",
max_capacity: 30,
encoach_subject_id: null,
topic_ids: [],
learning_objective_ids: [],
tag_ids: [],
difficulty_level: "",
cefr_level: "",
};
function CourseFormDialog({
open,
onOpenChange,
initialData,
courseId,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
initialData?: CourseFormData;
courseId?: number;
}) {
const { toast } = useToast();
const qc = useQueryClient();
const createMut = useCreateCourse();
const [form, setForm] = useState<CourseFormData>(initialData ?? emptyForm);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (open) {
setForm(initialData ?? emptyForm);
}
}, [open, initialData]);
const { data: subjects = [] } = useQuery({
queryKey: ["taxonomy", "subjects"],
queryFn: () => taxonomyService.listSubjects(),
});
const { data: topicsData } = useQuery({
queryKey: ["taxonomy", "topics", form.encoach_subject_id],
queryFn: () =>
form.encoach_subject_id
? taxonomyService.listTopics({ subject_id: form.encoach_subject_id })
: Promise.resolve([]),
enabled: !!form.encoach_subject_id,
});
const topics = topicsData ?? [];
const { data: objectivesData } = useQuery({
queryKey: ["lms", "objectives", form.topic_ids.join(",")],
queryFn: () =>
form.topic_ids.length > 0
? lmsService.listLearningObjectives({ topic_ids: form.topic_ids.join(",") })
: Promise.resolve({ items: [], total: 0 }),
enabled: form.topic_ids.length > 0,
});
const objectives = objectivesData?.items ?? [];
const { data: allTags = [] } = useQuery({
queryKey: ["resource-tags"],
queryFn: () => resourcesService.listTags(),
});
function handleSubjectChange(val: string) {
const sid = val === "none" ? null : Number(val);
setForm((f) => ({
...f,
encoach_subject_id: sid,
topic_ids: [],
learning_objective_ids: [],
}));
}
function toggleTopic(id: number) {
setForm((f) => {
const next = f.topic_ids.includes(id)
? f.topic_ids.filter((t) => t !== id)
: [...f.topic_ids, id];
const validObjectiveTopics = new Set(
topics.filter((t) => next.includes(t.id)).flatMap((t) => [t.id])
);
return {
...f,
topic_ids: next,
learning_objective_ids: f.learning_objective_ids.filter((oid) =>
objectives.some(
(o) => o.id === oid && validObjectiveTopics.has(o.topic_id)
)
),
};
});
}
function toggleObjective(id: number) {
setForm((f) => ({
...f,
learning_objective_ids: f.learning_objective_ids.includes(id)
? f.learning_objective_ids.filter((o) => o !== id)
: [...f.learning_objective_ids, id],
}));
}
function toggleTag(id: number) {
setForm((f) => ({
...f,
tag_ids: f.tag_ids.includes(id)
? f.tag_ids.filter((t) => t !== id)
: [...f.tag_ids, id],
}));
}
async function handleSave() {
if (!form.title.trim()) return;
const payload: Partial<CourseCreateRequest> = {
title: form.title.trim(),
code:
form.code.trim() ||
form.title.trim().toUpperCase().replace(/\s+/g, "-").slice(0, 16),
description: form.description,
max_capacity: form.max_capacity,
encoach_subject_id: form.encoach_subject_id ?? undefined,
topic_ids: form.topic_ids,
learning_objective_ids: form.learning_objective_ids,
tag_ids: form.tag_ids,
difficulty_level: (form.difficulty_level as CourseCreateRequest["difficulty_level"]) || undefined,
cefr_level: form.cefr_level || undefined,
};
if (courseId) {
setSaving(true);
try {
await lmsService.updateCourse(courseId, payload);
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
toast({ title: "Course updated" });
onOpenChange(false);
} catch (e: unknown) {
toast({ title: "Error", description: String(e instanceof Error ? e.message : e), variant: "destructive" });
}
setSaving(false);
} else {
createMut.mutate(payload as CourseCreateRequest, {
onSuccess: () => {
onOpenChange(false);
toast({ title: "Course created" });
},
onError: (e) =>
toast({ title: "Error", description: String(e.message || e), variant: "destructive" }),
});
}
}
const isPending = saving || createMut.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{courseId ? "Edit Course" : "Create New Course"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Title *</Label>
<Input
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
placeholder="e.g. IELTS Academic Preparation"
/>
</div>
<div className="space-y-2">
<Label>Code</Label>
<Input
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
placeholder="Auto-generated if empty"
/>
</div>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
rows={2}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Max Capacity</Label>
<Input
type="number"
value={form.max_capacity}
onChange={(e) => setForm((f) => ({ ...f, max_capacity: Number(e.target.value) || 30 }))}
/>
</div>
<div className="space-y-2">
<Label>Difficulty</Label>
<Select
value={form.difficulty_level || "none"}
onValueChange={(v) => setForm((f) => ({ ...f, difficulty_level: v === "none" ? "" : v }))}
>
<SelectTrigger><SelectValue placeholder="Select..." /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{DIFFICULTY_OPTIONS.map((d) => (
<SelectItem key={d.value} value={d.value}>{d.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>CEFR Level</Label>
<Select
value={form.cefr_level || "none"}
onValueChange={(v) => setForm((f) => ({ ...f, cefr_level: v === "none" ? "" : v }))}
>
<SelectTrigger><SelectValue placeholder="Select..." /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{CEFR_OPTIONS.map((c) => (
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Subject</Label>
<Select
value={form.encoach_subject_id ? String(form.encoach_subject_id) : "none"}
onValueChange={handleSubjectChange}
>
<SelectTrigger><SelectValue placeholder="Select subject..." /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{subjects.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{form.encoach_subject_id && topics.length > 0 && (
<div className="space-y-2">
<Label>Topics ({form.topic_ids.length} selected)</Label>
<div className="border rounded-md p-3 max-h-[140px] overflow-y-auto space-y-1">
{topics.map((t) => (
<label key={t.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 p-1 rounded">
<Checkbox
checked={form.topic_ids.includes(t.id)}
onCheckedChange={() => toggleTopic(t.id)}
/>
<span>{t.name}</span>
<span className="text-xs text-muted-foreground ml-auto">{t.domain_name}</span>
</label>
))}
</div>
</div>
)}
{form.topic_ids.length > 0 && objectives.length > 0 && (
<div className="space-y-2">
<Label>Learning Objectives ({form.learning_objective_ids.length} selected)</Label>
<div className="border rounded-md p-3 max-h-[140px] overflow-y-auto space-y-1">
{objectives.map((o) => (
<label key={o.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 p-1 rounded">
<Checkbox
checked={form.learning_objective_ids.includes(o.id)}
onCheckedChange={() => toggleObjective(o.id)}
/>
<span>{o.name}</span>
{o.bloom_level && (
<Badge variant="outline" className="text-[10px] ml-auto capitalize">
{o.bloom_level}
</Badge>
)}
</label>
))}
</div>
</div>
)}
{allTags.length > 0 && (
<div className="space-y-2">
<Label>Tags</Label>
<div className="flex flex-wrap gap-2">
{allTags.map((t: ResourceTag) => (
<Badge
key={t.id}
variant={form.tag_ids.includes(t.id) ? "default" : "outline"}
className="cursor-pointer select-none transition-colors"
style={
form.tag_ids.includes(t.id)
? { backgroundColor: t.color, borderColor: t.color, color: "#fff" }
: { borderColor: t.color, color: t.color }
}
onClick={() => toggleTag(t.id)}
>
{t.name}
</Badge>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={isPending || !form.title.trim()}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{courseId ? "Save Changes" : "Create Course"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function courseToFormData(c: Course): CourseFormData {
return {
title: c.title,
code: c.code,
description: c.description,
max_capacity: c.max_capacity,
encoach_subject_id: c.encoach_subject_id ?? null,
topic_ids: c.topic_ids ?? [],
learning_objective_ids: c.learning_objective_ids ?? [],
tag_ids: c.tag_ids ?? [],
difficulty_level: c.difficulty_level ?? "",
cefr_level: c.cefr_level ?? "",
};
}
export default function AdminCourses() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
const [enrollOpen, setEnrollOpen] = useState(false);
const [enrollCourseId, setEnrollCourseId] = useState<number | null>(null);
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
const [enrollTab, setEnrollTab] = useState<"students" | "classroom">("students");
const [selectedBatchId, setSelectedBatchId] = useState<number | null>(null);
const { data: coursesData, isLoading } = useCourses();
const { data: studentsData } = useStudents({ size: 200 });
const { data: batchesData } = useBatches({ size: 200 });
const enrollMut = useBulkEnroll();
const qc = useQueryClient();
const { toast } = useToast();
const courses = coursesData?.items ?? [];
const allStudents = studentsData?.items ?? [];
const allBatches = batchesData?.items ?? [];
if (isLoading)
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
const filtered = courses.filter((c) =>
c.title.toLowerCase().includes(search.toLowerCase())
);
function openEnrollDialog(courseId: number) {
setEnrollCourseId(courseId);
setSelectedStudentIds([]);
setSelectedBatchId(null);
setEnrollTab("students");
setEnrollOpen(true);
}
function handleEnroll() {
if (!enrollCourseId) return;
if (enrollTab === "classroom" && selectedBatchId) {
enrollMut.mutate(
{ courseId: enrollCourseId, data: { batch_id: selectedBatchId } },
{
onSuccess: (res) => {
toast({ title: `Enrolled ${res.total_enrolled} student(s) from classroom` });
setEnrollOpen(false);
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
},
onError: (e) =>
toast({ title: "Enrollment failed", description: String(e.message || e), variant: "destructive" }),
},
);
} else if (enrollTab === "students" && selectedStudentIds.length > 0) {
enrollMut.mutate(
{ courseId: enrollCourseId, data: { student_ids: selectedStudentIds } },
{
onSuccess: (res) => {
toast({ title: `Enrolled ${res.total_enrolled} student(s)` });
setEnrollOpen(false);
},
onError: (e) =>
toast({ title: "Enrollment failed", description: String(e.message || e), variant: "destructive" }),
},
);
}
}
function toggleStudentSelection(sid: number) {
setSelectedStudentIds((prev) =>
prev.includes(sid) ? prev.filter((x) => x !== sid) : [...prev, sid]
);
}
const canEnroll =
(enrollTab === "students" && selectedStudentIds.length > 0) ||
(enrollTab === "classroom" && selectedBatchId !== null);
const enrollLabel =
enrollTab === "classroom"
? `Enroll Classroom${selectedBatchId ? ` (${allBatches.find((b) => b.id === selectedBatchId)?.student_count ?? 0} students)` : ""}`
: `Enroll ${selectedStudentIds.length} Student(s)`;
async function handleDelete(id: number, title: string) {
if (!window.confirm(`Delete course "${title}"?`)) return;
try {
await lmsService.deleteCourse(id);
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
toast({ title: "Course deleted" });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
toast({ title: "Delete failed", description: msg, variant: "destructive" });
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Courses</h1>
<p className="text-muted-foreground">Manage all platform courses.</p>
</div>
<div className="flex gap-2">
<AiCreationAssistant type="course" />
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New Course
</Button>
</div>
</div>
<AiTipBanner context="admin-courses" variant="recommendation" />
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search courses..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Course</TableHead>
<TableHead>Subject / Tags</TableHead>
<TableHead>Difficulty</TableHead>
<TableHead>Chapters</TableHead>
<TableHead>Enrolled / Cap</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[150px]" />
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((c) => (
<TableRow key={c.id}>
<TableCell>
<div>
<span className="font-medium">{c.title}</span>
<span className="text-xs text-muted-foreground ml-2">{c.code}</span>
</div>
{c.topic_names && c.topic_names.length > 0 && (
<div className="text-xs text-muted-foreground mt-0.5">
{c.topic_names.slice(0, 3).join(", ")}
{c.topic_names.length > 3 && ` +${c.topic_names.length - 3}`}
</div>
)}
</TableCell>
<TableCell>
<div className="space-y-1">
{c.encoach_subject_name && (
<div className="flex items-center gap-1 text-xs">
<BookOpen className="h-3 w-3" />
{c.encoach_subject_name}
</div>
)}
<div className="flex flex-wrap gap-1">
{(c.tags ?? []).slice(0, 3).map((t) => (
<Badge
key={t.id}
variant="outline"
className="text-[10px] px-1.5 py-0"
style={{ borderColor: t.color, color: t.color }}
>
{t.name}
</Badge>
))}
</div>
</div>
</TableCell>
<TableCell>
{c.difficulty_level && (
<Badge variant="outline" className="capitalize text-xs">
{c.difficulty_level}
</Badge>
)}
{c.cefr_level && (
<Badge variant="secondary" className="ml-1 text-xs uppercase">
{c.cefr_level}
</Badge>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-sm">
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
{c.chapter_count ?? 0}
{(c.objective_count ?? 0) > 0 && (
<span className="text-xs text-muted-foreground ml-1">
<Target className="inline h-3 w-3 mr-0.5" />
{c.objective_count}
</span>
)}
</div>
</TableCell>
<TableCell>
{c.enrolled} / {c.max_capacity}
</TableCell>
<TableCell>
<Badge
variant={c.status === "active" ? "default" : "secondary"}
className="capitalize"
>
{c.status}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
title="Edit course"
onClick={() => setEditingCourse(c)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Enroll students"
onClick={() => openEnrollDialog(c.id)}
>
<UserPlus className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" title="Assign exam" asChild>
<Link to={`/admin/generation?course_id=${c.id}`}>
<FileEdit className="h-4 w-4" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(c.id, c.title)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
No courses found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<CourseFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
/>
{editingCourse && (
<CourseFormDialog
open={!!editingCourse}
onOpenChange={(open) => {
if (!open) setEditingCourse(null);
}}
initialData={courseToFormData(editingCourse)}
courseId={editingCourse.id}
/>
)}
<Dialog open={enrollOpen} onOpenChange={setEnrollOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Enroll Students</DialogTitle>
<DialogDescription>
Enroll in: <strong>{courses.find((c) => c.id === enrollCourseId)?.title}</strong>
</DialogDescription>
</DialogHeader>
<Tabs value={enrollTab} onValueChange={(v) => setEnrollTab(v as "students" | "classroom")}>
<TabsList className="w-full">
<TabsTrigger value="students" className="flex-1 gap-1.5">
<UserPlus className="h-3.5 w-3.5" /> Individual Students
</TabsTrigger>
<TabsTrigger value="classroom" className="flex-1 gap-1.5">
<GraduationCap className="h-3.5 w-3.5" /> By Classroom
</TabsTrigger>
</TabsList>
<TabsContent value="students" className="mt-3">
<div className="max-h-[320px] overflow-y-auto space-y-1">
{allStudents.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No students found.</p>
) : (
allStudents.map((s) => (
<label key={s.id} className="flex items-center gap-3 p-2 rounded hover:bg-muted/50 cursor-pointer">
<Checkbox
checked={selectedStudentIds.includes(s.id)}
onCheckedChange={() => toggleStudentSelection(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>
</TabsContent>
<TabsContent value="classroom" className="mt-3">
<div className="max-h-[320px] overflow-y-auto space-y-2">
{allBatches.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No classrooms found. Create one on the Classrooms page first.
</p>
) : (
allBatches.map((b) => (
<label
key={b.id}
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedBatchId === b.id
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/50"
}`}
onClick={() => setSelectedBatchId(selectedBatchId === b.id ? null : b.id)}
>
<input
type="radio"
name="batch"
checked={selectedBatchId === b.id}
onChange={() => setSelectedBatchId(b.id)}
className="accent-primary"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{b.name}</p>
<p className="text-xs text-muted-foreground">
{b.course_name && <span>{b.course_name} · </span>}
{b.start_date && <span>{b.start_date} {b.end_date}</span>}
</p>
</div>
<Badge variant="secondary" className="shrink-0">
<Users className="h-3 w-3 mr-1" />{b.student_count} students
</Badge>
</label>
))
)}
</div>
{selectedBatchId && (
<p className="text-xs text-muted-foreground mt-2">
All students in this classroom will be enrolled in the course with the classroom linked.
</p>
)}
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => setEnrollOpen(false)}>Cancel</Button>
<Button onClick={handleEnroll} disabled={enrollMut.isPending || !canEnroll}>
{enrollMut.isPending ? "Enrolling..." : enrollLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}