Files
encoach_backend_new_v2/frontend/src/pages/student/PlacementTest.tsx
Yamen Ahmad 82ec3debcc feat: QA fixes, new APIs (assignments, rubrics, custom exams), Generation page enhancements
- Fix ELAI video generation (correct render endpoint, script splitting for 60s limit)
- Fix speaking script generation error handling and empty response display
- Add custom exam list API (GET /api/exam/custom/list)
- Add assignments REST API (list, create, get)
- Add rubrics REST API (list, create)
- Enhance Generation page: dynamic exam structures, auto-module selection, preview dialog, audio player
- Improve submit feedback with exam ID and status in toast notifications
- Fix ExamsListPage to show both custom exams and exam sessions
- Connect RubricsPage to backend API with fallback data
- Add Dockerfile, docker-compose.yml, requirements.txt for deployment
- Fix placement, grading, scoring, and auth controllers
- Add ErrorBoundary component for frontend resilience
- Add QA report and credentials documentation

Made-with: Cursor
2026-04-12 14:26:39 +04:00

452 lines
17 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams, useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Skeleton } from "@/components/ui/skeleton";
import { Progress } from "@/components/ui/progress";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Mic, Loader2, Square } from "lucide-react";
import { placementService } from "@/services/placement.service";
import { usePlacementAnswer, usePlacementAutoSave } from "@/hooks/queries/usePlacement";
import type { CATQuestion, CATSection } from "@/types";
import { cn } from "@/lib/utils";
type LocationState = { question?: CATQuestion; sessionId?: string };
function sectionTitle(s: CATSection): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
export default function PlacementTest() {
const [searchParams] = useSearchParams();
const sessionId = searchParams.get("session") || "";
const navigate = useNavigate();
const location = useLocation();
const locState = location.state as LocationState | undefined;
const answerMut = usePlacementAnswer();
const autoSave = usePlacementAutoSave();
const [question, setQuestion] = useState<CATQuestion | null>(null);
const [initializing, setInitializing] = useState(true);
const [betweenQuestions, setBetweenQuestions] = useState(false);
const [progressInfo, setProgressInfo] = useState<{ current: number; estimated_total: number } | null>(null);
const [sectionBridge, setSectionBridge] = useState<{
from: CATSection;
to: CATSection;
nextQuestion: CATQuestion;
} | null>(null);
const [singleAnswer, setSingleAnswer] = useState("");
const [multiAnswer, setMultiAnswer] = useState<string[]>([]);
const questionStartedAt = useRef<number>(Date.now());
const draftRef = useRef<{ question_id: number; answer: string | string[] } | null>(null);
useEffect(() => {
if (!sessionId) {
navigate("/student/placement", { replace: true });
return;
}
const fromState = locState?.question;
const fromStorage = sessionStorage.getItem(`placement_session_${sessionId}`);
let parsed: CATQuestion | null = null;
if (fromStorage) {
try {
const j = JSON.parse(fromStorage) as { question?: CATQuestion };
parsed = j.question ?? null;
} catch {
parsed = null;
}
}
const q = fromState ?? parsed;
if (q) {
setQuestion(q);
questionStartedAt.current = Date.now();
}
setInitializing(false);
}, [sessionId, navigate, locState]);
useEffect(() => {
if (!question) return;
setSingleAnswer("");
setMultiAnswer([]);
questionStartedAt.current = Date.now();
}, [question?.id]);
useEffect(() => {
if (!question) return;
const id = question.id;
if (question.type === "mcq_multi") {
draftRef.current = { question_id: id, answer: multiAnswer };
} else {
draftRef.current = { question_id: id, answer: singleAnswer };
}
}, [question, singleAnswer, multiAnswer]);
useEffect(() => {
if (!sessionId || !draftRef.current) return;
const tick = window.setInterval(() => {
const d = draftRef.current;
if (!d) return;
void autoSave.mutate({ session_id: sessionId, current_answer: d });
}, 10000);
return () => window.clearInterval(tick);
}, [sessionId, autoSave]);
const elapsedRef = useRef(0);
const [, setTick] = useState(0);
useEffect(() => {
const t = window.setInterval(() => {
elapsedRef.current += 1;
setTick((x) => x + 1);
}, 1000);
return () => window.clearInterval(t);
}, []);
const formatElapsed = (sec: number) => {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
};
const buildAnswerPayload = (): string | string[] => {
if (!question) return "";
if (question.type === "mcq_multi") return multiAnswer;
if (question.type === "gap_fill") return singleAnswer.trim();
return singleAnswer;
};
const submit = async () => {
if (!question || !sessionId) return;
const timeSpent = Date.now() - questionStartedAt.current;
setBetweenQuestions(true);
try {
const res = await answerMut.mutateAsync({
session_id: sessionId,
question_id: question.id,
answer: buildAnswerPayload(),
time_spent_ms: timeSpent,
});
if (res.progress) setProgressInfo(res.progress);
if (res.test_complete) {
navigate(`/student/placement/results?session=${encodeURIComponent(sessionId)}`);
return;
}
if (res.section_complete && res.next_question) {
setSectionBridge({
from: question.section,
to: res.next_question.section,
nextQuestion: res.next_question,
});
return;
}
if (res.next_question) {
setQuestion(res.next_question);
sessionStorage.setItem(`placement_session_${sessionId}`, JSON.stringify({ question: res.next_question }));
}
} finally {
setBetweenQuestions(false);
}
};
const continueAfterSection = () => {
if (!sectionBridge || !sessionId) return;
setQuestion(sectionBridge.nextQuestion);
sessionStorage.setItem(
`placement_session_${sessionId}`,
JSON.stringify({ question: sectionBridge.nextQuestion }),
);
setSectionBridge(null);
};
const toggleMulti = (label: string) => {
setMultiAnswer((prev) => (prev.includes(label) ? prev.filter((x) => x !== label) : [...prev, label]));
};
const displayProgress = progressInfo ?? {
current: question?.estimated_total ? 1 : 0,
estimated_total: question?.estimated_total ?? 0,
};
const approxFinal =
displayProgress.estimated_total > 0 && displayProgress.current >= displayProgress.estimated_total;
if (!sessionId) return null;
if (!initializing && !question && !sectionBridge) {
return (
<div className="min-h-screen flex items-center justify-center p-6 bg-background">
<Card className="max-w-md w-full border-0 shadow-lg">
<CardHeader>
<CardTitle>Session unavailable</CardTitle>
<CardDescription>Return to the briefing and start the test again.</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => navigate("/student/placement")}>Back</Button>
</CardContent>
</Card>
</div>
);
}
if (sectionBridge) {
return (
<div className="min-h-screen flex flex-col bg-background">
<header className="border-b px-6 py-4 flex items-center justify-between bg-card/80 backdrop-blur">
<span className="text-sm font-medium text-muted-foreground">Section complete</span>
<span className="text-sm tabular-nums">{formatElapsed(elapsedRef.current)}</span>
</header>
<main className="flex-1 flex items-center justify-center p-6">
<Card className="max-w-lg w-full border-0 shadow-xl text-center">
<CardHeader>
<CardTitle>
{sectionTitle(sectionBridge.from)} complete. Next: {sectionTitle(sectionBridge.to)}
</CardTitle>
<CardDescription>Take a short breath continue when you&apos;re ready.</CardDescription>
</CardHeader>
<CardContent>
<Button size="lg" className="w-full" onClick={continueAfterSection}>
Continue
</Button>
</CardContent>
</Card>
</main>
</div>
);
}
return (
<div className="min-h-screen flex flex-col bg-gradient-to-b from-muted/20 to-background">
<header className="sticky top-0 z-10 border-b px-4 md:px-8 py-3 flex flex-wrap items-center gap-4 justify-between bg-card/90 backdrop-blur supports-[backdrop-filter]:bg-card/80">
<div className="flex items-center gap-4 min-w-0">
<span className="text-sm font-semibold tabular-nums text-primary">{formatElapsed(elapsedRef.current)}</span>
<span className="text-sm font-medium truncate">{question ? sectionTitle(question.section) : "…"}</span>
</div>
<div className="flex items-center gap-3 flex-1 max-w-md min-w-[200px]">
<Progress
value={
displayProgress.estimated_total
? Math.min(100, (displayProgress.current / displayProgress.estimated_total) * 100)
: 0
}
className="h-2 flex-1"
/>
<span className="text-xs text-muted-foreground whitespace-nowrap tabular-nums">
Question {displayProgress.current} of ~{displayProgress.estimated_total || "?"}
</span>
</div>
</header>
<main className="flex-1 flex items-stretch justify-center p-4 md:p-8">
<div className="w-full max-w-3xl">
{initializing || betweenQuestions || !question ? (
<div className="space-y-4">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : (
<Card className="border-0 shadow-lg">
<CardContent className="pt-8 space-y-8">
{question.passage_text && (
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed whitespace-pre-wrap">
{question.passage_text}
</div>
)}
<p className="text-lg font-medium leading-snug">{question.stem}</p>
{(question.type === "mcq" || question.type === "definition_match") && question.options && (
<RadioGroup value={singleAnswer} onValueChange={setSingleAnswer} className="space-y-3">
{question.options.map((o) => (
<label
key={o.label}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-colors",
singleAnswer === o.label && "border-primary bg-primary/5",
)}
>
<RadioGroupItem value={o.label} id={`opt-${o.label}`} />
<span className="text-sm leading-snug">{o.text}</span>
</label>
))}
</RadioGroup>
)}
{question.type === "mcq_multi" && question.options && (
<div className="space-y-3">
{question.options.map((o) => (
<label
key={o.label}
className="flex cursor-pointer items-center gap-3 rounded-lg border p-4"
>
<Checkbox
checked={multiAnswer.includes(o.label)}
onCheckedChange={() => toggleMulti(o.label)}
/>
<span className="text-sm">{o.text}</span>
</label>
))}
</div>
)}
{question.type === "gap_fill" && (
<Input
value={singleAnswer}
onChange={(e) => setSingleAnswer(e.target.value)}
placeholder="Your answer"
className="text-base"
/>
)}
{question.type === "tfng" && (
<RadioGroup value={singleAnswer} onValueChange={setSingleAnswer} className="space-y-3">
{(question.options?.length
? question.options
: [
{ label: "T", text: "True" },
{ label: "F", text: "False" },
{ label: "NG", text: "Not Given" },
]
).map((o) => (
<label
key={o.label}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-lg border p-4",
singleAnswer === o.label && "border-primary bg-primary/5",
)}
>
<RadioGroupItem value={o.label} id={`tfng-${o.label}`} />
<span className="text-sm">{o.text}</span>
</label>
))}
</RadioGroup>
)}
{question.type === "audio_recording" && (
<PlacementSpeakingRecorder
sessionId={sessionId}
promptId={question.id}
onUploaded={() => setSingleAnswer("audio_submitted")}
/>
)}
<div className="flex justify-end pt-4">
<Button
size="lg"
className="min-w-[160px]"
disabled={answerMut.isPending || betweenQuestions}
onClick={() => void submit()}
>
{answerMut.isPending || betweenQuestions ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving
</>
) : approxFinal ? (
"Finish Test"
) : (
"Submit answer"
)}
</Button>
</div>
</CardContent>
</Card>
)}
</div>
</main>
</div>
);
}
function PlacementSpeakingRecorder({ sessionId, promptId, onUploaded }: {
sessionId: string; promptId: number; onUploaded: () => void;
}) {
const [recording, setRecording] = useState(false);
const [uploading, setUploading] = useState(false);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [elapsed, setElapsed] = useState(0);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const timerRef = useRef<number>(0);
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mr = new MediaRecorder(stream, { mimeType: "audio/webm" });
chunksRef.current = [];
mr.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
mr.onstop = () => {
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
setAudioUrl(URL.createObjectURL(blob));
stream.getTracks().forEach((t) => t.stop());
uploadAudio(blob);
};
mediaRecorderRef.current = mr;
mr.start();
setRecording(true);
setElapsed(0);
timerRef.current = window.setInterval(() => setElapsed((t) => t + 1), 1000);
} catch {
// microphone permission denied
}
};
const stopRecording = () => {
mediaRecorderRef.current?.stop();
setRecording(false);
window.clearInterval(timerRef.current);
};
const uploadAudio = async (blob: Blob) => {
setUploading(true);
try {
const file = new File([blob], "speaking.webm", { type: "audio/webm" });
await placementService.uploadSpeaking(sessionId, promptId, file);
onUploaded();
} catch {
// upload error handled silently
} finally {
setUploading(false);
}
};
const mm = Math.floor(elapsed / 60);
const ss = elapsed % 60;
return (
<div className="flex flex-col items-center gap-4 py-8 rounded-xl border border-dashed bg-muted/30">
{!recording && !audioUrl && (
<>
<Mic className="h-12 w-12 text-muted-foreground" />
<p className="text-sm text-muted-foreground text-center max-w-sm">
Click the button below to start recording your speaking response.
</p>
<Button type="button" variant="secondary" size="lg" onClick={startRecording} className="gap-2">
<Mic className="h-5 w-5" /> Start Recording
</Button>
</>
)}
{recording && (
<div className="flex flex-col items-center gap-3">
<div className="flex items-center gap-3">
<div className="h-3 w-3 rounded-full bg-red-500 animate-pulse" />
<span className="font-mono text-lg tabular-nums">{String(mm).padStart(2, "0")}:{String(ss).padStart(2, "0")}</span>
</div>
<Button type="button" variant="destructive" size="lg" onClick={stopRecording} className="gap-2">
<Square className="h-4 w-4" /> Stop Recording
</Button>
</div>
)}
{audioUrl && (
<div className="space-y-3 w-full max-w-md">
<audio controls src={audioUrl} className="w-full" />
{uploading && <p className="text-sm text-center text-muted-foreground flex items-center justify-center gap-2"><Loader2 className="h-4 w-4 animate-spin" /> Uploading...</p>}
{!uploading && <p className="text-sm text-center text-green-600">Recording uploaded successfully</p>}
</div>
)}
</div>
);
}