diff --git a/apps/admin/src/pages/Attendance/AttendanceAdmin.helpers.tsx b/apps/admin/src/pages/Attendance/AttendanceAdmin.helpers.tsx new file mode 100644 index 0000000..00448f0 --- /dev/null +++ b/apps/admin/src/pages/Attendance/AttendanceAdmin.helpers.tsx @@ -0,0 +1,199 @@ +import dayjs from 'dayjs'; +import type { AttendanceSummary } from './attendance-workspace'; +import type { LessonAttendanceRecord } from './types'; + +export type { AttendanceSummary } from './attendance-workspace'; + +export const DEFAULT_ATTENDANCE_PERIODS = [ + { periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1, enabled: true }, + { periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2, enabled: true }, + { periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3, enabled: true }, + { periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4, enabled: true }, +]; + +export const STATUS_META: Record< + string, + { label: string; color: string; className: string; short: string } +> = { + present: { label: '出勤', color: 'success', className: 'is-present', short: '勤' }, + absent: { label: '缺勤', color: 'error', className: 'is-absent', short: '缺' }, + leave: { label: '请假', color: 'processing', className: 'is-leave', short: '假' }, + pending: { label: '待确认', color: 'default', className: 'is-pending', short: '待' }, +}; + +export const ADMIN_CORRECTION_OPTIONS = [ + { value: 'present', label: '正常' }, + { value: 'leave', label: '请假' }, + { value: 'absent', label: '缺勤' }, +]; + +export interface ClassTeacherOption { + userId: number; + username: string | null; + name: string | null; + roleType: string; + subject: string | null; +} + +export interface ClassOption { + classId: number; + className: string; + teachers?: ClassTeacherOption[]; +} + +export type AttendanceRecordItem = LessonAttendanceRecord; + +export interface HistoryScheduleOption { + id: number; + classId: number; + weekDay: number; + startTime: string; + endTime: string; + startDate: string; + endDate: string; + subject: string; + teacherId: number | null; + teacherName?: string | null; + teacherUsername?: string | null; + status: string; +} + +export interface AlertItem { + studentId: number; + studentName: string; + studentNo: string; + className: string; + type: string; + count: number; + lastDate: string; +} + +export interface AttendancePeriodConfigItem { + id?: number; + periodKey: string; + label: string; + startTime: string; + endTime: string; + sortOrder: number; + enabled: boolean; +} + +export interface DingTalkSyncStatus { + lastPulledAt: string | null; + action: string | null; + username: string | null; + detail: string | null; +} + +export const EMPTY_SUMMARY: AttendanceSummary = { + total: 0, + present: 0, + late: 0, + absent: 0, + leave: 0, + pending: 0, +}; + +export function displayAttendanceStatus(status?: string | null): string { + return status === 'pending' || !status ? 'absent' : status; +} + +export function getTeacherDisplayName(teacher?: { + name?: string | null; + username?: string | null; +}): string { + const name = teacher?.name?.trim(); + if (name) return name; + return teacher?.username?.trim() || '未设置'; +} + +export function formatTeacherNames( + teachers: readonly { name?: string | null; username?: string | null }[], +): string { + const names = [ + ...new Set( + teachers + .map((teacher) => getTeacherDisplayName(teacher)) + .filter((name) => name && name !== '未设置'), + ), + ]; + return names.length > 0 ? names.join('、') : '未设置'; +} + +export function AttendanceStatusTag({ status }: { status: string }) { + const displayStatus = displayAttendanceStatus(status); + const meta = STATUS_META[displayStatus] ?? { + label: displayStatus, + color: 'default', + className: 'is-absent', + short: '?', + }; + return ( + + + {meta.label} + + ); +} + +export interface AdminStudentPanel { + key: string; + studentId: number; + studentName: string; + studentNo: string; + className: string; + records: AttendanceRecordItem[]; + statusBySession: Partial>; + primaryStatus: string; + rate: number; + latestDate: string; +} + +export const ADMIN_METRIC_META = [ + { key: 'all', label: '出勤率', short: '率' }, + { key: 'present', label: '正常', short: '正常' }, + { key: 'leave', label: '请假', short: '请假' }, + { key: 'absent', label: '缺勤', short: '缺勤' }, +]; + +function pickPrimaryStatus(records: AttendanceRecordItem[]) { + const priority = ['absent', 'leave', 'present']; + return ( + priority.find((item) => + records.some((record) => displayAttendanceStatus(record.status) === item), + ) || 'absent' + ); +} + +export function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminStudentPanel[] { + const map = new Map(); + for (const record of records) { + const current = map.get(record.studentId) ?? { + key: String(record.studentId), + studentId: record.studentId, + studentName: record.student?.name || '未知学生', + studentNo: record.student?.studentNo?.trim() || '', + className: record.class?.name || '未关联班级', + records: [], + statusBySession: {}, + primaryStatus: 'absent', + rate: 0, + latestDate: record.attendanceDate, + }; + current.records.push(record); + if (!current.statusBySession[record.session]) current.statusBySession[record.session] = record; + if (dayjs(record.attendanceDate).isAfter(dayjs(current.latestDate))) { + current.latestDate = record.attendanceDate; + } + map.set(record.studentId, current); + } + + return Array.from(map.values()).map((item) => { + const checked = item.records.filter((record) => record.status === 'present').length; + return { + ...item, + primaryStatus: pickPrimaryStatus(item.records), + rate: item.records.length > 0 ? Math.round((checked / item.records.length) * 100) : 0, + }; + }); +} diff --git a/apps/admin/src/pages/Attendance/AttendanceAdminColumns.tsx b/apps/admin/src/pages/Attendance/AttendanceAdminColumns.tsx new file mode 100644 index 0000000..a8965de --- /dev/null +++ b/apps/admin/src/pages/Attendance/AttendanceAdminColumns.tsx @@ -0,0 +1,131 @@ +import { Avatar, Segmented, Tag } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; +import EditableCell from '../../components/EditableCell'; +import { getPunchDisplayInfo } from './attendance-workspace'; +import { + ADMIN_CORRECTION_OPTIONS, + AttendanceStatusTag, + displayAttendanceStatus, + type AttendanceRecordItem, +} from './AttendanceAdmin.helpers'; + +export interface AttendanceAdminColumnContext { + isMobile: boolean; + canEdit: boolean; + sessionMap: Record; + correctingRecordId: number | null; + onSaveAdminRecordCell: ( + record: AttendanceRecordItem, + field: 'status' | 'remark', + value: unknown, + ) => void; + onUpdateAdminRecordStatus: (record: AttendanceRecordItem, nextStatus: string) => void; +} + +const buildAttendanceAdminDataColumns = (ctx: AttendanceAdminColumnContext) => { + const { isMobile, canEdit, sessionMap, onSaveAdminRecordCell } = ctx; + return [ + { + title: '学生', + dataIndex: ['student', 'name'], + fixed: (isMobile ? undefined : 'left') as 'left' | undefined, + width: 150, + render: (name: string, record: AttendanceRecordItem) => ( +
+ {name?.slice(0, 1)} +
+ {name || '-'} + {record.class?.name || '未关联班级'} +
+
+ ), + }, + { title: '日期', dataIndex: 'attendanceDate', width: 120 }, + { + title: '时段', + dataIndex: 'session', + width: 110, + render: (value: string) => sessionMap[value] || value, + }, + { + title: '状态', + dataIndex: 'status', + width: 105, + render: (value: string, record: AttendanceRecordItem) => ( + ({ + value: String(item.value), + label: item.label, + }))} + disabled={!canEdit} + onSave={async (next) => { + onSaveAdminRecordCell(record, 'status', next); + }} + > + + + ), + }, + { + title: '签到来源', + width: 220, + render: (_: unknown, record: AttendanceRecordItem) => { + const info = getPunchDisplayInfo(record); + if (!info) return ; + return ( +
+ {info.label} + {info.detail && {info.detail}} + {info.time && {dayjs(info.time).format('HH:mm:ss')}} +
+ ); + }, + }, + { + title: '备注', + dataIndex: 'remark', + ellipsis: true, + render: (value: string | null, record: AttendanceRecordItem) => ( + { + onSaveAdminRecordCell(record, 'remark', next); + }} + > + {value || } + + ), + }, + ] as ColumnsType; +}; + +const buildAttendanceAdminActionColumn = (ctx: AttendanceAdminColumnContext) => { + const { isMobile, correctingRecordId, onUpdateAdminRecordStatus } = ctx; + return { + title: '操作', + key: 'action', + fixed: (isMobile ? undefined : 'right') as 'right' | undefined, + width: 220, + render: (_: unknown, record: AttendanceRecordItem) => ( + void onUpdateAdminRecordStatus(record, String(value))} + /> + ), + }; +}; + +export const buildAttendanceAdminColumns = (ctx: AttendanceAdminColumnContext) => { + const dataColumns = buildAttendanceAdminDataColumns(ctx); + const actionColumn = ctx.canEdit ? [buildAttendanceAdminActionColumn(ctx)] : []; + return [...dataColumns, ...actionColumn] as ColumnsType; +}; diff --git a/apps/admin/src/pages/Attendance/AttendanceAdminHeader.tsx b/apps/admin/src/pages/Attendance/AttendanceAdminHeader.tsx new file mode 100644 index 0000000..751cf26 --- /dev/null +++ b/apps/admin/src/pages/Attendance/AttendanceAdminHeader.tsx @@ -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 ( + <> +
+
+

学生考勤中心

+ 班级考勤总览 +
+
+ + + + {syncStatus?.lastPulledAt + ? `最近拉取钉钉 ${dayjs(syncStatus.lastPulledAt).format('YYYY-MM-DD HH:mm')}` + : '暂无钉钉拉取记录'} + + + {canEdit && ( + + )} + } + loading={refreshingDingTalk} + onClick={onRefreshDingTalk} + > + 刷新钉钉考勤 + + } onClick={onExport}> + 导出当前报表 + +
+
+ +
+
+ + current.isAfter(dayjs(), 'day')} + onChange={onDateChange} + /> +
+
+ + : '当前日期没有排课'} + onChange={onScheduleChange} + options={scheduleOptions.map((schedule) => ({ + value: schedule.id, + label: `${schedule.subject}(${schedule.startTime}-${schedule.endTime})`, + }))} + /> +
+
+ + +
+
+ + +
+
+ +
+
+
+ 班级考勤概览 +

