From 000969b0b985b7ee3c428705e520497b727aafd9 Mon Sep 17 00:00:00 2001 From: Yamen Ahmad Date: Sun, 19 Apr 2026 11:28:26 +0400 Subject: [PATCH] feat(reports): replace mock Reports pages with real backend aggregates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/pages/RecordPage.tsx | 220 +++++++++++++++--- src/pages/StatsCorporatePage.tsx | 334 +++++++++++++++++++-------- src/pages/StudentPerformancePage.tsx | 295 +++++++++++++++++++---- src/services/reports.service.ts | 137 +++++++++++ 4 files changed, 801 insertions(+), 185 deletions(-) create mode 100644 src/services/reports.service.ts diff --git a/src/pages/RecordPage.tsx b/src/pages/RecordPage.tsx index 1217957..ba49e2e 100644 --- a/src/pages/RecordPage.tsx +++ b/src/pages/RecordPage.tsx @@ -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("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 (
-
-

Record

-

Browse assignment and exam attempt history.

+
+
+

Record

+

+ Browse assignment and exam attempt history. + {data && ( + ({data.total} attempts) + )} +

+
+
- + {records.length > 0 && ( + + )}
- + + + + + All Entities + {filters?.entities.map((e) => ( + + {e.name} + + ))} + - + + + + + All Users + {filters?.students.map((s) => ( + + {s.name} + + ))} +
- {["Day", "Week", "Month"].map(t => ( - + {(["all", "day", "week", "month"] as Period[]).map((p) => ( + ))}
- - - - AssignmentExamDate - ScoreDurationStatus - - - - {records.map((r) => ( - - {r.assignment} - {r.exam} - {r.date} - {r.score ? r.score.toFixed(1) : "—"} - {r.duration} - {r.status} + {isLoading ? ( +
+ Loading + attempts... +
+ ) : records.length === 0 ? ( +
+ No attempts match these filters. +
+ ) : ( +
+ + + Student + Assignment + Exam + Date + Score + Duration + Status - ))} - -
+ + + {records.map((r) => ( + + + {r.student_name || "—"} + + {r.assignment || "—"} + + {r.exam_code} + + {r.date || "—"} + + {r.score ? r.score.toFixed(1) : "—"} + + {r.duration} + + + {r.status_label} + + + + ))} + + + )}
diff --git a/src/pages/StatsCorporatePage.tsx b/src/pages/StatsCorporatePage.tsx index 12f72ba..7db2f73 100644 --- a/src/pages/StatsCorporatePage.tsx +++ b/src/pages/StatsCorporatePage.tsx @@ -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 (
-

Corporate Statistics

-

Entity-level performance analytics and reports.

+

+ Corporate Statistics +

+

+ Entity-level performance analytics and reports. + {data && ( + + ({attemptsConsidered} scored attempts) + + )} +

- {thresholds.map(t => ( - + {thresholds.map((t) => ( + ))}
- - + + + + + All Entities + {filters?.entities.map((e) => ( + + {e.name} + + ))} +
- - - Overview - Trends - Distribution - Comparison - + {isLoading ? ( +
+ Aggregating + attempts... +
+ ) : attemptsConsidered === 0 ? ( + + + No scored attempts match this threshold / entity combination yet. + + + ) : ( + + + Overview + Trends + Distribution + Comparison + - - - - Average Score by Module - - - - - - - - - - - - - + + + + + + Average Score by Module + + + + + + + + + + + + + + + - - - - Score Trend Over Time - - - - - - - - - - - - - + + + + + + Score Trend Over Time + + + + + + + + + + + + + + + - - - - Level Distribution - - - - - {distData.map((entry, i) => )} - - - - - - - + + + + + Level Distribution + + + + + + {distData.map((entry, i) => ( + + ))} + + + + + + + - - - - Entity comparison charts will appear here based on selected filters. - - - + + + + + + Entity Comparison (average band × 10) + + + + {comparison.length === 0 ? ( +

+ No entities have attempts above this threshold. +

+ ) : ( + + + + + + + + + + )} +
+
+
+
+ )}
); } diff --git a/src/pages/StudentPerformancePage.tsx b/src/pages/StudentPerformancePage.tsx index 8f7e844..41aabf4 100644 --- a/src/pages/StudentPerformancePage.tsx +++ b/src/pages/StudentPerformancePage.tsx @@ -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 {score.toFixed(1)}; +function ScoreBadge({ score }: { score: number | null }) { + if (score === null || score === undefined) { + return ; + } + const color = + score >= 7.5 + ? "bg-success/10 text-success" + : score >= 6.0 + ? "bg-warning/10 text-warning" + : "bg-destructive/10 text-destructive"; + return ( + + {score.toFixed(1)} + + ); } export default function StudentPerformancePage() { - const [showUtilisation, setShowUtilisation] = useState(false); + const [entityId, setEntityId] = useState("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 (
-
-

Student Performance

-

Track student scores across all IELTS modules.

+
+
+

+ Student Performance +

+

+ Track student scores across all IELTS modules. +

+
+
+ {summary && ( +
+ + +

Students tracked

+

{summary.total}

+
+
+ + +

Average overall band

+

{summary.avg}

+
+
+ + +

Top performer level

+

{summary.topLevel}

+
+
+
+ )} +
- setSearch(e.target.value)} + className="w-[220px]" + /> + + -
- - -
- - - - StudentEntityLevel - ReadingListening - WritingSpeaking - Overall - AI - - - - {students.map((s) => ( - - {s.name} - {s.entity} - {s.level} - - - - - - + {isLoading ? ( +
+ Loading + performance data... +
+ ) : rows.length === 0 ? ( +
+ No completed attempts match these filters yet. +
+ ) : ( +
+ + + Student + Entity + Level + Reading + Listening + Writing + Speaking + Overall + Attempts + AI - ))} - -
+ + + {rows.map((r) => ( + + + {r.student_name} + + {r.entity_name || "—"} + + {r.level ? ( + {r.level} + ) : ( + "—" + )} + + + + + + + + + + + + + + + + + + {r.attempts_count} + + + + + + ))} + + + )}
diff --git a/src/services/reports.service.ts b/src/services/reports.service.ts new file mode 100644 index 0000000..d6dbe6c --- /dev/null +++ b/src/services/reports.service.ts @@ -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; + +export const reportsService = { + async studentPerformance(params?: { + entity_id?: number; + level?: string; + search?: string; + since?: string; + }): Promise { + return api.get( + "/reports/student-performance", + params as QueryParams, + ); + }, + + async statsCorporate(params?: { + entity_id?: number; + threshold?: number; + months?: number; + since?: string; + }): Promise { + return api.get( + "/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 { + return api.get("/reports/record", params as QueryParams); + }, + + async filters(): Promise { + return api.get("/reports/filters"); + }, +};