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 b9df9b5299
commit 50f58dc995
40 changed files with 5982 additions and 503 deletions

View File

@@ -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 <RoleLayout navGroups={navGroups} role="student" />;
return (
<>
<RoleLayout navGroups={navGroups} role="student" />
<ExamPopup />
</>
);
}

View File

@@ -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<Set<number>>(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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-primary" />
Upcoming Exams ({pendingExams.length})
</DialogTitle>
</DialogHeader>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
{pendingExams.map((exam) => (
<div key={exam.id} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">{exam.exam_title || exam.schedule_name}</h3>
<Badge
variant={exam.schedule_state === "active" ? "default" : "secondary"}
className="capitalize text-xs"
>
{exam.schedule_state === "active" ? "Active Now" : exam.schedule_state}
</Badge>
</div>
{exam.schedule_name && exam.exam_title && (
<p className="text-xs text-muted-foreground">{exam.schedule_name}</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
From: {formatDate(exam.start_date)}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
To: {formatDate(exam.end_date)}
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
disabled={!exam.can_start}
onClick={() => handleStart(exam)}
className="gap-1.5"
>
<PlayCircle className="h-3.5 w-3.5" />
{exam.can_start ? "Start Exam" : "Not Available Yet"}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDismiss(exam.id)}
>
Dismiss
</Button>
{!exam.can_start && (
<span className="text-[11px] text-muted-foreground italic ml-auto">
{exam.schedule_state === "planned"
? "Exam will be available once it becomes active"
: "Exam is not currently active"}
</span>
)}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 <CheckCircle className="h-4 w-4 text-success" />;
if (s === "Rejected") return <XCircle className="h-4 w-4 text-destructive" />;
return <Clock className="h-4 w-4 text-warning" />;
if (s === "approved") return <CheckCircle className="h-4 w-4 text-green-600" />;
if (s === "rejected") return <XCircle className="h-4 w-4 text-destructive" />;
return <Clock className="h-4 w-4 text-amber-500" />;
};
const stateBadge = (state: string) => {
const map: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
approved: "default",
in_progress: "secondary",
rejected: "destructive",
draft: "outline",
};
return (
<Badge variant={map[state] || "outline"} className="capitalize">
{state.replace(/_/g, " ")}
</Badge>
);
};
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<number | null>(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<string, unknown>) => 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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Approval Workflows</h1>
<p className="text-muted-foreground">Manage multi-step approval processes for exam content.</p>
<p className="text-muted-foreground">
Manage multi-step approval processes for exam content.
</p>
</div>
<Dialog>
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) resetForm();
}}
>
<DialogTrigger asChild>
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Workflow</Button>
<Button size="sm">
<Plus className="h-4 w-4 mr-1" /> Create Workflow
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Workflow</DialogTitle></DialogHeader>
<DialogHeader>
<DialogTitle>Create Workflow</DialogTitle>
<DialogDescription>Define a multi-step approval process.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Workflow Name</Label><Input placeholder="e.g. Exam Content Review" /></div>
<div className="space-y-2">
<Label>Step 1 Assignee</Label>
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger>
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="kim">Mr. Kim</SelectItem></SelectContent>
</Select>
<Label>
Workflow Name <span className="text-destructive">*</span>
</Label>
<Input
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="e.g. Exam Content Review"
/>
</div>
<div className="space-y-2">
<Label>Step 2 Assignee</Label>
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger>
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="admin">Admin</SelectItem></SelectContent>
<Label>Type</Label>
<Select value={formType} onValueChange={setFormType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="custom">Custom</SelectItem>
<SelectItem value="exam_publish">Exam Publication</SelectItem>
<SelectItem value="assignment_publish">Assignment Publication</SelectItem>
<SelectItem value="content_publish">Content Publication</SelectItem>
</SelectContent>
</Select>
</div>
<Button className="w-full">Create</Button>
{formSteps.map((step, i) => (
<div key={i} className="space-y-1">
<div className="flex items-center justify-between">
<Label>Step {i + 1} Approver</Label>
{formSteps.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() =>
setFormSteps((s) => s.filter((_, idx) => idx !== i))
}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
<Select
value={step.approver_id}
onValueChange={(v) =>
setFormSteps((s) =>
s.map((st, idx) => (idx === i ? { ...st, approver_id: v } : st))
)
}
>
<SelectTrigger>
<SelectValue placeholder="Select approver" />
</SelectTrigger>
<SelectContent>
{users.map((u) => (
<SelectItem key={u.id} value={String(u.id)}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => setFormSteps((s) => [...s, { approver_id: "" }])}
>
<Plus className="h-3 w-3 mr-1" /> Add Step
</Button>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button
disabled={!formName.trim() || createMut.isPending}
onClick={handleCreate}
>
{createMut.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Creating...
</>
) : (
"Create"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="space-y-4">
{/* Loading */}
{(workflowsQ.isLoading || requestsQ.isLoading) && (
<div className="flex justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{/* Pending Requests */}
{pendingRequests.length > 0 && (
<div className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Clock className="h-5 w-5 text-amber-500" />
Pending Approval ({pendingRequests.length})
</h2>
{pendingRequests.map((req) => {
const wf = workflows.find((w) => w.id === req.workflow_id);
return (
<Card key={req.id} className="border-amber-200 bg-amber-50/30">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div>
<p className="font-semibold">{req.workflow_name}</p>
<p className="text-xs text-muted-foreground">
Requested by {req.requester_name} &middot;{" "}
{req.res_model.replace("encoach.", "")} #{req.res_id}
</p>
</div>
{stateBadge(req.state)}
</div>
{wf && (
<div className="flex items-center gap-2 flex-wrap mb-3">
{wf.steps.map((step, i) => {
const isActive = step.id === req.current_stage_id;
return (
<div key={step.id} className="flex items-center gap-2">
<div
className={`flex items-center gap-2 rounded-lg border p-3 min-w-[160px] transition-all ${
isActive
? "border-amber-400 bg-amber-50 ring-2 ring-amber-200"
: step.status === "approved"
? "border-green-200 bg-green-50/50"
: step.status === "rejected"
? "border-red-200 bg-red-50/50"
: "bg-muted/30"
}`}
>
{statusIcon(step.status)}
<div>
<p className="text-sm font-medium">Step {i + 1}</p>
<p className="text-xs text-muted-foreground">
{step.approver_name}
</p>
</div>
</div>
{i < wf.steps.length - 1 && (
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
)}
</div>
);
})}
</div>
)}
<div className="flex gap-2">
<Button
size="sm"
disabled={approveMut.isPending}
onClick={() => approveMut.mutate(req.id)}
>
{approveMut.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
) : (
<CheckCircle className="h-3.5 w-3.5 mr-1" />
)}
Approve
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setRejectDialog(req.id)}
>
<XCircle className="h-3.5 w-3.5 mr-1" />
Reject
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Workflow Templates */}
<div className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
<ShieldCheck className="h-5 w-5" />
Workflow Templates ({workflows.length})
</h2>
{workflows.length === 0 && !workflowsQ.isLoading && (
<Card className="border-dashed">
<CardContent className="p-8 text-center text-muted-foreground">
No workflows defined yet. Create one to get started.
</CardContent>
</Card>
)}
{workflows.map((wf) => (
<Card key={wf.id} className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">{wf.name}</CardTitle>
<Badge variant={wf.status === "Completed" ? "default" : wf.status === "Rejected" ? "destructive" : "secondary"}>{wf.status}</Badge>
<div className="flex items-center gap-3">
<CardTitle className="text-base font-semibold">{wf.name}</CardTitle>
<Badge variant="outline" className="text-xs capitalize">
{wf.type.replace(/_/g, " ")}
</Badge>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => {
if (confirm(`Delete workflow "${wf.name}"?`)) deleteMut.mutate(wf.id);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
{wf.steps.map((step, i) => (
<div key={i} className="flex items-center gap-2">
<div key={step.id} className="flex items-center gap-2">
<div className="flex items-center gap-2 rounded-lg border p-3 bg-muted/30 min-w-[160px]">
{statusIcon(step.status)}
<div className="h-6 w-6 rounded-full bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
{i + 1}
</div>
<div>
<p className="text-sm font-medium">{step.name}</p>
<p className="text-xs text-muted-foreground">{step.assignee}</p>
<p className="text-sm font-medium">{step.approver_name}</p>
<p className="text-xs text-muted-foreground">
{step.max_days}d limit
{step.auto_escalate ? " · auto-escalate" : ""}
</p>
</div>
</div>
{i < wf.steps.length - 1 && <div className="w-8 h-0.5 bg-border" />}
{i < wf.steps.length - 1 && (
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
)}
</div>
))}
</div>
{wf.status === "In Progress" && (
<div className="space-y-4 mt-4">
<AiGradingAssistant onAccept={(marks, feedback) => {
toast({ title: "AI Grade Accepted", description: `Marks: ${marks}/100 applied with AI feedback.` });
}} />
<div className="flex gap-2">
<Button size="sm" variant="default">Approve</Button>
<Button size="sm" variant="outline">Reject</Button>
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
{/* Completed Requests */}
{completedRequests.length > 0 && (
<div className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
<FileText className="h-5 w-5" />
Recent Decisions ({completedRequests.length})
</h2>
{completedRequests.map((req) => (
<Card key={req.id} className="border-0 shadow-sm">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="font-medium">{req.workflow_name}</p>
<p className="text-xs text-muted-foreground">
{req.requester_name} &middot; {req.res_model.replace("encoach.", "")} #
{req.res_id}
{req.created_at && (
<>
{" "}
&middot; {new Date(req.created_at).toLocaleDateString()}
</>
)}
</p>
</div>
{stateBadge(req.state)}
</CardContent>
</Card>
))}
</div>
)}
{/* Reject Dialog */}
<Dialog
open={rejectDialog !== null}
onOpenChange={(open) => {
if (!open) {
setRejectDialog(null);
setRejectComment("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Reject Request</DialogTitle>
<DialogDescription>Provide a reason for rejection.</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label>Comment</Label>
<Textarea
value={rejectComment}
onChange={(e) => setRejectComment(e.target.value)}
placeholder="Reason for rejection..."
rows={3}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRejectDialog(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={rejectMut.isPending}
onClick={() => {
if (rejectDialog !== null) {
rejectMut.mutate({ id: rejectDialog, comment: rejectComment });
}
}}
>
{rejectMut.isPending ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
Reject
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,91 +1,320 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Plus, Trash2 } from "lucide-react";
import { useAssignments, useCreateAssignment } from "@/hooks/queries";
import {
Search, Plus, Trash2, Loader2, Calendar, Users, Clock,
CheckCircle2, Archive, AlertTriangle, PlayCircle, Eye
} from "lucide-react";
import { assignmentsService } from "@/services/assignments.service";
import { api } from "@/lib/api-client";
import { useBatches, useStudents } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { ExamSchedule, ScheduleState, ExamScheduleCreateRequest } from "@/types";
interface CustomExam {
id: number;
title: string;
status: string;
}
interface Entity {
id: number;
name: string;
}
const STATE_TABS: { key: ScheduleState | "all"; label: string; icon: React.ReactNode }[] = [
{ key: "all", label: "All", icon: <Eye className="h-3.5 w-3.5" /> },
{ key: "active", label: "Active", icon: <PlayCircle className="h-3.5 w-3.5" /> },
{ key: "planned", label: "Planned", icon: <Clock className="h-3.5 w-3.5" /> },
{ key: "past", label: "Past", icon: <CheckCircle2 className="h-3.5 w-3.5" /> },
{ key: "start_expired", label: "Start Expired", icon: <AlertTriangle className="h-3.5 w-3.5" /> },
{ key: "archived", label: "Archived", icon: <Archive className="h-3.5 w-3.5" /> },
];
const OPTION_FIELDS: { key: keyof ExamScheduleCreateRequest; label: string }[] = [
{ key: "full_length", label: "Full length exams" },
{ key: "generate_different", label: "Generate different exams" },
{ key: "auto_release_results", label: "Auto release results" },
{ key: "auto_start", label: "Auto start exam" },
{ key: "official_exam", label: "Official Exam" },
{ key: "hide_assignee_details", label: "Hide Assignees Details from Teachers" },
];
const stateBadgeVariant = (s: ScheduleState): "default" | "secondary" | "outline" | "destructive" => {
switch (s) {
case "active": return "default";
case "planned": return "secondary";
case "past": return "outline";
case "start_expired": return "destructive";
case "archived": return "outline";
default: return "secondary";
}
};
type FormState = {
name: string;
exam_id: string;
entity_id: string;
start_date: string;
start_time: string;
end_date: string;
end_time: string;
assign_mode: "entity" | "batch" | "individual";
batch_ids: Set<number>;
student_ids: Set<number>;
full_length: boolean;
generate_different: boolean;
auto_release_results: boolean;
auto_start: boolean;
official_exam: boolean;
hide_assignee_details: boolean;
};
const emptyForm = (): FormState => ({
name: "",
exam_id: "",
entity_id: "",
start_date: "",
start_time: "09:00",
end_date: "",
end_time: "17:00",
assign_mode: "batch",
batch_ids: new Set(),
student_ids: new Set(),
full_length: true,
generate_different: false,
auto_release_results: false,
auto_start: false,
official_exam: false,
hide_assignee_details: false,
});
export default function AssignmentsPage() {
const [search, setSearch] = useState("");
const [stateFilter, setStateFilter] = useState<ScheduleState | "all">("all");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ title: "", entity_id: "", start_date: "", end_date: "" });
const [form, setForm] = useState<FormState>(emptyForm());
const { toast } = useToast();
const qc = useQueryClient();
const assignmentsQ = useAssignments({ size: 200 });
const createMut = useCreateAssignment();
const schedulesQ = useQuery({
queryKey: ["exam-schedules", stateFilter],
queryFn: () => assignmentsService.listSchedules(
stateFilter === "all" ? {} : { state: stateFilter }
),
});
const schedules = schedulesQ.data?.items ?? [];
const items = assignmentsQ.data?.items ?? assignmentsQ.data?.data ?? [];
const assignments = Array.isArray(items) ? items : [];
const q = search.toLowerCase();
const filtered = assignments.filter(a =>
a.title?.toLowerCase().includes(q) || a.entity_name?.toLowerCase().includes(q),
const customExamsQ = useQuery({
queryKey: ["custom-exams-for-assign"],
queryFn: () => api.get<{ items: CustomExam[]; total: number }>("/exam/custom/list?per_page=200"),
});
const publishedExams = (customExamsQ.data?.items ?? []).filter(
(e) => e.status === "published" || e.status === "draft"
);
const loading = assignmentsQ.isLoading;
const entitiesQ = useQuery({
queryKey: ["entities-for-assign"],
queryFn: () => api.get<{ items: Entity[] }>("/entities"),
});
const entities = entitiesQ.data?.items ?? [];
const batchesQ = useBatches({ size: 200 });
const batches = batchesQ.data?.items ?? [];
const studentsQ = useStudents({ size: 200 });
const students = studentsQ.data?.items ?? [];
const stateCounts = useMemo(() => {
const all = schedulesQ.data?.items ?? [];
const counts: Record<string, number> = { all: all.length };
for (const s of all) {
counts[s.state] = (counts[s.state] || 0) + 1;
}
return counts;
}, [schedulesQ.data]);
const allSchedulesQ = useQuery({
queryKey: ["exam-schedules", "all"],
queryFn: () => assignmentsService.listSchedules({}),
enabled: stateFilter !== "all",
});
const totalCounts = useMemo(() => {
const items = stateFilter === "all" ? schedules : (allSchedulesQ.data?.items ?? []);
const counts: Record<string, number> = { all: items.length };
for (const s of items) {
counts[s.state] = (counts[s.state] || 0) + 1;
}
return counts;
}, [stateFilter, schedules, allSchedulesQ.data]);
const createMut = useMutation({
mutationFn: (data: ExamScheduleCreateRequest) => assignmentsService.createSchedule(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
setCreateOpen(false);
setForm(emptyForm());
toast({ title: "Exam scheduled successfully" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Error", description: err.message }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => assignmentsService.deleteSchedule(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
toast({ title: "Schedule deleted" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Error", description: err.message }),
});
const archiveMut = useMutation({
mutationFn: (id: number) => assignmentsService.archiveSchedule(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
toast({ title: "Schedule archived" });
},
});
const q = search.toLowerCase();
const filtered = schedules.filter(
(s) => s.name.toLowerCase().includes(q) || s.exam_title.toLowerCase().includes(q)
);
function handleCreate() {
createMut.mutate(
{ title: form.title, entity_id: Number(form.entity_id) || 0, start_date: form.start_date, end_date: form.end_date },
{
onSuccess: () => { setCreateOpen(false); setForm({ title: "", entity_id: "", start_date: "", end_date: "" }); toast({ title: "Assignment created" }); },
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
},
);
const startDt = form.start_date && form.start_time ? `${form.start_date}T${form.start_time}:00` : "";
const endDt = form.end_date && form.end_time ? `${form.end_date}T${form.end_time}:00` : "";
if (!form.name || !form.exam_id || !startDt || !endDt) {
toast({ variant: "destructive", title: "Missing fields", description: "Please fill in name, exam, start date/time, and end date/time." });
return;
}
createMut.mutate({
name: form.name,
exam_id: Number(form.exam_id),
entity_id: form.entity_id ? Number(form.entity_id) : undefined,
start_date: startDt,
end_date: endDt,
assign_mode: form.assign_mode,
batch_ids: [...form.batch_ids],
student_ids: [...form.student_ids],
full_length: form.full_length,
generate_different: form.generate_different,
auto_release_results: form.auto_release_results,
auto_start: form.auto_start,
official_exam: form.official_exam,
hide_assignee_details: form.hide_assignee_details,
});
}
const updateF = (patch: Partial<FormState>) => setForm((p) => ({ ...p, ...patch }));
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Assignments</h1>
<p className="text-muted-foreground">Create and manage assignments.</p>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Calendar className="h-6 w-6 text-primary" /> Assignments
</h1>
<p className="text-muted-foreground">Schedule exams and assign them to entities, classes, or students.</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Create Assignment
<Button onClick={() => { setForm(emptyForm()); setCreateOpen(true); }}>
<Plus className="h-4 w-4 mr-1" /> Schedule Exam
</Button>
</div>
{/* State tabs */}
<div className="inline-flex items-center rounded-full border-2 border-primary bg-primary/5 p-1 gap-0">
{STATE_TABS.map((tab) => (
<button
key={tab.key}
className={`flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium transition-all ${
stateFilter === tab.key
? "bg-white text-primary shadow-sm"
: "text-primary/70 hover:text-primary"
}`}
onClick={() => setStateFilter(tab.key)}
>
{tab.icon}
{tab.label}
<span className={`text-xs ml-0.5 ${stateFilter === tab.key ? "text-primary" : "text-primary/50"}`}>
({totalCounts[tab.key] ?? 0})
</span>
</button>
))}
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search assignments..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
<Input placeholder="Search schedules..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
{loading ? (
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
{schedulesQ.isLoading ? (
<div className="flex justify-center py-12"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>
) : filtered.length === 0 ? (
<Card className="border-dashed">
<CardContent className="p-12 text-center text-muted-foreground">
<Calendar className="h-10 w-10 mx-auto mb-3 opacity-40" />
<p className="font-medium">No exam schedules found</p>
<p className="text-sm mt-1">Create a new schedule to assign exams to students.</p>
</CardContent>
</Card>
) : (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Schedule Name</TableHead>
<TableHead>Exam</TableHead>
<TableHead>Entity</TableHead>
<TableHead>Start</TableHead>
<TableHead>End</TableHead>
<TableHead>State</TableHead>
<TableHead>Assignees</TableHead>
<TableHead>Completed</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No assignments found.</TableCell></TableRow>
)}
{filtered.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">{a.title}</TableCell>
<TableCell>{a.entity_name || "—"}</TableCell>
<TableCell>{a.start_date || "—"}</TableCell>
<TableCell>{a.end_date || "—"}</TableCell>
<TableCell><Badge variant={a.state === "active" ? "default" : "secondary"} className="capitalize">{a.state}</Badge></TableCell>
<TableCell>{a.assignee_count ?? 0}</TableCell>
<TableCell>{a.completed_count ?? 0}</TableCell>
{filtered.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell>{s.exam_title}</TableCell>
<TableCell>{s.entity_name || "—"}</TableCell>
<TableCell className="text-xs">{s.start_date ? new Date(s.start_date).toLocaleString() : "—"}</TableCell>
<TableCell className="text-xs">{s.end_date ? new Date(s.end_date).toLocaleString() : "—"}</TableCell>
<TableCell>
<Badge variant={stateBadgeVariant(s.state)} className="capitalize">{s.state.replace("_", " ")}</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1"><Users className="h-3.5 w-3.5 text-muted-foreground" />{s.assignee_count}</div>
</TableCell>
<TableCell>{s.completed_count}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{s.state !== "archived" && (
<Button variant="ghost" size="icon" className="h-7 w-7" title="Archive"
onClick={() => archiveMut.mutate(s.id)}>
<Archive className="h-3.5 w-3.5" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" title="Delete"
onClick={() => { if (confirm(`Delete schedule "${s.name}"?`)) deleteMut.mutate(s.id); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -94,21 +323,169 @@ export default function AssignmentsPage() {
</Card>
)}
{/* Create Schedule Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create Assignment</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Title</Label><Input value={form.title} onChange={(e) => setForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. IELTS Prep Q2" /></div>
<div className="space-y-2"><Label>Entity ID</Label><Input type="number" value={form.entity_id} onChange={(e) => setForm(f => ({ ...f, entity_id: e.target.value }))} /></div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2"><Label>Start Date</Label><Input type="date" value={form.start_date} onChange={(e) => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
<div className="space-y-2"><Label>End Date</Label><Input type="date" value={form.end_date} onChange={(e) => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Schedule Exam</DialogTitle>
<DialogDescription>Select an exam, assign it to students, and set scheduling options.</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
{/* Basic info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Schedule Name <span className="text-destructive">*</span></Label>
<Input value={form.name} onChange={(e) => updateF({ name: e.target.value })} placeholder="e.g. Q2 IELTS Exam" />
</div>
<div className="space-y-1">
<Label>Select Exam <span className="text-destructive">*</span></Label>
<Select value={form.exam_id} onValueChange={(v) => updateF({ exam_id: v })}>
<SelectTrigger><SelectValue placeholder="Choose exam..." /></SelectTrigger>
<SelectContent>
{publishedExams.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>
{e.title} <span className="text-xs text-muted-foreground ml-1">({e.status})</span>
</SelectItem>
))}
{publishedExams.length === 0 && (
<SelectItem value="_none" disabled>No exams available</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
{/* Date/Time */}
<div className="space-y-2">
<Label className="text-sm font-semibold">Schedule Date & Time</Label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="space-y-1">
<Label className="text-xs">Start Date <span className="text-destructive">*</span></Label>
<Input type="date" value={form.start_date} onChange={(e) => updateF({ start_date: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs">Start Time</Label>
<Input type="time" value={form.start_time} onChange={(e) => updateF({ start_time: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs">End Date <span className="text-destructive">*</span></Label>
<Input type="date" value={form.end_date} onChange={(e) => updateF({ end_date: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs">End Time</Label>
<Input type="time" value={form.end_time} onChange={(e) => updateF({ end_time: e.target.value })} />
</div>
</div>
</div>
{/* Options checkboxes */}
<div className="space-y-2">
<Label className="text-sm font-semibold">Exam Options</Label>
<div className="flex flex-wrap gap-x-6 gap-y-2">
{OPTION_FIELDS.map((opt) => (
<label key={opt.key} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={form[opt.key] as boolean}
onCheckedChange={(checked) => updateF({ [opt.key]: !!checked } as Partial<FormState>)}
className="h-4 w-4"
/>
{opt.label}
</label>
))}
</div>
</div>
{/* Assignment target */}
<div className="space-y-3">
<Label className="text-sm font-semibold">Assign To</Label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<Label className="text-xs">Assignment Mode</Label>
<Select value={form.assign_mode} onValueChange={(v) => updateF({ assign_mode: v as FormState["assign_mode"] })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="entity">Entire Entity</SelectItem>
<SelectItem value="batch">Class / Batch</SelectItem>
<SelectItem value="individual">Individual Students</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Entity</Label>
<Select value={form.entity_id} onValueChange={(v) => updateF({ entity_id: v })}>
<SelectTrigger><SelectValue placeholder="Select entity..." /></SelectTrigger>
<SelectContent>
{entities.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
))}
{entities.length === 0 && (
<SelectItem value="_none" disabled>No entities</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
{/* Batch selection */}
{form.assign_mode === "batch" && (
<div className="space-y-1">
<Label className="text-xs">Select Classes</Label>
<div className="max-h-36 overflow-y-auto border rounded-md p-2 space-y-1">
{batches.map((b) => (
<label key={b.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<Checkbox
checked={form.batch_ids.has(b.id)}
onCheckedChange={(checked) => {
setForm((p) => {
const next = new Set(p.batch_ids);
checked ? next.add(b.id) : next.delete(b.id);
return { ...p, batch_ids: next };
});
}}
className="h-3.5 w-3.5"
/>
{b.name}
</label>
))}
{batches.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No classes available</p>}
</div>
</div>
)}
{/* Individual student selection */}
{form.assign_mode === "individual" && (
<div className="space-y-1">
<Label className="text-xs">Select Students</Label>
<div className="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1">
{students.map((s) => (
<label key={s.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<Checkbox
checked={form.student_ids.has(s.id)}
onCheckedChange={(checked) => {
setForm((p) => {
const next = new Set(p.student_ids);
checked ? next.add(s.id) : next.delete(s.id);
return { ...p, student_ids: next };
});
}}
className="h-3.5 w-3.5"
/>
{s.name}
{s.email && <span className="text-xs text-muted-foreground ml-auto">{s.email}</span>}
</label>
))}
{students.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No students available</p>}
</div>
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button disabled={createMut.isPending || !form.title} onClick={handleCreate}>
{createMut.isPending ? "Creating..." : "Create"}
<Button disabled={createMut.isPending || !form.name || !form.exam_id || !form.start_date || !form.end_date} onClick={handleCreate}>
{createMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Schedule Exam"}
</Button>
</DialogFooter>
</DialogContent>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -52,12 +53,17 @@ import {
Sparkles,
Settings2,
ListChecks,
GraduationCap,
Dumbbell,
Lock,
} from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import { generationService } from "@/services/generation.service";
import { mediaService, type Avatar } from "@/services/media.service";
import { examsService } from "@/services/exams.service";
import { api } from "@/lib/api-client";
import { useToast } from "@/hooks/use-toast";
import type { ExamStructureConfig } from "@/types";
type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry";
@@ -222,6 +228,7 @@ interface ModuleState {
approvalWorkflow: string;
rubricGroup: string;
rubricCriteria: string;
rubricId: string;
totalMarks: number;
gradingSystem: string;
shuffling: boolean;
@@ -273,6 +280,7 @@ function defaultModuleState(mod: ModuleKey): ModuleState {
approvalWorkflow: "",
rubricGroup: "",
rubricCriteria: "",
rubricId: "",
totalMarks: 0,
gradingSystem: "",
shuffling: false,
@@ -288,9 +296,17 @@ function defaultModuleState(mod: ModuleKey): ModuleState {
export default function GenerationPage() {
const { toast } = useToast();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [examMode, setExamMode] = useState<"official" | "practice">("official");
const [title, setTitle] = useState("");
const [examLabel, setExamLabel] = useState("");
const [examStructure, setExamStructure] = useState("");
const [newStructureDialog, setNewStructureDialog] = useState<{
open: boolean;
name: string;
modules: Set<ModuleKey>;
}>({ open: false, name: "", modules: new Set() });
const [selectedModules, setSelectedModules] = useState<Set<ModuleKey>>(new Set());
const [activeModule, setActiveModule] = useState<ModuleKey | null>(null);
const [moduleStates, setModuleStates] = useState<Record<string, ModuleState>>({});
@@ -352,6 +368,47 @@ export default function GenerationPage() {
});
const structures = structuresQ.data?.items ?? [];
const rubricsQ = useQuery({
queryKey: ["rubrics"],
queryFn: () => examsService.listRubrics({}),
});
const rubrics = rubricsQ.data?.items ?? [];
const rubricGroupsQ = useQuery({
queryKey: ["rubric-groups"],
queryFn: () => examsService.listRubricGroups({}),
});
const rubricGroups = rubricGroupsQ.data?.items ?? [];
const entitiesQ = useQuery({
queryKey: ["entities"],
queryFn: () => api.get<{ items: { id: number; name: string; code: string; type: string }[] }>("/entities"),
});
const entities = entitiesQ.data?.items ?? [];
const workflowsQ = useQuery({
queryKey: ["approval-workflows"],
queryFn: () => api.get<{ items: { id: number; name: string; type: string }[] }>("/approval-workflows"),
});
const approvalWorkflows = workflowsQ.data?.items ?? [];
const selectedStructure = structures.find((s) => String(s.id) === examStructure);
const selectedStructureConfig = (selectedStructure?.config && typeof selectedStructure.config === "object" ? selectedStructure.config : null) as ExamStructureConfig | null;
const isOfficialLocked = examMode === "official" && !!selectedStructure;
const createStructureMut = useMutation({
mutationFn: (data: { name: string; modules: string[] }) =>
examsService.createStructure(data),
onSuccess: (created) => {
queryClient.invalidateQueries({ queryKey: ["exam-structures"] });
setExamStructure(String(created.id));
setNewStructureDialog({ open: false, name: "", modules: new Set() });
toast({ title: "Structure created", description: `"${created.name}" has been added.` });
},
onError: (err: Error) =>
toast({ variant: "destructive", title: "Failed to create structure", description: err.message }),
});
const getModuleState = useCallback((mod: ModuleKey): ModuleState => {
return moduleStates[mod] ?? defaultModuleState(mod);
}, [moduleStates]);
@@ -579,13 +636,44 @@ export default function GenerationPage() {
accessType: st.accessType,
shuffling: st.shuffling,
gradingSystem: st.gradingSystem,
passages: mod === "reading" ? st.passages.map((p) => ({ text: p.text, category: p.category, type: p.type, exercises: p.exercises })) : undefined,
sections: mod === "listening" ? st.listeningSections.map((s) => ({ type: s.type, context: s.context, audioUrl: s.audioUrl })) : undefined,
tasks: mod === "writing" ? st.writingTasks.map((t) => ({ instructions: t.instructions, wordLimit: t.wordLimit, marks: t.marks })) : undefined,
parts: mod === "speaking" ? st.speakingParts.map((p) => ({ type: p.type, script: p.script, videoUrl: p.videoUrl, marks: p.marks })) : undefined,
rubricId: st.rubricId,
entity: st.entity,
approvalWorkflow: st.approvalWorkflow,
totalMarks: st.totalMarks,
passages: mod === "reading" ? st.passages.map((p) => ({
text: p.text, category: p.category, type: p.type,
exercises: p.exercises?.map((ex: Record<string, unknown>) => ({
type: ex.type, prompt: ex.prompt, options: ex.options,
correct_answer: ex.correct_answer, explanation: ex.explanation,
instructions: ex.instructions, marks: ex.marks || 1,
difficulty: ex.difficulty || st.difficulty?.[0] || "B2",
})),
})) : undefined,
sections: mod === "listening" ? st.listeningSections.map((s) => ({
type: s.type, context: s.context, audioUrl: s.audioUrl,
exercises: s.exercises?.map((ex: Record<string, unknown>) => ({
type: ex.type, prompt: ex.prompt, options: ex.options,
correct_answer: ex.correct_answer, explanation: ex.explanation,
instructions: ex.instructions, marks: ex.marks || 1,
difficulty: ex.difficulty || st.difficulty?.[0] || "B2",
})),
})) : undefined,
tasks: mod === "writing" ? st.writingTasks.map((t) => ({
instructions: t.instructions, wordLimit: t.wordLimit,
marks: t.marks, category: t.category, type: t.type,
})) : undefined,
parts: mod === "speaking" ? st.speakingParts.map((p) => ({
type: p.type, script: p.script, videoUrl: p.videoUrl, marks: p.marks,
})) : undefined,
};
}
return generationService.submitExam({ title, label: examLabel, modules: modulesPayload, skip_approval: skipApproval });
return generationService.submitExam({
title, label: examLabel,
exam_mode: examMode,
structure_id: examStructure ? Number(examStructure) : undefined,
modules: modulesPayload,
skip_approval: skipApproval,
});
},
onSuccess: (res) => toast({
title: "Exam submitted successfully",
@@ -599,27 +687,6 @@ export default function GenerationPage() {
generateAudioMut.isPending || generateWritingMut.isPending ||
generateSpeakingMut.isPending || generateVideoMut.isPending || submitMut.isPending;
const renderDifficultyTags = (mod: ModuleKey) => {
const st = getModuleState(mod);
return (
<div className="space-y-1">
<Label className="text-xs">Difficulty</Label>
<div className="flex flex-wrap gap-1">
{st.difficulty.map((d) => (
<Badge key={d} variant="secondary" className="text-xs gap-1">
{d}
<button onClick={() => updateModuleState(mod, { difficulty: st.difficulty.filter((x) => x !== d) })} className="ml-0.5 hover:text-destructive"><X className="h-3 w-3" /></button>
</Badge>
))}
<Select onValueChange={(v) => { if (!st.difficulty.includes(v)) updateModuleState(mod, { difficulty: [...st.difficulty, v] }); }}>
<SelectTrigger className="h-6 w-16 text-xs"><Plus className="h-3 w-3" /></SelectTrigger>
<SelectContent>{CEFR_LEVELS.filter((l) => !st.difficulty.includes(l)).map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
);
};
const renderCommonConfig = (mod: ModuleKey) => {
const st = getModuleState(mod);
return (
@@ -628,7 +695,34 @@ export default function GenerationPage() {
<Label className="text-xs">Timer (minutes)</Label>
<Input type="number" value={st.timer} min={1} onChange={(e) => updateModuleState(mod, { timer: Number(e.target.value) || 1 })} className="h-8 text-xs" />
</div>
{renderDifficultyTags(mod)}
{examMode === "official" && (
<div className="space-y-1">
<Label className="text-xs">Rubric</Label>
<Select value={st.rubricId} onValueChange={(v) => updateModuleState(mod, { rubricId: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
<SelectContent>
{rubrics.length > 0 && (
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
)}
{rubrics.map((r) => (
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
))}
{rubricGroups.length > 0 && (
<>
<div className="border-t my-1" />
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
</>
)}
{rubricGroups.map((g) => (
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
))}
{rubrics.length === 0 && rubricGroups.length === 0 && (
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
)}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1">
<Label className="text-xs">Access Type</Label>
<Select value={st.accessType} onValueChange={(v) => updateModuleState(mod, { accessType: v })}>
@@ -643,21 +737,36 @@ export default function GenerationPage() {
<Label className="text-xs">Entities</Label>
<Select value={st.entity} onValueChange={(v) => updateModuleState(mod, { entity: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
<SelectContent><SelectItem value="none">None</SelectItem></SelectContent>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{entities.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Approval Workflow</Label>
<Select value={st.approvalWorkflow} onValueChange={(v) => updateModuleState(mod, { approvalWorkflow: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
<SelectContent><SelectItem value="default">Default</SelectItem><SelectItem value="none">None</SelectItem></SelectContent>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{approvalWorkflows.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>{w.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Grading System</Label>
<Select value={st.gradingSystem} onValueChange={(v) => updateModuleState(mod, { gradingSystem: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="System Select" /></SelectTrigger>
<SelectContent><SelectItem value="ielts">IELTS Band</SelectItem><SelectItem value="percentage">Percentage</SelectItem></SelectContent>
<SelectContent>
<SelectItem value="ielts">IELTS Band (1-9)</SelectItem>
<SelectItem value="percentage">Percentage (0-100%)</SelectItem>
<SelectItem value="pass_fail">Pass / Fail</SelectItem>
<SelectItem value="cefr">CEFR Level (A1-C2)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
@@ -1589,23 +1698,6 @@ export default function GenerationPage() {
const renderLevelModule = () => {
if (activeModule !== "level") return null;
const st = getModuleState("level");
const renderLevelDifficulty = () => (
<div className="space-y-1">
<Label className="text-xs">Difficulty</Label>
<div className="flex flex-wrap gap-1">
{st.difficulty.map((d) => (
<Badge key={d} variant="secondary" className="text-xs gap-1">
{d}
<button onClick={() => updateModuleState("level", { difficulty: st.difficulty.filter((x) => x !== d) })} className="ml-0.5 hover:text-destructive"><X className="h-3 w-3" /></button>
</Badge>
))}
<Select onValueChange={(v) => { if (!st.difficulty.includes(v)) updateModuleState("level", { difficulty: [...st.difficulty, v] }); }}>
<SelectTrigger className="h-6 w-16 text-xs"><Plus className="h-3 w-3" /></SelectTrigger>
<SelectContent>{CEFR_LEVELS.filter((l) => !st.difficulty.includes(l)).map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
);
return (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
@@ -1613,7 +1705,34 @@ export default function GenerationPage() {
<Label className="text-xs">Timer (minutes)</Label>
<Input type="number" value={st.timer} min={1} onChange={(e) => updateModuleState("level", { timer: Number(e.target.value) || 1 })} className="h-8 text-xs" />
</div>
{renderLevelDifficulty()}
{examMode === "official" && (
<div className="space-y-1">
<Label className="text-xs">Rubric</Label>
<Select value={st.rubricId} onValueChange={(v) => updateModuleState("level", { rubricId: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
<SelectContent>
{rubrics.length > 0 && (
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
)}
{rubrics.map((r) => (
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
))}
{rubricGroups.length > 0 && (
<>
<div className="border-t my-1" />
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
</>
)}
{rubricGroups.map((g) => (
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
))}
{rubrics.length === 0 && rubricGroups.length === 0 && (
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
)}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1">
<Label className="text-xs">Number of Parts</Label>
<Input type="number" value={st.numberOfParts} min={1} max={5} className="h-8 text-xs"
@@ -1638,7 +1757,12 @@ export default function GenerationPage() {
<Label className="text-xs">Entities</Label>
<Select value={st.entity} onValueChange={(v) => updateModuleState("level", { entity: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
<SelectContent><SelectItem value="none">None</SelectItem></SelectContent>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{entities.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 col-span-2 md:col-span-1">
@@ -1797,21 +1921,34 @@ export default function GenerationPage() {
<Label className="text-xs">Timer (minutes)</Label>
<Input type="number" value={st.timer} min={1} onChange={(e) => updateModuleState("industry", { timer: Number(e.target.value) || 1 })} className="h-8 text-xs" />
</div>
<div className="space-y-1">
<Label className="text-xs">Difficulty</Label>
<div className="flex flex-wrap gap-1">
{st.difficulty.map((d) => (
<Badge key={d} variant="secondary" className="text-xs gap-1">
{d}
<button onClick={() => updateModuleState("industry", { difficulty: st.difficulty.filter((x) => x !== d) })} className="ml-0.5 hover:text-destructive"><X className="h-3 w-3" /></button>
</Badge>
))}
<Select onValueChange={(v) => { if (!st.difficulty.includes(v)) updateModuleState("industry", { difficulty: [...st.difficulty, v] }); }}>
<SelectTrigger className="h-6 w-16 text-xs"><Plus className="h-3 w-3" /></SelectTrigger>
<SelectContent>{INDUSTRY_DIFFICULTY_LEVELS.filter((l) => !st.difficulty.includes(l)).map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
{examMode === "official" && (
<div className="space-y-1">
<Label className="text-xs">Rubric</Label>
<Select value={st.rubricId} onValueChange={(v) => updateModuleState("industry", { rubricId: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
<SelectContent>
{rubrics.length > 0 && (
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
)}
{rubrics.map((r) => (
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
))}
{rubricGroups.length > 0 && (
<>
<div className="border-t my-1" />
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
</>
)}
{rubricGroups.map((g) => (
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
))}
{rubrics.length === 0 && rubricGroups.length === 0 && (
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
)}
<div className="space-y-1">
<Label className="text-xs">Number of Parts</Label>
<Input type="number" value={st.numberOfParts} min={1} max={5} className="h-8 text-xs"
@@ -1836,7 +1973,12 @@ export default function GenerationPage() {
<Label className="text-xs">Entities</Label>
<Select value={st.entity} onValueChange={(v) => updateModuleState("industry", { entity: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
<SelectContent><SelectItem value="none">None</SelectItem></SelectContent>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{entities.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 col-span-2 md:col-span-1">
@@ -1946,6 +2088,110 @@ export default function GenerationPage() {
);
};
const handleStructureSelect = (val: string) => {
setExamStructure(val);
const s = structures.find((st) => String(st.id) === val);
if (s) {
const mods = (Array.isArray(s.modules) ? s.modules : []) as string[];
if (mods.length) {
const next = new Set<ModuleKey>();
mods.forEach((m) => { if (MODULE_KEYS.includes(m as ModuleKey)) next.add(m as ModuleKey); });
setSelectedModules(next);
if (next.size > 0) setActiveModule([...next][0]);
}
}
};
const handleModeSwitch = (mode: "official" | "practice") => {
setExamMode(mode);
if (mode === "practice") {
setExamStructure("");
}
setSelectedModules(new Set());
setActiveModule(null);
};
const renderOfficialStructurePreview = () => {
if (!selectedStructure || !selectedStructureConfig) return null;
const cfg = selectedStructureConfig;
const mods = (Array.isArray(selectedStructure.modules) ? selectedStructure.modules : []) as string[];
return (
<Card className="border bg-muted/30">
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Lock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold">{selectedStructure.name}</span>
{cfg.exam_type && <Badge variant="outline" className="capitalize text-xs">{cfg.exam_type}</Badge>}
{selectedStructure.industry && <Badge variant="secondary" className="text-xs">{selectedStructure.industry}</Badge>}
</div>
<span className="text-xs text-muted-foreground">Read-only structure</span>
</div>
<div className="flex gap-1.5 flex-wrap">
{mods.map((m) => (
<Badge key={m} variant="secondary" className="capitalize text-xs">{m}</Badge>
))}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-1">
{cfg.listening && mods.includes("listening") && (
<div className="rounded-md border bg-background p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium"><Headphones className="h-3.5 w-3.5 text-teal-600" /> Listening</div>
<p className="text-xs text-muted-foreground">{cfg.listening.parts?.length ?? 0} parts &middot; {cfg.listening.total_questions ?? 0} questions</p>
{cfg.listening.parts?.map((p, i) => (
<p key={i} className="text-xs text-muted-foreground">P{i + 1}: {p.label}</p>
))}
</div>
)}
{cfg.reading && mods.includes("reading") && (
<div className="rounded-md border bg-background p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium"><BookOpen className="h-3.5 w-3.5 text-blue-600" /> Reading</div>
<p className="text-xs text-muted-foreground">{cfg.reading.passages?.length ?? 0} passages &middot; {cfg.reading.total_questions ?? 0} questions</p>
{cfg.reading.passages?.map((p, i) => (
<p key={i} className="text-xs text-muted-foreground">{cfg.exam_type === "academic" ? "Passage" : "Section"} {i + 1}: {p.style}</p>
))}
</div>
)}
{cfg.writing && mods.includes("writing") && (
<div className="rounded-md border bg-background p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium"><PenTool className="h-3.5 w-3.5 text-orange-600" /> Writing</div>
{cfg.writing.tasks?.map((t, i) => (
<p key={i} className="text-xs text-muted-foreground">{t.label || `Task ${i + 1}`}: {t.type} (min {t.min_words} words)</p>
))}
{!cfg.writing.tasks?.length && cfg.writing.task1 && (
<p className="text-xs text-muted-foreground">Task 1: {cfg.writing.task1.type} &middot; Task 2: {cfg.writing.task2?.type}</p>
)}
</div>
)}
{cfg.speaking && mods.includes("speaking") && (
<div className="rounded-md border bg-background p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium"><Mic className="h-3.5 w-3.5 text-purple-600" /> Speaking</div>
{cfg.speaking.parts?.map((p, i) => (
<p key={i} className="text-xs text-muted-foreground">Part {i + 1}: {p.label} ({p.duration_min}-{p.duration_max} min)</p>
))}
</div>
)}
{cfg.level && mods.includes("level") && (
<div className="rounded-md border bg-background p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium"><Layers className="h-3.5 w-3.5 text-indigo-600" /> Level</div>
<p className="text-xs text-muted-foreground">
{Object.values(cfg.level.exercise_types || {}).reduce((s, v) => s + v, 0)} total questions
</p>
</div>
)}
{cfg.industry && mods.includes("industry") && (
<div className="rounded-md border bg-background p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium"><Briefcase className="h-3.5 w-3.5 text-amber-700" /> Industry</div>
<p className="text-xs text-muted-foreground">Difficulty: {cfg.industry.difficulty}</p>
</div>
)}
</div>
</CardContent>
</Card>
);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -1956,9 +2202,34 @@ export default function GenerationPage() {
</div>
</div>
{/* Mode tabs */}
<div className="flex gap-2 border-b pb-0">
<button
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
examMode === "official"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30"
}`}
onClick={() => handleModeSwitch("official")}
>
<GraduationCap className="h-4 w-4" /> Official Exam
</button>
<button
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
examMode === "practice"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30"
}`}
onClick={() => handleModeSwitch("practice")}
>
<Dumbbell className="h-4 w-4" /> Practice Exam
</button>
</div>
<AiTipBanner context="generation" variant="recommendation" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Title, Label, Structure row */}
<div className={`grid grid-cols-1 gap-4 ${examMode === "official" ? "md:grid-cols-3" : "md:grid-cols-2"}`}>
<div className="space-y-1">
<Label>Title <span className="text-destructive">*</span></Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Insert title" />
@@ -1967,52 +2238,87 @@ export default function GenerationPage() {
<Label>Exam Label</Label>
<Input value={examLabel} onChange={(e) => setExamLabel(e.target.value)} placeholder="Exam Label" />
</div>
<div className="space-y-1">
<Label>Exam Structure</Label>
<Select value={examStructure} onValueChange={(val) => {
setExamStructure(val);
const s = structures.find((st) => String(st.id) === val);
if (s) {
const mods = (s as Record<string, unknown>).modules as string[] | undefined;
if (Array.isArray(mods) && mods.length) {
const next = new Set<ModuleKey>();
mods.forEach((m) => { if (MODULE_KEYS.includes(m as ModuleKey)) next.add(m as ModuleKey); });
setSelectedModules(next);
if (next.size > 0) setActiveModule([...next][0]);
{examMode === "official" && (
<div className="space-y-1">
<Label>Exam Structure <span className="text-destructive">*</span></Label>
<Select value={examStructure} onValueChange={(val) => {
if (val === "__add_new__") {
navigate("/admin/exam-structures");
return;
}
}
}}>
<SelectTrigger><SelectValue placeholder="Select an exam structure" /></SelectTrigger>
<SelectContent>
{structures.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))}
{structures.length === 0 && (
<SelectItem value="_none" disabled>No structures available</SelectItem>
)}
</SelectContent>
</Select>
</div>
handleStructureSelect(val);
}}>
<SelectTrigger><SelectValue placeholder="Select an exam structure" /></SelectTrigger>
<SelectContent>
{structures.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))}
{structures.length === 0 && (
<SelectItem value="_none" disabled>No structures available</SelectItem>
)}
<div className="border-t my-1" />
<SelectItem value="__add_new__" className="text-primary font-medium">
<span className="flex items-center gap-1.5"><Plus className="h-3.5 w-3.5" /> Add New</span>
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<div>
<p className="text-sm text-muted-foreground mb-3">Please select which subjects the exam is about</p>
<div className="flex flex-wrap gap-3">
{MODULES.map((m) => (
<button key={m.key}
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-3 transition-all ${
selectedModules.has(m.key) ? `${m.bgColor} border-current ${m.color} shadow-sm` : "border-muted bg-background hover:bg-muted/50"
}`}
onClick={() => toggleModule(m.key)}>
<span className={m.color}>{m.icon}</span>
<span className="text-sm font-medium">{m.label}</span>
{selectedModules.has(m.key) && (
<Badge variant="default" className="ml-1 h-5 text-[10px]">Select</Badge>
)}
</button>
))}
{/* Official mode: read-only structure preview */}
{examMode === "official" && selectedStructure && renderOfficialStructurePreview()}
{examMode === "official" && !selectedStructure && (
<Card className="border-dashed">
<CardContent className="p-8 text-center text-muted-foreground">
<GraduationCap className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">Select an Exam Structure above to view its configuration and start generating.</p>
</CardContent>
</Card>
)}
{/* Practice mode: free module selection */}
{examMode === "practice" && (
<div>
<p className="text-sm text-muted-foreground mb-3">Select which modules you want to practice</p>
<div className="flex flex-wrap gap-3">
{MODULES.map((m) => (
<button key={m.key}
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-3 transition-all ${
selectedModules.has(m.key) ? `${m.bgColor} border-current ${m.color} shadow-sm` : "border-muted bg-background hover:bg-muted/50"
}`}
onClick={() => toggleModule(m.key)}>
<span className={m.color}>{m.icon}</span>
<span className="text-sm font-medium">{m.label}</span>
{selectedModules.has(m.key) && (
<Badge variant="default" className="ml-1 h-5 text-[10px]">Select</Badge>
)}
</button>
))}
</div>
</div>
</div>
)}
{/* Official mode: module selector from structure's modules (locked, non-toggleable) */}
{examMode === "official" && isOfficialLocked && selectedModules.size > 0 && (
<div>
<p className="text-sm text-muted-foreground mb-3 flex items-center gap-1.5">
<Lock className="h-3.5 w-3.5" /> Modules defined by structure &mdash; select one to configure
</p>
<div className="flex flex-wrap gap-3">
{MODULES.filter((m) => selectedModules.has(m.key)).map((m) => (
<button key={m.key}
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-3 transition-all ${
activeModule === m.key ? `${m.bgColor} border-current ${m.color} shadow-sm` : "border-muted bg-background hover:bg-muted/50"
}`}
onClick={() => setActiveModule(m.key)}>
<span className={m.color}>{m.icon}</span>
<span className="text-sm font-medium">{m.label}</span>
</button>
))}
</div>
</div>
)}
{activeModule && selectedModules.has(activeModule) && (
<div className="flex gap-3">
@@ -2530,6 +2836,67 @@ export default function GenerationPage() {
)}
</DialogContent>
</Dialog>
<Dialog open={newStructureDialog.open} onOpenChange={(open) => {
if (!open) setNewStructureDialog({ open: false, name: "", modules: new Set() });
}}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>New Exam Structure</DialogTitle>
<DialogDescription>Create a new exam structure with selected modules.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label>Name <span className="text-destructive">*</span></Label>
<Input
value={newStructureDialog.name}
onChange={(e) => setNewStructureDialog((prev) => ({ ...prev, name: e.target.value }))}
placeholder="e.g. Business English Assessment"
autoFocus
/>
</div>
<div className="space-y-2">
<Label>Modules</Label>
<div className="grid grid-cols-2 gap-2">
{MODULES.map((m) => (
<label key={m.key} className={`flex items-center gap-2 rounded-lg border px-3 py-2 cursor-pointer transition-all ${
newStructureDialog.modules.has(m.key) ? `${m.bgColor} ${m.color}` : "hover:bg-muted/50"
}`}>
<Checkbox
checked={newStructureDialog.modules.has(m.key)}
onCheckedChange={(checked) => {
setNewStructureDialog((prev) => {
const next = new Set(prev.modules);
if (checked) next.add(m.key); else next.delete(m.key);
return { ...prev, modules: next };
});
}}
/>
{m.icon}
<span className="text-sm font-medium">{m.label}</span>
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setNewStructureDialog({ open: false, name: "", modules: new Set() })}>
Cancel
</Button>
<Button
disabled={!newStructureDialog.name.trim() || newStructureDialog.modules.size === 0 || createStructureMut.isPending}
onClick={() => {
createStructureMut.mutate({
name: newStructureDialog.name.trim(),
modules: [...newStructureDialog.modules],
});
}}
>
{createStructureMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Creating...</> : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,48 +1,527 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Plus, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Search, Plus, Loader2, Pencil, Trash2, X, Sparkles, ChevronDown } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
import { examsService } from "@/services/exams.service";
import { useToast } from "@/hooks/use-toast";
interface RubricItem {
id: number;
name: string;
levels: string[];
skill: string;
exam_type: string;
criteria: number;
criteria_text: string;
levels: string[];
created: string;
skill?: string;
}
const FALLBACK_RUBRICS: RubricItem[] = [
{ id: 1, name: "IELTS Writing Task 2", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 4, created: "2025-01-05" },
{ id: 2, name: "Speaking Fluency", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 3, created: "2025-01-10" },
{ id: 3, name: "Reading Comprehension", levels: ["A1","A2","B1","B2","C1"], criteria: 5, created: "2025-02-01" },
interface CriterionEntry {
name: string;
weight: number;
descriptors: Record<string, string>;
}
const SKILL_OPTIONS = [
{ value: "writing", label: "Writing" },
{ value: "speaking", label: "Speaking" },
];
const rubricGroups = [
{ id: 1, name: "Academic IELTS Full", rubrics: ["IELTS Writing Task 2", "Speaking Fluency", "Reading Comprehension"], created: "2025-02-15" },
{ id: 2, name: "Business English", rubrics: ["Speaking Fluency"], created: "2025-03-01" },
const EXAM_TYPE_OPTIONS = [
{ value: "academic", label: "Academic" },
{ value: "general_training", label: "General Training" },
{ value: "general_english", label: "General English" },
];
const ALL_CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"];
interface RubricFormState {
name: string;
skill: string;
exam_type: string;
criteria: CriterionEntry[];
levels: Set<string>;
}
const emptyCriterion = (levels: Set<string>): CriterionEntry => ({
name: "",
weight: 0,
descriptors: Object.fromEntries([...levels].map((l) => [l, ""])),
});
const emptyForm = (): RubricFormState => ({
name: "",
skill: "writing",
exam_type: "academic",
criteria: [],
levels: new Set(ALL_CEFR_LEVELS),
});
function parseCriteriaText(text: string, levels: string[]): CriterionEntry[] {
if (!text) return [];
const mapStructured = (arr: unknown[]): CriterionEntry[] =>
arr.map((c) => {
const obj = c as Record<string, unknown>;
return {
name: String(obj.name || ""),
weight: Number(obj.weight || 0),
descriptors:
typeof obj.descriptors === "object" && obj.descriptors !== null
? Object.fromEntries(levels.map((l) => [l, String((obj.descriptors as Record<string, unknown>)[l] || "")]))
: Object.fromEntries(levels.map((l) => [l, ""])),
};
});
try {
const parsed = JSON.parse(text);
// Handle {"criteria": [...]} wrapper (from AI or previous saves)
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && Array.isArray(parsed.criteria)) {
return mapStructured(parsed.criteria);
}
if (Array.isArray(parsed)) {
if (parsed.length > 0 && typeof parsed[0] === "object" && parsed[0] !== null && "name" in parsed[0]) {
return mapStructured(parsed);
}
return parsed.filter(Boolean).map((name) => ({
name: String(name),
weight: Math.round(100 / parsed.length),
descriptors: Object.fromEntries(levels.map((l) => [l, ""])),
}));
}
} catch { /* not JSON */ }
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
if (lines.length === 0) return [];
return lines.map((name) => ({
name,
weight: Math.round(100 / lines.length),
descriptors: Object.fromEntries(levels.map((l) => [l, ""])),
}));
}
interface GroupFormState {
name: string;
rubric_ids: Set<number>;
}
const emptyGroupForm = (): GroupFormState => ({ name: "", rubric_ids: new Set() });
export default function RubricsPage() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState<RubricFormState>(emptyForm());
const [editTarget, setEditTarget] = useState<RubricItem | null>(null);
const [editForm, setEditForm] = useState<RubricFormState>(emptyForm());
const [groupCreateOpen, setGroupCreateOpen] = useState(false);
const [groupCreateForm, setGroupCreateForm] = useState<GroupFormState>(emptyGroupForm());
const [groupEditTarget, setGroupEditTarget] = useState<{ id: number; name: string; rubric_ids: number[] } | null>(null);
const [groupEditForm, setGroupEditForm] = useState<GroupFormState>(emptyGroupForm());
const rubricsQ = useQuery({
queryKey: ["rubrics"],
queryFn: () => examsService.listRubrics({}),
});
const backendRubrics = (rubricsQ.data?.items ?? []) as RubricItem[];
const rubrics = backendRubrics.length > 0 ? backendRubrics : FALLBACK_RUBRICS;
const rubrics = (rubricsQ.data?.items ?? []) as RubricItem[];
const rubricGroupsQ = useQuery({
queryKey: ["rubric-groups"],
queryFn: () => examsService.listRubricGroups({}),
});
const rubricGroups = rubricGroupsQ.data?.items ?? [];
const createMut = useMutation({
mutationFn: (data: Record<string, unknown>) => examsService.createRubric(data as never),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["rubrics"] });
setCreateOpen(false);
setCreateForm(emptyForm());
toast({ title: "Rubric created" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Create failed", description: err.message }),
});
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) =>
examsService.updateRubric(id, data as never),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["rubrics"] });
setEditTarget(null);
toast({ title: "Rubric updated" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Update failed", description: err.message }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => examsService.deleteRubric(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["rubrics"] });
toast({ title: "Rubric deleted" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Delete failed", description: err.message }),
});
const createGroupMut = useMutation({
mutationFn: (data: { name: string; rubric_ids: number[] }) => examsService.createRubricGroup(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["rubric-groups"] });
setGroupCreateOpen(false);
setGroupCreateForm(emptyGroupForm());
toast({ title: "Rubric group created" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Create group failed", description: err.message }),
});
const updateGroupMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: { name?: string; rubric_ids?: number[] } }) =>
examsService.updateRubricGroup(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["rubric-groups"] });
setGroupEditTarget(null);
toast({ title: "Rubric group updated" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Update group failed", description: err.message }),
});
const deleteGroupMut = useMutation({
mutationFn: (id: number) => examsService.deleteRubricGroup(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["rubric-groups"] });
toast({ title: "Rubric group deleted" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Delete group failed", description: err.message }),
});
const [suggesting, setSuggesting] = useState(false);
const suggestCriteria = async (
form: RubricFormState,
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
) => {
setSuggesting(true);
try {
const result = await examsService.suggestRubricCriteria({
name: form.name,
skill: form.skill,
exam_type: form.exam_type,
levels: [...form.levels],
});
if (result.criteria && Array.isArray(result.criteria) && result.criteria.length > 0) {
const levelsArr = [...form.levels];
const entries: CriterionEntry[] = result.criteria.map((c: unknown) => {
if (typeof c === "object" && c !== null) {
const obj = c as Record<string, unknown>;
return {
name: String(obj.name || ""),
weight: Number(obj.weight || 0),
descriptors: typeof obj.descriptors === "object" && obj.descriptors !== null
? Object.fromEntries(
levelsArr.map((l) => [l, String((obj.descriptors as Record<string, unknown>)[l] || "")])
)
: Object.fromEntries(levelsArr.map((l) => [l, ""])),
};
}
return { name: String(c), weight: 0, descriptors: Object.fromEntries(levelsArr.map((l) => [l, ""])) };
});
setForm((f) => ({ ...f, criteria: entries }));
toast({ title: "Criteria generated", description: `${entries.length} criteria with band descriptors created by AI.` });
}
} catch (err) {
toast({ variant: "destructive", title: "AI suggestion failed", description: (err as Error).message });
} finally {
setSuggesting(false);
}
};
const openEdit = (r: RubricItem) => {
const levelsArr = Array.isArray(r.levels) && r.levels.length > 0 ? r.levels : ALL_CEFR_LEVELS;
setEditTarget(r);
setEditForm({
name: r.name,
skill: r.skill || "writing",
exam_type: r.exam_type || "academic",
criteria: parseCriteriaText(r.criteria_text, levelsArr),
levels: new Set(levelsArr),
});
};
const formToPayload = (form: RubricFormState) => ({
name: form.name.trim(),
skill: form.skill,
exam_type: form.exam_type,
criteria: JSON.stringify(form.criteria.filter((c) => c.name.trim())),
levels: [...form.levels],
});
const filtered = rubrics.filter((r) => {
if (!search) return true;
const q = search.toLowerCase();
return r.name?.toLowerCase().includes(q) || r.skill?.toLowerCase().includes(q);
});
const updateCriterion = (
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
index: number,
patch: Partial<CriterionEntry>,
) => {
setForm((f) => {
const criteria = [...f.criteria];
criteria[index] = { ...criteria[index], ...patch };
return { ...f, criteria };
});
};
const updateDescriptor = (
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
cIndex: number,
level: string,
value: string,
) => {
setForm((f) => {
const criteria = [...f.criteria];
criteria[cIndex] = {
...criteria[cIndex],
descriptors: { ...criteria[cIndex].descriptors, [level]: value },
};
return { ...f, criteria };
});
};
const renderForm = (
form: RubricFormState,
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
) => {
const levelsArr = [...form.levels].sort(
(a, b) => ALL_CEFR_LEVELS.indexOf(a) - ALL_CEFR_LEVELS.indexOf(b)
);
const totalWeight = form.criteria.reduce((s, c) => s + (c.weight || 0), 0);
return (
<div className="space-y-4">
<div className="space-y-2">
<Label>Rubric Name <span className="text-destructive">*</span></Label>
<Input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="e.g. IELTS Writing Task 1"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Skill</Label>
<Select value={form.skill} onValueChange={(v) => setForm((f) => ({ ...f, skill: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{SKILL_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Exam Type</Label>
<Select value={form.exam_type} onValueChange={(v) => setForm((f) => ({ ...f, exam_type: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{EXAM_TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>CEFR Levels</Label>
<div className="flex flex-wrap gap-2">
{ALL_CEFR_LEVELS.map((level) => {
const checked = form.levels.has(level);
return (
<label
key={level}
className={`flex items-center gap-1.5 rounded-md border px-3 py-1.5 cursor-pointer transition-all text-sm font-medium ${
checked ? "bg-primary/10 border-primary text-primary" : "hover:bg-muted/50"
}`}
>
<Checkbox
checked={checked}
onCheckedChange={(val) => {
setForm((f) => {
const next = new Set(f.levels);
if (val) next.add(level); else next.delete(level);
return { ...f, levels: next };
});
}}
/>
{level}
</label>
);
})}
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label>Criteria</Label>
{form.criteria.length > 0 && (
<span className={`ml-2 text-xs ${totalWeight === 100 ? "text-green-600" : "text-amber-600"}`}>
(Total weight: {totalWeight}%)
</span>
)}
</div>
<Button
type="button"
size="sm"
className="h-8 bg-gradient-to-r from-violet-500 to-indigo-500 text-white hover:from-violet-600 hover:to-indigo-600"
disabled={suggesting}
onClick={() => suggestCriteria(form, setForm)}
>
{suggesting
? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> Generating...</>
: <><Sparkles className="h-3.5 w-3.5 mr-1.5" /> Generate with AI</>
}
</Button>
</div>
{suggesting ? (
<div className="flex flex-col items-center justify-center py-8 gap-2 text-muted-foreground rounded-lg border border-dashed border-violet-300 bg-violet-50/50">
<Loader2 className="h-6 w-6 animate-spin text-violet-500" />
<p className="text-sm font-medium">AI is generating criteria with band descriptors...</p>
<p className="text-xs">Based on {form.skill} / {form.exam_type.replace(/_/g, " ")}</p>
</div>
) : form.criteria.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 gap-2 text-muted-foreground rounded-lg border border-dashed">
<Sparkles className="h-5 w-5 text-violet-400" />
<p className="text-sm">No criteria yet.</p>
<p className="text-xs">Click <strong>Generate with AI</strong> to create criteria automatically, or add manually.</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-1"
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
>
<Plus className="h-3 w-3 mr-1" /> Add Manually
</Button>
</div>
) : (
<div className="space-y-3">
{form.criteria.map((c, ci) => (
<Collapsible key={ci} defaultOpen={form.criteria.length <= 3}>
<div className="rounded-lg border bg-card">
<div className="flex items-center gap-2 p-3">
<CollapsibleTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
<ChevronDown className="h-4 w-4" />
</Button>
</CollapsibleTrigger>
<Input
value={c.name}
onChange={(e) => updateCriterion(setForm, ci, { name: e.target.value })}
placeholder="Criterion name"
className="flex-1 h-8 font-medium"
/>
<div className="flex items-center gap-1 shrink-0">
<Input
type="number"
min={0}
max={100}
value={c.weight}
onChange={(e) => updateCriterion(setForm, ci, { weight: Number(e.target.value) })}
className="w-16 h-8 text-center text-sm"
/>
<span className="text-xs text-muted-foreground">%</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => setForm((f) => ({ ...f, criteria: f.criteria.filter((_, i) => i !== ci) }))}
>
<X className="h-4 w-4" />
</Button>
</div>
<CollapsibleContent>
<div className="px-3 pb-3 pt-0 border-t">
<p className="text-xs text-muted-foreground my-2">Band level descriptors:</p>
<div className="space-y-2">
{levelsArr.map((level) => (
<div key={level} className="flex gap-2 items-start">
<Badge variant="outline" className="mt-1.5 shrink-0 w-9 justify-center text-xs">{level}</Badge>
<Textarea
value={c.descriptors[level] || ""}
onChange={(e) => updateDescriptor(setForm, ci, level, e.target.value)}
placeholder={`Performance descriptor for ${level}...`}
className="min-h-[40px] h-10 text-sm resize-none"
/>
</div>
))}
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
>
<Plus className="h-3 w-3 mr-1" /> Add Criterion
</Button>
</div>
)}
</div>
</div>
);
};
return (
<div className="space-y-6">
@@ -53,25 +532,25 @@ export default function RubricsPage() {
</div>
<div className="flex gap-2">
<AiCreationAssistant type="rubric" />
<Dialog>
<Dialog open={createOpen} onOpenChange={(open) => { setCreateOpen(open); if (!open) setCreateForm(emptyForm()); }}>
<DialogTrigger asChild>
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Rubric</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader><DialogTitle>Create Rubric</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Rubric Name</Label><Input placeholder="e.g. IELTS Writing Task 1" /></div>
<div className="space-y-3">
<Label>Level Descriptors</Label>
{["A1","A2","B1","B2","C1","C2"].map(level => (
<div key={level} className="space-y-1">
<Label className="text-xs text-muted-foreground">{level}</Label>
<Textarea placeholder={`Descriptor for level ${level}...`} className="h-16" />
</div>
))}
</div>
<Button className="w-full">Create Rubric</Button>
</div>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Rubric</DialogTitle>
<DialogDescription>Define a scoring rubric. Use AI to generate criteria or add them manually.</DialogDescription>
</DialogHeader>
{renderForm(createForm, setCreateForm)}
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button
disabled={!createForm.name.trim() || createForm.criteria.filter((c) => c.name.trim()).length === 0 || createMut.isPending}
onClick={() => createMut.mutate(formToPayload(createForm))}
>
{createMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
@@ -90,51 +569,248 @@ export default function RubricsPage() {
<TabsTrigger value="groups">Rubric Groups</TabsTrigger>
</TabsList>
<TabsContent value="rubrics" className="mt-4">
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead><TableHead>Levels</TableHead><TableHead>Criteria</TableHead><TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rubrics.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.name}</TableCell>
<TableCell><div className="flex gap-1">{r.levels.map(l => <Badge key={l} variant="outline" className="text-xs">{l}</Badge>)}</div></TableCell>
<TableCell>{r.criteria}</TableCell>
<TableCell>{r.created}</TableCell>
{rubricsQ.isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{rubricsQ.error && (
<Card className="border-destructive">
<CardContent className="p-4 text-sm text-destructive">
Failed to load rubrics. The backend endpoint may not be available yet.
</CardContent>
</Card>
)}
{!rubricsQ.isLoading && !rubricsQ.error && filtered.length === 0 && (
<Card className="border-dashed">
<CardContent className="p-8 text-center text-muted-foreground">
No rubrics found. Create one to get started.
</CardContent>
</Card>
)}
{!rubricsQ.isLoading && filtered.length > 0 && (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Skill</TableHead>
<TableHead>Exam Type</TableHead>
<TableHead>Levels</TableHead>
<TableHead>Criteria</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[90px]">Actions</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TableHeader>
<TableBody>
{filtered.map((r) => (
<TableRow key={r.id} className="cursor-pointer hover:bg-muted/50" onClick={() => openEdit(r)}>
<TableCell className="font-medium">{r.name}</TableCell>
<TableCell><Badge variant="secondary" className="capitalize">{r.skill || "—"}</Badge></TableCell>
<TableCell className="capitalize text-sm">{r.exam_type?.replace(/_/g, " ") || "—"}</TableCell>
<TableCell>
<div className="flex gap-1">
{(r.levels || ALL_CEFR_LEVELS).map((l) => (
<Badge key={l} variant="outline" className="text-xs">{l}</Badge>
))}
</div>
</TableCell>
<TableCell>{r.criteria}</TableCell>
<TableCell className="text-sm text-muted-foreground">{r.created}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); openEdit(r); }}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"
onClick={(e) => { e.stopPropagation(); deleteMut.mutate(r.id); }}
disabled={deleteMut.isPending}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="groups" className="mt-4">
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Group Name</TableHead><TableHead>Rubrics</TableHead><TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rubricGroups.map((g) => (
<TableRow key={g.id}>
<TableCell className="font-medium">{g.name}</TableCell>
<TableCell><div className="flex gap-1 flex-wrap">{g.rubrics.map(r => <Badge key={r} variant="secondary" className="text-xs">{r}</Badge>)}</div></TableCell>
<TableCell>{g.created}</TableCell>
<div className="flex justify-end mb-3">
<Button size="sm" onClick={() => { setGroupCreateForm(emptyGroupForm()); setGroupCreateOpen(true); }}>
<Plus className="h-4 w-4 mr-1" /> Create Group
</Button>
</div>
{rubricGroupsQ.isLoading && (
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
)}
{!rubricGroupsQ.isLoading && rubricGroups.length === 0 && (
<Card className="border-dashed"><CardContent className="p-8 text-center text-muted-foreground text-sm">No rubric groups yet. Create one to bundle rubrics together.</CardContent></Card>
)}
{!rubricGroupsQ.isLoading && rubricGroups.length > 0 && (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Group Name</TableHead>
<TableHead>Rubrics</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-24 text-right">Actions</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TableHeader>
<TableBody>
{rubricGroups.map((g) => (
<TableRow key={g.id}>
<TableCell className="font-medium">{g.name}</TableCell>
<TableCell>
<div className="flex gap-1 flex-wrap">
{(g.rubric_names ?? []).map((rb) => (
<Badge key={rb} variant="secondary" className="text-xs">{rb}</Badge>
))}
{(!g.rubric_names || g.rubric_names.length === 0) && (
<span className="text-xs text-muted-foreground italic">No rubrics</span>
)}
</div>
</TableCell>
<TableCell>{g.created}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => {
setGroupEditTarget({ id: g.id, name: g.name, rubric_ids: g.rubric_ids });
setGroupEditForm({ name: g.name, rubric_ids: new Set(g.rubric_ids) });
}}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => { if (confirm(`Delete group "${g.name}"?`)) deleteGroupMut.mutate(g.id); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Create Group Dialog */}
<Dialog open={groupCreateOpen} onOpenChange={setGroupCreateOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Create Rubric Group</DialogTitle>
<DialogDescription>Bundle multiple rubrics into a group for easy selection.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label>Group Name <span className="text-destructive">*</span></Label>
<Input value={groupCreateForm.name} onChange={(e) => setGroupCreateForm((p) => ({ ...p, name: e.target.value }))} placeholder="e.g. Academic IELTS Full" />
</div>
<div className="space-y-1">
<Label>Select Rubrics</Label>
<div className="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1">
{rubrics.map((r) => (
<label key={r.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<Checkbox checked={groupCreateForm.rubric_ids.has(r.id)}
onCheckedChange={(checked) => {
setGroupCreateForm((p) => {
const next = new Set(p.rubric_ids);
checked ? next.add(r.id) : next.delete(r.id);
return { ...p, rubric_ids: next };
});
}} className="h-3.5 w-3.5" />
{r.name}
<Badge variant="outline" className="text-[10px] ml-auto">{r.skill}</Badge>
</label>
))}
{rubrics.length === 0 && <p className="text-xs text-muted-foreground italic py-2 text-center">No rubrics available</p>}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setGroupCreateOpen(false)}>Cancel</Button>
<Button disabled={!groupCreateForm.name.trim() || createGroupMut.isPending}
onClick={() => createGroupMut.mutate({ name: groupCreateForm.name.trim(), rubric_ids: [...groupCreateForm.rubric_ids] })}>
{createGroupMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Create Group"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Group Dialog */}
<Dialog open={!!groupEditTarget} onOpenChange={(open) => { if (!open) setGroupEditTarget(null); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Edit Rubric Group</DialogTitle>
<DialogDescription>Update the group name and selected rubrics.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label>Group Name <span className="text-destructive">*</span></Label>
<Input value={groupEditForm.name} onChange={(e) => setGroupEditForm((p) => ({ ...p, name: e.target.value }))} />
</div>
<div className="space-y-1">
<Label>Select Rubrics</Label>
<div className="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1">
{rubrics.map((r) => (
<label key={r.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<Checkbox checked={groupEditForm.rubric_ids.has(r.id)}
onCheckedChange={(checked) => {
setGroupEditForm((p) => {
const next = new Set(p.rubric_ids);
checked ? next.add(r.id) : next.delete(r.id);
return { ...p, rubric_ids: next };
});
}} className="h-3.5 w-3.5" />
{r.name}
<Badge variant="outline" className="text-[10px] ml-auto">{r.skill}</Badge>
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setGroupEditTarget(null)}>Cancel</Button>
<Button disabled={!groupEditForm.name.trim() || updateGroupMut.isPending}
onClick={() => {
if (!groupEditTarget) return;
updateGroupMut.mutate({ id: groupEditTarget.id, data: { name: groupEditForm.name.trim(), rubric_ids: [...groupEditForm.rubric_ids] } });
}}>
{updateGroupMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Saving...</> : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TabsContent>
</Tabs>
<Dialog open={!!editTarget} onOpenChange={(open) => { if (!open) setEditTarget(null); }}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Rubric</DialogTitle>
<DialogDescription>Update the rubric details below. Use AI to regenerate criteria.</DialogDescription>
</DialogHeader>
{renderForm(editForm, setEditForm)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditTarget(null)}>Cancel</Button>
<Button
disabled={!editForm.name.trim() || editForm.criteria.filter((c) => c.name.trim()).length === 0 || updateMut.isPending}
onClick={() => {
if (!editTarget) return;
updateMut.mutate({ id: editTarget.id, data: formToPayload(editForm) });
}}
>
{updateMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Saving...</> : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -39,20 +39,35 @@ interface FeedbackEntry {
source: string;
}
interface AnswerEntry {
question_id: number;
answer: string;
score: number;
is_correct: boolean;
feedback: string;
}
interface ExamResultsData {
attempt_id: number;
exam_id: number | null;
exam_title?: string;
status: string;
completed_at: string;
released_at: string;
listening_band: number;
reading_band: number;
writing_band: number;
speaking_band: number;
overall_band: number;
cefr_level: string;
scores: ScoreEntry[];
feedback: FeedbackEntry[];
completed_at?: string;
released_at?: string;
started_at?: string | null;
finished_at?: string | null;
total_score?: number;
max_score?: number;
percentage?: number;
listening_band?: number;
reading_band?: number;
writing_band?: number;
speaking_band?: number;
overall_band?: number;
cefr_level?: string;
scores?: ScoreEntry[];
feedback?: FeedbackEntry[];
answers?: AnswerEntry[];
}
export default function ExamResults() {
@@ -99,11 +114,13 @@ export default function ExamResults() {
);
}
const overall = results.overall_band;
const hasDetailedScores = Array.isArray(results.scores) && results.scores.length > 0;
const overall = results.overall_band ?? 0;
const pct = results.percentage ?? (results.max_score ? Math.round((results.total_score ?? 0) / results.max_score * 100) : 0);
const cefr = results.cefr_level?.toUpperCase() || "N/A";
const passed = overall >= 7;
const passed = hasDetailedScores ? overall >= 7 : pct >= 70;
const skillScores = results.scores.filter((s) => s.skill !== "overall");
const skillScores = hasDetailedScores ? results.scores!.filter((s) => s.skill !== "overall") : [];
const SKILLS = skillScores.map((s) => ({
skill: s.skill.charAt(0).toUpperCase() + s.skill.slice(1),
band: s.band_score,
@@ -114,25 +131,39 @@ export default function ExamResults() {
const RADAR_DATA = SKILLS.map((s) => ({ skill: s.skill, band: s.band }));
const feedbackList = results.feedback ?? [];
const feedbackBySkill: Record<string, FeedbackEntry[]> = {};
for (const fb of results.feedback) {
for (const fb of feedbackList) {
const key = "General";
if (!feedbackBySkill[key]) feedbackBySkill[key] = [];
feedbackBySkill[key].push(fb);
}
const answerEntries = results.answers ?? [];
return (
<div className="mx-auto max-w-5xl space-y-8 p-6">
<div className="text-center">
{results.exam_title && (
<h1 className="text-xl font-semibold mb-1">{results.exam_title}</h1>
)}
<p className="text-sm text-muted-foreground">Your overall result</p>
<div className="mt-2 flex items-center justify-center gap-3">
<Award className="h-12 w-12 text-primary" />
<span className="text-5xl font-bold tabular-nums">{overall}</span>
<Badge variant="secondary" className="text-lg">
Band
</Badge>
</div>
<p className="mt-2 text-lg text-muted-foreground">CEFR equivalent: {cefr}</p>
{hasDetailedScores ? (
<div className="mt-2 flex items-center justify-center gap-3">
<Award className="h-12 w-12 text-primary" />
<span className="text-5xl font-bold tabular-nums">{overall}</span>
<Badge variant="secondary" className="text-lg">Band</Badge>
</div>
) : (
<div className="mt-2 flex items-center justify-center gap-3">
<Award className="h-12 w-12 text-primary" />
<span className="text-5xl font-bold tabular-nums">{pct}%</span>
</div>
)}
<p className="mt-1 text-sm text-muted-foreground">
{results.total_score ?? 0} / {results.max_score ?? 0} marks
</p>
{hasDetailedScores && <p className="mt-1 text-lg text-muted-foreground">CEFR equivalent: {cefr}</p>}
{practice ? <Badge className="mt-2">Practice mode</Badge> : null}
</div>
@@ -185,11 +216,48 @@ export default function ExamResults() {
</CardContent>
</Card>
{results.feedback.length > 0 && (
{answerEntries.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Your Answers</CardTitle>
<CardDescription>
{answerEntries.filter((a) => a.is_correct).length} correct out of {answerEntries.length}
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Your Answer</TableHead>
<TableHead>Score</TableHead>
<TableHead>Result</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{answerEntries.map((a, i) => (
<TableRow key={a.question_id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="max-w-[200px] truncate">{a.answer || "—"}</TableCell>
<TableCell>{a.score}</TableCell>
<TableCell>
<Badge variant={a.is_correct ? "default" : "destructive"}>
{a.is_correct ? "Correct" : "Incorrect"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{feedbackList.length > 0 && (
<div>
<h2 className="mb-3 text-lg font-semibold">Feedback</h2>
<Accordion type="multiple" className="w-full">
{results.feedback.map((fb, i) => (
{feedbackList.map((fb, i) => (
<AccordionItem key={i} value={`fb-${i}`}>
<AccordionTrigger>
{fb.source === "ai" ? "AI Feedback" : fb.source === "teacher" ? "Teacher Feedback" : "Feedback"}{" "}

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>

View File

@@ -1,11 +1,26 @@
import { api } from "@/lib/api-client";
import type { Assignment, AssignmentState, AssignmentCreateRequest, PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
import type {
Assignment,
AssignmentState,
AssignmentCreateRequest,
ExamSchedule,
ExamScheduleCreateRequest,
ScheduleState,
StudentExamAssignment,
PaginatedResponse,
PaginationParams,
ApiSuccessResponse,
} from "@/types";
export interface AssignmentListParams extends PaginationParams {
state?: AssignmentState;
entity_id?: number;
}
export interface ScheduleListParams extends PaginationParams {
state?: ScheduleState;
}
export const assignmentsService = {
async list(params?: AssignmentListParams): Promise<PaginatedResponse<Assignment>> {
return api.get<PaginatedResponse<Assignment>>("/assignments", params as Record<string, string | number | boolean | undefined>);
@@ -34,4 +49,28 @@ export const assignmentsService = {
async start(id: number): Promise<Assignment> {
return api.post<Assignment>(`/assignments/${id}/start`);
},
async listSchedules(params?: ScheduleListParams): Promise<PaginatedResponse<ExamSchedule>> {
return api.get<PaginatedResponse<ExamSchedule>>("/exam-schedules", params as Record<string, string | number | boolean | undefined>);
},
async createSchedule(data: ExamScheduleCreateRequest): Promise<ExamSchedule> {
return api.post<ExamSchedule>("/exam-schedules", data);
},
async updateSchedule(id: number, data: Partial<ExamScheduleCreateRequest & { state: ScheduleState }>): Promise<ExamSchedule> {
return api.put<ExamSchedule>(`/exam-schedules/${id}`, data);
},
async deleteSchedule(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/exam-schedules/${id}`);
},
async archiveSchedule(id: number): Promise<ExamSchedule> {
return api.post<ExamSchedule>(`/exam-schedules/${id}/archive`);
},
async getStudentExams(): Promise<{ items: StudentExamAssignment[] }> {
return api.get<{ items: StudentExamAssignment[] }>("/student/my-exams");
},
};

View File

@@ -40,10 +40,30 @@ export const examsService = {
return api.post<Rubric>("/rubrics", data);
},
async updateRubric(id: number, data: Partial<Rubric>): Promise<Rubric> {
return api.put<Rubric>(`/rubrics/${id}`, data);
},
async deleteRubric(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/rubrics/${id}`);
},
async listRubricGroups(params?: PaginationParams): Promise<PaginatedResponse<RubricGroup>> {
return api.get<PaginatedResponse<RubricGroup>>("/rubric-groups", params as Record<string, string | number | boolean | undefined>);
},
async createRubricGroup(data: { name: string; rubric_ids: number[] }): Promise<RubricGroup> {
return api.post<RubricGroup>("/rubric-groups", data);
},
async updateRubricGroup(id: number, data: { name?: string; rubric_ids?: number[] }): Promise<RubricGroup> {
return api.put<RubricGroup>(`/rubric-groups/${id}`, data);
},
async deleteRubricGroup(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/rubric-groups/${id}`);
},
async listStructures(params?: PaginationParams & { entity_id?: number }): Promise<PaginatedResponse<ExamStructure>> {
return api.get<PaginatedResponse<ExamStructure>>("/exam-structures", params as Record<string, string | number | boolean | undefined>);
},
@@ -52,10 +72,23 @@ export const examsService = {
return api.post<ExamStructure>("/exam-structures", data);
},
async updateStructure(id: number, data: Partial<ExamStructure>): Promise<ExamStructure> {
return api.put<ExamStructure>(`/exam-structures/${id}`, data);
},
async deleteStructure(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/exam-structures/${id}`);
},
async suggestRubricCriteria(data: {
name?: string;
skill: string;
exam_type: string;
levels: string[];
}): Promise<{ criteria: string[]; suggested_levels?: string[] }> {
return api.post("/ai/suggest-rubric-criteria", data);
},
async getAvatars(): Promise<{ id: number; name: string; thumbnail: string }[]> {
return api.get("/exam/avatars");
},

View File

@@ -137,9 +137,11 @@ export const generationService = {
submitExam(data: {
title: string;
label: string;
exam_mode?: string;
structure_id?: number;
modules: Record<string, unknown>;
skip_approval?: boolean;
}): Promise<{ exam_id: number; status: string; template_id?: number }> {
}): Promise<{ exam_id: number; status: string; template_id?: number; total_questions?: number }> {
return api.post("/exam/generation/submit", data);
},
};

View File

@@ -35,3 +35,60 @@ export interface AssignmentCreateRequest {
exam_ids: number[];
assignee_ids?: number[];
}
export type ScheduleState = "planned" | "active" | "past" | "start_expired" | "archived";
export interface ExamSchedule {
id: number;
name: string;
exam_id: number | null;
exam_title: string;
entity_id: number | null;
entity_name: string;
start_date: string | null;
end_date: string | null;
state: ScheduleState;
assign_mode: "entity" | "batch" | "individual";
full_length: boolean;
generate_different: boolean;
auto_release_results: boolean;
auto_start: boolean;
official_exam: boolean;
hide_assignee_details: boolean;
batch_ids: number[];
batch_names: string[];
student_ids: number[];
assignee_count: number;
completed_count: number;
created: string;
}
export interface ExamScheduleCreateRequest {
name: string;
exam_id: number;
entity_id?: number;
start_date: string;
end_date: string;
assign_mode: "entity" | "batch" | "individual";
batch_ids?: number[];
student_ids?: number[];
full_length?: boolean;
generate_different?: boolean;
auto_release_results?: boolean;
auto_start?: boolean;
official_exam?: boolean;
hide_assignee_details?: boolean;
}
export interface StudentExamAssignment {
id: number;
exam_id: number;
exam_title: string;
schedule_name: string;
start_date: string | null;
end_date: string | null;
status: string;
schedule_state: ScheduleState;
auto_start: boolean;
can_start: boolean;
}

View File

@@ -10,20 +10,33 @@ export interface ExamSessionSection {
id: number;
title: string;
skill: string;
difficulty?: string;
time_limit_sec: number;
time_limit_min?: number;
total_marks?: number;
scoring_method?: string;
sequence?: number;
passage_text?: string;
instructions_text?: string;
questions: ExamQuestion[];
}
export type ExamOptionItem = { label: string; text: string } | string;
export interface ExamQuestion {
id: number;
type: string;
question_type?: string;
stem: string;
options?: { label: string; text: string }[];
options?: ExamOptionItem[];
passage_text?: string;
audio_url?: string;
audio_base64?: string;
visual_url?: string;
min_words?: number;
marks: number;
difficulty?: string;
source_type?: string;
}
export interface ExamAnswer {

View File

@@ -37,6 +37,8 @@ export interface RubricGroup {
id: number;
name: string;
rubric_ids: number[];
rubric_names?: string[];
created?: string;
}
export interface ExamStructure {
@@ -44,6 +46,77 @@ export interface ExamStructure {
name: string;
entity_id: number;
entity_name: string;
industry: string;
modules: ExamModule[];
config: ExamStructureConfig;
created_at: string;
}
export interface ListeningPartConfig {
key: string;
label: string;
questions: number;
question_types: Record<string, number>;
}
export interface ReadingPassageConfig {
style: string;
questions: number;
question_types: Record<string, number>;
}
export interface SpeakingPartConfig {
key: string;
label: string;
duration_min: number;
duration_max: number;
}
export interface WritingTaskConfig {
type: string;
min_words: number;
label?: string;
}
export interface LevelExerciseEntry {
type: string;
quantity: number;
}
export interface IndustryExerciseEntry {
type: string;
quantity: number;
}
export interface ExamStructureConfig {
exam_type: "academic" | "general";
listening?: {
parts: ListeningPartConfig[];
total_questions: number;
};
reading?: {
passages: ReadingPassageConfig[];
total_questions: number;
};
writing?: {
tasks: WritingTaskConfig[];
/** @deprecated kept for backward compat reads */
task1?: { type: string; min_words: number };
/** @deprecated kept for backward compat reads */
task2?: { type: string; min_words: number };
rubric_id: number | null;
};
speaking?: {
parts: SpeakingPartConfig[];
rubric_id: number | null;
};
level?: {
exercise_types: Record<string, number>;
entries?: LevelExerciseEntry[];
};
industry?: {
exercise_types: Record<string, number>;
entries?: IndustryExerciseEntry[];
difficulty: string;
};
}