Files
Edu/apps/portal-shell/src/features/teacher/attendance/attendance-sheet-client.tsx
SpecialX 73e09ca29a feat(portal-shell): attendance + classes + students 模块 8 页迁移(教师域 §9.1 B2)
§9.1 line 632-634 教师域:
- /shell/teacher/attendance (列表) / /sheet (表单) / /report (报表) / /stats (统计) — 4 页
- /shell/teacher/classes (列表) / /[id] (详情) / /schedule (课表) — 3 页
- /shell/teacher/students (列表) — 1 页
契约:
- attendance 全  → MSW 兜底
- classes 🟡 classInfo(id)  真实单查 + 列表  MSW
- students 全  → MSW 兜底

新增文件:
- src/lib/api/{attendance,classes,students}.ts (14 hooks 合计)
- src/lib/api/operations/{attendance,classes,students}.graphql.ts (14 documents)
- src/features/teacher/{attendance,classes,students}/ (clients + transformations + tests)
- src/app/shell/teacher/{attendance,classes,students}/ (8 page.tsx + 3 loading + 3 error)

修改文件:
- src/mocks/graphql-data.ts (12 mock 数据 + handler cases)
- src/messages/{zh-CN,en}.json (attendance/classes/students i18n 命名空间)
- src/lib/api/{index,operations/index}.ts (导出 attendance/classes/students)
- src/shared/lib/route-permissions.ts (attendance/classes/students 路由权限)
- scripts/check-page-count.ts (baseline 40 → 48)

DoD 验收(§11.3 11 项):
- typecheck 0 errors
- lint 0 errors
- vitest 566 tests passed
- lint:tokens 0 errors
- check:pages 48 PASS
- route-permissions 已声明
- 三态齐备
- @contract-pending + MSW 兜底
- i18n zh-CN + en 同步

设计决策:
- classes/[id] 走真实 classInfo(id) 查询
- 无 STUDENT_* 权限点,students 路由复用 CLASS_READ/CLASS_MANAGE

关联:ARCHITECTURE.md §5.3 / §5.4 / §5.5 / §9.1 / §10 P2 / §11.3 / §11.4
契约工单:docs/architecture/issues/contracts/core-edu_contract.md
2026-07-22 21:58:07 +08:00

233 lines
7.9 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";
/**
* 考勤点名表页 - 客户端组件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
* - loadingFormPageSkeleton由 server page Suspense 兜底)
* - errorerrorSummary 表单级错误
* - successnotify.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 包裹在 <Suspense> 中。
*/
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<Record<string, AttendanceEntryInput>>(
{},
);
const [submitting, setSubmitting] = useState(false);
// @contract-pendingMSW 兜底
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<string, AttendanceEntryInput> = {};
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<void> => {
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 ? (
<p className="text-sm text-destructive">
{t("sheet.loadFailed", { message: String(error) })}
</p>
) : undefined;
return (
<FormPageShell
title={t("sheet.title")}
description={t("sheet.description")}
icon={<ClipboardCheck className="size-6" />}
backHref="/shell/teacher/attendance"
loading={loading && !data}
submitting={submitting}
submitLabel={t("sheet.submit")}
cancelLabel={t("sheet.cancel")}
errorSummary={errorSummary}
onSubmit={handleSubmit}
>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium">{t("sheet.classId")}</span>
<input
type="text"
value={classId}
onChange={(e) => setClassId(e.target.value)}
placeholder={t("sheet.classIdPlaceholder")}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-sm font-medium">{t("sheet.date")}</span>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</label>
</div>
{data ? (
<div className="space-y-3">
<div className="rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">
{t("sheet.colStudentName")}
</th>
<th className="p-3 text-left font-medium">
{t("sheet.colStatus")}
</th>
<th className="p-3 text-left font-medium">
{t("sheet.colRemark")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{data.entries.map((entry) => {
const current = entries[entry.studentId];
const status = current?.status ?? entry.status;
return (
<tr key={entry.studentId}>
<td className="p-3 font-medium">{entry.studentName}</td>
<td className="p-3">
<div className="flex flex-wrap items-center gap-2">
{STATUS_OPTIONS.map((opt) => (
<button
key={opt}
type="button"
onClick={() =>
handleStatusChange(entry.studentId, opt)
}
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium transition-opacity ${
status === opt
? attendanceStatusToBadgeClass(opt)
: "bg-muted text-muted-foreground opacity-50"
}`}
aria-pressed={status === opt}
>
{formatAttendanceStatus(opt)}
</button>
))}
</div>
</td>
<td className="p-3 text-xs text-muted-foreground">
{current?.remark ?? entry.remark ?? "-"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="text-xs text-muted-foreground">
{t("sheet.mswNotice")}
</p>
</div>
) : null}
</div>
</FormPageShell>
);
}