按 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 已更新
343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
"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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||
*
|
||
* 关联: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-pending:MSW 兜底
|
||
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 };
|