Files
gongxue-base/apps/admin/src/pages/Attendance/AttendanceAdminHeader.tsx

275 lines
8.5 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.

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>
)}
</>
);
};