feat: 考勤模块重构与钉钉考勤同步
This commit is contained in:
275
apps/admin/src/pages/Attendance/AttendanceAdminHeader.tsx
Normal file
275
apps/admin/src/pages/Attendance/AttendanceAdminHeader.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
DatePicker,
|
||||
Select,
|
||||
Spin,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
ExportOutlined,
|
||||
FileSearchOutlined,
|
||||
ReloadOutlined,
|
||||
ScheduleOutlined,
|
||||
UndoOutlined,
|
||||
WarningFilled,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import {
|
||||
ADMIN_METRIC_META,
|
||||
STATUS_META,
|
||||
type AlertItem,
|
||||
type AttendanceSummary,
|
||||
type ClassOption,
|
||||
type DingTalkSyncStatus,
|
||||
type HistoryScheduleOption,
|
||||
} from './AttendanceAdmin.helpers';
|
||||
|
||||
export const AttendanceAdminHeader: React.FC<{
|
||||
syncStatus: DingTalkSyncStatus | null;
|
||||
canEdit: boolean;
|
||||
refreshingDingTalk: boolean;
|
||||
onOpenPeriodConfig: () => void;
|
||||
onRefreshDingTalk: () => void;
|
||||
onExport: () => void;
|
||||
attendanceDate: Dayjs | null;
|
||||
onDateChange: (date: Dayjs | null) => void;
|
||||
classId?: number;
|
||||
onClassChange: (value?: number) => void;
|
||||
classOptions: ClassOption[];
|
||||
effectiveScheduleId?: number;
|
||||
onScheduleChange: (value?: number) => void;
|
||||
scheduleOptions: HistoryScheduleOption[];
|
||||
scheduleOptionsLoading: boolean;
|
||||
session?: string;
|
||||
onSessionChange: (value?: string) => void;
|
||||
sessionOptions: Array<{ value: string; label: string }>;
|
||||
onReset: () => void;
|
||||
onQuery: () => void;
|
||||
selectedClass: string;
|
||||
dateLabel: string;
|
||||
visibleStudentCount: number;
|
||||
total: number;
|
||||
headTeacherNames: string;
|
||||
lifeTeacherNames: string;
|
||||
subjectTeacherNames: string;
|
||||
attendanceRate: number;
|
||||
summary: AttendanceSummary;
|
||||
metricFilter: string;
|
||||
onMetricFilterChange: (key: string) => void;
|
||||
alerts: AlertItem[];
|
||||
}> = ({
|
||||
syncStatus,
|
||||
canEdit,
|
||||
refreshingDingTalk,
|
||||
onOpenPeriodConfig,
|
||||
onRefreshDingTalk,
|
||||
onExport,
|
||||
attendanceDate,
|
||||
onDateChange,
|
||||
classId,
|
||||
onClassChange,
|
||||
classOptions,
|
||||
effectiveScheduleId,
|
||||
onScheduleChange,
|
||||
scheduleOptions,
|
||||
scheduleOptionsLoading,
|
||||
session,
|
||||
onSessionChange,
|
||||
sessionOptions,
|
||||
onReset,
|
||||
onQuery,
|
||||
selectedClass,
|
||||
dateLabel,
|
||||
visibleStudentCount,
|
||||
total,
|
||||
headTeacherNames,
|
||||
lifeTeacherNames,
|
||||
subjectTeacherNames,
|
||||
attendanceRate,
|
||||
summary,
|
||||
metricFilter,
|
||||
onMetricFilterChange,
|
||||
alerts,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<header className="student-center-topbar">
|
||||
<div className="student-center-title">
|
||||
<h1>学生考勤中心</h1>
|
||||
<span>班级考勤总览</span>
|
||||
</div>
|
||||
<div className="student-center-actions">
|
||||
<Tooltip title={syncStatus?.detail || syncStatus?.action || '暂无钉钉拉取记录'}>
|
||||
<span className="student-sync-status">
|
||||
<i />
|
||||
{syncStatus?.lastPulledAt
|
||||
? `最近拉取钉钉 ${dayjs(syncStatus.lastPulledAt).format('YYYY-MM-DD HH:mm')}`
|
||||
: '暂无钉钉拉取记录'}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{canEdit && (
|
||||
<Button icon={<ScheduleOutlined />} onClick={onOpenPeriodConfig}>
|
||||
时段配置
|
||||
</Button>
|
||||
)}
|
||||
<PermissionButton
|
||||
permission="attendance:create"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={refreshingDingTalk}
|
||||
onClick={onRefreshDingTalk}
|
||||
>
|
||||
刷新钉钉考勤
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="attendance:export" icon={<ExportOutlined />} onClick={onExport}>
|
||||
导出当前报表
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="student-filter-panel" aria-label="考勤筛选">
|
||||
<div className="student-filter-field student-filter-field--date">
|
||||
<label>日期</label>
|
||||
<DatePicker
|
||||
allowClear={false}
|
||||
value={attendanceDate}
|
||||
disabledDate={(current) => current.isAfter(dayjs(), 'day')}
|
||||
onChange={onDateChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="student-filter-field">
|
||||
<label>班级</label>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="全部班级"
|
||||
value={classId}
|
||||
onChange={onClassChange}
|
||||
options={classOptions.map((item) => ({ value: item.classId, label: item.className }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="student-filter-field">
|
||||
<label>科目</label>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder={classId ? '全部科目' : '请先选择班级'}
|
||||
value={effectiveScheduleId}
|
||||
loading={scheduleOptionsLoading}
|
||||
disabled={!classId || !attendanceDate}
|
||||
notFoundContent={scheduleOptionsLoading ? <Spin size="small" /> : '当前日期没有排课'}
|
||||
onChange={onScheduleChange}
|
||||
options={scheduleOptions.map((schedule) => ({
|
||||
value: schedule.id,
|
||||
label: `${schedule.subject}(${schedule.startTime}-${schedule.endTime})`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="student-filter-field">
|
||||
<label>任课老师</label>
|
||||
<Select disabled placeholder="全部老师" />
|
||||
</div>
|
||||
<div className="student-filter-field">
|
||||
<label>时段</label>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部时段"
|
||||
value={session}
|
||||
onChange={onSessionChange}
|
||||
options={sessionOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="student-filter-actions">
|
||||
<Button icon={<UndoOutlined />} onClick={onReset}>
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" icon={<FileSearchOutlined />} onClick={onQuery}>
|
||||
查询
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="student-class-overview" aria-label="班级考勤汇总">
|
||||
<div className="student-class-identity">
|
||||
<div className="student-class-heading">
|
||||
<span className="student-overview-kicker">班级考勤概览</span>
|
||||
<h2>{selectedClass}</h2>
|
||||
<p>
|
||||
{dateLabel} · 当前展示 {visibleStudentCount} 名学生 / {total} 条记录
|
||||
</p>
|
||||
</div>
|
||||
<div className="student-teacher-list" aria-label="筛选上下文">
|
||||
<div className="student-teacher-item">
|
||||
<Avatar>班</Avatar>
|
||||
<div>
|
||||
<span>班主任</span>
|
||||
<strong>{headTeacherNames}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="student-teacher-item">
|
||||
<Avatar>生</Avatar>
|
||||
<div>
|
||||
<span>生活老师</span>
|
||||
<strong>{lifeTeacherNames}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="student-teacher-item">
|
||||
<Avatar>任</Avatar>
|
||||
<div>
|
||||
<span>任课老师</span>
|
||||
<strong>{subjectTeacherNames}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="student-metric-strip">
|
||||
{ADMIN_METRIC_META.map((metric) => {
|
||||
const value =
|
||||
metric.key === 'all'
|
||||
? `${attendanceRate}%`
|
||||
: metric.key === 'absent'
|
||||
? summary.absent + summary.pending
|
||||
: (summary[metric.key as keyof AttendanceSummary] ?? 0);
|
||||
const meta = STATUS_META[metric.key] ?? { className: 'is-present' };
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={metric.key}
|
||||
className={`student-metric-card ${metricFilter === metric.key ? 'active' : ''}`}
|
||||
onClick={() => onMetricFilterChange(metric.key)}
|
||||
>
|
||||
<span className={`student-metric-icon ${meta.className}`}>{metric.short}</span>
|
||||
<span className="student-metric-copy">
|
||||
<strong>{value}</strong>
|
||||
<small>{metric.label}</small>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{alerts.length > 0 && (
|
||||
<div className="archive-alert">
|
||||
<WarningFilled />
|
||||
<div>
|
||||
<strong>{alerts.length} 名学生存在连续异常</strong>
|
||||
<span>建议优先核查最近 14 天的缺勤记录</span>
|
||||
</div>
|
||||
<Tooltip
|
||||
title={alerts
|
||||
.slice(0, 5)
|
||||
.map((item) => `${item.studentName}:${item.type}${item.count}次`)
|
||||
.join(';')}
|
||||
>
|
||||
<Button type="link">查看摘要</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user