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,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>
|
||||
|
||||
Reference in New Issue
Block a user