{selectedClass}

+

+ {dateLabel} · 当前展示 {visibleStudentCount} 名学生 / {total} 条记录 +

+
+
+
+ +
+ 班主任 + {headTeacherNames} +
+
+
+ +
+ 生活老师 + {lifeTeacherNames} +
+
+
+ +
+ 任课老师 + {subjectTeacherNames} +
+
+
+
+
+ {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 ( + + ); + })} +
+
+ + {alerts.length > 0 && ( +
+ +
+ {alerts.length} 名学生存在连续异常 + 建议优先核查最近 14 天的缺勤记录 +
+ `${item.studentName}:${item.type}${item.count}次`) + .join(';')} + > + + +
+ )} + + ); +}; diff --git a/apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx b/apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx new file mode 100644 index 0000000..3af0f74 --- /dev/null +++ b/apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx @@ -0,0 +1,194 @@ +import React from 'react'; +import { + Alert, + Avatar, + Button, + Drawer, + Form, + Input, + Modal, + Segmented, + Select, +} from 'antd'; +import { UndoOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { + ADMIN_CORRECTION_OPTIONS, + AttendanceStatusTag, + displayAttendanceStatus, + type AdminStudentPanel, + type AttendancePeriodConfigItem, + type AttendanceRecordItem, +} from './AttendanceAdmin.helpers'; + +export const PeriodConfigModal: React.FC<{ + open: boolean; + form: ReturnType>[0]; + onOk: () => void; + onCancel: () => void; + onReset: () => void; +}> = ({ open, form, onOk, onCancel, onReset }) => { + return ( + ( + <> + + + + + )} + > + +
+ + {(fields, { add, remove }) => ( +
+ {fields.map((field) => ( +
+ + + + + + + + + + + + + + { - setClassId(value); - setScheduleId(undefined); - setPage(1); - }} - options={classOptions.map((item) => ({ value: item.classId, label: item.className }))} - /> -
-
- - -
-
- - - - - - - - - - - - - -