feat(classes): add schedule and attendance-summary endpoints + frontend tabs
- Add GET /classes/:id/schedule returning ClassSchedule list with classroom name - Add GET /classes/:id/attendance-summary returning attendance/absence/late rates - Add 课表 and 出勤汇总 tabs to Classes detail page - Register ClassSchedule and AttendanceRecord in ClassesModule
This commit is contained in:
@@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card, Tabs, Descriptions, Table, Button, Space, Select, Modal, Tag,
|
||||
Popconfirm, message, Form, Input, DatePicker, InputNumber,
|
||||
Popconfirm, message, Form, Input, DatePicker, InputNumber, Row, Col, Statistic,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { ArrowLeftOutlined, PlusOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
@@ -29,6 +29,35 @@ interface ClassTeacher {
|
||||
subject: string | null;
|
||||
}
|
||||
|
||||
interface ClassScheduleItem {
|
||||
id: number;
|
||||
classId: number;
|
||||
classroomId: number;
|
||||
classroomName: string;
|
||||
weekDay: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
subject: string;
|
||||
teacherId: number | null;
|
||||
scheduleType: string;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface AttendanceSummary {
|
||||
total: number;
|
||||
present: number;
|
||||
late: number;
|
||||
absent: number;
|
||||
leave: number;
|
||||
presentRate: number;
|
||||
absentRate: number;
|
||||
lateRate: number;
|
||||
leaveRate: number;
|
||||
}
|
||||
|
||||
interface ClassDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -81,6 +110,15 @@ const ROLE_MAP: Record<string, string> = {
|
||||
academic_teacher: '学服老师',
|
||||
};
|
||||
|
||||
const WEEK_DAY_MAP: Record<number, string> = {
|
||||
1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六', 7: '周日',
|
||||
};
|
||||
|
||||
const SCHEDULE_TYPE_MAP: Record<string, string> = {
|
||||
INTERNAL: '内部排课',
|
||||
RENTAL: '租赁',
|
||||
};
|
||||
|
||||
// ---- Component ----
|
||||
|
||||
const ClassDetailPage: React.FC = () => {
|
||||
@@ -105,6 +143,12 @@ const ClassDetailPage: React.FC = () => {
|
||||
const [teacherSubject, setTeacherSubject] = useState('');
|
||||
const [teacherUserId, setTeacherUserId] = useState<number>();
|
||||
|
||||
// Schedule & attendance state
|
||||
const [schedules, setSchedules] = useState<ClassScheduleItem[]>([]);
|
||||
const [scheduleDateRange, setScheduleDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
|
||||
const [attendanceSummary, setAttendanceSummary] = useState<AttendanceSummary | null>(null);
|
||||
const [attendanceDateRange, setAttendanceDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
|
||||
|
||||
const fetchDetail = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -122,6 +166,38 @@ const ClassDetailPage: React.FC = () => {
|
||||
|
||||
useEffect(() => { fetchDetail(); }, [fetchDetail]);
|
||||
|
||||
const fetchSchedules = useCallback(async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (scheduleDateRange?.[0]) params.startDate = scheduleDateRange[0].format('YYYY-MM-DD');
|
||||
if (scheduleDateRange?.[1]) params.endDate = scheduleDateRange[1].format('YYYY-MM-DD');
|
||||
const res = await api.get<ClassScheduleItem[]>(`/classes/${id}/schedule`, { params });
|
||||
setSchedules(res || []);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '加载课表失败');
|
||||
}
|
||||
}, [id, scheduleDateRange]);
|
||||
|
||||
useEffect(() => { fetchSchedules(); }, [fetchSchedules]);
|
||||
|
||||
const fetchAttendanceSummary = useCallback(async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (attendanceDateRange?.[0]) params.startDate = attendanceDateRange[0].format('YYYY-MM-DD');
|
||||
if (attendanceDateRange?.[1]) params.endDate = attendanceDateRange[1].format('YYYY-MM-DD');
|
||||
const res = await api.get<AttendanceSummary>(`/classes/${id}/attendance-summary`, { params });
|
||||
setAttendanceSummary(res || null);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '加载出勤汇总失败');
|
||||
}
|
||||
}, [id, attendanceDateRange]);
|
||||
|
||||
useEffect(() => { fetchAttendanceSummary(); }, [fetchAttendanceSummary]);
|
||||
|
||||
const handleSaveInfo = async () => {
|
||||
try {
|
||||
const values = await editForm.validateFields();
|
||||
@@ -270,6 +346,20 @@ const ClassDetailPage: React.FC = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const scheduleColumns: ColumnsType<ClassScheduleItem> = [
|
||||
{ title: '教室', dataIndex: 'classroomName', render: (v: string | null) => v || '-' },
|
||||
{ title: '星期', dataIndex: 'weekDay', render: (v: number) => WEEK_DAY_MAP[v] || v },
|
||||
{ title: '时间', render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}` },
|
||||
{ title: '日期范围', render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}` },
|
||||
{ title: '科目', dataIndex: 'subject' },
|
||||
{ title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '启用' : v}</Tag>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
@@ -522,6 +612,66 @@ const ClassDetailPage: React.FC = () => {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'schedule',
|
||||
label: '课表',
|
||||
children: (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<DatePicker.RangePicker
|
||||
value={scheduleDateRange}
|
||||
onChange={(dates) => setScheduleDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
</Space>
|
||||
<Table<ClassScheduleItem>
|
||||
columns={scheduleColumns}
|
||||
dataSource={schedules}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 20 }}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'attendance-summary',
|
||||
label: '出勤汇总',
|
||||
children: (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<DatePicker.RangePicker
|
||||
value={attendanceDateRange}
|
||||
onChange={(dates) => setAttendanceDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
</Space>
|
||||
{attendanceSummary && (
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Card bordered={false}>
|
||||
<Statistic title="总记录" value={attendanceSummary.total} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card bordered={false}>
|
||||
<Statistic title="出勤率" value={attendanceSummary.presentRate} suffix="%" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card bordered={false}>
|
||||
<Statistic title="缺勤率" value={attendanceSummary.absentRate} suffix="%" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card bordered={false}>
|
||||
<Statistic title="迟到率" value={attendanceSummary.lateRate} suffix="%" />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user