feat: attendance frontend page + backend list/classes endpoints
- Create AttendancePage with filter bar, table, calendar view toggle, batch add modal - Register /attendance route with PermissionRoute (attendance:view) - Add menu item in MainLayout with CheckCircleOutlined icon - Add GET /attendance-records with pagination and filters - Add GET /attendance-records/classes for class dropdown - Add source column to AttendanceRecord entity (default: manual) - Frontend tsc --noEmit: clean; backend: only pre-existing test errors
This commit is contained in:
@@ -23,6 +23,7 @@ import ClassroomSchedulePage from './pages/ClassroomSchedule';
|
|||||||
import SchedulesPage from './pages/Schedules';
|
import SchedulesPage from './pages/Schedules';
|
||||||
import RolesPage from './pages/Roles';
|
import RolesPage from './pages/Roles';
|
||||||
import PermissionsPage from './pages/Permissions';
|
import PermissionsPage from './pages/Permissions';
|
||||||
|
import AttendancePage from './pages/Attendance';
|
||||||
import PermissionRoute from './components/PermissionRoute';
|
import PermissionRoute from './components/PermissionRoute';
|
||||||
|
|
||||||
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
@@ -202,6 +203,15 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="attendance"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="attendance:view">
|
||||||
|
<AttendancePage />
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="schedules"
|
path="schedules"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
SafetyOutlined,
|
SafetyOutlined,
|
||||||
KeyOutlined,
|
KeyOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { usePermission } from '../hooks/usePermission';
|
import { usePermission } from '../hooks/usePermission';
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ const allMenuItems: MenuItemType[] = [
|
|||||||
{ key: '/tenants', icon: <TagsOutlined />, label: '租赁方', permission: 'tenant:view' },
|
{ key: '/tenants', icon: <TagsOutlined />, label: '租赁方', permission: 'tenant:view' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ key: '/attendance', icon: <CheckCircleOutlined />, label: '考勤管理', permission: 'attendance:view' },
|
||||||
{ key: '/schedules', icon: <CalendarOutlined />, label: '排课管理', permission: 'schedule:view' },
|
{ key: '/schedules', icon: <CalendarOutlined />, label: '排课管理', permission: 'schedule:view' },
|
||||||
{ key: '/operation-logs', icon: <AuditOutlined />, label: '操作日志', permission: 'log:view' },
|
{ key: '/operation-logs', icon: <AuditOutlined />, label: '操作日志', permission: 'log:view' },
|
||||||
{ key: '/roles', icon: <SafetyOutlined />, label: '角色管理', permission: 'role:view' },
|
{ key: '/roles', icon: <SafetyOutlined />, label: '角色管理', permission: 'role:view' },
|
||||||
|
|||||||
632
apps/admin/src/pages/Attendance/index.tsx
Normal file
632
apps/admin/src/pages/Attendance/index.tsx
Normal file
@@ -0,0 +1,632 @@
|
|||||||
|
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
DatePicker,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
message,
|
||||||
|
Tag,
|
||||||
|
Card,
|
||||||
|
Input,
|
||||||
|
Tooltip,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
UnorderedListOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
|
import api from '../../api';
|
||||||
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
// ── Constants ──
|
||||||
|
|
||||||
|
const SESSION_OPTIONS = [
|
||||||
|
{ value: 'morning_reading', label: '早自习' },
|
||||||
|
{ value: 'morning', label: '上午' },
|
||||||
|
{ value: 'afternoon', label: '下午' },
|
||||||
|
{ value: 'evening_study', label: '晚自习' },
|
||||||
|
{ value: 'night_check', label: '晚寝' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SESSION_MAP: Record<string, string> = {
|
||||||
|
morning_reading: '早自习',
|
||||||
|
morning: '上午',
|
||||||
|
afternoon: '下午',
|
||||||
|
evening_study: '晚自习',
|
||||||
|
night_check: '晚寝',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { text: string; color: string }> = {
|
||||||
|
present: { text: '出勤', color: 'green' },
|
||||||
|
late: { text: '迟到', color: 'gold' },
|
||||||
|
absent: { text: '缺勤', color: 'red' },
|
||||||
|
leave: { text: '请假', color: 'blue' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = Object.entries(STATUS_MAP).map(([value, { text }]) => ({
|
||||||
|
value,
|
||||||
|
label: text,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const SOURCE_OPTIONS = [
|
||||||
|
{ value: 'manual', label: '手动录入' },
|
||||||
|
{ value: 'dingtalk', label: '钉钉导入' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SOURCE_MAP: Record<string, string> = {
|
||||||
|
manual: '手动录入',
|
||||||
|
dingtalk: '钉钉导入',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
interface ClassOption {
|
||||||
|
classId: number;
|
||||||
|
className: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttendanceRecordItem {
|
||||||
|
id: number;
|
||||||
|
studentId: number;
|
||||||
|
classId: number | null;
|
||||||
|
attendanceDate: string;
|
||||||
|
session: string;
|
||||||
|
status: string;
|
||||||
|
source?: string;
|
||||||
|
remark: string | null;
|
||||||
|
checkTime?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
student: { id: number; name: string };
|
||||||
|
class: { id: number; name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatchRecordInput {
|
||||||
|
studentId: number;
|
||||||
|
studentName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
const AttendancePage: React.FC = () => {
|
||||||
|
// ── State ──
|
||||||
|
const [records, setRecords] = useState<AttendanceRecordItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
const [classOptions, setClassOptions] = useState<ClassOption[]>([]);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [filterClassId, setFilterClassId] = useState<number | undefined>(undefined);
|
||||||
|
const [filterDateRange, setFilterDateRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||||
|
const [filterSession, setFilterSession] = useState<string | undefined>(undefined);
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||||
|
const [filterSource, setFilterSource] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
// View toggle
|
||||||
|
const [calendarView, setCalendarView] = useState(false);
|
||||||
|
const [calendarData, setCalendarData] = useState<
|
||||||
|
{ studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }[]
|
||||||
|
>([]);
|
||||||
|
const [calendarLoading, setCalendarLoading] = useState(false);
|
||||||
|
|
||||||
|
// Batch modal
|
||||||
|
const [batchModalOpen, setBatchModalOpen] = useState(false);
|
||||||
|
const [batchStudents, setBatchStudents] = useState<BatchRecordInput[]>([]);
|
||||||
|
const [batchDate, setBatchDate] = useState<Dayjs>(dayjs());
|
||||||
|
const [batchSession, setBatchSession] = useState<string>('morning');
|
||||||
|
const [batchStatus, setBatchStatus] = useState<string>('present');
|
||||||
|
const [batchRemark, setBatchRemark] = useState('');
|
||||||
|
const [batchSubmitting, setBatchSubmitting] = useState(false);
|
||||||
|
const [studentSearch, setStudentSearch] = useState('');
|
||||||
|
const [studentSearchResults, setStudentSearchResults] = useState<{ id: number; name: string }[]>([]);
|
||||||
|
const [studentSearchLoading, setStudentSearchLoading] = useState(false);
|
||||||
|
|
||||||
|
// ── Fetch classes ──
|
||||||
|
const fetchClasses = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/attendance-records/classes');
|
||||||
|
setClassOptions(res as ClassOption[]);
|
||||||
|
} catch {
|
||||||
|
// Silently fail — class filter just stays empty
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Fetch records ──
|
||||||
|
const fetchRecords = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: Record<string, string | number> = { page, pageSize };
|
||||||
|
if (filterClassId) params.classId = filterClassId;
|
||||||
|
if (filterDateRange?.[0]) params.dateFrom = filterDateRange[0].format('YYYY-MM-DD');
|
||||||
|
if (filterDateRange?.[1]) params.dateTo = filterDateRange[1].format('YYYY-MM-DD');
|
||||||
|
if (filterSession) params.session = filterSession;
|
||||||
|
if (filterStatus) params.status = filterStatus;
|
||||||
|
if (filterSource) params.source = filterSource;
|
||||||
|
|
||||||
|
const res = await api.get('/attendance-records', { params });
|
||||||
|
const data = res as { list: AttendanceRecordItem[]; total: number };
|
||||||
|
setRecords(data.list);
|
||||||
|
setTotal(data.total);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { message?: string };
|
||||||
|
message.error(err?.message || '获取考勤记录失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, filterClassId, filterDateRange, filterSession, filterStatus, filterSource]);
|
||||||
|
|
||||||
|
// ── Fetch calendar ──
|
||||||
|
const fetchCalendar = useCallback(async () => {
|
||||||
|
if (!filterClassId) {
|
||||||
|
message.warning('请先选择班级');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCalendarLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get('/attendance-records/calendar', {
|
||||||
|
params: { classId: filterClassId },
|
||||||
|
});
|
||||||
|
setCalendarData(res as { studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }[]);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { message?: string };
|
||||||
|
message.error(err?.message || '获取日历数据失败');
|
||||||
|
} finally {
|
||||||
|
setCalendarLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterClassId]);
|
||||||
|
|
||||||
|
// ── Effects ──
|
||||||
|
useEffect(() => {
|
||||||
|
fetchClasses();
|
||||||
|
}, [fetchClasses]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (calendarView) {
|
||||||
|
fetchCalendar();
|
||||||
|
} else {
|
||||||
|
fetchRecords();
|
||||||
|
}
|
||||||
|
}, [calendarView, fetchRecords, fetchCalendar]);
|
||||||
|
|
||||||
|
// ── Student search ──
|
||||||
|
const handleStudentSearch = useCallback(async (value: string) => {
|
||||||
|
setStudentSearch(value);
|
||||||
|
if (!value || value.length < 1) {
|
||||||
|
setStudentSearchResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStudentSearchLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get('/students', { params: { search: value, pageSize: 10 } });
|
||||||
|
const data = res as { list?: { id: number; name: string }[] } | { id: number; name: string }[];
|
||||||
|
const list = Array.isArray(data) ? data : data.list ?? [];
|
||||||
|
setStudentSearchResults(list);
|
||||||
|
} catch {
|
||||||
|
setStudentSearchResults([]);
|
||||||
|
} finally {
|
||||||
|
setStudentSearchLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addStudentToBatch = useCallback(
|
||||||
|
(studentId: number, studentName: string) => {
|
||||||
|
if (batchStudents.some((s) => s.studentId === studentId)) {
|
||||||
|
message.warning('该学生已在列表中');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBatchStudents((prev) => [...prev, { studentId, studentName }]);
|
||||||
|
setStudentSearch('');
|
||||||
|
setStudentSearchResults([]);
|
||||||
|
},
|
||||||
|
[batchStudents],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeBatchStudent = useCallback((studentId: number) => {
|
||||||
|
setBatchStudents((prev) => prev.filter((s) => s.studentId !== studentId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Batch submit ──
|
||||||
|
const handleBatchSubmit = useCallback(async () => {
|
||||||
|
if (batchStudents.length === 0) {
|
||||||
|
message.warning('请添加至少一名学生');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBatchSubmitting(true);
|
||||||
|
try {
|
||||||
|
const records = batchStudents.map((s) => ({
|
||||||
|
studentId: s.studentId,
|
||||||
|
classId: filterClassId,
|
||||||
|
attendanceDate: batchDate.format('YYYY-MM-DD'),
|
||||||
|
session: batchSession,
|
||||||
|
status: batchStatus,
|
||||||
|
remark: batchRemark || undefined,
|
||||||
|
}));
|
||||||
|
await api.post('/attendance-records/batch', { records });
|
||||||
|
message.success(`成功录入 ${batchStudents.length} 条考勤记录`);
|
||||||
|
setBatchModalOpen(false);
|
||||||
|
setBatchStudents([]);
|
||||||
|
setBatchRemark('');
|
||||||
|
fetchRecords();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { message?: string };
|
||||||
|
message.error(err?.message || '批量录入失败');
|
||||||
|
} finally {
|
||||||
|
setBatchSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [batchStudents, batchDate, batchSession, batchStatus, batchRemark, filterClassId, fetchRecords]);
|
||||||
|
|
||||||
|
// ── Reset filters ──
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setFilterClassId(undefined);
|
||||||
|
setFilterDateRange(null);
|
||||||
|
setFilterSession(undefined);
|
||||||
|
setFilterStatus(undefined);
|
||||||
|
setFilterSource(undefined);
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Table columns ──
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: '学生姓名',
|
||||||
|
dataIndex: ['student', 'name'],
|
||||||
|
key: 'studentName',
|
||||||
|
width: 100,
|
||||||
|
fixed: 'left' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '班级',
|
||||||
|
dataIndex: ['class', 'name'],
|
||||||
|
key: 'className',
|
||||||
|
width: 120,
|
||||||
|
render: (v: string | undefined) => v || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '日期',
|
||||||
|
dataIndex: 'attendanceDate',
|
||||||
|
key: 'attendanceDate',
|
||||||
|
width: 110,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '时段',
|
||||||
|
dataIndex: 'session',
|
||||||
|
key: 'session',
|
||||||
|
width: 90,
|
||||||
|
render: (v: string) => SESSION_MAP[v] || v,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 80,
|
||||||
|
render: (v: string) => {
|
||||||
|
const item = STATUS_MAP[v];
|
||||||
|
return item ? <Tag color={item.color}>{item.text}</Tag> : v;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '来源',
|
||||||
|
dataIndex: 'source',
|
||||||
|
key: 'source',
|
||||||
|
width: 90,
|
||||||
|
render: (v: string | undefined) => (v ? SOURCE_MAP[v] || v : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '备注',
|
||||||
|
dataIndex: 'remark',
|
||||||
|
key: 'remark',
|
||||||
|
width: 150,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: string | null) => v || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '录入时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
key: 'createdAt',
|
||||||
|
width: 160,
|
||||||
|
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 80,
|
||||||
|
fixed: 'right' as const,
|
||||||
|
render: (_: unknown, record: AttendanceRecordItem) => (
|
||||||
|
<PermissionButton permission="attendance:edit" size="small" onClick={() => message.info('编辑功能开发中')}>
|
||||||
|
编辑
|
||||||
|
</PermissionButton>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Calendar columns ──
|
||||||
|
const calendarDates = useMemo(() => {
|
||||||
|
const dates = new Set<string>();
|
||||||
|
for (const student of calendarData) {
|
||||||
|
for (const day of student.days) {
|
||||||
|
dates.add(day.date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(dates).sort();
|
||||||
|
}, [calendarData]);
|
||||||
|
|
||||||
|
// ── Render ──
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* ── Filter bar ── */}
|
||||||
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Row gutter={[12, 12]} align="middle">
|
||||||
|
<Col>
|
||||||
|
<Select
|
||||||
|
placeholder="选择班级"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 160 }}
|
||||||
|
value={filterClassId}
|
||||||
|
onChange={(v) => {
|
||||||
|
setFilterClassId(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
options={classOptions.map((c) => ({
|
||||||
|
value: c.classId,
|
||||||
|
label: c.className,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<RangePicker
|
||||||
|
value={filterDateRange}
|
||||||
|
onChange={(dates) => {
|
||||||
|
setFilterDateRange(dates as [Dayjs, Dayjs] | null);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder={['开始日期', '结束日期']}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Select
|
||||||
|
placeholder="时段"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 100 }}
|
||||||
|
value={filterSession}
|
||||||
|
onChange={(v) => {
|
||||||
|
setFilterSession(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
options={SESSION_OPTIONS}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Select
|
||||||
|
placeholder="状态"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 100 }}
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={(v) => {
|
||||||
|
setFilterStatus(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
options={STATUS_OPTIONS}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Select
|
||||||
|
placeholder="来源"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 110 }}
|
||||||
|
value={filterSource}
|
||||||
|
onChange={(v) => {
|
||||||
|
setFilterSource(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
options={SOURCE_OPTIONS}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={handleReset}>重置</Button>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Action bar ── */}
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<Space>
|
||||||
|
<PermissionButton
|
||||||
|
permission="attendance:create"
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => setBatchModalOpen(true)}
|
||||||
|
>
|
||||||
|
批量录入
|
||||||
|
</PermissionButton>
|
||||||
|
</Space>
|
||||||
|
<Button
|
||||||
|
icon={calendarView ? <UnorderedListOutlined /> : <CalendarOutlined />}
|
||||||
|
onClick={() => setCalendarView(!calendarView)}
|
||||||
|
>
|
||||||
|
{calendarView ? '列表视图' : '日历视图'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Table view ── */}
|
||||||
|
{!calendarView && (
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={records}
|
||||||
|
loading={loading}
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (t: number) => `共 ${t} 条`,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Calendar view ── */}
|
||||||
|
{calendarView && (
|
||||||
|
<Card loading={calendarLoading} title={calendarView ? '考勤日历' : undefined}>
|
||||||
|
{calendarData.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
|
||||||
|
暂无数据,请选择班级后查看
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
rowKey="studentId"
|
||||||
|
dataSource={calendarData}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: Math.max(800, calendarDates.length * 80) }}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '学生',
|
||||||
|
dataIndex: 'studentName',
|
||||||
|
key: 'studentName',
|
||||||
|
width: 100,
|
||||||
|
fixed: 'left' as const,
|
||||||
|
},
|
||||||
|
...calendarDates.map((date) => ({
|
||||||
|
title: (
|
||||||
|
<div style={{ textAlign: 'center', fontSize: 12 }}>
|
||||||
|
<div>{dayjs(date).format('MM/DD')}</div>
|
||||||
|
<div style={{ color: '#999' }}>{dayjs(date).format('ddd')}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
key: date,
|
||||||
|
width: 80,
|
||||||
|
render: (_: unknown, record: { studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }) => {
|
||||||
|
const dayRecord = record.days.find((d) => d.date === date);
|
||||||
|
if (!dayRecord) return <span style={{ color: '#d9d9d9' }}>-</span>;
|
||||||
|
const statusInfo = STATUS_MAP[dayRecord.status];
|
||||||
|
return (
|
||||||
|
<Tooltip title={`${SESSION_MAP[dayRecord.session] || dayRecord.session}: ${statusInfo?.text || dayRecord.status}`}>
|
||||||
|
<Tag color={statusInfo?.color || 'default'} style={{ margin: 0, cursor: 'pointer' }}>
|
||||||
|
{statusInfo?.text || dayRecord.status}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Batch add modal ── */}
|
||||||
|
<Modal
|
||||||
|
title="批量录入考勤"
|
||||||
|
open={batchModalOpen}
|
||||||
|
onOk={handleBatchSubmit}
|
||||||
|
onCancel={() => {
|
||||||
|
setBatchModalOpen(false);
|
||||||
|
setBatchStudents([]);
|
||||||
|
setBatchRemark('');
|
||||||
|
}}
|
||||||
|
confirmLoading={batchSubmitting}
|
||||||
|
width={640}
|
||||||
|
okText="提交"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item label="录入日期" required>
|
||||||
|
<DatePicker
|
||||||
|
value={batchDate}
|
||||||
|
onChange={(d) => setBatchDate(d || dayjs())}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item label="时段" required>
|
||||||
|
<Select
|
||||||
|
value={batchSession}
|
||||||
|
onChange={setBatchSession}
|
||||||
|
options={SESSION_OPTIONS}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item label="状态" required>
|
||||||
|
<Select
|
||||||
|
value={batchStatus}
|
||||||
|
onChange={setBatchStatus}
|
||||||
|
options={STATUS_OPTIONS}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Form.Item label="添加学生" required>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
value={undefined}
|
||||||
|
placeholder="搜索学生姓名或学号"
|
||||||
|
filterOption={false}
|
||||||
|
onSearch={handleStudentSearch}
|
||||||
|
onSelect={(value: number) => {
|
||||||
|
const student = studentSearchResults.find((s) => s.id === value);
|
||||||
|
if (student) addStudentToBatch(student.id, student.name);
|
||||||
|
}}
|
||||||
|
loading={studentSearchLoading}
|
||||||
|
options={studentSearchResults.map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: `${s.name} (ID: ${s.id})`,
|
||||||
|
}))}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
notFoundContent={studentSearch ? '未找到匹配的学生' : '输入关键词搜索'}
|
||||||
|
/>
|
||||||
|
{batchStudents.length > 0 && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{batchStudents.map((s) => (
|
||||||
|
<Tag
|
||||||
|
key={s.studentId}
|
||||||
|
closable
|
||||||
|
onClose={() => removeBatchStudent(s.studentId)}
|
||||||
|
style={{ marginBottom: 4 }}
|
||||||
|
>
|
||||||
|
{s.studentName}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="备注">
|
||||||
|
<Input.TextArea
|
||||||
|
value={batchRemark}
|
||||||
|
onChange={(e) => setBatchRemark(e.target.value)}
|
||||||
|
placeholder="可选备注信息"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AttendancePage;
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
BatchCreateAttendanceDto,
|
BatchCreateAttendanceDto,
|
||||||
AttendanceSummaryQueryDto,
|
AttendanceSummaryQueryDto,
|
||||||
AttendanceCalendarQueryDto,
|
AttendanceCalendarQueryDto,
|
||||||
|
QueryAttendanceRecordsDto,
|
||||||
QueryDingRawDto,
|
QueryDingRawDto,
|
||||||
MatchDingRecordDto,
|
MatchDingRecordDto,
|
||||||
} from './dto/attendance.dto';
|
} from './dto/attendance.dto';
|
||||||
@@ -50,6 +51,20 @@ export class AttendanceController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── List attendance records with filters ──
|
||||||
|
@Get('attendance-records')
|
||||||
|
@RequirePermission('attendance:view')
|
||||||
|
findAll(@Query() query: QueryAttendanceRecordsDto) {
|
||||||
|
return this.service.findAll(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get distinct classes with attendance records ──
|
||||||
|
@Get('attendance-records/classes')
|
||||||
|
@RequirePermission('attendance:view')
|
||||||
|
getClasses() {
|
||||||
|
return this.service.getClasses();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Attendance summary ──
|
// ── Attendance summary ──
|
||||||
@Get('attendance-records/summary')
|
@Get('attendance-records/summary')
|
||||||
@RequirePermission('attendance:view')
|
@RequirePermission('attendance:view')
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export class AttendanceService {
|
|||||||
session: r.session,
|
session: r.session,
|
||||||
status: r.status,
|
status: r.status,
|
||||||
remark: r.remark,
|
remark: r.remark,
|
||||||
|
source: r.source || 'manual',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -134,6 +135,64 @@ export class AttendanceService {
|
|||||||
return Array.from(studentMap.values());
|
return Array.from(studentMap.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── List attendance records with filters ──
|
||||||
|
async findAll(query: {
|
||||||
|
classId?: number;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
session?: string;
|
||||||
|
status?: string;
|
||||||
|
source?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}) {
|
||||||
|
const page = query.page || 1;
|
||||||
|
const pageSize = query.pageSize || 20;
|
||||||
|
|
||||||
|
const qb = this.attendanceRepo
|
||||||
|
.createQueryBuilder('ar')
|
||||||
|
.leftJoinAndSelect('ar.student', 'student')
|
||||||
|
.leftJoinAndSelect('ar.class', 'class');
|
||||||
|
|
||||||
|
if (query.classId) {
|
||||||
|
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||||
|
}
|
||||||
|
if (query.dateFrom) {
|
||||||
|
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
||||||
|
}
|
||||||
|
if (query.dateTo) {
|
||||||
|
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
|
||||||
|
}
|
||||||
|
if (query.session) {
|
||||||
|
qb.andWhere('ar.session = :session', { session: query.session });
|
||||||
|
}
|
||||||
|
if (query.status) {
|
||||||
|
qb.andWhere('ar.status = :status', { status: query.status });
|
||||||
|
}
|
||||||
|
if (query.source) {
|
||||||
|
qb.andWhere('ar.source = :source', { source: query.source });
|
||||||
|
}
|
||||||
|
|
||||||
|
qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC');
|
||||||
|
qb.skip((page - 1) * pageSize).take(pageSize);
|
||||||
|
|
||||||
|
const [list, total] = await qb.getManyAndCount();
|
||||||
|
return { list, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get distinct classes with attendance records ──
|
||||||
|
async getClasses() {
|
||||||
|
const qb = this.attendanceRepo
|
||||||
|
.createQueryBuilder('ar')
|
||||||
|
.leftJoin('ar.class', 'class')
|
||||||
|
.select('DISTINCT ar.classId', 'classId')
|
||||||
|
.addSelect('class.name', 'className')
|
||||||
|
.where('ar.classId IS NOT NULL')
|
||||||
|
.orderBy('ar.classId', 'ASC');
|
||||||
|
|
||||||
|
return qb.getRawMany();
|
||||||
|
}
|
||||||
|
|
||||||
// ── DingAttendance raw records ──
|
// ── DingAttendance raw records ──
|
||||||
async getDingRaw(query: QueryDingRawDto) {
|
async getDingRaw(query: QueryDingRawDto) {
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export class AttendanceRecordItem {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
remark?: string;
|
remark?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BatchCreateAttendanceDto {
|
export class BatchCreateAttendanceDto {
|
||||||
@@ -78,6 +82,44 @@ export class QueryDingRawDto {
|
|||||||
matchStatus?: string;
|
matchStatus?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class QueryAttendanceRecordsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Type(() => Number)
|
||||||
|
classId?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dateFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dateTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
session?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['present', 'late', 'absent', 'leave'])
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
source?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Type(() => Number)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Type(() => Number)
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class MatchDingRecordDto {
|
export class MatchDingRecordDto {
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export class AttendanceRecord {
|
|||||||
@Column({ length: 200, nullable: true })
|
@Column({ length: 200, nullable: true })
|
||||||
remark: string;
|
remark: string;
|
||||||
|
|
||||||
|
@Column({ name: 'source', length: 20, default: 'manual' })
|
||||||
|
source: string;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user