"use client"; /** * 考勤点名表页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2) * * 数据契约: * - 查询 attendanceSheet(classId, date):❌ schema 无此字段 → MSW 兜底(@contract-pending) * - mutation saveAttendanceSheet(input):❌ schema 无 Mutation → MSW 兜底(@contract-pending) * - 契约工单:docs/architecture/issues/contracts/classes_contract.md#attendance-sheet * * 三态规范(§11.3 DoD): * - loading:FormPageSkeleton(由 server page Suspense 兜底) * - error:errorSummary 表单级错误 * - success:notify.success + 刷新列表 * * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 */ import { ClipboardCheck } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useTransition, useState } from "react"; import { useTranslations } from "next-intl"; import { ATTENDANCE_STATUS, useAttendanceSheet, useSaveAttendanceSheet, type AttendanceEntryInput, } from "@/lib/api"; import { FormPageShell } from "@/shared/components/page-templates"; import { notify } from "@/shared/lib/notify"; import { attendanceStatusToBadgeClass, formatAttendanceStatus, } from "@/features/teacher/attendance/transformations"; const STATUS_OPTIONS = [ ATTENDANCE_STATUS.PRESENT, ATTENDANCE_STATUS.ABSENT, ATTENDANCE_STATUS.LATE, ATTENDANCE_STATUS.LEAVE, ]; /** * 表单客户端主体。需由 server page 包裹在 中。 */ export function AttendanceSheetClient(): React.ReactElement { const t = useTranslations("attendance"); const router = useRouter(); const searchParams = useSearchParams(); const [, startTransition] = useTransition(); const presetClassId = searchParams.get("classId") ?? ""; const today = new Date().toISOString().slice(0, 10); const presetDate = searchParams.get("date") ?? today; const [classId, setClassId] = useState(presetClassId); const [date, setDate] = useState(presetDate); const [entries, setEntries] = useState>( {}, ); const [submitting, setSubmitting] = useState(false); // @contract-pending:MSW 兜底 const { data, loading, error } = useAttendanceSheet(classId, date, { enabled: classId.length > 0 && date.length > 0, }); // 初始化 entries(首次加载数据后) useEffect(() => { if (!data) return; setEntries((prev) => { if (Object.keys(prev).length > 0) return prev; const next: Record = {}; for (const e of data.entries) { next[e.studentId] = { studentId: e.studentId, status: e.status, remark: e.remark ?? undefined, }; } return next; }); }, [data]); const handleStatusChange = (studentId: string, status: string): void => { setEntries((prev) => ({ ...prev, [studentId]: { studentId, status, remark: prev[studentId]?.remark, }, })); }; const { run: saveSheet } = useSaveAttendanceSheet(); const handleSubmit = async (): Promise => { if (!classId.trim()) { notify.error(t("sheet.errorClassRequired")); return; } if (!date) { notify.error(t("sheet.errorDateRequired")); return; } const entryList = Object.values(entries); if (entryList.length === 0) { notify.warning(t("sheet.errorNoEntries")); return; } setSubmitting(true); try { await saveSheet({ classId, date, entries: entryList }); notify.success(t("sheet.success")); startTransition(() => { router.push( `/shell/teacher/attendance?classId=${classId}&date=${date}`, ); }); } catch (err) { notify.error(`${t("sheet.error")}: ${String(err)}`); } finally { setSubmitting(false); } }; const errorSummary = error ? (

{t("sheet.loadFailed", { message: String(error) })}

) : undefined; return ( } backHref="/shell/teacher/attendance" loading={loading && !data} submitting={submitting} submitLabel={t("sheet.submit")} cancelLabel={t("sheet.cancel")} errorSummary={errorSummary} onSubmit={handleSubmit} >
{data ? (
{data.entries.map((entry) => { const current = entries[entry.studentId]; const status = current?.status ?? entry.status; return ( ); })}
{t("sheet.colStudentName")} {t("sheet.colStatus")} {t("sheet.colRemark")}
{entry.studentName}
{STATUS_OPTIONS.map((opt) => ( ))}
{current?.remark ?? entry.remark ?? "-"}

{t("sheet.mswNotice")}

) : null}
); }