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([]); const [classrooms, setClassrooms] = useState([]); const [organizations, setOrganizations] = useState([]); const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [form] = Form.useForm(); const [filterMonth, setFilterMonth] = useState(null); const [filterStatus, setFilterStatus] = useState(); const [searchText, setSearchText] = useState(''); const [saving, setSaving] = useState(false); const [unavailableDates, setUnavailableDates] = useState>(new Set()); const loadedUnavailableMonths = useRef>(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( '/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) => ( 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 ? ( {c.building ? `${c.building} · ` : ''} {c.name} ) : ( '-' )} ), }, { title: '承租机构', width: 100, dataIndex: 'lesseeOrganization', render: (t: any, r: any) => ( 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 ? ( {t.name} ) : ( '-' )} ), }, { title: '开始日期', dataIndex: 'startDate', width: 110, render: (v: string, r: any) => ( saveCell(r, 'startDate', next)} > {v} ), }, { title: '结束日期', dataIndex: 'endDate', width: 110, render: (v: string, r: any) => ( saveCell(r, 'endDate', next)} > {v} ), }, { 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) => ( saveCell(r, 'dailyRate', next)} > {v ? `¥${v}` : '-'} ), }, { title: '总额', dataIndex: 'totalAmount', width: 100, render: (v: any, r: any) => ( saveCell(r, 'totalAmount', next)} > {v ? `¥${v}` : '-'} ), }, { title: '状态', dataIndex: 'effectiveStatus', width: 90, render: (status: string) => { const config: Record = { active: { text: '进行中', color: 'green' }, ended: { text: '已结束', color: 'default' }, cancelled: { text: '已取消', color: 'red' }, }; return {config[status]?.text || status}; }, }, { title: '合同', width: 120, dataIndex: 'contractPath', render: (v: string, r: any) => v ? ( {hasPermission('rental:edit') ? ( handleDeleteContract(r.id)}> ) : ( '-' ), }, { title: '操作', width: 150, render: (_: any, record: any) => ( {record.effectiveStatus === 'active' && ( <> openEdit(record)} > 编辑 handleRentalAction(record.id, 'cancel')} > } > 取消 {!dayjs(record.startDate).isAfter(dayjs(), 'day') && ( handleRentalAction(record.id, 'end')} > } > 结束 )} )} {record.effectiveStatus !== 'active' && ( handleDelete(record.id)} > 归档 )} ), }, ], [classrooms, organizations, hasPermission], ); return (
setSearchText(v)} onChange={(e) => { if (!e.target.value) setSearchText(''); }} /> c.status === 'available') .map((c) => ({ value: c.id, label: `${c.building ? c.building + ' · ' : ''}${c.name}(${c.roomType})`, }))} /> !organization.isHost && organization.status === 'active') .map((organization) => ({ value: organization.id, label: organization.name, }))} /> unavailableDatesLoading || isDateUnavailable(date)} onPanelChange={(dates) => dates.forEach((date) => date && handleCalendarChange(date))} />
); }; export default ClassroomRentalsPage;