Files
encoach_frontend_new_v2/src/pages/admin/ModuleBuilder.tsx
Yamen Ahmad 11a7265460 feat(v3): restructure project + add complete frontend
- Restructure: move backend from new_project/ to backend/
- Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks)
- Add docs/ with SRS specs, user stories, and workflow documentation
- Update .gitignore for new directory layout

Workflows implemented:
  WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration,
  WF4 General English Exam, WF5 Course Generation,
  WF6 Entity Student Onboarding, AI Course Generation,
  Adaptive Learning Engine UI, White-Label Branding, Score Release

Made-with: Cursor
2026-04-10 17:26:42 +04:00

336 lines
14 KiB
TypeScript

import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useCourseDetail, useUpdateCourse } from "@/hooks/queries/useCourseGeneration";
import { courseGenerationService } from "@/services/course-generation.service";
import type { ModuleConfig } from "@/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { ChevronDown, GripVertical, Search, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
export default function ModuleBuilder() {
const { courseId: courseIdParam } = useParams();
const courseId = Number(courseIdParam);
const { data: course, isLoading } = useCourseDetail(courseId);
const updateCourse = useUpdateCourse();
const [modules, setModules] = useState<ModuleConfig[] | null>(null);
const [resourceOpen, setResourceOpen] = useState(false);
const [aiOpen, setAiOpen] = useState(false);
const [aiForm, setAiForm] = useState({ topic: "", difficulty: "B2", type: "reading" });
const [dragId, setDragId] = useState<number | null>(null);
const mergedModules = modules ?? course?.modules ?? [];
const bySkill = useMemo(() => {
const map = new Map<string, ModuleConfig[]>();
mergedModules.forEach((m) => {
const k = m.skill || "General";
if (!map.has(k)) map.set(k, []);
map.get(k)!.push(m);
});
return map;
}, [mergedModules]);
const totalHours = mergedModules.reduce((s, m) => s + m.estimated_hours, 0);
const syncModules = (next: ModuleConfig[]) => {
setModules(next);
if (course) {
updateCourse.mutate({
courseId,
payload: { modules: next },
});
}
};
const onDragStart = (id: number) => setDragId(id);
const onDrop = (targetId: number) => {
if (dragId === null || dragId === targetId) return;
const list = [...mergedModules];
const from = list.findIndex((m) => m.id === dragId);
const to = list.findIndex((m) => m.id === targetId);
if (from < 0 || to < 0) return;
const [item] = list.splice(from, 1);
list.splice(to, 0, item);
syncModules(list);
setDragId(null);
};
const publish = () => {
updateCourse.mutate({ courseId, payload: { status: "published" } });
};
if (isLoading) {
return (
<div className="p-6">
<Skeleton className="mb-4 h-10 w-64" />
<Skeleton className="h-96 w-full" />
</div>
);
}
return (
<div className="mx-auto max-w-5xl space-y-6 p-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Module builder</h1>
<p className="text-muted-foreground">
{course?.title ?? `Course #${courseId}`} · {totalHours}h total · {mergedModules.length} modules
</p>
</div>
<Button type="button" onClick={publish} disabled={updateCourse.isPending}>
Publish Course
</Button>
</div>
<div className="space-y-4">
{mergedModules.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Sparkles className="h-10 w-10 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-1">No modules yet</h3>
<p className="text-sm text-muted-foreground mb-4">
Add modules manually or use the AI assistant to auto-generate a module structure.
</p>
<div className="flex gap-2">
<Button
type="button"
onClick={() => {
const next: ModuleConfig[] = [
{ id: Date.now(), title: "New Module", skill: "Listening", estimated_hours: 2, resources: [], assessment_type: "quiz" },
];
syncModules(next);
}}
>
Add Module
</Button>
<Button type="button" variant="outline" onClick={() => setAiOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
AI Generate
</Button>
</div>
</CardContent>
</Card>
)}
{Array.from(bySkill.entries()).map(([skill, mods]) => (
<Collapsible key={skill} defaultOpen>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="flex cursor-pointer flex-row items-center justify-between">
<CardTitle className="text-lg">
{skill} · {mods.reduce((s, m) => s + m.estimated_hours, 0)}h · {mods.length} modules
</CardTitle>
<ChevronDown className="h-5 w-5" />
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4 pt-0">
{mods.map((mod, idx) => (
<div
key={mod.id}
draggable
onDragStart={() => onDragStart(mod.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => onDrop(mod.id)}
className={cn("rounded-lg border p-4", dragId === mod.id && "opacity-60")}
>
<div className="mb-3 flex items-center gap-2">
<GripVertical className="h-5 w-5 text-muted-foreground" />
<span className="font-medium">
Module {idx + 1}: {mod.title}
</span>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`t-${mod.id}`}>Title</Label>
<Input
id={`t-${mod.id}`}
defaultValue={mod.title}
onBlur={(e) => {
const next = mergedModules.map((m) =>
m.id === mod.id ? { ...m, title: e.target.value } : m,
);
syncModules(next);
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`h-${mod.id}`}>Hours</Label>
<Input
id={`h-${mod.id}`}
type="number"
defaultValue={mod.estimated_hours}
onBlur={(e) => {
const v = Number(e.target.value);
const next = mergedModules.map((m) =>
m.id === mod.id ? { ...m, estimated_hours: v } : m,
);
syncModules(next);
}}
/>
</div>
</div>
<div className="mt-3 space-y-2">
<Label>Resources</Label>
<div className="flex flex-wrap gap-2">
{mod.resources.map((r) => (
<span key={r.id} className="rounded-md bg-muted px-2 py-1 text-xs">
{r.title} ({r.type})
</span>
))}
<Button type="button" size="sm" variant="outline" onClick={() => setResourceOpen(true)}>
<Search className="mr-1 h-3 w-3" />
Browse Resources
</Button>
</div>
</div>
<div className="mt-3 space-y-2">
<Label htmlFor={`ex-${mod.id}`}>Practice exercises</Label>
<Textarea id={`ex-${mod.id}`} placeholder="Describe linked exercises" rows={2} />
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>Completion criteria</Label>
<Select
defaultValue={mod.completion_criteria}
onValueChange={(val) => {
const v = val as ModuleConfig["completion_criteria"];
const next = mergedModules.map((m) =>
m.id === mod.id ? { ...m, completion_criteria: v } : m,
);
syncModules(next);
}}
>
<SelectTrigger>
<SelectValue placeholder="Criteria" />
</SelectTrigger>
<SelectContent>
<SelectItem value="complete_all">complete_all</SelectItem>
<SelectItem value="score_threshold">score_threshold</SelectItem>
<SelectItem value="teacher_approval">teacher_approval</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor={`pre-${mod.id}`}>Prerequisite module id</Label>
<Input
id={`pre-${mod.id}`}
type="number"
defaultValue={mod.prerequisite_module_id ?? ""}
onBlur={(e) => {
const v = e.target.value ? Number(e.target.value) : undefined;
const next = mergedModules.map((m) =>
m.id === mod.id ? { ...m, prerequisite_module_id: v } : m,
);
syncModules(next);
}}
/>
</div>
</div>
<Separator className="my-3" />
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => setAiOpen(true)}
>
<Sparkles className="mr-1 h-3 w-3" />
Generate AI Content
</Button>
</div>
))}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
))}
</div>
<Dialog open={resourceOpen} onOpenChange={setResourceOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Resource search</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Input placeholder="Search title, skill, or tag" />
<ScrollArea className="h-48 rounded-md border p-2 text-sm text-muted-foreground">
Matching resources will list here from your library.
</ScrollArea>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setResourceOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={aiOpen} onOpenChange={setAiOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Generate AI content</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="ai-topic">Topic</Label>
<Input
id="ai-topic"
value={aiForm.topic}
onChange={(e) => setAiForm((f) => ({ ...f, topic: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-diff">Difficulty</Label>
<Input
id="ai-diff"
value={aiForm.difficulty}
onChange={(e) => setAiForm((f) => ({ ...f, difficulty: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-type">Type</Label>
<Input
id="ai-type"
value={aiForm.type}
onChange={(e) => setAiForm((f) => ({ ...f, type: e.target.value }))}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
onClick={() => {
void courseGenerationService
.generateAIResource({
topic: aiForm.topic,
difficulty: aiForm.difficulty,
type: aiForm.type,
})
.then(() => setAiOpen(false));
}}
>
Generate
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}