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

503 lines
17 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, 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;