Files
full_encoach_platform/frontend/src/pages/admin/AdminCourses.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

1153 lines
43 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, Layers, Sparkles, X } 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, CourseSection } 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 className="flex items-center gap-1">
Difficulty
<span
className="text-[11px] text-muted-foreground cursor-help"
title="General hardness tag shown to students when browsing courses (Beginner / Intermediate / Advanced). Use this to set expectations."
>(?)</span>
</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>
<p className="text-[11px] text-muted-foreground">
A plain-language tag (Beginner / Intermediate / Advanced).
</p>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-1">
CEFR Level
<span
className="text-[11px] text-muted-foreground cursor-help"
title="Common European Framework of Reference level (A1-C2). Used by placement logic, adaptive engine and rubric mapping."
>(?)</span>
</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>
<p className="text-[11px] text-muted-foreground">
Standard proficiency band (A1-C2); drives placement &amp; rubrics.
</p>
</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 ?? "",
};
}
function CourseSectionsDialog({
open,
onOpenChange,
course,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
course: Course;
}) {
const { toast } = useToast();
const qc = useQueryClient();
const [newName, setNewName] = useState("");
const [newCode, setNewCode] = useState("");
const [newSeq, setNewSeq] = useState<number>(10);
const [editing, setEditing] = useState<CourseSection | null>(null);
const [editName, setEditName] = useState("");
const [editCode, setEditCode] = useState("");
const [editSeq, setEditSeq] = useState<number>(10);
const [editActive, setEditActive] = useState(true);
const [busy, setBusy] = useState(false);
const sectionsQ = useQuery({
queryKey: ["course-sections", course.id],
queryFn: () => lmsService.listCourseSections(course.id),
enabled: open,
});
const sections = sectionsQ.data?.items ?? [];
function refreshAll() {
qc.invalidateQueries({ queryKey: ["course-sections", course.id] });
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
qc.invalidateQueries({ queryKey: ["lms-classroom-detail"] });
}
async function handleGenerateDefaults() {
setBusy(true);
try {
const res = await lmsService.generateDefaultCourseSections(course.id);
toast({
title: res.created_count > 0 ? `Generated ${res.created_count} section(s)` : "Defaults already exist",
});
refreshAll();
} catch (e: unknown) {
toast({
title: "Generation failed",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
}
setBusy(false);
}
async function handleAdd() {
const code = newCode.trim().toUpperCase();
const name = newName.trim() || `Section ${code}`;
if (!code) {
toast({ title: "Code is required", variant: "destructive" });
return;
}
setBusy(true);
try {
await lmsService.createCourseSection(course.id, {
name,
code,
sequence: newSeq || 10,
active: true,
});
toast({ title: `Section ${code} added` });
setNewName("");
setNewCode("");
setNewSeq(10);
refreshAll();
} catch (e: unknown) {
toast({
title: "Add failed",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
}
setBusy(false);
}
function startEdit(s: CourseSection) {
setEditing(s);
setEditName(s.name);
setEditCode(s.code);
setEditSeq(s.sequence || 10);
setEditActive(s.active);
}
async function saveEdit() {
if (!editing) return;
setBusy(true);
try {
await lmsService.updateCourseSection(course.id, editing.id, {
name: editName.trim() || editing.name,
code: editCode.trim().toUpperCase() || editing.code,
sequence: editSeq || editing.sequence,
active: editActive,
});
toast({ title: "Section updated" });
setEditing(null);
refreshAll();
} catch (e: unknown) {
toast({
title: "Update failed",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
}
setBusy(false);
}
async function handleDelete(s: CourseSection) {
if ((s.batch_count ?? 0) > 0) {
toast({
title: "Cannot delete",
description: `Section "${s.code}" has ${s.batch_count} batch(es). Reassign or remove them first.`,
variant: "destructive",
});
return;
}
if (!window.confirm(`Delete section "${s.name}" (${s.code})?`)) return;
setBusy(true);
try {
await lmsService.deleteCourseSection(course.id, s.id);
toast({ title: "Section deleted" });
refreshAll();
} catch (e: unknown) {
toast({
title: "Delete failed",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
}
setBusy(false);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[640px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Layers className="h-5 w-5" /> Sections {course.title}
</DialogTitle>
<DialogDescription>
Course sections (A, B, C) are templates. Each section can be hosted by a classroom,
which auto-creates a batch and enrolls the classroom roster.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
<div className="text-sm">
<p className="font-medium">Quick start</p>
<p className="text-xs text-muted-foreground">
Create the standard <strong>A · B · C</strong> sections for this course in one click.
</p>
</div>
<Button
size="sm"
onClick={handleGenerateDefaults}
disabled={busy || sectionsQ.isLoading}
variant="secondary"
>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
Generate A / B / C
</Button>
</div>
<div>
<Label className="text-sm font-medium">Existing sections</Label>
{sectionsQ.isLoading ? (
<div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
</div>
) : sections.length === 0 ? (
<p className="text-sm text-muted-foreground py-3">
No sections yet. Add one below or click <em>Generate A / B / C</em>.
</p>
) : (
<div className="mt-2 border rounded-md divide-y">
{sections.map((s) => (
<div key={s.id} className="flex items-center justify-between gap-3 px-3 py-2">
<div className="flex items-center gap-3 min-w-0">
<Badge variant="outline" className="font-mono">
{s.code}
</Badge>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{s.name}</p>
<p className="text-xs text-muted-foreground">
seq {s.sequence}
{s.batch_count != null && (
<span className="ml-2">· {s.batch_count} batch(es)</span>
)}
{!s.active && <span className="ml-2 text-amber-600">· inactive</span>}
</p>
</div>
</div>
<div className="flex gap-1 shrink-0">
<Button
size="icon"
variant="ghost"
title="Edit section"
onClick={() => startEdit(s)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
title="Delete section"
onClick={() => handleDelete(s)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
<div className="rounded-lg border p-3 space-y-3">
<Label className="text-sm font-medium">Add a new section</Label>
<div className="grid grid-cols-12 gap-2">
<div className="col-span-3">
<Label className="text-[11px] text-muted-foreground">Code *</Label>
<Input
placeholder="A"
value={newCode}
onChange={(e) => setNewCode(e.target.value)}
maxLength={8}
/>
</div>
<div className="col-span-7">
<Label className="text-[11px] text-muted-foreground">Name</Label>
<Input
placeholder="Section A"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
</div>
<div className="col-span-2">
<Label className="text-[11px] text-muted-foreground">Seq</Label>
<Input
type="number"
value={newSeq}
onChange={(e) => setNewSeq(Number(e.target.value) || 10)}
/>
</div>
</div>
<div className="flex justify-end">
<Button size="sm" onClick={handleAdd} disabled={busy || !newCode.trim()}>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Plus className="mr-2 h-4 w-4" />}
Add section
</Button>
</div>
</div>
</div>
{editing && (
<Dialog open={!!editing} onOpenChange={(open) => !open && setEditing(null)}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Pencil className="h-4 w-4" /> Edit section
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<div>
<Label>Code</Label>
<Input
value={editCode}
onChange={(e) => setEditCode(e.target.value)}
maxLength={8}
/>
</div>
<div>
<Label>Sequence</Label>
<Input
type="number"
value={editSeq}
onChange={(e) => setEditSeq(Number(e.target.value) || 10)}
/>
</div>
</div>
<div>
<Label>Name</Label>
<Input value={editName} onChange={(e) => setEditName(e.target.value)} />
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={editActive}
onCheckedChange={(v) => setEditActive(Boolean(v))}
/>
Active
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>
<X className="mr-2 h-4 w-4" />
Cancel
</Button>
<Button onClick={saveEdit} disabled={busy}>
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function AdminCourses() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
const [sectionsCourse, setSectionsCourse] = 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>Sections</TableHead>
<TableHead>Chapters</TableHead>
<TableHead>Enrolled / Cap</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[180px]" />
</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>
<button
type="button"
onClick={() => setSectionsCourse(c)}
className="group flex items-center gap-1 text-xs hover:underline"
title="Manage course sections (A/B/C…)"
>
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">{c.section_count ?? c.sections?.length ?? 0}</span>
<span className="text-muted-foreground">sections</span>
{c.sections && c.sections.length > 0 && (
<span className="ml-1 flex flex-wrap gap-0.5">
{c.sections.slice(0, 4).map((s) => (
<Badge
key={s.id}
variant="outline"
className="text-[10px] px-1 py-0 leading-none"
>
{s.code || s.name}
</Badge>
))}
{c.sections.length > 4 && (
<span className="text-[10px] text-muted-foreground">
+{c.sections.length - 4}
</span>
)}
</span>
)}
</button>
</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="Manage sections (A/B/C…)"
onClick={() => setSectionsCourse(c)}
>
<Layers 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={8} 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}
/>
)}
{sectionsCourse && (
<CourseSectionsDialog
open={!!sectionsCourse}
onOpenChange={(open) => {
if (!open) setSectionsCourse(null);
}}
course={sectionsCourse}
/>
)}
<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>
);
}