Files
encoach_frontend_v4/src/pages/admin/ExamReviewDetail.tsx
Yamen Ahmad fbd58fa5a6 feat(i18n,rtl): full Arabic localization + RTL sweep across all layouts
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
2026-04-19 18:13:16 +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 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 &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>
);
}