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
1153 lines
43 KiB
TypeScript
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 & 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>
|
|
);
|
|
}
|