From b78124bb7b55bd4d97a5a392b6f9d124e7eeaeee Mon Sep 17 00:00:00 2001 From: Yamen Ahmad Date: Thu, 16 Apr 2026 16:53:09 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20complete=20exam=20lifecycle=20=E2=80=94?= =?UTF-8?q?=20AI=20generation,=20submission,=20student=20session,=20and=20?= =?UTF-8?q?results?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/StudentLayout.tsx | 8 +- src/components/student/ExamPopup.tsx | 119 +++ src/pages/ApprovalWorkflowsPage.tsx | 565 ++++++++-- src/pages/AssignmentsPage.tsx | 479 ++++++++- src/pages/ExamStructuresPage.tsx | 1423 ++++++++++++++++++++++++-- src/pages/GenerationPage.tsx | 581 +++++++++-- src/pages/RubricsPage.tsx | 818 +++++++++++++-- src/pages/student/ExamResults.tsx | 116 ++- src/pages/student/ExamSession.tsx | 62 +- src/services/assignments.service.ts | 41 +- src/services/exams.service.ts | 33 + src/services/generation.service.ts | 4 +- src/types/assignment.ts | 57 ++ src/types/exam-session.ts | 15 +- src/types/exam.ts | 73 ++ 15 files changed, 3961 insertions(+), 433 deletions(-) create mode 100644 src/components/student/ExamPopup.tsx diff --git a/src/components/StudentLayout.tsx b/src/components/StudentLayout.tsx index 3fc64e3..9fca00f 100644 --- a/src/components/StudentLayout.tsx +++ b/src/components/StudentLayout.tsx @@ -1,4 +1,5 @@ import RoleLayout, { NavGroup } from "./RoleLayout"; +import ExamPopup from "./student/ExamPopup"; import { LayoutDashboard, BookOpen, ClipboardList, BarChart3, CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks, @@ -47,5 +48,10 @@ const navGroups: NavGroup[] = [ ]; export default function StudentLayout() { - return ; + return ( + <> + + + + ); } diff --git a/src/components/student/ExamPopup.tsx b/src/components/student/ExamPopup.tsx new file mode 100644 index 0000000..5fc037f --- /dev/null +++ b/src/components/student/ExamPopup.tsx @@ -0,0 +1,119 @@ +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Clock, Calendar, PlayCircle, AlertCircle } from "lucide-react"; +import { assignmentsService } from "@/services/assignments.service"; +import type { StudentExamAssignment } from "@/types"; + +export default function ExamPopup() { + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [dismissed, setDismissed] = useState>(new Set()); + + const { data } = useQuery({ + queryKey: ["student-my-exams"], + queryFn: () => assignmentsService.getStudentExams(), + refetchInterval: 30000, + }); + + const exams = (data?.items ?? []) as StudentExamAssignment[]; + const pendingExams = exams.filter((e) => !dismissed.has(e.id)); + + useEffect(() => { + if (pendingExams.length > 0 && !open) { + setOpen(true); + } + }, [pendingExams.length]); + + if (pendingExams.length === 0) return null; + + const formatDate = (iso: string | null) => { + if (!iso) return "—"; + const d = new Date(iso); + return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) + + " " + d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }); + }; + + const handleStart = (exam: StudentExamAssignment) => { + setOpen(false); + navigate(`/student/exam/${exam.exam_id}/session`); + }; + + const handleDismiss = (id: number) => { + setDismissed((prev) => new Set([...prev, id])); + const remaining = pendingExams.filter((e) => e.id !== id); + if (remaining.length === 0) setOpen(false); + }; + + return ( + + + + + + Upcoming Exams ({pendingExams.length}) + + +
+ {pendingExams.map((exam) => ( +
+
+

{exam.exam_title || exam.schedule_name}

+ + {exam.schedule_state === "active" ? "Active Now" : exam.schedule_state} + +
+ + {exam.schedule_name && exam.exam_title && ( +

{exam.schedule_name}

+ )} + +
+ + + From: {formatDate(exam.start_date)} + + + + To: {formatDate(exam.end_date)} + +
+ +
+ + + {!exam.can_start && ( + + {exam.schedule_state === "planned" + ? "Exam will be available once it becomes active" + : "Exam is not currently active"} + + )} +
+
+ ))} +
+
+
+ ); +} diff --git a/src/pages/ApprovalWorkflowsPage.tsx b/src/pages/ApprovalWorkflowsPage.tsx index f722e9a..f2e878b 100644 --- a/src/pages/ApprovalWorkflowsPage.tsx +++ b/src/pages/ApprovalWorkflowsPage.tsx @@ -1,119 +1,556 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Plus, CheckCircle, XCircle, Clock } from "lucide-react"; -import AiGradingAssistant from "@/components/ai/AiGradingAssistant"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Plus, + CheckCircle, + XCircle, + Clock, + Loader2, + Trash2, + ChevronRight, + ShieldCheck, + FileText, +} from "lucide-react"; import { useToast } from "@/hooks/use-toast"; +import { api } from "@/lib/api-client"; -const workflows = [ - { - id: 1, name: "Exam Content Review", status: "In Progress", - steps: [ - { name: "Initial Review", assignee: "Dr. Smith", status: "Approved" }, - { name: "Quality Check", assignee: "Prof. Lee", status: "Pending" }, - { name: "Final Approval", assignee: "Admin", status: "Waiting" }, - ], - }, - { - id: 2, name: "Rubric Approval", status: "Completed", - steps: [ - { name: "Draft Review", assignee: "Mr. Kim", status: "Approved" }, - { name: "Academic Board", assignee: "Dr. Smith", status: "Approved" }, - ], - }, - { - id: 3, name: "New Exam Structure", status: "Rejected", - steps: [ - { name: "Content Review", assignee: "Prof. Lee", status: "Approved" }, - { name: "Standards Check", assignee: "Dr. Smith", status: "Rejected" }, - ], - }, -]; +interface WorkflowStep { + id: number; + sequence: number; + approver_id: number | null; + approver_name: string; + status: string; + comment: string; + auto_escalate: boolean; + max_days: number; + acted_at: string | null; +} + +interface Workflow { + id: number; + name: string; + type: string; + entity_id: number | null; + entity_name: string | null; + allow_bypass: boolean; + active: boolean; + steps: WorkflowStep[]; + created: string; +} + +interface ApprovalRequest { + id: number; + workflow_id: number | null; + workflow_name: string; + res_model: string; + res_id: number; + state: string; + requester_id: number | null; + requester_name: string; + current_stage_id: number | null; + current_stage_sequence: number | null; + bypass_reason: string; + created_at: string | null; +} + +interface UserItem { + id: number; + name: string; + login: string; +} const statusIcon = (s: string) => { - if (s === "Approved") return ; - if (s === "Rejected") return ; - return ; + if (s === "approved") return ; + if (s === "rejected") return ; + return ; +}; + +const stateBadge = (state: string) => { + const map: Record = { + approved: "default", + in_progress: "secondary", + rejected: "destructive", + draft: "outline", + }; + return ( + + {state.replace(/_/g, " ")} + + ); }; export default function ApprovalWorkflowsPage() { const { toast } = useToast(); + const qc = useQueryClient(); + + const [createOpen, setCreateOpen] = useState(false); + const [formName, setFormName] = useState(""); + const [formType, setFormType] = useState("custom"); + const [formSteps, setFormSteps] = useState<{ approver_id: string }[]>([ + { approver_id: "" }, + { approver_id: "" }, + ]); + + const [rejectDialog, setRejectDialog] = useState(null); + const [rejectComment, setRejectComment] = useState(""); + + const workflowsQ = useQuery({ + queryKey: ["approval-workflows"], + queryFn: () => api.get<{ items: Workflow[]; total: number }>("/approval-workflows"), + }); + + const requestsQ = useQuery({ + queryKey: ["approval-requests"], + queryFn: () => api.get<{ items: ApprovalRequest[]; total: number }>("/approval-requests"), + }); + + const usersQ = useQuery({ + queryKey: ["approval-users"], + queryFn: () => api.get<{ items: UserItem[] }>("/approval-users"), + }); + + const workflows = workflowsQ.data?.items ?? []; + const requests = requestsQ.data?.items ?? []; + const users = usersQ.data?.items ?? []; + + const createMut = useMutation({ + mutationFn: (data: Record) => api.post("/approval-workflows", data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["approval-workflows"] }); + setCreateOpen(false); + resetForm(); + toast({ title: "Workflow created" }); + }, + onError: (e: Error) => toast({ variant: "destructive", title: "Create failed", description: e.message }), + }); + + const deleteMut = useMutation({ + mutationFn: (id: number) => api.delete(`/approval-workflows/${id}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["approval-workflows"] }); + toast({ title: "Workflow deleted" }); + }, + }); + + const approveMut = useMutation({ + mutationFn: (id: number) => api.post(`/approval-requests/${id}/approve`, {}), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["approval-requests"] }); + qc.invalidateQueries({ queryKey: ["approval-workflows"] }); + toast({ title: "Request approved" }); + }, + onError: (e: Error) => toast({ variant: "destructive", title: "Approve failed", description: e.message }), + }); + + const rejectMut = useMutation({ + mutationFn: ({ id, comment }: { id: number; comment: string }) => + api.post(`/approval-requests/${id}/reject`, { comment }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["approval-requests"] }); + qc.invalidateQueries({ queryKey: ["approval-workflows"] }); + setRejectDialog(null); + setRejectComment(""); + toast({ title: "Request rejected" }); + }, + onError: (e: Error) => toast({ variant: "destructive", title: "Reject failed", description: e.message }), + }); + + const resetForm = () => { + setFormName(""); + setFormType("custom"); + setFormSteps([{ approver_id: "" }, { approver_id: "" }]); + }; + + const handleCreate = () => { + createMut.mutate({ + name: formName.trim(), + type: formType, + steps: formSteps + .filter((s) => s.approver_id) + .map((s) => ({ approver_id: Number(s.approver_id) })), + }); + }; + + const pendingRequests = requests.filter((r) => r.state === "in_progress"); + const completedRequests = requests.filter((r) => r.state !== "in_progress"); + return (
+ {/* Header */}

Approval Workflows

-

Manage multi-step approval processes for exam content.

+

+ Manage multi-step approval processes for exam content. +

- + { + setCreateOpen(open); + if (!open) resetForm(); + }} + > - + - Create Workflow + + Create Workflow + Define a multi-step approval process. +
-
- - + + setFormName(e.target.value)} + placeholder="e.g. Exam Content Review" + />
- - + + + + + Custom + Exam Publication + Assignment Publication + Content Publication +
- + + {formSteps.map((step, i) => ( +
+
+ + {formSteps.length > 1 && ( + + )} +
+ +
+ ))} + +
+ + + +
-
+ {/* Loading */} + {(workflowsQ.isLoading || requestsQ.isLoading) && ( +
+ +
+ )} + + {/* Pending Requests */} + {pendingRequests.length > 0 && ( +
+

+ + Pending Approval ({pendingRequests.length}) +

+ {pendingRequests.map((req) => { + const wf = workflows.find((w) => w.id === req.workflow_id); + return ( + + +
+
+

{req.workflow_name}

+

+ Requested by {req.requester_name} ·{" "} + {req.res_model.replace("encoach.", "")} #{req.res_id} +

+
+ {stateBadge(req.state)} +
+ + {wf && ( +
+ {wf.steps.map((step, i) => { + const isActive = step.id === req.current_stage_id; + return ( +
+
+ {statusIcon(step.status)} +
+

Step {i + 1}

+

+ {step.approver_name} +

+
+
+ {i < wf.steps.length - 1 && ( + + )} +
+ ); + })} +
+ )} + +
+ + +
+
+
+ ); + })} +
+ )} + + {/* Workflow Templates */} +
+

+ + Workflow Templates ({workflows.length}) +

+ {workflows.length === 0 && !workflowsQ.isLoading && ( + + + No workflows defined yet. Create one to get started. + + + )} {workflows.map((wf) => (
- {wf.name} - {wf.status} +
+ {wf.name} + + {wf.type.replace(/_/g, " ")} + +
+
-
+
{wf.steps.map((step, i) => ( -
+
- {statusIcon(step.status)} +
+ {i + 1} +
-

{step.name}

-

{step.assignee}

+

{step.approver_name}

+

+ {step.max_days}d limit + {step.auto_escalate ? " · auto-escalate" : ""} +

- {i < wf.steps.length - 1 &&
} + {i < wf.steps.length - 1 && ( + + )}
))}
- {wf.status === "In Progress" && ( -
- { - toast({ title: "AI Grade Accepted", description: `Marks: ${marks}/100 applied with AI feedback.` }); - }} /> -
- - -
-
- )} ))}
+ + {/* Completed Requests */} + {completedRequests.length > 0 && ( +
+

+ + Recent Decisions ({completedRequests.length}) +

+ {completedRequests.map((req) => ( + + +
+

{req.workflow_name}

+

+ {req.requester_name} · {req.res_model.replace("encoach.", "")} # + {req.res_id} + {req.created_at && ( + <> + {" "} + · {new Date(req.created_at).toLocaleDateString()} + + )} +

+
+ {stateBadge(req.state)} +
+
+ ))} +
+ )} + + {/* Reject Dialog */} + { + if (!open) { + setRejectDialog(null); + setRejectComment(""); + } + }} + > + + + Reject Request + Provide a reason for rejection. + +
+ +