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:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 : [];
|
||||
})();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
173
src/pages/PrivacyCenter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
348
src/pages/admin/AIFeedbackTriage.tsx
Normal file
348
src/pages/admin/AIFeedbackTriage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
528
src/pages/admin/AIPromptEditor.tsx
Normal file
528
src/pages/admin/AIPromptEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
341
src/pages/admin/ExamReviewDetail.tsx
Normal file
341
src/pages/admin/ExamReviewDetail.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
205
src/pages/admin/ExamReviewQueue.tsx
Normal file
205
src/pages/admin/ExamReviewQueue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user