forked from wangziqi/gongxue-base
823 lines
29 KiB
TypeScript
823 lines
29 KiB
TypeScript
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,
|
||
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';
|
||
import { message } from '../../ui/app-message';
|
||
import { buildTransferPayload } from './occupancy-form';
|
||
|
||
const { RangePicker } = DatePicker;
|
||
|
||
const OccupanciesPage: React.FC = () => {
|
||
const [data, setData] = useState<any[]>([]);
|
||
const [students, setStudents] = useState<any[]>([]);
|
||
const [rooms, setRooms] = useState<any[]>([]);
|
||
const [organizations, setOrganizations] = 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 [transferAvailableBeds, setTransferAvailableBeds] = useState<any[]>([]);
|
||
const [transferAvailableLockers, setTransferAvailableLockers] = useState<any[]>([]);
|
||
|
||
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/basic-lookups'),
|
||
api.get('/rooms/overview'),
|
||
api.get('/organizations'),
|
||
])) as PromiseSettledResult<any>[];
|
||
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 : []);
|
||
setOrganizations(tnRes.status === 'fulfilled' ? tnRes.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);
|
||
if (!roomId) {
|
||
setAvailableBeds([]);
|
||
setAvailableLockers([]);
|
||
return;
|
||
}
|
||
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);
|
||
} catch (e) { console.error(e); }
|
||
};
|
||
|
||
const handleTransferRoomChange = async (roomId: number) => {
|
||
transferForm.setFieldValue('newBedId', undefined);
|
||
transferForm.setFieldValue('newLockerId', undefined);
|
||
if (!roomId) {
|
||
setTransferAvailableBeds([]);
|
||
setTransferAvailableLockers([]);
|
||
return;
|
||
}
|
||
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);
|
||
} catch (e) {
|
||
console.error(e);
|
||
setTransferAvailableBeds([]);
|
||
setTransferAvailableLockers([]);
|
||
message.error('目标宿舍床位和柜子加载失败');
|
||
}
|
||
};
|
||
|
||
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'),
|
||
stayType: values.stayType,
|
||
responsibleOrganizationId: values.responsibleOrganizationId,
|
||
notes: values.notes,
|
||
bedId: values.bedId,
|
||
lockerId: values.lockerId || undefined,
|
||
});
|
||
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>
|
||
<Popconfirm
|
||
title="确定删除此记录?"
|
||
onConfirm={async () => {
|
||
try {
|
||
await api.delete(`/occupancies/${record.id}`);
|
||
message.success('删除成功');
|
||
fetchData();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '删除失败');
|
||
}
|
||
}}
|
||
>
|
||
<PermissionButton permission="occupancy:delete" size="small" danger icon={<DeleteOutlined />}>
|
||
删除
|
||
</PermissionButton>
|
||
</Popconfirm>
|
||
</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
|
||
style={{
|
||
marginBottom: 16,
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
flexWrap: 'wrap',
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<Space wrap>
|
||
<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>
|
||
<PermissionButton
|
||
permission="occupancy:checkin"
|
||
type="primary"
|
||
icon={<PlusOutlined />}
|
||
onClick={() => {
|
||
checkInForm.resetFields();
|
||
checkInForm.setFieldsValue({ checkInDate: dayjs() });
|
||
setCheckInModal(true);
|
||
}}
|
||
>
|
||
入住登记
|
||
</PermissionButton>
|
||
<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>
|
||
<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>
|
||
<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>
|
||
</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>
|
||
) : (
|
||
<Popconfirm
|
||
title={`确定删除选中的 ${selectedRowKeys.length} 条入住记录?在住记录会自动跳过`}
|
||
onConfirm={handleBatchDelete}
|
||
okText="删除"
|
||
cancelText="取消"
|
||
>
|
||
<PermissionButton
|
||
permission="occupancy:delete"
|
||
danger
|
||
size="small"
|
||
icon={<DeleteOutlined />}
|
||
style={{ marginLeft: 12 }}
|
||
loading={batchLoading}
|
||
>
|
||
批量删除
|
||
</PermissionButton>
|
||
</Popconfirm>
|
||
)}
|
||
<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={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||
rowSelection={rowSelection}
|
||
/>
|
||
<Modal
|
||
title="入住登记"
|
||
open={checkInModal}
|
||
onOk={handleCheckIn}
|
||
onCancel={() => setCheckInModal(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) => ({
|
||
value: s.id,
|
||
label: `${s.name} (${s.idNumber ? maskIdNumber(s.idNumber) : (s.phone ? maskPhone(s.phone) : '')})`,
|
||
}))}
|
||
/>
|
||
</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: `${r.roomNumber} (${r.building || ''}) [${r.currentCount}/${r.capacity}]`,
|
||
disabled: r.currentCount >= r.capacity,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="checkInDate" label="入住日期" rules={[{ required: true }]}>
|
||
<DatePicker style={{ width: '100%' }} placeholder="选择入住日期" format="YYYY-MM-DD" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="billingStartDate"
|
||
label="计费起始日"
|
||
extra="默认与入住日期相同,可调整(如学生要求从次日开始计费)"
|
||
>
|
||
<DatePicker
|
||
style={{ width: '100%' }}
|
||
placeholder="选择计费起始日"
|
||
format="YYYY-MM-DD"
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="stayType" label="入住类型">
|
||
<Select
|
||
allowClear
|
||
options={[
|
||
{ value: 'short', label: '短租' },
|
||
{ value: 'long', label: '长租' },
|
||
]}
|
||
placeholder="默认为短租"
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="responsibleOrganizationId" label="负责机构">
|
||
<Select
|
||
showSearch
|
||
allowClear
|
||
optionFilterProp="label"
|
||
placeholder="默认取学生所属机构"
|
||
options={organizations.map((t: { id: number; name: string }) => ({
|
||
value: t.id,
|
||
label: t.name,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="bedId"
|
||
label="床位"
|
||
rules={[{ required: true, message: '请选择床位' }]}
|
||
>
|
||
<Select
|
||
placeholder="请先选择房间"
|
||
disabled={availableBeds.length === 0}
|
||
options={availableBeds.map((b) => ({
|
||
value: b.id,
|
||
label: b.bedNumber,
|
||
}))}
|
||
notFoundContent="该房间暂无可用床位"
|
||
/>
|
||
</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="可选分配柜子"
|
||
disabled={availableLockers.length === 0}
|
||
options={availableLockers.map((l) => ({
|
||
value: l.id,
|
||
label: l.lockerNumber,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="notes" label="备注">
|
||
<Input.TextArea rows={2} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
{/* 退宿弹窗 */}
|
||
<Modal
|
||
title={`退宿 - ${checkOutModal?.student?.name}`}
|
||
open={!!checkOutModal}
|
||
onOk={handleCheckOut}
|
||
onCancel={() => setCheckOutModal(null)}
|
||
okText="确认退宿"
|
||
confirmLoading={saving}
|
||
>
|
||
<Form form={checkOutForm} layout="vertical">
|
||
<Form.Item name="checkOutDate" label="退宿日期" rules={[{ required: true }]}>
|
||
<DatePicker style={{ width: '100%' }} placeholder="选择退宿日期" format="YYYY-MM-DD" />
|
||
</Form.Item>
|
||
<Form.Item name="billingEndDate" label="计费截止日" extra="默认与退宿日期相同">
|
||
<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}
|
||
onOk={handleBatchCheckOut}
|
||
onCancel={() => setBatchCheckOutModal(false)}
|
||
okText="确认批量退宿"
|
||
width={500}
|
||
>
|
||
<Form form={batchCheckOutForm} layout="vertical">
|
||
<Form.Item name="checkOutDate" label="退宿日期" rules={[{ required: true }]}>
|
||
<DatePicker style={{ width: '100%' }} placeholder="选择退宿日期" format="YYYY-MM-DD" />
|
||
</Form.Item>
|
||
<Form.Item name="billingEndDate" label="计费截止日" extra="默认与退宿日期相同">
|
||
<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}
|
||
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: `${r.roomNumber} (${r.building || ''}) [${r.currentCount}/${r.capacity}]`,
|
||
disabled: r.currentCount >= r.capacity,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="newBedId"
|
||
label="目标床位"
|
||
rules={[{ required: true, message: '请选择目标床位' }]}
|
||
>
|
||
<Select
|
||
placeholder="请先选择目标宿舍"
|
||
disabled={transferAvailableBeds.length === 0}
|
||
options={transferAvailableBeds.map((bed) => ({
|
||
value: bed.id,
|
||
label: bed.bedNumber,
|
||
}))}
|
||
notFoundContent="目标宿舍暂无可用床位"
|
||
/>
|
||
</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="可选分配目标宿舍柜子"
|
||
disabled={transferAvailableLockers.length === 0}
|
||
options={transferAvailableLockers.map((locker) => ({
|
||
value: locker.id,
|
||
label: locker.lockerNumber,
|
||
}))}
|
||
notFoundContent="目标宿舍暂无可用柜子"
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="transferDate" label="换房日期" rules={[{ required: true }]}>
|
||
<DatePicker style={{ width: '100%' }} placeholder="选择换房日期" format="YYYY-MM-DD" />
|
||
</Form.Item>
|
||
<Form.Item name="oldBillingEndDate" label="旧房计费截止日" extra="默认为换房当天">
|
||
<DatePicker
|
||
style={{ width: '100%' }}
|
||
placeholder="选择旧房计费截止日"
|
||
format="YYYY-MM-DD"
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="newBillingStartDate" label="新房计费起始日" extra="默认为换房次日">
|
||
<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;
|