feat: enhance dashboard with comprehensive stats and charts

This commit is contained in:
2026-07-05 21:52:50 +08:00
parent ab5898e2cf
commit 2be8b55c93
3 changed files with 167 additions and 26 deletions

View File

@@ -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<string, number>;
expenseByType: ExpenseByTypeRow[];
attendanceTrend: AttendanceTrendRow[];
incomeTrend: IncomeTrendRow[];
}
interface ApiResponse<T> { data: T }
const DashboardPage: React.FC = () => {
const screens = Grid.useBreakpoint();
const isMobile = !screens.sm;
const [stats, setStats] = useState<any>(null);
const [expenseStats, setExpenseStats] = useState<any[]>([]);
const [roomRanking, setRoomRanking] = useState<any[]>([]);
const [stats, setStats] = useState<DashboardStats | null>(null);
const [roomRanking, setRoomRanking] = useState<Array<{ roomNumber: string; total: string }>>([]);
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<string, string> = {
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={<DollarOutlined />}
prefix={<PercentageOutlined />}
/>
</Card>
</Col>
@@ -245,6 +280,34 @@ const DashboardPage: React.FC = () => {
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic title="班级总数" value={stats?.classCount ?? 0} prefix={<TeamOutlined />} />
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic title="教师总数" value={stats?.teacherCount ?? 0} prefix={<SolutionOutlined />} />
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic
title="待退押金"
value={stats?.pendingDeposits ?? 0}
precision={2}
prefix="¥"
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic title="活跃租赁" value={stats?.activeRentals ?? 0} prefix={<FileProtectOutlined />} />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12}>
<Card title="考勤趋势近30天">
@@ -256,21 +319,26 @@ const DashboardPage: React.FC = () => {
</Card>
</Col>
<Col xs={24} sm={12}>
<Card title="收入趋势近6个月">
{(stats?.incomeTrend || []).length > 0 ? (
<ReactECharts option={incomeLineOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} />
<Card title="今日出勤状态分布">
{Object.keys(stats?.attendanceByStatus ?? {}).length > 0 ? (
<ReactECharts option={attendanceRingOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} />
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12}>
<Card title="费用类型分布">
{expenseStats.length > 0 ? (
<ReactECharts option={pieOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} />
{((stats?.expenseByType) ?? []).length > 0 ? (
<ReactECharts option={{
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
color: COLORS,
series: [{ type: 'pie', radius: ['40%', '70%'], center: ['50%', '45%'], data: (stats?.expenseByType ?? []).map((e) => ({ name: expenseTypeMap[e.type] ?? e.type, value: Number(e.total) })) }],
}} style={{ width: '100%', height: isMobile ? 250 : 300 }} />
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
@@ -286,6 +354,18 @@ const DashboardPage: React.FC = () => {
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24}>
<Card title="月度收入趋势">
{(stats?.incomeTrend || []).length > 0 ? (
<ReactECharts option={incomeLineOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} />
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</Card>
</Col>
</Row>
</div>
);
};

View File

@@ -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],
})

View File

@@ -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<Classroom>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(Class) private classRepo: Repository<Class>,
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
) {}
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<string, number>);
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,
};