Files
Edu/apps/portal-shell/src/features/teacher/attendance/attendance-report-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

207 lines
6.2 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
*
* 数据契约:
* - 报表查询 attendanceReport(classId, range):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/classes_contract.md#attendance-report
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { FileText } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useAttendanceReport, type AttendanceReportItem } from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
import {
attendanceRateToColorClass,
formatAttendanceRate,
} from "@/features/teacher/attendance/transformations";
/**
* 报表客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function AttendanceReportClient(): React.ReactElement {
const t = useTranslations("attendance");
const tCommon = useTranslations("common");
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? "cls-001";
const startDate = searchParams.get("startDate") ?? undefined;
const endDate = searchParams.get("endDate") ?? undefined;
// @contract-pendingMSW 兜底
const { data, loading, error } = useAttendanceReport(
classId,
startDate,
endDate,
);
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{t("report.mswNotice")}
</p>
</div>
) : undefined;
const emptyNode =
!loading && !error && !data ? (
<EmptyState
icon={FileText}
title={t("report.notFound")}
action={{
label: t("report.backToList"),
href: "/shell/teacher/attendance",
}}
/>
) : undefined;
return (
<DetailPageShell
title={t("report.title")}
description={
data
? t("report.subtitle", {
className: data.className,
range: data.range,
})
: undefined
}
icon={<FileText className="size-6" />}
backHref="/shell/teacher/attendance"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={emptyNode}
>
{data ? <ReportSummarySection data={data} /> : null}
{data ? <ReportItemsSection items={data.items} /> : null}
</DetailPageShell>
);
}
/**
* 报表汇总区。
*/
function ReportSummarySection({
data,
}: {
data: NonNullable<ReturnType<typeof useAttendanceReport>["data"]>;
}): React.ReactElement {
const t = useTranslations("attendance");
const summary = data.summary;
return (
<DetailSection title={t("report.sectionSummary")}>
<DetailField
label={t("report.fieldTotal")}
value={String(summary.total)}
/>
<DetailField
label={t("report.fieldPresent")}
value={String(summary.present)}
/>
<DetailField
label={t("report.fieldAbsent")}
value={String(summary.absent)}
/>
<DetailField label={t("report.fieldLate")} value={String(summary.late)} />
<DetailField
label={t("report.fieldLeave")}
value={String(summary.leave)}
/>
<DetailField
label={t("report.fieldAttendanceRate")}
value={
<span
className={`font-medium ${attendanceRateToColorClass(summary.attendanceRate)}`}
>
{formatAttendanceRate(summary.attendanceRate)}
</span>
}
/>
</DetailSection>
);
}
/**
* 报表明细区(按学生聚合)。
*/
function ReportItemsSection({
items,
}: {
items: AttendanceReportItem[];
}): React.ReactElement {
const t = useTranslations("attendance");
return (
<DetailSection title={t("report.sectionItems")}>
<div className="overflow-x-auto 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("report.colStudentName")}
</th>
<th className="p-3 text-center font-medium">
{t("report.colPresent")}
</th>
<th className="p-3 text-center font-medium">
{t("report.colAbsent")}
</th>
<th className="p-3 text-center font-medium">
{t("report.colLate")}
</th>
<th className="p-3 text-center font-medium">
{t("report.colLeave")}
</th>
<th className="p-3 text-right font-medium">
{t("report.colAttendanceRate")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item) => (
<tr key={item.studentId} className="hover:bg-muted/30">
<td className="p-3 font-medium">{item.studentName}</td>
<td className="p-3 text-center text-muted-foreground">
{item.present}
</td>
<td className="p-3 text-center text-muted-foreground">
{item.absent}
</td>
<td className="p-3 text-center text-muted-foreground">
{item.late}
</td>
<td className="p-3 text-center text-muted-foreground">
{item.leave}
</td>
<td
className={`p-3 text-right font-medium ${attendanceRateToColorClass(item.attendanceRate)}`}
>
{formatAttendanceRate(item.attendanceRate)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</DetailSection>
);
}