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 RolesPage from './pages/Roles';
|
||||
import PermissionsPage from './pages/Permissions';
|
||||
import AttendancePage from './pages/Attendance';
|
||||
import PermissionRoute from './components/PermissionRoute';
|
||||
|
||||
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
|
||||
path="schedules"
|
||||
element={
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
CalendarOutlined,
|
||||
SafetyOutlined,
|
||||
KeyOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
|
||||
@@ -77,6 +78,7 @@ const allMenuItems: MenuItemType[] = [
|
||||
{ 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: '/operation-logs', icon: <AuditOutlined />, label: '操作日志', permission: 'log: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,
|
||||
AttendanceSummaryQueryDto,
|
||||
AttendanceCalendarQueryDto,
|
||||
QueryAttendanceRecordsDto,
|
||||
QueryDingRawDto,
|
||||
MatchDingRecordDto,
|
||||
} from './dto/attendance.dto';
|
||||
@@ -50,6 +51,20 @@ export class AttendanceController {
|
||||
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 ──
|
||||
@Get('attendance-records/summary')
|
||||
@RequirePermission('attendance:view')
|
||||
|
||||
@@ -37,6 +37,7 @@ export class AttendanceService {
|
||||
session: r.session,
|
||||
status: r.status,
|
||||
remark: r.remark,
|
||||
source: r.source || 'manual',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -134,6 +135,64 @@ export class AttendanceService {
|
||||
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 ──
|
||||
async getDingRaw(query: QueryDingRawDto) {
|
||||
const where: any = {};
|
||||
|
||||
@@ -36,6 +36,10 @@ export class AttendanceRecordItem {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remark?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export class BatchCreateAttendanceDto {
|
||||
@@ -78,6 +82,44 @@ export class QueryDingRawDto {
|
||||
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 {
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -44,6 +44,9 @@ export class AttendanceRecord {
|
||||
@Column({ length: 200, nullable: true })
|
||||
remark: string;
|
||||
|
||||
@Column({ name: 'source', length: 20, default: 'manual' })
|
||||
source: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user