"use client" import { useState, useRef, useEffect, useCallback } from "react" import { useFormStatus } from "react-dom" import { toast } from "sonner" import { useRouter } from "next/navigation" 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 { Button } from "@/shared/components/ui/button" import { Input } from "@/shared/components/ui/input" import { Label } from "@/shared/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/shared/components/ui/select" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } 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 { ATTENDANCE_STATUS_LABEL_KEYS, type AttendanceStatus, } from "../constants" type Option = { id: string; name: string } type Student = { id: string; name: string; email: string } const STATUS_OPTIONS: AttendanceStatus[] = [ "present", "absent", "late", "early_leave", "excused", ] const STATUS_SHORTCUTS: Record = { p: "present", a: "absent", l: "late", e: "early_leave", x: "excused", } const STATUS_STYLES: Record = { 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 { return { present: 0, absent: 0, late: 0, early_leave: 0, excused: 0, } } function SubmitButton() { const { pending } = useFormStatus() const t = useTranslations("attendance") return ( ) } export function AttendanceSheet({ classes, students, defaultClassId, defaultDate, }: { classes: Option[] students: Student[] defaultClassId?: string defaultDate?: string }) { const router = useRouter() const t = useTranslations("attendance") const today = new Date().toISOString().slice(0, 10) const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "") const [date, setDate] = useState(defaultDate ?? today) const [statuses, setStatuses] = useState>({}) const [searchQuery, setSearchQuery] = useState("") const [focusedStudentIndex, setFocusedStudentIndex] = useState(0) const [isSubmitting, setIsSubmitting] = useState(false) const [showSwitchConfirm, setShowSwitchConfirm] = useState(false) const [pendingClassId, setPendingClassId] = useState(null) const studentRefs = useRef<(HTMLTableRowElement | null)[]>([]) const handleStatusChange = useCallback((studentId: string, status: AttendanceStatus) => { setStatuses((prev) => ({ ...prev, [studentId]: status })) }, []) const markAllPresent = useCallback(() => { const all: Record = {} for (const s of students) all[s.id] = "present" 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) => { if (!classId || !date) { toast.error(t("errors.invalidForm")) return } const records = students.map((s) => ({ studentId: s.id, classId, date, status: statuses[s.id] ?? "present", })) if (records.length === 0) { toast.error(t("sheet.noStudents")) return } setIsSubmitting(true) formData.set("recordsJson", JSON.stringify(records)) try { const result = await batchRecordAttendanceAction(null, formData) if (result.success) { toast.success(result.message || t("sheet.saved")) router.push("/teacher/attendance") router.refresh() } else { toast.error(result.message || t("errors.unexpected")) } } catch { toast.error(t("errors.unexpected")) } finally { setIsSubmitting(false) } } return ( {isSubmitting && (
{t("sheet.saved")}...
)} {t("title.sheet")}

{t("description.teacherRecords")}

setDate(e.target.value)} className="pl-9" required aria-label={t("filters.date")} />
{students.length === 0 ? (

{t("sheet.noStudents")}

) : ( <>
{STATUS_OPTIONS.map((st) => { const Icon = STATUS_STYLES[st].icon return ( ) })}
setSearchQuery(e.target.value)} className="h-8 w-40 pl-8 text-sm" aria-label={t("list.columns.student")} />
# {t("list.columns.student")} {t("list.columns.remark")} {t("list.columns.status")} {filteredStudents.map((s, idx) => { const currentStatus = statuses[s.id] ?? "present" const isFocused = idx === effectiveFocusedIndex return ( { studentRefs.current[idx] = el }} className={cn("cursor-pointer", isFocused && "bg-primary/5")} onClick={() => setFocusedStudentIndex(idx)} role="button" tabIndex={isFocused ? 0 : -1} aria-label={s.name} > {idx + 1} {s.name} {s.email}
{STATUS_OPTIONS.map((st) => { const Icon = STATUS_STYLES[st].icon const isActive = currentStatus === st return ( ) })}
) })}
)}
{t("sheet.confirmDelete")} {t("description.teacherRecords")} {t("actions.cancel")} { if (pendingClassId) { confirmClassSwitch(pendingClassId) setPendingClassId(null) } setShowSwitchConfirm(false) }} > {t("actions.save")} ) }