TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Examens blancs : liste avec tentatives passées, choix du mode (chronométré /3// pratique), interface d'examen (navigation, compte à rebours, remise auto),4// écran de résultats avec analyse par concept et par axe.5import { useCallback, useEffect, useRef, useState } from "react";6import {7 AlertTriangle, CheckCircle2, ChevronLeft, ChevronRight, Clock3, FileCheck2,8 GraduationCap, Send, Timer, XCircle,9} from "lucide-react";10import { Badge, Button, Card, EmptyState, Modal, ProgressBar, Skeleton, Spinner, Textarea, cn } from "@/components/ui";11import { Markdown } from "@/components/chat/markdown";12import { DifficultyDots, ErrorBanner, fetchJson, fmtDateTime, postJson } from "./shared";1314type Exam = { id: number; kind: string; title: string; description: string; duration_minutes: number; questionCount: number };15type Attempt = { id: number; exam_id: number; mode: string; started_at: string; finished_at: string | null; score: number | null; total: number | null };16type ExamQuestion = { id: number; type: string; difficulty: number; question: string; options: string[] };17type SubmitResult = {18 score: number;19 total: number;20 detail: Record<string, { answer: string; correct: boolean; expected: string; explanation: string }>;21 analysis: {22 byConcept: { name: string; correct: number; total: number }[];23 byAxis: { axis: string; correct: number; total: number }[];24 weakest: string[];25 recommendation: string;26 };27 previous: { score: number; total: number; finished_at: string }[];28};2930const KIND_LABELS: Record<string, { label: string; tone: "brand" | "gold" | "neutral" }> = {31 intra: { label: "Intra", tone: "brand" },32 final: { label: "Final", tone: "gold" },33 thematic: { label: "Thématique", tone: "neutral" },34};35const AXIS_LABELS: Record<string, string> = {36 connaissances: "Connaissances",37 calcul: "Calcul",38 interpretation: "Interprétation",39 jugement: "Jugement professionnel",40 communication: "Communication",41};4243export function ExamsApp({ course }: { course: string }) {44 const upper = course.toUpperCase();4546 const [exams, setExams] = useState<Exam[] | null>(null);47 const [attempts, setAttempts] = useState<Attempt[]>([]);48 const [error, setError] = useState<string | null>(null);4950 // Choix du mode51 const [modeFor, setModeFor] = useState<Exam | null>(null);52 const [busy, setBusy] = useState(false);5354 // Tentative en cours55 const [attempt, setAttempt] = useState<{ attemptId: number; title: string; mode: "timed" | "practice"; durationMinutes: number; questions: ExamQuestion[] } | null>(null);56 const [answers, setAnswers] = useState<Record<string, string>>({});57 const [idx, setIdx] = useState(0);58 const [deadline, setDeadline] = useState<number | null>(null);59 const [remaining, setRemaining] = useState<number | null>(null);60 const [confirmOpen, setConfirmOpen] = useState(false);61 const submittedRef = useRef(false);6263 // Résultats64 const [result, setResult] = useState<SubmitResult | null>(null);65 const [openDetail, setOpenDetail] = useState<Record<string, boolean>>({});6667 const load = useCallback(() => {68 setError(null);69 fetchJson<{ exams: Exam[]; attempts: Attempt[] }>(`/api/learning/exams?course=${upper}`)70 .then((d) => { setExams(d.exams); setAttempts(d.attempts); })71 .catch((e) => { setExams([]); setError(e instanceof Error ? e.message : "Erreur de chargement."); });72 }, [upper]);7374 useEffect(() => { load(); }, [load]);7576 async function start(exam: Exam, mode: "timed" | "practice") {77 setBusy(true);78 setError(null);79 try {80 const d = await postJson<{ attemptId: number; durationMinutes: number; title: string; questions: ExamQuestion[] }>(81 "/api/learning/exams",82 { action: "start", examId: exam.id, mode }83 );84 submittedRef.current = false;85 setAttempt({ ...d, mode });86 setAnswers({});87 setIdx(0);88 setResult(null);89 setModeFor(null);90 setDeadline(mode === "timed" ? Date.now() + d.durationMinutes * 60_000 : null);91 } catch (e) {92 setError(e instanceof Error ? e.message : "Impossible de démarrer l'examen.");93 } finally {94 setBusy(false);95 }96 }9798 const submit = useCallback(async (auto = false) => {99 if (!attempt || submittedRef.current) return;100 submittedRef.current = true;101 setBusy(true);102 setConfirmOpen(false);103 setError(null);104 try {105 const d = await postJson<SubmitResult>("/api/learning/exams", {106 action: "submit", attemptId: attempt.attemptId, answers,107 });108 setResult(d);109 setAttempt(null);110 setDeadline(null);111 load();112 if (auto) setError(null);113 } catch (e) {114 submittedRef.current = false;115 setError(e instanceof Error ? e.message : "Erreur lors de la remise.");116 } finally {117 setBusy(false);118 }119 }, [attempt, answers, load]);120121 // Compte à rebours + remise automatique à 0:00122 useEffect(() => {123 if (!deadline) { setRemaining(null); return; }124 const tick = () => {125 const r = Math.max(0, Math.round((deadline - Date.now()) / 1000));126 setRemaining(r);127 if (r <= 0) submit(true);128 };129 tick();130 const t = setInterval(tick, 1000);131 return () => clearInterval(t);132 }, [deadline, submit]);133134 const answeredCount = attempt ? attempt.questions.filter((q) => (answers[String(q.id)] ?? "").trim() !== "").length : 0;135136 // ---------- Interface d'examen ----------137 if (attempt) {138 const q = attempt.questions[idx];139 const total = attempt.questions.length;140 const mins = remaining != null ? Math.floor(remaining / 60) : null;141 const secs = remaining != null ? remaining % 60 : null;142 const low = remaining != null && remaining < 300;143 return (144 <main className="px-4 sm:px-6 py-6 max-w-3xl mx-auto w-full space-y-4">145 <div className="flex flex-wrap items-center gap-3">146 <h2 className="font-bold text-fg">{attempt.title}</h2>147 <Badge tone={attempt.mode === "timed" ? "amber" : "neutral"}>148 {attempt.mode === "timed" ? "Chronométré" : "Mode pratique"}149 </Badge>150 {remaining != null && (151 <span152 className={cn(153 "ml-auto inline-flex items-center gap-1.5 font-mono font-semibold text-[15px] tabular-nums px-2.5 py-1 rounded-lg",154 low ? "text-red-600 dark:text-red-400 bg-red-500/10 animate-pulse-soft" : "text-fg bg-surface-2 dark:bg-brand-900/50"155 )}156 role="timer"157 aria-label="Temps restant"158 >159 <Timer size={15} /> {mins}:{String(secs).padStart(2, "0")}160 </span>161 )}162 </div>163164 {/* Grille de navigation */}165 <div className="flex flex-wrap gap-1.5" role="group" aria-label="Navigation entre les questions">166 {attempt.questions.map((qq, i) => {167 const done = (answers[String(qq.id)] ?? "").trim() !== "";168 return (169 <button170 key={qq.id}171 onClick={() => setIdx(i)}172 aria-label={`Question ${i + 1}${done ? " (répondue)" : ""}`}173 aria-current={i === idx}174 className={cn(175 "w-8 h-8 rounded-lg text-[12.5px] font-semibold border transition-colors",176 i === idx177 ? "border-brand-500 bg-brand-600 text-white"178 : done179 ? "border-emerald-500/40 bg-emerald-500/12 text-emerald-700 dark:text-emerald-400"180 : "border-app bg-card text-muted hover:text-fg"181 )}182 >183 {i + 1}184 </button>185 );186 })}187 </div>188189 <Card className="p-5 animate-fade-up">190 <div className="flex items-center gap-2 mb-3">191 <span className="text-[12.5px] font-semibold text-muted">Question {idx + 1} / {total}</span>192 <DifficultyDots value={q.difficulty} />193 </div>194 <Markdown content={q.question} />195 <div className="mt-4">196 {q.type === "mcq" ? (197 <div className="grid gap-2" role="radiogroup" aria-label="Options">198 {q.options.map((opt, i) => {199 const chosen = answers[String(q.id)] === opt;200 return (201 <button202 key={i}203 role="radio"204 aria-checked={chosen}205 onClick={() => setAnswers((a) => ({ ...a, [String(q.id)]: chosen ? "" : opt }))}206 className={cn(207 "text-left border rounded-xl px-4 py-2.5 text-sm transition-colors",208 chosen209 ? "border-brand-500 bg-brand-50 dark:bg-brand-900/40 text-fg"210 : "border-app bg-card text-fg hover:border-brand-300"211 )}212 >213 <span className="font-semibold text-brand-600 dark:text-brand-300 mr-2">{String.fromCharCode(65 + i)}.</span>214 {opt}215 </button>216 );217 })}218 </div>219 ) : (220 <Textarea221 value={answers[String(q.id)] ?? ""}222 onChange={(e) => setAnswers((a) => ({ ...a, [String(q.id)]: e.target.value }))}223 rows={4}224 placeholder="Votre réponse…"225 aria-label={`Réponse à la question ${idx + 1}`}226 />227 )}228 </div>229 </Card>230231 {error && <ErrorBanner message={error} />}232233 <div className="flex items-center gap-2">234 <Button variant="secondary" size="sm" onClick={() => setIdx((i) => Math.max(0, i - 1))} disabled={idx === 0}>235 <ChevronLeft size={15} /> Précédente236 </Button>237 <Button variant="secondary" size="sm" onClick={() => setIdx((i) => Math.min(total - 1, i + 1))} disabled={idx === total - 1}>238 Suivante <ChevronRight size={15} />239 </Button>240 <span className="ml-auto text-[12.5px] text-muted tabular-nums">{answeredCount}/{total} répondue{answeredCount > 1 ? "s" : ""}</span>241 <Button onClick={() => setConfirmOpen(true)} disabled={busy}>242 {busy ? <Spinner /> : <><Send size={14} /> Remettre</>}243 </Button>244 </div>245246 <Modal open={confirmOpen} onClose={() => setConfirmOpen(false)} title="Remettre l'examen ?">247 <div className="space-y-4">248 <p className="text-sm text-fg">249 Vous avez répondu à <strong>{answeredCount}</strong> question{answeredCount > 1 ? "s" : ""} sur <strong>{total}</strong>.250 {answeredCount < total && " Les questions sans réponse seront comptées comme incorrectes."}251 </p>252 <div className="flex gap-2 justify-end">253 <Button variant="secondary" onClick={() => setConfirmOpen(false)}>Continuer l'examen</Button>254 <Button onClick={() => submit()} disabled={busy}>{busy ? <Spinner /> : "Remettre maintenant"}</Button>255 </div>256 </div>257 </Modal>258 </main>259 );260 }261262 // ---------- Résultats ----------263 if (result) {264 const pct = result.total ? Math.round((result.score / result.total) * 100) : 0;265 return (266 <main className="px-4 sm:px-6 py-6 max-w-3xl mx-auto w-full space-y-5">267 <Card className="p-6 text-center animate-fade-up">268 <h2 className="text-lg font-bold text-fg">Résultats de l'examen</h2>269 <p className={cn("text-5xl font-bold my-3", pct >= 70 ? "text-emerald-500" : pct >= 50 ? "text-amber-500" : "text-red-500")}>270 {pct} %271 </p>272 <p className="text-sm text-muted">{result.score} / {result.total} bonne{result.score > 1 ? "s" : ""} réponse{result.score > 1 ? "s" : ""}</p>273 {result.previous.length > 0 && (274 <p className="text-[12.5px] text-muted mt-2">275 Tentatives précédentes : {result.previous.map((p) => `${p.total ? Math.round((p.score / p.total) * 100) : 0} %`).join(" · ")}276 {result.previous[0] && result.previous[0].total ? (277 pct > Math.round((result.previous[0].score / result.previous[0].total) * 100)278 ? " — en progression, bravo !"279 : ""280 ) : ""}281 </p>282 )}283 </Card>284285 <Card className="p-5">286 <h3 className="text-sm font-semibold text-fg mb-1.5">Recommandation</h3>287 <p className="text-sm text-fg">{result.analysis.recommendation}</p>288 {result.analysis.weakest.length > 0 && (289 <div className="flex flex-wrap gap-1.5 mt-2.5">290 {result.analysis.weakest.map((w) => <Badge key={w} tone="amber"><AlertTriangle size={11} /> {w}</Badge>)}291 </div>292 )}293 </Card>294295 <div className="grid sm:grid-cols-2 gap-4">296 <Card className="p-5">297 <h3 className="text-sm font-semibold text-fg mb-3">Par concept</h3>298 <div className="space-y-3">299 {result.analysis.byConcept.map((c) => (300 <div key={c.name}>301 <div className="flex justify-between text-[12.5px] mb-1">302 <span className="text-fg truncate mr-2">{c.name}</span>303 <span className="text-muted tabular-nums shrink-0">{c.correct}/{c.total}</span>304 </div>305 <ProgressBar value={c.total ? c.correct / c.total : 0} tone={c.correct === c.total ? "green" : "brand"} />306 </div>307 ))}308 </div>309 </Card>310 <Card className="p-5">311 <h3 className="text-sm font-semibold text-fg mb-3">Par axe de compétence</h3>312 <div className="space-y-3">313 {result.analysis.byAxis.map((a) => (314 <div key={a.axis}>315 <div className="flex justify-between text-[12.5px] mb-1">316 <span className="text-fg">{AXIS_LABELS[a.axis] ?? a.axis}</span>317 <span className="text-muted tabular-nums">{a.correct}/{a.total}</span>318 </div>319 <ProgressBar value={a.total ? a.correct / a.total : 0} tone={a.correct === a.total ? "green" : "brand"} />320 </div>321 ))}322 </div>323 </Card>324 </div>325326 <section aria-label="Détail par question">327 <h3 className="text-[13px] font-semibold text-muted uppercase tracking-wide mb-3">Détail par question</h3>328 <div className="space-y-2">329 {Object.entries(result.detail).map(([qid, d], i) => (330 <Card key={qid} className="overflow-hidden">331 <button332 onClick={() => setOpenDetail((o) => ({ ...o, [qid]: !o[qid] }))}333 className="w-full flex items-center gap-2.5 px-4 py-3 text-left"334 aria-expanded={!!openDetail[qid]}335 >336 {d.correct337 ? <CheckCircle2 size={17} className="text-emerald-500 shrink-0" />338 : <XCircle size={17} className="text-red-500 shrink-0" />}339 <span className="text-sm font-medium text-fg">Question {i + 1}</span>340 <span className="text-[12px] text-muted ml-auto">{d.correct ? "Réussie" : d.answer ? "Incorrecte" : "Sans réponse"}</span>341 </button>342 {openDetail[qid] && (343 <div className="px-4 pb-4 space-y-3 border-t border-app pt-3">344 <div>345 <p className="text-[11.5px] font-semibold text-muted uppercase tracking-wide mb-1">Votre réponse</p>346 <p className="text-sm text-fg whitespace-pre-wrap">{d.answer || "—"}</p>347 </div>348 <div>349 <p className="text-[11.5px] font-semibold text-muted uppercase tracking-wide mb-1">Réponse attendue</p>350 <Markdown content={d.expected} />351 </div>352 {d.explanation && (353 <div>354 <p className="text-[11.5px] font-semibold text-muted uppercase tracking-wide mb-1">Explication</p>355 <Markdown content={d.explanation} />356 </div>357 )}358 </div>359 )}360 </Card>361 ))}362 </div>363 </section>364365 <Button variant="secondary" onClick={() => setResult(null)}>Retour aux examens</Button>366 </main>367 );368 }369370 // ---------- Liste des examens ----------371 return (372 <main className="px-4 sm:px-6 py-6 max-w-3xl mx-auto w-full">373 <div className="mb-5">374 <h2 className="text-lg font-bold text-fg">Examens blancs</h2>375 <p className="text-[13px] text-muted">Simulez les conditions réelles (chronométré) ou entraînez-vous sans pression (pratique).</p>376 </div>377378 {error && <ErrorBanner message={error} className="mb-4" />}379380 {exams === null ? (381 <div className="space-y-3">382 <Skeleton className="h-28 w-full" />383 <Skeleton className="h-28 w-full" />384 </div>385 ) : exams.length === 0 ? (386 <EmptyState387 icon={<GraduationCap />}388 title="Aucun examen blanc"389 description="Les examens blancs de ce cours n'ont pas encore été publiés."390 />391 ) : (392 <div className="space-y-3">393 {exams.map((e) => {394 const past = attempts.filter((a) => a.exam_id === e.id && a.finished_at);395 const kind = KIND_LABELS[e.kind] ?? { label: e.kind, tone: "neutral" as const };396 return (397 <Card key={e.id} className="p-5 animate-fade-up">398 <div className="flex flex-wrap items-start gap-3">399 <div className="min-w-0 flex-1">400 <div className="flex flex-wrap items-center gap-2 mb-1">401 <h3 className="font-semibold text-fg">{e.title}</h3>402 <Badge tone={kind.tone}>{kind.label}</Badge>403 </div>404 {e.description && <p className="text-[13px] text-muted mb-2">{e.description}</p>}405 <div className="flex flex-wrap gap-3 text-[12.5px] text-muted">406 <span className="inline-flex items-center gap-1"><Clock3 size={13} /> {e.duration_minutes} min</span>407 <span className="inline-flex items-center gap-1"><FileCheck2 size={13} /> {e.questionCount} questions</span>408 </div>409 </div>410 <Button size="sm" onClick={() => setModeFor(e)}>Commencer</Button>411 </div>412 {past.length > 0 && (413 <div className="mt-3 pt-3 border-t border-app flex flex-wrap gap-1.5" aria-label="Tentatives passées">414 {past.slice(0, 6).map((a) => {415 const p = a.total ? Math.round(((a.score ?? 0) / a.total) * 100) : 0;416 return (417 <Badge key={a.id} tone={p >= 70 ? "green" : p >= 50 ? "amber" : "red"} className="tabular-nums">418 {p} % · {fmtDateTime(a.finished_at!)}419 </Badge>420 );421 })}422 </div>423 )}424 </Card>425 );426 })}427 </div>428 )}429430 {/* Choix du mode */}431 <Modal open={!!modeFor} onClose={() => setModeFor(null)} title={modeFor ? `Démarrer : ${modeFor.title}` : undefined}>432 {modeFor && (433 <div className="space-y-3">434 <button435 onClick={() => start(modeFor, "timed")}436 disabled={busy}437 className="w-full text-left border border-app rounded-xl p-4 hover:border-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/30 transition-colors disabled:opacity-60"438 >439 <div className="flex items-center gap-2 font-semibold text-fg text-sm mb-0.5">440 <Timer size={16} className="text-amber-500" /> Mode chronométré — {modeFor.duration_minutes} min441 </div>442 <p className="text-[12.5px] text-muted">443 Conditions réelles : compte à rebours visible, remise automatique à 0:00. Recommandé avant l'examen.444 </p>445 </button>446 <button447 onClick={() => start(modeFor, "practice")}448 disabled={busy}449 className="w-full text-left border border-app rounded-xl p-4 hover:border-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/30 transition-colors disabled:opacity-60"450 >451 <div className="flex items-center gap-2 font-semibold text-fg text-sm mb-0.5">452 <GraduationCap size={16} className="text-brand-500" /> Mode pratique — sans limite de temps453 </div>454 <p className="text-[12.5px] text-muted">455 Prenez le temps de réfléchir à chaque question. Idéal pour une première tentative.456 </p>457 </button>458 {busy && <div className="flex justify-center py-1"><Spinner /></div>}459 {error && <ErrorBanner message={error} />}460 </div>461 )}462 </Modal>463 </main>464 );465}466