forked from wangziqi/gongxue-base
feat(dashboard): add classroom utilization stats endpoint and UI
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Row, Col, Card, Statistic, DatePicker, Spin, Grid } from 'antd';
|
import { Row, Col, Card, Statistic, DatePicker, Spin, Grid } from 'antd';
|
||||||
import { TeamOutlined, HomeOutlined, CheckCircleOutlined, BankOutlined, PercentageOutlined, UserSwitchOutlined, SolutionOutlined, FileProtectOutlined } from '@ant-design/icons';
|
import { TeamOutlined, HomeOutlined, CheckCircleOutlined, BankOutlined, PercentageOutlined, UserSwitchOutlined, SolutionOutlined, FileProtectOutlined, ReadOutlined, CalendarOutlined } from '@ant-design/icons';
|
||||||
import ReactECharts from 'echarts-for-react';
|
import ReactECharts from 'echarts-for-react';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
@@ -68,7 +68,14 @@ const DashboardPage: React.FC = () => {
|
|||||||
const [classRanking, setClassRanking] = useState<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>({ top: [], bottom: [] });
|
const [classRanking, setClassRanking] = useState<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>({ top: [], bottom: [] });
|
||||||
const [classroomOccupancy, setClassroomOccupancy] = useState<ClassroomOccupancy[]>([]);
|
const [classroomOccupancy, setClassroomOccupancy] = useState<ClassroomOccupancy[]>([]);
|
||||||
const [ganttData, setGanttData] = useState<GanttRoom[]>([]);
|
const [ganttData, setGanttData] = useState<GanttRoom[]>([]);
|
||||||
const [roomRanking] = useState<Array<{ roomNumber: string; total: string }>>([]);
|
const [roomRanking, setRoomRanking] = useState<Array<{ roomNumber: string; total: string }>>([]);
|
||||||
|
const [classroomUtil, setClassroomUtil] = useState<{
|
||||||
|
totalClassrooms: number;
|
||||||
|
inUseCount: number;
|
||||||
|
utilizationRate: string;
|
||||||
|
scheduleCount: number;
|
||||||
|
rentalCount: number;
|
||||||
|
} | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [period, setPeriod] = useState<[string, string]>([
|
const [period, setPeriod] = useState<[string, string]>([
|
||||||
dayjs().startOf('month').format('YYYY-MM-DD'),
|
dayjs().startOf('month').format('YYYY-MM-DD'),
|
||||||
@@ -78,9 +85,9 @@ const DashboardPage: React.FC = () => {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [s, , cr, g] = await Promise.all([
|
const [s, rr, cr, g] = await Promise.all([
|
||||||
api.get<DashboardStats>('/dashboard/stats'),
|
api.get<DashboardStats>('/dashboard/stats'),
|
||||||
api.get('/dashboard/room-ranking', {
|
api.get<Array<{ roomNumber: string; total: string }>>('/dashboard/room-ranking', {
|
||||||
params: { periodStart: period[0], periodEnd: period[1] },
|
params: { periodStart: period[0], periodEnd: period[1] },
|
||||||
}),
|
}),
|
||||||
api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>('/dashboard/class-attendance-ranking'),
|
api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>('/dashboard/class-attendance-ranking'),
|
||||||
@@ -89,10 +96,13 @@ const DashboardPage: React.FC = () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
setStats(s);
|
setStats(s);
|
||||||
|
setRoomRanking(rr);
|
||||||
setClassRanking(cr);
|
setClassRanking(cr);
|
||||||
setGanttData(g);
|
setGanttData(g);
|
||||||
const co = await api.get<ClassroomOccupancy[]>('/dashboard/classroom-occupancy');
|
const co = await api.get<ClassroomOccupancy[]>('/dashboard/classroom-occupancy');
|
||||||
setClassroomOccupancy(co);
|
setClassroomOccupancy(co);
|
||||||
|
const cu = await api.get('/dashboard/classroom-utilization');
|
||||||
|
setClassroomUtil(cu);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
@@ -434,6 +444,29 @@ const DashboardPage: React.FC = () => {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<Card title="教室利用率" style={{ marginBottom: 16 }}>
|
||||||
|
<Row gutter={[24, 16]}>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Statistic title="教室总数" value={classroomUtil?.totalClassrooms ?? '-'} prefix={<ReadOutlined />} />
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Statistic title="今日使用" value={classroomUtil?.inUseCount ?? '-'} prefix={<CheckCircleOutlined />} />
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Statistic
|
||||||
|
title="利用率"
|
||||||
|
value={classroomUtil?.utilizationRate ?? '-'}
|
||||||
|
suffix="%"
|
||||||
|
prefix={<PercentageOutlined />}
|
||||||
|
valueStyle={{ color: Number(classroomUtil?.utilizationRate ?? 0) > 70 ? '#34C759' : '#FF9500' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Statistic title="内部排课" value={classroomUtil?.scheduleCount ?? '-'} prefix={<CalendarOutlined />} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
<Col xs={24} sm={12}>
|
<Col xs={24} sm={12}>
|
||||||
<Card title="考勤趋势(近30天)">
|
<Card title="考勤趋势(近30天)">
|
||||||
|
|||||||
@@ -48,4 +48,9 @@ export class DashboardController {
|
|||||||
getClassroomOccupancy() {
|
getClassroomOccupancy() {
|
||||||
return this.service.getClassroomOccupancy();
|
return this.service.getClassroomOccupancy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('classroom-utilization')
|
||||||
|
async getClassroomUtilization() {
|
||||||
|
return this.service.getClassroomUtilizationStats();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,4 +383,72 @@ export class DashboardService {
|
|||||||
occupancy: Math.min(((sMap[c.id] || 0) + (rMap[c.id] || 0) * 3) / 7, 1),
|
occupancy: Math.min(((sMap[c.id] || 0) + (rMap[c.id] || 0) * 3) / 7, 1),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getClassroomUtilizationStats() {
|
||||||
|
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||||
|
const totalClassrooms = await this.classroomRepo.count({
|
||||||
|
where: await this.scope.filter({ status: Not('archived') }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Count classrooms with active schedules today
|
||||||
|
const schedQb = this.scheduleRepo
|
||||||
|
.createQueryBuilder('s')
|
||||||
|
.select('COUNT(DISTINCT s.classroomId)', 'cnt')
|
||||||
|
.where('s.status = :active', { active: 'active' })
|
||||||
|
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
||||||
|
.andWhere('s.startDate <= :today AND s.endDate >= :today', { today });
|
||||||
|
if (scopeIds) schedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds });
|
||||||
|
const schedResult = await schedQb.getRawOne();
|
||||||
|
|
||||||
|
// Count classrooms with active rentals today
|
||||||
|
const rentalQb = this.rentalRepo
|
||||||
|
.createQueryBuilder('r')
|
||||||
|
.select('COUNT(DISTINCT r.classroomId)', 'cnt')
|
||||||
|
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||||
|
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today });
|
||||||
|
if (scopeIds) rentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds });
|
||||||
|
const rentalResult = await rentalQb.getRawOne();
|
||||||
|
|
||||||
|
// Combine: use Set merge of both
|
||||||
|
const combinedQb = this.scheduleRepo
|
||||||
|
.createQueryBuilder('s')
|
||||||
|
.select('s.classroomId')
|
||||||
|
.where('s.status = :active', { active: 'active' })
|
||||||
|
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
||||||
|
.andWhere('s.startDate <= :today AND s.endDate >= :today', { today })
|
||||||
|
.groupBy('s.classroomId');
|
||||||
|
if (scopeIds) combinedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds });
|
||||||
|
const schedIds = await combinedQb.getRawMany();
|
||||||
|
|
||||||
|
const combinedRentalQb = this.rentalRepo
|
||||||
|
.createQueryBuilder('r')
|
||||||
|
.select('r.classroomId')
|
||||||
|
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||||
|
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
|
||||||
|
.groupBy('r.classroomId');
|
||||||
|
if (scopeIds) combinedRentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds });
|
||||||
|
const rentalIds = await combinedRentalQb.getRawMany();
|
||||||
|
|
||||||
|
const allInUseIds = new Set([
|
||||||
|
...schedIds.map((s) => s.classroomId),
|
||||||
|
...rentalIds.map((r) => r.classroomId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const scheduleCount = parseInt(schedResult?.cnt || '0', 10);
|
||||||
|
const rentalCount = parseInt(rentalResult?.cnt || '0', 10);
|
||||||
|
const inUseCount = allInUseIds.size;
|
||||||
|
const utilizationRate = totalClassrooms > 0
|
||||||
|
? ((inUseCount / totalClassrooms) * 100).toFixed(1)
|
||||||
|
: '0';
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalClassrooms,
|
||||||
|
inUseCount,
|
||||||
|
utilizationRate,
|
||||||
|
scheduleCount,
|
||||||
|
rentalCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user