refactor(attendance,elective): 审计第二轮 — 全量完成 P0/P1 改进项
P0 修复: - 页面层 i18n 全量补齐(admin/teacher/parent/student × attendance/elective) - types.ts 状态标签常量迁移至 constants.ts(i18n key + Badge variant) - 修复 getTranslations 导入路径(next-intl → next-intl/server) P1 改进: - 解耦 parent 模块对 attendance 类型的直接依赖(本地 view-model 类型) - 导出纯函数(computeStats/buildWarnings/buildLotteryRankCase 等) - 统一空状态为 EmptyState 组件 - 清理死代码读 Action(attendance 5 个 + elective 3 个) - 预留监控埋点接口(trackEvent 13 个新事件名) - 补齐骨架屏 loading.tsx(8 个页面) - AlertDialog 替换 window.confirm(student-selection-view) - a11y 改进(aria-label/role/键盘导航) 修复: - AttendanceStatus 从 constants.ts 重导出,消除 types/constants 双源混乱 - buildWarnings 的 Translator 类型改用 ReturnType<typeof useTranslations>
This commit is contained in:
35
src/app/(dashboard)/admin/attendance/loading.tsx
Normal file
35
src/app/(dashboard)/admin/attendance/loading.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 p-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-72" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
import { BarChart3, ClipboardList } from "lucide-react"
|
import { BarChart3, ClipboardList } from "lucide-react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
@@ -15,11 +16,6 @@ import { AttendanceStatsCards } from "@/modules/attendance/components/attendance
|
|||||||
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
|
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
|
||||||
import type { AttendanceStatus } from "@/modules/attendance/types"
|
import type { AttendanceStatus } from "@/modules/attendance/types"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "考勤总览 - Next_Edu",
|
|
||||||
description: "查看全校所有班级的考勤记录",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
const isValidAttendanceStatus = (v?: string): v is AttendanceStatus =>
|
const isValidAttendanceStatus = (v?: string): v is AttendanceStatus =>
|
||||||
@@ -33,6 +29,7 @@ export default async function AdminAttendancePage({
|
|||||||
await requirePermission(Permissions.ATTENDANCE_READ)
|
await requirePermission(Permissions.ATTENDANCE_READ)
|
||||||
const sp = await searchParams
|
const sp = await searchParams
|
||||||
const ctx = await getAuthContext()
|
const ctx = await getAuthContext()
|
||||||
|
const t = await getTranslations("attendance")
|
||||||
|
|
||||||
const classId = getSearchParam(sp, "classId")
|
const classId = getSearchParam(sp, "classId")
|
||||||
const statusParam = getSearchParam(sp, "status")
|
const statusParam = getSearchParam(sp, "status")
|
||||||
@@ -62,13 +59,13 @@ export default async function AdminAttendancePage({
|
|||||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
<div className="flex items-center justify-between space-y-2">
|
<div className="flex items-center justify-between space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">考勤总览</h2>
|
<h2 className="text-2xl font-bold tracking-tight">{t("title.adminOverview")}</h2>
|
||||||
<p className="text-muted-foreground">查看全校所有班级的考勤记录。</p>
|
<p className="text-muted-foreground">{t("description.adminOverview")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href="/teacher/attendance/stats">
|
<Link href="/teacher/attendance/stats">
|
||||||
<BarChart3 className="mr-2 h-4 w-4" />
|
<BarChart3 className="mr-2 h-4 w-4" />
|
||||||
统计分析
|
{t("actions.stats")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,8 +76,8 @@ export default async function AdminAttendancePage({
|
|||||||
|
|
||||||
{result.items.length === 0 && !classId && !status && !date ? (
|
{result.items.length === 0 && !classId && !status && !date ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="暂无考勤记录"
|
title={t("list.empty")}
|
||||||
description="系统中尚未产生任何考勤记录。"
|
description={t("list.emptyDescription")}
|
||||||
icon={ClipboardList}
|
icon={ClipboardList}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
27
src/app/(dashboard)/admin/elective/[id]/edit/loading.tsx
Normal file
27
src/app/(dashboard)/admin/elective/[id]/edit/loading.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-9 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Skeleton className="h-10 w-32" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import type { Metadata } from "next"
|
|
||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { getElectiveCourseById } from "@/modules/elective/data-access"
|
import { getElectiveCourseById } from "@/modules/elective/data-access"
|
||||||
import { getGrades, getStaffOptions, getSubjectOptions } from "@/modules/school/data-access"
|
import { getGrades, getStaffOptions, getSubjectOptions } from "@/modules/school/data-access"
|
||||||
import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form"
|
import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "编辑选修课程 - Next_Edu",
|
|
||||||
description: "更新选修课程详情",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export default async function EditElectiveCoursePage({
|
export default async function EditElectiveCoursePage({
|
||||||
@@ -18,6 +13,7 @@ export default async function EditElectiveCoursePage({
|
|||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}): Promise<JSX.Element> {
|
}): Promise<JSX.Element> {
|
||||||
|
const t = await getTranslations("elective")
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
const [course, subjects, grades, teachers] = await Promise.all([
|
const [course, subjects, grades, teachers] = await Promise.all([
|
||||||
@@ -32,15 +28,15 @@ export default async function EditElectiveCoursePage({
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">编辑选修课程</h2>
|
<h2 className="text-2xl font-bold tracking-tight">{t("title.edit")}</h2>
|
||||||
<p className="text-muted-foreground">更新选修课程详情。</p>
|
<p className="text-muted-foreground">{t("description.edit")}</p>
|
||||||
</div>
|
</div>
|
||||||
<ElectiveCourseForm
|
<ElectiveCourseForm
|
||||||
mode="edit"
|
mode="edit"
|
||||||
course={course}
|
course={course}
|
||||||
subjects={subjects}
|
subjects={subjects}
|
||||||
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
|
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
|
||||||
teachers={teachers.map((t) => ({ id: t.id, name: t.name }))}
|
teachers={teachers.map((teacher) => ({ id: teacher.id, name: teacher.name }))}
|
||||||
backHref="/admin/elective"
|
backHref="/admin/elective"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
27
src/app/(dashboard)/admin/elective/create/loading.tsx
Normal file
27
src/app/(dashboard)/admin/elective/create/loading.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-9 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Skeleton className="h-10 w-32" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,17 +1,13 @@
|
|||||||
import type { Metadata } from "next"
|
|
||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { getGrades, getStaffOptions, getSubjectOptions } from "@/modules/school/data-access"
|
import { getGrades, getStaffOptions, getSubjectOptions } from "@/modules/school/data-access"
|
||||||
import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form"
|
import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "新建选修课程 - Next_Edu",
|
|
||||||
description: "创建新的选修课程",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export default async function CreateElectiveCoursePage(): Promise<JSX.Element> {
|
export default async function CreateElectiveCoursePage(): Promise<JSX.Element> {
|
||||||
|
const t = await getTranslations("elective")
|
||||||
const [subjects, grades, teachers] = await Promise.all([
|
const [subjects, grades, teachers] = await Promise.all([
|
||||||
getSubjectOptions(),
|
getSubjectOptions(),
|
||||||
getGrades(),
|
getGrades(),
|
||||||
@@ -21,14 +17,14 @@ export default async function CreateElectiveCoursePage(): Promise<JSX.Element> {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">新建选修课程</h2>
|
<h2 className="text-2xl font-bold tracking-tight">{t("title.create")}</h2>
|
||||||
<p className="text-muted-foreground">创建新的选修课程。</p>
|
<p className="text-muted-foreground">{t("description.create")}</p>
|
||||||
</div>
|
</div>
|
||||||
<ElectiveCourseForm
|
<ElectiveCourseForm
|
||||||
mode="create"
|
mode="create"
|
||||||
subjects={subjects}
|
subjects={subjects}
|
||||||
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
|
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
|
||||||
teachers={teachers.map((t) => ({ id: t.id, name: t.name }))}
|
teachers={teachers.map((teacher) => ({ id: teacher.id, name: teacher.name }))}
|
||||||
backHref="/admin/elective"
|
backHref="/admin/elective"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
27
src/app/(dashboard)/admin/elective/loading.tsx
Normal file
27
src/app/(dashboard)/admin/elective/loading.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 p-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-72" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-8 w-24" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { getElectiveCourses } from "@/modules/elective/data-access"
|
import { getElectiveCourses } from "@/modules/elective/data-access"
|
||||||
import { ElectiveCourseList } from "@/modules/elective/components/elective-course-list"
|
import { ElectiveCourseList } from "@/modules/elective/components/elective-course-list"
|
||||||
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||||
import type { ElectiveCourseStatus } from "@/modules/elective/types"
|
import type { ElectiveCourseStatus } from "@/modules/elective/types"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "选修课程 - Next_Edu",
|
|
||||||
description: "管理选修课程、开放/关闭选课与抽签",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
const isValidStatus = (v?: string): v is ElectiveCourseStatus =>
|
const isValidStatus = (v?: string): v is ElectiveCourseStatus =>
|
||||||
@@ -22,6 +18,7 @@ export default async function AdminElectivePage({
|
|||||||
searchParams: Promise<SearchParams>
|
searchParams: Promise<SearchParams>
|
||||||
}): Promise<JSX.Element> {
|
}): Promise<JSX.Element> {
|
||||||
const sp = await searchParams
|
const sp = await searchParams
|
||||||
|
const t = await getTranslations("elective")
|
||||||
const statusParam = getSearchParam(sp, "status")
|
const statusParam = getSearchParam(sp, "status")
|
||||||
const status = isValidStatus(statusParam) ? statusParam : undefined
|
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||||
|
|
||||||
@@ -30,9 +27,9 @@ export default async function AdminElectivePage({
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">选修课程</h2>
|
<h2 className="text-2xl font-bold tracking-tight">{t("title.adminList")}</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
管理选修课程、开放/关闭选课与抽签。
|
{t("description.adminList")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ElectiveCourseList
|
<ElectiveCourseList
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
import { getStudentAttendanceSummary } from "@/modules/attendance/data-access-stats"
|
import { getStudentAttendanceSummary } from "@/modules/attendance/data-access-stats"
|
||||||
import { StudentAttendanceView } from "@/modules/attendance/components/student-attendance-view"
|
import { StudentAttendanceView } from "@/modules/attendance/components/student-attendance-view"
|
||||||
@@ -5,21 +6,25 @@ import {
|
|||||||
ParentChildrenDataPage,
|
ParentChildrenDataPage,
|
||||||
ParentNoChildrenPage,
|
ParentNoChildrenPage,
|
||||||
} from "@/modules/parent/components/parent-children-data-page"
|
} from "@/modules/parent/components/parent-children-data-page"
|
||||||
|
import { ParentAttendanceWarning } from "@/modules/parent/components/parent-attendance-warning"
|
||||||
|
import { ParentAttendanceRateCard } from "@/modules/parent/components/parent-attendance-rate-card"
|
||||||
|
import { ParentAttendanceCalendar } from "@/modules/parent/components/parent-attendance-calendar"
|
||||||
import { CalendarCheck } from "lucide-react"
|
import { CalendarCheck } from "lucide-react"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export default async function ParentAttendancePage() {
|
export default async function ParentAttendancePage() {
|
||||||
|
const t = await getTranslations("attendance")
|
||||||
const ctx = await getAuthContext()
|
const ctx = await getAuthContext()
|
||||||
|
|
||||||
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
|
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
|
||||||
return (
|
return (
|
||||||
<ParentNoChildrenPage
|
<ParentNoChildrenPage
|
||||||
title="Children Attendance"
|
title={t("title.parent")}
|
||||||
description="View your children's attendance records."
|
description={t("description.parent")}
|
||||||
icon={CalendarCheck}
|
icon={CalendarCheck}
|
||||||
emptyTitle="No children linked"
|
emptyTitle={t("parent.noChildrenTitle")}
|
||||||
emptyDescription="Your account is not linked to any student accounts yet. Please contact the school administrator."
|
emptyDescription={t("parent.noChildrenDescription")}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -37,18 +42,27 @@ export default async function ParentAttendancePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ParentChildrenDataPage
|
<ParentChildrenDataPage
|
||||||
title="Children Attendance"
|
title={t("title.parent")}
|
||||||
description="View your children's attendance records."
|
description={t("parent.compareDescription")}
|
||||||
icon={CalendarCheck}
|
icon={CalendarCheck}
|
||||||
noRecordsTitle="No attendance records"
|
noRecordsTitle={t("list.empty")}
|
||||||
noRecordsDescription="Your children don't have any attendance records yet."
|
noRecordsDescription={t("parent.noRecordsDescription")}
|
||||||
items={validSummaries}
|
items={validSummaries}
|
||||||
renderItem={(summary) => (
|
renderItem={(summary) => (
|
||||||
<>
|
<div className="space-y-6">
|
||||||
<h3 className="text-lg font-semibold border-b pb-2">{summary.studentName}</h3>
|
<h3 className="text-lg font-semibold border-b pb-2">{summary.studentName}</h3>
|
||||||
|
<ParentAttendanceCalendar summary={summary} />
|
||||||
<StudentAttendanceView summary={summary} />
|
<StudentAttendanceView summary={summary} />
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
headerExtra={
|
||||||
|
validSummaries.length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<ParentAttendanceRateCard summaries={validSummaries} />
|
||||||
|
<ParentAttendanceWarning summaries={validSummaries} />
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
import { getStudentAttendanceSummary } from "@/modules/attendance/data-access-stats"
|
import { getStudentAttendanceSummary } from "@/modules/attendance/data-access-stats"
|
||||||
import { StudentAttendanceView } from "@/modules/attendance/components/student-attendance-view"
|
import { StudentAttendanceView } from "@/modules/attendance/components/student-attendance-view"
|
||||||
@@ -8,19 +9,20 @@ export const dynamic = "force-dynamic"
|
|||||||
|
|
||||||
export default async function StudentAttendancePage() {
|
export default async function StudentAttendancePage() {
|
||||||
const ctx = await getAuthContext()
|
const ctx = await getAuthContext()
|
||||||
|
const t = await getTranslations("attendance")
|
||||||
|
|
||||||
const summary = await getStudentAttendanceSummary(ctx.userId)
|
const summary = await getStudentAttendanceSummary(ctx.userId)
|
||||||
|
|
||||||
if (!summary) {
|
if (!summary) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">My Attendance</h2>
|
<h2 className="text-2xl font-bold tracking-tight">{t("title.student")}</h2>
|
||||||
<p className="text-muted-foreground">View your attendance records.</p>
|
<p className="text-muted-foreground">{t("description.student")}</p>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No user found"
|
title={t("errors.notFound")}
|
||||||
description="Unable to load your student profile."
|
description={t("errors.unexpected")}
|
||||||
icon={UserX}
|
icon={UserX}
|
||||||
className="border-none shadow-none"
|
className="border-none shadow-none"
|
||||||
/>
|
/>
|
||||||
@@ -29,10 +31,10 @@ export default async function StudentAttendancePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">My Attendance</h2>
|
<h2 className="text-2xl font-bold tracking-tight">{t("title.student")}</h2>
|
||||||
<p className="text-muted-foreground">View your attendance records and statistics.</p>
|
<p className="text-muted-foreground">{t("description.student")}</p>
|
||||||
</div>
|
</div>
|
||||||
<StudentAttendanceView summary={summary} />
|
<StudentAttendanceView summary={summary} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +1,19 @@
|
|||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
|
|
||||||
import { getAvailableCoursesForStudent, getStudentSelections } from "@/modules/elective/data-access-selections"
|
import { getAvailableCoursesForStudent, getStudentSelections } from "@/modules/elective/data-access-selections"
|
||||||
import { StudentSelectionView } from "@/modules/elective/components/student-selection-view"
|
import { StudentSelectionView } from "@/modules/elective/components/student-selection-view"
|
||||||
import { ElectiveFilters } from "@/modules/elective/components/elective-filters"
|
import { ElectiveFilters } from "@/modules/elective/components/elective-filters"
|
||||||
|
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
|
||||||
|
|
||||||
const getParam = (params: SearchParams, key: string) => {
|
|
||||||
const v = params[key]
|
|
||||||
return Array.isArray(v) ? v[0] : v
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function StudentElectivePage({
|
export default async function StudentElectivePage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<SearchParams>
|
searchParams: Promise<SearchParams>
|
||||||
}) {
|
}) {
|
||||||
|
const t = await getTranslations("elective")
|
||||||
const ctx = await getAuthContext()
|
const ctx = await getAuthContext()
|
||||||
const studentId = ctx.userId
|
const studentId = ctx.userId
|
||||||
|
|
||||||
@@ -39,10 +35,8 @@ export default async function StudentElectivePage({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Elective Courses</h2>
|
<h2 className="text-2xl font-bold tracking-tight">{t("title.student")}</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">{t("description.student")}</p>
|
||||||
Browse available electives and manage your selections.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{availableCourses.length > 0 && <ElectiveFilters />}
|
{availableCourses.length > 0 && <ElectiveFilters />}
|
||||||
<StudentSelectionView
|
<StudentSelectionView
|
||||||
|
|||||||
35
src/app/(dashboard)/teacher/attendance/loading.tsx
Normal file
35
src/app/(dashboard)/teacher/attendance/loading.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 p-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-72" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
|
import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { ListPagination, computePagination, paginate } from "@/shared/components/ui/list-pagination"
|
||||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||||
@@ -25,6 +27,8 @@ function parseAttendanceStatus(v?: string): AttendanceStatus | undefined {
|
|||||||
return v && VALID_STATUSES.has(v) ? (v as AttendanceStatus) : undefined
|
return v && VALID_STATUSES.has(v) ? (v as AttendanceStatus) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
export default async function TeacherAttendancePage({
|
export default async function TeacherAttendancePage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -32,6 +36,7 @@ export default async function TeacherAttendancePage({
|
|||||||
}): Promise<JSX.Element> {
|
}): Promise<JSX.Element> {
|
||||||
const sp = await searchParams
|
const sp = await searchParams
|
||||||
const ctx = await getAuthContext()
|
const ctx = await getAuthContext()
|
||||||
|
const t = await getTranslations("attendance")
|
||||||
|
|
||||||
const classId = getParam(sp, "classId")
|
const classId = getParam(sp, "classId")
|
||||||
const status = getParam(sp, "status")
|
const status = getParam(sp, "status")
|
||||||
@@ -49,24 +54,32 @@ export default async function TeacherAttendancePage({
|
|||||||
])
|
])
|
||||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||||
|
|
||||||
|
// 分页计算
|
||||||
|
const { page } = computePagination(sp, PAGE_SIZE)
|
||||||
|
const total = result.items.length
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
const currentPage = Math.min(page, totalPages)
|
||||||
|
const pagedRecords = paginate(result.items, currentPage, PAGE_SIZE)
|
||||||
|
const hasFilters = Boolean(classId || status || date)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
<div className="flex items-center justify-between space-y-2">
|
<div className="flex items-center justify-between space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Attendance</h1>
|
<h1 className="text-2xl font-bold tracking-tight">{t("title.teacherRecords")}</h1>
|
||||||
<p className="text-muted-foreground">Manage student attendance records.</p>
|
<p className="text-muted-foreground">{t("description.teacherRecords")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href="/teacher/attendance/stats">
|
<Link href="/teacher/attendance/stats">
|
||||||
<BarChart3 className="mr-2 h-4 w-4" aria-hidden="true" />
|
<BarChart3 className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
Statistics
|
{t("actions.stats")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/teacher/attendance/sheet">
|
<Link href="/teacher/attendance/sheet">
|
||||||
<PlusCircle className="mr-2 h-4 w-4" aria-hidden="true" />
|
<PlusCircle className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
Take Attendance
|
{t("actions.record")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,18 +87,31 @@ export default async function TeacherAttendancePage({
|
|||||||
|
|
||||||
<AttendanceFilters classes={classOptions} />
|
<AttendanceFilters classes={classOptions} />
|
||||||
|
|
||||||
{result.items.length === 0 && !classId && !status && !date ? (
|
{result.items.length === 0 && !hasFilters ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No attendance records"
|
title={t("list.empty")}
|
||||||
description="Start by taking attendance for your classes."
|
description={t("list.emptyTeacherDescription")}
|
||||||
icon={ClipboardList}
|
icon={ClipboardList}
|
||||||
action={{
|
action={{
|
||||||
label: "Take Attendance",
|
label: t("actions.record"),
|
||||||
href: "/teacher/attendance/sheet",
|
href: "/teacher/attendance/sheet",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AttendanceRecordList records={result.items} />
|
<div className="space-y-4">
|
||||||
|
<AttendanceRecordList records={pagedRecords} />
|
||||||
|
{total > 0 ? (
|
||||||
|
<ListPagination
|
||||||
|
page={currentPage}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
total={total}
|
||||||
|
totalPages={totalPages}
|
||||||
|
basePath="/teacher/attendance"
|
||||||
|
searchParams={sp}
|
||||||
|
itemLabel={t("stats.totalRecords")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
27
src/app/(dashboard)/teacher/attendance/sheet/loading.tsx
Normal file
27
src/app/(dashboard)/teacher/attendance/sheet/loading.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-72" />
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-2">
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Skeleton className="h-9 w-48" />
|
||||||
|
<Skeleton className="h-9 w-40" />
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||||
import { getClassStudentsForAttendance } from "@/modules/attendance/data-access"
|
import { getClassStudentsForAttendance } from "@/modules/attendance/data-access"
|
||||||
import { AttendanceSheet } from "@/modules/attendance/components/attendance-sheet"
|
import { AttendanceSheet } from "@/modules/attendance/components/attendance-sheet"
|
||||||
@@ -11,6 +12,7 @@ export default async function AttendanceSheetPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<SearchParams>
|
searchParams: Promise<SearchParams>
|
||||||
}): Promise<JSX.Element> {
|
}): Promise<JSX.Element> {
|
||||||
|
const t = await getTranslations("attendance")
|
||||||
const sp = await searchParams
|
const sp = await searchParams
|
||||||
|
|
||||||
const defaultClassId = getParam(sp, "classId")
|
const defaultClassId = getParam(sp, "classId")
|
||||||
@@ -27,10 +29,8 @@ export default async function AttendanceSheetPage({
|
|||||||
return (
|
return (
|
||||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Take Attendance</h1>
|
<h1 className="text-2xl font-bold tracking-tight">{t("title.sheet")}</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">{t("sheet.description")}</p>
|
||||||
Select a class and date, then mark attendance for each student.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AttendanceSheet
|
<AttendanceSheet
|
||||||
|
|||||||
36
src/app/(dashboard)/teacher/attendance/stats/loading.tsx
Normal file
36
src/app/(dashboard)/teacher/attendance/stats/loading.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-72" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-10 w-full max-w-md" />
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||||
import { getClassAttendanceStats } from "@/modules/attendance/data-access-stats"
|
import { getClassAttendanceStats } from "@/modules/attendance/data-access-stats"
|
||||||
import { AttendanceStatsCard } from "@/modules/attendance/components/attendance-stats-card"
|
import { AttendanceStatsCard } from "@/modules/attendance/components/attendance-stats-card"
|
||||||
@@ -15,6 +16,7 @@ export default async function AttendanceStatsPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<SearchParams>
|
searchParams: Promise<SearchParams>
|
||||||
}): Promise<JSX.Element> {
|
}): Promise<JSX.Element> {
|
||||||
|
const t = await getTranslations("attendance")
|
||||||
const sp = await searchParams
|
const sp = await searchParams
|
||||||
|
|
||||||
const classId = getParam(sp, "classId")
|
const classId = getParam(sp, "classId")
|
||||||
@@ -27,12 +29,12 @@ export default async function AttendanceStatsPage({
|
|||||||
return (
|
return (
|
||||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Attendance Statistics</h1>
|
<h1 className="text-2xl font-bold tracking-tight">{t("title.teacherStats")}</h1>
|
||||||
<p className="text-muted-foreground">View class attendance statistics.</p>
|
<p className="text-muted-foreground">{t("description.teacherStats")}</p>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No classes"
|
title={t("stats.noClasses")}
|
||||||
description="You don't have any classes yet."
|
description={t("stats.noClassesDescription")}
|
||||||
icon={BarChart3}
|
icon={BarChart3}
|
||||||
className="border-none shadow-none"
|
className="border-none shadow-none"
|
||||||
/>
|
/>
|
||||||
@@ -53,8 +55,8 @@ export default async function AttendanceStatsPage({
|
|||||||
return (
|
return (
|
||||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Attendance Statistics</h1>
|
<h1 className="text-2xl font-bold tracking-tight">{t("title.teacherStats")}</h1>
|
||||||
<p className="text-muted-foreground">View class attendance statistics and trends.</p>
|
<p className="text-muted-foreground">{t("description.teacherStats")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AttendanceStatsClassSelector
|
<AttendanceStatsClassSelector
|
||||||
@@ -68,14 +70,14 @@ export default async function AttendanceStatsPage({
|
|||||||
<>
|
<>
|
||||||
<AttendanceStatsCard stats={summary.stats} />
|
<AttendanceStatsCard stats={summary.stats} />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="mb-4 text-lg font-semibold">Student Records</h2>
|
<h2 className="mb-4 text-lg font-semibold">{t("stats.studentRecords")}</h2>
|
||||||
<AttendanceRecordList records={summary.studentRecords} />
|
<AttendanceRecordList records={summary.studentRecords} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No data"
|
title={t("stats.noData")}
|
||||||
description="No attendance data available for this class."
|
description={t("stats.noDataDescription")}
|
||||||
icon={BarChart3}
|
icon={BarChart3}
|
||||||
className="border-none shadow-none"
|
className="border-none shadow-none"
|
||||||
/>
|
/>
|
||||||
|
|||||||
27
src/app/(dashboard)/teacher/elective/loading.tsx
Normal file
27
src/app/(dashboard)/teacher/elective/loading.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 p-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-72" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-8 w-24" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||||
import { getElectiveCourses } from "@/modules/elective/data-access"
|
import { getElectiveCourses } from "@/modules/elective/data-access"
|
||||||
@@ -23,6 +24,7 @@ export default async function TeacherElectivePage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<SearchParams>
|
searchParams: Promise<SearchParams>
|
||||||
}): Promise<JSX.Element> {
|
}): Promise<JSX.Element> {
|
||||||
|
const t = await getTranslations("elective")
|
||||||
const ctx = await getAuthContext()
|
const ctx = await getAuthContext()
|
||||||
const teacherId = ctx.userId
|
const teacherId = ctx.userId
|
||||||
|
|
||||||
@@ -37,10 +39,8 @@ export default async function TeacherElectivePage({
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">My Elective Courses</h1>
|
<h1 className="text-2xl font-bold tracking-tight">{t("title.teacher")}</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">{t("description.teacher")}</p>
|
||||||
View and manage the elective courses you teach.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<ElectiveCourseList
|
<ElectiveCourseList
|
||||||
courses={courses}
|
courses={courses}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"
|
|||||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
import { trackEvent } from "@/shared/lib/track-event"
|
||||||
import { verifyTeacherOwnsClass } from "@/modules/classes/data-access"
|
import { verifyTeacherOwnsClass } from "@/modules/classes/data-access"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -18,16 +19,8 @@ import {
|
|||||||
updateAttendanceRecord,
|
updateAttendanceRecord,
|
||||||
deleteAttendanceRecord,
|
deleteAttendanceRecord,
|
||||||
getAttendanceRecordClassId,
|
getAttendanceRecordClassId,
|
||||||
getAttendanceRecords,
|
|
||||||
getClassAttendanceForDate,
|
|
||||||
getAttendanceRules,
|
|
||||||
upsertAttendanceRules,
|
upsertAttendanceRules,
|
||||||
} from "./data-access"
|
} from "./data-access"
|
||||||
import {
|
|
||||||
getStudentAttendanceSummary,
|
|
||||||
getClassAttendanceStats,
|
|
||||||
} from "./data-access-stats"
|
|
||||||
import type { AttendanceQueryParams, AttendanceListItem } from "./types"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验当前用户对考勤记录的归属权限。
|
* 校验当前用户对考勤记录的归属权限。
|
||||||
@@ -50,6 +43,25 @@ async function assertRecordOwnership(
|
|||||||
return { ok: false, message: "Insufficient permissions" }
|
return { ok: false, message: "Insufficient permissions" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验当前用户对班级的归属权限。
|
||||||
|
* - admin(scope=all):直接放行
|
||||||
|
* - teacher(scope=class_taught):必须为该班级的任课教师
|
||||||
|
* - 其他 scope:拒绝
|
||||||
|
*/
|
||||||
|
async function assertClassOwnership(
|
||||||
|
classId: string,
|
||||||
|
ctx: Awaited<ReturnType<typeof requirePermission>>
|
||||||
|
): Promise<{ ok: boolean; message?: string }> {
|
||||||
|
if (ctx.dataScope.type === "all") return { ok: true }
|
||||||
|
if (ctx.dataScope.type === "class_taught") {
|
||||||
|
const owns = await verifyTeacherOwnsClass(classId, ctx.userId)
|
||||||
|
if (!owns) return { ok: false, message: "You do not own this class" }
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
return { ok: false, message: "Insufficient permissions" }
|
||||||
|
}
|
||||||
|
|
||||||
export async function recordAttendanceAction(
|
export async function recordAttendanceAction(
|
||||||
prevState: ActionState<string> | null,
|
prevState: ActionState<string> | null,
|
||||||
formData: FormData
|
formData: FormData
|
||||||
@@ -76,6 +88,13 @@ export async function recordAttendanceAction(
|
|||||||
|
|
||||||
const id = await createAttendanceRecord(parsed.data, ctx.userId)
|
const id = await createAttendanceRecord(parsed.data, ctx.userId)
|
||||||
revalidatePath("/teacher/attendance")
|
revalidatePath("/teacher/attendance")
|
||||||
|
await trackEvent({
|
||||||
|
event: "attendance.recorded",
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: id,
|
||||||
|
targetType: "attendance_record",
|
||||||
|
properties: { studentId: parsed.data.studentId, classId: parsed.data.classId, status: parsed.data.status },
|
||||||
|
})
|
||||||
return { success: true, message: "Attendance recorded", data: id }
|
return { success: true, message: "Attendance recorded", data: id }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
@@ -110,6 +129,12 @@ export async function batchRecordAttendanceAction(
|
|||||||
|
|
||||||
const count = await batchCreateAttendanceRecords(parsed.data, ctx.userId)
|
const count = await batchCreateAttendanceRecords(parsed.data, ctx.userId)
|
||||||
revalidatePath("/teacher/attendance")
|
revalidatePath("/teacher/attendance")
|
||||||
|
await trackEvent({
|
||||||
|
event: "attendance.batch_recorded",
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetType: "attendance_record",
|
||||||
|
properties: { count, classId: parsed.data.records[0]?.classId },
|
||||||
|
})
|
||||||
return { success: true, message: `Recorded attendance for ${count} students`, data: count }
|
return { success: true, message: `Recorded attendance for ${count} students`, data: count }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
@@ -147,6 +172,13 @@ export async function updateAttendanceAction(
|
|||||||
|
|
||||||
await updateAttendanceRecord(id, parsed.data)
|
await updateAttendanceRecord(id, parsed.data)
|
||||||
revalidatePath("/teacher/attendance")
|
revalidatePath("/teacher/attendance")
|
||||||
|
await trackEvent({
|
||||||
|
event: "attendance.updated",
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: id,
|
||||||
|
targetType: "attendance_record",
|
||||||
|
properties: { status: parsed.data.status },
|
||||||
|
})
|
||||||
return { success: true, message: "Attendance updated" }
|
return { success: true, message: "Attendance updated" }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
@@ -168,89 +200,13 @@ export async function deleteAttendanceAction(
|
|||||||
|
|
||||||
await deleteAttendanceRecord(id)
|
await deleteAttendanceRecord(id)
|
||||||
revalidatePath("/teacher/attendance")
|
revalidatePath("/teacher/attendance")
|
||||||
return { success: true, message: "Attendance record deleted" }
|
await trackEvent({
|
||||||
} catch (e) {
|
event: "attendance.deleted",
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
userId: ctx.userId,
|
||||||
if (e instanceof Error) return { success: false, message: e.message }
|
targetId: id,
|
||||||
return { success: false, message: "Unexpected error" }
|
targetType: "attendance_record",
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAttendanceAction(
|
|
||||||
params: AttendanceQueryParams
|
|
||||||
): Promise<ActionState<{ items: AttendanceListItem[]; total: number; page: number; pageSize: number; totalPages: number }>> {
|
|
||||||
try {
|
|
||||||
const ctx = await requirePermission(Permissions.ATTENDANCE_READ)
|
|
||||||
const result = await getAttendanceRecords({
|
|
||||||
...params,
|
|
||||||
scope: ctx.dataScope,
|
|
||||||
currentUserId: ctx.userId,
|
|
||||||
})
|
})
|
||||||
return {
|
return { success: true, message: "Attendance record deleted" }
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
items: result.items,
|
|
||||||
total: result.total,
|
|
||||||
page: result.page,
|
|
||||||
pageSize: result.pageSize,
|
|
||||||
totalPages: result.totalPages,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
|
||||||
if (e instanceof Error) return { success: false, message: e.message }
|
|
||||||
return { success: false, message: "Unexpected error" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getStudentAttendanceAction(
|
|
||||||
studentId: string,
|
|
||||||
startDate?: string,
|
|
||||||
endDate?: string
|
|
||||||
): Promise<ActionState<Awaited<ReturnType<typeof getStudentAttendanceSummary>>>> {
|
|
||||||
try {
|
|
||||||
const ctx = await requirePermission(Permissions.ATTENDANCE_READ)
|
|
||||||
|
|
||||||
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
|
|
||||||
return { success: false, message: "Can only view your own attendance" }
|
|
||||||
}
|
|
||||||
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
|
|
||||||
return { success: false, message: "Can only view your children's attendance" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const summary = await getStudentAttendanceSummary(studentId, startDate, endDate)
|
|
||||||
return { success: true, data: summary }
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
|
||||||
if (e instanceof Error) return { success: false, message: e.message }
|
|
||||||
return { success: false, message: "Unexpected error" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getClassAttendanceStatsAction(
|
|
||||||
classId: string,
|
|
||||||
startDate?: string,
|
|
||||||
endDate?: string
|
|
||||||
): Promise<ActionState<Awaited<ReturnType<typeof getClassAttendanceStats>>>> {
|
|
||||||
try {
|
|
||||||
await requirePermission(Permissions.ATTENDANCE_READ)
|
|
||||||
const result = await getClassAttendanceStats(classId, startDate, endDate)
|
|
||||||
return { success: true, data: result }
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
|
||||||
if (e instanceof Error) return { success: false, message: e.message }
|
|
||||||
return { success: false, message: "Unexpected error" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getClassAttendanceForDateAction(
|
|
||||||
classId: string,
|
|
||||||
date: string
|
|
||||||
): Promise<ActionState<AttendanceListItem[]>> {
|
|
||||||
try {
|
|
||||||
await requirePermission(Permissions.ATTENDANCE_READ)
|
|
||||||
const records = await getClassAttendanceForDate(classId, date)
|
|
||||||
return { success: true, data: records }
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
if (e instanceof Error) return { success: false, message: e.message }
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
@@ -263,7 +219,7 @@ export async function saveAttendanceRulesAction(
|
|||||||
formData: FormData
|
formData: FormData
|
||||||
): Promise<ActionState<string>> {
|
): Promise<ActionState<string>> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.ATTENDANCE_MANAGE)
|
const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE)
|
||||||
|
|
||||||
const parsed = AttendanceRuleSchema.safeParse({
|
const parsed = AttendanceRuleSchema.safeParse({
|
||||||
classId: formData.get("classId"),
|
classId: formData.get("classId"),
|
||||||
@@ -280,8 +236,20 @@ export async function saveAttendanceRulesAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ownership = await assertClassOwnership(parsed.data.classId, ctx)
|
||||||
|
if (!ownership.ok) {
|
||||||
|
return { success: false, message: ownership.message ?? "Ownership check failed" }
|
||||||
|
}
|
||||||
|
|
||||||
const id = await upsertAttendanceRules(parsed.data)
|
const id = await upsertAttendanceRules(parsed.data)
|
||||||
revalidatePath("/teacher/attendance")
|
revalidatePath("/teacher/attendance")
|
||||||
|
await trackEvent({
|
||||||
|
event: "attendance.rules_saved",
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: id,
|
||||||
|
targetType: "attendance_rule",
|
||||||
|
properties: { classId: parsed.data.classId },
|
||||||
|
})
|
||||||
return { success: true, message: "Attendance rules saved", data: id }
|
return { success: true, message: "Attendance rules saved", data: id }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
@@ -289,17 +257,3 @@ export async function saveAttendanceRulesAction(
|
|||||||
return { success: false, message: "Unexpected error" }
|
return { success: false, message: "Unexpected error" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAttendanceRulesAction(
|
|
||||||
classId?: string
|
|
||||||
): Promise<ActionState<Awaited<ReturnType<typeof getAttendanceRules>>>> {
|
|
||||||
try {
|
|
||||||
await requirePermission(Permissions.ATTENDANCE_READ)
|
|
||||||
const rules = await getAttendanceRules(classId)
|
|
||||||
return { success: true, data: rules }
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
|
||||||
if (e instanceof Error) return { success: false, message: e.message }
|
|
||||||
return { success: false, message: "Unexpected error" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { useCallback } from "react"
|
import { useCallback } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -12,23 +13,18 @@ import {
|
|||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
|
||||||
|
import { ATTENDANCE_STATUS_OPTIONS, ATTENDANCE_STATUS_LABEL_KEYS } from "../constants"
|
||||||
|
|
||||||
type Option = { id: string; name: string }
|
type Option = { id: string; name: string }
|
||||||
|
|
||||||
interface AttendanceFiltersProps {
|
interface AttendanceFiltersProps {
|
||||||
classes: Option[]
|
classes: Option[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
|
||||||
{ value: "present", label: "Present" },
|
|
||||||
{ value: "absent", label: "Absent" },
|
|
||||||
{ value: "late", label: "Late" },
|
|
||||||
{ value: "early_leave", label: "Early Leave" },
|
|
||||||
{ value: "excused", label: "Excused" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
|
export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
const t = useTranslations("attendance")
|
||||||
|
|
||||||
const updateParam = useCallback(
|
const updateParam = useCallback(
|
||||||
(key: string, value: string) => {
|
(key: string, value: string) => {
|
||||||
@@ -50,13 +46,13 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-3">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label className="text-xs">Class</Label>
|
<Label className="text-xs">{t("filters.class")}</Label>
|
||||||
<Select value={classId} onValueChange={(v) => updateParam("classId", v)}>
|
<Select value={classId} onValueChange={(v) => updateParam("classId", v)}>
|
||||||
<SelectTrigger className="h-9">
|
<SelectTrigger className="h-9" aria-label={t("filters.class")}>
|
||||||
<SelectValue placeholder="All classes" />
|
<SelectValue placeholder={t("filters.allClasses")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All classes</SelectItem>
|
<SelectItem value="all">{t("filters.allClasses")}</SelectItem>
|
||||||
{classes.map((c) => (
|
{classes.map((c) => (
|
||||||
<SelectItem key={c.id} value={c.id}>
|
<SelectItem key={c.id} value={c.id}>
|
||||||
{c.name}
|
{c.name}
|
||||||
@@ -67,16 +63,16 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label className="text-xs">Status</Label>
|
<Label className="text-xs">{t("filters.status")}</Label>
|
||||||
<Select value={status} onValueChange={(v) => updateParam("status", v)}>
|
<Select value={status} onValueChange={(v) => updateParam("status", v)}>
|
||||||
<SelectTrigger className="h-9">
|
<SelectTrigger className="h-9" aria-label={t("filters.status")}>
|
||||||
<SelectValue placeholder="All statuses" />
|
<SelectValue placeholder={t("filters.allStatuses")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All statuses</SelectItem>
|
<SelectItem value="all">{t("filters.allStatuses")}</SelectItem>
|
||||||
{STATUS_OPTIONS.map((s) => (
|
{ATTENDANCE_STATUS_OPTIONS.map((s) => (
|
||||||
<SelectItem key={s.value} value={s.value}>
|
<SelectItem key={s} value={s}>
|
||||||
{s.label}
|
{t(ATTENDANCE_STATUS_LABEL_KEYS[s])}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -84,12 +80,13 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label className="text-xs">Date</Label>
|
<Label className="text-xs">{t("filters.date")}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
value={date}
|
value={date}
|
||||||
onChange={(e) => updateParam("date", e.target.value)}
|
onChange={(e) => updateParam("date", e.target.value)}
|
||||||
className="h-9"
|
className="h-9"
|
||||||
|
aria-label={t("filters.date")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { Trash2 } from "lucide-react"
|
import { useTranslations } from "next-intl"
|
||||||
|
import { Trash2, Inbox } from "lucide-react"
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@@ -23,17 +24,19 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/shared/components/ui/dialog"
|
} from "@/shared/components/ui/dialog"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
import { deleteAttendanceAction } from "../actions"
|
import { deleteAttendanceAction } from "../actions"
|
||||||
import {
|
import {
|
||||||
ATTENDANCE_STATUS_COLORS,
|
ATTENDANCE_STATUS_BADGE_VARIANTS,
|
||||||
ATTENDANCE_STATUS_LABELS,
|
ATTENDANCE_STATUS_LABEL_KEYS,
|
||||||
type AttendanceListItem,
|
} from "../constants"
|
||||||
} from "../types"
|
import type { AttendanceListItem } from "../types"
|
||||||
|
|
||||||
export function AttendanceRecordList({ records }: { records: AttendanceListItem[] }) {
|
export function AttendanceRecordList({ records }: { records: AttendanceListItem[] }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const t = useTranslations("attendance")
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
|
||||||
@@ -43,19 +46,22 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[
|
|||||||
const result = await deleteAttendanceAction(deleteId)
|
const result = await deleteAttendanceAction(deleteId)
|
||||||
setIsDeleting(false)
|
setIsDeleting(false)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message)
|
toast.success(result.message || t("sheet.deleted"))
|
||||||
setDeleteId(null)
|
setDeleteId(null)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || "Failed to delete")
|
toast.error(result.message || t("errors.unexpected"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border bg-card p-8 text-center text-sm text-muted-foreground">
|
<EmptyState
|
||||||
No attendance records found.
|
icon={Inbox}
|
||||||
</div>
|
title={t("list.empty")}
|
||||||
|
description={t("list.emptyDescription")}
|
||||||
|
className="h-auto border-none shadow-none"
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,13 +71,13 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Student</TableHead>
|
<TableHead>{t("list.columns.student")}</TableHead>
|
||||||
<TableHead>Class</TableHead>
|
<TableHead>{t("list.columns.class")}</TableHead>
|
||||||
<TableHead>Date</TableHead>
|
<TableHead>{t("list.columns.date")}</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>{t("list.columns.status")}</TableHead>
|
||||||
<TableHead>Remark</TableHead>
|
<TableHead>{t("list.columns.remark")}</TableHead>
|
||||||
<TableHead>Recorded By</TableHead>
|
<TableHead>{t("list.columns.recorder")}</TableHead>
|
||||||
<TableHead>Created</TableHead>
|
<TableHead>{t("list.columns.createdAt")}</TableHead>
|
||||||
<TableHead className="w-12"></TableHead>
|
<TableHead className="w-12"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -82,8 +88,8 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[
|
|||||||
<TableCell>{r.className}</TableCell>
|
<TableCell>{r.className}</TableCell>
|
||||||
<TableCell>{r.date}</TableCell>
|
<TableCell>{r.date}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={ATTENDANCE_STATUS_COLORS[r.status]} className="capitalize">
|
<Badge variant={ATTENDANCE_STATUS_BADGE_VARIANTS[r.status]} className="capitalize">
|
||||||
{ATTENDANCE_STATUS_LABELS[r.status]}
|
{t(ATTENDANCE_STATUS_LABEL_KEYS[r.status])}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-[200px] truncate text-muted-foreground">
|
<TableCell className="max-w-[200px] truncate text-muted-foreground">
|
||||||
@@ -97,6 +103,7 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-destructive"
|
className="h-8 w-8 text-destructive"
|
||||||
onClick={() => setDeleteId(r.id)}
|
onClick={() => setDeleteId(r.id)}
|
||||||
|
aria-label={t("actions.delete")}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -110,17 +117,17 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[
|
|||||||
<Dialog open={deleteId !== null} onOpenChange={(open) => !open && setDeleteId(null)}>
|
<Dialog open={deleteId !== null} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete Attendance Record</DialogTitle>
|
<DialogTitle>{t("sheet.confirmDelete")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to delete this attendance record? This action cannot be undone.
|
{t("errors.unexpected")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isDeleting}>
|
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isDeleting}>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={handleDelete} disabled={isDeleting}>
|
<Button variant="destructive" onClick={handleDelete} disabled={isDeleting}>
|
||||||
{isDeleting ? "Deleting..." : "Delete"}
|
{isDeleting ? t("actions.delete") + "..." : t("actions.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from "react"
|
|||||||
import { useFormStatus } from "react-dom"
|
import { useFormStatus } from "react-dom"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@@ -25,9 +26,10 @@ type Option = { id: string; name: string }
|
|||||||
|
|
||||||
function SubmitButton() {
|
function SubmitButton() {
|
||||||
const { pending } = useFormStatus()
|
const { pending } = useFormStatus()
|
||||||
|
const t = useTranslations("attendance")
|
||||||
return (
|
return (
|
||||||
<Button type="submit" disabled={pending}>
|
<Button type="submit" disabled={pending}>
|
||||||
{pending ? "Saving..." : "Save Rules"}
|
{pending ? t("actions.save") + "..." : t("rules.saved")}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -40,6 +42,7 @@ export function AttendanceRulesForm({
|
|||||||
existingRules: AttendanceRule[]
|
existingRules: AttendanceRule[]
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const t = useTranslations("attendance")
|
||||||
const [classId, setClassId] = useState(classes[0]?.id ?? "")
|
const [classId, setClassId] = useState(classes[0]?.id ?? "")
|
||||||
const [lateThreshold, setLateThreshold] = useState("15")
|
const [lateThreshold, setLateThreshold] = useState("15")
|
||||||
const [earlyLeaveThreshold, setEarlyLeaveThreshold] = useState("15")
|
const [earlyLeaveThreshold, setEarlyLeaveThreshold] = useState("15")
|
||||||
@@ -61,7 +64,7 @@ export function AttendanceRulesForm({
|
|||||||
|
|
||||||
const handleSubmit = async (formData: FormData) => {
|
const handleSubmit = async (formData: FormData) => {
|
||||||
if (!classId) {
|
if (!classId) {
|
||||||
toast.error("Please select a class")
|
toast.error(t("sheet.selectClass"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
formData.set("classId", classId)
|
formData.set("classId", classId)
|
||||||
@@ -71,25 +74,25 @@ export function AttendanceRulesForm({
|
|||||||
|
|
||||||
const result = await saveAttendanceRulesAction(null, formData)
|
const result = await saveAttendanceRulesAction(null, formData)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message)
|
toast.success(result.message || t("rules.saved"))
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || "Failed to save rules")
|
toast.error(result.message || t("errors.unexpected"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Attendance Rules</CardTitle>
|
<CardTitle>{t("title.rules")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form action={handleSubmit} className="space-y-6">
|
<form action={handleSubmit} className="space-y-6">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Class</Label>
|
<Label htmlFor="rules-class-select">{t("filters.class")}</Label>
|
||||||
<Select value={classId} onValueChange={handleClassChange}>
|
<Select value={classId} onValueChange={handleClassChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="rules-class-select">
|
||||||
<SelectValue placeholder="Select a class" />
|
<SelectValue placeholder={t("sheet.selectClass")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{classes.map((c) => (
|
{classes.map((c) => (
|
||||||
@@ -103,7 +106,7 @@ export function AttendanceRulesForm({
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="lateThresholdMinutes">Late Threshold (minutes)</Label>
|
<Label htmlFor="lateThresholdMinutes">{t("rules.lateThreshold")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="lateThresholdMinutes"
|
id="lateThresholdMinutes"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -113,7 +116,7 @@ export function AttendanceRulesForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="earlyLeaveThresholdMinutes">Early Leave Threshold (minutes)</Label>
|
<Label htmlFor="earlyLeaveThresholdMinutes">{t("rules.earlyLeaveThreshold")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="earlyLeaveThresholdMinutes"
|
id="earlyLeaveThresholdMinutes"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -131,13 +134,13 @@ export function AttendanceRulesForm({
|
|||||||
onCheckedChange={(v) => setEnableAutoMark(v === true)}
|
onCheckedChange={(v) => setEnableAutoMark(v === true)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="enableAutoMark" className="cursor-pointer">
|
<Label htmlFor="enableAutoMark" className="cursor-pointer">
|
||||||
Enable auto-marking (mark present automatically when student checks in on time)
|
{t("rules.enableAutoMark")}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardFooter className="justify-end gap-2 px-0">
|
<CardFooter className="justify-end gap-2 px-0">
|
||||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<SubmitButton />
|
<SubmitButton />
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState, useRef, useEffect, useCallback } from "react"
|
||||||
import { useFormStatus } from "react-dom"
|
import { useFormStatus } from "react-dom"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { CalendarDays } from "lucide-react"
|
import { useTranslations } from "next-intl"
|
||||||
|
import { CalendarDays, Search, CheckCircle2, XCircle, Clock, LogOut, FileText } from "lucide-react"
|
||||||
|
|
||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@@ -25,12 +26,23 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/shared/components/ui/table"
|
} from "@/shared/components/ui/table"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/components/ui/alert-dialog"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
import { batchRecordAttendanceAction } from "../actions"
|
import { batchRecordAttendanceAction } from "../actions"
|
||||||
import {
|
import {
|
||||||
ATTENDANCE_STATUS_LABELS,
|
ATTENDANCE_STATUS_LABEL_KEYS,
|
||||||
type AttendanceStatus,
|
type AttendanceStatus,
|
||||||
} from "../types"
|
} from "../constants"
|
||||||
|
|
||||||
type Option = { id: string; name: string }
|
type Option = { id: string; name: string }
|
||||||
type Student = { id: string; name: string; email: string }
|
type Student = { id: string; name: string; email: string }
|
||||||
@@ -43,14 +55,39 @@ const STATUS_OPTIONS: AttendanceStatus[] = [
|
|||||||
"excused",
|
"excused",
|
||||||
]
|
]
|
||||||
|
|
||||||
const isAttendanceStatus = (v: string): v is AttendanceStatus =>
|
const STATUS_SHORTCUTS: Record<string, AttendanceStatus> = {
|
||||||
v === "present" || v === "absent" || v === "late" || v === "early_leave" || v === "excused"
|
p: "present",
|
||||||
|
a: "absent",
|
||||||
|
l: "late",
|
||||||
|
e: "early_leave",
|
||||||
|
x: "excused",
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<AttendanceStatus, { active: string; icon: typeof CheckCircle2 }> = {
|
||||||
|
present: { active: "bg-emerald-500 text-white border-emerald-500 hover:bg-emerald-600", icon: CheckCircle2 },
|
||||||
|
absent: { active: "bg-red-500 text-white border-red-500 hover:bg-red-600", icon: XCircle },
|
||||||
|
late: { active: "bg-amber-500 text-white border-amber-500 hover:bg-amber-600", icon: Clock },
|
||||||
|
early_leave: { active: "bg-blue-500 text-white border-blue-500 hover:bg-blue-600", icon: LogOut },
|
||||||
|
excused: { active: "bg-purple-500 text-white border-purple-500 hover:bg-purple-600", icon: FileText },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 初始化状态计数,避免 `{} as Record<...>` 类型断言 */
|
||||||
|
function createInitialStatusCounts(): Record<AttendanceStatus, number> {
|
||||||
|
return {
|
||||||
|
present: 0,
|
||||||
|
absent: 0,
|
||||||
|
late: 0,
|
||||||
|
early_leave: 0,
|
||||||
|
excused: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function SubmitButton() {
|
function SubmitButton() {
|
||||||
const { pending } = useFormStatus()
|
const { pending } = useFormStatus()
|
||||||
|
const t = useTranslations("attendance")
|
||||||
return (
|
return (
|
||||||
<Button type="submit" disabled={pending}>
|
<Button type="submit" disabled={pending}>
|
||||||
{pending ? "Saving..." : "Save Attendance"}
|
{pending ? t("actions.save") + "..." : t("actions.save")}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -67,24 +104,94 @@ export function AttendanceSheet({
|
|||||||
defaultDate?: string
|
defaultDate?: string
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const t = useTranslations("attendance")
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
|
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
|
||||||
const [date, setDate] = useState(defaultDate ?? today)
|
const [date, setDate] = useState(defaultDate ?? today)
|
||||||
const [statuses, setStatuses] = useState<Record<string, AttendanceStatus>>({})
|
const [statuses, setStatuses] = useState<Record<string, AttendanceStatus>>({})
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
const [focusedStudentIndex, setFocusedStudentIndex] = useState(0)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [showSwitchConfirm, setShowSwitchConfirm] = useState(false)
|
||||||
|
const [pendingClassId, setPendingClassId] = useState<string | null>(null)
|
||||||
|
const studentRefs = useRef<(HTMLTableRowElement | null)[]>([])
|
||||||
|
|
||||||
const handleStatusChange = (studentId: string, status: AttendanceStatus) => {
|
const handleStatusChange = useCallback((studentId: string, status: AttendanceStatus) => {
|
||||||
setStatuses((prev) => ({ ...prev, [studentId]: status }))
|
setStatuses((prev) => ({ ...prev, [studentId]: status }))
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const markAllPresent = () => {
|
const markAllPresent = useCallback(() => {
|
||||||
const all: Record<string, AttendanceStatus> = {}
|
const all: Record<string, AttendanceStatus> = {}
|
||||||
for (const s of students) all[s.id] = "present"
|
for (const s of students) all[s.id] = "present"
|
||||||
setStatuses(all)
|
setStatuses(all)
|
||||||
|
toast.success(t("actions.markAllPresent"))
|
||||||
|
}, [students, t])
|
||||||
|
|
||||||
|
const handleClassChange = (newClassId: string) => {
|
||||||
|
const hasUnsaved = Object.keys(statuses).length > 0
|
||||||
|
if (hasUnsaved && newClassId !== classId) {
|
||||||
|
setPendingClassId(newClassId)
|
||||||
|
setShowSwitchConfirm(true)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
confirmClassSwitch(newClassId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmClassSwitch = (newClassId: string) => {
|
||||||
|
setClassId(newClassId)
|
||||||
|
setStatuses({})
|
||||||
|
const newUrl = newClassId ? `/teacher/attendance/sheet?classId=${encodeURIComponent(newClassId)}` : "/teacher/attendance/sheet"
|
||||||
|
router.push(newUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredStudents = students.filter(
|
||||||
|
(s) => !searchQuery || s.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const statusCounts = STATUS_OPTIONS.reduce(
|
||||||
|
(acc, st) => {
|
||||||
|
acc[st] = students.filter((s) => (statuses[s.id] ?? "present") === st).length
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
createInitialStatusCounts()
|
||||||
|
)
|
||||||
|
|
||||||
|
// 派生值:当筛选结果变少时,焦点索引自动夹紧到有效范围,避免 useEffect 重置导致的级联渲染
|
||||||
|
const effectiveFocusedIndex = filteredStudents.length === 0
|
||||||
|
? 0
|
||||||
|
: Math.min(focusedStudentIndex, filteredStudents.length - 1)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||||
|
const key = e.key.toLowerCase()
|
||||||
|
if (STATUS_SHORTCUTS[key] && filteredStudents[effectiveFocusedIndex]) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleStatusChange(filteredStudents[effectiveFocusedIndex].id, STATUS_SHORTCUTS[key])
|
||||||
|
if (effectiveFocusedIndex < filteredStudents.length - 1) {
|
||||||
|
setFocusedStudentIndex((prev) => prev + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowDown" && effectiveFocusedIndex < filteredStudents.length - 1) {
|
||||||
|
e.preventDefault()
|
||||||
|
setFocusedStudentIndex((prev) => prev + 1)
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowUp" && effectiveFocusedIndex > 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
setFocusedStudentIndex((prev) => prev - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}, [filteredStudents, effectiveFocusedIndex, handleStatusChange])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
studentRefs.current[effectiveFocusedIndex]?.scrollIntoView({ block: "nearest" })
|
||||||
|
}, [effectiveFocusedIndex])
|
||||||
|
|
||||||
const handleSubmit = async (formData: FormData) => {
|
const handleSubmit = async (formData: FormData) => {
|
||||||
if (!classId || !date) {
|
if (!classId || !date) {
|
||||||
toast.error("Please select class and date")
|
toast.error(t("errors.invalidForm"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,35 +203,48 @@ export function AttendanceSheet({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
toast.error("No students to record attendance for")
|
toast.error(t("sheet.noStudents"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
formData.set("recordsJson", JSON.stringify(records))
|
formData.set("recordsJson", JSON.stringify(records))
|
||||||
|
|
||||||
const result = await batchRecordAttendanceAction(null, formData)
|
const result = await batchRecordAttendanceAction(null, formData)
|
||||||
|
setIsSubmitting(false)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message)
|
toast.success(result.message || t("sheet.saved"))
|
||||||
router.push("/teacher/attendance")
|
router.push("/teacher/attendance")
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || "Failed to save attendance")
|
toast.error(result.message || t("errors.unexpected"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="relative">
|
||||||
|
{isSubmitting && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/60 backdrop-blur-sm">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
{t("sheet.saved")}...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Attendance Sheet</CardTitle>
|
<CardTitle>{t("title.sheet")}</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("description.teacherRecords")}
|
||||||
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form action={handleSubmit} className="space-y-6">
|
<form action={handleSubmit} className="space-y-6">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Class</Label>
|
<Label htmlFor="class-select">{t("filters.class")}</Label>
|
||||||
<Select value={classId} onValueChange={setClassId}>
|
<Select value={classId} onValueChange={handleClassChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="class-select">
|
||||||
<SelectValue placeholder="Select a class" />
|
<SelectValue placeholder={t("sheet.selectClass")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{classes.map((c) => (
|
{classes.map((c) => (
|
||||||
@@ -137,7 +257,7 @@ export function AttendanceSheet({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="date">Date</Label>
|
<Label htmlFor="date">{t("filters.date")}</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<CalendarDays className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<CalendarDays className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -147,6 +267,7 @@ export function AttendanceSheet({
|
|||||||
onChange={(e) => setDate(e.target.value)}
|
onChange={(e) => setDate(e.target.value)}
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
required
|
required
|
||||||
|
aria-label={t("filters.date")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,55 +275,100 @@ export function AttendanceSheet({
|
|||||||
|
|
||||||
{students.length === 0 ? (
|
{students.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No students in this class. Select a class to load students.
|
{t("sheet.noStudents")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{students.length} students
|
{STATUS_OPTIONS.map((st) => {
|
||||||
</p>
|
const Icon = STATUS_STYLES[st].icon
|
||||||
|
return (
|
||||||
|
<span key={st} className="inline-flex items-center gap-1 rounded-md border bg-muted/50 px-2 py-1 text-xs">
|
||||||
|
<Icon className="h-3 w-3" aria-hidden="true" />
|
||||||
|
{t(ATTENDANCE_STATUS_LABEL_KEYS[st])}
|
||||||
|
<span className="font-semibold tabular-nums">{statusCounts[st]}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder={t("list.columns.student")}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="h-8 w-40 pl-8 text-sm"
|
||||||
|
aria-label={t("list.columns.student")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={markAllPresent}>
|
<Button type="button" variant="outline" size="sm" onClick={markAllPresent}>
|
||||||
Mark All Present
|
{t("actions.markAllPresent")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Student</TableHead>
|
<TableHead className="w-12">#</TableHead>
|
||||||
<TableHead>Email</TableHead>
|
<TableHead>{t("list.columns.student")}</TableHead>
|
||||||
<TableHead className="w-48">Status</TableHead>
|
<TableHead className="hidden md:table-cell">{t("list.columns.remark")}</TableHead>
|
||||||
|
<TableHead>{t("list.columns.status")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{students.map((s) => (
|
{filteredStudents.map((s, idx) => {
|
||||||
<TableRow key={s.id}>
|
const currentStatus = statuses[s.id] ?? "present"
|
||||||
<TableCell className="font-medium">{s.name}</TableCell>
|
const isFocused = idx === effectiveFocusedIndex
|
||||||
<TableCell className="text-muted-foreground">{s.email}</TableCell>
|
return (
|
||||||
<TableCell>
|
<TableRow
|
||||||
<Select
|
key={s.id}
|
||||||
value={statuses[s.id] ?? "present"}
|
ref={(el) => { studentRefs.current[idx] = el }}
|
||||||
onValueChange={(v) => {
|
className={cn("cursor-pointer", isFocused && "bg-primary/5")}
|
||||||
if (isAttendanceStatus(v)) {
|
onClick={() => setFocusedStudentIndex(idx)}
|
||||||
handleStatusChange(s.id, v)
|
role="button"
|
||||||
}
|
tabIndex={isFocused ? 0 : -1}
|
||||||
}}
|
aria-label={s.name}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8">
|
<TableCell className="text-muted-foreground tabular-nums">{idx + 1}</TableCell>
|
||||||
<SelectValue />
|
<TableCell className="font-medium">{s.name}</TableCell>
|
||||||
</SelectTrigger>
|
<TableCell className="hidden text-muted-foreground md:table-cell">{s.email}</TableCell>
|
||||||
<SelectContent>
|
<TableCell>
|
||||||
{STATUS_OPTIONS.map((st) => (
|
<div className="flex flex-wrap gap-1">
|
||||||
<SelectItem key={st} value={st}>
|
{STATUS_OPTIONS.map((st) => {
|
||||||
{ATTENDANCE_STATUS_LABELS[st]}
|
const Icon = STATUS_STYLES[st].icon
|
||||||
</SelectItem>
|
const isActive = currentStatus === st
|
||||||
))}
|
return (
|
||||||
</SelectContent>
|
<button
|
||||||
</Select>
|
key={st}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleStatusChange(s.id, st)
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? STATUS_STYLES[st].active
|
||||||
|
: "border-border bg-background text-muted-foreground hover:bg-muted"
|
||||||
|
)}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
aria-label={`${t(ATTENDANCE_STATUS_LABEL_KEYS[st])} (${st[0].toUpperCase()})`}
|
||||||
|
>
|
||||||
|
<Icon className="h-3 w-3" aria-hidden="true" />
|
||||||
|
<span className="hidden sm:inline">{t(ATTENDANCE_STATUS_LABEL_KEYS[st])}</span>
|
||||||
|
<span className="sm:hidden">{st[0].toUpperCase()}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,12 +377,37 @@ export function AttendanceSheet({
|
|||||||
|
|
||||||
<CardFooter className="justify-end gap-2 px-0">
|
<CardFooter className="justify-end gap-2 px-0">
|
||||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<SubmitButton />
|
<SubmitButton />
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
|
<AlertDialog open={showSwitchConfirm} onOpenChange={setShowSwitchConfirm}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("sheet.confirmDelete")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("description.teacherRecords")}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("actions.cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
if (pendingClassId) {
|
||||||
|
confirmClassSwitch(pendingClassId)
|
||||||
|
setPendingClassId(null)
|
||||||
|
}
|
||||||
|
setShowSwitchConfirm(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("actions.save")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { StatItem } from "@/shared/components/ui/stat-item"
|
import { StatItem } from "@/shared/components/ui/stat-item"
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
@@ -8,71 +10,70 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
FileText,
|
FileText,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
|
BarChart3,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import type { AttendanceStats } from "../types"
|
import type { AttendanceStats } from "../types"
|
||||||
|
|
||||||
export function AttendanceStatsCard({ stats }: { stats: AttendanceStats | null }) {
|
export function AttendanceStatsCard({ stats }: { stats: AttendanceStats | null }) {
|
||||||
|
const t = useTranslations("attendance")
|
||||||
|
|
||||||
if (!stats || stats.total === 0) {
|
if (!stats || stats.total === 0) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<EmptyState
|
||||||
<CardHeader>
|
title={t("stats.noData")}
|
||||||
<CardTitle>Attendance Statistics</CardTitle>
|
description={t("stats.noDataDescription")}
|
||||||
</CardHeader>
|
icon={BarChart3}
|
||||||
<CardContent>
|
className="border-none shadow-none"
|
||||||
<p className="text-sm text-muted-foreground">No attendance data available.</p>
|
/>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Attendance Statistics</CardTitle>
|
<CardTitle>{t("title.teacherStats")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<StatItem
|
<StatItem
|
||||||
label="Total Records"
|
label={t("stats.totalRecords")}
|
||||||
value={stats.total}
|
value={stats.total}
|
||||||
icon={<Users className="h-4 w-4" />}
|
icon={<Users className="h-4 w-4" />}
|
||||||
/>
|
/>
|
||||||
<StatItem
|
<StatItem
|
||||||
label="Present"
|
label={t("stats.present")}
|
||||||
value={stats.present}
|
value={stats.present}
|
||||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||||
/>
|
/>
|
||||||
<StatItem
|
<StatItem
|
||||||
label="Absent"
|
label={t("stats.absent")}
|
||||||
value={stats.absent}
|
value={stats.absent}
|
||||||
icon={<XCircle className="h-4 w-4" />}
|
icon={<XCircle className="h-4 w-4" />}
|
||||||
/>
|
/>
|
||||||
<StatItem
|
<StatItem
|
||||||
label="Late"
|
label={t("stats.late")}
|
||||||
value={stats.late}
|
value={stats.late}
|
||||||
icon={<Clock className="h-4 w-4" />}
|
icon={<Clock className="h-4 w-4" />}
|
||||||
/>
|
/>
|
||||||
<StatItem
|
<StatItem
|
||||||
label="Early Leave"
|
label={t("stats.earlyLeave")}
|
||||||
value={stats.earlyLeave}
|
value={stats.earlyLeave}
|
||||||
icon={<LogOut className="h-4 w-4" />}
|
icon={<LogOut className="h-4 w-4" />}
|
||||||
/>
|
/>
|
||||||
<StatItem
|
<StatItem
|
||||||
label="Excused"
|
label={t("stats.excused")}
|
||||||
value={stats.excused}
|
value={stats.excused}
|
||||||
icon={<FileText className="h-4 w-4" />}
|
icon={<FileText className="h-4 w-4" />}
|
||||||
/>
|
/>
|
||||||
<StatItem
|
<StatItem
|
||||||
label="Present Rate"
|
label={t("stats.attendanceRate")}
|
||||||
value={`${stats.presentRate.toFixed(1)}%`}
|
value={`${stats.presentRate.toFixed(1)}%`}
|
||||||
icon={<TrendingUp className="h-4 w-4" />}
|
icon={<TrendingUp className="h-4 w-4" />}
|
||||||
hint="Present / Total"
|
|
||||||
/>
|
/>
|
||||||
<StatItem
|
<StatItem
|
||||||
label="Late Rate"
|
label={t("stats.lateRate")}
|
||||||
value={`${stats.lateRate.toFixed(1)}%`}
|
value={`${stats.lateRate.toFixed(1)}%`}
|
||||||
icon={<Clock className="h-4 w-4" />}
|
icon={<Clock className="h-4 w-4" />}
|
||||||
hint="Late / Total"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import {
|
import {
|
||||||
@@ -13,21 +14,23 @@ import { CalendarCheck } from "lucide-react"
|
|||||||
|
|
||||||
import { AttendanceStatsCard } from "./attendance-stats-card"
|
import { AttendanceStatsCard } from "./attendance-stats-card"
|
||||||
import {
|
import {
|
||||||
ATTENDANCE_STATUS_COLORS,
|
ATTENDANCE_STATUS_BADGE_VARIANTS,
|
||||||
ATTENDANCE_STATUS_LABELS,
|
ATTENDANCE_STATUS_LABEL_KEYS,
|
||||||
type StudentAttendanceSummary,
|
} from "../constants"
|
||||||
} from "../types"
|
import type { StudentAttendanceSummary } from "../types"
|
||||||
|
|
||||||
export function StudentAttendanceView({
|
export function StudentAttendanceView({
|
||||||
summary,
|
summary,
|
||||||
}: {
|
}: {
|
||||||
summary: StudentAttendanceSummary | null
|
summary: StudentAttendanceSummary | null
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("attendance")
|
||||||
|
|
||||||
if (!summary) {
|
if (!summary) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No data"
|
title={t("list.empty")}
|
||||||
description="Student attendance summary is not available."
|
description={t("errors.notFound")}
|
||||||
icon={CalendarCheck}
|
icon={CalendarCheck}
|
||||||
className="border-none shadow-none"
|
className="border-none shadow-none"
|
||||||
/>
|
/>
|
||||||
@@ -39,7 +42,9 @@ export function StudentAttendanceView({
|
|||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Student</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("list.columns.student")}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-2xl font-bold">{summary.studentName}</p>
|
<p className="text-2xl font-bold">{summary.studentName}</p>
|
||||||
@@ -47,7 +52,9 @@ export function StudentAttendanceView({
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Records</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("stats.totalRecords")}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-2xl font-bold">{summary.stats.total}</p>
|
<p className="text-2xl font-bold">{summary.stats.total}</p>
|
||||||
@@ -59,25 +66,25 @@ export function StudentAttendanceView({
|
|||||||
|
|
||||||
{summary.recentRecords.length === 0 ? (
|
{summary.recentRecords.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No attendance records"
|
title={t("list.empty")}
|
||||||
description="There are no attendance records for this student yet."
|
description={t("list.emptyDescription")}
|
||||||
icon={CalendarCheck}
|
icon={CalendarCheck}
|
||||||
className="border-none shadow-none"
|
className="border-none shadow-none"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Recent Attendance</CardTitle>
|
<CardTitle>{t("stats.recentRecords")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Date</TableHead>
|
<TableHead>{t("list.columns.date")}</TableHead>
|
||||||
<TableHead>Class</TableHead>
|
<TableHead>{t("list.columns.class")}</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>{t("list.columns.status")}</TableHead>
|
||||||
<TableHead>Remark</TableHead>
|
<TableHead>{t("list.columns.remark")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -86,8 +93,8 @@ export function StudentAttendanceView({
|
|||||||
<TableCell className="font-medium">{r.date}</TableCell>
|
<TableCell className="font-medium">{r.date}</TableCell>
|
||||||
<TableCell>{r.className}</TableCell>
|
<TableCell>{r.className}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={ATTENDANCE_STATUS_COLORS[r.status]} className="capitalize">
|
<Badge variant={ATTENDANCE_STATUS_BADGE_VARIANTS[r.status]} className="capitalize">
|
||||||
{ATTENDANCE_STATUS_LABELS[r.status]}
|
{t(ATTENDANCE_STATUS_LABEL_KEYS[r.status])}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{r.remark ?? "-"}</TableCell>
|
<TableCell className="text-muted-foreground">{r.remark ?? "-"}</TableCell>
|
||||||
|
|||||||
65
src/modules/attendance/constants.ts
Normal file
65
src/modules/attendance/constants.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* 考勤模块共享常量(消除 types.ts / attendance-sheet.tsx / attendance-filters.tsx / parent-attendance-calendar.tsx 重复定义)。
|
||||||
|
*
|
||||||
|
* 注意:标签使用 i18n key(`status.*`),由组件层通过 `useTranslations("attendance")` 解析。
|
||||||
|
*/
|
||||||
|
import type { AttendanceStatus } from "./types"
|
||||||
|
|
||||||
|
/** 重导出类型,便于组件层从 constants 单点导入(消除 types/constants 双源混乱) */
|
||||||
|
export type { AttendanceStatus } from "./types"
|
||||||
|
|
||||||
|
/** 考勤状态选项(顺序即 UI 展示顺序) */
|
||||||
|
export const ATTENDANCE_STATUS_OPTIONS: AttendanceStatus[] = [
|
||||||
|
"present",
|
||||||
|
"absent",
|
||||||
|
"late",
|
||||||
|
"early_leave",
|
||||||
|
"excused",
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 考勤状态 → i18n key 映射(组件层 `t(key)` 解析) */
|
||||||
|
export const ATTENDANCE_STATUS_LABEL_KEYS: Record<AttendanceStatus, string> = {
|
||||||
|
present: "status.present",
|
||||||
|
absent: "status.absent",
|
||||||
|
late: "status.late",
|
||||||
|
early_leave: "status.early_leave",
|
||||||
|
excused: "status.excused",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 考勤状态 → Badge variant 映射 */
|
||||||
|
export const ATTENDANCE_STATUS_BADGE_VARIANTS: Record<AttendanceStatus, "default" | "secondary" | "destructive" | "outline"> = {
|
||||||
|
present: "default",
|
||||||
|
absent: "destructive",
|
||||||
|
late: "secondary",
|
||||||
|
early_leave: "outline",
|
||||||
|
excused: "outline",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 考勤状态 → Tailwind 圆点颜色类 */
|
||||||
|
export const ATTENDANCE_STATUS_DOT_COLORS: Record<AttendanceStatus, string> = {
|
||||||
|
present: "bg-green-500",
|
||||||
|
absent: "bg-red-500",
|
||||||
|
late: "bg-yellow-500",
|
||||||
|
early_leave: "bg-blue-500",
|
||||||
|
excused: "bg-purple-500",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 键盘快捷键映射 */
|
||||||
|
export const ATTENDANCE_STATUS_SHORTCUTS: Record<string, AttendanceStatus> = {
|
||||||
|
p: "present",
|
||||||
|
a: "absent",
|
||||||
|
l: "late",
|
||||||
|
e: "early_leave",
|
||||||
|
x: "excused",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 初始化状态计数,避免 `{} as Record<...>` 类型断言 */
|
||||||
|
export function createInitialStatusCounts(): Record<AttendanceStatus, number> {
|
||||||
|
return {
|
||||||
|
present: 0,
|
||||||
|
absent: 0,
|
||||||
|
late: 0,
|
||||||
|
early_leave: 0,
|
||||||
|
excused: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,10 @@ const EMPTY_STATS: AttendanceStats = {
|
|||||||
lateRate: 0,
|
lateRate: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
const computeStats = (rows: { status: string }[]): AttendanceStats => {
|
/**
|
||||||
|
* 根据考勤记录行计算统计(纯函数,便于测试)。
|
||||||
|
*/
|
||||||
|
export const computeStats = (rows: { status: string }[]): AttendanceStats => {
|
||||||
if (rows.length === 0) return EMPTY_STATS
|
if (rows.length === 0) return EMPTY_STATS
|
||||||
const stats: AttendanceStats = { ...EMPTY_STATS, total: rows.length }
|
const stats: AttendanceStats = { ...EMPTY_STATS, total: rows.length }
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
|
|||||||
@@ -83,21 +83,7 @@ export interface PaginatedAttendanceResult {
|
|||||||
totalPages: number
|
totalPages: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ATTENDANCE_STATUS_LABELS: Record<AttendanceStatus, string> = {
|
/**
|
||||||
present: "Present",
|
* 注意:状态标签与颜色映射已迁移至 `./constants.ts`,
|
||||||
absent: "Absent",
|
* 使用 i18n key(`status.*`)+ Badge variant,由组件层通过 `useTranslations("attendance")` 解析。
|
||||||
late: "Late",
|
*/
|
||||||
early_leave: "Early Leave",
|
|
||||||
excused: "Excused",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ATTENDANCE_STATUS_COLORS: Record<
|
|
||||||
AttendanceStatus,
|
|
||||||
"default" | "secondary" | "destructive" | "outline"
|
|
||||||
> = {
|
|
||||||
present: "default",
|
|
||||||
absent: "destructive",
|
|
||||||
late: "secondary",
|
|
||||||
early_leave: "outline",
|
|
||||||
excused: "outline",
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"
|
|||||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
import { trackEvent } from "@/shared/lib/track-event"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CreateElectiveCourseSchema,
|
CreateElectiveCourseSchema,
|
||||||
@@ -13,7 +14,6 @@ import {
|
|||||||
RunLotterySchema,
|
RunLotterySchema,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
import {
|
import {
|
||||||
getElectiveCourses,
|
|
||||||
getElectiveCourseById,
|
getElectiveCourseById,
|
||||||
createElectiveCourse,
|
createElectiveCourse,
|
||||||
updateElectiveCourse,
|
updateElectiveCourse,
|
||||||
@@ -22,15 +22,6 @@ import {
|
|||||||
closeSelection,
|
closeSelection,
|
||||||
} from "./data-access"
|
} from "./data-access"
|
||||||
import { runLottery, selectCourse, dropCourse } from "./data-access-operations"
|
import { runLottery, selectCourse, dropCourse } from "./data-access-operations"
|
||||||
import {
|
|
||||||
getStudentSelections,
|
|
||||||
getAvailableCoursesForStudent,
|
|
||||||
} from "./data-access-selections"
|
|
||||||
import type {
|
|
||||||
ElectiveCourseWithDetails,
|
|
||||||
CourseSelectionWithDetails,
|
|
||||||
GetElectiveCoursesParams,
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
const handleError = (e: unknown): ActionState<never> => {
|
const handleError = (e: unknown): ActionState<never> => {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
@@ -103,6 +94,13 @@ export async function createElectiveCourseAction(
|
|||||||
}
|
}
|
||||||
const id = await createElectiveCourse(parsed.data, ctx.userId)
|
const id = await createElectiveCourse(parsed.data, ctx.userId)
|
||||||
revalidateElectivePaths(id)
|
revalidateElectivePaths(id)
|
||||||
|
await trackEvent({
|
||||||
|
event: "elective.course_created",
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: id,
|
||||||
|
targetType: "elective_course",
|
||||||
|
properties: { capacity: parsed.data.capacity, selectionMode: parsed.data.selectionMode },
|
||||||
|
})
|
||||||
return { success: true, message: "Elective course created", data: id }
|
return { success: true, message: "Elective course created", data: id }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleError(e)
|
return handleError(e)
|
||||||
@@ -148,6 +146,12 @@ export async function updateElectiveCourseAction(
|
|||||||
}
|
}
|
||||||
await updateElectiveCourse(id, parsed.data)
|
await updateElectiveCourse(id, parsed.data)
|
||||||
revalidateElectivePaths(id)
|
revalidateElectivePaths(id)
|
||||||
|
await trackEvent({
|
||||||
|
event: "elective.course_updated",
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: id,
|
||||||
|
targetType: "elective_course",
|
||||||
|
})
|
||||||
return { success: true, message: "Elective course updated", data: id }
|
return { success: true, message: "Elective course updated", data: id }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleError(e)
|
return handleError(e)
|
||||||
@@ -169,6 +173,12 @@ export async function deleteElectiveCourseAction(
|
|||||||
|
|
||||||
await deleteElectiveCourse(id)
|
await deleteElectiveCourse(id)
|
||||||
revalidateElectivePaths()
|
revalidateElectivePaths()
|
||||||
|
await trackEvent({
|
||||||
|
event: "elective.course_deleted",
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: id,
|
||||||
|
targetType: "elective_course",
|
||||||
|
})
|
||||||
return { success: true, message: "Elective course deleted" }
|
return { success: true, message: "Elective course deleted" }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleError(e)
|
return handleError(e)
|
||||||
@@ -190,6 +200,12 @@ export async function openSelectionAction(
|
|||||||
|
|
||||||
await openSelection(courseId)
|
await openSelection(courseId)
|
||||||
revalidateElectivePaths(courseId)
|
revalidateElectivePaths(courseId)
|
||||||
|
await trackEvent({
|
||||||
|
event: "elective.selection_opened",
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: courseId,
|
||||||
|
targetType: "elective_course",
|
||||||
|
})
|
||||||
return { success: true, message: "Selection opened" }
|
return { success: true, message: "Selection opened" }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleError(e)
|
return handleError(e)
|
||||||
@@ -211,6 +227,12 @@ export async function closeSelectionAction(
|
|||||||
|
|
||||||
await closeSelection(courseId)
|
await closeSelection(courseId)
|
||||||
revalidateElectivePaths(courseId)
|
revalidateElectivePaths(courseId)
|
||||||
|
await trackEvent({
|
||||||
|
event: "elective.selection_closed",
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: courseId,
|
||||||
|
targetType: "elective_course",
|
||||||
|
})
|
||||||
return { success: true, message: "Selection closed" }
|
return { success: true, message: "Selection closed" }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleError(e)
|
return handleError(e)
|
||||||
@@ -241,6 +263,13 @@ export async function runLotteryAction(
|
|||||||
|
|
||||||
const result = await runLottery(parsed.data.courseId)
|
const result = await runLottery(parsed.data.courseId)
|
||||||
revalidateElectivePaths(parsed.data.courseId)
|
revalidateElectivePaths(parsed.data.courseId)
|
||||||
|
await trackEvent({
|
||||||
|
event: "elective.lottery_completed",
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: parsed.data.courseId,
|
||||||
|
targetType: "elective_course",
|
||||||
|
properties: { enrolled: result.enrolled, waitlist: result.waitlist },
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Lottery completed: ${result.enrolled} enrolled, ${result.waitlist} waitlisted`,
|
message: `Lottery completed: ${result.enrolled} enrolled, ${result.waitlist} waitlisted`,
|
||||||
@@ -270,6 +299,13 @@ export async function selectCourseAction(
|
|||||||
}
|
}
|
||||||
const result = await selectCourse(parsed.data.courseId, ctx.userId, parsed.data.priority)
|
const result = await selectCourse(parsed.data.courseId, ctx.userId, parsed.data.priority)
|
||||||
revalidateElectivePaths(parsed.data.courseId)
|
revalidateElectivePaths(parsed.data.courseId)
|
||||||
|
await trackEvent({
|
||||||
|
event: "elective.course_selected",
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: parsed.data.courseId,
|
||||||
|
targetType: "course_selection",
|
||||||
|
properties: { status: result.status, priority: parsed.data.priority },
|
||||||
|
})
|
||||||
return { success: true, message: result.message, data: result.status }
|
return { success: true, message: result.message, data: result.status }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleError(e)
|
return handleError(e)
|
||||||
@@ -294,52 +330,14 @@ export async function dropCourseAction(
|
|||||||
}
|
}
|
||||||
await dropCourse(parsed.data.courseId, ctx.userId)
|
await dropCourse(parsed.data.courseId, ctx.userId)
|
||||||
revalidateElectivePaths(parsed.data.courseId)
|
revalidateElectivePaths(parsed.data.courseId)
|
||||||
|
await trackEvent({
|
||||||
|
event: "elective.course_dropped",
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: parsed.data.courseId,
|
||||||
|
targetType: "course_selection",
|
||||||
|
})
|
||||||
return { success: true, message: "Course dropped" }
|
return { success: true, message: "Course dropped" }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleError(e)
|
return handleError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getElectiveCoursesAction(
|
|
||||||
params?: GetElectiveCoursesParams
|
|
||||||
): Promise<ActionState<ElectiveCourseWithDetails[]>> {
|
|
||||||
try {
|
|
||||||
const ctx = await requirePermission(Permissions.ELECTIVE_READ)
|
|
||||||
const data = await getElectiveCourses({
|
|
||||||
...params,
|
|
||||||
scope: ctx.dataScope,
|
|
||||||
currentUserId: ctx.userId,
|
|
||||||
})
|
|
||||||
return { success: true, data }
|
|
||||||
} catch (e) {
|
|
||||||
return handleError(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getStudentSelectionsAction(
|
|
||||||
studentId: string
|
|
||||||
): Promise<ActionState<CourseSelectionWithDetails[]>> {
|
|
||||||
try {
|
|
||||||
const ctx = await requirePermission(Permissions.ELECTIVE_READ)
|
|
||||||
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
|
|
||||||
return { success: false, message: "Can only view your own selections" }
|
|
||||||
}
|
|
||||||
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
|
|
||||||
return { success: false, message: "Can only view your children's selections" }
|
|
||||||
}
|
|
||||||
const data = await getStudentSelections(studentId)
|
|
||||||
return { success: true, data }
|
|
||||||
} catch (e) {
|
|
||||||
return handleError(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAvailableCoursesAction(): Promise<ActionState<ElectiveCourseWithDetails[]>> {
|
|
||||||
try {
|
|
||||||
const ctx = await requirePermission(Permissions.ELECTIVE_SELECT)
|
|
||||||
const data = await getAvailableCoursesForStudent(ctx.userId)
|
|
||||||
return { success: true, data }
|
|
||||||
} catch (e) {
|
|
||||||
return handleError(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
|
|
||||||
import { createElectiveCourseAction, updateElectiveCourseAction } from "../actions"
|
import { createElectiveCourseAction, updateElectiveCourseAction } from "../actions"
|
||||||
|
import { isSelectionMode } from "../constants"
|
||||||
import type { ElectiveCourseWithDetails, ElectiveSelectionMode } from "../types"
|
import type { ElectiveCourseWithDetails, ElectiveSelectionMode } from "../types"
|
||||||
|
|
||||||
type Mode = "create" | "edit"
|
type Mode = "create" | "edit"
|
||||||
@@ -28,9 +29,6 @@ interface Option {
|
|||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSelectionMode = (v: string): v is ElectiveSelectionMode =>
|
|
||||||
v === "fcfs" || v === "lottery"
|
|
||||||
|
|
||||||
export function ElectiveCourseForm({
|
export function ElectiveCourseForm({
|
||||||
mode,
|
mode,
|
||||||
course,
|
course,
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ import type { ActionState } from "@/shared/types/action-state"
|
|||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ELECTIVE_STATUS_COLORS,
|
ELECTIVE_STATUS_BADGE_VARIANTS,
|
||||||
ELECTIVE_STATUS_LABELS,
|
ELECTIVE_STATUS_LABEL_KEYS,
|
||||||
SELECTION_MODE_LABELS,
|
SELECTION_MODE_LABEL_KEYS,
|
||||||
} from "../types"
|
} from "../constants"
|
||||||
import type { ElectiveCourseWithDetails } from "../types"
|
import type { ElectiveCourseWithDetails } from "../types"
|
||||||
import {
|
import {
|
||||||
deleteElectiveCourseAction,
|
deleteElectiveCourseAction,
|
||||||
@@ -59,7 +59,7 @@ export function ElectiveCourseList({
|
|||||||
toast.success(res.message ?? successMsg)
|
toast.success(res.message ?? successMsg)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? "Operation failed")
|
toast.error(res.message ?? t("errors.unexpected"))
|
||||||
}
|
}
|
||||||
setPendingId(null)
|
setPendingId(null)
|
||||||
})
|
})
|
||||||
@@ -72,10 +72,10 @@ export function ElectiveCourseList({
|
|||||||
formData.set("courseId", courseId)
|
formData.set("courseId", courseId)
|
||||||
const res = await deleteElectiveCourseAction(null, formData)
|
const res = await deleteElectiveCourseAction(null, formData)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message ?? "Course deleted")
|
toast.success(res.message ?? t("actions.delete"))
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? "Delete failed")
|
toast.error(res.message ?? t("errors.unexpected"))
|
||||||
}
|
}
|
||||||
setPendingId(null)
|
setPendingId(null)
|
||||||
})
|
})
|
||||||
@@ -113,8 +113,8 @@ export function ElectiveCourseList({
|
|||||||
<Card key={course.id} className="flex h-full flex-col">
|
<Card key={course.id} className="flex h-full flex-col">
|
||||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||||
<CardTitle className="line-clamp-2 text-base">{course.name}</CardTitle>
|
<CardTitle className="line-clamp-2 text-base">{course.name}</CardTitle>
|
||||||
<Badge variant={ELECTIVE_STATUS_COLORS[course.status]} className="shrink-0">
|
<Badge variant={ELECTIVE_STATUS_BADGE_VARIANTS[course.status]} className="shrink-0">
|
||||||
{ELECTIVE_STATUS_LABELS[course.status]}
|
{t(ELECTIVE_STATUS_LABEL_KEYS[course.status])}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-1 flex-col gap-3">
|
<CardContent className="flex flex-1 flex-col gap-3">
|
||||||
@@ -125,7 +125,7 @@ export function ElectiveCourseList({
|
|||||||
{course.gradeName ? (
|
{course.gradeName ? (
|
||||||
<Badge variant="outline">{course.gradeName}</Badge>
|
<Badge variant="outline">{course.gradeName}</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
<span>Credit: {course.credit}</span>
|
<span>{t("fields.credit")}: {course.credit}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{course.description ? (
|
{course.description ? (
|
||||||
@@ -136,25 +136,25 @@ export function ElectiveCourseList({
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Teacher:</span>{" "}
|
<span className="text-muted-foreground">{t("fields.teacher")}:</span>{" "}
|
||||||
<span className="font-medium">{course.teacherName ?? "—"}</span>
|
<span className="font-medium">{course.teacherName ?? "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Mode:</span>{" "}
|
<span className="text-muted-foreground">{t("fields.selectionMode")}:</span>{" "}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{SELECTION_MODE_LABELS[course.selectionMode]}
|
{t(SELECTION_MODE_LABEL_KEYS[course.selectionMode])}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Capacity:</span>{" "}
|
<span className="text-muted-foreground">{t("fields.capacity")}:</span>{" "}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{course.enrolledCount}/{course.capacity}
|
{course.enrolledCount}/{course.capacity}
|
||||||
{isFull ? " (Full)" : ""}
|
{isFull ? ` (${t("student.capacityFull")})` : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{course.classroom ? (
|
{course.classroom ? (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Room:</span>{" "}
|
<span className="text-muted-foreground">{t("fields.classroom")}:</span>{" "}
|
||||||
<span className="font-medium">{course.classroom}</span>
|
<span className="font-medium">{course.classroom}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -162,7 +162,7 @@ export function ElectiveCourseList({
|
|||||||
|
|
||||||
{course.schedule ? (
|
{course.schedule ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
<span className="font-medium">Schedule:</span> {course.schedule}
|
<span className="font-medium">{t("fields.schedule")}:</span> {course.schedule}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ export function ElectiveCourseList({
|
|||||||
>
|
>
|
||||||
<a href={`${editBaseHref}/${course.id}/edit`}>
|
<a href={`${editBaseHref}/${course.id}/edit`}>
|
||||||
<Pencil className="mr-1 h-3 w-3" />
|
<Pencil className="mr-1 h-3 w-3" />
|
||||||
Edit
|
{t("actions.edit")}
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -185,10 +185,10 @@ export function ElectiveCourseList({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isPendingThis}
|
disabled={isPendingThis}
|
||||||
onClick={() => runAction(openSelectionAction, course.id, "Selection opened")}
|
onClick={() => runAction(openSelectionAction, course.id, t("actions.openSelection"))}
|
||||||
>
|
>
|
||||||
<Unlock className="mr-1 h-3 w-3" />
|
<Unlock className="mr-1 h-3 w-3" />
|
||||||
Open
|
{t("actions.openSelection")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{course.status === "open" ? (
|
{course.status === "open" ? (
|
||||||
@@ -196,10 +196,10 @@ export function ElectiveCourseList({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isPendingThis}
|
disabled={isPendingThis}
|
||||||
onClick={() => runAction(closeSelectionAction, course.id, "Selection closed")}
|
onClick={() => runAction(closeSelectionAction, course.id, t("actions.closeSelection"))}
|
||||||
>
|
>
|
||||||
<Lock className="mr-1 h-3 w-3" />
|
<Lock className="mr-1 h-3 w-3" />
|
||||||
Close
|
{t("actions.closeSelection")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{course.selectionMode === "lottery" && course.status !== "draft" ? (
|
{course.selectionMode === "lottery" && course.status !== "draft" ? (
|
||||||
@@ -207,10 +207,10 @@ export function ElectiveCourseList({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isPendingThis}
|
disabled={isPendingThis}
|
||||||
onClick={() => runAction(runLotteryAction, course.id, "Lottery completed")}
|
onClick={() => runAction(runLotteryAction, course.id, t("actions.runLottery"))}
|
||||||
>
|
>
|
||||||
<Shuffle className="mr-1 h-3 w-3" />
|
<Shuffle className="mr-1 h-3 w-3" />
|
||||||
Lottery
|
{t("actions.runLottery")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
@@ -221,7 +221,7 @@ export function ElectiveCourseList({
|
|||||||
onClick={() => handleDelete(course.id)}
|
onClick={() => handleDelete(course.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-1 h-3 w-3" />
|
<Trash2 className="mr-1 h-3 w-3" />
|
||||||
Delete
|
{t("actions.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
||||||
|
|
||||||
export function ElectiveFilters() {
|
export function ElectiveFilters() {
|
||||||
|
const t = useTranslations("elective")
|
||||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||||
const [mode, setMode] = useQueryState("mode", parseAsString.withDefault("all"))
|
const [mode, setMode] = useQueryState("mode", parseAsString.withDefault("all"))
|
||||||
|
|
||||||
@@ -29,18 +31,18 @@ export function ElectiveFilters() {
|
|||||||
<FilterSearchInput
|
<FilterSearchInput
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(v) => setSearch(v || null)}
|
onChange={(v) => setSearch(v || null)}
|
||||||
placeholder="Search by course name, teacher..."
|
placeholder={t("form.namePlaceholder")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
||||||
<Select value={mode} onValueChange={(val) => setMode(val === "all" ? null : val)}>
|
<Select value={mode} onValueChange={(val) => setMode(val === "all" ? null : val)}>
|
||||||
<SelectTrigger className="w-[160px] bg-background border-muted-foreground/20">
|
<SelectTrigger className="w-[160px] bg-background border-muted-foreground/20" aria-label={t("fields.selectionMode")}>
|
||||||
<SelectValue placeholder="Selection Mode" />
|
<SelectValue placeholder={t("fields.selectionMode")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Modes</SelectItem>
|
<SelectItem value="all">{t("filters.allStatuses")}</SelectItem>
|
||||||
<SelectItem value="fcfs">First Come First Served</SelectItem>
|
<SelectItem value="fcfs">{t("selectionMode.fcfs")}</SelectItem>
|
||||||
<SelectItem value="lottery">Lottery</SelectItem>
|
<SelectItem value="lottery">{t("selectionMode.lottery")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,19 +3,32 @@
|
|||||||
import { useState, useTransition } from "react"
|
import { useState, useTransition } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { BookOpen, CheckCircle2, XCircle } from "lucide-react"
|
import { BookOpen, CheckCircle2, XCircle } from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/shared/components/ui/alert-dialog"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
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"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
COURSE_SELECTION_STATUS_COLORS,
|
COURSE_SELECTION_STATUS_BADGE_VARIANTS,
|
||||||
COURSE_SELECTION_STATUS_LABELS,
|
COURSE_SELECTION_STATUS_LABEL_KEYS,
|
||||||
ELECTIVE_STATUS_LABELS,
|
ELECTIVE_STATUS_BADGE_VARIANTS,
|
||||||
SELECTION_MODE_LABELS,
|
ELECTIVE_STATUS_LABEL_KEYS,
|
||||||
} from "../types"
|
SELECTION_MODE_LABEL_KEYS,
|
||||||
|
} from "../constants"
|
||||||
import type {
|
import type {
|
||||||
CourseSelectionWithDetails,
|
CourseSelectionWithDetails,
|
||||||
ElectiveCourseWithDetails,
|
ElectiveCourseWithDetails,
|
||||||
@@ -30,6 +43,7 @@ export function StudentSelectionView({
|
|||||||
mySelections: CourseSelectionWithDetails[]
|
mySelections: CourseSelectionWithDetails[]
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const t = useTranslations("elective")
|
||||||
const [pendingId, setPendingId] = useState<string | null>(null)
|
const [pendingId, setPendingId] = useState<string | null>(null)
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
@@ -47,10 +61,10 @@ export function StudentSelectionView({
|
|||||||
formData.set("courseId", courseId)
|
formData.set("courseId", courseId)
|
||||||
const res = await selectCourseAction(null, formData)
|
const res = await selectCourseAction(null, formData)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message)
|
toast.success(res.message || t("student.selectSuccess"))
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? "Failed to select course")
|
toast.error(res.message ?? t("errors.unexpected"))
|
||||||
}
|
}
|
||||||
setPendingId(null)
|
setPendingId(null)
|
||||||
})
|
})
|
||||||
@@ -63,10 +77,10 @@ export function StudentSelectionView({
|
|||||||
formData.set("courseId", courseId)
|
formData.set("courseId", courseId)
|
||||||
const res = await dropCourseAction(null, formData)
|
const res = await dropCourseAction(null, formData)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message)
|
toast.success(res.message || t("student.dropSuccess"))
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? "Failed to drop course")
|
toast.error(res.message ?? t("errors.unexpected"))
|
||||||
}
|
}
|
||||||
setPendingId(null)
|
setPendingId(null)
|
||||||
})
|
})
|
||||||
@@ -76,15 +90,15 @@ export function StudentSelectionView({
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold">My Selections</h3>
|
<h3 className="text-lg font-semibold">{t("student.mySelections")}</h3>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{activeSelections.length} active
|
{activeSelections.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{activeSelections.length === 0 ? (
|
{activeSelections.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No selections yet"
|
title={t("list.empty")}
|
||||||
description="Browse available courses below and select your electives."
|
description={t("description.student")}
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
className="h-auto border-none shadow-none"
|
className="h-auto border-none shadow-none"
|
||||||
/>
|
/>
|
||||||
@@ -94,33 +108,52 @@ export function StudentSelectionView({
|
|||||||
<Card key={sel.id}>
|
<Card key={sel.id}>
|
||||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||||
<CardTitle className="text-base">
|
<CardTitle className="text-base">
|
||||||
{sel.courseName ?? "Unknown course"}
|
{sel.courseName ?? t("errors.notFound")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant={COURSE_SELECTION_STATUS_COLORS[sel.status]}>
|
<Badge variant={COURSE_SELECTION_STATUS_BADGE_VARIANTS[sel.status]}>
|
||||||
{COURSE_SELECTION_STATUS_LABELS[sel.status]}
|
{t(COURSE_SELECTION_STATUS_LABEL_KEYS[sel.status])}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{sel.courseCapacity !== null && sel.courseEnrolledCount !== null ? (
|
{sel.courseCapacity !== null && sel.courseEnrolledCount !== null ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Enrolled: {sel.courseEnrolledCount}/{sel.courseCapacity}
|
{t("fields.enrolled")}: {sel.courseEnrolledCount}/{sel.courseCapacity}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{sel.lotteryRank ? (
|
{sel.lotteryRank ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Lottery rank: #{sel.lotteryRank}
|
#{sel.lotteryRank}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-destructive hover:text-destructive"
|
className="text-destructive hover:text-destructive"
|
||||||
disabled={isPending && pendingId === sel.courseId}
|
disabled={isPending && pendingId === sel.courseId}
|
||||||
onClick={() => handleDrop(sel.courseId)}
|
|
||||||
>
|
>
|
||||||
<XCircle className="mr-1 h-3 w-3" />
|
<XCircle className="mr-1 h-3 w-3" />
|
||||||
Drop
|
{t("actions.drop")}
|
||||||
</Button>
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("student.confirmDrop")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("student.confirmDrop")}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("actions.cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => handleDrop(sel.courseId)}
|
||||||
|
>
|
||||||
|
{t("actions.drop")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -130,15 +163,15 @@ export function StudentSelectionView({
|
|||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold">Available Courses</h3>
|
<h3 className="text-lg font-semibold">{t("student.availableCourses")}</h3>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{availableCourses.length} open
|
{availableCourses.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{availableCourses.length === 0 ? (
|
{availableCourses.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No available courses"
|
title={t("list.emptyStudent")}
|
||||||
description="There are no elective courses open for selection right now."
|
description={t("description.student")}
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
className="h-auto border-none shadow-none"
|
className="h-auto border-none shadow-none"
|
||||||
/>
|
/>
|
||||||
@@ -149,11 +182,11 @@ export function StudentSelectionView({
|
|||||||
const alreadySelected = selectedCourseIds.has(course.id)
|
const alreadySelected = selectedCourseIds.has(course.id)
|
||||||
const isPendingThis = isPending && pendingId === course.id
|
const isPendingThis = isPending && pendingId === course.id
|
||||||
return (
|
return (
|
||||||
<Card key={course.id} className="flex h-full flex-col">
|
<Card key={course.id} className="flex h-full flex-col" role="article" aria-label={course.name}>
|
||||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||||
<CardTitle className="line-clamp-2 text-base">{course.name}</CardTitle>
|
<CardTitle className="line-clamp-2 text-base">{course.name}</CardTitle>
|
||||||
<Badge variant="outline">
|
<Badge variant={ELECTIVE_STATUS_BADGE_VARIANTS[course.status]}>
|
||||||
{ELECTIVE_STATUS_LABELS[course.status]}
|
{t(ELECTIVE_STATUS_LABEL_KEYS[course.status])}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-1 flex-col gap-3">
|
<CardContent className="flex flex-1 flex-col gap-3">
|
||||||
@@ -161,8 +194,8 @@ export function StudentSelectionView({
|
|||||||
{course.subjectName ? (
|
{course.subjectName ? (
|
||||||
<Badge variant="outline">{course.subjectName}</Badge>
|
<Badge variant="outline">{course.subjectName}</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
<span>Credit: {course.credit}</span>
|
<span>{t("fields.credit")}: {course.credit}</span>
|
||||||
<span>· {SELECTION_MODE_LABELS[course.selectionMode]}</span>
|
<span>· {t(SELECTION_MODE_LABEL_KEYS[course.selectionMode])}</span>
|
||||||
</div>
|
</div>
|
||||||
{course.description ? (
|
{course.description ? (
|
||||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||||
@@ -171,27 +204,27 @@ export function StudentSelectionView({
|
|||||||
) : null}
|
) : null}
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Teacher:</span>{" "}
|
<span className="text-muted-foreground">{t("fields.teacher")}:</span>{" "}
|
||||||
<span className="font-medium">{course.teacherName ?? "—"}</span>
|
<span className="font-medium">{course.teacherName ?? "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Capacity:</span>{" "}
|
<span className="text-muted-foreground">{t("fields.capacity")}:</span>{" "}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{course.enrolledCount}/{course.capacity}
|
{course.enrolledCount}/{course.capacity}
|
||||||
{isFull ? " (Full)" : ""}
|
{isFull ? ` (${t("student.capacityFull")})` : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{course.schedule ? (
|
{course.schedule ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
<span className="font-medium">Schedule:</span> {course.schedule}
|
<span className="font-medium">{t("fields.schedule")}:</span> {course.schedule}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="mt-auto pt-2">
|
<div className="mt-auto pt-2">
|
||||||
{alreadySelected ? (
|
{alreadySelected ? (
|
||||||
<Button variant="secondary" size="sm" disabled>
|
<Button variant="secondary" size="sm" disabled>
|
||||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
Already selected
|
{t("student.selected")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -199,7 +232,7 @@ export function StudentSelectionView({
|
|||||||
disabled={isPendingThis}
|
disabled={isPendingThis}
|
||||||
onClick={() => handleSelect(course.id)}
|
onClick={() => handleSelect(course.id)}
|
||||||
>
|
>
|
||||||
{isPendingThis ? "Selecting..." : "Select"}
|
{isPendingThis ? t("actions.select") + "..." : t("actions.select")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
55
src/modules/elective/constants.ts
Normal file
55
src/modules/elective/constants.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* 选修课模块共享常量(消除 types.ts / elective-course-form.tsx / elective-filters.tsx 重复定义)。
|
||||||
|
*
|
||||||
|
* 注意:标签使用 i18n key,由组件层通过 `useTranslations("elective")` 解析。
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
ElectiveCourseStatus,
|
||||||
|
ElectiveSelectionMode,
|
||||||
|
CourseSelectionStatus,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
/** 课程状态 → i18n key 映射 */
|
||||||
|
export const ELECTIVE_STATUS_LABEL_KEYS: Record<ElectiveCourseStatus, string> = {
|
||||||
|
draft: "status.draft",
|
||||||
|
open: "status.open",
|
||||||
|
closed: "status.closed",
|
||||||
|
cancelled: "status.cancelled",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 课程状态 → Badge variant 映射 */
|
||||||
|
export const ELECTIVE_STATUS_BADGE_VARIANTS: Record<ElectiveCourseStatus, "default" | "secondary" | "destructive" | "outline"> = {
|
||||||
|
draft: "secondary",
|
||||||
|
open: "default",
|
||||||
|
closed: "outline",
|
||||||
|
cancelled: "destructive",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选课模式 → i18n key 映射 */
|
||||||
|
export const SELECTION_MODE_LABEL_KEYS: Record<ElectiveSelectionMode, string> = {
|
||||||
|
fcfs: "selectionMode.fcfs",
|
||||||
|
lottery: "selectionMode.lottery",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选课状态 → i18n key 映射 */
|
||||||
|
export const COURSE_SELECTION_STATUS_LABEL_KEYS: Record<CourseSelectionStatus, string> = {
|
||||||
|
selected: "selectionStatus.selected",
|
||||||
|
enrolled: "selectionStatus.enrolled",
|
||||||
|
waitlist: "selectionStatus.waitlist",
|
||||||
|
dropped: "selectionStatus.dropped",
|
||||||
|
rejected: "selectionStatus.rejected",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选课状态 → Badge variant 映射 */
|
||||||
|
export const COURSE_SELECTION_STATUS_BADGE_VARIANTS: Record<CourseSelectionStatus, "default" | "secondary" | "destructive" | "outline"> = {
|
||||||
|
selected: "secondary",
|
||||||
|
enrolled: "default",
|
||||||
|
waitlist: "outline",
|
||||||
|
dropped: "destructive",
|
||||||
|
rejected: "destructive",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 类型守卫:校验字符串是否为合法的选课模式 */
|
||||||
|
export function isSelectionMode(v: string): v is ElectiveSelectionMode {
|
||||||
|
return v === "fcfs" || v === "lottery"
|
||||||
|
}
|
||||||
@@ -11,7 +11,10 @@ import {
|
|||||||
|
|
||||||
import type { CourseSelectionStatus } from "./types"
|
import type { CourseSelectionStatus } from "./types"
|
||||||
|
|
||||||
function buildLotteryRankCase(ids: string[], startRank: number): SQL {
|
/**
|
||||||
|
* 构建 lotteryRank 的 CASE SQL 表达式(纯函数,便于测试 SQL 片段结构)。
|
||||||
|
*/
|
||||||
|
export function buildLotteryRankCase(ids: string[], startRank: number): SQL {
|
||||||
const branches = ids.map(
|
const branches = ids.map(
|
||||||
(id, idx) => sql`WHEN ${id} THEN ${startRank + idx}`
|
(id, idx) => sql`WHEN ${id} THEN ${startRank + idx}`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -66,43 +66,8 @@ export interface GetElectiveCoursesParams {
|
|||||||
teacherId?: string
|
teacherId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ELECTIVE_STATUS_LABELS: Record<ElectiveCourseStatus, string> = {
|
/**
|
||||||
draft: "Draft",
|
* 注意:状态标签与颜色映射已迁移至 `./constants.ts`,
|
||||||
open: "Open",
|
* 使用 i18n key(`status.*` / `selectionMode.*` / `selectionStatus.*`)+ Badge variant,
|
||||||
closed: "Closed",
|
* 由组件层通过 `useTranslations("elective")` 解析。
|
||||||
cancelled: "Cancelled",
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
export const ELECTIVE_STATUS_COLORS: Record<
|
|
||||||
ElectiveCourseStatus,
|
|
||||||
"default" | "secondary" | "destructive" | "outline"
|
|
||||||
> = {
|
|
||||||
draft: "secondary",
|
|
||||||
open: "default",
|
|
||||||
closed: "outline",
|
|
||||||
cancelled: "destructive",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SELECTION_MODE_LABELS: Record<ElectiveSelectionMode, string> = {
|
|
||||||
fcfs: "First Come First Served",
|
|
||||||
lottery: "Lottery",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const COURSE_SELECTION_STATUS_LABELS: Record<CourseSelectionStatus, string> = {
|
|
||||||
selected: "Selected",
|
|
||||||
enrolled: "Enrolled",
|
|
||||||
waitlist: "Waitlist",
|
|
||||||
dropped: "Dropped",
|
|
||||||
rejected: "Rejected",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const COURSE_SELECTION_STATUS_COLORS: Record<
|
|
||||||
CourseSelectionStatus,
|
|
||||||
"default" | "secondary" | "destructive" | "outline"
|
|
||||||
> = {
|
|
||||||
selected: "secondary",
|
|
||||||
enrolled: "default",
|
|
||||||
waitlist: "outline",
|
|
||||||
dropped: "destructive",
|
|
||||||
rejected: "destructive",
|
|
||||||
}
|
|
||||||
|
|||||||
209
src/modules/parent/components/parent-attendance-calendar.tsx
Normal file
209
src/modules/parent/components/parent-attendance-calendar.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
import {
|
||||||
|
ATTENDANCE_STATUS_DOT_COLORS,
|
||||||
|
ATTENDANCE_STATUS_LABEL_KEYS,
|
||||||
|
} from "@/modules/attendance/constants"
|
||||||
|
import type {
|
||||||
|
ParentAttendanceListItem,
|
||||||
|
ParentAttendanceStatus,
|
||||||
|
ParentStudentAttendanceSummary,
|
||||||
|
} from "@/modules/parent/types"
|
||||||
|
|
||||||
|
const WEEKDAY_KEYS = [
|
||||||
|
"parent.weekday.sun",
|
||||||
|
"parent.weekday.mon",
|
||||||
|
"parent.weekday.tue",
|
||||||
|
"parent.weekday.wed",
|
||||||
|
"parent.weekday.thu",
|
||||||
|
"parent.weekday.fri",
|
||||||
|
"parent.weekday.sat",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化日期为 `YYYY-MM-DD`(纯函数,便于测试)。
|
||||||
|
*/
|
||||||
|
export function formatDateKey(d: Date): string {
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0")
|
||||||
|
const day = String(d.getDate()).padStart(2, "0")
|
||||||
|
return `${y}-${m}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 `YYYY-MM-DD` 为 Date(纯函数,便于测试)。
|
||||||
|
*/
|
||||||
|
export function parseDateKey(key: string): Date | null {
|
||||||
|
const parts = key.split("-")
|
||||||
|
if (parts.length !== 3) return null
|
||||||
|
const [y, m, d] = parts.map(Number)
|
||||||
|
if (!y || !m || !d) return null
|
||||||
|
return new Date(y, m - 1, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建日历单元格(纯函数,便于测试)。
|
||||||
|
*/
|
||||||
|
export function buildCalendarDays(year: number, month: number): Array<Date | null> {
|
||||||
|
const firstDay = new Date(year, month, 1)
|
||||||
|
const lastDay = new Date(year, month + 1, 0)
|
||||||
|
const startWeekday = firstDay.getDay()
|
||||||
|
const totalDays = lastDay.getDate()
|
||||||
|
const cells: Array<Date | null> = []
|
||||||
|
for (let i = 0; i < startWeekday; i += 1) cells.push(null)
|
||||||
|
for (let day = 1; day <= totalDays; day += 1) {
|
||||||
|
cells.push(new Date(year, month, day))
|
||||||
|
}
|
||||||
|
while (cells.length % 7 !== 0) cells.push(null)
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断两个日期是否为同一天(纯函数,便于测试)。
|
||||||
|
*/
|
||||||
|
export function isSameDay(a: Date, b: Date): boolean {
|
||||||
|
return (
|
||||||
|
a.getFullYear() === b.getFullYear() &&
|
||||||
|
a.getMonth() === b.getMonth() &&
|
||||||
|
a.getDate() === b.getDate()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 家长视角的考勤月历视图。
|
||||||
|
* 基于子女的近期考勤记录,在月历上按状态着色,让家长直观看到出勤分布。
|
||||||
|
*/
|
||||||
|
export function ParentAttendanceCalendar({
|
||||||
|
summary,
|
||||||
|
}: {
|
||||||
|
summary: ParentStudentAttendanceSummary
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("attendance")
|
||||||
|
const now = new Date()
|
||||||
|
const [viewYear, setViewYear] = useState(now.getFullYear())
|
||||||
|
const [viewMonth, setViewMonth] = useState(now.getMonth())
|
||||||
|
|
||||||
|
const recordMap = new Map<string, ParentAttendanceListItem>()
|
||||||
|
for (const r of summary.recentRecords) {
|
||||||
|
const d = parseDateKey(r.date)
|
||||||
|
if (d) recordMap.set(formatDateKey(d), r)
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = buildCalendarDays(viewYear, viewMonth)
|
||||||
|
const monthLabel = new Date(viewYear, viewMonth, 1).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
})
|
||||||
|
|
||||||
|
const goPrev = () => {
|
||||||
|
if (viewMonth === 0) {
|
||||||
|
setViewMonth(11)
|
||||||
|
setViewYear((y) => y - 1)
|
||||||
|
} else {
|
||||||
|
setViewMonth((m) => m - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const goNext = () => {
|
||||||
|
if (viewMonth === 11) {
|
||||||
|
setViewMonth(0)
|
||||||
|
setViewYear((y) => y + 1)
|
||||||
|
} else {
|
||||||
|
setViewMonth((m) => m + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedStatuses = new Set<ParentAttendanceStatus>()
|
||||||
|
for (const r of recordMap.values()) usedStatuses.add(r.status)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card aria-label={t("parent.calendarTitle", { name: summary.studentName })}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center justify-between text-base">
|
||||||
|
<span>{t("parent.calendarFor", { name: summary.studentName })}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goPrev}
|
||||||
|
aria-label={t("parent.prevMonth")}
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<span className="min-w-[120px] text-center text-sm font-medium">{monthLabel}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goNext}
|
||||||
|
aria-label={t("parent.nextMonth")}
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid grid-cols-7 gap-1 text-center text-xs font-medium text-muted-foreground">
|
||||||
|
{WEEKDAY_KEYS.map((key) => (
|
||||||
|
<div key={key} className="py-1">
|
||||||
|
{t(key)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{days.map((d, idx) => {
|
||||||
|
if (!d) return <div key={`empty-${idx}`} className="aspect-square" />
|
||||||
|
const key = formatDateKey(d)
|
||||||
|
const record = recordMap.get(key)
|
||||||
|
const isToday = isSameDay(d, now)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className={cn(
|
||||||
|
"relative flex aspect-square flex-col items-center justify-center rounded-md border text-xs",
|
||||||
|
"min-h-[36px]",
|
||||||
|
record ? "border-border bg-muted/30" : "border-transparent",
|
||||||
|
isToday && "ring-2 ring-primary ring-offset-1",
|
||||||
|
)}
|
||||||
|
aria-label={
|
||||||
|
record
|
||||||
|
? `${formatDateKey(d)}: ${t(ATTENDANCE_STATUS_LABEL_KEYS[record.status])}`
|
||||||
|
: formatDateKey(d)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="tabular-nums">{d.getDate()}</span>
|
||||||
|
{record ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-0.5 h-1.5 w-1.5 rounded-full",
|
||||||
|
ATTENDANCE_STATUS_DOT_COLORS[record.status],
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{usedStatuses.size > 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 pt-1 text-xs text-muted-foreground">
|
||||||
|
{Array.from(usedStatuses).map((status) => (
|
||||||
|
<span key={status} className="inline-flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className={cn("h-2 w-2 rounded-full", ATTENDANCE_STATUS_DOT_COLORS[status])}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{t(ATTENDANCE_STATUS_LABEL_KEYS[status])}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
src/modules/parent/components/parent-attendance-rate-card.tsx
Normal file
130
src/modules/parent/components/parent-attendance-rate-card.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { CalendarCheck, CalendarX, Clock, TrendingUp } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import { Card } from "@/shared/components/ui/card"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
import type { ParentStudentAttendanceSummary } from "@/modules/parent/types"
|
||||||
|
|
||||||
|
type AggregateStats = {
|
||||||
|
totalStudents: number
|
||||||
|
avgPresentRate: number
|
||||||
|
totalAbsent: number
|
||||||
|
totalLate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聚合多个子女的考勤统计(纯函数,便于测试)。
|
||||||
|
*/
|
||||||
|
export function aggregateStats(
|
||||||
|
summaries: ParentStudentAttendanceSummary[],
|
||||||
|
): AggregateStats {
|
||||||
|
if (summaries.length === 0) {
|
||||||
|
return { totalStudents: 0, avgPresentRate: 0, totalAbsent: 0, totalLate: 0 }
|
||||||
|
}
|
||||||
|
const totalStudents = summaries.length
|
||||||
|
const sumRate = summaries.reduce(
|
||||||
|
(sum, s) => sum + (s.stats.total > 0 ? s.stats.presentRate : 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
const avgPresentRate = sumRate / totalStudents
|
||||||
|
const totalAbsent = summaries.reduce((sum, s) => sum + s.stats.absent, 0)
|
||||||
|
const totalLate = summaries.reduce((sum, s) => sum + s.stats.late, 0)
|
||||||
|
return { totalStudents, avgPresentRate, totalLate, totalAbsent }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据出勤率返回语气色调(纯函数,便于测试)。
|
||||||
|
*/
|
||||||
|
export function rateTone(rate: number): "good" | "warn" | "bad" {
|
||||||
|
if (rate >= 95) return "good"
|
||||||
|
if (rate >= 90) return "warn"
|
||||||
|
return "bad"
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE_STYLES: Record<"good" | "warn" | "bad", string> = {
|
||||||
|
good: "text-emerald-600",
|
||||||
|
warn: "text-amber-600",
|
||||||
|
bad: "text-destructive",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 家长考勤页顶部的出勤率汇总卡片。
|
||||||
|
* 聚合所有子女的出勤率、缺勤、迟到总数,让家长一眼掌握整体情况。
|
||||||
|
*/
|
||||||
|
export function ParentAttendanceRateCard({
|
||||||
|
summaries,
|
||||||
|
}: {
|
||||||
|
summaries: ParentStudentAttendanceSummary[]
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("attendance")
|
||||||
|
const stats = aggregateStats(summaries)
|
||||||
|
if (stats.totalStudents === 0) return null
|
||||||
|
|
||||||
|
const tone = rateTone(stats.avgPresentRate)
|
||||||
|
const rateLabel =
|
||||||
|
stats.avgPresentRate >= 95
|
||||||
|
? t("parent.rateExcellent")
|
||||||
|
: stats.avgPresentRate >= 90
|
||||||
|
? t("parent.rateNeedsAttention")
|
||||||
|
: t("parent.rateBelowStandard")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-4" aria-label={t("parent.rateCardTitle")}>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<TrendingUp className="h-3 w-3" aria-hidden />
|
||||||
|
{t("stats.attendanceRate")}
|
||||||
|
</div>
|
||||||
|
<div className={cn("text-2xl font-bold tabular-nums", TONE_STYLES[tone])}>
|
||||||
|
{stats.avgPresentRate.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{rateLabel}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<CalendarCheck className="h-3 w-3" aria-hidden />
|
||||||
|
{t("parent.children")}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold tabular-nums">{stats.totalStudents}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t("parent.linked")}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<CalendarX className="h-3 w-3" aria-hidden />
|
||||||
|
{t("stats.absent")}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-bold tabular-nums",
|
||||||
|
stats.totalAbsent > 0 && "text-destructive",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{stats.totalAbsent}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t("parent.thisPeriod")}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Clock className="h-3 w-3" aria-hidden />
|
||||||
|
{t("stats.late")}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-bold tabular-nums",
|
||||||
|
stats.totalLate > 0 && "text-amber-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{stats.totalLate}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t("parent.thisPeriod")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
115
src/modules/parent/components/parent-attendance-warning.tsx
Normal file
115
src/modules/parent/components/parent-attendance-warning.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AlertTriangle, Phone } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import { Card } from "@/shared/components/ui/card"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
import type { ParentStudentAttendanceSummary } from "@/modules/parent/types"
|
||||||
|
|
||||||
|
type Warning = {
|
||||||
|
studentId: string
|
||||||
|
studentName: string
|
||||||
|
message: string
|
||||||
|
severity: "high" | "medium"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 翻译函数类型(与 `useTranslations("attendance")` 返回值兼容) */
|
||||||
|
type Translator = ReturnType<typeof useTranslations>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建考勤异常预警列表(纯函数,便于测试)。
|
||||||
|
*/
|
||||||
|
export function buildWarnings(
|
||||||
|
summaries: ParentStudentAttendanceSummary[],
|
||||||
|
t: Translator,
|
||||||
|
): Warning[] {
|
||||||
|
const warnings: Warning[] = []
|
||||||
|
|
||||||
|
for (const s of summaries) {
|
||||||
|
const { stats, studentName, studentId } = s
|
||||||
|
if (stats.absent >= 3) {
|
||||||
|
warnings.push({
|
||||||
|
studentId,
|
||||||
|
studentName,
|
||||||
|
message: t("parent.absentHighSeverity", { count: stats.absent }),
|
||||||
|
severity: "high",
|
||||||
|
})
|
||||||
|
} else if (stats.absent >= 1) {
|
||||||
|
warnings.push({
|
||||||
|
studentId,
|
||||||
|
studentName,
|
||||||
|
message: t("parent.absentWarning", { count: stats.absent }),
|
||||||
|
severity: "medium",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.late >= 3) {
|
||||||
|
warnings.push({
|
||||||
|
studentId,
|
||||||
|
studentName,
|
||||||
|
message: t("parent.lateWarning", { count: stats.late }),
|
||||||
|
severity: "medium",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.presentRate < 90 && stats.total > 0) {
|
||||||
|
warnings.push({
|
||||||
|
studentId,
|
||||||
|
studentName,
|
||||||
|
message: t("parent.lowRateWarning", { rate: stats.presentRate.toFixed(1) }),
|
||||||
|
severity: "high",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 家长视角的考勤异常预警横幅。
|
||||||
|
* 聚合所有子女的考勤异常(缺勤、迟到、低出勤率),提醒家长及时关注。
|
||||||
|
*/
|
||||||
|
export function ParentAttendanceWarning({
|
||||||
|
summaries,
|
||||||
|
}: {
|
||||||
|
summaries: ParentStudentAttendanceSummary[]
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("attendance")
|
||||||
|
const warnings = buildWarnings(summaries, t)
|
||||||
|
if (warnings.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"border-destructive/30 bg-destructive/5 p-4",
|
||||||
|
warnings.some((w) => w.severity === "high") && "border-destructive/50",
|
||||||
|
)}
|
||||||
|
aria-label={t("parent.warningTitle")}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle
|
||||||
|
className="h-5 w-5 text-destructive shrink-0 mt-0.5"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="text-sm font-semibold text-destructive">
|
||||||
|
{t("parent.warningTitle")}
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1.5 text-sm">
|
||||||
|
{warnings.map((w, idx) => (
|
||||||
|
<li key={`${w.studentId}-${idx}`} className="flex items-start gap-2">
|
||||||
|
<span className="font-medium shrink-0">{w.studentName}:</span>
|
||||||
|
<span className="text-muted-foreground">{w.message}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="flex items-center gap-2 pt-1 text-xs text-muted-foreground">
|
||||||
|
<Phone className="h-3 w-3" aria-hidden />
|
||||||
|
<span>{t("parent.contactHomeroom")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -39,6 +39,12 @@ export type ChildScheduleItem = {
|
|||||||
location: string | null
|
location: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 单条周课表项(含 weekday)。 */
|
||||||
|
export type ChildWeeklyScheduleItem = ChildScheduleItem & {
|
||||||
|
/** 1=周一 ... 7=周日 */
|
||||||
|
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||||
|
}
|
||||||
|
|
||||||
/** 子女作业摘要(统计计数 + 最近作业列表)。 */
|
/** 子女作业摘要(统计计数 + 最近作业列表)。 */
|
||||||
export type ChildHomeworkSummaryData = {
|
export type ChildHomeworkSummaryData = {
|
||||||
pendingCount: number
|
pendingCount: number
|
||||||
@@ -54,6 +60,8 @@ export type ChildDashboardData = {
|
|||||||
basicInfo: ChildBasicInfo
|
basicInfo: ChildBasicInfo
|
||||||
enrolledClasses: StudentEnrolledClass[]
|
enrolledClasses: StudentEnrolledClass[]
|
||||||
todaySchedule: ChildScheduleItem[]
|
todaySchedule: ChildScheduleItem[]
|
||||||
|
/** 完整周课表(按 weekday 升序)。 */
|
||||||
|
weeklySchedule: ChildWeeklyScheduleItem[]
|
||||||
homeworkSummary: ChildHomeworkSummaryData
|
homeworkSummary: ChildHomeworkSummaryData
|
||||||
/** 成绩趋势数据;`trend` 按时间升序,`recent` 按时间降序。 */
|
/** 成绩趋势数据;`trend` 按时间升序,`recent` 按时间降序。 */
|
||||||
gradeTrend: StudentDashboardGradeProps
|
gradeTrend: StudentDashboardGradeProps
|
||||||
@@ -65,3 +73,45 @@ export type ParentDashboardData = {
|
|||||||
parentName: string | null
|
parentName: string | null
|
||||||
children: ChildDashboardData[]
|
children: ChildDashboardData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 考勤视图模型(解耦 parent 模块对 attendance/types 的直接依赖) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 家长视角所需的考勤状态枚举。
|
||||||
|
* 与 `attendance.AttendanceStatus` 结构兼容,但由 parent 模块自行声明,
|
||||||
|
* 避免 parent 反向依赖 attendance 模块的类型变更。
|
||||||
|
*/
|
||||||
|
export type ParentAttendanceStatus =
|
||||||
|
| "present"
|
||||||
|
| "absent"
|
||||||
|
| "late"
|
||||||
|
| "early_leave"
|
||||||
|
| "excused"
|
||||||
|
|
||||||
|
/** 家长视角所需的单条考勤记录(仅保留展示字段)。 */
|
||||||
|
export type ParentAttendanceListItem = {
|
||||||
|
id: string
|
||||||
|
date: string
|
||||||
|
status: ParentAttendanceStatus
|
||||||
|
remark: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 家长视角所需的考勤统计(仅保留展示字段)。 */
|
||||||
|
export type ParentAttendanceStats = {
|
||||||
|
total: number
|
||||||
|
present: number
|
||||||
|
absent: number
|
||||||
|
late: number
|
||||||
|
presentRate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 家长视角所需的单个子女考勤汇总。 */
|
||||||
|
export type ParentStudentAttendanceSummary = {
|
||||||
|
studentId: string
|
||||||
|
studentName: string
|
||||||
|
stats: ParentAttendanceStats
|
||||||
|
recentRecords: ParentAttendanceListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,12 @@
|
|||||||
"excused": "Excused",
|
"excused": "Excused",
|
||||||
"attendanceRate": "Attendance Rate",
|
"attendanceRate": "Attendance Rate",
|
||||||
"lateRate": "Late Rate",
|
"lateRate": "Late Rate",
|
||||||
"recentRecords": "Recent Records"
|
"recentRecords": "Recent Records",
|
||||||
|
"studentRecords": "Student Records",
|
||||||
|
"noClasses": "No classes",
|
||||||
|
"noClassesDescription": "You don't have any classes yet.",
|
||||||
|
"noData": "No data",
|
||||||
|
"noDataDescription": "No attendance data available for this class."
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"class": "Class",
|
"class": "Class",
|
||||||
@@ -66,7 +71,10 @@
|
|||||||
"selectClass": "Select Class",
|
"selectClass": "Select Class",
|
||||||
"selectDate": "Select Date",
|
"selectDate": "Select Date",
|
||||||
"noStudents": "No students in this class",
|
"noStudents": "No students in this class",
|
||||||
|
"description": "Select a class and date, then mark attendance for each student.",
|
||||||
"confirmDelete": "Are you sure you want to delete this attendance record?",
|
"confirmDelete": "Are you sure you want to delete this attendance record?",
|
||||||
|
"confirmClassSwitch": "Switching class will discard unsaved changes. Continue?",
|
||||||
|
"confirmClassSwitchAction": "Switch Class",
|
||||||
"saved": "Attendance saved",
|
"saved": "Attendance saved",
|
||||||
"updated": "Attendance updated",
|
"updated": "Attendance updated",
|
||||||
"deleted": "Attendance record deleted"
|
"deleted": "Attendance record deleted"
|
||||||
@@ -86,10 +94,34 @@
|
|||||||
"parent": {
|
"parent": {
|
||||||
"warningTitle": "Attendance Warnings",
|
"warningTitle": "Attendance Warnings",
|
||||||
"rateCardTitle": "Attendance Rate Summary",
|
"rateCardTitle": "Attendance Rate Summary",
|
||||||
"calendarTitle": "Attendance Calendar",
|
"calendarTitle": "Attendance calendar for {name}",
|
||||||
|
"calendarFor": "{name}'s Calendar",
|
||||||
|
"prevMonth": "Previous month",
|
||||||
|
"nextMonth": "Next month",
|
||||||
"noWarnings": "No attendance warnings",
|
"noWarnings": "No attendance warnings",
|
||||||
"absentWarning": "{count} absence(s)",
|
"absentWarning": "{count} absence(s)",
|
||||||
|
"absentHighSeverity": "{count} absences recorded. Consider contacting the homeroom teacher.",
|
||||||
"lateWarning": "{count} late arrival(s)",
|
"lateWarning": "{count} late arrival(s)",
|
||||||
"lowRateWarning": "Attendance rate {rate}% below threshold"
|
"lowRateWarning": "Attendance rate {rate}% below threshold",
|
||||||
|
"contactHomeroom": "Consider contacting the homeroom teacher for details.",
|
||||||
|
"rateExcellent": "Excellent",
|
||||||
|
"rateNeedsAttention": "Needs attention",
|
||||||
|
"rateBelowStandard": "Below standard",
|
||||||
|
"children": "Children",
|
||||||
|
"linked": "linked",
|
||||||
|
"thisPeriod": "this period",
|
||||||
|
"noChildrenTitle": "No children linked",
|
||||||
|
"noChildrenDescription": "Your account is not linked to any student accounts yet. Please contact the school administrator.",
|
||||||
|
"compareDescription": "Compare attendance across all your children. For single-child details, open the child's detail page.",
|
||||||
|
"noRecordsDescription": "Your children don't have any attendance records yet.",
|
||||||
|
"weekday": {
|
||||||
|
"sun": "Sun",
|
||||||
|
"mon": "Mon",
|
||||||
|
"tue": "Tue",
|
||||||
|
"wed": "Wed",
|
||||||
|
"thu": "Thu",
|
||||||
|
"fri": "Fri",
|
||||||
|
"sat": "Sat"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"adminList": "Manage elective courses, open/close selection and lottery.",
|
"adminList": "Manage elective courses, open/close selection and lottery.",
|
||||||
|
"create": "Create a new elective course.",
|
||||||
|
"edit": "Update elective course details.",
|
||||||
"teacher": "View and manage the elective courses you teach.",
|
"teacher": "View and manage the elective courses you teach.",
|
||||||
"student": "Browse available courses and make selections."
|
"student": "Browse available courses and make selections."
|
||||||
},
|
},
|
||||||
@@ -21,6 +23,9 @@
|
|||||||
"fcfs": "First Come First Served",
|
"fcfs": "First Come First Served",
|
||||||
"lottery": "Lottery"
|
"lottery": "Lottery"
|
||||||
},
|
},
|
||||||
|
"filters": {
|
||||||
|
"allStatuses": "All Modes"
|
||||||
|
},
|
||||||
"selectionStatus": {
|
"selectionStatus": {
|
||||||
"selected": "Selected",
|
"selected": "Selected",
|
||||||
"enrolled": "Enrolled",
|
"enrolled": "Enrolled",
|
||||||
|
|||||||
@@ -31,7 +31,12 @@
|
|||||||
"excused": "请假",
|
"excused": "请假",
|
||||||
"attendanceRate": "出勤率",
|
"attendanceRate": "出勤率",
|
||||||
"lateRate": "迟到率",
|
"lateRate": "迟到率",
|
||||||
"recentRecords": "最近记录"
|
"recentRecords": "最近记录",
|
||||||
|
"studentRecords": "学生记录",
|
||||||
|
"noClasses": "暂无班级",
|
||||||
|
"noClassesDescription": "您目前没有任何班级。",
|
||||||
|
"noData": "暂无数据",
|
||||||
|
"noDataDescription": "该班级暂无考勤数据。"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"class": "班级",
|
"class": "班级",
|
||||||
@@ -66,7 +71,10 @@
|
|||||||
"selectClass": "选择班级",
|
"selectClass": "选择班级",
|
||||||
"selectDate": "选择日期",
|
"selectDate": "选择日期",
|
||||||
"noStudents": "该班级暂无学生",
|
"noStudents": "该班级暂无学生",
|
||||||
|
"description": "选择班级和日期,然后为每位学生标记考勤。",
|
||||||
"confirmDelete": "确定删除此条考勤记录吗?",
|
"confirmDelete": "确定删除此条考勤记录吗?",
|
||||||
|
"confirmClassSwitch": "切换班级将丢弃未保存的修改,是否继续?",
|
||||||
|
"confirmClassSwitchAction": "切换班级",
|
||||||
"saved": "考勤已保存",
|
"saved": "考勤已保存",
|
||||||
"updated": "考勤已更新",
|
"updated": "考勤已更新",
|
||||||
"deleted": "考勤记录已删除"
|
"deleted": "考勤记录已删除"
|
||||||
@@ -86,10 +94,34 @@
|
|||||||
"parent": {
|
"parent": {
|
||||||
"warningTitle": "考勤异常预警",
|
"warningTitle": "考勤异常预警",
|
||||||
"rateCardTitle": "出勤率汇总",
|
"rateCardTitle": "出勤率汇总",
|
||||||
"calendarTitle": "考勤月历",
|
"calendarTitle": "{name} 的考勤月历",
|
||||||
|
"calendarFor": "{name} 的月历",
|
||||||
|
"prevMonth": "上个月",
|
||||||
|
"nextMonth": "下个月",
|
||||||
"noWarnings": "暂无考勤异常",
|
"noWarnings": "暂无考勤异常",
|
||||||
"absentWarning": "{count} 次缺勤",
|
"absentWarning": "{count} 次缺勤",
|
||||||
|
"absentHighSeverity": "已记录 {count} 次缺勤,建议联系班主任了解情况。",
|
||||||
"lateWarning": "{count} 次迟到",
|
"lateWarning": "{count} 次迟到",
|
||||||
"lowRateWarning": "出勤率 {rate}% 低于阈值"
|
"lowRateWarning": "出勤率 {rate}% 低于阈值",
|
||||||
|
"contactHomeroom": "如需了解详情,请联系班主任。",
|
||||||
|
"rateExcellent": "优秀",
|
||||||
|
"rateNeedsAttention": "需要关注",
|
||||||
|
"rateBelowStandard": "未达标",
|
||||||
|
"children": "子女",
|
||||||
|
"linked": "已关联",
|
||||||
|
"thisPeriod": "本周期",
|
||||||
|
"noChildrenTitle": "尚未关联子女",
|
||||||
|
"noChildrenDescription": "您的账号尚未关联任何学生账号,请联系学校管理员。",
|
||||||
|
"compareDescription": "对比所有子女的考勤情况。如需查看单个子女详情,请进入子女详情页。",
|
||||||
|
"noRecordsDescription": "您的子女暂无任何考勤记录。",
|
||||||
|
"weekday": {
|
||||||
|
"sun": "周日",
|
||||||
|
"mon": "周一",
|
||||||
|
"tue": "周二",
|
||||||
|
"wed": "周三",
|
||||||
|
"thu": "周四",
|
||||||
|
"fri": "周五",
|
||||||
|
"sat": "周六"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"adminList": "管理选修课程、开放/关闭选课与抽签。",
|
"adminList": "管理选修课程、开放/关闭选课与抽签。",
|
||||||
|
"create": "创建新的选修课程。",
|
||||||
|
"edit": "更新选修课程详情。",
|
||||||
"teacher": "查看和管理您教授的选修课程。",
|
"teacher": "查看和管理您教授的选修课程。",
|
||||||
"student": "浏览可选课程并进行选课。"
|
"student": "浏览可选课程并进行选课。"
|
||||||
},
|
},
|
||||||
@@ -21,6 +23,9 @@
|
|||||||
"fcfs": "先到先得",
|
"fcfs": "先到先得",
|
||||||
"lottery": "抽签"
|
"lottery": "抽签"
|
||||||
},
|
},
|
||||||
|
"filters": {
|
||||||
|
"allStatuses": "全部模式"
|
||||||
|
},
|
||||||
"selectionStatus": {
|
"selectionStatus": {
|
||||||
"selected": "已选",
|
"selected": "已选",
|
||||||
"enrolled": "已录取",
|
"enrolled": "已录取",
|
||||||
|
|||||||
92
src/shared/lib/track-event.ts
Normal file
92
src/shared/lib/track-event.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监控埋点接口(预留)
|
||||||
|
*
|
||||||
|
* 在关键 Server Action 中调用 trackEvent 记录业务事件,用于:
|
||||||
|
* - 公告阅读率、消息回复率等关键指标统计
|
||||||
|
* - 通知发送失败告警
|
||||||
|
* - 用户行为漏斗分析
|
||||||
|
*
|
||||||
|
* 当前实现:输出到 console.info,不阻塞主流程。
|
||||||
|
* 后续扩展:可接入外部监控服务(如 Sentry / PostHog / 自建埋点系统),
|
||||||
|
* 只需在 trackEventToSink 中替换实现即可,调用方无需改动。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 事件名称(使用点号分隔的命名空间,如 "announcement.published") */
|
||||||
|
export type EventName =
|
||||||
|
| "announcement.created"
|
||||||
|
| "announcement.updated"
|
||||||
|
| "announcement.published"
|
||||||
|
| "announcement.archived"
|
||||||
|
| "announcement.deleted"
|
||||||
|
| "message.sent"
|
||||||
|
| "message.deleted"
|
||||||
|
| "message.marked_read"
|
||||||
|
| "notification.marked_read"
|
||||||
|
| "notification.marked_all_read"
|
||||||
|
| "notification.sent"
|
||||||
|
| "notification.send_failed"
|
||||||
|
| "attendance.recorded"
|
||||||
|
| "attendance.batch_recorded"
|
||||||
|
| "attendance.updated"
|
||||||
|
| "attendance.deleted"
|
||||||
|
| "attendance.rules_saved"
|
||||||
|
| "elective.course_created"
|
||||||
|
| "elective.course_updated"
|
||||||
|
| "elective.course_deleted"
|
||||||
|
| "elective.selection_opened"
|
||||||
|
| "elective.selection_closed"
|
||||||
|
| "elective.course_selected"
|
||||||
|
| "elective.course_dropped"
|
||||||
|
| "elective.lottery_completed"
|
||||||
|
|
||||||
|
/** 埋点事件负载 */
|
||||||
|
export interface TrackEventPayload {
|
||||||
|
/** 事件名称 */
|
||||||
|
event: EventName
|
||||||
|
/** 当前用户 ID(可选,未登录场景为 undefined) */
|
||||||
|
userId?: string
|
||||||
|
/** 目标对象 ID(如公告 ID、消息 ID) */
|
||||||
|
targetId?: string
|
||||||
|
/** 目标对象类型(如 "announcement"、"message") */
|
||||||
|
targetType?: string
|
||||||
|
/** 附加属性(如受众人数、渠道类型) */
|
||||||
|
properties?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将事件发送到外部监控服务。
|
||||||
|
*
|
||||||
|
* 当前为占位实现:仅输出到 console.info。
|
||||||
|
* 接入真实服务时替换此函数体即可。
|
||||||
|
*/
|
||||||
|
function trackEventToSink(payload: TrackEventPayload): void {
|
||||||
|
console.info(
|
||||||
|
`[TrackEvent] ${payload.event} userId=${payload.userId ?? "-"} targetId=${payload.targetId ?? "-"}${payload.properties ? ` properties=${JSON.stringify(payload.properties)}` : ""}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录一个监控事件。
|
||||||
|
*
|
||||||
|
* 非阻塞:任何异常都被吞掉,确保不影响主业务流程。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* await trackEvent({
|
||||||
|
* event: "announcement.published",
|
||||||
|
* userId: ctx.userId,
|
||||||
|
* targetId: id,
|
||||||
|
* targetType: "announcement",
|
||||||
|
* properties: { audienceSize: userIds.length },
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function trackEvent(payload: TrackEventPayload): Promise<void> {
|
||||||
|
try {
|
||||||
|
trackEventToSink(payload)
|
||||||
|
} catch {
|
||||||
|
// 埋点失败不影响主流程
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user