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:
209
src/modules/parent/components/parent-attendance-calendar.tsx
Normal file
209
src/modules/parent/components/parent-attendance-calendar.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
"use client"
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import {
|
||||
ATTENDANCE_STATUS_DOT_COLORS,
|
||||
ATTENDANCE_STATUS_LABEL_KEYS,
|
||||
} from "@/modules/attendance/constants"
|
||||
import type {
|
||||
ParentAttendanceListItem,
|
||||
ParentAttendanceStatus,
|
||||
ParentStudentAttendanceSummary,
|
||||
} from "@/modules/parent/types"
|
||||
|
||||
const WEEKDAY_KEYS = [
|
||||
"parent.weekday.sun",
|
||||
"parent.weekday.mon",
|
||||
"parent.weekday.tue",
|
||||
"parent.weekday.wed",
|
||||
"parent.weekday.thu",
|
||||
"parent.weekday.fri",
|
||||
"parent.weekday.sat",
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 格式化日期为 `YYYY-MM-DD`(纯函数,便于测试)。
|
||||
*/
|
||||
export function formatDateKey(d: Date): string {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(d.getDate()).padStart(2, "0")
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 `YYYY-MM-DD` 为 Date(纯函数,便于测试)。
|
||||
*/
|
||||
export function parseDateKey(key: string): Date | null {
|
||||
const parts = key.split("-")
|
||||
if (parts.length !== 3) return null
|
||||
const [y, m, d] = parts.map(Number)
|
||||
if (!y || !m || !d) return null
|
||||
return new Date(y, m - 1, d)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建日历单元格(纯函数,便于测试)。
|
||||
*/
|
||||
export function buildCalendarDays(year: number, month: number): Array<Date | null> {
|
||||
const firstDay = new Date(year, month, 1)
|
||||
const lastDay = new Date(year, month + 1, 0)
|
||||
const startWeekday = firstDay.getDay()
|
||||
const totalDays = lastDay.getDate()
|
||||
const cells: Array<Date | null> = []
|
||||
for (let i = 0; i < startWeekday; i += 1) cells.push(null)
|
||||
for (let day = 1; day <= totalDays; day += 1) {
|
||||
cells.push(new Date(year, month, day))
|
||||
}
|
||||
while (cells.length % 7 !== 0) cells.push(null)
|
||||
return cells
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断两个日期是否为同一天(纯函数,便于测试)。
|
||||
*/
|
||||
export function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 家长视角的考勤月历视图。
|
||||
* 基于子女的近期考勤记录,在月历上按状态着色,让家长直观看到出勤分布。
|
||||
*/
|
||||
export function ParentAttendanceCalendar({
|
||||
summary,
|
||||
}: {
|
||||
summary: ParentStudentAttendanceSummary
|
||||
}) {
|
||||
const t = useTranslations("attendance")
|
||||
const now = new Date()
|
||||
const [viewYear, setViewYear] = useState(now.getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(now.getMonth())
|
||||
|
||||
const recordMap = new Map<string, ParentAttendanceListItem>()
|
||||
for (const r of summary.recentRecords) {
|
||||
const d = parseDateKey(r.date)
|
||||
if (d) recordMap.set(formatDateKey(d), r)
|
||||
}
|
||||
|
||||
const days = buildCalendarDays(viewYear, viewMonth)
|
||||
const monthLabel = new Date(viewYear, viewMonth, 1).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
})
|
||||
|
||||
const goPrev = () => {
|
||||
if (viewMonth === 0) {
|
||||
setViewMonth(11)
|
||||
setViewYear((y) => y - 1)
|
||||
} else {
|
||||
setViewMonth((m) => m - 1)
|
||||
}
|
||||
}
|
||||
const goNext = () => {
|
||||
if (viewMonth === 11) {
|
||||
setViewMonth(0)
|
||||
setViewYear((y) => y + 1)
|
||||
} else {
|
||||
setViewMonth((m) => m + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const usedStatuses = new Set<ParentAttendanceStatus>()
|
||||
for (const r of recordMap.values()) usedStatuses.add(r.status)
|
||||
|
||||
return (
|
||||
<Card aria-label={t("parent.calendarTitle", { name: summary.studentName })}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between text-base">
|
||||
<span>{t("parent.calendarFor", { name: summary.studentName })}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
aria-label={t("parent.prevMonth")}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="min-w-[120px] text-center text-sm font-medium">{monthLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
aria-label={t("parent.nextMonth")}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-7 gap-1 text-center text-xs font-medium text-muted-foreground">
|
||||
{WEEKDAY_KEYS.map((key) => (
|
||||
<div key={key} className="py-1">
|
||||
{t(key)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((d, idx) => {
|
||||
if (!d) return <div key={`empty-${idx}`} className="aspect-square" />
|
||||
const key = formatDateKey(d)
|
||||
const record = recordMap.get(key)
|
||||
const isToday = isSameDay(d, now)
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
"relative flex aspect-square flex-col items-center justify-center rounded-md border text-xs",
|
||||
"min-h-[36px]",
|
||||
record ? "border-border bg-muted/30" : "border-transparent",
|
||||
isToday && "ring-2 ring-primary ring-offset-1",
|
||||
)}
|
||||
aria-label={
|
||||
record
|
||||
? `${formatDateKey(d)}: ${t(ATTENDANCE_STATUS_LABEL_KEYS[record.status])}`
|
||||
: formatDateKey(d)
|
||||
}
|
||||
>
|
||||
<span className="tabular-nums">{d.getDate()}</span>
|
||||
{record ? (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 h-1.5 w-1.5 rounded-full",
|
||||
ATTENDANCE_STATUS_DOT_COLORS[record.status],
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{usedStatuses.size > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-3 pt-1 text-xs text-muted-foreground">
|
||||
{Array.from(usedStatuses).map((status) => (
|
||||
<span key={status} className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn("h-2 w-2 rounded-full", ATTENDANCE_STATUS_DOT_COLORS[status])}
|
||||
aria-hidden
|
||||
/>
|
||||
{t(ATTENDANCE_STATUS_LABEL_KEYS[status])}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user