Files
NextEdu/src/modules/attendance/components/attendance-sheet.tsx
SpecialX 048fc1c386 feat(attendance): add correlation, trend, warnings, report print, and services
- Add attendance-grade-correlation-card and data-access-correlation, correlation-compute

- Add attendance-trend-chart and trend-compute for trend analysis

- Add attendance-warnings-card and warning-compute for attendance warnings

- Add attendance-report-print for printable reports

- Add class-comparison-card for class attendance comparison

- Add notifications and services directory
2026-07-03 10:24:16 +08:00

504 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useState, useRef, useEffect, useCallback, useMemo } 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, School } 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 { resolveActionError } from "@/shared/lib/resolve-action-error"
import { batchRecordAttendanceAction } from "../actions"
import {
ATTENDANCE_STATUS_OPTIONS,
ATTENDANCE_STATUS_SHORTCUTS,
ATTENDANCE_STATUS_LABEL_KEYS,
createInitialStatusCounts,
type AttendanceStatus,
} from "../constants"
import type { AttendancePeriod } from "../types"
type Option = { id: string; name: string }
type Student = { id: string; name: string; email: string }
/** L-6节次选项顺序即选择器中的展示顺序。 */
const ATTENDANCE_PERIOD_OPTIONS: AttendancePeriod[] = [
"full_day",
"morning_reading",
"morning",
"afternoon",
"evening",
]
/** L-6节次对应的 i18n key 后缀。 */
const ATTENDANCE_PERIOD_LABEL_KEYS: Record<AttendancePeriod, string> = {
full_day: "period.full_day",
morning_reading: "period.morning_reading",
morning: "period.morning",
afternoon: "period.afternoon",
evening: "period.evening",
}
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 },
school_activity: { active: "bg-cyan-500 text-white border-cyan-500 hover:bg-cyan-600", icon: School },
}
/** 需要填写原因的状态(缺勤/迟到/早退/请假/校内活动)。 */
const STATUSES_REQUIRING_REASON: ReadonlySet<AttendanceStatus> = new Set([
"absent",
"late",
"early_leave",
"excused",
"school_activity",
])
function SubmitButton() {
const { pending } = useFormStatus()
const t = useTranslations("attendance")
return (
<Button type="submit" disabled={pending}>
{pending ? t("actions.save") + "..." : t("actions.save")}
</Button>
)
}
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)
// L-6节次默认 full_day向后兼容已有数据
const [period, setPeriod] = useState<AttendancePeriod>("full_day")
const [statuses, setStatuses] = useState<Record<string, AttendanceStatus>>({})
const [reasons, setReasons] = useState<Record<string, string>>({})
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 containerRef = useRef<HTMLDivElement>(null)
const handleStatusChange = useCallback((studentId: string, status: AttendanceStatus) => {
setStatuses((prev) => ({ ...prev, [studentId]: status }))
// 切回 present 时清除原因present 不需要原因)
if (status === "present") {
setReasons((prev) => {
if (!prev[studentId]) return prev
const next = { ...prev }
delete next[studentId]
return next
})
}
}, [])
const handleReasonChange = useCallback((studentId: string, reason: string) => {
setReasons((prev) => ({ ...prev, [studentId]: reason }))
}, [])
const markAllPresent = useCallback(() => {
const all: Record<string, AttendanceStatus> = {}
for (const s of students) all[s.id] = "present"
setStatuses(all)
setReasons({})
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({})
setReasons({})
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 = useMemo(
() =>
ATTENDANCE_STATUS_OPTIONS.reduce(
(acc, st) => {
acc[st] = students.filter((s) => (statuses[s.id] ?? "present") === st).length
return acc
},
createInitialStatusCounts()
),
[students, statuses]
)
// 派生值:当筛选结果变少时,焦点索引自动夹紧到有效范围,避免 useEffect 重置导致的级联渲染
const effectiveFocusedIndex = filteredStudents.length === 0
? 0
: Math.min(focusedStudentIndex, filteredStudents.length - 1)
useEffect(() => {
const container = containerRef.current
if (!container) return
const handleKeyDown = (e: KeyboardEvent) => {
// P2-7 修复:限制监听范围到容器内,并排除所有可交互元素
const target = e.target
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable)
) {
return
}
const key = e.key.toLowerCase()
if (ATTENDANCE_STATUS_SHORTCUTS[key] && filteredStudents[effectiveFocusedIndex]) {
e.preventDefault()
handleStatusChange(filteredStudents[effectiveFocusedIndex].id, ATTENDANCE_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)
}
}
container.addEventListener("keydown", handleKeyDown)
return () => container.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) => {
const status = statuses[s.id] ?? "present"
return {
studentId: s.id,
classId,
date,
status,
// L-6节次考勤提交时携带当前选中的节次
period,
// 仅当状态需要原因且原因非空时携带present 不需要原因)
reason:
STATUSES_REQUIRING_REASON.has(status) && reasons[s.id]
? reasons[s.id].slice(0, 255)
: undefined,
}
})
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(resolveActionError(result, t, t("errors.unexpected")))
}
} catch {
toast.error(t("errors.unexpected"))
} finally {
setIsSubmitting(false)
}
}
return (
<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.saving")}
</div>
</div>
)}
<CardHeader>
<CardTitle>{t("title.sheet")}</CardTitle>
<p className="text-sm text-muted-foreground">
{t("description.teacherRecords")}
</p>
</CardHeader>
<CardContent>
<form action={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="class-select">{t("filters.class")}</Label>
<Select value={classId} onValueChange={handleClassChange}>
<SelectTrigger id="class-select">
<SelectValue placeholder={t("sheet.selectClass")} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="date">{t("filters.date")}</Label>
<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" />
<Input
id="date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="pl-9"
required
aria-label={t("filters.date")}
/>
</div>
</div>
{/* L-6节次选择器允许按节次记录考勤 */}
<div className="grid gap-2">
<Label htmlFor="period-select">{t("period.label")}</Label>
<Select value={period} onValueChange={(v) => setPeriod(v as AttendancePeriod)}>
<SelectTrigger id="period-select">
<SelectValue placeholder={t("period.label")} />
</SelectTrigger>
<SelectContent>
{ATTENDANCE_PERIOD_OPTIONS.map((p) => (
<SelectItem key={p} value={p}>
{t(ATTENDANCE_PERIOD_LABEL_KEYS[p])}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{students.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("sheet.noStudents")}
</p>
) : (
<>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
{ATTENDANCE_STATUS_OPTIONS.map((st) => {
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}>
{t("actions.markAllPresent")}
</Button>
</div>
</div>
<div ref={containerRef} className="rounded-md border" tabIndex={-1}>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>{t("list.columns.student")}</TableHead>
<TableHead className="hidden md:table-cell">{t("list.columns.reason")}</TableHead>
<TableHead>{t("list.columns.status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredStudents.map((s, idx) => {
const currentStatus = statuses[s.id] ?? "present"
const isFocused = idx === effectiveFocusedIndex
const needsReason = STATUSES_REQUIRING_REASON.has(currentStatus)
return (
<TableRow
key={s.id}
ref={(el) => { 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}
>
<TableCell className="text-muted-foreground tabular-nums">{idx + 1}</TableCell>
<TableCell className="font-medium">
<div className="flex flex-col">
<span>{s.name}</span>
<span className="text-xs text-muted-foreground md:hidden">{s.email}</span>
</div>
</TableCell>
<TableCell className="hidden md:table-cell">
<Input
type="text"
value={reasons[s.id] ?? ""}
onChange={(e) => handleReasonChange(s.id, e.target.value)}
onClick={(e) => e.stopPropagation()}
disabled={!needsReason}
placeholder={needsReason ? t("sheet.reasonPlaceholder") : ""}
maxLength={255}
className="h-8 text-sm"
aria-label={t("list.columns.reason")}
/>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{ATTENDANCE_STATUS_OPTIONS.map((st) => {
const Icon = STATUS_STYLES[st].icon
const isActive = currentStatus === st
return (
<button
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>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</>
)}
<CardFooter className="justify-end gap-2 px-0">
<Button type="button" variant="outline" onClick={() => router.back()}>
{t("actions.cancel")}
</Button>
<SubmitButton />
</CardFooter>
</form>
</CardContent>
<AlertDialog open={showSwitchConfirm} onOpenChange={setShowSwitchConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("sheet.confirmClassSwitch")}</AlertDialogTitle>
<AlertDialogDescription>
{t("sheet.confirmClassSwitch")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("actions.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (pendingClassId) {
confirmClassSwitch(pendingClassId)
setPendingClassId(null)
}
setShowSwitchConfirm(false)
}}
>
{t("sheet.confirmClassSwitchAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
)
}