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
This commit is contained in:
@@ -1,77 +1,270 @@
|
||||
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 { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useState } from "react";
|
||||
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 students = [
|
||||
{ name: "Sarah Johnson", entity: "Acme Corp", reading: 7.5, listening: 8.0, writing: 7.0, speaking: 7.5, overall: 7.5, level: "B2" },
|
||||
{ name: "Ahmed Hassan", entity: "Global Ltd", reading: 5.5, listening: 6.0, writing: 5.0, speaking: 5.5, overall: 5.5, level: "A2" },
|
||||
{ name: "Maria Garcia", entity: "Acme Corp", reading: 8.5, listening: 8.0, writing: 8.0, speaking: 8.5, overall: 8.25, level: "C1" },
|
||||
{ name: "Li Wei", entity: "Tech Co", reading: 6.5, listening: 6.0, writing: 6.0, speaking: 6.5, overall: 6.25, level: "B1" },
|
||||
{ name: "Emma Brown", entity: "Acme Corp", reading: 4.5, listening: 5.0, writing: 4.0, speaking: 4.5, overall: 4.5, level: "A1" },
|
||||
{ name: "John Park", entity: "Global Ltd", reading: 7.0, listening: 7.5, writing: 6.5, speaking: 7.0, overall: 7.0, level: "B2" },
|
||||
];
|
||||
const LEVELS = ["all", "A1", "A2", "B1", "B2", "C1", "C2"] as const;
|
||||
|
||||
function ScoreBadge({ score }: { score: number }) {
|
||||
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>;
|
||||
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 [showUtilisation, setShowUtilisation] = useState(false);
|
||||
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>
|
||||
<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 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">
|
||||
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="All Entities" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="all">All Entities</SelectItem><SelectItem value="acme">Acme Corp</SelectItem><SelectItem value="global">Global Ltd</SelectItem></SelectContent>
|
||||
<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 className="flex items-center gap-2 ml-auto">
|
||||
<Switch id="util" checked={showUtilisation} onCheckedChange={setShowUtilisation} />
|
||||
<Label htmlFor="util" className="text-sm">Show Utilisation</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<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="w-10">AI</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{students.map((s) => (
|
||||
<TableRow key={s.name}>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
<TableCell>{s.entity}</TableCell>
|
||||
<TableCell><Badge variant="outline">{s.level}</Badge></TableCell>
|
||||
<TableCell className="text-center"><ScoreBadge score={s.reading} /></TableCell>
|
||||
<TableCell className="text-center"><ScoreBadge score={s.listening} /></TableCell>
|
||||
<TableCell className="text-center"><ScoreBadge score={s.writing} /></TableCell>
|
||||
<TableCell className="text-center"><ScoreBadge score={s.speaking} /></TableCell>
|
||||
<TableCell className="text-center"><ScoreBadge score={s.overall} /></TableCell>
|
||||
<TableCell><AiGradeExplainer studentName={s.name} /></TableCell>
|
||||
{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>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user