// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 import React, { useState, useMemo, useCallback } from 'react'; import { useNavigate } from 'react-router'; import { Alert, App, Form } from 'antd'; import dayjs, { type Dayjs } from 'dayjs'; import api from '../../api'; import { message } from '../../ui/app-message'; import { buildCheckInPayload, buildTransferPayload } from './occupancy-form'; import { buildOccupancyColumns } from './OccupancyColumns'; import type { OccupancyRow } from './OccupancyColumns'; import { BatchCheckOutModal, CheckInModal, CheckOutModal, TransferModal, } from './OccupancyModals'; import { usePermission } from '../../hooks/usePermission'; import { occupancyParamsForView, occupancyViewPolicy, type OccupancyView } from '../archive-view'; import { useQuery } from '@tanstack/react-query'; import { validateResponse } from '../../utils/validate'; import { occupanciesSchema } from '../../api/schemas'; import { OccupanciesTableArea } from './OccupanciesTableArea'; import { OccupanciesToolbar } from './OccupanciesToolbar'; import { useOccupancyMutations } from './useOccupancyMutations'; import { QueryErrorState } from '../../components/QueryState'; import { NextStepHint } from '../../components/NextStepHint'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; import { useDownload } from '../../hooks/useDownload'; interface StudentLookupRow { id: number; name: string; studentNo?: string; idNumber?: string; phone?: string; status?: string; } interface RoomOverviewRow { id: number; roomNumber: string; building?: string; capacity?: number; currentCount?: number; floor?: number | null; roomType?: string; status?: string; } const OccupanciesPage: React.FC = () => { const { modal } = App.useApp(); const navigate = useNavigate(); const { hasPermission, permissionsReady } = usePermission(); const canCheckIn = permissionsReady && hasPermission('occupancy:checkin'); const canCheckOut = permissionsReady && hasPermission('occupancy:checkout'); const canTransfer = permissionsReady && hasPermission('occupancy:transfer'); const canDelete = permissionsReady && hasPermission('occupancy:delete'); const canPurge = permissionsReady && hasPermission('occupancy:purge'); const [checkInModal, setCheckInModal] = useState(false); const [checkOutModal, setCheckOutModal] = useState(null); const [transferModal, setTransferModal] = useState(null); // 入住成功后的「下一步」引导提示 const [nextStepHint, setNextStepHint] = useState<'billing' | null>(null); const [viewMode, setViewMode] = useState('active'); const viewPolicy = occupancyViewPolicy(viewMode); 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 changeViewMode = (mode: OccupancyView) => { setViewMode(mode); setSelectedRowKeys([]); }; const changeDateRange = (dates: [dayjs.Dayjs | null, dayjs.Dayjs | null] | null) => { setDateRange(dates); setSelectedRowKeys([]); }; const { data: fetchResult = { data: [], students: [], rooms: [] }, isLoading, isFetching, isError, refetch, } = useQuery<{ data: OccupancyRow[]; students: StudentLookupRow[]; rooms: RoomOverviewRow[] }>({ queryKey: ['occupancies', viewMode, dateRange], queryFn: async () => { const [occRes, stuRes, rmRes] = await Promise.allSettled([ api.get('/occupancies', { params: { ...occupancyParamsForView(viewMode), dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), dateTo: dateRange?.[1]?.format('YYYY-MM-DD'), }, }), api.get('/students/basic-lookups'), api.get('/rooms/overview'), ]); const labels = ['入住数据', '学生列表', '房间列表']; [stuRes, rmRes].forEach((res, i) => { if (res.status === 'rejected') { message.warning(`${labels[i + 1]}加载失败`); } }); if (occRes.status === 'rejected') { throw occRes.reason; } return { data: validateResponse(occupanciesSchema, occRes.value), students: stuRes.status === 'fulfilled' ? stuRes.value : [], rooms: rmRes.status === 'fulfilled' ? rmRes.value : [], }; }, }); const data = fetchResult.data; const students = fetchResult.students; const rooms = fetchResult.rooms; const loading = isLoading || isFetching; // RouteKeeper 保活页面切回时刷新入住列表 useVisibleRefetch(['occupancies']); const { checkInMutation, checkOutMutation, transferMutation, batchCheckOutMutation, batchDeleteMutation, batchRestoreMutation, archiveMutation, purgeMutation, batchPurgeMutation, importMutation, } = useOccupancyMutations(); 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: RoomOverviewRow) => { 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: RoomOverviewRow) => { 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((date): date is string => Boolean(date)) .reduce((latest: string | undefined, date) => !latest || date > latest ? date : latest, undefined), [selectedBatchRecords], ); const latestSelectedBillingStartDate = useMemo( () => selectedBatchRecords .map((item) => item.billingStartDate || item.checkInDate) .filter((date): date is string => Boolean(date)) .reduce((latest: string | undefined, date) => !latest || date > latest ? date : latest, undefined), [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 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 checkInMutation.mutateAsync(buildCheckInPayload(values)); message.success('入住登记成功'); setCheckInModal(false); checkInForm.resetFields(); // 引导业务闭环的下一步:录费用 → 生成账单 setNextStepHint('billing'); } catch { // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } }; const handleCheckOut = async () => { const values = await checkOutForm.validateFields(); setSaving(true); try { await checkOutMutation.mutateAsync({ id: checkOutModal.id, payload: { checkOutDate: values.checkOutDate.format('YYYY-MM-DD'), billingEndDate: values.billingEndDate?.format('YYYY-MM-DD'), checkOutReason: values.checkOutReason, }, }); message.success('退宿成功'); setCheckOutModal(null); checkOutForm.resetFields(); } catch { // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } }; const handleTransfer = async () => { const values = await transferForm.validateFields(); setSaving(true); try { await transferMutation.mutateAsync({ id: transferModal.id, payload: buildTransferPayload(values), }); message.success('换房成功'); setTransferModal(null); transferForm.resetFields(); } catch { // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } }; const handleBatchCheckOut = async () => { if (batchLoading) return; const values = await batchCheckOutForm.validateFields(); setBatchLoading(true); try { const res: any = await batchCheckOutMutation.mutateAsync({ 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([]); } catch { // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; const handleBatchDelete = async () => { if (batchLoading) return; setBatchLoading(true); try { const res: any = await batchDeleteMutation.mutateAsync(selectedRowKeys); message.success(res?.message || `已归档 ${selectedRowKeys.length} 条`); setSelectedRowKeys([]); } catch { // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; const handleBatchRestore = async () => { if (batchLoading) return; setBatchLoading(true); try { const res = await batchRestoreMutation.mutateAsync(selectedRowKeys); message.success( `已恢复 ${res.restored} 条入住记录${res.skipped ? `,跳过 ${res.skipped} 条` : ''}`, ); setSelectedRowKeys([]); } catch { // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; const handlePurge = useCallback( (id: number, studentName: string) => { modal.confirm({ title: `永久删除入住记录(${studentName})?`, content: '删除后不可恢复,该入住记录将被物理删除。确定继续?', okText: '永久删除', okButtonProps: { danger: true }, cancelText: '取消', onOk: async () => { try { await purgeMutation.mutateAsync(id); message.success('已永久删除(不可恢复)'); } catch { // 错误提示由 useApiMutation 统一处理 } }, }); }, [modal, purgeMutation], ); const handleBatchPurge = async () => { if (batchLoading) return; setBatchLoading(true); try { const res: any = await batchPurgeMutation.mutateAsync(selectedRowKeys); message.success(res?.message || `已永久删除 ${selectedRowKeys.length} 条`); setSelectedRowKeys([]); } catch { // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; const columns = useMemo( () => buildOccupancyColumns({ readonly: viewPolicy.readonly, canPurge, canDelete, onPurge: handlePurge, onArchive: (id) => archiveMutation.mutateAsync(id), onCheckOut: (record) => { setCheckOutModal(record); checkOutForm.setFieldsValue({ checkOutDate: dayjs() }); }, onTransfer: (record) => { setTransferAvailableBeds([]); setTransferAvailableLockers([]); transferForm.resetFields(); setTransferModal(record); transferForm.setFieldsValue({ transferDate: dayjs() }); }, }), [ viewPolicy.readonly, canPurge, canDelete, handlePurge, archiveMutation, checkOutForm, transferForm, ], ); const rowSelection = useMemo( () => ({ selectedRowKeys, onChange: (keys: any[]) => setSelectedRowKeys(keys), getCheckboxProps: (record: any) => viewMode === 'active' ? { disabled: !!record.checkOutDate } : {}, }), [selectedRowKeys, viewMode], ); const { downloading: templateDownloading, run: runTemplateDownload } = useDownload(); const { downloading: exportDownloading, run: runExportDownload } = useDownload(); return (
{ checkInForm.resetFields(); setAvailableBeds([]); setAvailableLockers([]); setAvailableResourcesLoading(false); const today = dayjs(); checkInForm.setFieldsValue({ checkInDate: today, billingStartDate: today, stayType: 'short', collectDeposit: true, depositAmount: 500, }); setCheckInModal(true); }} onImport={async ({ file, onSuccess, onError }: any) => { 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 importMutation.mutateAsync({ formData, params: params.toString(), }); if (res.errors?.length > 0) { modal.warning({ title: res.message, content: res.errors.join('\n'), width: 500, }); } else { message.success(res.message); } onSuccess?.(res); } catch (e) { onError?.(e as Error); } }} autoDeposit={autoDeposit} onAutoDepositChange={setAutoDeposit} depositAmount={depositAmount} onDepositAmountChange={setDepositAmount} onDownloadTemplate={() => { void runTemplateDownload('/occupancies/template', '入住名单导入模板.xlsx', { successMsg: '模板已下载', errorMsg: '下载失败', }); }} templateLoading={templateDownloading} onExport={() => { const params = viewMode === 'active' ? '?active=true' : ''; const filename = viewMode === 'active' ? '在住记录.xlsx' : '全部入住记录.xlsx'; void runExportDownload('/occupancies/export' + params, filename, { successMsg: '入住记录已导出', errorMsg: '导出失败', }); }} exportLoading={exportDownloading} /> {nextStepHint === 'billing' && ( navigate('/expenses') }} onClose={() => setNextStepHint(null)} /> )} {isError ? ( void refetch()} /> ) : ( { batchCheckOutForm.resetFields(); batchCheckOutForm.setFieldsValue({ checkOutDate: dayjs() }); setBatchCheckOutModal(true); }} onBatchDelete={handleBatchDelete} onBatchRestore={handleBatchRestore} onBatchPurge={handleBatchPurge} onClearSelection={() => setSelectedRowKeys([])} /> )} { setCheckInModal(false); setAvailableBeds([]); setAvailableLockers([]); setAvailableResourcesLoading(false); }} /> setCheckOutModal(null)} /> setBatchCheckOutModal(false)} /> { setTransferModal(null); transferForm.resetFields(); setTransferAvailableBeds([]); setTransferAvailableLockers([]); }} />
); }; export default OccupanciesPage;