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
This commit is contained in:
Yamen Ahmad
2026-04-19 14:16:32 +04:00
parent 9b20d6ce66
commit b02c2e7526
75 changed files with 3727 additions and 551 deletions

View File

@@ -31,9 +31,10 @@ interface PlatformStats {
export default function AdminDashboard() {
const { data: stats } = useQuery<PlatformStats>({
queryKey: ["platform", "stats"],
queryFn: async () => {
const res = await api.get<{ data: PlatformStats }>("/stats");
return (res as { data: PlatformStats }).data ?? res;
queryFn: async (): Promise<PlatformStats> => {
const res = await api.get<{ data: PlatformStats } | PlatformStats>("/stats");
const wrapped = res as { data?: PlatformStats };
return wrapped.data ?? (res as PlatformStats);
},
staleTime: 30_000,
});

View File

@@ -66,11 +66,11 @@ export default function EntitiesPage() {
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const raw = entitiesQ.data;
const raw = entitiesQ.data as unknown;
const entities: { id: number; name: string; code: string; type: string; user_count: number; role_count: number; active: boolean }[] = (() => {
if (!raw) return [];
if (Array.isArray(raw)) return raw as never[];
const r = raw as Record<string, unknown>;
const r = raw as { items?: unknown; data?: unknown };
const arr = (r.items ?? r.data ?? []) as never[];
return Array.isArray(arr) ? arr : [];
})();

View File

@@ -1,54 +0,0 @@
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { GraduationCap, Play, Sparkles } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
export default function ExamPage() {
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] gap-4 max-w-md mx-auto">
<AiTipBanner context="exam" variant="recommendation" />
<Card className="border-0 shadow-sm w-full">
<CardContent className="p-8 text-center space-y-6">
<div className="mx-auto h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<GraduationCap className="h-8 w-8 text-primary" />
</div>
<div>
<h2 className="text-xl font-bold mb-1">Ready to Start?</h2>
<p className="text-muted-foreground text-sm">IELTS Academic Mock Exam</p>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Student</span>
<span className="font-medium">Sarah Johnson</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Level</span>
<Badge variant="outline">B2</Badge>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Modules</span>
<span className="font-medium">4</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Duration</span>
<span className="font-medium">180 min</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Mode</span>
<Badge variant="secondary">Practice</Badge>
</div>
</div>
<div className="rounded-lg bg-primary/5 border border-primary/20 p-3 text-left">
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-1"><Sparkles className="h-3 w-3" /> AI Pre-Exam Tip</p>
<p className="text-xs text-muted-foreground">Your last mock scored 7.5. To target 8.0, focus on time management in Writing Task 2 you spent 45 min last time vs recommended 40 min.</p>
</div>
<Button className="w-full" size="lg">
<Play className="h-4 w-4 mr-2" /> Begin Exam
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -37,12 +37,14 @@ export default function ExamsListPage() {
});
const sessionsQ = useInstitutionalExamSessions();
const sessionItems = sessionsQ.data?.data ?? sessionsQ.data?.items ?? [];
const sessions = Array.isArray(sessionItems) ? sessionItems : [];
const sessionItems = sessionsQ.data?.items ?? [];
const sessions: Record<string, string>[] = Array.isArray(sessionItems)
? (sessionItems as unknown as Record<string, string>[])
: [];
const customExams = customQ.data?.items ?? [];
const q = search.toLowerCase();
const filteredSessions = sessions.filter((s: Record<string, string>) =>
const filteredSessions = sessions.filter((s) =>
s.name?.toLowerCase().includes(q) || s.course_name?.toLowerCase().includes(q),
);
@@ -140,7 +142,7 @@ export default function ExamsListPage() {
{filteredSessions.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No exam sessions found.</TableCell></TableRow>
)}
{filteredSessions.map((s: Record<string, string>, i: number) => (
{filteredSessions.map((s, i) => (
<TableRow key={s.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{s.name}</TableCell>

View File

@@ -63,7 +63,7 @@ import { mediaService, type Avatar } from "@/services/media.service";
import { examsService } from "@/services/exams.service";
import { api } from "@/lib/api-client";
import { useToast } from "@/hooks/use-toast";
import type { ExamStructureConfig } from "@/types";
import type { ExamStructure, ExamStructureConfig } from "@/types";
type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry";
@@ -398,7 +398,7 @@ export default function GenerationPage() {
const createStructureMut = useMutation({
mutationFn: (data: { name: string; modules: string[] }) =>
examsService.createStructure(data),
examsService.createStructure(data as unknown as Partial<ExamStructure>),
onSuccess: (created) => {
queryClient.invalidateQueries({ queryKey: ["exam-structures"] });
setExamStructure(String(created.id));
@@ -693,7 +693,7 @@ export default function GenerationPage() {
try {
const status = await mediaService.getVideoStatus(videoId);
if (status.status === "done" || status.status === "completed" || status.video_url) {
updatedParts[pp.index] = { ...part, videoUrl: status.video_url || status.url || "" };
updatedParts[pp.index] = { ...part, videoUrl: status.video_url || "" };
changed = true;
toast({ title: "Video ready!", description: "Avatar video has been generated." });
} else if (status.status === "error" || status.status === "failed") {
@@ -726,7 +726,7 @@ export default function GenerationPage() {
totalMarks: st.totalMarks,
passages: mod === "reading" ? st.passages.map((p) => ({
text: p.text, category: p.category, type: p.type,
exercises: p.exercises?.map((ex: Record<string, unknown>) => ({
exercises: p.exercises?.map((ex) => ({
type: ex.type, prompt: ex.prompt, options: ex.options,
correct_answer: ex.correct_answer, explanation: ex.explanation,
instructions: ex.instructions, marks: ex.marks || 1,
@@ -735,7 +735,7 @@ export default function GenerationPage() {
})) : undefined,
sections: mod === "listening" ? st.listeningSections.map((s) => ({
type: s.type, context: s.context, audioUrl: s.audioUrl,
exercises: s.exercises?.map((ex: Record<string, unknown>) => ({
exercises: s.exercises?.map((ex) => ({
type: ex.type, prompt: ex.prompt, options: ex.options,
correct_answer: ex.correct_answer, explanation: ex.explanation,
instructions: ex.instructions, marks: ex.marks || 1,

View File

@@ -1,14 +0,0 @@
// Update this page (the content is just a fallback if you fail to update the page)
const Index = () => {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">Welcome to Your Blank App</h1>
<p className="text-xl text-muted-foreground">Start building your amazing project here!</p>
</div>
</div>
);
};
export default Index;

173
src/pages/PrivacyCenter.tsx Normal file
View File

@@ -0,0 +1,173 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Download, ShieldAlert, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { gdprService } from "@/services/gdpr.service";
import { useAuth } from "@/contexts/AuthContext";
export default function PrivacyCenter() {
const navigate = useNavigate();
const { logout } = useAuth();
const [exporting, setExporting] = useState(false);
const [erasing, setErasing] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [confirmText, setConfirmText] = useState("");
const downloadExport = async () => {
try {
setExporting(true);
const payload = await gdprService.exportMyData();
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `encoach-data-export-${new Date()
.toISOString()
.slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast.success("Data export downloaded");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Export failed");
} finally {
setExporting(false);
}
};
const eraseAccount = async () => {
try {
setErasing(true);
await gdprService.eraseMyAccount();
toast.success("Your account has been erased. Signing out…");
setTimeout(async () => {
try {
await logout();
} catch {
// ignore — the server may have already invalidated our tokens
}
navigate("/login", { replace: true });
}, 1200);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Erasure failed");
setErasing(false);
}
};
return (
<div className="mx-auto max-w-3xl space-y-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
<ShieldAlert className="mr-1 inline h-5 w-5" />
Privacy Center
</h1>
<p className="text-muted-foreground mt-1 text-sm">
Manage your personal data held by EnCoach under GDPR and equivalent
regulations.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Download your data</CardTitle>
<CardDescription>
We'll package your profile, entity memberships, exam attempts,
answers, AI feedback, and tickets into a single JSON file.
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={downloadExport} disabled={exporting}>
<Download className="mr-1 h-4 w-4" />
{exporting ? "Preparing export…" : "Download my data"}
</Button>
</CardContent>
</Card>
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-destructive">Delete my account</CardTitle>
<CardDescription>
This anonymises your profile and removes personal fields from our
records. Exam attempts are retained (without identifying
information) for statistical integrity. This action cannot be
undone.
</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="destructive"
onClick={() => setConfirmOpen(true)}
disabled={erasing}
>
<Trash2 className="mr-1 h-4 w-4" />
{erasing ? "Erasing…" : "Erase my account"}
</Button>
</CardContent>
</Card>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent description="Erasure is permanent — type DELETE to confirm.">
<AlertDialogHeader>
<AlertDialogTitle>Erase your account?</AlertDialogTitle>
<AlertDialogDescription>
We'll anonymise your personal data and deactivate your login.
Aggregate analytics (exam scores, attempt counts) will remain
but will no longer be linked to you.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-1">
<Label htmlFor="confirm-delete">
Type <strong>DELETE</strong> to confirm:
</Label>
<Input
id="confirm-delete"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="DELETE"
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConfirmText("")}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={confirmText !== "DELETE" || erasing}
onClick={async () => {
setConfirmOpen(false);
await eraseAccount();
setConfirmText("");
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Erase account
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -1,75 +0,0 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { User } from "lucide-react";
import { api } from "@/lib/api-client";
import { useMutation } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/contexts/AuthContext";
export default function ProfilePage() {
const { user } = useAuth();
const { toast } = useToast();
const nameParts = (user?.name || "Admin User").split(" ");
const [first, setFirst] = useState(nameParts[0] || "");
const [last, setLast] = useState(nameParts.slice(1).join(" ") || "");
const [email, setEmail] = useState(user?.email || "");
const [curPw, setCurPw] = useState("");
const [newPw, setNewPw] = useState("");
const [confirmPw, setConfirmPw] = useState("");
const saveMut = useMutation({
mutationFn: () => api.patch("/user", { first_name: first, last_name: last, email }),
onSuccess: () => toast({ title: "Profile saved" }),
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const pwMut = useMutation({
mutationFn: () => api.post("/user/change-password", { current_password: curPw, new_password: newPw }),
onSuccess: () => { setCurPw(""); setNewPw(""); setConfirmPw(""); toast({ title: "Password updated" }); },
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="text-2xl font-bold tracking-tight">Profile</h1>
<p className="text-muted-foreground">Manage your account information.</p>
</div>
<Card className="border-0 shadow-sm">
<CardHeader><CardTitle className="text-base">Personal Information</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4 mb-4">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<User className="h-8 w-8 text-primary" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>First Name</Label><Input value={first} onChange={(e) => setFirst(e.target.value)} /></div>
<div className="space-y-2"><Label>Last Name</Label><Input value={last} onChange={(e) => setLast(e.target.value)} /></div>
</div>
<div className="space-y-2"><Label>Email</Label><Input value={email} onChange={(e) => setEmail(e.target.value)} type="email" /></div>
<Button onClick={() => saveMut.mutate()} disabled={saveMut.isPending}>
{saveMut.isPending ? "Saving..." : "Save Changes"}
</Button>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader><CardTitle className="text-base">Change Password</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2"><Label>Current Password</Label><Input type="password" value={curPw} onChange={(e) => setCurPw(e.target.value)} placeholder="••••••••" /></div>
<div className="space-y-2"><Label>New Password</Label><Input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} placeholder="••••••••" /></div>
<div className="space-y-2"><Label>Confirm New Password</Label><Input type="password" value={confirmPw} onChange={(e) => setConfirmPw(e.target.value)} placeholder="••••••••" /></div>
{newPw && confirmPw && newPw !== confirmPw && <p className="text-sm text-destructive">Passwords do not match.</p>}
<Button onClick={() => pwMut.mutate()} disabled={pwMut.isPending || !curPw || !newPw || newPw !== confirmPw}>
{pwMut.isPending ? "Updating..." : "Update Password"}
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -165,7 +165,7 @@ export default function RubricsPage() {
queryKey: ["rubrics"],
queryFn: () => examsService.listRubrics({}),
});
const rubrics = (rubricsQ.data?.items ?? []) as RubricItem[];
const rubrics = (rubricsQ.data?.items ?? []) as unknown as RubricItem[];
const rubricGroupsQ = useQuery({
queryKey: ["rubric-groups"],

View File

@@ -0,0 +1,348 @@
import { useMemo, useState } from "react";
import { CheckCircle2, Filter, MessageSquare, ThumbsDown, ThumbsUp } from "lucide-react";
import { toast } from "sonner";
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,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import {
useAIFeedbackList,
useResolveAIFeedback,
} from "@/hooks/queries/useAIFeedback";
import type {
AIFeedback,
AIFeedbackStatus,
AIFeedbackSubjectType,
} from "@/types/ai-feedback";
const STATUS_COLORS: Record<AIFeedbackStatus, string> = {
open: "bg-amber-100 text-amber-900 border-amber-300",
acknowledged: "bg-sky-100 text-sky-900 border-sky-300",
fixed: "bg-emerald-100 text-emerald-900 border-emerald-300",
dismissed: "bg-slate-100 text-slate-700 border-slate-300",
};
type ResolveChoice = Exclude<AIFeedbackStatus, "open">;
function ResolveDialog({
feedback,
onClose,
}: {
feedback: AIFeedback | null;
onClose: () => void;
}) {
const resolve = useResolveAIFeedback();
const [status, setStatus] = useState<ResolveChoice>("acknowledged");
const [notes, setNotes] = useState("");
const open = !!feedback;
return (
<Dialog open={open} onOpenChange={(v) => (!v ? onClose() : null)}>
<DialogContent
className="max-w-lg"
description="Triage this feedback by setting its status and leaving a short note for the audit trail."
>
<DialogHeader>
<DialogTitle>Resolve feedback</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<div className="text-muted-foreground text-xs uppercase">
Status
</div>
<Select
value={status}
onValueChange={(v) => setStatus(v as ResolveChoice)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="acknowledged">Acknowledged</SelectItem>
<SelectItem value="fixed">Fixed</SelectItem>
<SelectItem value="dismissed">Dismissed</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<div className="text-muted-foreground text-xs uppercase">
Notes
</div>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={4}
placeholder="What did you do? (optional)"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
onClick={async () => {
if (!feedback) return;
try {
await resolve.mutateAsync({
id: feedback.id,
status,
notes,
});
toast.success(`Marked ${status}`);
setNotes("");
onClose();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Resolve failed",
);
}
}}
disabled={resolve.isPending}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function AIFeedbackTriage() {
const [statusFilter, setStatusFilter] = useState<AIFeedbackStatus | "all">("open");
const [ratingFilter, setRatingFilter] = useState<"up" | "down" | "all">("down");
const [subjectFilter, setSubjectFilter] = useState<
AIFeedbackSubjectType | "all"
>("all");
const [selected, setSelected] = useState<AIFeedback | null>(null);
const params = useMemo(
() => ({
status: statusFilter === "all" ? undefined : statusFilter,
rating: ratingFilter === "all" ? undefined : ratingFilter,
subject_type: subjectFilter === "all" ? undefined : subjectFilter,
page: 0,
size: 50,
}),
[statusFilter, ratingFilter, subjectFilter],
);
const { data, isLoading } = useAIFeedbackList(params);
const items: AIFeedback[] = data?.items ?? [];
return (
<div className="mx-auto max-w-7xl space-y-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
<MessageSquare className="mr-1 inline h-5 w-5" />
AI feedback triage
</h1>
<p className="text-muted-foreground mt-1 text-sm">
Student thumbs up/down on AI output use this to catch broken
prompts, bad questions, and low-quality coach replies.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>
<Filter className="mr-1 inline h-4 w-4" />
Filters
</CardTitle>
<CardDescription>
Default view surfaces open thumbs-down reports, which are usually
the most actionable.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-3">
<div className="space-y-1">
<div className="text-muted-foreground text-xs uppercase">
Status
</div>
<Select
value={statusFilter}
onValueChange={(v) =>
setStatusFilter(v as AIFeedbackStatus | "all")
}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="acknowledged">Acknowledged</SelectItem>
<SelectItem value="fixed">Fixed</SelectItem>
<SelectItem value="dismissed">Dismissed</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<div className="text-muted-foreground text-xs uppercase">
Rating
</div>
<Select
value={ratingFilter}
onValueChange={(v) => setRatingFilter(v as "up" | "down" | "all")}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="down">Thumbs down</SelectItem>
<SelectItem value="up">Thumbs up</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<div className="text-muted-foreground text-xs uppercase">
Type
</div>
<Select
value={subjectFilter}
onValueChange={(v) =>
setSubjectFilter(v as AIFeedbackSubjectType | "all")
}
>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="question">Exam question</SelectItem>
<SelectItem value="coach">Coach reply</SelectItem>
<SelectItem value="explanation">Explanation</SelectItem>
<SelectItem value="translation">Translation</SelectItem>
<SelectItem value="narrative">Report narrative</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Feedback ({data?.total ?? 0})</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : items.length === 0 ? (
<p className="text-muted-foreground text-sm">
No feedback matches the current filters.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>User</TableHead>
<TableHead>Subject</TableHead>
<TableHead>Rating</TableHead>
<TableHead>Prompt</TableHead>
<TableHead>Comment</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((f) => (
<TableRow key={f.id}>
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
{f.create_date
? new Date(f.create_date).toLocaleString()
: "—"}
</TableCell>
<TableCell className="text-sm">{f.user_name}</TableCell>
<TableCell className="font-mono text-xs">
{f.subject_key}
</TableCell>
<TableCell>
{f.rating === "up" ? (
<Badge className="bg-emerald-500 text-white hover:bg-emerald-500">
<ThumbsUp className="mr-1 h-3 w-3" />
Up
</Badge>
) : (
<Badge className="bg-rose-500 text-white hover:bg-rose-500">
<ThumbsDown className="mr-1 h-3 w-3" />
Down
</Badge>
)}
</TableCell>
<TableCell className="font-mono text-xs">
{f.prompt_key
? `${f.prompt_key} v${f.prompt_version ?? "?"}`
: "—"}
</TableCell>
<TableCell className="max-w-md text-sm">
{f.comment || <span className="text-muted-foreground"></span>}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={STATUS_COLORS[f.status]}
>
{f.status}
</Badge>
</TableCell>
<TableCell className="text-right">
{f.status === "open" ? (
<Button
size="sm"
variant="outline"
onClick={() => setSelected(f)}
>
Resolve
</Button>
) : (
<span className="text-muted-foreground text-xs">
{f.resolution_notes || "—"}
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<ResolveDialog feedback={selected} onClose={() => setSelected(null)} />
</div>
);
}

View File

@@ -0,0 +1,528 @@
import { useEffect, useMemo, useState } from "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,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import {
useAIPrompt,
useAIPromptKeys,
useAIPromptVersions,
useActivateAIPrompt,
useCreateAIPrompt,
useRenderAIPrompt,
} from "@/hooks/queries/useAIPrompts";
import type { AIPromptSummary } from "@/types/ai-prompt";
import { CheckCircle2, FileText, History, Play, PlusCircle } from "lucide-react";
import { toast } from "sonner";
function SelectedKeyPanel({
selectedKey,
onSelectVersion,
activePromptId,
}: {
selectedKey: string;
onSelectVersion: (id: number) => void;
activePromptId: number | null;
}) {
const { data, isLoading } = useAIPromptVersions(selectedKey);
const activate = useActivateAIPrompt();
if (isLoading) {
return <Skeleton className="h-32 w-full" />;
}
const versions = data ?? [];
if (!versions.length) {
return (
<p className="text-muted-foreground text-sm">
No versions yet for this prompt.
</p>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Version</TableHead>
<TableHead>Title</TableHead>
<TableHead>Author</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{versions.map((v) => (
<TableRow
key={v.id}
className={activePromptId === v.id ? "bg-muted/50" : undefined}
>
<TableCell className="font-mono">v{v.version}</TableCell>
<TableCell>{v.title}</TableCell>
<TableCell>{v.author_name || "—"}</TableCell>
<TableCell>
{v.is_active ? (
<Badge className="bg-emerald-500 text-white hover:bg-emerald-500">
Active
</Badge>
) : (
<Badge variant="secondary">Archived</Badge>
)}
</TableCell>
<TableCell className="space-x-2 text-right">
<Button
size="sm"
variant="outline"
onClick={() => onSelectVersion(v.id)}
>
<FileText className="mr-1 h-3 w-3" />
Open
</Button>
{!v.is_active ? (
<Button
size="sm"
onClick={async () => {
try {
await activate.mutateAsync(v.id);
toast.success(`Activated v${v.version}`);
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Activate failed",
);
}
}}
disabled={activate.isPending}
>
<CheckCircle2 className="mr-1 h-3 w-3" />
Activate
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
function PromptDetail({ promptId }: { promptId: number }) {
const { data, isLoading } = useAIPrompt(promptId);
const render = useRenderAIPrompt();
// Variables detected from the prompt body — keep local state keyed by name
// so the editor can show one input per placeholder.
const [sampleValues, setSampleValues] = useState<Record<string, string>>({});
const [rendered, setRendered] = useState<string>("");
useEffect(() => {
setRendered("");
setSampleValues({});
}, [promptId]);
if (isLoading || !data) {
return <Skeleton className="h-40 w-full" />;
}
const variables = data.variables ?? [];
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">
{data.title}{" "}
<span className="text-muted-foreground font-normal">
({data.key} v{data.version})
</span>
</h3>
{data.description ? (
<p className="text-muted-foreground mt-1 text-sm">
{data.description}
</p>
) : null}
</div>
<div>
<Label className="text-xs uppercase tracking-wide">Template</Label>
<ScrollArea className="bg-muted/30 mt-1 max-h-64 rounded border">
<pre className="p-3 font-mono text-sm whitespace-pre-wrap">
{data.content}
</pre>
</ScrollArea>
</div>
{variables.length > 0 ? (
<div className="space-y-2">
<Label className="text-xs uppercase tracking-wide">
Sample variables
</Label>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
{variables.map((v) => (
<div key={v} className="space-y-1">
<Label className="text-xs">{v}</Label>
<Input
value={sampleValues[v] ?? ""}
onChange={(e) =>
setSampleValues((prev) => ({
...prev,
[v]: e.target.value,
}))
}
placeholder={`Sample value for {${v}}`}
/>
</div>
))}
</div>
<Button
size="sm"
variant="outline"
onClick={async () => {
try {
const r = await render.mutateAsync({
id: data.id,
variables: sampleValues,
});
setRendered(r.rendered);
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Render failed",
);
}
}}
disabled={render.isPending}
>
<Play className="mr-1 h-3 w-3" />
Render preview
</Button>
{rendered ? (
<ScrollArea className="bg-background mt-2 max-h-48 rounded border">
<pre className="p-3 font-mono text-sm whitespace-pre-wrap">
{rendered}
</pre>
</ScrollArea>
) : null}
</div>
) : null}
</div>
);
}
function NewVersionDialog({
open,
onOpenChange,
defaultKey,
defaultContent,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
defaultKey: string;
defaultContent: string;
}) {
const create = useCreateAIPrompt();
const [key, setKey] = useState(defaultKey);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [content, setContent] = useState(defaultContent);
const [activate, setActivate] = useState(true);
useEffect(() => {
if (open) {
setKey(defaultKey);
setContent(defaultContent);
setTitle("");
setDescription("");
setActivate(true);
}
}, [open, defaultKey, defaultContent]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-2xl"
description="Create a new prompt version; the previous active version will be archived."
>
<DialogHeader>
<DialogTitle>New prompt version</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>Key</Label>
<Input
value={key}
onChange={(e) => setKey(e.target.value.trim())}
placeholder="exam.mcq.generate"
/>
<p className="text-muted-foreground mt-1 text-xs">
Lowercase dotted identifier. Reuse an existing key to bump its
version.
</p>
</div>
<div>
<Label>Title</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="MCQ generation — CEFR B2 — v6"
/>
</div>
<div>
<Label>Description (optional)</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
placeholder="What changed? What should editors know?"
/>
</div>
<div>
<Label>Template</Label>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={10}
className="font-mono text-sm"
placeholder="You are an exam author. Generate {count} MCQs for {topic}…"
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={activate}
onChange={(e) => setActivate(e.target.checked)}
/>
Activate this version immediately
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={async () => {
if (!key || !title || !content.trim()) {
toast.error("Key, title, and content are required");
return;
}
try {
await create.mutateAsync({
key,
title,
description,
content,
activate,
});
toast.success("Prompt version created");
onOpenChange(false);
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Create failed",
);
}
}}
disabled={create.isPending}
>
Save version
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function AIPromptEditor() {
const [search, setSearch] = useState("");
const { data, isLoading } = useAIPromptKeys({ search, page: 1, size: 50 });
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [selectedPromptId, setSelectedPromptId] = useState<number | null>(null);
const [newOpen, setNewOpen] = useState(false);
const items: AIPromptSummary[] = data?.items ?? [];
const activePromptId = useMemo(() => {
if (!selectedKey) return null;
const match = items.find((it) => it.key === selectedKey);
return match?.id ?? null;
}, [items, selectedKey]);
// Autoselect first key on load.
useEffect(() => {
if (!selectedKey && items.length) {
setSelectedKey(items[0].key);
setSelectedPromptId(items[0].id);
}
}, [items, selectedKey]);
return (
<div className="mx-auto max-w-7xl space-y-6 p-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
AI prompt library
</h1>
<p className="text-muted-foreground mt-1 text-sm">
Versioned, auditable templates that the AI pipelines render at
runtime. Non-engineers can iterate here without shipping code.
</p>
</div>
<Button onClick={() => setNewOpen(true)}>
<PlusCircle className="mr-1 h-4 w-4" />
New version
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Prompt keys</CardTitle>
<CardDescription>
Each row shows the latest version of a prompt key. Click a row to
inspect all versions.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Input
placeholder="Search keys (e.g. exam.mcq)"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm"
/>
{isLoading ? (
<Skeleton className="h-32 w-full" />
) : items.length === 0 ? (
<p className="text-muted-foreground text-sm">
No prompts yet. Click <strong>New version</strong> to add one.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Key</TableHead>
<TableHead>Latest</TableHead>
<TableHead>Title</TableHead>
<TableHead>Variables</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((p) => (
<TableRow
key={p.id}
className={
selectedKey === p.key
? "bg-muted/40 cursor-pointer"
: "cursor-pointer"
}
onClick={() => {
setSelectedKey(p.key);
setSelectedPromptId(p.id);
}}
>
<TableCell className="font-mono text-xs">{p.key}</TableCell>
<TableCell>
v{p.version}{" "}
{p.total_versions && p.total_versions > 1 ? (
<span className="text-muted-foreground text-xs">
(of {p.total_versions})
</span>
) : null}
</TableCell>
<TableCell>{p.title}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{p.variables.slice(0, 4).map((v) => (
<Badge key={v} variant="outline" className="text-xs">
{v}
</Badge>
))}
{p.variables.length > 4 ? (
<Badge variant="outline" className="text-xs">
+{p.variables.length - 4}
</Badge>
) : null}
</div>
</TableCell>
<TableCell>
{p.is_active ? (
<Badge className="bg-emerald-500 text-white hover:bg-emerald-500">
Active
</Badge>
) : (
<Badge variant="secondary">No active</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{selectedKey ? (
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>
<History className="mr-1 inline h-4 w-4" />
Versions of {selectedKey}
</CardTitle>
</CardHeader>
<CardContent>
<SelectedKeyPanel
selectedKey={selectedKey}
onSelectVersion={setSelectedPromptId}
activePromptId={selectedPromptId}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Preview</CardTitle>
<CardDescription>
Inspect template body and try a dry-run render.
</CardDescription>
</CardHeader>
<CardContent>
{selectedPromptId ? (
<PromptDetail promptId={selectedPromptId} />
) : (
<p className="text-muted-foreground text-sm">
Select a version from the list.
</p>
)}
</CardContent>
</Card>
</div>
) : null}
<NewVersionDialog
open={newOpen}
onOpenChange={setNewOpen}
defaultKey={selectedKey ?? ""}
defaultContent=""
/>
</div>
);
}

View File

@@ -24,7 +24,7 @@ export default function AdminGradebook() {
const { data: coursesData } = useCourses({ size: 200 });
const { data: subjectsData } = useSubjects();
const coursesList = coursesData?.items ?? [];
const subjectsList = Array.isArray(subjectsData) ? subjectsData : subjectsData?.data ?? subjectsData?.items ?? [];
const subjectsList = Array.isArray(subjectsData) ? subjectsData : [];
const createMutation = useCreateGradingAssignment();
const updateMutation = useUpdateGradingAssignment();
const deleteMutation = useDeleteGradingAssignment();

View File

@@ -29,7 +29,7 @@ export default function AdminLessons() {
const { data: subjectsData } = useSubjects();
const courses = coursesData?.items ?? [];
const allBatches = batchesData?.items ?? [];
const subjects = Array.isArray(subjectsData) ? subjectsData : subjectsData?.data ?? subjectsData?.items ?? [];
const subjects = Array.isArray(subjectsData) ? subjectsData : [];
const batchesForCourse = useMemo(
() => (form.course_id ? allBatches.filter((b) => b.course_id === Number(form.course_id)) : allBatches),
[allBatches, form.course_id],

View File

@@ -39,9 +39,10 @@ export default function AdminLmsDashboard() {
const { data: dbStats } = useQuery<DashboardStats>({
queryKey: ["dashboard", "stats"],
queryFn: async () => {
const res = await api.get<{ data: DashboardStats }>("/stats");
return (res as { data: DashboardStats }).data ?? res;
queryFn: async (): Promise<DashboardStats> => {
const res = await api.get<{ data: DashboardStats } | DashboardStats>("/stats");
const wrapped = res as { data?: DashboardStats };
return wrapped.data ?? (res as DashboardStats);
},
staleTime: 30_000,
});

View File

@@ -19,7 +19,7 @@ export default function AdminTeachers() {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [gender, setGender] = useState("male");
const [gender, setGender] = useState<"male" | "female">("male");
const [specialization, setSpecialization] = useState("");
const { toast } = useToast();
const qc = useQueryClient();
@@ -157,7 +157,7 @@ export default function AdminTeachers() {
<div className="space-y-2"><Label>Email *</Label><Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
<div className="space-y-2">
<Label>Gender</Label>
<Select value={gender} onValueChange={setGender}>
<Select value={gender} onValueChange={(v) => setGender(v as "male" | "female")}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="male">Male</SelectItem>

View File

@@ -0,0 +1,341 @@
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>
);
}

View File

@@ -0,0 +1,205 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import {
AlertTriangle,
CheckCircle2,
FileText,
Search,
Sparkles,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useExamReviewQueue } from "@/hooks/queries/useExamReview";
/**
* Admin-facing queue of AI-generated exams that failed the automated quality
* gate and need a human to sign off before they can be published to students.
*
* The list intentionally stays dense — each row shows only the signals an
* operator needs to prioritise (quality score, failing-question count, AI
* model used). Clicking through to the detail page reveals the full report.
*/
export default function ExamReviewQueue() {
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const pageSize = 20;
const { data, isLoading } = useExamReviewQueue({
page,
size: pageSize,
search: search || undefined,
status: "pending_review",
});
const items = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Exam Review Queue</h1>
<p className="text-sm text-muted-foreground mt-1">
AI-generated exams that failed the automated quality gate. Review
each one, then approve to publish or reject back to draft.
</p>
</div>
<Badge variant="secondary" className="gap-1">
<Sparkles className="h-3 w-3" />
{total} awaiting review
</Badge>
</div>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by exam title…"
value={search}
onChange={(e) => {
setPage(1);
setSearch(e.target.value);
}}
className="pl-9"
/>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="p-6 space-y-2">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : items.length === 0 ? (
<div className="p-10 text-center">
<CheckCircle2 className="mx-auto h-10 w-10 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
Nothing in the review queue. Any time an AI-generated exam
fails the quality gate, it will show up here.
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[32%]">Exam</TableHead>
<TableHead>Subject</TableHead>
<TableHead className="text-center">Questions</TableHead>
<TableHead className="text-center">Avg. Quality</TableHead>
<TableHead className="text-center">Failing</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((exam) => {
const avg = exam.summary.avg_quality_score;
const failing = exam.summary.failing_count;
return (
<TableRow key={exam.id}>
<TableCell>
<div className="flex items-start gap-2">
<FileText className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div className="min-w-0">
<Link
to={`/admin/exam/review/${exam.id}`}
className="font-medium hover:underline"
>
{exam.title}
</Link>
<div className="text-xs text-muted-foreground mt-0.5">
{exam.summary.ai_generated_count} AI-generated ·{" "}
{exam.teacher_name || "Unassigned"}
</div>
</div>
</div>
</TableCell>
<TableCell className="text-sm">
{exam.subject_name || "—"}
</TableCell>
<TableCell className="text-center text-sm">
{exam.summary.question_count}
</TableCell>
<TableCell className="text-center">
<Badge
variant={
avg >= 0.85
? "default"
: avg >= 0.7
? "secondary"
: "destructive"
}
className="tabular-nums"
>
{(avg * 100).toFixed(0)}%
</Badge>
</TableCell>
<TableCell className="text-center">
{failing > 0 ? (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" />
{failing}
</Badge>
) : (
<span className="text-muted-foreground text-sm"></span>
)}
</TableCell>
<TableCell className="text-right">
<Button asChild size="sm" variant="outline">
<Link to={`/admin/exam/review/${exam.id}`}>Review</Link>
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{pageCount > 1 && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
Page {page} of {pageCount} · {total} total
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= pageCount}
onClick={() => setPage((p) => Math.min(pageCount, p + 1))}
>
Next
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -19,7 +19,7 @@ import {
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { useIeltsValidation, usePublishIeltsExam, useAssignIeltsExam } from "@/hooks/queries/useExamTemplates";
import { useIeltsExamValidation, usePublishIeltsExam, useAssignIeltsExam } from "@/hooks/queries/useExamTemplates";
import { useStudents, useBatches } from "@/hooks/queries/useLms";
import { ieltsExamService } from "@/services/ielts-exam.service";
import type { ExamValidationReport, ValidationCheck } from "@/types";
@@ -40,7 +40,7 @@ export default function IeltsExamValidation() {
const navigate = useNavigate();
const examId = Number(examIdParam);
const validationQ = useIeltsValidation(Number.isFinite(examId) ? examId : 0);
const validationQ = useIeltsExamValidation(Number.isFinite(examId) ? examId : 0);
const publishMut = usePublishIeltsExam();
const assignMut = useAssignIeltsExam();

View File

@@ -176,7 +176,7 @@ function EditResourceDialog({
</div>
<div className="space-y-2">
<Label>Status</Label>
<Select value={status} onValueChange={setStatus}>
<Select value={status} onValueChange={(v) => setStatus(v as "pending" | "approved" | "rejected")}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pending">Pending</SelectItem>

View File

@@ -50,7 +50,7 @@ export default function TaxonomyManager() {
const [showAddTopic, setShowAddTopic] = useState<number | null>(null);
const [newTopicName, setNewTopicName] = useState("");
const [newTopicDifficulty, setNewTopicDifficulty] = useState("medium");
const [newTopicDifficulty, setNewTopicDifficulty] = useState<"easy" | "medium" | "hard">("medium");
const [newTopicHours, setNewTopicHours] = useState("1");
const [editingSubjectId, setEditingSubjectId] = useState<number | null>(null);
@@ -62,7 +62,7 @@ export default function TaxonomyManager() {
const [editingTopicId, setEditingTopicId] = useState<number | null>(null);
const [editTopicName, setEditTopicName] = useState("");
const [editTopicDifficulty, setEditTopicDifficulty] = useState("medium");
const [editTopicDifficulty, setEditTopicDifficulty] = useState<"easy" | "medium" | "hard">("medium");
const [editTopicHours, setEditTopicHours] = useState("1");
const [editTopicDesc, setEditTopicDesc] = useState("");
@@ -142,7 +142,7 @@ export default function TaxonomyManager() {
const startEditTopic = (t: { id: number; name: string; difficulty_level?: string; estimated_hours?: number; description?: string }) => {
setEditingTopicId(t.id);
setEditTopicName(t.name);
setEditTopicDifficulty(t.difficulty_level ?? "medium");
setEditTopicDifficulty((t.difficulty_level as "easy" | "medium" | "hard") ?? "medium");
setEditTopicHours(String(t.estimated_hours ?? 1));
setEditTopicDesc(t.description ?? "");
};
@@ -333,7 +333,7 @@ export default function TaxonomyManager() {
{showAddTopic === domain.id && (
<div className="flex items-center gap-2 p-2 rounded border bg-muted/50">
<Input placeholder="Topic name" value={newTopicName} onChange={e => setNewTopicName(e.target.value)} className="h-8 text-sm" />
<Select value={newTopicDifficulty} onValueChange={setNewTopicDifficulty}>
<Select value={newTopicDifficulty} onValueChange={(v) => setNewTopicDifficulty(v as "easy" | "medium" | "hard")}>
<SelectTrigger className="w-28 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="easy">Easy</SelectItem>
@@ -352,7 +352,7 @@ export default function TaxonomyManager() {
<div className="space-y-2">
<div className="flex items-center gap-2">
<Input value={editTopicName} onChange={e => setEditTopicName(e.target.value)} className="h-7 text-sm flex-1" placeholder="Topic name" />
<Select value={editTopicDifficulty} onValueChange={setEditTopicDifficulty}>
<Select value={editTopicDifficulty} onValueChange={(v) => setEditTopicDifficulty(v as "easy" | "medium" | "hard")}>
<SelectTrigger className="w-28 h-7"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="easy">Easy</SelectItem>

View File

@@ -166,7 +166,7 @@ export default function AiIeltsCourse() {
const band = Number(targetBand || course?.target_level || 7);
createIelts.mutate(
{
skill: skillsRanked[0]?.skill ?? "writing",
skill: (skillsRanked[0]?.skill ?? "writing") as "reading" | "listening" | "writing" | "speaking",
target_band: Number.isFinite(band) ? band : 7,
},
{

View File

@@ -78,7 +78,7 @@ export default function ExamResults() {
const { data: results, isLoading, isError } = useQuery<ExamResultsData>({
queryKey: ["exam-results", examId],
queryFn: () => examSessionService.getResults(Number(examId)),
queryFn: () => examSessionService.getResults<ExamResultsData>(Number(examId)),
enabled: !!examId,
});

View File

@@ -61,7 +61,7 @@ export default function StudentAttendance() {
{attendanceRecords.map(a => (
<TableRow key={a.id}>
<TableCell>{a.date}</TableCell>
<TableCell>{a.courseName}</TableCell>
<TableCell>{a.course_name}</TableCell>
<TableCell><Badge variant={badgeVariant(a.status)} className="capitalize">{a.status}</Badge></TableCell>
</TableRow>
))}

View File

@@ -77,7 +77,7 @@ export default function StudentDiscussionBoard() {
const [newTitle, setNewTitle] = useState("");
const [newContent, setNewContent] = useState("");
const posts = postsData?.results ?? [];
const posts = postsData?.items ?? [];
const handleCreatePost = () => {
if (!selectedBoardId) return;

View File

@@ -11,15 +11,15 @@ export default function StudentGrades() {
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const avgGrade = gradeRecords.length
? (gradeRecords.reduce((sum, g) => sum + (g.grade / g.maxGrade) * 100, 0) / gradeRecords.length).toFixed(1)
? (gradeRecords.reduce((sum, g) => sum + (g.grade / g.max_grade) * 100, 0) / gradeRecords.length).toFixed(1)
: "0";
const highest = gradeRecords.length
? Math.max(...gradeRecords.map(g => (g.grade / g.maxGrade) * 100)).toFixed(1)
? Math.max(...gradeRecords.map(g => (g.grade / g.max_grade) * 100)).toFixed(1)
: "0";
const chartData = gradeRecords.map(g => ({
name: g.assignmentTitle.length > 15 ? g.assignmentTitle.slice(0, 15) + "…" : g.assignmentTitle,
grade: Math.round((g.grade / g.maxGrade) * 100),
name: g.assignment_title.length > 15 ? g.assignment_title.slice(0, 15) + "…" : g.assignment_title,
grade: Math.round((g.grade / g.max_grade) * 100),
}));
return (
@@ -69,11 +69,11 @@ export default function StudentGrades() {
<TableBody>
{gradeRecords.map(g => (
<TableRow key={g.id}>
<TableCell className="font-medium">{g.assignmentTitle}</TableCell>
<TableCell>{g.courseName}</TableCell>
<TableCell className="font-medium">{g.assignment_title}</TableCell>
<TableCell>{g.course_name}</TableCell>
<TableCell><Badge variant="outline" className="text-xs capitalize">{g.type}</Badge></TableCell>
<TableCell>{g.date}</TableCell>
<TableCell className="text-right font-bold">{g.grade}/{g.maxGrade}</TableCell>
<TableCell className="text-right font-bold">{g.grade}/{g.max_grade}</TableCell>
<TableCell><AiGradeExplainer studentName="Sarah Johnson" /></TableCell>
</TableRow>
))}

View File

@@ -23,8 +23,8 @@ export default function StudentMessages() {
const { data: unreadData } = useUnreadMessageCount();
const sendMessage = useSendMessage();
const inbox = inboxData?.results ?? [];
const sent = sentData?.results ?? [];
const inbox = inboxData?.items ?? [];
const sent = sentData?.items ?? [];
const unreadCount = unreadData?.count ?? 0;
const [showCompose, setShowCompose] = useState(false);

View File

@@ -8,21 +8,21 @@ export default function StudentTimetable() {
const { data: timetableSessions = [], isLoading } = useTimetable();
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const mySessions = timetableSessions.filter(s => ["c1", "c2", "c5", "c8"].includes(s.courseId));
const mySessions = timetableSessions.filter(s => ["c1", "c2", "c5", "c8"].includes(String(s.course_id)));
function getSessionAt(day: string, hour: string) {
return mySessions.find(s => s.day === day && s.startTime <= hour && s.endTime > hour);
return mySessions.find(s => s.day === day && s.start_time <= hour && s.end_time > hour);
}
function isStart(day: string, hour: string) {
return mySessions.find(s => s.day === day && s.startTime === hour);
return mySessions.find(s => s.day === day && s.start_time === hour);
}
function getSpan(session: (typeof mySessions)[0]) {
const start = parseInt(session.startTime.split(":")[0]);
const end = parseInt(session.endTime.split(":")[0]);
const startMin = parseInt(session.startTime.split(":")[1]);
const endMin = parseInt(session.endTime.split(":")[1]);
const start = parseInt(session.start_time.split(":")[0]);
const end = parseInt(session.end_time.split(":")[0]);
const startMin = parseInt(session.start_time.split(":")[1]);
const endMin = parseInt(session.end_time.split(":")[1]);
return (end - start) + (endMin > 0 ? 0.5 : 0) - (startMin > 0 ? 0.5 : 0);
}
@@ -55,8 +55,8 @@ export default function StudentTimetable() {
className="rounded-md p-2 text-xs text-white"
style={{ backgroundColor: session.color, minHeight: `${getSpan(session) * 48}px` }}
>
<p className="font-semibold">{session.courseName}</p>
<p className="opacity-80">{session.startTime}-{session.endTime}</p>
<p className="font-semibold">{session.course_name}</p>
<p className="opacity-80">{session.start_time}-{session.end_time}</p>
<p className="opacity-70">{session.room}</p>
</div>
);

View File

@@ -57,7 +57,7 @@ export default function ChapterDetail() {
queryFn: () => resourcesService.list({
search: libSearch || undefined,
resource_type: libTypeFilter === "all" ? undefined : libTypeFilter,
limit: 50,
size: 50,
}),
enabled: showUpload,
});

View File

@@ -23,7 +23,7 @@ export default function TeacherAnnouncements() {
const { toast } = useToast();
const { data: announcements = [], isLoading } = useAnnouncements();
const { data: coursesData } = useCourses();
const courses = coursesData?.results ?? [];
const courses = coursesData?.items ?? [];
const createAnnouncement = useCreateAnnouncement();
const publishAnnouncement = usePublishAnnouncement();
@@ -84,7 +84,7 @@ export default function TeacherAnnouncements() {
<SelectContent>
<SelectItem value="all">All Courses</SelectItem>
{courses.map(c => (
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
<SelectItem key={c.id} value={String(c.id)}>{c.title}</SelectItem>
))}
</SelectContent>
</Select>

View File

@@ -89,7 +89,7 @@ export default function TeacherDiscussionBoard() {
const [newTitle, setNewTitle] = useState("");
const [newContent, setNewContent] = useState("");
const posts = postsData?.results ?? [];
const posts = postsData?.items ?? [];
const handleCreatePost = () => {
if (!selectedBoardId) return;