import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { Table, Button, Modal, Form, Select, DatePicker, Input, InputNumber, Space, message, Tag, Popconfirm, Upload, Switch, Alert, Tooltip, } from 'antd'; import { PlusOutlined, SwapOutlined, LogoutOutlined, DeleteOutlined, UploadOutlined, DownloadOutlined, ExportOutlined, } from '@ant-design/icons'; import dayjs from 'dayjs'; import api from '../../api'; import { downloadBlob } from '../../utils/download'; import { maskPhone, maskIdNumber } from '../../utils/sensitive'; import PermissionButton from '../../components/PermissionButton'; const { RangePicker } = DatePicker; const OccupanciesPage: React.FC = () => { const [data, setData] = useState([]); const [students, setStudents] = useState([]); const [rooms, setRooms] = useState([]); const [tenants, setTenants] = 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 [checkInForm] = Form.useForm(); const [checkOutForm] = Form.useForm(); const [transferForm] = Form.useForm(); const [batchCheckOutForm] = Form.useForm(); const fetchData = useCallback(async () => { setLoading(true); try { const [occRes, stuRes, rmRes, tnRes] = (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'), api.get('/rooms/overview'), api.get('/tenants'), ])) as PromiseSettledResult[]; const labels = ['入住数据', '学生列表', '房间列表', '租赁方']; [occRes, stuRes, rmRes, tnRes].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 : []); setTenants(tnRes.status === 'fulfilled' ? tnRes.value : []); } catch (e) { console.error(e); message.error('数据加载异常'); } setLoading(false); }, [showActive, dateRange]); useEffect(() => { fetchData(); setSelectedRowKeys([]); }, [fetchData]); 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', { studentId: values.studentId, roomId: values.roomId, checkInDate: values.checkInDate.format('YYYY-MM-DD'), billingStartDate: values.billingStartDate?.format('YYYY-MM-DD'), rentalType: values.rentalType, tenantId: values.tenantId, notes: values.notes, }); 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`, { newRoomId: values.newRoomId, transferDate: values.transferDate.format('YYYY-MM-DD'), oldBillingEndDate: values.oldBillingEndDate?.format('YYYY-MM-DD'), newBillingStartDate: values.newBillingStartDate?.format('YYYY-MM-DD'), reason: values.reason, }); 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(); 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 || '批量退宿失败'); } }; const handleBatchDelete = async () => { 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 || '批量删除失败'); } }; const columns = useMemo(() => [ { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, { title: '宿舍', width: 120, render: (_: any, r: any) => r.room?.roomNumber || '-' }, { 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={() => { 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(); checkInForm.setFieldsValue({ checkInDate: dayjs() }); 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 }} > 批量退宿 ) : ( } style={{ marginLeft: 12 }} > 批量删除 )} } type="info" style={{ marginBottom: 12 }} /> )} `共 ${total} 条` }} rowSelection={rowSelection} /> {/* 入住登记弹窗 */} setCheckInModal(false)} okText="确认入住" confirmLoading={saving} width={500} >
({ value: r.id, label: `${r.roomNumber} (${r.building || ''}) [${r.currentCount}/${r.capacity}]`, disabled: r.currentCount >= r.capacity, }))} /> ({ value: t.id, label: t.name, }))} />
{/* 退宿弹窗 */} setCheckOutModal(null)} okText="确认退宿" confirmLoading={saving} >
即将退宿的学生:
{data .filter((r: any) => selectedRowKeys.includes(r.id)) .map((r: any) => ( {r.student?.name} ({r.room?.roomNumber}) ))}
{/* 换房弹窗 */} setTransferModal(null)} okText="确认换房" confirmLoading={saving} width={500} >
); }; export default OccupanciesPage;