Files
gongxue-base/apps/admin/src/pages/ClassroomSchedule/index.tsx

411 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;