SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
20.9 KB · 466 lines tsx
Raw Blame History
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