React 18 + TypeScript + Vite 5 + Tailwind CSS + shadcn/ui frontend: - Multi-role auth: student, teacher, admin, corporate, agent, developer - Student portal: dashboard, courses, adaptive learning, exams, placement - Teacher portal: courses, assignments, grading, attendance, AI workbench - Admin portal: 60+ management pages (LMS, exams, users, entities, AI) - AI-powered features: study coach, generation page, gap analysis, TTS - Exam system: session, auto-save, results, grading queue - Full IELTS workflow: reading/listening/writing/speaking modules Made-with: Cursor
206 lines
7.8 KiB
TypeScript
206 lines
7.8 KiB
TypeScript
import { useState } from "react";
|
|
import { useParams } from "react-router-dom";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { useIeltsValidation, useSubmitExaminerReview } from "@/hooks/queries/useAiCourse";
|
|
import type { ExaminerReviewItem } from "@/types";
|
|
import { CheckCircle2, XCircle } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
export default function AiIeltsValidation() {
|
|
const { courseId: cid } = useParams<{ courseId: string }>();
|
|
const courseId = Number(cid);
|
|
const { data, isLoading, refetch } = useIeltsValidation(Number.isFinite(courseId) ? courseId : undefined);
|
|
const submitReview = useSubmitExaminerReview();
|
|
|
|
const [preview, setPreview] = useState<ExaminerReviewItem | null>(null);
|
|
const [notes, setNotes] = useState("");
|
|
const [checklist, setChecklist] = useState({
|
|
topic_accuracy: true,
|
|
difficulty_appropriate: true,
|
|
ielts_aligned: true,
|
|
culturally_sensitive: true,
|
|
});
|
|
|
|
const layer1 = data?.layer1_checks ?? [];
|
|
const layer2 = data?.layer2_items ?? [];
|
|
|
|
const send = (approved: boolean) => {
|
|
if (!preview) return;
|
|
submitReview.mutate({
|
|
logId: preview.id,
|
|
action: approved ? "approve" : "reject",
|
|
examiner_notes: approved ? undefined : notes,
|
|
}, {
|
|
onSuccess: () => {
|
|
toast.success(approved ? "Approved." : "Rejected with notes.");
|
|
setPreview(null);
|
|
setNotes("");
|
|
refetch();
|
|
},
|
|
onError: (e) => toast.error(e instanceof Error ? e.message : "Failed"),
|
|
});
|
|
};
|
|
|
|
if (isLoading) {
|
|
return <div className="p-6 text-muted-foreground">Loading validation…</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8 p-6 max-w-6xl mx-auto">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">IELTS content validation</h1>
|
|
<p className="text-muted-foreground text-sm mt-1">Layer 1 automation · Layer 2 examiner queue</p>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Layer 1 — Automated checks</CardTitle>
|
|
<CardDescription>Format, calibration, answer keys</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="rounded-md border overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Check</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Detail</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{layer1.map((c) => (
|
|
<TableRow key={c.name}>
|
|
<TableCell className="font-medium">{c.name}</TableCell>
|
|
<TableCell>
|
|
<span className="inline-flex items-center gap-1">
|
|
{c.status === "pass" ? (
|
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
|
) : (
|
|
<XCircle className="h-4 w-4 text-destructive" />
|
|
)}
|
|
{c.status}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground">{c.detail}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Layer 2 — Examiner review queue</CardTitle>
|
|
<CardDescription>Approve or reject with structured notes</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="rounded-md border overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Skill</TableHead>
|
|
<TableHead>Content type</TableHead>
|
|
<TableHead>Target band</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead className="text-right">Action</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{layer2.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell>{item.skill}</TableCell>
|
|
<TableCell>{item.content_type}</TableCell>
|
|
<TableCell>{item.target_band}</TableCell>
|
|
<TableCell>{item.status}</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button size="sm" variant="outline" onClick={() => setPreview(item)}>
|
|
Review
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Dialog open={!!preview} onOpenChange={(o) => !o && setPreview(null)}>
|
|
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Content preview</DialogTitle>
|
|
</DialogHeader>
|
|
{preview && (
|
|
<div className="space-y-4 text-sm">
|
|
<p>
|
|
<span className="font-medium">Skill:</span> {preview.skill} ·{" "}
|
|
<span className="font-medium">Type:</span> {preview.content_type} ·{" "}
|
|
<span className="font-medium">Band:</span> {preview.target_band}
|
|
</p>
|
|
<div className="rounded-md border bg-muted/40 p-4 min-h-[120px] text-muted-foreground">
|
|
Full content preview loads here from the authoring pipeline.
|
|
</div>
|
|
<div className="space-y-3">
|
|
<Label className="text-base">Examiner checklist</Label>
|
|
<div className="space-y-2">
|
|
{(
|
|
[
|
|
["topic_accuracy", "Accuracy"],
|
|
["difficulty_appropriate", "Difficulty"],
|
|
["ielts_aligned", "IELTS alignment"],
|
|
["culturally_sensitive", "Cultural sensitivity"],
|
|
] as const
|
|
).map(([key, label]) => (
|
|
<div key={key} className="flex items-center gap-2">
|
|
<Checkbox
|
|
id={key}
|
|
checked={checklist[key]}
|
|
onCheckedChange={(c) => setChecklist((prev) => ({ ...prev, [key]: !!c }))}
|
|
/>
|
|
<Label htmlFor={key} className="font-normal cursor-pointer">
|
|
{label}
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="notes">Rejection notes</Label>
|
|
<Textarea
|
|
id="notes"
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
placeholder="Required when rejecting"
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button variant="outline" onClick={() => send(true)} disabled={submitReview.isPending}>
|
|
Approve
|
|
</Button>
|
|
<Button variant="destructive" onClick={() => send(false)} disabled={submitReview.isPending}>
|
|
Reject with Notes
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|