diff --git a/apps/admin/src/pages/Dashboard/index.tsx b/apps/admin/src/pages/Dashboard/index.tsx index 3f7952b..acb7b3d 100644 --- a/apps/admin/src/pages/Dashboard/index.tsx +++ b/apps/admin/src/pages/Dashboard/index.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; 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 dayjs from 'dayjs'; import api from '../../api'; @@ -68,7 +68,14 @@ const DashboardPage: React.FC = () => { const [classRanking, setClassRanking] = useState<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>({ top: [], bottom: [] }); const [classroomOccupancy, setClassroomOccupancy] = useState([]); const [ganttData, setGanttData] = useState([]); - const [roomRanking] = useState>([]); + const [roomRanking, setRoomRanking] = useState>([]); + const [classroomUtil, setClassroomUtil] = useState<{ + totalClassrooms: number; + inUseCount: number; + utilizationRate: string; + scheduleCount: number; + rentalCount: number; + } | null>(null); const [loading, setLoading] = useState(true); const [period, setPeriod] = useState<[string, string]>([ dayjs().startOf('month').format('YYYY-MM-DD'), @@ -78,9 +85,9 @@ const DashboardPage: React.FC = () => { const fetchData = async () => { setLoading(true); try { - const [s, , cr, g] = await Promise.all([ + const [s, rr, cr, g] = await Promise.all([ api.get('/dashboard/stats'), - api.get('/dashboard/room-ranking', { + api.get>('/dashboard/room-ranking', { params: { periodStart: period[0], periodEnd: period[1] }, }), api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>('/dashboard/class-attendance-ranking'), @@ -89,10 +96,13 @@ const DashboardPage: React.FC = () => { }), ]); setStats(s); + setRoomRanking(rr); setClassRanking(cr); setGanttData(g); const co = await api.get('/dashboard/classroom-occupancy'); setClassroomOccupancy(co); + const cu = await api.get('/dashboard/classroom-utilization'); + setClassroomUtil(cu); } catch (e) { console.error(e); } @@ -434,6 +444,29 @@ const DashboardPage: React.FC = () => { + + + + } /> + + + } /> + + + } + valueStyle={{ color: Number(classroomUtil?.utilizationRate ?? 0) > 70 ? '#34C759' : '#FF9500' }} + /> + + + } /> + + + + diff --git a/apps/server/src/dashboard/dashboard.controller.ts b/apps/server/src/dashboard/dashboard.controller.ts index 55fba8e..c427221 100644 --- a/apps/server/src/dashboard/dashboard.controller.ts +++ b/apps/server/src/dashboard/dashboard.controller.ts @@ -48,4 +48,9 @@ export class DashboardController { getClassroomOccupancy() { return this.service.getClassroomOccupancy(); } + + @Get('classroom-utilization') + async getClassroomUtilization() { + return this.service.getClassroomUtilizationStats(); + } } diff --git a/apps/server/src/dashboard/dashboard.service.ts b/apps/server/src/dashboard/dashboard.service.ts index 75d61cb..9c6d151 100644 --- a/apps/server/src/dashboard/dashboard.service.ts +++ b/apps/server/src/dashboard/dashboard.service.ts @@ -383,4 +383,72 @@ export class DashboardService { 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, + }; + } }