feat(task1): restructure directories for turborepo monorepo
- Move backend/ to apps/server/ via git mv - Move frontend/ to apps/admin/ via git mv - Create packages/typescript-config/ with base, nestjs, and react-vite presets
This commit is contained in:
228
apps/admin/src/pages/ClassroomSchedule/index.tsx
Normal file
228
apps/admin/src/pages/ClassroomSchedule/index.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { DatePicker, Card, Row, Col, Statistic, Tag, Space, Button, Modal, Spin, Empty, Tooltip } from 'antd';
|
||||
import { CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
|
||||
interface ScheduleData {
|
||||
year: number;
|
||||
month: number;
|
||||
days: number;
|
||||
classrooms: any[];
|
||||
tenants: any[];
|
||||
matrix: Record<number, Record<number, any>>;
|
||||
summary: Record<number, { totalDays: number; rentedDays: number; idleDays: number; occupancyRate: number }>;
|
||||
}
|
||||
|
||||
const ClassroomSchedulePage: React.FC = () => {
|
||||
const [month, setMonth] = useState<Dayjs>(dayjs());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<ScheduleData | null>(null);
|
||||
const [detailModal, setDetailModal] = useState<any>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: any = await api.get('/classroom-rentals/schedule', {
|
||||
params: { year: month.year(), month: month.month() + 1 },
|
||||
});
|
||||
setData(res);
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { fetchData(); }, [month]);
|
||||
|
||||
// 按楼栋+楼层分组教室
|
||||
const groups = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const map = new Map<string, any[]>();
|
||||
for (const c of data.classrooms) {
|
||||
const key = `${c.building || '其他'}${c.floor ? ` · ${c.floor}层` : ''}`;
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(c);
|
||||
}
|
||||
return Array.from(map.entries()).map(([name, classrooms]) => ({ name, classrooms }));
|
||||
}, [data]);
|
||||
|
||||
// 整体统计
|
||||
const overall = useMemo(() => {
|
||||
if (!data) return { total: 0, rented: 0, rate: 0 };
|
||||
let rented = 0;
|
||||
const total = data.classrooms.length * data.days;
|
||||
for (const cid of Object.keys(data.summary)) {
|
||||
rented += data.summary[+cid].rentedDays;
|
||||
}
|
||||
return {
|
||||
total,
|
||||
rented,
|
||||
rate: total > 0 ? Math.round((rented / total) * 100) : 0,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const showDetail = async (rentalId: number) => {
|
||||
try {
|
||||
const res: any = await api.get(`/classroom-rentals/${rentalId}`);
|
||||
setDetailModal(res);
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleDownloadContract = (id: number, filename?: string) => {
|
||||
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
|
||||
const token = localStorage.getItem('token');
|
||||
fetch(`${baseURL}/classroom-rentals/${id}/contract`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(res => res.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename || `contract-${id}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||||
<Space>
|
||||
<CalendarOutlined style={{ fontSize: 20 }} />
|
||||
<h3 style={{ margin: 0 }}>教室排期总览</h3>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button onClick={() => setMonth(month.subtract(1, 'month'))}>上月</Button>
|
||||
<DatePicker picker="month" value={month} onChange={(v) => v && setMonth(v)} allowClear={false} placeholder="选择月份" format="YYYY年M月" />
|
||||
<Button onClick={() => setMonth(month.add(1, 'month'))}>下月</Button>
|
||||
<Button type="primary" onClick={() => setMonth(dayjs())}>回到本月</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} sm={6}><Card size="small"><Statistic title="教室总数" value={data?.classrooms.length || 0} /></Card></Col>
|
||||
<Col xs={12} sm={6}><Card size="small"><Statistic title="本月天数" value={data?.days || 0} /></Card></Col>
|
||||
<Col xs={12} sm={6}><Card size="small"><Statistic title="总占用天数" value={overall.rented} suffix={`/${overall.total}`} /></Card></Col>
|
||||
<Col xs={12} sm={6}><Card size="small"><Statistic title="整体占用率" value={overall.rate} suffix="%" valueStyle={{ color: overall.rate > 70 ? '#cf1322' : overall.rate > 40 ? '#fa8c16' : '#3f8600' }} /></Card></Col>
|
||||
</Row>
|
||||
|
||||
{/* 租赁方图例 */}
|
||||
{data && data.tenants.length > 0 && (
|
||||
<Card size="small" style={{ marginBottom: 16 }} title="租赁方图例">
|
||||
<Space wrap>
|
||||
{data.tenants.map(t => (
|
||||
<Tag key={t.id} color={t.color} style={{ background: t.color, color: '#fff', borderColor: t.color }}>{t.name}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Spin spinning={loading}>
|
||||
{!data || data.classrooms.length === 0 ? (
|
||||
<Empty description="暂无教室数据" />
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
{groups.map(group => (
|
||||
<Card
|
||||
key={group.name}
|
||||
size="small"
|
||||
title={group.name}
|
||||
style={{ marginBottom: 12 }}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#fafafa' }}>
|
||||
<th style={{ position: 'sticky', left: 0, background: '#fafafa', zIndex: 2, padding: '8px', border: '1px solid #f0f0f0', minWidth: 120, textAlign: 'left' }}>教室</th>
|
||||
<th style={{ padding: '8px 6px', border: '1px solid #f0f0f0', minWidth: 60 }}>类型</th>
|
||||
<th style={{ padding: '8px 6px', border: '1px solid #f0f0f0', minWidth: 70 }}>占用率</th>
|
||||
{Array.from({ length: data.days }, (_, i) => i + 1).map(d => (
|
||||
<th key={d} style={{ padding: '8px 4px', border: '1px solid #f0f0f0', minWidth: 26, textAlign: 'center' }}>{d}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.classrooms.map(c => {
|
||||
const sum = data.summary[c.id] || { rentedDays: 0, totalDays: data.days, occupancyRate: 0 };
|
||||
return (
|
||||
<tr key={c.id}>
|
||||
<td style={{ position: 'sticky', left: 0, background: '#fff', zIndex: 1, padding: '6px 8px', border: '1px solid #f0f0f0', fontWeight: 500 }}>{c.name}</td>
|
||||
<td style={{ padding: '6px', border: '1px solid #f0f0f0', textAlign: 'center' }}>{c.roomType}</td>
|
||||
<td style={{ padding: '6px', border: '1px solid #f0f0f0', textAlign: 'center', color: sum.occupancyRate > 0.7 ? '#cf1322' : sum.occupancyRate > 0.4 ? '#fa8c16' : '#3f8600' }}>
|
||||
{Math.round(sum.occupancyRate * 100)}%
|
||||
</td>
|
||||
{Array.from({ length: data.days }, (_, i) => i + 1).map(d => {
|
||||
const cell = data.matrix[c.id]?.[d];
|
||||
return (
|
||||
<td
|
||||
key={d}
|
||||
onClick={() => cell && showDetail(cell.rentalId)}
|
||||
style={{
|
||||
padding: 0,
|
||||
border: '1px solid #f0f0f0',
|
||||
background: cell?.color || '#fff',
|
||||
height: 26,
|
||||
cursor: cell ? 'pointer' : 'default',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{cell && (
|
||||
<Tooltip title={`${cell.tenantName}${cell.hasContract ? ' · 有合同' : ''}`}>
|
||||
<span style={{ color: '#fff', fontSize: 10, fontWeight: 600 }}>
|
||||
{cell.hasContract ? '📄' : ''}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
<Modal
|
||||
title="租赁详情"
|
||||
open={!!detailModal}
|
||||
onCancel={() => setDetailModal(null)}
|
||||
footer={null}
|
||||
width={500}
|
||||
>
|
||||
{detailModal && (
|
||||
<div style={{ lineHeight: 2 }}>
|
||||
<div><strong>教室:</strong>{detailModal.classroom?.building} · {detailModal.classroom?.name}({detailModal.classroom?.roomType})</div>
|
||||
<div><strong>租赁方:</strong>
|
||||
<Tag color={detailModal.tenant?.color} style={{ background: detailModal.tenant?.color, color: '#fff', borderColor: detailModal.tenant?.color }}>
|
||||
{detailModal.tenant?.name}
|
||||
</Tag>
|
||||
</div>
|
||||
<div><strong>联系人:</strong>{detailModal.tenant?.contact || '-'} {detailModal.tenant?.phone || ''}</div>
|
||||
<div><strong>起止日期:</strong>{detailModal.startDate} ~ {detailModal.endDate}({dayjs(detailModal.endDate).diff(dayjs(detailModal.startDate), 'day') + 1}天)</div>
|
||||
{detailModal.dailyRate && <div><strong>日租金:</strong>¥{detailModal.dailyRate}</div>}
|
||||
{detailModal.totalAmount && <div><strong>合同总额:</strong>¥{detailModal.totalAmount}</div>}
|
||||
{detailModal.notes && <div><strong>备注:</strong>{detailModal.notes}</div>}
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<strong>合同文件:</strong>
|
||||
{detailModal.contractPath ? (
|
||||
<Button
|
||||
type="link"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => handleDownloadContract(detailModal.id, detailModal.contractOriginalName)}
|
||||
>
|
||||
{detailModal.contractOriginalName || '下载'}
|
||||
</Button>
|
||||
) : '未上传'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClassroomSchedulePage;
|
||||
Reference in New Issue
Block a user