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:
Yamen Ahmad
2026-04-19 11:28:26 +04:00
parent 95938c0079
commit 000969b0b9
4 changed files with 801 additions and 185 deletions

View File

@@ -1,66 +1,212 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Loader2, Download } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import AiReportNarrative from "@/components/ai/AiReportNarrative";
import { reportsService, RecordRow } from "@/services/reports.service";
const records = [
{ id: 1, assignment: "IELTS Prep Q1", exam: "EX-001", date: "2025-03-01", score: 7.5, duration: "175 min", status: "Completed" },
{ id: 2, assignment: "Speaking Workshop", exam: "EX-005", date: "2025-03-05", score: 6.0, duration: "14 min", status: "Completed" },
{ id: 3, assignment: "Full Mock Exam", exam: "EX-006", date: "2025-03-10", score: null, duration: "120 min", status: "In Progress" },
{ id: 4, assignment: "Listening Bootcamp", exam: "EX-003", date: "2025-02-20", score: 5.5, duration: "28 min", status: "Completed" },
];
type Period = "all" | "day" | "week" | "month";
export default function RecordPage() {
const [entityId, setEntityId] = useState("all");
const [userId, setUserId] = useState("all");
const [period, setPeriod] = useState<Period>("all");
const { data: filters } = useQuery({
queryKey: ["reports-filters"],
queryFn: () => reportsService.filters(),
});
const { data, isLoading } = useQuery({
queryKey: ["record", entityId, userId, period],
queryFn: () =>
reportsService.record({
entity_id: entityId === "all" ? undefined : Number(entityId),
user_id: userId === "all" ? undefined : Number(userId),
period: period === "all" ? undefined : period,
size: 100,
}),
});
const records: RecordRow[] = data?.items ?? [];
const exportCsv = () => {
const header = [
"Student",
"Assignment",
"Exam",
"Date",
"Score",
"Duration",
"Status",
];
const lines = records.map((r) =>
[
r.student_name,
r.assignment,
r.exam_code || r.exam,
r.date ?? "",
r.score ?? "",
r.duration,
r.status_label,
]
.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 = `records-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const statusVariant = (status: string) => {
if (status === "completed" || status === "released" || status === "scored")
return "default";
if (status === "in_progress" || status === "scoring") return "secondary";
return "outline";
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Record</h1>
<p className="text-muted-foreground">Browse assignment and exam attempt history.</p>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Record</h1>
<p className="text-muted-foreground">
Browse assignment and exam attempt history.
{data && (
<span className="ml-2 text-xs">({data.total} attempts)</span>
)}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={exportCsv}
disabled={!records.length}
>
<Download className="h-4 w-4 mr-1" /> Export CSV
</Button>
</div>
<AiTipBanner context="record" variant="insight" />
<AiReportNarrative report_type="record" data={{ records }} />
{records.length > 0 && (
<AiReportNarrative report_type="record" data={{ records }} />
)}
<div className="flex flex-wrap gap-3 items-center">
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="Entity" /></SelectTrigger>
<SelectContent><SelectItem value="acme">Acme Corp</SelectItem><SelectItem value="global">Global Ltd</SelectItem></SelectContent>
<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><SelectTrigger className="w-[180px]"><SelectValue placeholder="Select User" /></SelectTrigger>
<SelectContent><SelectItem value="sarah">Sarah Johnson</SelectItem><SelectItem value="ahmed">Ahmed Hassan</SelectItem></SelectContent>
<Select value={userId} onValueChange={setUserId}>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder="All Users" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Users</SelectItem>
{filters?.students.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-1">
{["Day", "Week", "Month"].map(t => (
<Button key={t} variant="outline" size="sm">{t}</Button>
{(["all", "day", "week", "month"] as Period[]).map((p) => (
<Button
key={p}
variant={period === p ? "default" : "outline"}
size="sm"
onClick={() => setPeriod(p)}
>
{p === "all" ? "All" : p.charAt(0).toUpperCase() + p.slice(1)}
</Button>
))}
</div>
</div>
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Assignment</TableHead><TableHead>Exam</TableHead><TableHead>Date</TableHead>
<TableHead>Score</TableHead><TableHead>Duration</TableHead><TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.assignment}</TableCell>
<TableCell className="font-mono text-xs">{r.exam}</TableCell>
<TableCell>{r.date}</TableCell>
<TableCell>{r.score ? r.score.toFixed(1) : "—"}</TableCell>
<TableCell>{r.duration}</TableCell>
<TableCell><Badge variant={r.status === "Completed" ? "default" : "secondary"}>{r.status}</Badge></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
attempts...
</div>
) : records.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
No attempts match these filters.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Assignment</TableHead>
<TableHead>Exam</TableHead>
<TableHead>Date</TableHead>
<TableHead>Score</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Status</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">
{r.student_name || "—"}
</TableCell>
<TableCell>{r.assignment || "—"}</TableCell>
<TableCell className="font-mono text-xs">
{r.exam_code}
</TableCell>
<TableCell>{r.date || "—"}</TableCell>
<TableCell>
{r.score ? r.score.toFixed(1) : "—"}
</TableCell>
<TableCell>{r.duration}</TableCell>
<TableCell>
<Badge variant={statusVariant(r.status)}>
{r.status_label}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>

View File

@@ -1,126 +1,266 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useQuery } from "@tanstack/react-query";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, PieChart, Pie, Cell } from "recharts";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
LineChart,
Line,
PieChart,
Pie,
Cell,
} from "recharts";
import { Loader2 } from "lucide-react";
import AiReportNarrative from "@/components/ai/AiReportNarrative";
import { reportsService } from "@/services/reports.service";
const thresholds = ["0%", "50%", "70%", "90%"];
const barData = [
{ module: "Reading", score: 72 },
{ module: "Listening", score: 68 },
{ module: "Writing", score: 61 },
{ module: "Speaking", score: 65 },
];
const trendData = [
{ month: "Jan", avg: 58 }, { month: "Feb", avg: 62 }, { month: "Mar", avg: 65 },
{ month: "Apr", avg: 64 }, { month: "May", avg: 69 }, { month: "Jun", avg: 72 },
];
const distData = [
{ name: "A1", value: 15, color: "hsl(0, 72%, 51%)" },
{ name: "A2", value: 22, color: "hsl(38, 92%, 50%)" },
{ name: "B1", value: 30, color: "hsl(199, 89%, 48%)" },
{ name: "B2", value: 20, color: "hsl(243, 75%, 59%)" },
{ name: "C1", value: 10, color: "hsl(142, 71%, 45%)" },
{ name: "C2", value: 3, color: "hsl(280, 65%, 50%)" },
];
export default function StatsCorporatePage() {
const [threshold, setThreshold] = useState("0%");
const [entityId, setEntityId] = useState("all");
const { data: filters } = useQuery({
queryKey: ["reports-filters"],
queryFn: () => reportsService.filters(),
});
const { data, isLoading } = useQuery({
queryKey: ["stats-corporate", threshold, entityId],
queryFn: () =>
reportsService.statsCorporate({
entity_id: entityId === "all" ? undefined : Number(entityId),
threshold: Number(threshold.replace("%", "")),
months: 6,
}),
});
const barData = data?.by_module ?? [];
const trendData = data?.trend ?? [];
const distData = data?.distribution ?? [];
const comparison = data?.comparison ?? [];
const attemptsConsidered = data?.meta.attempts_considered ?? 0;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Corporate Statistics</h1>
<p className="text-muted-foreground">Entity-level performance analytics and reports.</p>
<h1 className="text-2xl font-bold tracking-tight">
Corporate Statistics
</h1>
<p className="text-muted-foreground">
Entity-level performance analytics and reports.
{data && (
<span className="ml-2 text-xs">
({attemptsConsidered} scored attempts)
</span>
)}
</p>
</div>
<div className="flex flex-wrap gap-3 items-center">
<div className="flex gap-1">
{thresholds.map(t => (
<Button key={t} variant={threshold === t ? "default" : "outline"} size="sm" onClick={() => setThreshold(t)}>{t}</Button>
{thresholds.map((t) => (
<Button
key={t}
variant={threshold === t ? "default" : "outline"}
size="sm"
onClick={() => setThreshold(t)}
>
{t}
</Button>
))}
</div>
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="All Entities" /></SelectTrigger>
<SelectContent><SelectItem value="all">All</SelectItem><SelectItem value="acme">Acme Corp</SelectItem></SelectContent>
</Select>
<Select><SelectTrigger className="w-[180px]"><SelectValue placeholder="All Assignments" /></SelectTrigger>
<SelectContent><SelectItem value="all">All Assignments</SelectItem></SelectContent>
<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>
</div>
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="trends">Trends</TabsTrigger>
<TabsTrigger value="distribution">Distribution</TabsTrigger>
<TabsTrigger value="comparison">Comparison</TabsTrigger>
</TabsList>
{isLoading ? (
<div className="flex items-center justify-center p-12 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Aggregating
attempts...
</div>
) : attemptsConsidered === 0 ? (
<Card className="border-0 shadow-sm">
<CardContent className="p-8 text-center text-muted-foreground">
No scored attempts match this threshold / entity combination yet.
</CardContent>
</Card>
) : (
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="trends">Trends</TabsTrigger>
<TabsTrigger value="distribution">Distribution</TabsTrigger>
<TabsTrigger value="comparison">Comparison</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4">
<AiReportNarrative report_type="corporate-overview" data={{ modules: barData }} />
<Card className="border-0 shadow-sm">
<CardHeader><CardTitle className="text-base">Average Score by Module</CardTitle></CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={barData}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 16%, 90%)" />
<XAxis dataKey="module" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Bar dataKey="score" fill="hsl(243, 75%, 59%)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="overview" className="mt-4">
<AiReportNarrative
report_type="corporate-overview"
data={{ modules: barData }}
/>
<Card className="border-0 shadow-sm">
<CardHeader>
<CardTitle className="text-base">
Average Score by Module
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={barData}>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(220, 16%, 90%)"
/>
<XAxis dataKey="module" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Bar
dataKey="score"
fill="hsl(243, 75%, 59%)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="trends" className="mt-4">
<AiReportNarrative report_type="corporate-trends" data={{ trends: trendData }} />
<Card className="border-0 shadow-sm">
<CardHeader><CardTitle className="text-base">Score Trend Over Time</CardTitle></CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 16%, 90%)" />
<XAxis dataKey="month" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Line type="monotone" dataKey="avg" stroke="hsl(243, 75%, 59%)" strokeWidth={2} dot={{ r: 4 }} />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="trends" className="mt-4">
<AiReportNarrative
report_type="corporate-trends"
data={{ trends: trendData }}
/>
<Card className="border-0 shadow-sm">
<CardHeader>
<CardTitle className="text-base">
Score Trend Over Time
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={trendData}>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(220, 16%, 90%)"
/>
<XAxis dataKey="month" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Line
type="monotone"
dataKey="avg"
stroke="hsl(243, 75%, 59%)"
strokeWidth={2}
dot={{ r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="distribution" className="mt-4">
<AiReportNarrative report_type="corporate-distribution" data={{ distribution: distData }} />
<Card className="border-0 shadow-sm">
<CardHeader><CardTitle className="text-base">Level Distribution</CardTitle></CardHeader>
<CardContent className="flex justify-center">
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie data={distData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={100} label>
{distData.map((entry, i) => <Cell key={i} fill={entry.color} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="distribution" className="mt-4">
<AiReportNarrative
report_type="corporate-distribution"
data={{ distribution: distData }}
/>
<Card className="border-0 shadow-sm">
<CardHeader>
<CardTitle className="text-base">Level Distribution</CardTitle>
</CardHeader>
<CardContent className="flex justify-center">
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={distData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={100}
label
>
{distData.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="comparison" className="mt-4">
<AiReportNarrative report_type="corporate-comparison" data={{ threshold }} />
<Card className="border-0 shadow-sm">
<CardContent className="p-8 text-center text-muted-foreground">Entity comparison charts will appear here based on selected filters.</CardContent>
</Card>
</TabsContent>
</Tabs>
<TabsContent value="comparison" className="mt-4">
<AiReportNarrative
report_type="corporate-comparison"
data={{ threshold, entities: comparison }}
/>
<Card className="border-0 shadow-sm">
<CardHeader>
<CardTitle className="text-base">
Entity Comparison (average band × 10)
</CardTitle>
</CardHeader>
<CardContent>
{comparison.length === 0 ? (
<p className="text-center text-muted-foreground p-8">
No entities have attempts above this threshold.
</p>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={comparison}>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(220, 16%, 90%)"
/>
<XAxis dataKey="entity_name" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Bar
dataKey="avg"
fill="hsl(142, 71%, 45%)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
)}
</div>
);
}

View File

@@ -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>

View File

@@ -0,0 +1,137 @@
import { api } from "@/lib/api-client";
export interface StudentPerformanceRow {
student_id: number;
student_name: string;
login: string;
entity_id: number | null;
entity_name: string;
reading: number | null;
listening: number | null;
writing: number | null;
speaking: number | null;
overall: number | null;
level: string | null;
attempts_count: number;
last_attempt_at: string | null;
}
export interface StudentPerformanceResponse {
items: StudentPerformanceRow[];
total: number;
}
export interface StatsModuleRow {
module: string;
score: number;
n: number;
}
export interface StatsTrendRow {
month: string;
avg: number;
period: string;
n: number;
}
export interface StatsDistributionRow {
name: string;
value: number;
color: string;
}
export interface StatsComparisonRow {
entity_id: number | null;
entity_name: string;
avg: number;
attempts: number;
}
export interface StatsCorporateResponse {
by_module: StatsModuleRow[];
trend: StatsTrendRow[];
distribution: StatsDistributionRow[];
comparison: StatsComparisonRow[];
meta: {
attempts_considered: number;
threshold: number;
months: number;
};
}
export interface RecordRow {
id: number;
student_id: number | null;
student_name: string;
assignment: string;
assignment_id: number | null;
exam: string;
exam_code: string;
exam_id: number | null;
entity_id: number | null;
entity_name: string;
date: string | null;
started_at: string | null;
completed_at: string | null;
score: number | null;
duration_min: number | null;
duration: string;
status: string;
status_label: string;
cefr_level: string | null;
}
export interface RecordResponse {
items: RecordRow[];
total: number;
page: number;
size: number;
}
export interface ReportsFiltersResponse {
entities: { id: number; name: string }[];
students: { id: number; name: string; login: string }[];
}
type QueryParams = Record<string, string | number | boolean | undefined>;
export const reportsService = {
async studentPerformance(params?: {
entity_id?: number;
level?: string;
search?: string;
since?: string;
}): Promise<StudentPerformanceResponse> {
return api.get<StudentPerformanceResponse>(
"/reports/student-performance",
params as QueryParams,
);
},
async statsCorporate(params?: {
entity_id?: number;
threshold?: number;
months?: number;
since?: string;
}): Promise<StatsCorporateResponse> {
return api.get<StatsCorporateResponse>(
"/reports/stats-corporate",
params as QueryParams,
);
},
async record(params?: {
user_id?: number;
entity_id?: number;
period?: "day" | "week" | "month";
status?: string;
page?: number;
size?: number;
}): Promise<RecordResponse> {
return api.get<RecordResponse>("/reports/record", params as QueryParams);
},
async filters(): Promise<ReportsFiltersResponse> {
return api.get<ReportsFiltersResponse>("/reports/filters");
},
};