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:
SpecialX
2026-06-22 17:33:29 +08:00
parent 76966581b8
commit f62b8c0f86
46 changed files with 1748 additions and 545 deletions

View 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>
)
}

View File

@@ -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}
/> />
) : ( ) : (

View 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>
)
}

View File

@@ -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>

View 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>
)
}

View File

@@ -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>

View 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>
)
}

View File

@@ -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

View File

@@ -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
}
/> />
) )
} }

View File

@@ -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>

View File

@@ -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

View 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>
)
}

View File

@@ -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>
) )

View 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>
)
}

View File

@@ -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

View 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>
)
}

View File

@@ -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"
/> />

View 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>
)
}

View File

@@ -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}

View File

@@ -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" }
} }
/**
* 校验当前用户对班级的归属权限。
* - adminscope=all直接放行
* - teacherscope=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" }
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
) )
} }

View File

@@ -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>

View File

@@ -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>

View 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,
}
}

View File

@@ -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) {

View File

@@ -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",
}

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View 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"
}

View File

@@ -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}`
) )

View File

@@ -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",
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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[]
}

View File

@@ -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"
}
} }
} }

View File

@@ -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",

View File

@@ -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": "周六"
}
} }
} }

View File

@@ -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": "已录取",

View 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 {
// 埋点失败不影响主流程
}
}