Files
encoach_backend_new_v3/frontend/src/pages/admin/GradingQueue.tsx
Yamen Ahmad f1c4953a63 feat(v3): restructure project + add complete frontend
- Restructure: move backend from new_project/ to backend/
- Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks)
- Add docs/ with SRS specs, user stories, and workflow documentation
- Update .gitignore for new directory layout

Workflows implemented:
  WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration,
  WF4 General English Exam, WF5 Course Generation,
  WF6 Entity Student Onboarding, AI Course Generation,
  Adaptive Learning Engine UI, White-Label Branding, Score Release

Made-with: Cursor
2026-04-10 17:26:42 +04:00

228 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import {
useGradingQueue,
useStudentResponse,
useGradingRubric,
useAIGradeSuggestion,
useSubmitGrade,
} from "@/hooks/queries/useGrading";
import type { GradingQueueItem } from "@/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Slider } from "@/components/ui/slider";
import { AlertTriangle } from "lucide-react";
const WRITING_CRITERIA = ["Task Achievement", "Coherence", "Lexical Resource", "Grammatical Range"];
const SPEAKING_CRITERIA = ["Fluency", "Lexical Resource", "Grammatical Range", "Pronunciation"];
export default function GradingQueue() {
const { examId: examIdParam } = useParams();
const examId = Number(examIdParam);
const { data: queue = [], isLoading } = useGradingQueue({ exam_id: examId });
const filtered = useMemo(() => {
if (!Number.isFinite(examId) || examId <= 0) return queue;
return queue.filter((row) => row.exam_id === examId);
}, [queue, examId]);
const [open, setOpen] = useState(false);
const [active, setActive] = useState<GradingQueueItem | null>(null);
const skill = active?.skill ?? "writing";
const attemptId = active?.id ?? 0;
const responseQ = useStudentResponse(attemptId, skill);
const rubricQ = useGradingRubric(attemptId, skill);
const aiSuggest = useAIGradeSuggestion();
const submitGrade = useSubmitGrade();
const [scores, setScores] = useState<Record<string, number>>({});
const criteriaNames = skill === "speaking" ? SPEAKING_CRITERIA : WRITING_CRITERIA;
const openRow = (row: GradingQueueItem) => {
setActive(row);
setScores({});
setOpen(true);
};
useEffect(() => {
if (!open || !rubricQ.data) return;
const names = skill === "speaking" ? SPEAKING_CRITERIA : WRITING_CRITERIA;
const list = rubricQ.data;
const next: Record<string, number> = {};
list.forEach((c) => {
next[c.name] = typeof c.score === "number" ? c.score : 0;
});
names.forEach((n) => {
if (next[n] === undefined) next[n] = 0;
});
setScores(next);
}, [open, rubricQ.data, skill]);
const handleAISuggest = () => {
if (!active) return;
aiSuggest.mutate(
{ attemptId: active.id, skill },
{
onSuccess: (data) => {
setScores((prev) => {
const next = { ...prev };
data.criteria_scores.forEach((c) => {
next[c.name] = c.score;
});
return next;
});
},
},
);
};
const handleSubmit = () => {
if (!active) return;
submitGrade.mutate(
{
attempt_id: active.id,
skill,
criteria_scores: criteriaNames.map((name) => ({ name, score: scores[name] ?? 0 })),
},
{
onSuccess: () => {
setOpen(false);
setActive(null);
},
},
);
};
return (
<div className="space-y-6 p-6">
<div>
<h1 className="text-2xl font-bold">Grading queue</h1>
<p className="text-muted-foreground">Exam {examIdParam}</p>
</div>
<Card>
<CardHeader>
<CardTitle>Pending submissions</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Student Name</TableHead>
<TableHead>Exam Title</TableHead>
<TableHead>Skill</TableHead>
<TableHead>Submitted At</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((row) => (
<TableRow key={`${row.id}-${row.skill}`}>
<TableCell className="font-medium">{row.student_name}</TableCell>
<TableCell>{row.exam_title}</TableCell>
<TableCell className="capitalize">{row.skill}</TableCell>
<TableCell>{new Date(row.submitted_at).toLocaleString()}</TableCell>
<TableCell>
<Badge variant={row.status === "pending" ? "secondary" : "default"}>{row.status}</Badge>
</TableCell>
<TableCell className="text-right">
<Button type="button" size="sm" onClick={() => openRow(row)}>
Grade Now
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent className="flex w-full flex-col sm:max-w-xl">
<SheetHeader>
<SheetTitle>Grade response</SheetTitle>
</SheetHeader>
<ScrollArea className="flex-1 pr-4">
<div className="space-y-6 py-4">
{responseQ.isLoading ? (
<Skeleton className="h-32 w-full" />
) : responseQ.data ? (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">{responseQ.data.prompt_text}</p>
{responseQ.data.type === "writing" && responseQ.data.text ? (
<div className="rounded-md border bg-muted/30 p-4 text-sm whitespace-pre-wrap">{responseQ.data.text}</div>
) : null}
{responseQ.data.type === "speaking" ? (
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
Audio response placeholder
</div>
) : null}
</div>
) : null}
<Separator />
<div className="space-y-4">
<h3 className="font-semibold">Rubric</h3>
{criteriaNames.map((name) => (
<div key={name} className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={`score-${name}`}>{name}</Label>
<span className="text-sm text-muted-foreground">09</span>
</div>
<Slider
id={`score-${name}`}
min={0}
max={9}
step={1}
value={[scores[name] ?? 0]}
onValueChange={(v) => setScores((s) => ({ ...s, [name]: v[0] ?? 0 }))}
/>
<p className="text-sm font-medium">{scores[name] ?? 0}</p>
</div>
))}
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" onClick={handleAISuggest} disabled={aiSuggest.isPending || !active}>
AI Grade Suggestion
</Button>
<Button type="button" onClick={handleSubmit} disabled={submitGrade.isPending || !active}>
Submit Grade
</Button>
</div>
{aiSuggest.isError ? (
<p className="flex items-center gap-2 text-sm text-destructive">
<AlertTriangle className="h-4 w-4" />
Suggestion could not be loaded.
</p>
) : null}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
</div>
);
}