Files
encoach_backend_new_v3/frontend/src/pages/admin/IeltsContentPool.tsx
Yamen Ahmad f1c4953a63 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

354 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { useIeltsContentPool, useIeltsAutoAssemble, useIeltsSkills } from "@/hooks/queries/useExamTemplates";
import { ieltsExamService } from "@/services/ielts-exam.service";
import type { ContentPoolFilters, ContentPoolItem, ExamDifficulty, IELTSSkill, IELTSSkillConfig } from "@/types";
import { toast } from "sonner";
const SKILLS: { value: IELTSSkill; label: string }[] = [
{ value: "listening", label: "Listening" },
{ value: "reading", label: "Reading" },
{ value: "writing", label: "Writing" },
{ value: "speaking", label: "Speaking" },
];
function difficultyLabel(d: ExamDifficulty): string {
return d.charAt(0).toUpperCase() + d.slice(1);
}
export default function IeltsContentPool() {
const { examId: examIdParam } = useParams<{ examId: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const examId = Number(examIdParam);
const [skill, setSkill] = useState<IELTSSkill>(() => (searchParams.get("skill") as IELTSSkill) || "listening");
const [part, setPart] = useState<number>(() => Number(searchParams.get("part")) || 1);
const [difficultyIdx, setDifficultyIdx] = useState(1);
const difficulties: ExamDifficulty[] = ["easy", "medium", "hard"];
const difficulty = difficulties[difficultyIdx] ?? "medium";
const [topicInput, setTopicInput] = useState("");
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<number[]>([]);
const [pendingHybrid, setPendingHybrid] = useState<ContentPoolItem[]>([]);
const assemblyMode = useMemo(() => {
if (!Number.isFinite(examId)) return "manual";
return sessionStorage.getItem(`ielts_assembly_${examId}`) ?? "manual";
}, [examId]);
const filters: ContentPoolFilters = useMemo(
() => ({
skill,
part,
difficulty,
topics: topicInput
.split(",")
.map((t) => t.trim())
.filter(Boolean),
search: search.trim() || undefined,
}),
[skill, part, difficulty, topicInput, search],
);
const poolQ = useIeltsContentPool(Number.isFinite(examId) ? examId : 0, filters);
const skillsQ = useIeltsSkills(Number.isFinite(examId) ? examId : 0);
const autoAssemble = useIeltsAutoAssemble();
const items = useMemo(() => {
const raw = poolQ.data;
if (Array.isArray(raw)) return raw;
if (raw && typeof raw === "object" && Array.isArray((raw as { data?: unknown }).data)) {
return (raw as { data: ContentPoolItem[] }).data;
}
return [] as ContentPoolItem[];
}, [poolQ.data]);
const skillsData = useMemo(() => {
const raw = skillsQ.data;
if (Array.isArray(raw)) return raw;
if (raw && typeof raw === "object" && Array.isArray((raw as { data?: unknown }).data)) {
return (raw as { data: IELTSSkillConfig[] }).data;
}
return [];
}, [skillsQ.data]);
const requiredTotal = useMemo(() => {
const cfg = skillsData.find((s) => s.skill === skill);
if (!cfg?.parts?.length) {
if (skill === "listening") return 40;
if (skill === "reading") return 40;
if (skill === "writing") return 2;
return 3;
}
return cfg.parts.reduce((a, p) => a + p.question_count, 0);
}, [skillsData, skill]);
useEffect(() => {
setSearchParams(
(prev) => {
const sp = new URLSearchParams(prev);
sp.set("skill", skill);
sp.set("part", String(part));
return sp;
},
{ replace: true },
);
}, [skill, part, setSearchParams]);
const didAutoRun = useRef(false);
useEffect(() => {
if (assemblyMode !== "auto" || !Number.isFinite(examId) || didAutoRun.current) return;
didAutoRun.current = true;
let cancelled = false;
(async () => {
try {
const res = await autoAssemble.mutateAsync(examId);
if (cancelled) return;
const flat = (Array.isArray(res) ? res : []).flatMap((r) => r.selected_items?.map((i) => i.id) ?? []);
setSelected(flat);
} catch {
toast.error("Auto-assembly could not run.");
}
})();
return () => {
cancelled = true;
};
}, [assemblyMode, autoAssemble, examId]);
useEffect(() => {
if (assemblyMode !== "hybrid" || !Number.isFinite(examId)) return;
let cancelled = false;
(async () => {
try {
const res = await ieltsExamService.suggest(examId);
if (cancelled) return;
const rows = Array.isArray(res) ? res : [];
const forSkill = rows.find((r) => r.skill === skill && r.part === part);
setPendingHybrid(forSkill?.selected_items ?? []);
} catch {
setPendingHybrid([]);
}
})();
return () => {
cancelled = true;
};
}, [assemblyMode, examId, part, skill]);
const toggleSelect = useCallback(
(id: number) => {
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
},
[],
);
const selectedItems = useMemo(() => {
const map = new Map(items.map((i) => [i.id, i]));
return selected.map((id) => map.get(id)).filter(Boolean) as ContentPoolItem[];
}, [items, selected]);
const poolDisabled = assemblyMode === "auto";
const saveContinue = async () => {
try {
await ieltsExamService.saveQuestions(examId, part, selected);
toast.success("Selections saved.");
navigate(`/admin/exam/ielts/${examId}/validate`);
} catch {
toast.error("Save failed.");
}
};
if (!Number.isFinite(examId) || examId <= 0) {
return <p className="p-6 text-destructive">Invalid exam.</p>;
}
return (
<div className="p-6 max-w-[1400px] mx-auto space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Content pool</h1>
<p className="text-muted-foreground mt-1">
Assembly: <span className="text-foreground font-medium capitalize">{assemblyMode}</span>
{assemblyMode === "auto" ? " — pool is read-only; system selections apply." : null}
{assemblyMode === "hybrid" ? " — review suggestions on the right." : null}
</p>
</div>
<div className="grid gap-4 lg:grid-cols-5">
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle>Browse pool</CardTitle>
<CardDescription>Filter and preview candidate items.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>Skill</Label>
<Select value={skill} onValueChange={(v) => setSkill(v as IELTSSkill)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SKILLS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Part</Label>
<Select value={String(part)} onValueChange={(v) => setPart(Number(v))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4].map((n) => (
<SelectItem key={n} value={String(n)}>
Part {n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Difficulty ({difficultyLabel(difficulty)})</Label>
<Slider min={0} max={2} step={1} value={[difficultyIdx]} onValueChange={(v) => setDifficultyIdx(v[0] ?? 1)} />
</div>
<div className="space-y-2">
<Label htmlFor="topics">Topic tags</Label>
<Input
id="topics"
placeholder="education, environment …"
value={topicInput}
onChange={(e) => setTopicInput(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="search">Search</Label>
<Input id="search" placeholder="Keyword in stem" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Separator />
{poolQ.isLoading ? (
<div className="space-y-2">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : (
<ScrollArea className="h-[min(520px,55vh)] pr-3">
<div className={`space-y-3 ${poolDisabled ? "opacity-50 pointer-events-none" : ""}`}>
{items.map((item) => (
<Card key={item.id} className="border-muted">
<CardHeader className="py-3 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{difficultyLabel(item.difficulty)}</Badge>
<span className="text-xs text-muted-foreground">Used {item.usage_count}×</span>
</div>
<CardTitle className="text-sm font-normal leading-snug">{item.preview_text}</CardTitle>
<div className="flex flex-wrap gap-1">
{item.topic_tags.map((t) => (
<Badge key={t} variant="secondary" className="text-xs">
{t}
</Badge>
))}
</div>
</CardHeader>
<CardContent className="pt-0 flex justify-end gap-2">
<Button
type="button"
size="sm"
variant={selected.includes(item.id) ? "secondary" : "default"}
disabled={poolDisabled}
onClick={() => toggleSelect(item.id)}
>
{selected.includes(item.id) ? "Remove" : "Add"}
</Button>
</CardContent>
</Card>
))}
{items.length === 0 ? <p className="text-sm text-muted-foreground">No items match these filters.</p> : null}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Selected questions</CardTitle>
<CardDescription>
{selected.length}/{requiredTotal} selected
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ScrollArea className="h-[min(400px,45vh)]">
<ul className="space-y-2 text-sm">
{selectedItems.map((i) => (
<li key={i.id} className="rounded-md border p-2 flex justify-between gap-2">
<span className="line-clamp-2">{i.preview_text}</span>
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => toggleSelect(i.id)}>
<X className="h-4 w-4" />
</Button>
</li>
))}
{selectedItems.length === 0 ? <li className="text-muted-foreground">Nothing selected yet.</li> : null}
</ul>
</ScrollArea>
{assemblyMode === "hybrid" && pendingHybrid.length > 0 ? (
<div className="space-y-2">
<p className="text-sm font-medium">Suggestions</p>
{pendingHybrid.map((item) => (
<div key={item.id} className="flex items-start justify-between gap-2 rounded-md border p-2 text-sm">
<span className="line-clamp-3">{item.preview_text}</span>
<div className="flex gap-1 shrink-0">
<Button
type="button"
size="icon"
variant="outline"
onClick={() => {
if (!selected.includes(item.id)) setSelected((s) => [...s, item.id]);
setPendingHybrid((p) => p.filter((x) => x.id !== item.id));
}}
>
<Check className="h-4 w-4" />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
onClick={() => setPendingHybrid((p) => p.filter((x) => x.id !== item.id))}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
) : null}
<Button className="w-full" type="button" onClick={saveContinue}>
Save & continue
</Button>
</CardContent>
</Card>
</div>
</div>
);
}