Files
encoach_frontend_new_v2/src/pages/admin/ExamReviewDetail.tsx
Yamen Ahmad b02c2e7526 feat(frontend): Phase 2/3 hardening release
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
2026-04-19 14:16:32 +04:00

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 &amp; 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 &amp; 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>
);
}