feat: complete exam lifecycle — AI generation, submission, student session, and results

- Backend: AI generation fallbacks when OpenAI not configured, full exam
  submission saving all params (difficulty, rubric, entity, grading system,
  approval workflow) and creating linked question records per section
- Backend: new exam session controller with get_session, autosave, submit,
  status, and results endpoints; student attempt/answer/score models
- Backend: new controllers for entities, approval workflows, exam schedules
- Frontend: exam session split-layout with passage panel, question types
  (MCQ, T/F/NG, gap-fill, writing, speaking), timer, and review dialog
- Frontend: results page with percentage score, per-answer breakdown table
- Frontend: generation page dynamic dropdowns, full payload submission
- Frontend: updated types for ExamSessionSection, ExamQuestion options

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-16 16:53:09 +04:00
parent 1d7ebea2af
commit b78124bb7b
15 changed files with 3961 additions and 433 deletions

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useExamSession, useExamAutoSave, useExamSubmit } from "@/hooks/queries/useExamSession";
import type { ExamAnswer, ExamQuestion, ExamSessionSection } from "@/types";
import type { ExamAnswer, ExamOptionItem, ExamQuestion, ExamSessionSection } from "@/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
@@ -27,6 +27,11 @@ function normalizeType(t: string | null | undefined) {
return (t ?? "").toLowerCase().replace(/\s+/g, "_");
}
function normalizeOption(o: ExamOptionItem): { label: string; text: string } {
if (typeof o === "string") return { label: o, text: o };
return { label: o.label ?? o.text ?? "", text: o.text ?? o.label ?? "" };
}
function countWords(s: string) {
return s.trim() ? s.trim().split(/\s+/).length : 0;
}
@@ -222,18 +227,19 @@ export default function ExamSession() {
const a = answers.get(q.id);
if (!a) return null;
const nt = normalizeType(q.type);
const opts = (q.options ?? []).map(normalizeOption);
if (nt.includes("listen") || q.audio_url) {
return (
<div className="space-y-4">
<ListeningPlayer audioUrl={q.audio_url} audioBase64={q.audio_base64} />
{q.options?.length ? (
{opts.length ? (
<RadioGroup
value={typeof a.answer === "string" ? a.answer : ""}
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
className="space-y-2"
>
{q.options.map((o) => (
{opts.map((o) => (
<div key={o.label} className="flex items-center space-x-2">
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
@@ -249,7 +255,7 @@ export default function ExamSession() {
const selected = Array.isArray(a.answer) ? a.answer : [];
return (
<div className="space-y-3">
{q.options?.map((o) => (
{opts.map((o) => (
<div key={o.label} className="flex items-center space-x-2">
<Checkbox
id={`${q.id}-${o.label}`}
@@ -267,7 +273,7 @@ export default function ExamSession() {
);
}
if (nt.includes("gap")) {
if (nt.includes("gap") || nt.includes("fill") || nt.includes("short_answer") || nt.includes("summary")) {
return (
<Input
value={typeof a.answer === "string" ? a.answer : ""}
@@ -279,12 +285,12 @@ export default function ExamSession() {
}
if (nt.includes("true") || nt.includes("tfng") || nt === "yes_no_not_given") {
const opts = q.options?.length
? q.options
const tfOpts = opts.length
? opts
: [
{ label: "T", text: "True" },
{ label: "F", text: "False" },
{ label: "NG", text: "Not Given" },
{ label: "TRUE", text: "TRUE" },
{ label: "FALSE", text: "FALSE" },
{ label: "NOT GIVEN", text: "NOT GIVEN" },
];
return (
<RadioGroup
@@ -292,7 +298,7 @@ export default function ExamSession() {
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
className="space-y-2"
>
{opts.map((o) => (
{tfOpts.map((o) => (
<div key={o.label} className="flex items-center space-x-2">
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
@@ -338,7 +344,7 @@ export default function ExamSession() {
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
className="space-y-2"
>
{q.options?.map((o) => (
{opts.map((o) => (
<div key={o.label} className="flex items-center space-x-2">
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
@@ -389,13 +395,39 @@ export default function ExamSession() {
</header>
<div className="flex flex-1 min-h-0 flex-col md:flex-row">
<ScrollArea className="flex-1 p-6">
{section?.passage_text ? (
<ScrollArea className="w-full md:w-1/2 border-r p-6">
<div className="prose prose-sm dark:prose-invert max-w-none">
<h3 className="text-base font-semibold mb-2">{section.title}</h3>
{section.difficulty && (
<span className="inline-block text-xs font-medium bg-primary/10 text-primary px-2 py-0.5 rounded mb-3">
Level: {section.difficulty}
</span>
)}
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed whitespace-pre-line">
{section.passage_text}
</div>
</div>
</ScrollArea>
) : null}
<ScrollArea className={cn("flex-1 p-6", section?.passage_text && "md:w-1/2")}>
{question ? (
<Card className="border-none shadow-none">
<CardContent className="space-y-6 p-0">
{question.passage_text ? (
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed">{question.passage_text}</div>
{section?.instructions_text ? (
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 text-sm leading-relaxed border border-blue-200 dark:border-blue-800">
<p className="font-medium text-blue-800 dark:text-blue-200 mb-1">Instructions</p>
{section.instructions_text}
</div>
) : null}
<div className="flex items-center gap-3">
<span className="inline-flex items-center justify-center h-7 w-7 rounded-full bg-primary text-primary-foreground text-xs font-bold">
{questionIdx + 1}
</span>
<span className="text-xs text-muted-foreground">
of {section?.questions.length ?? 0} · {question.marks} mark{question.marks !== 1 ? "s" : ""}
</span>
</div>
<div className="prose prose-sm dark:prose-invert max-w-none">
<p className="font-medium">{question.stem}</p>
</div>