Files
encoach_frontend_v4/src/pages/StudentPerformancePage.tsx
Yamen Ahmad 000969b0b9 feat(reports): replace mock Reports pages with real backend aggregates
Wire the three admin Reports pages (/admin/student-performance,
/admin/stats-corporate, /admin/record) to a new
encoach_lms_api/controllers/reports.py that aggregates from
encoach.student.attempt.

* /api/reports/student-performance: per-student band averages + CEFR,
  with entity / level / search filters.
* /api/reports/stats-corporate: by_module bar, N-month trend line,
  CEFR distribution pie, entity comparison bar, threshold + entity
  filters and meta.attempts_considered for UI.
* /api/reports/record: paginated attempt history with entity / user /
  period filters, EX-### exam codes, duration derived from start/end.
* /api/reports/filters: shared picker returning only entities /
  students that actually have attempts.

Frontend: new reports.service.ts, all three pages rewritten to hit
these endpoints; Recharts graphs now read live data, CSV export added
on Student Performance and Record.

Seeding: seed_reports.py completes any in_progress attempts and
backfills 6 months of historical attempts across 3 entities so the
trend / distribution / KPI panels have meaningful data. Idempotent.

Tests: test_reports_flows.py (25/25 PASS) covers shape, filters,
pagination. Regressions still green: Configuration 24/24, Support
29/29, Training 26/26. Browser-verified on localhost:8080 with admin
login — all 4 tabs in Stats Corporate render, Student Performance
shows real students + KPIs, Record shows 28 attempts with filter +
CSV export working.

Docs: new §20 in PROJECT_SUMMARY.md documenting scope, artifacts,
seeding, test results, and gotchas (encoach.exam.custom uses `title`
not `name`; encoach.entity requires `code`; in_progress attempts are
excluded from aggregates).

Made-with: Cursor
2026-04-19 11:28:26 +04:00

273 lines
9.0 KiB
TypeScript

import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Loader2, Download } from "lucide-react";
import AiStudyCoach from "@/components/ai/AiStudyCoach";
import AiGradeExplainer from "@/components/ai/AiGradeExplainer";
import { reportsService } from "@/services/reports.service";
const LEVELS = ["all", "A1", "A2", "B1", "B2", "C1", "C2"] as const;
function ScoreBadge({ score }: { score: number | null }) {
if (score === null || score === undefined) {
return <span className="text-muted-foreground"></span>;
}
const color =
score >= 7.5
? "bg-success/10 text-success"
: score >= 6.0
? "bg-warning/10 text-warning"
: "bg-destructive/10 text-destructive";
return (
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${color}`}
>
{score.toFixed(1)}
</span>
);
}
export default function StudentPerformancePage() {
const [entityId, setEntityId] = useState<string>("all");
const [level, setLevel] = useState<(typeof LEVELS)[number]>("all");
const [search, setSearch] = useState("");
const { data: filters } = useQuery({
queryKey: ["reports-filters"],
queryFn: () => reportsService.filters(),
});
const { data, isLoading } = useQuery({
queryKey: ["student-performance", entityId, level, search],
queryFn: () =>
reportsService.studentPerformance({
entity_id: entityId === "all" ? undefined : Number(entityId),
level: level === "all" ? undefined : level,
search: search || undefined,
}),
});
const rows = data?.items ?? [];
const summary = useMemo(() => {
if (!rows.length) return null;
const overall = rows
.map((r) => r.overall)
.filter((v): v is number => v !== null);
const avg = overall.length
? overall.reduce((a, b) => a + b, 0) / overall.length
: null;
return {
total: rows.length,
avg: avg !== null ? avg.toFixed(1) : "—",
topLevel: rows[0]?.level ?? "—",
};
}, [rows]);
const exportCsv = () => {
const header = [
"Student",
"Entity",
"Level",
"Reading",
"Listening",
"Writing",
"Speaking",
"Overall",
"Attempts",
];
const lines = rows.map((r) =>
[
r.student_name,
r.entity_name,
r.level ?? "",
r.reading ?? "",
r.listening ?? "",
r.writing ?? "",
r.speaking ?? "",
r.overall ?? "",
r.attempts_count,
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(","),
);
const blob = new Blob([[header.join(","), ...lines].join("\n")], {
type: "text/csv",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `student-performance-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Student Performance
</h1>
<p className="text-muted-foreground">
Track student scores across all IELTS modules.
</p>
</div>
<Button variant="outline" size="sm" onClick={exportCsv} disabled={!rows.length}>
<Download className="h-4 w-4 mr-1" /> Export CSV
</Button>
</div>
{summary && (
<div className="grid gap-3 md:grid-cols-3">
<Card className="border-0 shadow-sm">
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Students tracked</p>
<p className="text-2xl font-bold">{summary.total}</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Average overall band</p>
<p className="text-2xl font-bold">{summary.avg}</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Top performer level</p>
<p className="text-2xl font-bold">{summary.topLevel}</p>
</CardContent>
</Card>
</div>
)}
<AiStudyCoach />
<div className="flex flex-wrap gap-3 items-center">
<Input
placeholder="Search student..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-[220px]"
/>
<Select value={entityId} onValueChange={setEntityId}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All Entities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Entities</SelectItem>
{filters?.entities.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>
{e.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={level}
onValueChange={(v) => setLevel(v as (typeof LEVELS)[number])}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Level" />
</SelectTrigger>
<SelectContent>
{LEVELS.map((l) => (
<SelectItem key={l} value={l}>
{l === "all" ? "All Levels" : l}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
{isLoading ? (
<div className="flex items-center justify-center p-12 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading
performance data...
</div>
) : rows.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
No completed attempts match these filters yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Entity</TableHead>
<TableHead>Level</TableHead>
<TableHead className="text-center">Reading</TableHead>
<TableHead className="text-center">Listening</TableHead>
<TableHead className="text-center">Writing</TableHead>
<TableHead className="text-center">Speaking</TableHead>
<TableHead className="text-center">Overall</TableHead>
<TableHead className="text-center">Attempts</TableHead>
<TableHead className="w-10">AI</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.student_id}>
<TableCell className="font-medium">
{r.student_name}
</TableCell>
<TableCell>{r.entity_name || "—"}</TableCell>
<TableCell>
{r.level ? (
<Badge variant="outline">{r.level}</Badge>
) : (
"—"
)}
</TableCell>
<TableCell className="text-center">
<ScoreBadge score={r.reading} />
</TableCell>
<TableCell className="text-center">
<ScoreBadge score={r.listening} />
</TableCell>
<TableCell className="text-center">
<ScoreBadge score={r.writing} />
</TableCell>
<TableCell className="text-center">
<ScoreBadge score={r.speaking} />
</TableCell>
<TableCell className="text-center">
<ScoreBadge score={r.overall} />
</TableCell>
<TableCell className="text-center text-sm text-muted-foreground">
{r.attempts_count}
</TableCell>
<TableCell>
<AiGradeExplainer studentName={r.student_name} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}