275 lines
8.5 KiB
TypeScript
275 lines
8.5 KiB
TypeScript
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">
|
||
<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>
|
||
)}
|
||
</>
|
||
);
|
||
};
|