forked from wangziqi/gongxue-base
162 lines
4.4 KiB
TypeScript
162 lines
4.4 KiB
TypeScript
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<keyof AttendanceSummary, 'total'>] += 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<T extends LessonAttendanceFilterRecord>(
|
||
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,
|
||
};
|
||
}
|