Files
gongxue-base/apps/admin/src/pages/Occupancies/index.tsx
2026-07-23 14:28:58 +08:00

1094 lines
39 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, { 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';
import { usePermission } from '../../hooks/usePermission';
const { RangePicker } = DatePicker;
const OccupanciesPage: React.FC = () => {
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 [data, setData] = useState<any[]>([]);
const [students, setStudents] = useState<any[]>([]);
const [rooms, setRooms] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [checkInModal, setCheckInModal] = useState(false);
const [checkOutModal, setCheckOutModal] = useState<any>(null);
const [transferModal, setTransferModal] = useState<any>(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<number[]>([]);
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<any[]>([]);
const [availableLockers, setAvailableLockers] = useState<any[]>([]);
const [availableResourcesLoading, setAvailableResourcesLoading] = useState(false);
const [transferAvailableBeds, setTransferAvailableBeds] = useState<any[]>([]);
const [transferAvailableLockers, setTransferAvailableLockers] = useState<any[]>([]);
const [transferResourcesLoading, setTransferResourcesLoading] = useState(false);
const selectedCheckInRoomId = Form.useWatch('roomId', checkInForm);
const selectedTransferRoomId = Form.useWatch('newRoomId', transferForm);
// Close modals when the user loses the required permission
useEffect(() => { if (!canCheckIn) { setCheckInModal(false); checkInForm.resetFields(); } }, [canCheckIn, checkInForm]);
useEffect(() => { if (!canCheckOut && checkOutModal) { setCheckOutModal(null); checkOutForm.resetFields(); } }, [canCheckOut, checkOutModal, checkOutForm]);
useEffect(() => { if (!canCheckOut) { setBatchCheckOutModal(false); batchCheckOutForm.resetFields(); } }, [canCheckOut, batchCheckOutForm]);
useEffect(() => { if (!canTransfer && transferModal) { setTransferModal(null); transferForm.resetFields(); } }, [canTransfer, transferModal, transferForm]);
const activeOccupancyByStudentId = useMemo(() => {
const map = new Map<number, any>();
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<any>[];
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<any[]>(`/rooms/${roomId}/beds/available`),
api.get<any[]>(`/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<any[]>(`/rooms/${roomId}/beds/available`),
api.get<any[]>(`/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<string, unknown>) =>
(r.bed as Record<string, string> | undefined)?.bedNumber || '-',
},
{
title: '柜子',
width: 80,
render: (_: unknown, r: Record<string, unknown>) =>
(r.locker as Record<string, string> | undefined)?.lockerNumber || '-',
},
{ title: '入住日期', dataIndex: 'checkInDate', width: 110 },
{ title: '计费起始', dataIndex: 'billingStartDate', width: 110 },
{
title: '退宿日期',
dataIndex: 'checkOutDate',
width: 110,
render: (v: any) => v || <Tag color="green"></Tag>,
},
{ title: '计费截止', dataIndex: 'billingEndDate', render: (v: any) => v || '-' },
{ title: '退宿原因', dataIndex: 'checkOutReason', render: (v: any) => v || '-' },
{
title: '操作',
width: 220,
render: (_: any, record: any) =>
!record.checkOutDate ? (
<Space>
<PermissionButton
permission="occupancy:checkout"
size="small"
icon={<LogoutOutlined />}
onClick={() => {
setCheckOutModal(record);
checkOutForm.setFieldsValue({ checkOutDate: dayjs() });
}}
>
退宿
</PermissionButton>
<PermissionButton
permission="occupancy:transfer"
size="small"
icon={<SwapOutlined />}
onClick={() => {
setTransferAvailableBeds([]);
setTransferAvailableLockers([]);
transferForm.resetFields();
setTransferModal(record);
transferForm.setFieldsValue({ transferDate: dayjs() });
}}
>
</PermissionButton>
</Space>
) : (
<Space>
<Tag>退宿</Tag>
{canDelete ? (
<Popconfirm
title="确定归档此记录?"
onConfirm={async () => {
try {
await api.delete(`/occupancies/${record.id}`);
message.success('归档成功');
fetchData();
} catch (e: any) {
message.error(e?.message || '归档失败');
}
}}
>
<Button
size="small"
danger
icon={<InboxOutlined />}
>
</Button>
</Popconfirm>
) : null}
</Space>
),
},
],
[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 (
<div>
<Alert
title="一站式导入"
description="导入入住名单时会优先按手机号关联已有学生,所属机构自动取学生档案;未找到学生或宿舍时会自动创建。后续仅需在此页面处理换房/退宿等日常操作即可。"
type="info"
showIcon
closable
style={{ marginBottom: 16 }}
/>
<div className="responsive-toolbar">
<Space wrap className="responsive-toolbar__group">
<Button type={showActive ? 'primary' : 'default'} onClick={() => setShowActive(true)}>
</Button>
<Button type={!showActive ? 'primary' : 'default'} onClick={() => setShowActive(false)}>
</Button>
<Input.Search
placeholder="搜索学生姓名或房间号"
onSearch={setSearchText}
allowClear
style={{ width: 200 }}
/>
<RangePicker
value={dateRange}
onChange={(dates) => {
setDateRange(dates ? [dates[0], dates[1]] : null);
}}
placeholder={['入住开始', '入住结束']}
style={{ width: 240 }}
/>
</Space>
<Space wrap className="responsive-toolbar__group">
<PermissionButton
permission="occupancy:checkin"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
checkInForm.resetFields();
setAvailableBeds([]);
setAvailableLockers([]);
setAvailableResourcesLoading(false);
const today = dayjs();
checkInForm.setFieldsValue({
checkInDate: today,
billingStartDate: today,
stayType: 'short',
collectDeposit: true,
depositAmount: 500,
});
setCheckInModal(true);
}}
>
</PermissionButton>
{canCheckIn ? (
<>
<Upload
accept=".xlsx,.xls"
showUploadList={false}
customRequest={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 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);
}
}}
>
<Tooltip title="按手机号关联学生,并自动创建缺失的学生、宿舍和入住记录">
<Button type="primary" ghost icon={<UploadOutlined />}>
</Button>
</Tooltip>
</Upload>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<Switch size="small" checked={autoDeposit} onChange={setAutoDeposit} />
{autoDeposit && (
<Space.Compact>
<InputNumber
size="small"
min={0}
value={depositAmount}
onChange={(v) => setDepositAmount(v || 500)}
style={{ width: 60 }}
/>
<span
style={{
padding: '0 8px',
display: 'flex',
alignItems: 'center',
border: '1px solid #d9d9d9',
backgroundColor: '#fafafa',
fontSize: 12,
}}
>
</span>
</Space.Compact>
)}
</span>
</>
) : null}
<PermissionButton
permission="occupancy:view"
icon={<DownloadOutlined />}
onClick={() => {
downloadBlob('/occupancies/template', '入住名单导入模板.xlsx').catch(() =>
message.error('下载失败'),
);
}}
>
</PermissionButton>
<PermissionButton
permission="occupancy:view"
icon={<ExportOutlined />}
onClick={() => {
const params = showActive ? '?active=true' : '';
const filename = showActive ? '在住记录.xlsx' : '全部入住记录.xlsx';
downloadBlob('/occupancies/export' + params, filename).catch(() =>
message.error('导出失败'),
);
}}
>
</PermissionButton>
</Space>
</div>
{selectedRowKeys.length > 0 && (
<Alert
title={
<span>
<strong>{selectedRowKeys.length}</strong>
{showActive ? (
<PermissionButton
permission="occupancy:checkout"
type="primary"
size="small"
icon={<LogoutOutlined />}
onClick={() => {
batchCheckOutForm.resetFields();
batchCheckOutForm.setFieldsValue({ checkOutDate: dayjs() });
setBatchCheckOutModal(true);
}}
style={{ marginLeft: 12 }}
loading={batchLoading}
>
退宿
</PermissionButton>
) : (
canDelete ? (
<Popconfirm
title={`确定归档选中的 ${selectedRowKeys.length} 条入住记录?在住记录会自动跳过`}
onConfirm={handleBatchDelete}
okText="归档"
cancelText="取消"
>
<Button
danger
size="small"
icon={<InboxOutlined />}
style={{ marginLeft: 12 }}
loading={batchLoading}
>
</Button>
</Popconfirm>
) : null
)}
<Button size="small" onClick={() => setSelectedRowKeys([])} style={{ marginLeft: 8 }}>
</Button>
</span>
}
type="info"
style={{ marginBottom: 12 }}
/>
)}
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
scroll={{ x: 1300 }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
rowSelection={rowSelection}
/>
<Modal
title="入住登记"
open={checkInModal && canCheckIn}
onOk={canCheckIn ? handleCheckIn : undefined}
onCancel={() => {
setCheckInModal(false);
setAvailableBeds([]);
setAvailableLockers([]);
setAvailableResourcesLoading(false);
}}
okText="确认入住"
confirmLoading={saving}
width={500}
>
<Form form={checkInForm} layout="vertical">
<Form.Item
name="studentId"
label="选择学生"
rules={[{ required: true, message: '请选择学生' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder="搜索并选择学生"
options={students
.filter((s: any) => s.status === 'active')
.map((s: any) => {
const activeOccupancy = activeOccupancyByStudentId.get(s.id);
const identifier = s.idNumber
? maskIdNumber(s.idNumber)
: s.phone
? maskPhone(s.phone)
: '';
return {
value: s.id,
label: `${s.name} (${identifier})${activeOccupancy ? ` · 已入住${activeOccupancy.room?.roomNumber ? ` ${activeOccupancy.room.roomNumber}` : ''}` : ''}`,
disabled: !!activeOccupancy,
};
})}
/>
</Form.Item>
<Form.Item
name="roomId"
label="选择宿舍"
rules={[{ required: true, message: '请选择宿舍' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder="搜索并选择宿舍"
onChange={handleRoomChange}
options={rooms.map((r) => ({
value: r.id,
label: roomOptionLabel(r),
disabled: !isRoomSelectable(r),
}))}
/>
</Form.Item>
<Form.Item
name="checkInDate"
label="入住日期"
rules={[{ required: true, message: '请选择入住日期' }]}
>
<DatePicker style={{ width: '100%' }} placeholder="选择入住日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item
name="billingStartDate"
label="计费起始日"
dependencies={['checkInDate']}
extra="默认与入住日期相同,可调整(如学生要求从次日开始计费)"
rules={[
{ required: true, message: '请选择计费起始日' },
({ getFieldValue }) => ({
validator: dateNotBefore(
getFieldValue('checkInDate'),
'计费起始日不能早于入住日期',
),
}),
]}
>
<DatePicker
style={{ width: '100%' }}
placeholder="选择计费起始日"
format="YYYY-MM-DD"
/>
</Form.Item>
<Form.Item
name="stayType"
label="入住类型"
rules={[{ required: true, message: '请选择入住类型' }]}
>
<Select
options={[
{ value: 'short', label: '短租' },
{ value: 'long', label: '长租' },
]}
placeholder="默认为短租"
/>
</Form.Item>
<Form.Item
name="bedId"
label="床位"
rules={[
{ required: true, message: '请选择床位' },
{
validator: (_: unknown, value?: number) =>
!value || availableBeds.some((bed) => bed.id === value)
? Promise.resolve()
: Promise.reject(new Error('请选择当前宿舍下的可用床位')),
},
]}
>
<Select
placeholder={selectedCheckInRoomId ? '请选择床位' : '请先选择房间'}
loading={availableResourcesLoading}
disabled={
!selectedCheckInRoomId || availableResourcesLoading || availableBeds.length === 0
}
options={availableBeds.map((b) => ({
value: b.id,
label: b.bedNumber,
}))}
notFoundContent={selectedCheckInRoomId ? '该房间暂无可用床位' : '请先选择房间'}
/>
</Form.Item>
{availableBeds.length > 0 && (
<div style={{ marginTop: -16, marginBottom: 16, color: '#888', fontSize: 12 }}>
{availableBeds.length}
</div>
)}
<Form.Item name="lockerId" label="柜子(可选)">
<Select
allowClear
placeholder="可选分配柜子"
loading={availableResourcesLoading}
disabled={
!selectedCheckInRoomId || availableResourcesLoading || availableLockers.length === 0
}
options={availableLockers.map((l) => ({
value: l.id,
label: l.lockerNumber,
}))}
/>
</Form.Item>
<Form.Item
name="collectDeposit"
label="押金缴纳"
valuePropName="checked"
extra="开启后,确认入住时同步生成已缴押金记录;已有已缴押金时不会重复创建"
>
<Switch checkedChildren="已缴" unCheckedChildren="不缴" />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, current) => prev.collectDeposit !== current.collectDeposit}
>
{({ getFieldValue }) =>
getFieldValue('collectDeposit') ? (
<Form.Item
name="depositAmount"
label="押金金额"
rules={[{ required: true, message: '请输入押金金额' }]}
>
<InputNumber min={0.01} precision={2} addonAfter="元" style={{ width: '100%' }} />
</Form.Item>
) : null
}
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
{/* 退宿弹窗 */}
<Modal
title={`退宿 - ${checkOutModal?.student?.name}`}
open={!!checkOutModal && canCheckOut}
onOk={canCheckOut ? handleCheckOut : undefined}
onCancel={() => setCheckOutModal(null)}
okText="确认退宿"
confirmLoading={saving}
>
<Form form={checkOutForm} layout="vertical">
<Form.Item
name="checkOutDate"
label="退宿日期"
rules={[
{ required: true, message: '请选择退宿日期' },
{ validator: dateNotBefore(checkOutModal?.checkInDate, '退宿日期不能早于入住日期') },
]}
>
<DatePicker style={{ width: '100%' }} placeholder="选择退宿日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item
name="billingEndDate"
label="计费截止日"
dependencies={['checkOutDate']}
extra="默认与退宿日期相同"
rules={[
({ getFieldValue }) => ({
validator: dateNotBefore(
checkOutModal?.billingStartDate ||
checkOutModal?.checkInDate ||
getFieldValue('checkOutDate'),
'计费截止日不能早于计费起始日',
),
}),
]}
>
<DatePicker
style={{ width: '100%' }}
placeholder="选择计费截止日"
format="YYYY-MM-DD"
/>
</Form.Item>
<Form.Item name="checkOutReason" label="退宿原因">
<Select
allowClear
options={[
{ value: '换房', label: '换房' },
{ value: '退训', label: '退训' },
{ value: '结业', label: '结业' },
{ value: '毕业', label: '毕业' },
{ value: '其他', label: '其他' },
]}
/>
</Form.Item>
</Form>
</Modal>
{/* 批量退宿弹窗 */}
<Modal
title={`批量退宿(${selectedRowKeys.length} 人)`}
open={batchCheckOutModal && canCheckOut}
onOk={canCheckOut ? handleBatchCheckOut : undefined}
onCancel={() => setBatchCheckOutModal(false)}
okText="确认批量退宿"
width={500}
>
<Form form={batchCheckOutForm} layout="vertical">
<Form.Item
name="checkOutDate"
label="退宿日期"
rules={[
{ required: true, message: '请选择退宿日期' },
{
validator: dateNotBefore(
latestSelectedCheckInDate,
'退宿日期不能早于所选记录中最晚的入住日期',
),
},
]}
>
<DatePicker style={{ width: '100%' }} placeholder="选择退宿日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item
name="billingEndDate"
label="计费截止日"
extra="默认与退宿日期相同"
rules={[
{
validator: dateNotBefore(
latestSelectedBillingStartDate,
'计费截止日不能早于所选记录中最晚的计费起始日',
),
},
]}
>
<DatePicker
style={{ width: '100%' }}
placeholder="选择计费截止日"
format="YYYY-MM-DD"
/>
</Form.Item>
<Form.Item name="checkOutReason" label="退宿原因">
<Select
allowClear
options={[
{ value: '结业', label: '结业' },
{ value: '退训', label: '退训' },
{ value: '毕业', label: '毕业' },
{ value: '其他', label: '其他' },
]}
/>
</Form.Item>
</Form>
<div
style={{
marginTop: 12,
padding: '8px 12px',
background: '#f5f5f5',
borderRadius: 6,
maxHeight: 150,
overflow: 'auto',
}}
>
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>退宿</div>
{data
.filter((r: any) => selectedRowKeys.includes(r.id))
.map((r: any) => (
<Tag key={r.id} style={{ marginBottom: 4 }}>
{r.student?.name} ({r.room?.roomNumber})
</Tag>
))}
</div>
</Modal>
{/* 换房弹窗 */}
<Modal
title={`换房 - ${transferModal?.student?.name}`}
open={!!transferModal && canTransfer}
onOk={handleTransfer}
onCancel={() => {
setTransferModal(null);
transferForm.resetFields();
setTransferAvailableBeds([]);
setTransferAvailableLockers([]);
}}
okText="确认换房"
confirmLoading={saving}
width={500}
>
<Form form={transferForm} layout="vertical">
<Form.Item name="newRoomId" label="目标宿舍" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="选择目标宿舍"
onChange={handleTransferRoomChange}
options={rooms
.filter((r: any) => r.id !== transferModal?.roomId)
.map((r: any) => ({
value: r.id,
label: roomOptionLabel(r),
disabled: !isRoomSelectable(r),
}))}
/>
</Form.Item>
<Form.Item
name="newBedId"
label="目标床位"
rules={[
{ required: true, message: '请选择目标床位' },
{
validator: (_: unknown, value?: number) =>
!value || transferAvailableBeds.some((bed) => bed.id === value)
? Promise.resolve()
: Promise.reject(new Error('请选择目标宿舍下的可用床位')),
},
]}
>
<Select
placeholder={selectedTransferRoomId ? '请选择目标床位' : '请先选择目标宿舍'}
loading={transferResourcesLoading}
disabled={
!selectedTransferRoomId ||
transferResourcesLoading ||
transferAvailableBeds.length === 0
}
options={transferAvailableBeds.map((bed) => ({
value: bed.id,
label: bed.bedNumber,
}))}
notFoundContent={selectedTransferRoomId ? '目标宿舍暂无可用床位' : '请先选择目标宿舍'}
/>
</Form.Item>
{transferAvailableBeds.length > 0 && (
<div style={{ marginTop: -16, marginBottom: 16, color: '#888', fontSize: 12 }}>
{transferAvailableBeds.length}
</div>
)}
<Form.Item name="newLockerId" label="目标柜子(可选)">
<Select
allowClear
placeholder="可选分配目标宿舍柜子"
loading={transferResourcesLoading}
disabled={
!selectedTransferRoomId ||
transferResourcesLoading ||
transferAvailableLockers.length === 0
}
options={transferAvailableLockers.map((locker) => ({
value: locker.id,
label: locker.lockerNumber,
}))}
notFoundContent="目标宿舍暂无可用柜子"
/>
</Form.Item>
<Form.Item
name="transferDate"
label="换房日期"
rules={[
{ required: true, message: '请选择换房日期' },
{
validator: dateNotBefore(transferModal?.checkInDate, '换房日期不能早于原入住日期'),
},
]}
>
<DatePicker style={{ width: '100%' }} placeholder="选择换房日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item
name="oldBillingEndDate"
label="旧房计费截止日"
dependencies={['transferDate']}
extra="默认为换房当天"
rules={[
({ getFieldValue }) => ({
validator: dateNotBefore(
transferModal?.billingStartDate ||
transferModal?.checkInDate ||
getFieldValue('transferDate'),
'旧房计费截止日不能早于计费起始日',
),
}),
]}
>
<DatePicker
style={{ width: '100%' }}
placeholder="选择旧房计费截止日"
format="YYYY-MM-DD"
/>
</Form.Item>
<Form.Item
name="newBillingStartDate"
label="新房计费起始日"
dependencies={['transferDate']}
extra="默认为换房次日"
rules={[
({ getFieldValue }) => ({
validator: dateNotBefore(
getFieldValue('transferDate'),
'新房计费起始日不能早于换房日期',
),
}),
]}
>
<DatePicker
style={{ width: '100%' }}
placeholder="选择新房计费起始日"
format="YYYY-MM-DD"
/>
</Form.Item>
<Form.Item name="reason" label="换房原因">
<Input />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default OccupanciesPage;