Files
NextEdu/src/app/(dashboard)/management/grade/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

226 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 type { Metadata } from "next"
import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
import { getGradesForStaff } from "@/modules/school/data-access"
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { BarChart3 } from "lucide-react"
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 TeacherGradeInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("school")
const params = await searchParams
const gradeId = getParam(params, "gradeId")
const teacherId = await getTeacherIdForMutations()
const grades = await getGradesForStaff(teacherId)
const allowedIds = new Set(grades.map((g) => g.id))
const selected = gradeId && gradeId !== "all" && allowedIds.has(gradeId) ? gradeId : ""
const insights = selected ? await getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : null
const buildHref = (gId: string): string => {
const p = new URLSearchParams()
if (gId && gId !== "all") p.set("gradeId", gId)
const qs = p.toString()
return qs ? `/management/grade/insights?${qs}` : "/management/grade/insights"
}
if (grades.length === 0) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<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>
<EmptyState
icon={BarChart3}
title={t("grades.gradeInsights.selectToView")}
description={t("grades.gradeInsights.selectToViewDescription")}
className="h-[360px] bg-card"
/>
</div>
)
}
return (
<div className="flex h-full flex-col space-y-8 p-8">
<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>
{/* 年级筛选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-[360px] bg-card"
/>
) : !insights ? (
<EmptyState
icon={BarChart3}
title={t("grades.gradeInsights.notFound")}
description={t("grades.gradeInsights.notFoundDescription")}
className="h-[360px] bg-card"
/>
) : insights.assignments.length === 0 ? (
<EmptyState
icon={BarChart3}
title={t("grades.gradeInsights.noData")}
description={t("grades.gradeInsights.noDataDescription")}
className="h-[360px] 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 ? 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-11: 移动端表格水平滚动 */}
<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-11: 移动端表格水平滚动 */}
<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>
)
}