feat(admin): 管理端考勤明细批量纠错
- 原始考勤明细表支持行选择(跨页保留),选中后出现 「标记正常/标记请假/标记缺勤」批量操作条 - 批量提交前确认,成功后按 updated/failed 数量反馈, 失败记录(如已结算)提示原因;筛选条件变化自动清空选择 - 复用 PUT /attendance-records/batch-status 接口 aislop scan: 5 引擎 0 issues
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card, Empty, Input, Spin, Table } from 'antd';
|
import { Button, Card, Empty, Input, Spin, Table } from 'antd';
|
||||||
import { ExportOutlined } from '@ant-design/icons';
|
import { ExportOutlined } from '@ant-design/icons';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +27,11 @@ export const AttendanceAdminWorkspace: React.FC<{
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
total: number;
|
total: number;
|
||||||
onPageChange: (page: number, pageSize: number) => void;
|
onPageChange: (page: number, pageSize: number) => void;
|
||||||
|
/** 明细表批量纠错 */
|
||||||
|
selectedRecordKeys: number[];
|
||||||
|
onSelectRecords: (keys: number[]) => void;
|
||||||
|
onBatchCorrect: (status: string) => void;
|
||||||
|
batchCorrecting: boolean;
|
||||||
}> = ({
|
}> = ({
|
||||||
metricFilter,
|
metricFilter,
|
||||||
studentSearch,
|
studentSearch,
|
||||||
@@ -44,6 +49,10 @@ export const AttendanceAdminWorkspace: React.FC<{
|
|||||||
pageSize,
|
pageSize,
|
||||||
total,
|
total,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
selectedRecordKeys,
|
||||||
|
onSelectRecords,
|
||||||
|
onBatchCorrect,
|
||||||
|
batchCorrecting,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -135,12 +144,51 @@ export const AttendanceAdminWorkspace: React.FC<{
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Card className="student-record-card" variant="borderless" title="原始考勤明细">
|
<Card className="student-record-card" variant="borderless" title="原始考勤明细">
|
||||||
|
{selectedRecordKeys.length > 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 8,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>已选 {selectedRecordKeys.length} 条</span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
loading={batchCorrecting}
|
||||||
|
onClick={() => onBatchCorrect('present')}
|
||||||
|
>
|
||||||
|
标记正常
|
||||||
|
</Button>
|
||||||
|
<Button size="small" loading={batchCorrecting} onClick={() => onBatchCorrect('leave')}>
|
||||||
|
标记请假
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
loading={batchCorrecting}
|
||||||
|
onClick={() => onBatchCorrect('absent')}
|
||||||
|
>
|
||||||
|
标记缺勤
|
||||||
|
</Button>
|
||||||
|
<Button size="small" type="text" onClick={() => onSelectRecords([])}>
|
||||||
|
清空选择
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<Table<AttendanceRecordItem>
|
<Table<AttendanceRecordItem>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={records}
|
dataSource={records}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
scroll={{ x: 'max-content' }}
|
scroll={{ x: 'max-content' }}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys: selectedRecordKeys,
|
||||||
|
onChange: (keys) => onSelectRecords(keys as number[]),
|
||||||
|
}}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||||
import { validateResponse } from '../../utils/validate';
|
import { validateResponse } from '../../utils/validate';
|
||||||
|
import { getErrorMessage } from '../../utils/error';
|
||||||
import {
|
import {
|
||||||
attendanceAlertsSchema,
|
attendanceAlertsSchema,
|
||||||
attendanceClassOptionsSchema,
|
attendanceClassOptionsSchema,
|
||||||
@@ -57,6 +58,9 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
|
|||||||
const [studentSearch, setStudentSearch] = useState('');
|
const [studentSearch, setStudentSearch] = useState('');
|
||||||
const [selectedStudent, setSelectedStudent] = useState<AdminStudentPanel | null>(null);
|
const [selectedStudent, setSelectedStudent] = useState<AdminStudentPanel | null>(null);
|
||||||
const [correctingRecordId, setCorrectingRecordId] = useState<number | null>(null);
|
const [correctingRecordId, setCorrectingRecordId] = useState<number | null>(null);
|
||||||
|
// 明细表批量纠错:选中的记录 ID + 执行中状态
|
||||||
|
const [selectedRecordIds, setSelectedRecordIds] = useState<number[]>([]);
|
||||||
|
const [batchCorrecting, setBatchCorrecting] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
useVisibleRefetch(['attendance', 'records']);
|
useVisibleRefetch(['attendance', 'records']);
|
||||||
const recordQueryKey = [
|
const recordQueryKey = [
|
||||||
@@ -71,6 +75,11 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
|
|||||||
scheduleId,
|
scheduleId,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
// 筛选条件变化时清空批量选择,避免把上一批条件的记录带到新条件下提交
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedRecordIds([]);
|
||||||
|
}, [classId, attendanceDate, status, session, scheduleId]);
|
||||||
|
|
||||||
const { data: classOptions = [] } = useQuery<ClassOption[]>({
|
const { data: classOptions = [] } = useQuery<ClassOption[]>({
|
||||||
queryKey: ['attendance', 'meta', 'classes'],
|
queryKey: ['attendance', 'meta', 'classes'],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
@@ -400,6 +409,36 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 批量纠错:对选中的明细记录统一标记状态 */
|
||||||
|
const batchCorrectStatus = async (nextStatus: string) => {
|
||||||
|
if (selectedRecordIds.length === 0) return;
|
||||||
|
const statusLabel =
|
||||||
|
nextStatus === 'present' ? '正常' : nextStatus === 'leave' ? '请假' : '缺勤';
|
||||||
|
modal.confirm({
|
||||||
|
title: `将选中的 ${selectedRecordIds.length} 条记录标记为「${statusLabel}」?`,
|
||||||
|
content: '此操作会立即写入考勤记录;已结算的记录无法修改。',
|
||||||
|
okText: '确认',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
setBatchCorrecting(true);
|
||||||
|
try {
|
||||||
|
const res = await api.put<{ updated: number; failed: number; failedIds: number[] }>(
|
||||||
|
'/attendance-records/batch-status',
|
||||||
|
{ ids: selectedRecordIds, status: nextStatus },
|
||||||
|
);
|
||||||
|
setSelectedRecordIds([]);
|
||||||
|
message.success(`已更新 ${res.updated} 条记录`);
|
||||||
|
if (res.failed > 0) message.warning(`有 ${res.failed} 条更新失败(可能已结算)`);
|
||||||
|
void refetchRecords();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
message.error(getErrorMessage(e, '批量更新失败'));
|
||||||
|
} finally {
|
||||||
|
setBatchCorrecting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const saveAdminRecordCell = async (
|
const saveAdminRecordCell = async (
|
||||||
record: AttendanceRecordItem,
|
record: AttendanceRecordItem,
|
||||||
field: 'status' | 'remark',
|
field: 'status' | 'remark',
|
||||||
@@ -544,6 +583,10 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
|
|||||||
setPage(nextPage);
|
setPage(nextPage);
|
||||||
setPageSize(nextPageSize);
|
setPageSize(nextPageSize);
|
||||||
}}
|
}}
|
||||||
|
selectedRecordKeys={selectedRecordIds}
|
||||||
|
onSelectRecords={setSelectedRecordIds}
|
||||||
|
onBatchCorrect={batchCorrectStatus}
|
||||||
|
batchCorrecting={batchCorrecting}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user