Roadmap P0
- Ship /logo.svg fallback and rewire asset references (superseded later by
the project-manager PNG in a separate commit).
Roadmap P1
- Response envelope alignment ({items,total,page,size}) across services
and query hooks.
- Approval/ticket UX updates tied to the new backend rollback semantics.
Roadmap P2
- React.lazy + Vite manualChunks to split vendor-radix/charts/icons/forms
from the main bundle and cut initial JS.
- Fix the 181 tsc errors (PaginationParams class + per-service generics).
- Auto-refresh flow for JWT refresh tokens wired into api-client.ts.
Roadmap P3
- i18n: i18next + language detector, en/ar locales, RTL auto-switch,
LanguageToggle component in RoleLayout and AdminLmsLayout.
- GDPR: PrivacyCenter page for data export and right-to-erasure, backed
by gdpr.service.ts; logout via AuthContext after erasure.
- Human-in-the-loop exam review UI: admin ExamReviewQueue + ExamReviewDetail
pages, useExamReview hooks, exam-review.service.ts.
- AI quality loop: AIPromptEditor (versioning + render preview),
AIFeedbackButtons for students, AIFeedbackTriage admin page, dedicated
services, hooks, and types.
- Dark mode: ThemeProvider + ThemeToggle + chart color tokens.
- Accessibility: DialogDescription on every DialogContent; alert-dialog
and sheet updates.
- Retire dead pages: ExamPage marketing, Index, ProfilePage import.
- Playwright e2e scaffolding: playwright.config.ts + e2e/login.spec.ts
smoke tests, npm scripts test:e2e / test:e2e:install.
Made-with: Cursor
342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
import { useState } from "react";
|
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
|
import {
|
|
AlertTriangle,
|
|
ArrowLeft,
|
|
CheckCircle2,
|
|
Sparkles,
|
|
XCircle,
|
|
} from "lucide-react";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { toast } from "@/components/ui/sonner";
|
|
|
|
import {
|
|
useApproveExamReview,
|
|
useExamReviewDetail,
|
|
useRejectExamReview,
|
|
} from "@/hooks/queries/useExamReview";
|
|
import type { ExamReviewQuestion } from "@/types/exam-review";
|
|
|
|
const FAILING_THRESHOLD = 0.7;
|
|
|
|
function QuestionCard({ q, index }: { q: ExamReviewQuestion; index: number }) {
|
|
const failing = (q.quality_score ?? 0) < FAILING_THRESHOLD;
|
|
return (
|
|
<Card className={failing ? "border-destructive/40" : undefined}>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
|
|
<span className="font-mono">Q{index + 1}</span>
|
|
<span>·</span>
|
|
<Badge variant="outline" className="capitalize">
|
|
{q.skill}
|
|
</Badge>
|
|
<Badge variant="outline" className="capitalize">
|
|
{q.question_type}
|
|
</Badge>
|
|
<Badge variant="outline" className="capitalize">
|
|
{q.difficulty}
|
|
</Badge>
|
|
{q.ai_generated && (
|
|
<Badge variant="secondary" className="gap-1">
|
|
<Sparkles className="h-3 w-3" />
|
|
AI
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<CardTitle className="text-sm leading-snug">
|
|
{q.stem.length > 220 ? `${q.stem.slice(0, 220)}…` : q.stem}
|
|
</CardTitle>
|
|
</div>
|
|
<Badge
|
|
variant={
|
|
(q.quality_score ?? 0) >= 0.85
|
|
? "default"
|
|
: failing
|
|
? "destructive"
|
|
: "secondary"
|
|
}
|
|
className="tabular-nums shrink-0"
|
|
>
|
|
{((q.quality_score ?? 0) * 100).toFixed(0)}%
|
|
</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
{q.quality_report && Object.keys(q.quality_report).length > 0 && (
|
|
<CardContent>
|
|
<Label className="text-xs text-muted-foreground">Quality report</Label>
|
|
<pre className="mt-1 rounded-md bg-muted/50 p-3 text-xs overflow-x-auto">
|
|
{JSON.stringify(q.quality_report, null, 2)}
|
|
</pre>
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export default function ExamReviewDetail() {
|
|
const { examId } = useParams<{ examId: string }>();
|
|
const navigate = useNavigate();
|
|
const parsedId = examId ? Number(examId) : undefined;
|
|
|
|
const { data: exam, isLoading } = useExamReviewDetail(parsedId);
|
|
const approve = useApproveExamReview();
|
|
const reject = useRejectExamReview();
|
|
|
|
const [approveNotes, setApproveNotes] = useState("");
|
|
const [rejectNotes, setRejectNotes] = useState("");
|
|
const [approveOpen, setApproveOpen] = useState(false);
|
|
const [rejectOpen, setRejectOpen] = useState(false);
|
|
|
|
if (isLoading || !exam || !parsedId) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<Skeleton className="h-8 w-64" />
|
|
<Skeleton className="h-40 w-full" />
|
|
<Skeleton className="h-64 w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const handleApprove = async () => {
|
|
try {
|
|
await approve.mutateAsync({ examId: parsedId, notes: approveNotes });
|
|
toast.success("Exam approved and published.");
|
|
setApproveOpen(false);
|
|
navigate("/admin/exam/review-queue");
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Approve failed");
|
|
}
|
|
};
|
|
|
|
const handleReject = async () => {
|
|
if (!rejectNotes.trim()) {
|
|
toast.error("Rejection notes are required.");
|
|
return;
|
|
}
|
|
try {
|
|
await reject.mutateAsync({ examId: parsedId, notes: rejectNotes });
|
|
toast.success("Exam rejected and returned to draft.");
|
|
setRejectOpen(false);
|
|
navigate("/admin/exam/review-queue");
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Reject failed");
|
|
}
|
|
};
|
|
|
|
const summary = exam.summary;
|
|
const alreadyReviewed = exam.status !== "pending_review";
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="space-y-1">
|
|
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
|
<Link to="/admin/exam/review-queue">
|
|
<ArrowLeft className="h-4 w-4 mr-1" /> Back to queue
|
|
</Link>
|
|
</Button>
|
|
<h1 className="text-2xl font-bold tracking-tight">{exam.title}</h1>
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Badge variant="outline" className="capitalize">
|
|
{exam.status.replace("_", " ")}
|
|
</Badge>
|
|
{exam.subject_name && <span>· {exam.subject_name}</span>}
|
|
{exam.teacher_name && <span>· Created by {exam.teacher_name}</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{!alreadyReviewed && (
|
|
<div className="flex items-center gap-2">
|
|
<Dialog open={rejectOpen} onOpenChange={setRejectOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button variant="outline" className="gap-1">
|
|
<XCircle className="h-4 w-4" /> Reject
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Reject this exam?</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="reject-notes">
|
|
Feedback for the author (required)
|
|
</Label>
|
|
<Textarea
|
|
id="reject-notes"
|
|
value={rejectNotes}
|
|
onChange={(e) => setRejectNotes(e.target.value)}
|
|
rows={6}
|
|
placeholder="Explain what needs to change before this exam can be published."
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => setRejectOpen(false)}
|
|
disabled={reject.isPending}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleReject}
|
|
disabled={reject.isPending || !rejectNotes.trim()}
|
|
>
|
|
Reject & return to draft
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={approveOpen} onOpenChange={setApproveOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button className="gap-1">
|
|
<CheckCircle2 className="h-4 w-4" /> Approve & publish
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Approve and publish?</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="approve-notes">Notes (optional)</Label>
|
|
<Textarea
|
|
id="approve-notes"
|
|
value={approveNotes}
|
|
onChange={(e) => setApproveNotes(e.target.value)}
|
|
rows={4}
|
|
placeholder="Any comments or context for the audit log."
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => setApproveOpen(false)}
|
|
disabled={approve.isPending}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleApprove}
|
|
disabled={approve.isPending}
|
|
>
|
|
Publish exam
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{alreadyReviewed && exam.reviewed_by_name && (
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm">Previously reviewed</CardTitle>
|
|
<CardDescription>
|
|
{exam.reviewed_by_name}
|
|
{exam.reviewed_at ? ` · ${new Date(exam.reviewed_at).toLocaleString()}` : ""}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
{exam.review_notes && (
|
|
<CardContent className="text-sm whitespace-pre-wrap">
|
|
{exam.review_notes}
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
)}
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Quality summary</CardTitle>
|
|
<CardDescription>
|
|
Aggregated across every question in this exam.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
|
<div>
|
|
<div className="text-muted-foreground">Questions</div>
|
|
<div className="text-2xl font-semibold tabular-nums">
|
|
{summary.question_count}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-muted-foreground">Average quality</div>
|
|
<div className="text-2xl font-semibold tabular-nums">
|
|
{(summary.avg_quality_score * 100).toFixed(0)}%
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-muted-foreground">Lowest score</div>
|
|
<div className="text-2xl font-semibold tabular-nums">
|
|
{(summary.min_quality_score * 100).toFixed(0)}%
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-muted-foreground flex items-center gap-1">
|
|
<AlertTriangle className="h-3 w-3" /> Failing
|
|
</div>
|
|
<div className="text-2xl font-semibold tabular-nums text-destructive">
|
|
{summary.failing_count}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Separator />
|
|
|
|
<div className="space-y-3">
|
|
{exam.sections.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
This exam has no sections yet.
|
|
</p>
|
|
) : (
|
|
exam.sections.map((section) => (
|
|
<section key={section.id} className="space-y-3">
|
|
<h2 className="text-base font-semibold flex items-center gap-2">
|
|
<span>{section.title}</span>
|
|
<Badge variant="outline" className="capitalize text-xs">
|
|
{section.skill || "general"}
|
|
</Badge>
|
|
<span className="text-sm font-normal text-muted-foreground">
|
|
· {section.questions.length} questions
|
|
</span>
|
|
</h2>
|
|
<div className="space-y-2">
|
|
{section.questions.map((q, idx) => (
|
|
<QuestionCard key={q.id} q={q} index={idx} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|