Ship three fully-wired admin areas end-to-end with APIs, seeds, tests and docs. Backend (new `encoach_lms_api` addon + existing addons): - Institutional: academic years/terms, departments, admission registers & admissions, courses/batches, lessons, fees (terms + student fees + invoicing with income-account auto-wiring), gradebook (assignments/grades), library, facilities (encoach.asset), student leave, result templates + marksheets (incl. delete-with-cascade). - Support: `encoach.ticket` model + CRUD/assignee routes; payment records derived from `op.student.fees.details` and `account.move`; platform settings backed by `encoach.code` and `ir.config_parameter` (packages + grading config). - Training: `encoach.vocab.item` + `encoach.grammar.rule` (plus progress models) with CRUD, pagination, search/level filters, and upsert-style progress endpoints. Odoo 19 compatibility: `_sql_constraints` replaced with `@api.constrains`; `ValidationError`/`UserError` mapped to HTTP 400. Frontend: - Rewire institutional admin pages (Academic Year Manager, Admissions, Courses, Lessons, Fees, Gradebook, Library, Facilities, Student Leave, Marksheets, Taxonomy, Resources) to real APIs with React Query invalidation and dialogs. - New typed services: `payments.service.ts`, `platformSettings.service.ts`, `training.service.ts`. Updated `fees/gradebook/lms/courseware/taxonomy/ resources/student-progress/generation` services + related types. - Rewrite `VocabularyPage`, `GrammarPage`, `PaymentRecordPage`, `SettingsPage`, `TicketsPage` to consume live data with search/filter/progress/CRUD flows. - New shared components: `TaxonomyCascade`, `MaterialViewer`, `teacher/TeacherLibrary`. - Favicons/branding assets and misc. UX polish across teacher/student pages. Tooling & QA: - Seeders: `seed_demo.py`, `seed_demo_data.py`, `seed_institutional.py` (idempotent, covers institutional + support + training fixtures incl. income-account wiring). - API write-flow test suites: `test_write_flows.py` (institutional), `test_support_flows.py` (support), `test_training_flows.py` (training), `test_ai_full.py`. All suites pass end-to-end. - Docs: add `docs/PROJECT_SUMMARY.md` with per-section scope, artifacts and QA. - `.gitignore`: ignore `pgdata_bak_*/`, `frontend/.vite/`, `frontend/dist/`, `frontend/node_modules/`. Made-with: Cursor
761 lines
28 KiB
TypeScript
761 lines
28 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { useCourses, useCreateCourse, useStudents, useBulkEnroll, useBatches } from "@/hooks/queries";
|
|
import { lmsService, taxonomyService, resourcesService } from "@/services";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Search, Plus, Trash2, UserPlus, FileEdit, Pencil, BookOpen, Target, Loader2, Users, GraduationCap } from "lucide-react";
|
|
import { Link } from "react-router-dom";
|
|
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import type { Course, CourseCreateRequest } from "@/types";
|
|
import type { ResourceTag } from "@/types/adaptive";
|
|
|
|
const DIFFICULTY_OPTIONS = [
|
|
{ value: "beginner", label: "Beginner" },
|
|
{ value: "intermediate", label: "Intermediate" },
|
|
{ value: "advanced", label: "Advanced" },
|
|
];
|
|
|
|
const CEFR_OPTIONS = [
|
|
{ value: "pre_a1", label: "Pre-A1" },
|
|
{ value: "a1", label: "A1" },
|
|
{ value: "a2", label: "A2" },
|
|
{ value: "b1", label: "B1" },
|
|
{ value: "b2", label: "B2" },
|
|
{ value: "c1", label: "C1" },
|
|
{ value: "c2", label: "C2" },
|
|
];
|
|
|
|
interface CourseFormData {
|
|
title: string;
|
|
code: string;
|
|
description: string;
|
|
max_capacity: number;
|
|
encoach_subject_id: number | null;
|
|
topic_ids: number[];
|
|
learning_objective_ids: number[];
|
|
tag_ids: number[];
|
|
difficulty_level: string;
|
|
cefr_level: string;
|
|
}
|
|
|
|
const emptyForm: CourseFormData = {
|
|
title: "",
|
|
code: "",
|
|
description: "",
|
|
max_capacity: 30,
|
|
encoach_subject_id: null,
|
|
topic_ids: [],
|
|
learning_objective_ids: [],
|
|
tag_ids: [],
|
|
difficulty_level: "",
|
|
cefr_level: "",
|
|
};
|
|
|
|
function CourseFormDialog({
|
|
open,
|
|
onOpenChange,
|
|
initialData,
|
|
courseId,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
initialData?: CourseFormData;
|
|
courseId?: number;
|
|
}) {
|
|
const { toast } = useToast();
|
|
const qc = useQueryClient();
|
|
const createMut = useCreateCourse();
|
|
const [form, setForm] = useState<CourseFormData>(initialData ?? emptyForm);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setForm(initialData ?? emptyForm);
|
|
}
|
|
}, [open, initialData]);
|
|
|
|
const { data: subjects = [] } = useQuery({
|
|
queryKey: ["taxonomy", "subjects"],
|
|
queryFn: () => taxonomyService.listSubjects(),
|
|
});
|
|
|
|
const { data: topicsData } = useQuery({
|
|
queryKey: ["taxonomy", "topics", form.encoach_subject_id],
|
|
queryFn: () =>
|
|
form.encoach_subject_id
|
|
? taxonomyService.listTopics({ subject_id: form.encoach_subject_id })
|
|
: Promise.resolve([]),
|
|
enabled: !!form.encoach_subject_id,
|
|
});
|
|
const topics = topicsData ?? [];
|
|
|
|
const { data: objectivesData } = useQuery({
|
|
queryKey: ["lms", "objectives", form.topic_ids.join(",")],
|
|
queryFn: () =>
|
|
form.topic_ids.length > 0
|
|
? lmsService.listLearningObjectives({ topic_ids: form.topic_ids.join(",") })
|
|
: Promise.resolve({ items: [], total: 0 }),
|
|
enabled: form.topic_ids.length > 0,
|
|
});
|
|
const objectives = objectivesData?.items ?? [];
|
|
|
|
const { data: allTags = [] } = useQuery({
|
|
queryKey: ["resource-tags"],
|
|
queryFn: () => resourcesService.listTags(),
|
|
});
|
|
|
|
function handleSubjectChange(val: string) {
|
|
const sid = val === "none" ? null : Number(val);
|
|
setForm((f) => ({
|
|
...f,
|
|
encoach_subject_id: sid,
|
|
topic_ids: [],
|
|
learning_objective_ids: [],
|
|
}));
|
|
}
|
|
|
|
function toggleTopic(id: number) {
|
|
setForm((f) => {
|
|
const next = f.topic_ids.includes(id)
|
|
? f.topic_ids.filter((t) => t !== id)
|
|
: [...f.topic_ids, id];
|
|
const validObjectiveTopics = new Set(
|
|
topics.filter((t) => next.includes(t.id)).flatMap((t) => [t.id])
|
|
);
|
|
return {
|
|
...f,
|
|
topic_ids: next,
|
|
learning_objective_ids: f.learning_objective_ids.filter((oid) =>
|
|
objectives.some(
|
|
(o) => o.id === oid && validObjectiveTopics.has(o.topic_id)
|
|
)
|
|
),
|
|
};
|
|
});
|
|
}
|
|
|
|
function toggleObjective(id: number) {
|
|
setForm((f) => ({
|
|
...f,
|
|
learning_objective_ids: f.learning_objective_ids.includes(id)
|
|
? f.learning_objective_ids.filter((o) => o !== id)
|
|
: [...f.learning_objective_ids, id],
|
|
}));
|
|
}
|
|
|
|
function toggleTag(id: number) {
|
|
setForm((f) => ({
|
|
...f,
|
|
tag_ids: f.tag_ids.includes(id)
|
|
? f.tag_ids.filter((t) => t !== id)
|
|
: [...f.tag_ids, id],
|
|
}));
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (!form.title.trim()) return;
|
|
const payload: Partial<CourseCreateRequest> = {
|
|
title: form.title.trim(),
|
|
code:
|
|
form.code.trim() ||
|
|
form.title.trim().toUpperCase().replace(/\s+/g, "-").slice(0, 16),
|
|
description: form.description,
|
|
max_capacity: form.max_capacity,
|
|
encoach_subject_id: form.encoach_subject_id ?? undefined,
|
|
topic_ids: form.topic_ids,
|
|
learning_objective_ids: form.learning_objective_ids,
|
|
tag_ids: form.tag_ids,
|
|
difficulty_level: (form.difficulty_level as CourseCreateRequest["difficulty_level"]) || undefined,
|
|
cefr_level: form.cefr_level || undefined,
|
|
};
|
|
|
|
if (courseId) {
|
|
setSaving(true);
|
|
try {
|
|
await lmsService.updateCourse(courseId, payload);
|
|
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
|
|
toast({ title: "Course updated" });
|
|
onOpenChange(false);
|
|
} catch (e: unknown) {
|
|
toast({ title: "Error", description: String(e instanceof Error ? e.message : e), variant: "destructive" });
|
|
}
|
|
setSaving(false);
|
|
} else {
|
|
createMut.mutate(payload as CourseCreateRequest, {
|
|
onSuccess: () => {
|
|
onOpenChange(false);
|
|
toast({ title: "Course created" });
|
|
},
|
|
onError: (e) =>
|
|
toast({ title: "Error", description: String(e.message || e), variant: "destructive" }),
|
|
});
|
|
}
|
|
}
|
|
|
|
const isPending = saving || createMut.isPending;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-[600px] max-h-[85vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{courseId ? "Edit Course" : "Create New Course"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-2">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Title *</Label>
|
|
<Input
|
|
value={form.title}
|
|
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
|
placeholder="e.g. IELTS Academic Preparation"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Code</Label>
|
|
<Input
|
|
value={form.code}
|
|
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
|
|
placeholder="Auto-generated if empty"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Description</Label>
|
|
<Textarea
|
|
value={form.description}
|
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Max Capacity</Label>
|
|
<Input
|
|
type="number"
|
|
value={form.max_capacity}
|
|
onChange={(e) => setForm((f) => ({ ...f, max_capacity: Number(e.target.value) || 30 }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Difficulty</Label>
|
|
<Select
|
|
value={form.difficulty_level || "none"}
|
|
onValueChange={(v) => setForm((f) => ({ ...f, difficulty_level: v === "none" ? "" : v }))}
|
|
>
|
|
<SelectTrigger><SelectValue placeholder="Select..." /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">None</SelectItem>
|
|
{DIFFICULTY_OPTIONS.map((d) => (
|
|
<SelectItem key={d.value} value={d.value}>{d.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>CEFR Level</Label>
|
|
<Select
|
|
value={form.cefr_level || "none"}
|
|
onValueChange={(v) => setForm((f) => ({ ...f, cefr_level: v === "none" ? "" : v }))}
|
|
>
|
|
<SelectTrigger><SelectValue placeholder="Select..." /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">None</SelectItem>
|
|
{CEFR_OPTIONS.map((c) => (
|
|
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Subject</Label>
|
|
<Select
|
|
value={form.encoach_subject_id ? String(form.encoach_subject_id) : "none"}
|
|
onValueChange={handleSubjectChange}
|
|
>
|
|
<SelectTrigger><SelectValue placeholder="Select subject..." /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">None</SelectItem>
|
|
{subjects.map((s) => (
|
|
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{form.encoach_subject_id && topics.length > 0 && (
|
|
<div className="space-y-2">
|
|
<Label>Topics ({form.topic_ids.length} selected)</Label>
|
|
<div className="border rounded-md p-3 max-h-[140px] overflow-y-auto space-y-1">
|
|
{topics.map((t) => (
|
|
<label key={t.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 p-1 rounded">
|
|
<Checkbox
|
|
checked={form.topic_ids.includes(t.id)}
|
|
onCheckedChange={() => toggleTopic(t.id)}
|
|
/>
|
|
<span>{t.name}</span>
|
|
<span className="text-xs text-muted-foreground ml-auto">{t.domain_name}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{form.topic_ids.length > 0 && objectives.length > 0 && (
|
|
<div className="space-y-2">
|
|
<Label>Learning Objectives ({form.learning_objective_ids.length} selected)</Label>
|
|
<div className="border rounded-md p-3 max-h-[140px] overflow-y-auto space-y-1">
|
|
{objectives.map((o) => (
|
|
<label key={o.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 p-1 rounded">
|
|
<Checkbox
|
|
checked={form.learning_objective_ids.includes(o.id)}
|
|
onCheckedChange={() => toggleObjective(o.id)}
|
|
/>
|
|
<span>{o.name}</span>
|
|
{o.bloom_level && (
|
|
<Badge variant="outline" className="text-[10px] ml-auto capitalize">
|
|
{o.bloom_level}
|
|
</Badge>
|
|
)}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{allTags.length > 0 && (
|
|
<div className="space-y-2">
|
|
<Label>Tags</Label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{allTags.map((t: ResourceTag) => (
|
|
<Badge
|
|
key={t.id}
|
|
variant={form.tag_ids.includes(t.id) ? "default" : "outline"}
|
|
className="cursor-pointer select-none transition-colors"
|
|
style={
|
|
form.tag_ids.includes(t.id)
|
|
? { backgroundColor: t.color, borderColor: t.color, color: "#fff" }
|
|
: { borderColor: t.color, color: t.color }
|
|
}
|
|
onClick={() => toggleTag(t.id)}
|
|
>
|
|
{t.name}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
|
<Button onClick={handleSave} disabled={isPending || !form.title.trim()}>
|
|
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{courseId ? "Save Changes" : "Create Course"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function courseToFormData(c: Course): CourseFormData {
|
|
return {
|
|
title: c.title,
|
|
code: c.code,
|
|
description: c.description,
|
|
max_capacity: c.max_capacity,
|
|
encoach_subject_id: c.encoach_subject_id ?? null,
|
|
topic_ids: c.topic_ids ?? [],
|
|
learning_objective_ids: c.learning_objective_ids ?? [],
|
|
tag_ids: c.tag_ids ?? [],
|
|
difficulty_level: c.difficulty_level ?? "",
|
|
cefr_level: c.cefr_level ?? "",
|
|
};
|
|
}
|
|
|
|
export default function AdminCourses() {
|
|
const [search, setSearch] = useState("");
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
|
|
const [enrollOpen, setEnrollOpen] = useState(false);
|
|
const [enrollCourseId, setEnrollCourseId] = useState<number | null>(null);
|
|
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
|
|
const [enrollTab, setEnrollTab] = useState<"students" | "classroom">("students");
|
|
const [selectedBatchId, setSelectedBatchId] = useState<number | null>(null);
|
|
const { data: coursesData, isLoading } = useCourses();
|
|
const { data: studentsData } = useStudents({ size: 200 });
|
|
const { data: batchesData } = useBatches({ size: 200 });
|
|
const enrollMut = useBulkEnroll();
|
|
const qc = useQueryClient();
|
|
const { toast } = useToast();
|
|
|
|
const courses = coursesData?.items ?? [];
|
|
const allStudents = studentsData?.items ?? [];
|
|
const allBatches = batchesData?.items ?? [];
|
|
|
|
if (isLoading)
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[400px]">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
|
</div>
|
|
);
|
|
|
|
const filtered = courses.filter((c) =>
|
|
c.title.toLowerCase().includes(search.toLowerCase())
|
|
);
|
|
|
|
function openEnrollDialog(courseId: number) {
|
|
setEnrollCourseId(courseId);
|
|
setSelectedStudentIds([]);
|
|
setSelectedBatchId(null);
|
|
setEnrollTab("students");
|
|
setEnrollOpen(true);
|
|
}
|
|
|
|
function handleEnroll() {
|
|
if (!enrollCourseId) return;
|
|
|
|
if (enrollTab === "classroom" && selectedBatchId) {
|
|
enrollMut.mutate(
|
|
{ courseId: enrollCourseId, data: { batch_id: selectedBatchId } },
|
|
{
|
|
onSuccess: (res) => {
|
|
toast({ title: `Enrolled ${res.total_enrolled} student(s) from classroom` });
|
|
setEnrollOpen(false);
|
|
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
|
},
|
|
onError: (e) =>
|
|
toast({ title: "Enrollment failed", description: String(e.message || e), variant: "destructive" }),
|
|
},
|
|
);
|
|
} else if (enrollTab === "students" && selectedStudentIds.length > 0) {
|
|
enrollMut.mutate(
|
|
{ courseId: enrollCourseId, data: { student_ids: selectedStudentIds } },
|
|
{
|
|
onSuccess: (res) => {
|
|
toast({ title: `Enrolled ${res.total_enrolled} student(s)` });
|
|
setEnrollOpen(false);
|
|
},
|
|
onError: (e) =>
|
|
toast({ title: "Enrollment failed", description: String(e.message || e), variant: "destructive" }),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
function toggleStudentSelection(sid: number) {
|
|
setSelectedStudentIds((prev) =>
|
|
prev.includes(sid) ? prev.filter((x) => x !== sid) : [...prev, sid]
|
|
);
|
|
}
|
|
|
|
const canEnroll =
|
|
(enrollTab === "students" && selectedStudentIds.length > 0) ||
|
|
(enrollTab === "classroom" && selectedBatchId !== null);
|
|
const enrollLabel =
|
|
enrollTab === "classroom"
|
|
? `Enroll Classroom${selectedBatchId ? ` (${allBatches.find((b) => b.id === selectedBatchId)?.student_count ?? 0} students)` : ""}`
|
|
: `Enroll ${selectedStudentIds.length} Student(s)`;
|
|
|
|
async function handleDelete(id: number, title: string) {
|
|
if (!window.confirm(`Delete course "${title}"?`)) return;
|
|
try {
|
|
await lmsService.deleteCourse(id);
|
|
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
|
|
toast({ title: "Course deleted" });
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
toast({ title: "Delete failed", description: msg, variant: "destructive" });
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">Courses</h1>
|
|
<p className="text-muted-foreground">Manage all platform courses.</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<AiCreationAssistant type="course" />
|
|
<Button onClick={() => setCreateOpen(true)}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
New Course
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<AiTipBanner context="admin-courses" variant="recommendation" />
|
|
<div className="relative max-w-sm">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search courses..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Course</TableHead>
|
|
<TableHead>Subject / Tags</TableHead>
|
|
<TableHead>Difficulty</TableHead>
|
|
<TableHead>Chapters</TableHead>
|
|
<TableHead>Enrolled / Cap</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead className="w-[150px]" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filtered.map((c) => (
|
|
<TableRow key={c.id}>
|
|
<TableCell>
|
|
<div>
|
|
<span className="font-medium">{c.title}</span>
|
|
<span className="text-xs text-muted-foreground ml-2">{c.code}</span>
|
|
</div>
|
|
{c.topic_names && c.topic_names.length > 0 && (
|
|
<div className="text-xs text-muted-foreground mt-0.5">
|
|
{c.topic_names.slice(0, 3).join(", ")}
|
|
{c.topic_names.length > 3 && ` +${c.topic_names.length - 3}`}
|
|
</div>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="space-y-1">
|
|
{c.encoach_subject_name && (
|
|
<div className="flex items-center gap-1 text-xs">
|
|
<BookOpen className="h-3 w-3" />
|
|
{c.encoach_subject_name}
|
|
</div>
|
|
)}
|
|
<div className="flex flex-wrap gap-1">
|
|
{(c.tags ?? []).slice(0, 3).map((t) => (
|
|
<Badge
|
|
key={t.id}
|
|
variant="outline"
|
|
className="text-[10px] px-1.5 py-0"
|
|
style={{ borderColor: t.color, color: t.color }}
|
|
>
|
|
{t.name}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
{c.difficulty_level && (
|
|
<Badge variant="outline" className="capitalize text-xs">
|
|
{c.difficulty_level}
|
|
</Badge>
|
|
)}
|
|
{c.cefr_level && (
|
|
<Badge variant="secondary" className="ml-1 text-xs uppercase">
|
|
{c.cefr_level}
|
|
</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-1 text-sm">
|
|
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
|
{c.chapter_count ?? 0}
|
|
{(c.objective_count ?? 0) > 0 && (
|
|
<span className="text-xs text-muted-foreground ml-1">
|
|
<Target className="inline h-3 w-3 mr-0.5" />
|
|
{c.objective_count}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
{c.enrolled} / {c.max_capacity}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge
|
|
variant={c.status === "active" ? "default" : "secondary"}
|
|
className="capitalize"
|
|
>
|
|
{c.status}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title="Edit course"
|
|
onClick={() => setEditingCourse(c)}
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title="Enroll students"
|
|
onClick={() => openEnrollDialog(c.id)}
|
|
>
|
|
<UserPlus className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" title="Assign exam" asChild>
|
|
<Link to={`/admin/generation?course_id=${c.id}`}>
|
|
<FileEdit className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleDelete(c.id, c.title)}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
{filtered.length === 0 && (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
|
|
No courses found.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<CourseFormDialog
|
|
open={createOpen}
|
|
onOpenChange={setCreateOpen}
|
|
/>
|
|
|
|
{editingCourse && (
|
|
<CourseFormDialog
|
|
open={!!editingCourse}
|
|
onOpenChange={(open) => {
|
|
if (!open) setEditingCourse(null);
|
|
}}
|
|
initialData={courseToFormData(editingCourse)}
|
|
courseId={editingCourse.id}
|
|
/>
|
|
)}
|
|
|
|
<Dialog open={enrollOpen} onOpenChange={setEnrollOpen}>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>Enroll Students</DialogTitle>
|
|
<DialogDescription>
|
|
Enroll in: <strong>{courses.find((c) => c.id === enrollCourseId)?.title}</strong>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<Tabs value={enrollTab} onValueChange={(v) => setEnrollTab(v as "students" | "classroom")}>
|
|
<TabsList className="w-full">
|
|
<TabsTrigger value="students" className="flex-1 gap-1.5">
|
|
<UserPlus className="h-3.5 w-3.5" /> Individual Students
|
|
</TabsTrigger>
|
|
<TabsTrigger value="classroom" className="flex-1 gap-1.5">
|
|
<GraduationCap className="h-3.5 w-3.5" /> By Classroom
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="students" className="mt-3">
|
|
<div className="max-h-[320px] overflow-y-auto space-y-1">
|
|
{allStudents.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-4">No students found.</p>
|
|
) : (
|
|
allStudents.map((s) => (
|
|
<label key={s.id} className="flex items-center gap-3 p-2 rounded hover:bg-muted/50 cursor-pointer">
|
|
<Checkbox
|
|
checked={selectedStudentIds.includes(s.id)}
|
|
onCheckedChange={() => toggleStudentSelection(s.id)}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium truncate">{s.name}</p>
|
|
<p className="text-xs text-muted-foreground">{s.email}</p>
|
|
</div>
|
|
</label>
|
|
))
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="classroom" className="mt-3">
|
|
<div className="max-h-[320px] overflow-y-auto space-y-2">
|
|
{allBatches.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-4">
|
|
No classrooms found. Create one on the Classrooms page first.
|
|
</p>
|
|
) : (
|
|
allBatches.map((b) => (
|
|
<label
|
|
key={b.id}
|
|
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
|
selectedBatchId === b.id
|
|
? "border-primary bg-primary/5"
|
|
: "border-border hover:bg-muted/50"
|
|
}`}
|
|
onClick={() => setSelectedBatchId(selectedBatchId === b.id ? null : b.id)}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="batch"
|
|
checked={selectedBatchId === b.id}
|
|
onChange={() => setSelectedBatchId(b.id)}
|
|
className="accent-primary"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium">{b.name}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{b.course_name && <span>{b.course_name} · </span>}
|
|
{b.start_date && <span>{b.start_date} — {b.end_date}</span>}
|
|
</p>
|
|
</div>
|
|
<Badge variant="secondary" className="shrink-0">
|
|
<Users className="h-3 w-3 mr-1" />{b.student_count} students
|
|
</Badge>
|
|
</label>
|
|
))
|
|
)}
|
|
</div>
|
|
{selectedBatchId && (
|
|
<p className="text-xs text-muted-foreground mt-2">
|
|
All students in this classroom will be enrolled in the course with the classroom linked.
|
|
</p>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setEnrollOpen(false)}>Cancel</Button>
|
|
<Button onClick={handleEnroll} disabled={enrollMut.isPending || !canEnroll}>
|
|
{enrollMut.isPending ? "Enrolling..." : enrollLabel}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|