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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user