feat: enhance dashboard with comprehensive stats and charts
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user