diff --git a/apps/admin/src/pages/Dashboard/index.tsx b/apps/admin/src/pages/Dashboard/index.tsx index 8fda22c..cc3919f 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, DollarOutlined, CheckCircleOutlined, BankOutlined, PercentageOutlined, UserSwitchOutlined } from '@ant-design/icons'; +import { TeamOutlined, HomeOutlined, DollarOutlined, CheckCircleOutlined, BankOutlined, PercentageOutlined, UserSwitchOutlined, SolutionOutlined, WalletOutlined, FileProtectOutlined } from '@ant-design/icons'; import ReactECharts from 'echarts-for-react'; import dayjs from 'dayjs'; import api from '../../api'; @@ -18,12 +18,40 @@ const COLORS = [ '#FFCC00', ]; +interface BillStatRow { status: string; count: string; total: string } +interface AttendanceTrendRow { date: string; rate: string } +interface IncomeTrendRow { month: string; amount: number } +interface OccupancyByBuildingRow { building: string; count: string } +interface ExpenseByTypeRow { type: string; total: string } + +interface DashboardStats { + totalRooms: number; + totalStudents: number; + occupiedBeds: number; + totalCapacity: number; + occupancyRate: string; + billStats: BillStatRow[]; + classroomCount: number; + classroomOccupancyRate: string; + todayAttendanceRate: string; + monthlyIncome: number; + classCount: number; + teacherCount: number; + pendingDeposits: number; + activeRentals: number; + occupancyByBuilding: OccupancyByBuildingRow[]; + attendanceByStatus: Record; + expenseByType: ExpenseByTypeRow[]; + attendanceTrend: AttendanceTrendRow[]; + incomeTrend: IncomeTrendRow[]; +} + +interface ApiResponse { data: T } + const DashboardPage: React.FC = () => { const screens = Grid.useBreakpoint(); - const isMobile = !screens.sm; - const [stats, setStats] = useState(null); - const [expenseStats, setExpenseStats] = useState([]); - const [roomRanking, setRoomRanking] = useState([]); + const [stats, setStats] = useState(null); + const [roomRanking, setRoomRanking] = useState>([]); const [loading, setLoading] = useState(true); const [period, setPeriod] = useState<[string, string]>([ dayjs().startOf('month').format('YYYY-MM-DD'), @@ -33,18 +61,14 @@ const DashboardPage: React.FC = () => { const fetchData = async () => { setLoading(true); try { - const [s, e, r] = await Promise.all([ + const [s, r] = await Promise.all([ api.get('/dashboard/stats'), - api.get('/dashboard/expense-stats', { - params: { periodStart: period[0], periodEnd: period[1] }, - }), api.get('/dashboard/room-ranking', { params: { periodStart: period[0], periodEnd: period[1] }, }), ]); setStats(s); - setExpenseStats(e as any); - setRoomRanking(r as any); + setRoomRanking(r as unknown as Array<{ roomNumber: string; total: string }>); } catch (e) { console.error(e); } @@ -67,23 +91,34 @@ const DashboardPage: React.FC = () => { other: '其他', }; + const attendanceLabelMap: Record = { + present: '出勤', + absent: '缺勤', + late: '迟到', + early: '早退', + leave: '请假', + }; - // 费用饼图 - const pieOption = { + // 今日出勤状态分布环图 + const attendanceRingOption = { tooltip: { trigger: 'item' }, legend: { bottom: 0 }, series: [ { type: 'pie', radius: ['40%', '70%'], - data: expenseStats.map((e) => ({ - name: expenseTypeMap[e.type] || e.type, - value: Number(e.total), + center: ['50%', '45%'], + data: Object.entries(stats?.attendanceByStatus ?? {}).map(([status, count]) => ({ + name: attendanceLabelMap[status] ?? status, + value: count, })), + itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 }, }, ], + color: COLORS, }; + // 宿舍费用排行 const barOption = { tooltip: {}, @@ -201,7 +236,7 @@ const DashboardPage: React.FC = () => { title="入住率" value={stats?.occupancyRate || 0} suffix="%" - prefix={} + prefix={} /> @@ -245,6 +280,34 @@ const DashboardPage: React.FC = () => { + + + + } /> + + + + + } /> + + + + + + + + + + } /> + + + + @@ -256,21 +319,26 @@ const DashboardPage: React.FC = () => { - - {(stats?.incomeTrend || []).length > 0 ? ( - + + {Object.keys(stats?.attendanceByStatus ?? {}).length > 0 ? ( + ) : ( -
暂无收入数据
+
暂无考勤数据
)}
- + - {expenseStats.length > 0 ? ( - + {((stats?.expenseByType) ?? []).length > 0 ? ( + ({ name: expenseTypeMap[e.type] ?? e.type, value: Number(e.total) })) }], + }} style={{ width: '100%', height: isMobile ? 250 : 300 }} /> ) : (
暂无费用数据
)} @@ -286,6 +354,18 @@ const DashboardPage: React.FC = () => {
+ + + + + {(stats?.incomeTrend || []).length > 0 ? ( + + ) : ( +
暂无收入数据
+ )} +
+ +
); }; diff --git a/apps/server/src/dashboard/dashboard.module.ts b/apps/server/src/dashboard/dashboard.module.ts index cd3a283..b864386 100644 --- a/apps/server/src/dashboard/dashboard.module.ts +++ b/apps/server/src/dashboard/dashboard.module.ts @@ -8,11 +8,15 @@ import { RoomExpense } from '../entities/room-expense.entity'; import { Classroom } from '../entities/classroom.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; +import { Class } from '../entities/class.entity'; +import { Deposit } from '../entities/deposit.entity'; +import { ClassroomRental } from '../entities/classroom-rental.entity'; +import { ClassTeacher } from '../entities/class-teacher.entity'; import { DashboardService } from './dashboard.service'; import { DashboardController } from './dashboard.controller'; @Module({ - imports: [TypeOrmModule.forFeature([Room, Student, Occupancy, Bill, RoomExpense, Classroom, ClassSchedule, AttendanceRecord])], + imports: [TypeOrmModule.forFeature([Room, Student, Occupancy, Bill, RoomExpense, Classroom, ClassSchedule, AttendanceRecord, Class, Deposit, ClassroomRental, ClassTeacher])], controllers: [DashboardController], providers: [DashboardService], }) diff --git a/apps/server/src/dashboard/dashboard.service.ts b/apps/server/src/dashboard/dashboard.service.ts index 0ff923c..7e9d69b 100644 --- a/apps/server/src/dashboard/dashboard.service.ts +++ b/apps/server/src/dashboard/dashboard.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, IsNull, Not } from 'typeorm'; +import { Repository, IsNull, Not, MoreThanOrEqual } from 'typeorm'; import { Room } from '../entities/room.entity'; import { Student } from '../entities/student.entity'; import { Occupancy } from '../entities/occupancy.entity'; @@ -9,6 +9,10 @@ import { RoomExpense } from '../entities/room-expense.entity'; import { Classroom } from '../entities/classroom.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; +import { Class } from '../entities/class.entity'; +import { Deposit } from '../entities/deposit.entity'; +import { ClassroomRental } from '../entities/classroom-rental.entity'; +import { ClassTeacher } from '../entities/class-teacher.entity'; @Injectable() export class DashboardService { @@ -21,6 +25,10 @@ export class DashboardService { @InjectRepository(Classroom) private classroomRepo: Repository, @InjectRepository(ClassSchedule) private scheduleRepo: Repository, @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, + @InjectRepository(Class) private classRepo: Repository, + @InjectRepository(Deposit) private depositRepo: Repository, + @InjectRepository(ClassroomRental) private rentalRepo: Repository, + @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, ) {} async getStats() { @@ -89,6 +97,48 @@ export class DashboardService { const attendanceTrend = await this.getAttendanceTrend(todayStr); const incomeTrend = await this.getIncomeTrend(currentMonth); + // --- New stats --- + const classCount = await this.classRepo.count(); + + const teacherResult = await this.classTeacherRepo + .createQueryBuilder('ct') + .select('COUNT(DISTINCT ct.userId)', 'cnt') + .getRawOne(); + const teacherCount = parseInt(teacherResult?.cnt || '0', 10); + + const pendingResult = await this.depositRepo + .createQueryBuilder('d') + .select('SUM(d.amount)', 'total') + .where('d.status = :paid', { paid: 'paid' }) + .andWhere('d.refundStatus IS NULL') + .getRawOne(); + const pendingDeposits = parseFloat(pendingResult?.total || '0'); + + const activeRentals = await this.rentalRepo.count({ where: { endDate: MoreThanOrEqual(todayStr) } }); + + const occupancyByBuilding = await this.occRepo + .createQueryBuilder('o') + .leftJoin('o.room', 'r') + .select('r.building', 'building') + .addSelect('COUNT(*)', 'count') + .where('o.checkOutDate IS NULL') + .groupBy('r.building') + .getRawMany(); + + const attendanceByStatus = attTodayStats.reduce((acc, r) => { + acc[r.status] = parseInt(r.count, 10); + return acc; + }, {} as Record); + + const expenseByType = await this.expRepo + .createQueryBuilder('e') + .select('e.expenseType', 'type') + .addSelect('SUM(e.amount)', 'total') + .where('e.periodStart >= :start', { start: `${currentMonth}-01` }) + .andWhere('e.periodEnd <= :end', { end: this.nextMonth(currentMonth) }) + .groupBy('e.expenseType') + .getRawMany(); + return { totalRooms, totalStudents, @@ -100,6 +150,13 @@ export class DashboardService { classroomOccupancyRate, todayAttendanceRate, monthlyIncome, + classCount, + teacherCount, + pendingDeposits, + activeRentals, + occupancyByBuilding, + attendanceByStatus, + expenseByType, attendanceTrend, incomeTrend, };