feat: enhance dashboard with attendance count + Gantt chart
This commit is contained in:
@@ -19,10 +19,24 @@ const COLORS = [
|
||||
];
|
||||
|
||||
interface BillStatRow { status: string; count: string; total: string }
|
||||
interface ClassAttendanceRank { className: string; present: number; total: number; rate: number }
|
||||
interface ClassroomOccupancy { name: string; building: string; capacity: number; scheduleDays: number; rentalCount: number; occupancy: number }
|
||||
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 GanttOccupancy {
|
||||
studentName: string;
|
||||
studentId?: string;
|
||||
checkInDate: string;
|
||||
checkOutDate: string | null;
|
||||
billingStartDate?: string;
|
||||
billingEndDate?: string;
|
||||
}
|
||||
interface GanttRoom {
|
||||
roomNumber: string;
|
||||
occupancies: GanttOccupancy[];
|
||||
}
|
||||
|
||||
interface DashboardStats {
|
||||
totalRooms: number;
|
||||
@@ -39,6 +53,7 @@ interface DashboardStats {
|
||||
teacherCount: number;
|
||||
pendingDeposits: number;
|
||||
activeRentals: number;
|
||||
todayPresent: number;
|
||||
occupancyByBuilding: OccupancyByBuildingRow[];
|
||||
attendanceByStatus: Record<string, number>;
|
||||
expenseByType: ExpenseByTypeRow[];
|
||||
@@ -52,6 +67,9 @@ const DashboardPage: React.FC = () => {
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.sm;
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [classRanking, setClassRanking] = useState<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>({ top: [], bottom: [] });
|
||||
const [classroomOccupancy, setClassroomOccupancy] = useState<ClassroomOccupancy[]>([]);
|
||||
const [ganttData, setGanttData] = useState<GanttRoom[]>([]);
|
||||
const [roomRanking, setRoomRanking] = useState<Array<{ roomNumber: string; total: string }>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [period, setPeriod] = useState<[string, string]>([
|
||||
@@ -62,14 +80,21 @@ const DashboardPage: React.FC = () => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [s, r] = await Promise.all([
|
||||
const [s, r, cr, g] = await Promise.all([
|
||||
api.get('/dashboard/stats'),
|
||||
api.get('/dashboard/room-ranking', {
|
||||
params: { periodStart: period[0], periodEnd: period[1] },
|
||||
}),
|
||||
api.get('/dashboard/class-attendance-ranking'),
|
||||
api.get('/dashboard/gantt', {
|
||||
params: { periodStart: period[0], periodEnd: period[1] },
|
||||
}),
|
||||
]);
|
||||
setStats(s);
|
||||
setRoomRanking(r as unknown as Array<{ roomNumber: string; total: string }>);
|
||||
setClassRanking(cr as unknown as { top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] });
|
||||
setGanttData(g as GanttRoom[]);
|
||||
const co = await api.get('/dashboard/classroom-occupancy');
|
||||
setClassroomOccupancy(co as ClassroomOccupancy[]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
@@ -80,17 +105,15 @@ const DashboardPage: React.FC = () => {
|
||||
fetchData();
|
||||
}, [period]);
|
||||
|
||||
const expenseTypeMap: Record<string, string> = {
|
||||
water: '水费',
|
||||
electricity: '电费',
|
||||
cleaning: '保洁费',
|
||||
damage: '损坏赔偿',
|
||||
penalty: '罚款',
|
||||
key: '钥匙费',
|
||||
remote: '空调遥控器',
|
||||
deposit_deduction: '押金扣除',
|
||||
other: '其他',
|
||||
};
|
||||
const [expenseTypeMap, setExpenseTypeMap] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/expense-types').then((types: any[]) => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const t of types) map[t.code] = t.name;
|
||||
setExpenseTypeMap(map);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const attendanceLabelMap: Record<string, string> = {
|
||||
present: '出勤',
|
||||
@@ -139,6 +162,46 @@ const DashboardPage: React.FC = () => {
|
||||
],
|
||||
};
|
||||
|
||||
// 班级考勤排行 - 前5
|
||||
const classRankingTopOption = {
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: (v: number) => `${v}%` },
|
||||
grid: { left: 80, right: 30, bottom: 30, top: 10 },
|
||||
xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: classRanking.top.map((r) => r.className),
|
||||
inverse: true,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: classRanking.top.map((r) => r.rate),
|
||||
itemStyle: { color: '#34C759', borderRadius: [0, 4, 4, 0] },
|
||||
label: { show: true, position: 'right', formatter: '{c}%' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 班级考勤排行 - 后5
|
||||
const classRankingBottomOption = {
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: (v: number) => `${v}%` },
|
||||
grid: { left: 80, right: 30, bottom: 30, top: 10 },
|
||||
xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: classRanking.bottom.map((r) => r.className),
|
||||
inverse: true,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: classRanking.bottom.map((r) => r.rate),
|
||||
itemStyle: { color: '#FF3B30', borderRadius: [0, 4, 4, 0] },
|
||||
label: { show: true, position: 'right', formatter: '{c}%' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 考勤趋势折线图
|
||||
const attendanceLineOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
@@ -182,6 +245,57 @@ const DashboardPage: React.FC = () => {
|
||||
],
|
||||
};
|
||||
|
||||
// 入住时间线(甘特图)
|
||||
const ganttOption = {
|
||||
tooltip: {
|
||||
formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) =>
|
||||
`${p.data.name}<br/>入住: ${p.data.value[1]}<br/>退宿: ${p.data.value[2]}`,
|
||||
},
|
||||
grid: { left: 100, right: 30, bottom: 40, top: 20 },
|
||||
xAxis: { type: 'time' },
|
||||
yAxis: { type: 'category', data: ganttData.map((r) => r.roomNumber), inverse: true },
|
||||
dataZoom: [
|
||||
{ type: 'slider', xAxisIndex: 0, bottom: 10, height: 20 },
|
||||
{ type: 'inside', xAxisIndex: 0 },
|
||||
],
|
||||
series: [{
|
||||
type: 'custom',
|
||||
renderItem: (_params: unknown, api: {
|
||||
value: (i: number) => string | boolean;
|
||||
coord: (p: [string | number, string | number]) => [number, number];
|
||||
size: (p: [number, number]) => [number, number];
|
||||
}) => {
|
||||
const cat = api.value(0);
|
||||
const start = api.coord([api.value(1), cat]);
|
||||
const end = api.coord([api.value(2), cat]);
|
||||
const height = api.size([0, 1])[1] * 0.6;
|
||||
const rectShape = {
|
||||
x: start[0],
|
||||
y: start[1] - height / 2,
|
||||
width: Math.max(end[0] - start[0], 2),
|
||||
height,
|
||||
};
|
||||
return {
|
||||
type: 'rect' as const,
|
||||
shape: rectShape,
|
||||
style: { fill: api.value(3) ? '#34C759' : '#FF9500', stroke: '#fff', lineWidth: 1 },
|
||||
};
|
||||
},
|
||||
encode: { x: [1, 2], y: 0 },
|
||||
data: ganttData.flatMap((r) =>
|
||||
(r.occupancies || []).map((o) => ({
|
||||
name: o.studentName,
|
||||
value: [
|
||||
r.roomNumber,
|
||||
o.checkInDate,
|
||||
o.checkOutDate || new Date().toISOString().slice(0, 10),
|
||||
!o.checkOutDate,
|
||||
] as [string, string, string, boolean],
|
||||
}))
|
||||
),
|
||||
}],
|
||||
};
|
||||
|
||||
if (loading && !stats)
|
||||
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
||||
|
||||
@@ -307,6 +421,16 @@ const DashboardPage: React.FC = () => {
|
||||
<Statistic title="活跃租赁" value={stats?.activeRentals ?? 0} prefix={<FileProtectOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="今日出勤"
|
||||
value={stats?.todayPresent ?? 0}
|
||||
suffix="人"
|
||||
prefix={<CheckCircleOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
@@ -330,6 +454,56 @@ const DashboardPage: React.FC = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Card title="班级出勤率 TOP 5">
|
||||
{classRanking.top.length > 0 ? (
|
||||
<ReactECharts option={classRankingTopOption} 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="班级出勤率 末位 5">
|
||||
{classRanking.bottom.length > 0 ? (
|
||||
<ReactECharts option={classRankingBottomOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} />
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>暂无考勤数据</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24}>
|
||||
<Card title="教室占用热力图">
|
||||
{classroomOccupancy.length > 0 ? (
|
||||
<ReactECharts option={{
|
||||
tooltip: {
|
||||
formatter: (p: any) => `${p.name}<br/>排课: ${p.data.scheduleDays}天 租赁: ${p.data.rentalCount}个 占用率: ${(p.data.occupancy * 100).toFixed(0)}%`,
|
||||
},
|
||||
grid: { left: 100, right: 20, bottom: 30, top: 10 },
|
||||
xAxis: { type: 'value', max: 1 },
|
||||
yAxis: { type: 'category', data: classroomOccupancy.map((r) => r.name), inverse: true },
|
||||
visualMap: {
|
||||
min: 0, max: 1, orient: 'horizontal', left: 'center', bottom: 0,
|
||||
inRange: { color: ['#e6f4ff', '#91caff', '#40a9ff', '#0050b3', '#002c8c'] },
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: classroomOccupancy.map((r) => ({ name: r.name, value: r.occupancy, scheduleDays: r.scheduleDays, rentalCount: r.rentalCount, occupancy: r.occupancy })),
|
||||
itemStyle: { borderRadius: [0, 4, 4, 0] },
|
||||
label: { show: true, position: 'right', formatter: (p: any) => `${(p.data.occupancy * 100).toFixed(0)}%` },
|
||||
}],
|
||||
}} style={{ width: '100%', height: isMobile ? 300 : 400 }} />
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>暂无教室数据</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Card title="费用类型分布">
|
||||
@@ -367,6 +541,18 @@ const DashboardPage: React.FC = () => {
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24}>
|
||||
<Card title="入住时间线(甘特图)">
|
||||
{ganttData.length > 0 ? (
|
||||
<ReactECharts option={ganttOption} style={{ width: '100%', height: isMobile ? 300 : 450 }} />
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>暂无入住数据</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user