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:
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>
|
||||
|
||||
Reference in New Issue
Block a user