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 { useParams, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Card, Tabs, Descriptions, Table, Button, Space, Select, Modal, Tag,
|
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';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { ArrowLeftOutlined, PlusOutlined, DownloadOutlined } from '@ant-design/icons';
|
import { ArrowLeftOutlined, PlusOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||||
@@ -29,6 +29,35 @@ interface ClassTeacher {
|
|||||||
subject: string | null;
|
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 {
|
interface ClassDetail {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -81,6 +110,15 @@ const ROLE_MAP: Record<string, string> = {
|
|||||||
academic_teacher: '学服老师',
|
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 ----
|
// ---- Component ----
|
||||||
|
|
||||||
const ClassDetailPage: React.FC = () => {
|
const ClassDetailPage: React.FC = () => {
|
||||||
@@ -105,6 +143,12 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
const [teacherSubject, setTeacherSubject] = useState('');
|
const [teacherSubject, setTeacherSubject] = useState('');
|
||||||
const [teacherUserId, setTeacherUserId] = useState<number>();
|
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 () => {
|
const fetchDetail = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -122,6 +166,38 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => { fetchDetail(); }, [fetchDetail]);
|
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 () => {
|
const handleSaveInfo = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await editForm.validateFields();
|
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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
@@ -522,6 +612,66 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
</div>
|
</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>
|
</Card>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
QueryClassDto,
|
QueryClassDto,
|
||||||
AddStudentsDto,
|
AddStudentsDto,
|
||||||
AddTeacherDto,
|
AddTeacherDto,
|
||||||
|
QueryClassScheduleDto,
|
||||||
|
QueryClassAttendanceSummaryDto,
|
||||||
} from './dto/class.dto';
|
} from './dto/class.dto';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
@@ -49,6 +51,21 @@ export class ClassesController {
|
|||||||
return this.service.findOne(+id);
|
return this.service.findOne(+id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/schedule')
|
||||||
|
@RequirePermission('class:view')
|
||||||
|
getSchedule(@Param('id') id: string, @Query() query: QueryClassScheduleDto) {
|
||||||
|
return this.service.getSchedule(+id, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/attendance-summary')
|
||||||
|
@RequirePermission('class:view')
|
||||||
|
getAttendanceSummary(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query() query: QueryClassAttendanceSummaryDto,
|
||||||
|
) {
|
||||||
|
return this.service.getAttendanceSummary(+id, query);
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePermission('class:create')
|
@RequirePermission('class:create')
|
||||||
async create(@Body() dto: CreateClassDto, @Request() req: any) {
|
async create(@Body() dto: CreateClassDto, @Request() req: any) {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { CommonModule } from '../common/common.module';
|
import { CommonModule } from '../common/common.module';
|
||||||
import { Class, ClassStudent, ClassTeacher } from '../entities';
|
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord } from '../entities';
|
||||||
import { ClassesService } from './classes.service';
|
import { ClassesService } from './classes.service';
|
||||||
import { ClassesController } from './classes.controller';
|
import { ClassesController } from './classes.controller';
|
||||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher]), OperationLogsModule, NotificationsModule, CommonModule],
|
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord]), OperationLogsModule, NotificationsModule, CommonModule],
|
||||||
controllers: [ClassesController],
|
controllers: [ClassesController],
|
||||||
providers: [ClassesService],
|
providers: [ClassesService],
|
||||||
exports: [ClassesService],
|
exports: [ClassesService],
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, In, Like } from 'typeorm';
|
import { Repository, In, Like } from 'typeorm';
|
||||||
import { Class, ClassStudent, ClassTeacher } from '../entities';
|
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Classroom } from '../entities';
|
||||||
import { CreateClassDto, UpdateClassDto, QueryClassDto, AddTeacherDto } from './dto/class.dto';
|
import { CreateClassDto, UpdateClassDto, QueryClassDto, AddTeacherDto, QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto';
|
||||||
import { CampusScope } from '../common/campus-scope';
|
import { CampusScope } from '../common/campus-scope';
|
||||||
|
|
||||||
interface RawStudentCount {
|
interface RawStudentCount {
|
||||||
@@ -19,6 +19,10 @@ export class ClassesService {
|
|||||||
private classStudentRepo: Repository<ClassStudent>,
|
private classStudentRepo: Repository<ClassStudent>,
|
||||||
@InjectRepository(ClassTeacher)
|
@InjectRepository(ClassTeacher)
|
||||||
private classTeacherRepo: Repository<ClassTeacher>,
|
private classTeacherRepo: Repository<ClassTeacher>,
|
||||||
|
@InjectRepository(ClassSchedule)
|
||||||
|
private scheduleRepo: Repository<ClassSchedule>,
|
||||||
|
@InjectRepository(AttendanceRecord)
|
||||||
|
private attendanceRepo: Repository<AttendanceRecord>,
|
||||||
private readonly scope: CampusScope,
|
private readonly scope: CampusScope,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -118,7 +122,7 @@ export class ClassesService {
|
|||||||
async update(id: number, dto: UpdateClassDto) {
|
async update(id: number, dto: UpdateClassDto) {
|
||||||
const cls = await this.classRepo.findOne({ where: { id } });
|
const cls = await this.classRepo.findOne({ where: { id } });
|
||||||
if (!cls) throw new NotFoundException('班级不存在');
|
if (!cls) throw new NotFoundException('班级不存在');
|
||||||
await this.classRepo.update(id, dto as Record<string, unknown>);
|
await this.classRepo.update(id, dto);
|
||||||
return this.findOne(id);
|
return this.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,4 +200,61 @@ export class ClassesService {
|
|||||||
await this.classRepo.update(classId, updates);
|
await this.classRepo.update(classId, updates);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSchedule(classId: number, query: QueryClassScheduleDto) {
|
||||||
|
const qb = this.scheduleRepo
|
||||||
|
.createQueryBuilder('cs')
|
||||||
|
.leftJoinAndSelect('cs.classroom', 'classroom')
|
||||||
|
.where('cs.classId = :classId', { classId });
|
||||||
|
|
||||||
|
if (query.startDate) {
|
||||||
|
qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate });
|
||||||
|
}
|
||||||
|
if (query.endDate) {
|
||||||
|
qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate });
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedules = await qb
|
||||||
|
.orderBy('cs.weekDay', 'ASC')
|
||||||
|
.addOrderBy('cs.startTime', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
return schedules.map((s) => ({
|
||||||
|
...s,
|
||||||
|
classroomName: (s.classroom as Classroom | undefined)?.name || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) {
|
||||||
|
const qb = this.attendanceRepo
|
||||||
|
.createQueryBuilder('ar')
|
||||||
|
.where('ar.classId = :classId', { classId });
|
||||||
|
|
||||||
|
if (query.startDate) {
|
||||||
|
qb.andWhere('ar.attendanceDate >= :startDate', { startDate: query.startDate });
|
||||||
|
}
|
||||||
|
if (query.endDate) {
|
||||||
|
qb.andWhere('ar.attendanceDate <= :endDate', { endDate: query.endDate });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await qb.getMany();
|
||||||
|
|
||||||
|
const total = rows.length;
|
||||||
|
const present = rows.filter((r) => r.status === 'present').length;
|
||||||
|
const late = rows.filter((r) => r.status === 'late').length;
|
||||||
|
const absent = rows.filter((r) => r.status === 'absent').length;
|
||||||
|
const leave = rows.filter((r) => r.status === 'leave').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
present,
|
||||||
|
late,
|
||||||
|
absent,
|
||||||
|
leave,
|
||||||
|
presentRate: total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0,
|
||||||
|
absentRate: total > 0 ? Number(((absent / total) * 100).toFixed(1)) : 0,
|
||||||
|
lateRate: total > 0 ? Number(((late / total) * 100).toFixed(1)) : 0,
|
||||||
|
leaveRate: total > 0 ? Number(((leave / total) * 100).toFixed(1)) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,3 +115,19 @@ export class AddTeacherDto {
|
|||||||
@IsOptional() @IsString()
|
@IsOptional() @IsString()
|
||||||
subject?: string;
|
subject?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class QueryClassScheduleDto {
|
||||||
|
@IsOptional() @IsDateString()
|
||||||
|
startDate?: string;
|
||||||
|
|
||||||
|
@IsOptional() @IsDateString()
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QueryClassAttendanceSummaryDto {
|
||||||
|
@IsOptional() @IsDateString()
|
||||||
|
startDate?: string;
|
||||||
|
|
||||||
|
@IsOptional() @IsDateString()
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user