import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { Table, Button, Modal, Form, Select, DatePicker, Input, InputNumber, Space, Tag, Popconfirm, Upload, Switch, Tooltip, Empty, Alert, } from 'antd'; import { PlusOutlined, SwapOutlined, LogoutOutlined, InboxOutlined, UploadOutlined, DownloadOutlined, ExportOutlined, } from '@ant-design/icons'; import dayjs, { type Dayjs } from 'dayjs'; import api from '../../api'; import { downloadBlob } from '../../utils/download'; import { maskPhone, maskIdNumber } from '../../utils/sensitive'; import PermissionButton from '../../components/PermissionButton'; import { message } from '../../ui/app-message'; import { buildCheckInPayload, buildTransferPayload } from './occupancy-form'; const { RangePicker } = DatePicker; const OccupanciesPage: React.FC = () => { const [data, setData] = useState([]); const [students, setStudents] = useState([]); const [rooms, setRooms] = useState([]); const [loading, setLoading] = useState(false); const [checkInModal, setCheckInModal] = useState(false); const [checkOutModal, setCheckOutModal] = useState(null); const [transferModal, setTransferModal] = useState(null); const [showActive, setShowActive] = useState(true); const [autoDeposit, setAutoDeposit] = useState(true); const [depositAmount, setDepositAmount] = useState(500); const [searchText, setSearchText] = useState(''); const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null); const [batchCheckOutModal, setBatchCheckOutModal] = useState(false); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [saving, setSaving] = useState(false); const [batchLoading, setBatchLoading] = useState(false); const [checkInForm] = Form.useForm(); const [checkOutForm] = Form.useForm(); const [transferForm] = Form.useForm(); const [batchCheckOutForm] = Form.useForm(); const [availableBeds, setAvailableBeds] = useState([]); const [availableLockers, setAvailableLockers] = useState([]); const [availableResourcesLoading, setAvailableResourcesLoading] = useState(false); const [transferAvailableBeds, setTransferAvailableBeds] = useState([]); const [transferAvailableLockers, setTransferAvailableLockers] = useState([]); const [transferResourcesLoading, setTransferResourcesLoading] = useState(false); const selectedCheckInRoomId = Form.useWatch('roomId', checkInForm); const selectedTransferRoomId = Form.useWatch('newRoomId', transferForm); const activeOccupancyByStudentId = useMemo(() => { const map = new Map(); data.forEach((item) => { if (!item.checkOutDate && item.status !== 'archived') map.set(item.studentId, item); }); return map; }, [data]); const isRoomSelectable = useCallback((room: any) => { const currentCount = Number(room.currentCount || 0); const capacity = Number(room.capacity || 0); return room.status !== 'archived' && room.status !== 'maintenance' && currentCount < capacity; }, []); const roomOptionLabel = useCallback((room: any) => { const base = `${room.roomNumber} (${room.building || ''}) [${room.currentCount}/${room.capacity}]`; if (room.status === 'maintenance') return `${base} · 维修中`; if (room.status === 'archived') return `${base} · 已归档`; if (Number(room.currentCount || 0) >= Number(room.capacity || 0)) return `${base} · 已满`; return base; }, []); const selectedBatchRecords = useMemo( () => data.filter((item) => selectedRowKeys.includes(item.id) && !item.checkOutDate), [data, selectedRowKeys], ); const latestSelectedCheckInDate = useMemo( () => selectedBatchRecords.map((item) => item.checkInDate).filter(Boolean).sort().at(-1), [selectedBatchRecords], ); const latestSelectedBillingStartDate = useMemo( () => selectedBatchRecords .map((item) => item.billingStartDate || item.checkInDate) .filter(Boolean) .sort() .at(-1), [selectedBatchRecords], ); const dateNotBefore = (start: string | Dayjs | null | undefined, messageText: string) => (_: unknown, value?: Dayjs | null) => { if (!value || !start) return Promise.resolve(); const startDate = dayjs.isDayjs(start) ? start : dayjs(start); return value.isBefore(startDate, 'day') ? Promise.reject(new Error(messageText)) : Promise.resolve(); }; const fetchData = useCallback(async () => { setLoading(true); try { const [occRes, stuRes, rmRes] = (await Promise.allSettled([ api.get('/occupancies', { params: { active: showActive ? 'true' : undefined, dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), dateTo: dateRange?.[1]?.format('YYYY-MM-DD'), }, }), api.get('/students/basic-lookups'), api.get('/rooms/overview'), ])) as PromiseSettledResult[]; const labels = ['入住数据', '学生列表', '房间列表']; [occRes, stuRes, rmRes].forEach((res, i) => { if (res.status === 'rejected') { message.warning(`${labels[i]}加载失败`); } }); setData(occRes.status === 'fulfilled' ? occRes.value : []); setStudents(stuRes.status === 'fulfilled' ? stuRes.value : []); setRooms(rmRes.status === 'fulfilled' ? rmRes.value : []); } catch (e) { console.error(e); message.error('数据加载异常'); } setLoading(false); }, [showActive, dateRange]); useEffect(() => { fetchData(); setSelectedRowKeys([]); }, [fetchData]); const handleRoomChange = async (roomId: number) => { checkInForm.setFieldValue('bedId', undefined); checkInForm.setFieldValue('lockerId', undefined); setAvailableBeds([]); setAvailableLockers([]); if (!roomId) return; setAvailableResourcesLoading(true); try { const [beds, lockers] = await Promise.all([ api.get(`/rooms/${roomId}/beds/available`), api.get(`/rooms/${roomId}/lockers/available`), ]); setAvailableBeds(beds); setAvailableLockers(lockers); if (beds.length === 1) checkInForm.setFieldValue('bedId', beds[0].id); if (beds.length === 0) message.warning('该宿舍暂无可用床位,请先在宿舍详情添加或释放床位'); } catch (e: any) { console.error(e); setAvailableBeds([]); setAvailableLockers([]); message.error(e?.message || '宿舍床位和柜子加载失败'); } finally { setAvailableResourcesLoading(false); } }; const handleTransferRoomChange = async (roomId: number) => { transferForm.setFieldValue('newBedId', undefined); transferForm.setFieldValue('newLockerId', undefined); setTransferAvailableBeds([]); setTransferAvailableLockers([]); if (!roomId) return; setTransferResourcesLoading(true); try { const [beds, lockers] = await Promise.all([ api.get(`/rooms/${roomId}/beds/available`), api.get(`/rooms/${roomId}/lockers/available`), ]); setTransferAvailableBeds(beds); setTransferAvailableLockers(lockers); if (beds.length === 1) transferForm.setFieldValue('newBedId', beds[0].id); if (beds.length === 0) message.warning('目标宿舍暂无可用床位,请先在宿舍详情添加或释放床位'); } catch (e: any) { console.error(e); setTransferAvailableBeds([]); setTransferAvailableLockers([]); message.error(e?.message || '目标宿舍床位和柜子加载失败'); } finally { setTransferResourcesLoading(false); } }; const filteredData = useMemo(() => { if (!searchText) return data; const keyword = searchText.toLowerCase(); return data.filter( (r: any) => r.student?.name?.toLowerCase().includes(keyword) || r.room?.roomNumber?.toLowerCase().includes(keyword), ); }, [data, searchText]); const handleCheckIn = async () => { const values = await checkInForm.validateFields(); setSaving(true); try { await api.post('/occupancies/check-in', buildCheckInPayload(values)); message.success('入住登记成功'); setCheckInModal(false); checkInForm.resetFields(); fetchData(); } catch (e: any) { message.error(e?.message || '操作失败'); } finally { setSaving(false); } }; const handleCheckOut = async () => { const values = await checkOutForm.validateFields(); setSaving(true); try { await api.put(`/occupancies/${checkOutModal.id}/check-out`, { checkOutDate: values.checkOutDate.format('YYYY-MM-DD'), billingEndDate: values.billingEndDate?.format('YYYY-MM-DD'), checkOutReason: values.checkOutReason, }); message.success('退宿成功'); setCheckOutModal(null); checkOutForm.resetFields(); fetchData(); } catch (e: any) { message.error(e?.message || '操作失败'); } finally { setSaving(false); } }; const handleTransfer = async () => { const values = await transferForm.validateFields(); setSaving(true); try { await api.put(`/occupancies/${transferModal.id}/transfer`, buildTransferPayload(values)); message.success('换房成功'); setTransferModal(null); transferForm.resetFields(); fetchData(); } catch (e: any) { message.error(e?.message || '操作失败'); } finally { setSaving(false); } }; const handleBatchCheckOut = async () => { const values = await batchCheckOutForm.validateFields(); setBatchLoading(true); try { const res: any = await api.post('/occupancies/batch-check-out', { ids: selectedRowKeys, checkOutDate: values.checkOutDate.format('YYYY-MM-DD'), billingEndDate: values.billingEndDate?.format('YYYY-MM-DD'), checkOutReason: values.checkOutReason, }); message.success(res.message || `已成功退宿 ${res.success} 人`); setBatchCheckOutModal(false); batchCheckOutForm.resetFields(); setSelectedRowKeys([]); fetchData(); } catch (e: any) { message.error(e?.message || '批量退宿失败'); } finally { setBatchLoading(false); } }; const handleBatchDelete = async () => { setBatchLoading(true); try { const res: any = await api.post('/occupancies/batch-delete', { ids: selectedRowKeys }); message.success(res?.message || `已归档 ${selectedRowKeys.length} 条`); setSelectedRowKeys([]); fetchData(); } catch (e: any) { message.error(e?.message || '批量归档失败'); } finally { setBatchLoading(false); } }; const columns = useMemo( () => [ { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, { title: '宿舍', width: 120, render: (_: any, r: any) => r.room?.roomNumber || '-' }, { title: '床位', width: 80, render: (_: unknown, r: Record) => (r.bed as Record | undefined)?.bedNumber || '-', }, { title: '柜子', width: 80, render: (_: unknown, r: Record) => (r.locker as Record | undefined)?.lockerNumber || '-', }, { title: '入住日期', dataIndex: 'checkInDate', width: 110 }, { title: '计费起始', dataIndex: 'billingStartDate', width: 110 }, { title: '退宿日期', dataIndex: 'checkOutDate', width: 110, render: (v: any) => v || 在住, }, { title: '计费截止', dataIndex: 'billingEndDate', render: (v: any) => v || '-' }, { title: '退宿原因', dataIndex: 'checkOutReason', render: (v: any) => v || '-' }, { title: '操作', width: 220, render: (_: any, record: any) => !record.checkOutDate ? ( } onClick={() => { setCheckOutModal(record); checkOutForm.setFieldsValue({ checkOutDate: dayjs() }); }} > 退宿 } onClick={() => { setTransferAvailableBeds([]); setTransferAvailableLockers([]); transferForm.resetFields(); setTransferModal(record); transferForm.setFieldsValue({ transferDate: dayjs() }); }} > 换房 ) : ( 已退宿 { try { await api.delete(`/occupancies/${record.id}`); message.success('归档成功'); fetchData(); } catch (e: any) { message.error(e?.message || '归档失败'); } }} > } > 归档 ), }, ], [fetchData, setCheckOutModal, checkOutForm, setTransferModal, transferForm], ); const rowSelection = useMemo( () => ({ selectedRowKeys, onChange: (keys: any[]) => setSelectedRowKeys(keys), // 「在住记录」Tab:禁用已退宿(防止误选用于批量退宿);「全部记录」Tab:均可选用于批量归档 getCheckboxProps: (record: any) => (showActive ? { disabled: !!record.checkOutDate } : {}), }), [selectedRowKeys, showActive], ); return (
{ setDateRange(dates ? [dates[0], dates[1]] : null); }} placeholder={['入住开始', '入住结束']} style={{ width: 240 }} /> } onClick={() => { checkInForm.resetFields(); setAvailableBeds([]); setAvailableLockers([]); setAvailableResourcesLoading(false); const today = dayjs(); checkInForm.setFieldsValue({ checkInDate: today, billingStartDate: today, stayType: 'short', collectDeposit: true, depositAmount: 500, }); setCheckInModal(true); }} > 入住登记 { const formData = new FormData(); formData.append('file', file); const params = new URLSearchParams(); if (autoDeposit) { params.set('autoDeposit', 'true'); params.set('depositAmount', String(depositAmount)); } try { const res: any = await api.post( `/occupancies/import?${params.toString()}`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }, ); if (res.errors?.length > 0) { Modal.warning({ title: res.message, content: res.errors.join('\n'), width: 500, }); } else { message.success(res.message); } onSuccess?.(res); fetchData(); } catch (e: any) { message.error(e?.message || '导入失败'); onError?.(e); } }} > } onClick={() => { downloadBlob('/occupancies/template', '入住名单导入模板.xlsx').catch(() => message.error('下载失败'), ); }} > 下载模板 } onClick={() => { const params = showActive ? '?active=true' : ''; const filename = showActive ? '在住记录.xlsx' : '全部入住记录.xlsx'; downloadBlob('/occupancies/export' + params, filename).catch(() => message.error('导出失败'), ); }} > 导出记录 导入时自动收押金 {autoDeposit && ( setDepositAmount(v || 500)} style={{ width: 60 }} /> )}
{selectedRowKeys.length > 0 && ( 已选 {selectedRowKeys.length} 条记录 {showActive ? ( } onClick={() => { batchCheckOutForm.resetFields(); batchCheckOutForm.setFieldsValue({ checkOutDate: dayjs() }); setBatchCheckOutModal(true); }} style={{ marginLeft: 12 }} loading={batchLoading} > 批量退宿 ) : ( } style={{ marginLeft: 12 }} loading={batchLoading} > 批量归档 )} } type="info" style={{ marginBottom: 12 }} /> )} }} scroll={{ x: 1300 }} pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50, 100], showTotal: (total) => `共 ${total} 条`, }} rowSelection={rowSelection} /> { setCheckInModal(false); setAvailableBeds([]); setAvailableLockers([]); setAvailableResourcesLoading(false); }} okText="确认入住" confirmLoading={saving} width={500} >
({ value: r.id, label: roomOptionLabel(r), disabled: !isRoomSelectable(r), }))} /> ({ validator: dateNotBefore(getFieldValue('checkInDate'), '计费起始日不能早于入住日期'), }), ]} > ({ value: b.id, label: b.bedNumber, }))} notFoundContent={selectedCheckInRoomId ? '该房间暂无可用床位' : '请先选择房间'} /> {availableBeds.length > 0 && (
空闲 {availableBeds.length} 张床位
)}
{/* 批量退宿弹窗 */} setBatchCheckOutModal(false)} okText="确认批量退宿" width={500} >
r.id !== transferModal?.roomId) .map((r: any) => ({ value: r.id, label: roomOptionLabel(r), disabled: !isRoomSelectable(r), }))} /> !value || transferAvailableBeds.some((bed) => bed.id === value) ? Promise.resolve() : Promise.reject(new Error('请选择目标宿舍下的可用床位')), }, ]} > ({ value: locker.id, label: locker.lockerNumber, }))} notFoundContent="目标宿舍暂无可用柜子" /> ({ validator: dateNotBefore( transferModal?.billingStartDate || transferModal?.checkInDate || getFieldValue('transferDate'), '旧房计费截止日不能早于计费起始日', ), }), ]} > ({ validator: dateNotBefore(getFieldValue('transferDate'), '新房计费起始日不能早于换房日期'), }), ]} >
); }; export default OccupanciesPage;