export type AttendanceExperience = 'teacher' | 'admin'; export type SchedulePhase = 'upcoming' | 'ongoing' | 'ended'; import { getRoleDomains } from '../../auth/menu-policy'; export function getAttendanceExperience( permissions: readonly string[], roles: readonly string[], ): AttendanceExperience { const domains = getRoleDomains(roles, permissions); if ( permissions.includes('attendance:edit') || domains.has('academic') || domains.has('super') ) { return 'admin'; } return 'teacher'; } function toMinuteOfDay(time: string): number { const [hour = 0, minute = 0] = time.split(':').map(Number); return hour * 60 + minute; } export function getSchedulePhase( startTime: string, endTime: string, now = new Date(), ): SchedulePhase { const current = now.getHours() * 60 + now.getMinutes(); if (current < toMinuteOfDay(startTime)) return 'upcoming'; if (current <= toMinuteOfDay(endTime)) return 'ongoing'; return 'ended'; } export function canPullAttendance(phase: SchedulePhase): boolean { return phase !== 'upcoming'; } export interface AttendanceSummary { total: number; present: number; late: number; absent: number; leave: number; pending: number; } export function summarizeAttendance(records: readonly { status: string }[]): AttendanceSummary { const summary: AttendanceSummary = { total: records.length, present: 0, late: 0, absent: 0, leave: 0, pending: 0, }; for (const record of records) { if (record.status in summary && record.status !== 'total') { summary[record.status as Exclude] += 1; } } return summary; } export interface LessonCheckinSummary { total: number; checkedIn: number; notCheckedIn: number; } export function summarizeLessonCheckins( records: readonly { status: string }[], ): LessonCheckinSummary { const checkedIn = records.filter( (record) => record.status === 'present' || record.status === 'late', ).length; return { total: records.length, checkedIn, notCheckedIn: records.length - checkedIn, }; } export type LessonAttendanceFilter = 'all' | 'checked_in' | 'not_checked_in'; export interface LessonAttendanceFilterRecord { student: { name: string }; status: string; } export function filterLessonAttendanceRecords( records: readonly T[], keyword: string, filter: LessonAttendanceFilter, ): T[] { const normalizedKeyword = keyword.trim().toLocaleLowerCase('zh-CN'); return records.filter((record) => { const matchesKeyword = !normalizedKeyword || record.student.name.toLocaleLowerCase('zh-CN').includes(normalizedKeyword); if (!matchesKeyword || filter === 'all') return matchesKeyword; const checkedIn = record.status === 'present' || record.status === 'late'; return filter === 'checked_in' ? checkedIn : !checkedIn; }); } export interface PunchDisplayRecord { status: string; source?: string; punchTime?: string | null; punchSource?: string | null; punchDeviceName?: string | null; punchDeviceId?: string | null; } export interface PunchDisplayInfo { label: string; machine: boolean; detail?: string; time?: string; } export function getPunchDisplayInfo(record: PunchDisplayRecord): PunchDisplayInfo | null { if (record.status !== 'present' && record.status !== 'late') return null; if (record.source === 'manual') return { label: '老师手动标记', machine: false }; const source = (record.punchSource || '').trim().toUpperCase(); const machine = ['ATM', 'ATTENDANCE_MACHINE', 'MACHINE', 'DEVICE'].some( (value) => source === value || source.includes(value), ); const label = machine ? '考勤机打卡' : source === 'USER' ? '手机打卡' : source.includes('BEACON') || source.includes('BLE') ? '蓝牙打卡' : source.includes('WIFI') ? 'Wi-Fi 打卡' : source.includes('APPROVE') ? '审批补卡' : source ? `其他打卡(${record.punchSource})` : '打卡来源未知'; const device = record.punchDeviceName?.trim(); const deviceId = record.punchDeviceId?.trim(); const detail = device ? deviceId && deviceId !== device ? `${device}(${deviceId})` : device : deviceId || undefined; return { label, machine, detail, time: record.punchTime || undefined, }; }