411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
import React, { useEffect, useState, useMemo, useCallback } 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';
|
||
import { downloadBlob } from '../../utils/download';
|
||
import { message } from '../../ui/app-message';
|
||
|
||
interface ScheduleData {
|
||
year: number;
|
||
month: number;
|
||
days: number;
|
||
classrooms: any[];
|
||
organizations: 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 = useCallback(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: unknown) {
|
||
const err = e as { message?: string };
|
||
message.error(err?.message || '加载失败,请稍后重试');
|
||
}
|
||
setLoading(false);
|
||
}, [month]);
|
||
|
||
useEffect(() => {
|
||
fetchData();
|
||
}, [fetchData]);
|
||
|
||
// 按楼栋+楼层分组教室
|
||
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: unknown) {
|
||
const err = e as { message?: string };
|
||
message.error(err?.message || '加载详情失败');
|
||
}
|
||
};
|
||
|
||
const handleDownloadContract = async (id: number, filename?: string) => {
|
||
try {
|
||
await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`);
|
||
} catch {
|
||
// downloadBlob already shows an error via throw
|
||
}
|
||
};
|
||
|
||
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="%"
|
||
styles={{
|
||
value: {
|
||
color: overall.rate > 70 ? '#cf1322' : overall.rate > 40 ? '#fa8c16' : '#3f8600',
|
||
},
|
||
}}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
|
||
{/* 图例 */}
|
||
{data && (
|
||
<Card size="small" style={{ marginBottom: 16 }} title="图例">
|
||
<Space wrap>
|
||
<Tag color="#52c41a">内部排课</Tag>
|
||
{data.organizations.map((t) => (
|
||
<Tag
|
||
key={t.id}
|
||
color={t.color}
|
||
style={{ background: t.color, color: '#fff', borderColor: t.color }}
|
||
>
|
||
{t.name} (租赁)
|
||
</Tag>
|
||
))}
|
||
<Tag color="#d9d9d9" style={{ color: '#999' }}>
|
||
空闲
|
||
</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 }}
|
||
styles={{ body: { 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];
|
||
const isInternal = cell?.scheduleType === 'INTERNAL';
|
||
const isRental = cell?.scheduleType === 'RENTAL';
|
||
return (
|
||
<td
|
||
key={d}
|
||
onClick={() => {
|
||
if (isRental) showDetail(cell.rentalId);
|
||
}}
|
||
style={{
|
||
padding: 0,
|
||
border: '1px solid #f0f0f0',
|
||
background: cell?.color || '#fff',
|
||
height: 26,
|
||
cursor: isRental ? 'pointer' : 'default',
|
||
textAlign: 'center',
|
||
}}
|
||
>
|
||
{cell && (
|
||
<Tooltip
|
||
title={
|
||
isInternal
|
||
? `${cell.className} · ${cell.subject}\n${cell.teacherName} · ${cell.startTime}-${cell.endTime}`
|
||
: `${cell.organizationName}${cell.hasContract ? ' · 有合同' : ''}`
|
||
}
|
||
>
|
||
<span style={{ color: '#fff', fontSize: 10, fontWeight: 600 }}>
|
||
{isInternal ? '📖' : 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.lesseeOrganization?.color}
|
||
style={{
|
||
background: detailModal.lesseeOrganization?.color,
|
||
color: '#fff',
|
||
borderColor: detailModal.lesseeOrganization?.color,
|
||
}}
|
||
>
|
||
{detailModal.lesseeOrganization?.name}
|
||
</Tag>
|
||
</div>
|
||
<div>
|
||
<strong>联系人:</strong>
|
||
{detailModal.lesseeOrganization?.contactName || '-'}{' '}
|
||
{detailModal.lesseeOrganization?.phone || ''}
|
||
</div>
|
||
<div>
|
||
<strong>起止日期:</strong>
|
||
{detailModal.startDate} ~ {detailModal.endDate}(
|
||
{dayjs(detailModal.endDate).diff(dayjs(detailModal.startDate), 'day') + 1}天)
|
||
</div>
|
||
{detailModal.dailyRate != null && (
|
||
<div>
|
||
<strong>日租金:</strong>¥{detailModal.dailyRate}
|
||
</div>
|
||
)}
|
||
{detailModal.totalAmount != null && (
|
||
<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;
|