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
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
"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 包裹在 <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-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<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user