256 lines
7.7 KiB
TypeScript
256 lines
7.7 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { Row, Col, Card, Statistic, DatePicker, Spin, Grid } from 'antd';
|
|
import { TeamOutlined, HomeOutlined, DollarOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
|
import ReactECharts from 'echarts-for-react';
|
|
import dayjs from 'dayjs';
|
|
import api from '../../api';
|
|
|
|
const { RangePicker } = DatePicker;
|
|
|
|
const COLORS = [
|
|
'#007AFF',
|
|
'#34C759',
|
|
'#FF9500',
|
|
'#FF3B30',
|
|
'#5AC8FA',
|
|
'#AF52DE',
|
|
'#FF2D55',
|
|
'#FFCC00',
|
|
];
|
|
|
|
const DashboardPage: React.FC = () => {
|
|
const screens = Grid.useBreakpoint();
|
|
const isMobile = !screens.sm;
|
|
const [stats, setStats] = useState<any>(null);
|
|
const [ganttData, setGanttData] = useState<any[]>([]);
|
|
const [expenseStats, setExpenseStats] = useState<any[]>([]);
|
|
const [roomRanking, setRoomRanking] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [period, setPeriod] = useState<[string, string]>([
|
|
dayjs().startOf('month').format('YYYY-MM-DD'),
|
|
dayjs().endOf('month').format('YYYY-MM-DD'),
|
|
]);
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [s, g, e, r] = await Promise.all([
|
|
api.get('/dashboard/stats'),
|
|
api.get('/dashboard/gantt', { params: { periodStart: period[0], periodEnd: period[1] } }),
|
|
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);
|
|
setGanttData(g as any);
|
|
setExpenseStats(e as any);
|
|
setRoomRanking(r as any);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [period]);
|
|
|
|
const expenseTypeMap: Record<string, string> = {
|
|
water: '水费',
|
|
electricity: '电费',
|
|
cleaning: '保洁费',
|
|
damage: '损坏赔偿',
|
|
penalty: '罚款',
|
|
key: '钥匙费',
|
|
remote: '空调遥控器',
|
|
deposit_deduction: '押金扣除',
|
|
other: '其他',
|
|
};
|
|
|
|
// 甘特图配置
|
|
const ganttOption = () => {
|
|
if (!ganttData.length) return {};
|
|
const rooms = ganttData.map((d) => d.roomNumber);
|
|
const pStart = new Date(period[0]).getTime();
|
|
const pEnd = new Date(period[1]).getTime();
|
|
|
|
const data: any[] = [];
|
|
ganttData.forEach((room, roomIdx) => {
|
|
room.occupancies.forEach((occ: any, i: number) => {
|
|
const start = Math.max(new Date(occ.checkInDate).getTime(), pStart);
|
|
const end = occ.checkOutDate ? Math.min(new Date(occ.checkOutDate).getTime(), pEnd) : pEnd;
|
|
data.push({
|
|
name: occ.studentName,
|
|
value: [roomIdx, start, end, occ.studentName],
|
|
itemStyle: { color: COLORS[(occ.studentId || i) % COLORS.length] },
|
|
});
|
|
});
|
|
});
|
|
|
|
return {
|
|
tooltip: {
|
|
formatter: (p: any) => {
|
|
const v = p.value;
|
|
return `${p.name}<br/>宿舍: ${rooms[v[0]]}<br/>入住: ${dayjs(v[1]).format('MM-DD')} ~ ${dayjs(v[2]).format('MM-DD')}`;
|
|
},
|
|
},
|
|
grid: { left: 80, right: 30, top: 20, bottom: 30 },
|
|
xAxis: { type: 'time', min: pStart, max: pEnd },
|
|
yAxis: { type: 'category', data: rooms, inverse: true },
|
|
series: [
|
|
{
|
|
type: 'custom',
|
|
renderItem: (_params: any, api: any) => {
|
|
const catIdx = api.value(0);
|
|
const start = api.coord([api.value(1), catIdx]);
|
|
const end = api.coord([api.value(2), catIdx]);
|
|
const height = api.size([0, 1])[1] * 0.6;
|
|
return {
|
|
type: 'rect',
|
|
shape: { x: start[0], y: start[1] - height / 2, width: end[0] - start[0], height },
|
|
style: { ...api.style(), fill: api.visual('color'), stroke: '#fff', lineWidth: 1 },
|
|
};
|
|
},
|
|
encode: { x: [1, 2], y: 0 },
|
|
data,
|
|
},
|
|
],
|
|
};
|
|
};
|
|
|
|
// 费用饼图
|
|
const pieOption = {
|
|
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),
|
|
})),
|
|
},
|
|
],
|
|
};
|
|
|
|
// 宿舍费用排行
|
|
const barOption = {
|
|
tooltip: {},
|
|
grid: { left: 80, right: 20, bottom: 30, top: 10 },
|
|
xAxis: { type: 'value' },
|
|
yAxis: {
|
|
type: 'category',
|
|
data: roomRanking.map((r) => r.roomNumber).reverse(),
|
|
inverse: false,
|
|
},
|
|
series: [
|
|
{
|
|
type: 'bar',
|
|
data: roomRanking.map((r) => Number(r.total)).reverse(),
|
|
itemStyle: { color: '#007AFF', borderRadius: [0, 4, 4, 0] },
|
|
},
|
|
],
|
|
};
|
|
|
|
if (loading && !stats)
|
|
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
|
|
|
return (
|
|
<div>
|
|
<div
|
|
style={{
|
|
marginBottom: 16,
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: isMobile ? 'flex-start' : 'center',
|
|
flexDirection: isMobile ? 'column' : 'row',
|
|
gap: 12,
|
|
}}
|
|
>
|
|
<h2 style={{ margin: 0 }}>数据面板</h2>
|
|
<RangePicker
|
|
value={[dayjs(period[0]), dayjs(period[1])]}
|
|
onChange={(dates) => {
|
|
if (dates) setPeriod([dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')]);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
|
<Col xs={12} sm={12} md={6}>
|
|
<Card>
|
|
<Statistic title="宿舍总数" value={stats?.totalRooms || 0} prefix={<HomeOutlined />} />
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={12} md={6}>
|
|
<Card>
|
|
<Statistic
|
|
title="在读学生"
|
|
value={stats?.totalStudents || 0}
|
|
prefix={<TeamOutlined />}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={12} md={6}>
|
|
<Card>
|
|
<Statistic
|
|
title="当前在住"
|
|
value={stats?.occupiedBeds || 0}
|
|
suffix={`/ ${stats?.totalCapacity || 0}`}
|
|
prefix={<CheckCircleOutlined />}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={12} md={6}>
|
|
<Card>
|
|
<Statistic
|
|
title="入住率"
|
|
value={stats?.occupancyRate || 0}
|
|
suffix="%"
|
|
prefix={<DollarOutlined />}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Card title="入住时间线(甘特图)" style={{ marginBottom: 24 }}>
|
|
{ganttData.length > 0 ? (
|
|
<ReactECharts
|
|
option={ganttOption()}
|
|
style={{ width: '100%', height: Math.max(300, ganttData.length * 40) }}
|
|
/>
|
|
) : (
|
|
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>暂无入住数据</div>
|
|
)}
|
|
</Card>
|
|
|
|
<Row gutter={[16, 16]}>
|
|
<Col xs={24} sm={12}>
|
|
<Card title="费用类型分布">
|
|
{expenseStats.length > 0 ? (
|
|
<ReactECharts option={pieOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} />
|
|
) : (
|
|
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>暂无费用数据</div>
|
|
)}
|
|
</Card>
|
|
</Col>
|
|
<Col xs={24} sm={12}>
|
|
<Card title="宿舍费用排行 TOP 20">
|
|
{roomRanking.length > 0 ? (
|
|
<ReactECharts option={barOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} />
|
|
) : (
|
|
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>暂无费用数据</div>
|
|
)}
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DashboardPage;
|