diff --git a/apps/admin/src/pages/Classes/detail.tsx b/apps/admin/src/pages/Classes/detail.tsx index 2b73e33..cc0243f 100644 --- a/apps/admin/src/pages/Classes/detail.tsx +++ b/apps/admin/src/pages/Classes/detail.tsx @@ -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 = { academic_teacher: '学服老师', }; +const WEEK_DAY_MAP: Record = { + 1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六', 7: '周日', +}; + +const SCHEDULE_TYPE_MAP: Record = { + INTERNAL: '内部排课', + RENTAL: '租赁', +}; + // ---- Component ---- const ClassDetailPage: React.FC = () => { @@ -105,6 +143,12 @@ const ClassDetailPage: React.FC = () => { const [teacherSubject, setTeacherSubject] = useState(''); const [teacherUserId, setTeacherUserId] = useState(); + // Schedule & attendance state + const [schedules, setSchedules] = useState([]); + const [scheduleDateRange, setScheduleDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]); + const [attendanceSummary, setAttendanceSummary] = useState(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 = {}; + 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(`/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 = {}; + 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(`/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 = [ + { 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) => {v === 'active' ? '启用' : v}, + }, + ]; + return ( { ), }, + { + key: 'schedule', + label: '课表', + children: ( +
+ + setScheduleDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])} + placeholder={['开始日期', '结束日期']} + /> + + + columns={scheduleColumns} + dataSource={schedules} + rowKey="id" + pagination={{ pageSize: 20 }} + /> +
+ ), + }, + { + key: 'attendance-summary', + label: '出勤汇总', + children: ( +
+ + setAttendanceDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])} + placeholder={['开始日期', '结束日期']} + /> + + {attendanceSummary && ( + + + + + + + + + + + + + + + + + + + + + + + )} +
+ ), + }, ]} />
diff --git a/apps/server/src/classes/classes.controller.ts b/apps/server/src/classes/classes.controller.ts index 4bb81a2..a7b8a7c 100644 --- a/apps/server/src/classes/classes.controller.ts +++ b/apps/server/src/classes/classes.controller.ts @@ -19,6 +19,8 @@ import { QueryClassDto, AddStudentsDto, AddTeacherDto, + QueryClassScheduleDto, + QueryClassAttendanceSummaryDto, } from './dto/class.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; @@ -49,6 +51,21 @@ export class ClassesController { 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() @RequirePermission('class:create') async create(@Body() dto: CreateClassDto, @Request() req: any) { diff --git a/apps/server/src/classes/classes.module.ts b/apps/server/src/classes/classes.module.ts index 6bebbce..95b9232 100644 --- a/apps/server/src/classes/classes.module.ts +++ b/apps/server/src/classes/classes.module.ts @@ -1,14 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; 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 { ClassesController } from './classes.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher]), OperationLogsModule, NotificationsModule, CommonModule], + imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord]), OperationLogsModule, NotificationsModule, CommonModule], controllers: [ClassesController], providers: [ClassesService], exports: [ClassesService], diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts index 303b1d2..bb8d77c 100644 --- a/apps/server/src/classes/classes.service.ts +++ b/apps/server/src/classes/classes.service.ts @@ -1,8 +1,8 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In, Like } from 'typeorm'; -import { Class, ClassStudent, ClassTeacher } from '../entities'; -import { CreateClassDto, UpdateClassDto, QueryClassDto, AddTeacherDto } from './dto/class.dto'; +import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Classroom } from '../entities'; +import { CreateClassDto, UpdateClassDto, QueryClassDto, AddTeacherDto, QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto'; import { CampusScope } from '../common/campus-scope'; interface RawStudentCount { @@ -19,6 +19,10 @@ export class ClassesService { private classStudentRepo: Repository, @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, + @InjectRepository(ClassSchedule) + private scheduleRepo: Repository, + @InjectRepository(AttendanceRecord) + private attendanceRepo: Repository, private readonly scope: CampusScope, ) {} @@ -118,7 +122,7 @@ export class ClassesService { async update(id: number, dto: UpdateClassDto) { const cls = await this.classRepo.findOne({ where: { id } }); if (!cls) throw new NotFoundException('班级不存在'); - await this.classRepo.update(id, dto as Record); + await this.classRepo.update(id, dto); return this.findOne(id); } @@ -196,4 +200,61 @@ export class ClassesService { 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, + }; + } } diff --git a/apps/server/src/classes/dto/class.dto.ts b/apps/server/src/classes/dto/class.dto.ts index dc428a9..fc136d7 100644 --- a/apps/server/src/classes/dto/class.dto.ts +++ b/apps/server/src/classes/dto/class.dto.ts @@ -115,3 +115,19 @@ export class AddTeacherDto { @IsOptional() @IsString() subject?: string; } + +export class QueryClassScheduleDto { + @IsOptional() @IsDateString() + startDate?: string; + + @IsOptional() @IsDateString() + endDate?: string; +} + +export class QueryClassAttendanceSummaryDto { + @IsOptional() @IsDateString() + startDate?: string; + + @IsOptional() @IsDateString() + endDate?: string; +}