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

703 lines
22 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, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Table,
Button,
Modal,
Form,
Select,
DatePicker,
InputNumber,
Input,
Space,
Tag,
Popconfirm,
Upload,
Tooltip,
Empty,
} from 'antd';
import {
PlusOutlined,
UploadOutlined,
FileTextOutlined,
StopOutlined,
CheckOutlined,
} from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import { downloadBlob } from '../../utils/download';
import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell';
import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission';
interface UnavailableDatesResponse {
dates: string[];
}
export const unavailableDatesCacheKey = (classroomId: number, date: Dayjs) =>
`${classroomId}:${date.format('YYYY-MM')}`;
const ClassroomRentalsPage: React.FC = () => {
const { hasPermission, hasAnyPermission } = usePermission();
const [data, setData] = useState<any[]>([]);
const [classrooms, setClassrooms] = useState<any[]>([]);
const [organizations, setOrganizations] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [form] = Form.useForm();
const [filterMonth, setFilterMonth] = useState<Dayjs | null>(null);
const [filterStatus, setFilterStatus] = useState<string | undefined>();
const [searchText, setSearchText] = useState('');
const [saving, setSaving] = useState(false);
const [unavailableDates, setUnavailableDates] = useState<Set<string>>(new Set());
const loadedUnavailableMonths = useRef<Set<string>>(new Set());
const unavailableRequestVersion = useRef(0);
const [unavailableDatesLoading, setUnavailableDatesLoading] = useState(false);
const selectedClassroomId = Form.useWatch('classroomId', form);
const filteredData = useMemo(() => {
return data.filter((r: any) => {
if (filterStatus && r.effectiveStatus !== filterStatus) return false;
if (!searchText) return true;
const s = searchText.toLowerCase();
const matchClassroom = r.classroom?.name?.toLowerCase().includes(s);
const matchOrganization = r.lesseeOrganization?.name?.toLowerCase().includes(s);
return matchClassroom || matchOrganization;
});
}, [data, searchText, filterStatus]);
const fetchData = async () => {
setLoading(true);
try {
const params: any = {};
if (filterMonth) params.month = filterMonth.format('YYYY-MM');
params.includeEnded = true;
const res: any = await api.get('/classroom-rentals', { params });
setData(res);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
}
setLoading(false);
};
const fetchMeta = async () => {
try {
const [cr, tn]: any = await Promise.all([
api.get('/classrooms'),
api.get('/organizations', { params: { scope: 'all' } }),
]);
setClassrooms(cr);
setOrganizations(tn);
} catch (e: any) {
message.error(e?.message || '加载教室列表失败');
}
};
useEffect(() => {
if (hasAnyPermission('rental:create', 'rental:edit')) fetchMeta();
}, [hasAnyPermission]);
useEffect(() => {
fetchData();
}, [filterMonth]);
const resetUnavailableDates = () => {
unavailableRequestVersion.current += 1;
loadedUnavailableMonths.current.clear();
setUnavailableDates(new Set());
};
const loadUnavailableDates = useCallback(
async (classroomId: number, date: Dayjs, excludeId?: number) => {
const key = unavailableDatesCacheKey(classroomId, date);
if (loadedUnavailableMonths.current.has(key)) return;
loadedUnavailableMonths.current.add(key);
const requestVersion = unavailableRequestVersion.current;
setUnavailableDatesLoading(true);
try {
const response = await api.get<UnavailableDatesResponse>(
'/classroom-rentals/unavailable-dates',
{
params: {
classroomId,
year: date.year(),
month: date.month() + 1,
excludeId,
},
},
);
if (requestVersion !== unavailableRequestVersion.current) return;
setUnavailableDates((current) => {
const next = new Set(current);
response.dates.forEach((item) => next.add(item));
return next;
});
} catch (e: any) {
loadedUnavailableMonths.current.delete(key);
if (requestVersion === unavailableRequestVersion.current) {
message.error(e?.message || '加载教室占用日期失败');
}
} finally {
if (requestVersion === unavailableRequestVersion.current) {
setUnavailableDatesLoading(false);
}
}
},
[],
);
const handleClassroomChange = (classroomId: number) => {
form.setFieldValue('dateRange', undefined);
resetUnavailableDates();
void loadUnavailableDates(classroomId, dayjs(), editing?.id);
void loadUnavailableDates(classroomId, dayjs().add(1, 'month'), editing?.id);
};
const handleCalendarChange = (date: Dayjs) => {
const classroomId = form.getFieldValue('classroomId');
if (classroomId) void loadUnavailableDates(classroomId, date, editing?.id);
};
const isDateUnavailable = (date: Dayjs) => unavailableDates.has(date.format('YYYY-MM-DD'));
const rangeIncludesUnavailableDate = (range?: [Dayjs, Dayjs]) => {
if (!range) return false;
for (
let date = range[0].startOf('day');
!date.isAfter(range[1], 'day');
date = date.add(1, 'day')
) {
if (isDateUnavailable(date)) return true;
}
return false;
};
const handleSave = async () => {
const values = await form.validateFields();
if (rangeIncludesUnavailableDate(values.dateRange)) {
message.error('所选日期范围包含已排课或已租赁日期,请重新选择');
return;
}
setSaving(true);
const payload = {
classroomId: values.classroomId,
lessorOrganizationId: values.lessorOrganizationId,
lesseeOrganizationId: values.lesseeOrganizationId,
startDate: values.dateRange[0].format('YYYY-MM-DD'),
endDate: values.dateRange[1].format('YYYY-MM-DD'),
dailyRate: values.dailyRate,
totalAmount: values.totalAmount,
notes: values.notes,
};
try {
if (editing) {
await api.put(`/classroom-rentals/${editing.id}`, payload);
message.success('更新成功');
} else {
await api.post('/classroom-rentals', payload);
message.success('创建成功');
}
setModalOpen(false);
form.resetFields();
setEditing(null);
fetchData();
} catch (e: any) {
if (e?.conflicts?.length) {
const list = e.conflicts
.map((c: any) => `${c.organizationName}(${c.startDate}~${c.endDate})`)
.join('、');
message.error(`时间段冲突:${list}`);
} else {
message.error(e?.message || '操作失败');
}
} finally {
setSaving(false);
}
};
const saveCell = async (record: any, field: string, value: unknown) => {
await api.put(`/classroom-rentals/${record.id}`, { [field]: value });
message.success('已保存');
await fetchData();
};
const handleDelete = async (id: number) => {
try {
await api.delete(`/classroom-rentals/${id}`);
message.success('已归档');
fetchData();
} catch (e: any) {
message.error(e?.message || '归档失败');
}
};
const handleRentalAction = async (id: number, action: 'cancel' | 'end') => {
try {
await api.put(`/classroom-rentals/${id}/${action}`);
message.success(action === 'cancel' ? '租赁已取消' : '租赁已结束');
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleDownloadContract = async (id: number, filename?: string) => {
try {
await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`);
} catch {
message.error('下载失败(可能文件已丢失)');
}
};
const handleDeleteContract = async (id: number) => {
try {
await api.delete(`/classroom-rentals/${id}/contract`);
message.success('合同已移除');
fetchData();
} catch (e: any) {
message.error(e?.message || '移除失败');
}
};
const openEdit = (record: any) => {
setEditing(record);
resetUnavailableDates();
form.setFieldsValue({
classroomId: record.classroomId,
lessorOrganizationId: record.lessorOrganizationId,
lesseeOrganizationId: record.lesseeOrganizationId,
dateRange: [dayjs(record.startDate), dayjs(record.endDate)],
dailyRate: record.dailyRate ? Number(record.dailyRate) : undefined,
totalAmount: record.totalAmount ? Number(record.totalAmount) : undefined,
notes: record.notes,
});
setModalOpen(true);
void loadUnavailableDates(record.classroomId, dayjs(record.startDate), record.id);
void loadUnavailableDates(
record.classroomId,
dayjs(record.startDate).add(1, 'month'),
record.id,
);
};
const columns = useMemo(
() => [
{
title: '教室',
width: 120,
dataIndex: 'classroom',
render: (c: any, r: any) => (
<EditableCell
value={r.classroomId}
editor="select"
options={classrooms
.filter((item) => item.status !== 'archived')
.map((item) => ({
value: item.id,
label: item.building ? `${item.building} · ${item.name}` : item.name,
}))}
permission="rental:edit"
disabled={r.effectiveStatus !== 'active'}
required
onSave={(next) => saveCell(r, 'classroomId', next)}
>
{c ? (
<span>
{c.building ? `${c.building} · ` : ''}
{c.name}
</span>
) : (
'-'
)}
</EditableCell>
),
},
{
title: '承租机构',
width: 100,
dataIndex: 'lesseeOrganization',
render: (t: any, r: any) => (
<EditableCell
value={r.lesseeOrganizationId}
editor="select"
options={organizations
.filter((item) => item.status !== 'archived')
.map((item) => ({ value: item.id, label: item.name }))}
permission="rental:edit"
disabled={r.effectiveStatus !== 'active'}
required
onSave={(next) => saveCell(r, 'lesseeOrganizationId', next)}
>
{t ? (
<Tag
color={t.color}
style={{ background: t.color, color: '#fff', borderColor: t.color }}
>
{t.name}
</Tag>
) : (
'-'
)}
</EditableCell>
),
},
{
title: '开始日期',
dataIndex: 'startDate',
width: 110,
render: (v: string, r: any) => (
<EditableCell
value={v}
editor="date"
permission="rental:edit"
disabled={r.effectiveStatus !== 'active'}
required
onSave={(next) => saveCell(r, 'startDate', next)}
>
{v}
</EditableCell>
),
},
{
title: '结束日期',
dataIndex: 'endDate',
width: 110,
render: (v: string, r: any) => (
<EditableCell
value={v}
editor="date"
permission="rental:edit"
disabled={r.effectiveStatus !== 'active'}
required
onSave={(next) => saveCell(r, 'endDate', next)}
>
{v}
</EditableCell>
),
},
{
title: '时长',
width: 80,
render: (_: any, r: any) => {
const d = dayjs(r.endDate).diff(dayjs(r.startDate), 'day') + 1;
return `${d}`;
},
},
{
title: '日租金',
dataIndex: 'dailyRate',
width: 100,
render: (v: any, r: any) => (
<EditableCell
value={v}
editor="money"
min={0.01}
permission="rental:edit"
disabled={r.effectiveStatus !== 'active'}
onSave={(next) => saveCell(r, 'dailyRate', next)}
>
{v ? `¥${v}` : '-'}
</EditableCell>
),
},
{
title: '总额',
dataIndex: 'totalAmount',
width: 100,
render: (v: any, r: any) => (
<EditableCell
value={v}
editor="money"
min={0.01}
permission="rental:edit"
disabled={r.effectiveStatus !== 'active'}
onSave={(next) => saveCell(r, 'totalAmount', next)}
>
{v ? `¥${v}` : '-'}
</EditableCell>
),
},
{
title: '状态',
dataIndex: 'effectiveStatus',
width: 90,
render: (status: string) => {
const config: Record<string, { text: string; color: string }> = {
active: { text: '进行中', color: 'green' },
ended: { text: '已结束', color: 'default' },
cancelled: { text: '已取消', color: 'red' },
};
return <Tag color={config[status]?.color}>{config[status]?.text || status}</Tag>;
},
},
{
title: '合同',
width: 120,
dataIndex: 'contractPath',
render: (v: string, r: any) =>
v ? (
<Space>
<Tooltip title={r.contractOriginalName}>
<Button
size="small"
icon={<FileTextOutlined />}
onClick={() => handleDownloadContract(r.id, r.contractOriginalName)}
>
</Button>
</Tooltip>
{hasPermission('rental:edit') ? (
<Popconfirm title="移除合同文件?" onConfirm={() => handleDeleteContract(r.id)}>
<Button size="small" danger icon={<StopOutlined />} aria-label="移除合同文件" />
</Popconfirm>
) : null}
</Space>
) : hasPermission('rental:edit') ? (
<Upload
accept="application/pdf"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
if (file.size > 10 * 1024 * 1024) {
message.error('文件不能超过 10MB');
onError?.(new Error('size'));
return;
}
const formData = new FormData();
formData.append('file', file);
try {
await api.post(`/classroom-rentals/${r.id}/contract`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
message.success('合同已上传');
onSuccess?.({});
fetchData();
} catch (e: any) {
message.error(e?.message || '上传失败');
onError?.(e);
}
}}
>
<Button size="small" icon={<UploadOutlined />}>
PDF
</Button>
</Upload>
) : (
'-'
),
},
{
title: '操作',
width: 150,
render: (_: any, record: any) => (
<Space>
{record.effectiveStatus === 'active' && (
<>
<PermissionButton
permission="rental:edit"
size="small"
onClick={() => openEdit(record)}
>
</PermissionButton>
<Popconfirm
title="确定取消该租赁?"
onConfirm={() => handleRentalAction(record.id, 'cancel')}
>
<PermissionButton
permission="rental:edit"
size="small"
danger
icon={<StopOutlined />}
>
</PermissionButton>
</Popconfirm>
{!dayjs(record.startDate).isAfter(dayjs(), 'day') && (
<Popconfirm
title="确定今天结束该租赁?"
onConfirm={() => handleRentalAction(record.id, 'end')}
>
<PermissionButton
permission="rental:edit"
size="small"
icon={<CheckOutlined />}
>
</PermissionButton>
</Popconfirm>
)}
</>
)}
{record.effectiveStatus !== 'active' && (
<Popconfirm
title="确定归档该租赁订单?合同文件会保留。"
onConfirm={() => handleDelete(record.id)}
>
<PermissionButton permission="rental:delete" size="small" danger>
</PermissionButton>
</Popconfirm>
)}
</Space>
),
},
],
[classrooms, organizations, hasPermission],
);
return (
<div>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search
placeholder="搜索教室/承租机构"
allowClear
style={{ width: 180 }}
onSearch={(v) => setSearchText(v)}
onChange={(e) => {
if (!e.target.value) setSearchText('');
}}
/>
<DatePicker
picker="month"
placeholder="按月筛选"
value={filterMonth}
onChange={setFilterMonth}
allowClear
format="YYYY-MM"
/>
<Select
placeholder="状态"
allowClear
style={{ width: 110 }}
value={filterStatus}
onChange={setFilterStatus}
options={[
{ value: 'active', label: '进行中' },
{ value: 'ended', label: '已结束' },
{ value: 'cancelled', label: '已取消' },
]}
/>
</Space>
<PermissionButton
permission="rental:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditing(null);
form.resetFields();
resetUnavailableDates();
setModalOpen(true);
}}
>
</PermissionButton>
</div>
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
scroll={{ x: 1200 }}
/>
<Modal
title={editing ? '编辑租赁' : '新增租赁'}
open={modalOpen}
onOk={handleSave}
onCancel={() => {
setModalOpen(false);
setEditing(null);
resetUnavailableDates();
}}
confirmLoading={saving}
okText="保存"
width={600}
>
<Form form={form} layout="vertical">
<Form.Item name="classroomId" label="教室" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="选择教室"
onChange={handleClassroomChange}
options={classrooms
.filter((c) => c.status === 'available')
.map((c) => ({
value: c.id,
label: `${c.building ? c.building + ' · ' : ''}${c.name}${c.roomType}`,
}))}
/>
</Form.Item>
<Form.Item name="lessorOrganizationId" label="出租机构" tooltip="默认由本机构出租">
<Select
showSearch
optionFilterProp="label"
placeholder="默认本机构"
allowClear
options={organizations
.filter((organization) => organization.isHost)
.map((organization) => ({
value: organization.id,
label: `${organization.name}(本机构)`,
}))}
/>
</Form.Item>
<Form.Item
name="lesseeOrganizationId"
label="承租机构"
rules={[{ required: true, message: '请选择承租机构' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder="选择外部承租机构"
options={organizations
.filter((organization) => !organization.isHost && organization.status === 'active')
.map((organization) => ({
value: organization.id,
label: organization.name,
}))}
/>
</Form.Item>
<Form.Item name="dateRange" label="租赁起止日期" rules={[{ required: true }]}>
<DatePicker.RangePicker
style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']}
format="YYYY-MM-DD"
disabled={!selectedClassroomId}
disabledDate={(date) => unavailableDatesLoading || isDateUnavailable(date)}
onPanelChange={(dates) => dates.forEach((date) => date && handleCalendarChange(date))}
/>
</Form.Item>
<Form.Item name="dailyRate" label="日租金(可选)">
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item name="totalAmount" label="合同总额(可选)">
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default ClassroomRentalsPage;