Files
Edu/apps/portal-shell/src/features/admin/attendance/admin-attendance-client.tsx
SpecialX f991bf0446 feat(portal-shell): 管理域全模块功能补齐与差异修复
按 ARCHITECTURE.md 与 admin-NeedTodo.md 要求补齐所有管理页面缺失功能:
- users/roles/permissions:权限矩阵搜索/折叠、zod 校验、value 字段
- audit-logs:行内详情对话框、分页页码、ChartCardShell
- school:CRUD 对话框、GradeOverviewCards、academic-year 侧栏
- announcements/invitation-codes/ai-settings:发布按钮、分页、zod 校验
- course-plans/elective:Select 导入、undefined 处理
- error-book/scheduling/questions/lesson-plans/attendance:统计卡片

验证:typecheck 0 错误、arch:scan 已更新
2026-07-30 17:49:49 +08:00

343 lines
11 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.4 / §10 P5 / admin-NeedTodo §四)
*
* 数据契约:
* - adminAttendanceStats():❌ schema 无 → MSW 兜底(@contract-pending
* - attendanceGradeCorrelation():❌ schema 无 → MSW 兜底
* - adminClasses():❌ schema 无 → MSW 兜底(用于班级筛选下拉)
* - grades():✅ 真实 schema用于年级筛选下拉
* - classComparison():❌ schema 无 → MSW 兜底(由 ClassComparisonCard 内部调用)
*
* URL 状态:?classId=xxx&status=xxx&date=xxx&gradeId=xxx&page=xxx
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { CalendarCheck } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import {
useAdminAttendanceStats,
useAdminClasses,
useAttendanceGradeCorrelation,
useGrades,
} from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { StatCard } from "@/shared/components/ui/stat-card";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
computeAbnormalRate,
computeAvgCorrelation,
formatAbnormalRate,
formatCorrelation,
formatRate,
formatRecordDate,
hasAttendanceData,
presentRateToColorClass,
truncateNote,
type AttendanceStatus,
} from "@/features/admin/attendance/transformations";
import { ClassComparisonCard } from "@/features/admin/attendance/class-comparison-card";
import { AttendanceGradeCorrelationCard } from "@/features/admin/attendance/attendance-grade-correlation-card";
import { AttendanceRecordsList } from "@/features/admin/attendance/attendance-records-list";
/** 考勤状态选项(用于筛选下拉) */
const STATUS_OPTIONS: readonly AttendanceStatus[] = [
"present",
"absent",
"late",
"leave",
] as const;
/**
* 考勤管理客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
*/
export function AdminAttendanceClient(): React.ReactElement {
const t = useTranslations("admin.attendance.list");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const classId = searchParams.get("classId") ?? "";
const status = searchParams.get("status") ?? "";
const date = searchParams.get("date") ?? "";
const gradeId = searchParams.get("gradeId") ?? "";
// @contract-pendingMSW 兜底
const { data: stats, loading, error } = useAdminAttendanceStats();
const { data: correlations } = useAttendanceGradeCorrelation();
const { data: classes } = useAdminClasses();
const { data: grades } = useGrades();
const avgCorrelation = useMemo(
() => computeAvgCorrelation(correlations ?? []),
[correlations],
);
const updateFilter = (key: string, next: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (next) {
params.set(key, next);
} else {
params.delete(key);
}
// 切换筛选时重置页码
if (key !== "page") {
params.delete("page");
}
startTransition(() => {
router.push(`/shell/admin/attendance?${params.toString()}`);
});
};
const hasData = hasAttendanceData(stats);
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("mswNotice")}</p>
</div>
) : undefined;
const emptyNode = (
<EmptyState
icon={CalendarCheck}
title={t("emptyTitle")}
description={t("emptyDescription")}
/>
);
return (
<ListPageShell
title={t("title")}
description={t("description")}
icon={<CalendarCheck className="size-6" />}
filters={
<AttendanceFilters
classId={classId}
status={status}
date={date}
gradeId={gradeId}
classes={classes ?? []}
grades={grades ?? []}
onClassChange={(v) => updateFilter("classId", v)}
onStatusChange={(v) => updateFilter("status", v)}
onDateChange={(v) => updateFilter("date", v)}
onGradeChange={(v) => updateFilter("gradeId", v)}
/>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={!hasData && !loading}
emptyNode={emptyNode}
errorNode={errorNode}
>
{stats ? (
<AttendanceContent stats={stats} avgCorrelation={avgCorrelation} />
) : null}
{/* 班级对比卡 + 考勤-成绩关联分析卡并排布局lg 以上双列) */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<ClassComparisonCard />
<AttendanceGradeCorrelationCard />
</div>
{/* 考勤记录列表:按 classId/status/date/gradeId 筛选 + URL 分页 */}
<AttendanceRecordsList
classId={classId}
status={status}
date={date}
gradeId={gradeId}
/>
<p className="text-xs text-muted-foreground">{t("mswNotice")}</p>
</ListPageShell>
);
}
/**
* 考勤筛选栏(年级 + 班级 + 状态 + 日期)。
*/
function AttendanceFilters({
classId,
status,
date,
gradeId,
classes,
grades,
onClassChange,
onStatusChange,
onDateChange,
onGradeChange,
}: {
classId: string;
status: string;
date: string;
gradeId: string;
classes: NonNullable<ReturnType<typeof useAdminClasses>["data"]>;
grades: NonNullable<ReturnType<typeof useGrades>["data"]>;
onClassChange: (v: string) => void;
onStatusChange: (v: string) => void;
onDateChange: (v: string) => void;
onGradeChange: (v: string) => void;
}): React.ReactElement {
const t = useTranslations("admin.attendance.list");
return (
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">
{t("gradeFilter")}
</label>
<select
value={gradeId}
onChange={(e) => onGradeChange(e.target.value)}
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
>
<option value="">{t("allGrades")}</option>
{grades.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">
{t("classFilter")}
</label>
<select
value={classId}
onChange={(e) => onClassChange(e.target.value)}
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
>
<option value="">{t("allClasses")}</option>
{classes.map((cls) => (
<option key={cls.id} value={cls.id}>
{cls.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">
{t("statusFilter")}
</label>
<select
value={status}
onChange={(e) => onStatusChange(e.target.value)}
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
>
<option value="">{t("allStatuses")}</option>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{t(
`status${s.charAt(0).toUpperCase()}${s.slice(1)}` as
| "statusPresent"
| "statusAbsent"
| "statusLate"
| "statusLeave",
)}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">
{t("dateFilter")}
</label>
<input
type="date"
value={date}
onChange={(e) => onDateChange(e.target.value)}
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
/>
</div>
</div>
);
}
/**
* 考勤主体内容(统计卡片)。
* 班级对比与考勤-成绩关联分析由独立卡片组件渲染ClassComparisonCard /
* AttendanceGradeCorrelationCard各自管理数据获取与三态。
*/
function AttendanceContent({
stats,
avgCorrelation,
}: {
stats: NonNullable<ReturnType<typeof useAdminAttendanceStats>["data"]>;
avgCorrelation: number;
}): React.ReactElement {
const t = useTranslations("admin.attendance.list");
const abnormalRate = computeAbnormalRate(
stats.absentRate,
stats.lateRate,
stats.earlyLeaveRate,
);
return (
<div className="flex flex-col gap-6">
{/* 统计卡片 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard title={t("statsTotalRecords")} value={stats.totalRecords} />
<StatCard
title={t("statsPresentRate")}
value={formatRate(stats.presentRate)}
valueClassName={presentRateToColorClass(stats.presentRate)}
/>
<StatCard
title={t("statsAbnormalRate")}
value={formatAbnormalRate(
stats.absentRate,
stats.lateRate,
stats.earlyLeaveRate,
)}
valueClassName={abnormalRate > 0.1 ? "text-destructive" : undefined}
/>
<StatCard
title={t("statsAvgCorrelation")}
value={formatCorrelation(avgCorrelation)}
/>
</div>
{/* 次级统计卡片 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<StatCard
title={t("statsAbsentRate")}
value={formatRate(stats.absentRate)}
valueClassName="text-destructive"
/>
<StatCard
title={t("statsLateRate")}
value={formatRate(stats.lateRate)}
valueClassName="text-amber-600 dark:text-amber-400"
/>
<StatCard
title={t("statsEarlyLeaveRate")}
value={formatRate(stats.earlyLeaveRate)}
valueClassName="text-sky-600 dark:text-sky-400"
/>
</div>
</div>
);
}
/**
* 备注/日期格式化辅助导出(供页面其他部分复用,对齐 DoD 纯函数)。
*/
export { formatRecordDate, truncateNote };