Addresses the QA notes on the end-to-end approval flow. Highlights:
Backend
- encoach_ai/generation_submit: create encoach.approval.request on submit so
submitted exams actually reach the approver queue (previously only the
workflow id was stored on the exam, leaving approvers with an empty inbox).
Initial exam status flips to pending_approval instead of draft.
- encoach_exam_template/approval_workflows:
* /api/approval-users filters out students (user_type='student',
op_student_id set, share=True) so the approver dropdown only lists staff.
* _ser_request enriches requests with target_name / target_status and the
current stage approver for UI badges.
* list_requests supports mine=1 / requester=1 so approvers only see queue
items awaiting their action.
* approve_request / reject_request now transition the underlying
encoach.exam.custom to published / rejected on final approval.
- encoach.exam.custom.status: add pending_approval and rejected states to
match the approval workflow transitions.
Frontend UX
- GenerationPage: rubric field shows "Auto-graded — no rubric" for Listening
and Reading (rubrics only apply to Writing / Speaking). Divider dropdowns
now carry explicit help text explaining None / Line / Space / Page Break
and where to write prompts. Selecting an official exam structure
auto-populates the required tasks/sections/parts, the delete button is
hidden for essential tasks, and submit is blocked if the user dropped
below the structure's required count.
- RubricsPage: "Add Criterion" is now a dropdown with IELTS-specific
presets (Task Achievement, Coherence & Cohesion, Fluency & Coherence, …)
keyed off the selected skill, plus a "Custom (blank row)" fallback.
- AdminCourses: added tooltips and helper copy clarifying Difficulty vs
CEFR Level in the Create Course dialog.
- CustomExamCreate: validate the whole form before Save/Publish, surface
backend error messages (413/504/401) instead of a generic toast, read the
exam id from the correct response field, and avoid marking Publish as
failed when only the optional Save-as-Template step errors.
- ResourceManager / TeacherLibrary: upload toast now shows a concrete
reason (HTTP 413 / 504 / 401) instead of a generic "Upload failed".
Config (504 / 413 / resource upload fixes)
- odoo.conf + odoo-docker.conf: raise limit_time_cpu to 600s,
limit_time_real to 900s, limit_request to 128 MB, and memory caps so
/api/exam/generation/submit and multipart resource uploads stop hitting
504 / 413 during QA.
- frontend/Dockerfile (nginx): add client_max_body_size 128m, bump
proxy_read_timeout / proxy_send_timeout to 900s, and disable request
buffering so large AI/resource payloads stream through the proxy.
Made-with: Cursor
779 lines
29 KiB
TypeScript
779 lines
29 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 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 ?? "",
|
|
};
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|