Files
NextEdu/src/app/(dashboard)/admin/school/grades/insights/page.tsx
SpecialX 37d2688a28 feat(app): add lesson-plans, practice, and grade dashboard routes
- Add admin/lesson-plans, parent/lesson-plans, student/lesson-plans routes

- Add student/practice and teacher/practice routes for adaptive practice

- Add management/grade/dashboard and management/grade/practice routes

- Add teacher/lesson-plans error and loading boundaries

- Update existing admin, parent, student, teacher pages with new features

- Update globals.css and proxy middleware
2026-06-24 12:03:47 +08:00

230 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Link from "next/link"
import type { Metadata } from "next"
import type { JSX } from "react"
import { BarChart3 } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getGrades } from "@/modules/school/data-access"
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
import { getSchoolWideGradeSummary } from "@/modules/grades/data-access-analytics"
import { SchoolWideSummaryCard } from "@/modules/grades/components/school-wide-summary-card"
import { GradeInsightsFilters } from "@/modules/school/components/grade-insights-filters"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { StatCard } from "@/shared/components/ui/stat-card"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate, formatNumber } from "@/shared/lib/utils"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
export const dynamic = "force-dynamic"
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("school")
return {
title: `${t("grades.gradeInsights.title")} - Next_Edu`,
description: t("grades.gradeInsights.description"),
}
}
export default async function AdminGradeInsightsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
const ctx = await requirePermission(Permissions.SCHOOL_MANAGE)
const t = await getTranslations("school")
const params = await searchParams
const gradeId = getParam(params, "gradeId")
const selected = gradeId && gradeId !== "all" ? gradeId : ""
// grades、insights、全校汇总无数据依赖并行查询
const [grades, insights, schoolWideSummary] = await Promise.all([
getGrades(),
selected ? getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : Promise.resolve(null),
// v3-P2-9管理员全校成绩汇总视图
getSchoolWideGradeSummary(ctx.dataScope),
])
const buildHref = (gId: string): string => {
const p = new URLSearchParams()
if (gId && gId !== "all") p.set("gradeId", gId)
const qs = p.toString()
return qs ? `/admin/school/grades/insights?${qs}` : "/admin/school/grades/insights"
}
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">{t("grades.gradeInsights.title")}</h2>
<p className="text-muted-foreground">{t("grades.gradeInsights.description")}</p>
</div>
<Button asChild variant="outline">
<Link href="/admin/school/grades">{t("grades.gradeInsights.manageGrades")}</Link>
</Button>
</div>
{/* v3-P2-9: 全校成绩汇总视图 */}
{schoolWideSummary.grades.length > 0 && (
<SchoolWideSummaryCard summary={schoolWideSummary} />
)}
{/* 年级筛选ChipNav 即时切换,无整页刷新 */}
<GradeInsightsFilters
grades={grades.map((g) => ({ id: g.id, name: g.name, schoolName: g.school.name }))}
currentGradeId={selected || "all"}
buildHref={buildHref}
/>
{!selected ? (
<EmptyState
icon={BarChart3}
title={t("grades.gradeInsights.selectToView")}
description={t("grades.gradeInsights.selectToViewDescription")}
className="h-80 bg-card"
/>
) : !insights ? (
<EmptyState
icon={BarChart3}
title={t("grades.gradeInsights.notFound")}
description={t("grades.gradeInsights.notFoundDescription")}
className="h-80 bg-card"
/>
) : insights.assignments.length === 0 ? (
<EmptyState
icon={BarChart3}
title={t("grades.gradeInsights.noData")}
description={t("grades.gradeInsights.noDataDescription")}
className="h-80 bg-card"
/>
) : (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<StatCard
title={t("grades.gradeInsights.classes")}
value={insights.classCount}
description={`${insights.grade.school.name} / ${insights.grade.name}`}
valueClassName="tabular-nums"
/>
<StatCard
title={t("grades.gradeInsights.students")}
value={insights.studentCounts.total}
description={`${t("grades.gradeInsights.active")} ${insights.studentCounts.active}${t("grades.gradeInsights.inactive")} ${insights.studentCounts.inactive}`}
valueClassName="tabular-nums"
/>
<StatCard
title={t("grades.gradeInsights.overallAvg")}
value={formatNumber(insights.overallScores.avg)}
description="-"
valueClassName="tabular-nums"
/>
<StatCard
title={t("grades.gradeInsights.latestAvg")}
value={formatNumber(insights.latest?.scoreStats.avg ?? null)}
description={insights.latest?.title ?? "-"}
valueClassName="tabular-nums"
/>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("grades.gradeInsights.homeworkTimeline")}</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{insights.assignments.length}
</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
{/* v4-P1-10: 移动端表格水平滚动 */}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>{t("grades.gradeInsights.assignment")}</TableHead>
<TableHead>{t("grades.gradeInsights.status")}</TableHead>
<TableHead>{t("grades.gradeInsights.created")}</TableHead>
<TableHead className="text-right">{t("grades.gradeInsights.targeted")}</TableHead>
<TableHead className="text-right">{t("grades.gradeInsights.submitted")}</TableHead>
<TableHead className="text-right">{t("grades.gradeInsights.graded")}</TableHead>
<TableHead className="text-right">{t("grades.gradeInsights.avg")}</TableHead>
<TableHead className="text-right">{t("grades.gradeInsights.median")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.assignments.map((a) => (
<TableRow key={a.assignmentId}>
<TableCell className="font-medium">{a.title}</TableCell>
<TableCell>
<Badge variant="secondary" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(a.createdAt)}</TableCell>
<TableCell className="text-right tabular-nums">{a.targetCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.submittedCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.gradedCount}</TableCell>
<TableCell className="text-right tabular-nums">{formatNumber(a.scoreStats.avg)}</TableCell>
<TableCell className="text-right tabular-nums">{formatNumber(a.scoreStats.median)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("grades.gradeInsights.classRanking")}</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{insights.classes.length}
</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
{/* v4-P1-10: 移动端表格水平滚动 */}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>{t("grades.gradeInsights.class")}</TableHead>
<TableHead className="text-right">{t("grades.gradeInsights.students")}</TableHead>
<TableHead className="text-right">{t("grades.gradeInsights.latestAvgCol")}</TableHead>
<TableHead className="text-right">{t("grades.gradeInsights.prevAvg")}</TableHead>
<TableHead className="text-right">{t("grades.gradeInsights.delta")}</TableHead>
<TableHead className="text-right">{t("grades.gradeInsights.overallAvgCol")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.classes.map((c) => (
<TableRow key={c.class.id}>
<TableCell className="font-medium">
{c.class.name}
{c.class.homeroom ? (
<span className="text-muted-foreground"> {c.class.homeroom}</span>
) : null}
</TableCell>
<TableCell className="text-right tabular-nums">{c.studentCounts.total}</TableCell>
<TableCell className="text-right tabular-nums">{formatNumber(c.latestAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{formatNumber(c.prevAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{formatNumber(c.deltaAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{formatNumber(c.overallScores.avg)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>
)}
</div>
)
}