503 lines
17 KiB
TypeScript
503 lines
17 KiB
TypeScript
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||
import { useImmer } from 'use-immer';
|
||
import {
|
||
App,
|
||
Modal,
|
||
Form,
|
||
Select,
|
||
DatePicker,
|
||
InputNumber,
|
||
Input,
|
||
Space,
|
||
} from 'antd';
|
||
import { PlusOutlined } from '@ant-design/icons';
|
||
import dayjs, { Dayjs } from 'dayjs';
|
||
import api from '../../api';
|
||
import { downloadBlob } from '../../utils/download';
|
||
import PermissionButton from '../../components/PermissionButton';
|
||
import { message } from '../../ui/app-message';
|
||
import { usePermission } from '../../hooks/usePermission';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||
import { getErrorMessage } from '../../utils/error';
|
||
import { validateResponse } from '../../utils/validate';
|
||
import { classroomsSchema, organizationsSchema, rentalsSchema } from '../../api/schemas';
|
||
import { RentalTable } from './RentalTable';
|
||
|
||
interface UnavailableDatesResponse {
|
||
dates: string[];
|
||
}
|
||
|
||
export const unavailableDatesCacheKey = (classroomId: number, date: Dayjs) =>
|
||
`${classroomId}:${date.format('YYYY-MM')}`;
|
||
|
||
const ClassroomRentalsPage: React.FC = () => {
|
||
const { modal } = App.useApp();
|
||
const { hasPermission, hasAnyPermission } = usePermission();
|
||
const canPurgeRental = hasPermission('rental:purge');
|
||
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] = useImmer<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 {
|
||
data = [],
|
||
isLoading,
|
||
isFetching,
|
||
} = useQuery<any[]>({
|
||
queryKey: ['classroom-rentals', filterMonth],
|
||
queryFn: async () => {
|
||
try {
|
||
const params: any = {};
|
||
if (filterMonth) params.month = filterMonth.format('YYYY-MM');
|
||
params.includeEnded = true;
|
||
return validateResponse<any[]>(
|
||
rentalsSchema,
|
||
await api.get('/classroom-rentals', { params }),
|
||
);
|
||
} catch (e: any) {
|
||
message.error(e?.message || '加载失败,请稍后重试');
|
||
return [];
|
||
}
|
||
},
|
||
});
|
||
const {
|
||
data: meta = { classrooms: [], organizations: [] },
|
||
} = useQuery<{ classrooms: any[]; organizations: any[] }>({
|
||
queryKey: ['classroom-rentals', 'meta'],
|
||
enabled: hasAnyPermission('rental:create', 'rental:edit'),
|
||
queryFn: async () => {
|
||
try {
|
||
const [cr, tn]: any = await Promise.all([
|
||
api.get('/classrooms'),
|
||
api.get('/organizations', { params: { scope: 'all' } }),
|
||
]);
|
||
return {
|
||
classrooms: validateResponse<any[]>(classroomsSchema, cr),
|
||
organizations: validateResponse<any[]>(organizationsSchema, tn),
|
||
};
|
||
} catch (e: any) {
|
||
message.error(e?.message || '加载教室列表失败');
|
||
return { classrooms: [], organizations: [] };
|
||
}
|
||
},
|
||
});
|
||
const classrooms = meta.classrooms;
|
||
const organizations = meta.organizations;
|
||
const loading = isLoading || isFetching;
|
||
|
||
const saveMutation = useApiMutation(
|
||
async (payload: Record<string, unknown>) =>
|
||
editing
|
||
? api.put(`/classroom-rentals/${editing.id}`, payload)
|
||
: api.post('/classroom-rentals', payload),
|
||
{
|
||
invalidate: [['classroom-rentals']],
|
||
onError: (error: unknown) => {
|
||
const e = error as {
|
||
conflicts?: Array<{ organizationName?: string; startDate?: string; endDate?: string }>;
|
||
};
|
||
if (e?.conflicts?.length) {
|
||
const list = e.conflicts
|
||
.map((c) => `${c.organizationName}(${c.startDate}~${c.endDate})`)
|
||
.join('、');
|
||
message.error(`时间段冲突:${list}`);
|
||
} else {
|
||
message.error(getErrorMessage(error, '操作失败'));
|
||
}
|
||
},
|
||
},
|
||
);
|
||
const saveCellMutation = useApiMutation(
|
||
async ({ record, field, value }: { record: any; field: string; value: unknown }) =>
|
||
api.put(`/classroom-rentals/${record.id}`, { [field]: value }),
|
||
{ invalidate: [['classroom-rentals']] },
|
||
);
|
||
const deleteMutation = useApiMutation(
|
||
async (id: number) => api.delete(`/classroom-rentals/${id}`),
|
||
{ invalidate: [['classroom-rentals']] },
|
||
);
|
||
const purgeMutation = useApiMutation(
|
||
async (id: number) => api.delete(`/classroom-rentals/${id}/permanent`),
|
||
{ invalidate: [['classroom-rentals']] },
|
||
);
|
||
const actionMutation = useApiMutation(
|
||
async ({ id, action }: { id: number; action: 'cancel' | 'end' }) =>
|
||
api.put(`/classroom-rentals/${id}/${action}`),
|
||
{ invalidate: [['classroom-rentals']] },
|
||
);
|
||
const deleteContractMutation = useApiMutation(
|
||
async (id: number) => api.delete(`/classroom-rentals/${id}/contract`),
|
||
{ invalidate: [['classroom-rentals']] },
|
||
);
|
||
const uploadContractMutation = useApiMutation(
|
||
async ({ id, formData }: { id: number; formData: FormData }) =>
|
||
api.post(`/classroom-rentals/${id}/contract`, formData),
|
||
{ invalidate: [['classroom-rentals']] },
|
||
);
|
||
|
||
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 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((draft) => {
|
||
response.dates.forEach((item) => draft.add(item));
|
||
});
|
||
} catch (e: any) {
|
||
loadedUnavailableMonths.current.delete(key);
|
||
if (requestVersion === unavailableRequestVersion.current) {
|
||
message.error(e?.message || '加载教室占用日期失败');
|
||
}
|
||
} finally {
|
||
if (requestVersion === unavailableRequestVersion.current) {
|
||
setUnavailableDatesLoading(false);
|
||
}
|
||
}
|
||
},
|
||
[setUnavailableDates],
|
||
);
|
||
|
||
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 {
|
||
await saveMutation.mutateAsync(payload);
|
||
message.success(editing ? '更新成功' : '创建成功');
|
||
setModalOpen(false);
|
||
form.resetFields();
|
||
setEditing(null);
|
||
} catch {
|
||
// 错误提示由 useApiMutation 统一处理
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const saveCell = async (record: any, field: string, value: unknown) => {
|
||
try {
|
||
await saveCellMutation.mutateAsync({ record, field, value });
|
||
message.success('已保存');
|
||
} catch {
|
||
// 错误提示由 useApiMutation 统一处理
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id: number) => {
|
||
try {
|
||
await deleteMutation.mutateAsync(id);
|
||
message.success('已归档');
|
||
} catch {
|
||
// 错误提示由 useApiMutation 统一处理
|
||
}
|
||
};
|
||
|
||
const handlePurge = (id: number, name: string) => {
|
||
modal.confirm({
|
||
title: `永久删除租赁订单(${name})?`,
|
||
content: '删除后不可恢复,排课与合同文件将被清除(存在考勤记录时将无法删除)。确定继续?',
|
||
okText: '永久删除',
|
||
okButtonProps: { danger: true },
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
try {
|
||
await purgeMutation.mutateAsync(id);
|
||
message.success('已永久删除(不可恢复)');
|
||
} catch {
|
||
// 错误提示由 useApiMutation 统一处理
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
const handleRentalAction = async (id: number, action: 'cancel' | 'end') => {
|
||
try {
|
||
await actionMutation.mutateAsync({ id, action });
|
||
message.success(action === 'cancel' ? '租赁已取消' : '租赁已结束');
|
||
} catch {
|
||
// 错误提示由 useApiMutation 统一处理
|
||
}
|
||
};
|
||
|
||
const handleDownloadContract = async (id: number, filename?: string) => {
|
||
try {
|
||
await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`);
|
||
} catch (e) {
|
||
console.error('下载合同失败', e);
|
||
message.error('下载失败(可能文件已丢失)');
|
||
}
|
||
};
|
||
|
||
const handleDeleteContract = async (id: number) => {
|
||
try {
|
||
await deleteContractMutation.mutateAsync(id);
|
||
message.success('合同已移除');
|
||
} catch {
|
||
// 错误提示由 useApiMutation 统一处理
|
||
}
|
||
};
|
||
|
||
const handleUploadContract = async (id: number, formData: FormData) => {
|
||
return uploadContractMutation.mutateAsync({ id, formData });
|
||
};
|
||
|
||
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,
|
||
);
|
||
};
|
||
|
||
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>
|
||
<RentalTable
|
||
data={filteredData}
|
||
loading={loading}
|
||
classrooms={classrooms}
|
||
organizations={organizations}
|
||
canPurgeRental={canPurgeRental}
|
||
hasPermission={hasPermission}
|
||
onSaveCell={saveCell}
|
||
onEdit={openEdit}
|
||
onAction={handleRentalAction}
|
||
onArchive={handleDelete}
|
||
onPurge={handlePurge}
|
||
onDownloadContract={handleDownloadContract}
|
||
onDeleteContract={handleDeleteContract}
|
||
onUploadContract={handleUploadContract}
|
||
/>
|
||
<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;
|