Frontend - i18n: install tailwindcss-rtl, Cairo font, RTL-aware direction in index.css. - Language toggle: localize aria-label / menu label, persist choice, update document dir synchronously. - Sidebar: add `side` prop so the drawer pins to the right in RTL; wire up AdminLmsLayout, RoleLayout (student/teacher) and AppSidebar to pass side = i18n.dir() === 'rtl' ? 'right' : 'left'. - AdminLmsLayout: convert every nav item from hard-coded title to titleKey, translate group labels (incl. the collapsible Training), breadcrumbs, user menu (Profile / Settings / Logout), help button and toggle aria labels; replace physical mr-/right- utilities with logical me-/end-. - AI components (AiTipBanner, AiInsightsPanel, AiAlertBanner, AiSearchBar, AiAssistantDrawer): apply dir="auto" at the container level, localize titles, loading / error / empty states. - Dashboards (admin / student / teacher): wrap numeric values in <bdi>, localize dates via ar-EG, fix flex direction for KPI and assignment cards. - UI primitives (breadcrumb, calendar, carousel, dropdown-menu, menubar, context-menu, pagination, sidebar): flip chevrons in RTL via a scoped CSS rule, swap pl-/pr-/ml-/mr- for ps-/pe-/ms-/me-. - Add logical-direction helpers and bidirectional isolation classes. Locales - Expand en.ts and ar.ts with full `nav`, `sidebarGroup`, `breadcrumb`, `userMenu`, `chrome`, `ai`, and dashboard key sets; keep key parity. API client - `api-client.ts` reads the active language from localStorage/i18n and sends `Accept-Language` on every request so the backend can localize AI output. Backend (encoach_ai) - openai_service: add _LANGUAGE_NAMES, normalize_language, language-aware system prompt injection for every OpenAI call. - coach_service + controllers (coach_controller, ai_controller): thread the requested language from headers / user locale down to OpenAIService. - ai_feedback: fix latent registry error by pointing course_id at op.course instead of the non-existent encoach.course. Other - .gitignore: ignore runtime odoo logs and local caches. 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 me-1 rtl:rotate-180" /> 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>
|
|
);
|
|
}
|