feat(school): add grade dashboard and insights filters
- Add grade-dashboard components directory for school-wide grade analytics - Add grade-insights-filters component for filtering grade insights - Update grades-view and data-access
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import type { JSX } from "react"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
|
||||
import type { GradeDistributionByGradeResult } from "@/modules/grades/types"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
import { formatNumber } from "@/shared/lib/utils"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
interface Props {
|
||||
data: GradeDistributionByGradeResult
|
||||
}
|
||||
|
||||
export function GradeDistributionPanel({ data }: Props): JSX.Element {
|
||||
const t = useTranslations("school")
|
||||
|
||||
const stats = data.stats
|
||||
const buckets = data.overall.buckets
|
||||
|
||||
const maxCount = Math.max(...buckets.map((b) => b.count), 1)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
title={t("grades.gradeDashboard.distribution.totalRecords")}
|
||||
value={data.overall.totalCount}
|
||||
icon={BarChart3}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("grades.gradeDashboard.distribution.averageScore")}
|
||||
value={stats ? formatNumber(stats.average) : "-"}
|
||||
icon={BarChart3}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("grades.gradeDashboard.distribution.passRate")}
|
||||
value={stats ? `${formatNumber(stats.passRate)}%` : "-"}
|
||||
icon={BarChart3}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("grades.gradeDashboard.distribution.excellentRate")}
|
||||
value={stats ? `${formatNumber(stats.excellentRate)}%` : "-"}
|
||||
icon={BarChart3}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("grades.gradeDashboard.distribution.scoreBuckets")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{buckets.map((b) => (
|
||||
<div key={b.label} className="flex items-center gap-3">
|
||||
<div className="w-16 text-sm text-muted-foreground">{b.label}</div>
|
||||
<div className="flex-1">
|
||||
<div className="h-7 rounded-md bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${(b.count / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-12 text-right text-sm font-medium tabular-nums">{b.count}</div>
|
||||
</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.gradeDashboard.distribution.byClass")}</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">{data.byClass.length}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>{t("grades.gradeDashboard.distribution.class")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeDashboard.distribution.students")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeDashboard.distribution.averageScore")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeDashboard.distribution.passRate")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeDashboard.distribution.excellentRate")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeDashboard.distribution.count")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.byClass.map((c) => (
|
||||
<TableRow key={c.classId}>
|
||||
<TableCell className="font-medium">{c.className}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{c.stats?.count ?? 0}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.stats?.average ?? null)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{c.stats ? `${formatNumber(c.stats.passRate)}%` : "-"}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{c.stats ? `${formatNumber(c.stats.excellentRate)}%` : "-"}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{c.distribution.totalCount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { JSX } from "react"
|
||||
import { FileText, CheckCircle2, Clock } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import type { GradeExamsResult } from "@/modules/exams/types"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
import { formatDate, formatNumber } from "@/shared/lib/utils"
|
||||
|
||||
interface Props {
|
||||
data: GradeExamsResult
|
||||
}
|
||||
|
||||
const statusVariant = (status: string): "default" | "secondary" | "outline" => {
|
||||
if (status === "published") return "default"
|
||||
if (status === "draft") return "secondary"
|
||||
return "outline"
|
||||
}
|
||||
|
||||
export function GradeExamsPanel({ data }: Props): JSX.Element {
|
||||
const t = useTranslations("school")
|
||||
|
||||
const gradedRate = data.totals.totalSubmissions > 0
|
||||
? Math.round((data.totals.totalGraded / data.totals.totalSubmissions) * 1000) / 10
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
title={t("grades.gradeDashboard.exams.totalExams")}
|
||||
value={data.totals.examCount}
|
||||
icon={FileText}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("grades.gradeDashboard.exams.published")}
|
||||
value={data.totals.publishedCount}
|
||||
icon={CheckCircle2}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("grades.gradeDashboard.exams.totalSubmissions")}
|
||||
value={data.totals.totalSubmissions}
|
||||
icon={Clock}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("grades.gradeDashboard.exams.gradedRate")}
|
||||
value={`${formatNumber(gradedRate)}%`}
|
||||
icon={CheckCircle2}
|
||||
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.gradeDashboard.exams.examList")}</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">{data.exams.length}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.exams.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("grades.gradeDashboard.noData")}</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>{t("grades.gradeInsights.assignment")}</TableHead>
|
||||
<TableHead>{t("grades.gradeDashboard.exams.subject")}</TableHead>
|
||||
<TableHead>{t("grades.gradeDashboard.exams.status")}</TableHead>
|
||||
<TableHead>{t("grades.gradeDashboard.exams.scheduledAt")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeDashboard.exams.submissions")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeDashboard.exams.graded")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeDashboard.exams.avgScore")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.exams.map((e) => (
|
||||
<TableRow key={e.id}>
|
||||
<TableCell className="font-medium">{e.title}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{e.subjectName ?? "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(e.status)} className="capitalize">{e.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{e.scheduledAt ? formatDate(e.scheduledAt) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{e.submissionCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{e.gradedCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(e.averageScore)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { JSX } from "react"
|
||||
import { ClipboardCheck, CheckCircle2, Clock } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import type { GradeHomeworkInsights } from "@/modules/classes/types"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
import { formatNumber } from "@/shared/lib/utils"
|
||||
|
||||
interface Props {
|
||||
data: GradeHomeworkInsights
|
||||
}
|
||||
|
||||
export function GradeHomeworkPanel({ data }: Props): JSX.Element {
|
||||
const t = useTranslations("school")
|
||||
|
||||
const totalTargeted = data.assignments.reduce((sum, a) => sum + a.targetCount, 0)
|
||||
const totalSubmitted = data.assignments.reduce((sum, a) => sum + a.submittedCount, 0)
|
||||
const totalGraded = data.assignments.reduce((sum, a) => sum + a.gradedCount, 0)
|
||||
const avgSubmissionRate = totalTargeted > 0
|
||||
? Math.round((totalSubmitted / totalTargeted) * 1000) / 10
|
||||
: 0
|
||||
const gradedRate = totalSubmitted > 0
|
||||
? Math.round((totalGraded / totalSubmitted) * 1000) / 10
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
title={t("grades.gradeDashboard.homework.totalAssignments")}
|
||||
value={data.assignments.length}
|
||||
icon={ClipboardCheck}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("grades.gradeDashboard.homework.totalSubmissions")}
|
||||
value={totalSubmitted}
|
||||
icon={Clock}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("grades.gradeDashboard.homework.avgSubmissionRate")}
|
||||
value={`${formatNumber(avgSubmissionRate)}%`}
|
||||
icon={ClipboardCheck}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("grades.gradeDashboard.homework.gradedRate")}
|
||||
value={`${formatNumber(gradedRate)}%`}
|
||||
icon={CheckCircle2}
|
||||
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.gradeDashboard.homework.assignmentList")}</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">{data.assignments.length}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<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 className="text-right">{t("grades.gradeDashboard.homework.targeted")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeDashboard.homework.submitted")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeDashboard.homework.graded")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeDashboard.homework.completionRate")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.avg")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.assignments.map((a) => {
|
||||
const rate = a.targetCount > 0
|
||||
? Math.round((a.submittedCount / a.targetCount) * 1000) / 10
|
||||
: 0
|
||||
return (
|
||||
<TableRow key={a.assignmentId}>
|
||||
<TableCell className="font-medium">{a.title}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="capitalize">{a.status}</Badge>
|
||||
</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(rate)}%</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(a.scoreStats.avg)}</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.gradeDashboard.homework.classComparison")}</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">{data.classes.length}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<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.delta")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.overallAvgCol")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.classes.map((c) => (
|
||||
<TableRow key={c.class.id}>
|
||||
<TableCell className="font-medium">{c.class.name}</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.deltaAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.overallScores.avg)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { JSX } from "react"
|
||||
import { BookOpen, Clock, CheckCircle2, TrendingUp } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import type { GradeCoursePlanProgressResult } from "@/modules/course-plans/types"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
import { formatNumber } from "@/shared/lib/utils"
|
||||
|
||||
interface Props {
|
||||
data: GradeCoursePlanProgressResult
|
||||
}
|
||||
|
||||
const statusVariant = (status: string): "default" | "secondary" | "outline" => {
|
||||
if (status === "active") return "default"
|
||||
if (status === "completed") return "secondary"
|
||||
return "outline"
|
||||
}
|
||||
|
||||
export function GradeProgressPanel({ data }: Props): JSX.Element {
|
||||
const t = useTranslations("school")
|
||||
const { overall, items } = data
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
title={t("grades.gradeDashboard.progress.totalPlans")}
|
||||
value={overall.totalPlans}
|
||||
icon={BookOpen}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("grades.gradeDashboard.progress.completedHours")}
|
||||
value={`${overall.completedHours}/${overall.totalHours}`}
|
||||
icon={Clock}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("grades.gradeDashboard.progress.activePlans")}
|
||||
value={overall.activePlans}
|
||||
icon={TrendingUp}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("grades.gradeDashboard.progress.completedPlans")}
|
||||
value={overall.completedPlans}
|
||||
icon={CheckCircle2}
|
||||
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.gradeDashboard.progress.overallProgress")}</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">{formatNumber(overall.progressRate)}%</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-4 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${overall.progressRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{overall.completedHours} / {overall.totalHours} {t("grades.gradeDashboard.progress.completedHoursCol")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">{t("grades.gradeDashboard.progress.progressMatrix")}</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">{items.length}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{items.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("grades.gradeDashboard.noData")}</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>{t("grades.gradeDashboard.progress.class")}</TableHead>
|
||||
<TableHead>{t("grades.gradeDashboard.progress.subject")}</TableHead>
|
||||
<TableHead>{t("grades.gradeDashboard.progress.teacher")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeDashboard.progress.completedHoursCol")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeDashboard.progress.items")}</TableHead>
|
||||
<TableHead>{t("grades.gradeDashboard.progress.progress")}</TableHead>
|
||||
<TableHead>{t("grades.gradeDashboard.progress.status")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((it) => (
|
||||
<TableRow key={it.planId}>
|
||||
<TableCell className="font-medium">{it.className ?? "-"}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{it.subjectName ?? "-"}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{it.teacherName ?? "-"}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{it.completedHours}/{it.totalHours}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{it.completedItemCount}/{it.itemCount}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-20 h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${it.progressRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{formatNumber(it.progressRate)}%
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(it.status)} className="capitalize">{it.status}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
src/modules/school/components/grade-insights-filters.tsx
Normal file
48
src/modules/school/components/grade-insights-filters.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { ChipNav } from "@/shared/components/ui/chip-nav"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
|
||||
interface GradeInsightsFiltersProps {
|
||||
grades: Array<{ id: string; name: string; schoolName: string }>
|
||||
currentGradeId: string
|
||||
buildHref: (gradeId: string) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* 年级洞察筛选器:使用 ChipNav 替代原生 form get,
|
||||
* 点击 chip 即时通过 URL 参数切换,无整页刷新。
|
||||
*/
|
||||
export async function GradeInsightsFilters({
|
||||
grades,
|
||||
currentGradeId,
|
||||
buildHref,
|
||||
}: GradeInsightsFiltersProps): Promise<JSX.Element> {
|
||||
const t = await getTranslations("school")
|
||||
|
||||
const options = grades.map((g) => ({
|
||||
id: g.id,
|
||||
name: `${g.schoolName} / ${g.name}`,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">{t("grades.gradeInsights.title")}</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{grades.length}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChipNav
|
||||
options={options}
|
||||
currentId={currentGradeId}
|
||||
buildHref={buildHref}
|
||||
allOption={{ id: "all", label: t("grades.gradeInsights.selectGrade") }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||
import { BarChart3, MoreHorizontal, Pencil, Plus, Trash2, Users, GraduationCap, UserCog } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { parseAsString, useQueryState } from "nuqs"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import type { GradeListItem, SchoolListItem, StaffOption } from "../types"
|
||||
import type { GradeOverviewStats } from "../data-access"
|
||||
import { createGradeAction, deleteGradeAction, updateGradeAction } from "../actions"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
@@ -71,10 +72,12 @@ export function GradesClient({
|
||||
grades,
|
||||
schools,
|
||||
staff,
|
||||
gradeStats,
|
||||
}: {
|
||||
grades: GradeListItem[]
|
||||
schools: SchoolListItem[]
|
||||
staff: StaffOption[]
|
||||
gradeStats: GradeOverviewStats[]
|
||||
}) {
|
||||
const t = useTranslations("school")
|
||||
const router = useRouter()
|
||||
@@ -92,6 +95,13 @@ export function GradesClient({
|
||||
const [createState, setCreateState] = useState<FormState>(() => toFormState(null, defaultSchoolId))
|
||||
const [editState, setEditState] = useState<FormState>(() => toFormState(null, defaultSchoolId))
|
||||
|
||||
// 年级概览统计映射,用于卡片视图
|
||||
const statsMap = useMemo(() => {
|
||||
const m = new Map<string, GradeOverviewStats>()
|
||||
for (const s of gradeStats) m.set(s.gradeId, s)
|
||||
return m
|
||||
}, [gradeStats])
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen) return
|
||||
if (createState.schoolId.trim().length > 0) return
|
||||
@@ -329,6 +339,122 @@ export function GradesClient({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 年级概览卡片视图:让管理员一目了然看到各年级规模 */}
|
||||
{filteredGrades.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filteredGrades.slice(0, 8).map((g) => {
|
||||
const stats = statsMap.get(g.id)
|
||||
return (
|
||||
<Card key={g.id} className="shadow-none">
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold">{g.name}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{g.school.name}</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" disabled={isWorking}>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
router.push(`/admin/school/grades/insights?gradeId=${encodeURIComponent(g.id)}`)
|
||||
}
|
||||
>
|
||||
<BarChart3 className="mr-2 h-3.5 w-3.5" />
|
||||
{t("grades.gradeOverview.viewInsights")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => openEdit(g)}>
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
{t("grades.actions.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setDeleteItem(g)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
{t("grades.actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* 统计指标 */}
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<div className="flex items-center justify-center text-muted-foreground">
|
||||
<GraduationCap className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm font-semibold tabular-nums">
|
||||
{stats?.classCount ?? 0}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t("grades.gradeOverview.classCount")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<div className="flex items-center justify-center text-muted-foreground">
|
||||
<Users className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm font-semibold tabular-nums">
|
||||
{stats?.studentCount ?? 0}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t("grades.gradeOverview.studentCount")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<div className="flex items-center justify-center text-muted-foreground">
|
||||
<UserCog className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm font-semibold tabular-nums">
|
||||
{stats?.teacherCount ?? 0}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t("grades.gradeOverview.teacherCount")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 年级主任/教学主任 */}
|
||||
<div className="space-y-1 border-t pt-2 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("grades.gradeOverview.gradeHead")}</span>
|
||||
<span className="truncate font-medium">
|
||||
{g.gradeHead?.name ?? t("grades.gradeOverview.notSet")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("grades.gradeOverview.teachingHead")}</span>
|
||||
<span className="truncate font-medium">
|
||||
{g.teachingHead?.name ?? t("grades.gradeOverview.notSet")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 快捷操作 */}
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
>
|
||||
<a href={`/admin/school/grades/insights?gradeId=${encodeURIComponent(g.id)}`}>
|
||||
<BarChart3 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("grades.gradeOverview.viewInsights")}
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-1 flex-col gap-2 md:flex-row md:items-center">
|
||||
<div className="flex-1 md:max-w-sm">
|
||||
@@ -427,6 +553,7 @@ export function GradesClient({
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -482,6 +609,7 @@ export function GradesClient({
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -798,3 +798,51 @@ export const getOrgTree = cache(async (): Promise<OrgTreeNode[]> => {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 年级概览统计:每个年级的班级数、学生数、教师数。
|
||||
* 用于年级管理页面的卡片视图,让管理员一目了然看到各年级规模。
|
||||
*/
|
||||
export interface GradeOverviewStats {
|
||||
gradeId: string
|
||||
classCount: number
|
||||
studentCount: number
|
||||
teacherCount: number
|
||||
}
|
||||
|
||||
export const getGradeOverviewStats = cache(async (): Promise<GradeOverviewStats[]> => {
|
||||
try {
|
||||
// 动态导入 classes 模块避免循环依赖
|
||||
const { getAdminClasses } = await import("@/modules/classes/data-access")
|
||||
const allClasses = await getAdminClasses()
|
||||
|
||||
// 按年级分组统计班级数和学生数
|
||||
const statsByGrade = new Map<string, { classCount: number; studentCount: number; teacherIds: Set<string> }>()
|
||||
|
||||
for (const cls of allClasses) {
|
||||
const gradeId = cls.gradeId
|
||||
if (typeof gradeId !== "string" || gradeId.length === 0) continue
|
||||
|
||||
const existing = statsByGrade.get(gradeId) ?? { classCount: 0, studentCount: 0, teacherIds: new Set<string>() }
|
||||
existing.classCount += 1
|
||||
existing.studentCount += cls.studentCount ?? 0
|
||||
// 班主任算一个教师
|
||||
if (cls.teacher?.id) existing.teacherIds.add(cls.teacher.id)
|
||||
// 学科教师也算
|
||||
for (const st of cls.subjectTeachers ?? []) {
|
||||
if (st.teacher?.id) existing.teacherIds.add(st.teacher.id)
|
||||
}
|
||||
statsByGrade.set(gradeId, existing)
|
||||
}
|
||||
|
||||
return Array.from(statsByGrade.entries()).map(([gradeId, s]) => ({
|
||||
gradeId,
|
||||
classCount: s.classCount,
|
||||
studentCount: s.studentCount,
|
||||
teacherCount: s.teacherIds.size,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("getGradeOverviewStats failed:", error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user