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"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
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 { toast } from "sonner"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { parseAsString, useQueryState } from "nuqs"
|
import { parseAsString, useQueryState } from "nuqs"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import type { GradeListItem, SchoolListItem, StaffOption } from "../types"
|
import type { GradeListItem, SchoolListItem, StaffOption } from "../types"
|
||||||
|
import type { GradeOverviewStats } from "../data-access"
|
||||||
import { createGradeAction, deleteGradeAction, updateGradeAction } from "../actions"
|
import { createGradeAction, deleteGradeAction, updateGradeAction } from "../actions"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
@@ -71,10 +72,12 @@ export function GradesClient({
|
|||||||
grades,
|
grades,
|
||||||
schools,
|
schools,
|
||||||
staff,
|
staff,
|
||||||
|
gradeStats,
|
||||||
}: {
|
}: {
|
||||||
grades: GradeListItem[]
|
grades: GradeListItem[]
|
||||||
schools: SchoolListItem[]
|
schools: SchoolListItem[]
|
||||||
staff: StaffOption[]
|
staff: StaffOption[]
|
||||||
|
gradeStats: GradeOverviewStats[]
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("school")
|
const t = useTranslations("school")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -92,6 +95,13 @@ export function GradesClient({
|
|||||||
const [createState, setCreateState] = useState<FormState>(() => toFormState(null, defaultSchoolId))
|
const [createState, setCreateState] = useState<FormState>(() => toFormState(null, defaultSchoolId))
|
||||||
const [editState, setEditState] = 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(() => {
|
useEffect(() => {
|
||||||
if (!createOpen) return
|
if (!createOpen) return
|
||||||
if (createState.schoolId.trim().length > 0) return
|
if (createState.schoolId.trim().length > 0) return
|
||||||
@@ -329,6 +339,122 @@ export function GradesClient({
|
|||||||
|
|
||||||
return (
|
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-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 flex-1 flex-col gap-2 md:flex-row md:items-center">
|
||||||
<div className="flex-1 md:max-w-sm">
|
<div className="flex-1 md:max-w-sm">
|
||||||
@@ -427,6 +553,7 @@ export function GradesClient({
|
|||||||
className="h-auto border-none shadow-none"
|
className="h-auto border-none shadow-none"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -482,6 +609,7 @@ export function GradesClient({
|
|||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -798,3 +798,51 @@ export const getOrgTree = cache(async (): Promise<OrgTreeNode[]> => {
|
|||||||
return []
|
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