feat: settle course attendance automatically
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
getAttendanceExperience,
|
||||
getSchedulePhase,
|
||||
summarizeAttendance,
|
||||
summarizeLessonCheckins,
|
||||
} from './attendance-workspace';
|
||||
|
||||
describe('attendance role experience', () => {
|
||||
@@ -51,3 +52,16 @@ describe('attendance summary', () => {
|
||||
).toEqual({ total: 4, present: 2, late: 1, absent: 1, leave: 0, pending: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('lesson check-in summary', () => {
|
||||
it('counts late punches as checked in and missing punches as not checked in', () => {
|
||||
expect(
|
||||
summarizeLessonCheckins([
|
||||
{ status: 'present' },
|
||||
{ status: 'late' },
|
||||
{ status: 'pending' },
|
||||
{ status: 'absent' },
|
||||
]),
|
||||
).toEqual({ total: 4, checkedIn: 2, notCheckedIn: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,3 +63,22 @@ export function summarizeAttendance(records: readonly { status: string }[]): Att
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
canPullAttendance,
|
||||
getAttendanceExperience,
|
||||
getSchedulePhase,
|
||||
summarizeAttendance,
|
||||
summarizeLessonCheckins,
|
||||
type AttendanceSummary,
|
||||
type SchedulePhase,
|
||||
} from './attendance-workspace';
|
||||
@@ -215,6 +215,27 @@ function SummaryStrip({ summary }: { summary: AttendanceSummary }) {
|
||||
);
|
||||
}
|
||||
|
||||
function LessonCheckinSummaryStrip({ records }: { records: readonly AttendanceRecordItem[] }) {
|
||||
const summary = summarizeLessonCheckins(records);
|
||||
const rate = summary.total > 0 ? Math.round((summary.checkedIn / summary.total) * 100) : 0;
|
||||
return (
|
||||
<div className="attendance-summary-strip">
|
||||
<div className="attendance-rate">
|
||||
<Progress type="circle" percent={rate} size={64} strokeWidth={9} />
|
||||
<div><span>打卡率</span><strong>{summary.total} 人</strong></div>
|
||||
</div>
|
||||
<div className="attendance-summary-cell">
|
||||
<span className="attendance-summary-icon is-present">勤</span>
|
||||
<div><strong>{summary.checkedIn}</strong><span>已打卡</span></div>
|
||||
</div>
|
||||
<div className="attendance-summary-cell">
|
||||
<span className="attendance-summary-icon is-absent">缺</span>
|
||||
<div><strong>{summary.notCheckedIn}</strong><span>未打卡</span></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AttendancePage: React.FC = () => {
|
||||
const { permissions, hasPermission } = usePermission();
|
||||
const roles = useMemo(readCurrentRoles, []);
|
||||
@@ -235,7 +256,6 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
const [lessonRecords, setLessonRecords] = useState<AttendanceRecordItem[]>([]);
|
||||
const [recordLoading, setRecordLoading] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [finishing, setFinishing] = useState(false);
|
||||
|
||||
const loadWorkspace = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -257,15 +277,6 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
[workspace],
|
||||
);
|
||||
|
||||
const loadLessonAttendance = useCallback(async (schedule: TodaySchedule) => {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const data = await api.get<LessonAttendanceResponse>(
|
||||
`/attendance-lessons/schedules/${schedule.id}`,
|
||||
{ params: { date: today } },
|
||||
);
|
||||
setLessonSession(data.session);
|
||||
setLessonRecords(data.records);
|
||||
}, []);
|
||||
|
||||
const openAttendance = useCallback(async (schedule: TodaySchedule) => {
|
||||
setSelectedSchedule(schedule);
|
||||
@@ -279,7 +290,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
);
|
||||
setLessonSession(data.session);
|
||||
setLessonRecords(data.records);
|
||||
message.success('钉钉考勤已更新,请核对待确认和异常记录');
|
||||
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
|
||||
} catch (error: unknown) {
|
||||
setLessonSession(null);
|
||||
setLessonRecords([]);
|
||||
@@ -307,23 +318,6 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
[],
|
||||
);
|
||||
|
||||
const completeAttendance = useCallback(async () => {
|
||||
if (!lessonSession || !selectedSchedule) return;
|
||||
setFinishing(true);
|
||||
try {
|
||||
const result = await api.post<LessonAttendanceResponse>(
|
||||
`/attendance-lessons/${lessonSession.id}/complete`,
|
||||
);
|
||||
setLessonSession(result.session);
|
||||
setLessonRecords(result.records);
|
||||
message.success('考勤核对已完成');
|
||||
await loadLessonAttendance(selectedSchedule);
|
||||
} catch (error: unknown) {
|
||||
message.error((error as { message?: string })?.message || '完成点名失败');
|
||||
} finally {
|
||||
setFinishing(false);
|
||||
}
|
||||
}, [lessonSession, selectedSchedule, loadLessonAttendance]);
|
||||
|
||||
const now = new Date();
|
||||
const schedules = workspace?.todaySchedules ?? [];
|
||||
@@ -333,7 +327,6 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
const nextSchedule = schedules.find(
|
||||
(item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',
|
||||
);
|
||||
const drawerSummary = summarizeAttendance(lessonRecords);
|
||||
const isAttendanceCompleted = lessonSession?.status === 'completed';
|
||||
|
||||
return (
|
||||
@@ -342,7 +335,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
<div>
|
||||
<span className="attendance-eyebrow">TEACHING DAY · {dayjs().format('MM月DD日 dddd')}</span>
|
||||
<h1>今天,从课程开始</h1>
|
||||
<p>课表先同步到钉钉考勤;课程开始后,老师可随时拉取该节课的最新打卡结果并核对异常。</p>
|
||||
<p>课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。</p>
|
||||
</div>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadWorkspace()}>
|
||||
刷新
|
||||
@@ -403,18 +396,11 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
<Alert
|
||||
type={isAttendanceCompleted ? 'success' : 'info'}
|
||||
showIcon
|
||||
title={isAttendanceCompleted ? '本节课考勤已核对完成;如有错误仍可直接修正' : '钉钉考勤已拉取,请核对待确认和异常学生'}
|
||||
title={isAttendanceCompleted ? '本节课考勤已结算' : '当前打卡结果;课程截止后将自动做最终结算'}
|
||||
style={{ marginBottom: 16 }}
|
||||
action={
|
||||
!isAttendanceCompleted ? (
|
||||
<Button type="primary" loading={finishing} onClick={() => void completeAttendance()}>
|
||||
完成核对
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SummaryStrip summary={drawerSummary} />
|
||||
<LessonCheckinSummaryStrip records={lessonRecords} />
|
||||
<Table<AttendanceRecordItem>
|
||||
rowKey="id"
|
||||
loading={recordLoading}
|
||||
@@ -427,24 +413,31 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
render: (name: string) => <div className="student-cell"><Avatar size={32}>{name?.slice(0, 1)}</Avatar><strong>{name || '-'}</strong></div>,
|
||||
},
|
||||
{
|
||||
title: '考勤结果', dataIndex: 'status', width: 310,
|
||||
render: (value: string, record: AttendanceRecordItem) => (
|
||||
<div className="attendance-marking-actions">
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
title: '考勤结果', dataIndex: 'status', width: 230,
|
||||
render: (value: string, record: AttendanceRecordItem) => {
|
||||
const checkedIn = value === 'present' || value === 'late';
|
||||
return (
|
||||
<div className="attendance-marking-actions">
|
||||
<Button
|
||||
key={option.value}
|
||||
size="small"
|
||||
type={value === option.value ? 'primary' : 'default'}
|
||||
danger={value === option.value && option.value === 'absent'}
|
||||
onClick={() => void updateLessonRecord(record, option.value)}
|
||||
type={checkedIn ? 'primary' : 'default'}
|
||||
onClick={() => void updateLessonRecord(record, 'present')}
|
||||
>
|
||||
{option.label}
|
||||
已打卡
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
<Button
|
||||
size="small"
|
||||
type={!checkedIn ? 'primary' : 'default'}
|
||||
danger={!checkedIn}
|
||||
onClick={() => void updateLessonRecord(record, 'absent')}
|
||||
>
|
||||
未打卡
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => <AttendanceStatusTag status={value} /> },
|
||||
{ title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => <AttendanceStatusTag status={value === 'present' || value === 'late' ? 'present' : 'absent'} /> },
|
||||
{ title: '备注', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text">—</span> },
|
||||
]}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user