"use client" import { ChevronLeft, ChevronRight } from "lucide-react" import { useState, useRef, type KeyboardEvent } 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 { 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 = [] 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() ) } /** * 家长视角的考勤月历视图。 * 基于子女的近期考勤记录,在月历上按状态着色,让家长直观看到出勤分布。 * 支持键盘方向键导航(a11y):← → ↑ ↓ 在日期格子间移动,Home/End 跳至行首/尾。 */ 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 [focusedIdx, setFocusedIdx] = useState(-1) const gridRef = useRef(null) const recordMap = new Map() 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) } setFocusedIdx(-1) } const goNext = () => { if (viewMonth === 11) { setViewMonth(0) setViewYear((y) => y + 1) } else { setViewMonth((m) => m + 1) } setFocusedIdx(-1) } /** * 键盘导航:方向键在日期格子间移动,Home/End 跳至行首/尾。 * 仅在非空格子(有 Date 的格子)间移动,跳过 null 占位。 */ const handleKeyDown = (e: KeyboardEvent) => { if (focusedIdx < 0) return const nonNullIndices = days .map((d, i) => (d ? i : -1)) .filter((i) => i >= 0) const currentPos = nonNullIndices.indexOf(focusedIdx) if (currentPos < 0) return const cols = 7 const currentRow = Math.floor(currentPos / cols) const currentCol = currentPos % cols let nextPos = currentPos switch (e.key) { case "ArrowRight": nextPos = Math.min(currentPos + 1, nonNullIndices.length - 1) break case "ArrowLeft": nextPos = Math.max(currentPos - 1, 0) break case "ArrowDown": { const target = (currentRow + 1) * cols + currentCol nextPos = target < nonNullIndices.length ? target : currentPos break } case "ArrowUp": { const target = (currentRow - 1) * cols + currentCol nextPos = target >= 0 ? target : currentPos break } case "Home": nextPos = currentRow * cols break case "End": nextPos = Math.min((currentRow + 1) * cols - 1, nonNullIndices.length - 1) break default: return } e.preventDefault() const nextIdx = nonNullIndices[nextPos] if (nextIdx !== undefined && nextIdx !== focusedIdx) { setFocusedIdx(nextIdx) const cell = gridRef.current?.querySelector(`[data-day-idx="${nextIdx}"]`) cell?.focus() } } const usedStatuses = new Set() for (const r of recordMap.values()) usedStatuses.add(r.status) return ( {t("parent.calendarFor", { name: summary.studentName })}
{monthLabel}
{WEEKDAY_KEYS.map((key) => (
{t(key)}
))}
{days.map((d, idx) => { if (!d) return
const key = formatDateKey(d) const record = recordMap.get(key) const isToday = isSameDay(d, now) const isFocused = focusedIdx === idx return (
setFocusedIdx(idx)} className={cn( "relative flex aspect-square flex-col items-center justify-center rounded-md border text-xs", "min-h-[36px] cursor-default", record ? "border-border bg-muted/30" : "border-transparent", isToday && "ring-2 ring-primary ring-offset-1", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", )} role="gridcell" aria-label={ record ? `${formatDateKey(d)}: ${t(ATTENDANCE_STATUS_LABEL_KEYS[record.status])}` : formatDateKey(d) } aria-selected={isFocused} > {d.getDate()} {record ? ( ) : null}
) })}
{usedStatuses.size > 0 ? (
{Array.from(usedStatuses).map((status) => ( {t(ATTENDANCE_STATUS_LABEL_KEYS[status])} ))}
) : null} ) }