fix(admin): complete occupancy check-in form payload

This commit is contained in:
2026-07-18 20:40:18 +08:00
parent f06b114a29
commit 7439663302
3 changed files with 306 additions and 58 deletions

View File

@@ -26,13 +26,13 @@ import {
DownloadOutlined, DownloadOutlined,
ExportOutlined, ExportOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs, { type Dayjs } from 'dayjs';
import api from '../../api'; import api from '../../api';
import { downloadBlob } from '../../utils/download'; import { downloadBlob } from '../../utils/download';
import { maskPhone, maskIdNumber } from '../../utils/sensitive'; import { maskPhone, maskIdNumber } from '../../utils/sensitive';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { buildTransferPayload } from './occupancy-form'; import { buildCheckInPayload, buildTransferPayload } from './occupancy-form';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
@@ -59,8 +59,61 @@ const OccupanciesPage: React.FC = () => {
const [batchCheckOutForm] = Form.useForm(); const [batchCheckOutForm] = Form.useForm();
const [availableBeds, setAvailableBeds] = useState<any[]>([]); const [availableBeds, setAvailableBeds] = useState<any[]>([]);
const [availableLockers, setAvailableLockers] = useState<any[]>([]); const [availableLockers, setAvailableLockers] = useState<any[]>([]);
const [availableResourcesLoading, setAvailableResourcesLoading] = useState(false);
const [transferAvailableBeds, setTransferAvailableBeds] = useState<any[]>([]); const [transferAvailableBeds, setTransferAvailableBeds] = useState<any[]>([]);
const [transferAvailableLockers, setTransferAvailableLockers] = useState<any[]>([]); const [transferAvailableLockers, setTransferAvailableLockers] = useState<any[]>([]);
const [transferResourcesLoading, setTransferResourcesLoading] = useState(false);
const selectedCheckInRoomId = Form.useWatch('roomId', checkInForm);
const selectedTransferRoomId = Form.useWatch('newRoomId', 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 () => { const fetchData = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -100,11 +153,11 @@ const OccupanciesPage: React.FC = () => {
const handleRoomChange = async (roomId: number) => { const handleRoomChange = async (roomId: number) => {
checkInForm.setFieldValue('bedId', undefined); checkInForm.setFieldValue('bedId', undefined);
checkInForm.setFieldValue('lockerId', undefined); checkInForm.setFieldValue('lockerId', undefined);
if (!roomId) {
setAvailableBeds([]); setAvailableBeds([]);
setAvailableLockers([]); setAvailableLockers([]);
return; if (!roomId) return;
}
setAvailableResourcesLoading(true);
try { try {
const [beds, lockers] = await Promise.all([ const [beds, lockers] = await Promise.all([
api.get<any[]>(`/rooms/${roomId}/beds/available`), api.get<any[]>(`/rooms/${roomId}/beds/available`),
@@ -113,19 +166,25 @@ const OccupanciesPage: React.FC = () => {
setAvailableBeds(beds); setAvailableBeds(beds);
setAvailableLockers(lockers); setAvailableLockers(lockers);
if (beds.length === 1) checkInForm.setFieldValue('bedId', beds[0].id); if (beds.length === 1) checkInForm.setFieldValue('bedId', beds[0].id);
} catch (e) { if (beds.length === 0) message.warning('该宿舍暂无可用床位,请先在宿舍详情添加或释放床位');
} catch (e: any) {
console.error(e); console.error(e);
setAvailableBeds([]);
setAvailableLockers([]);
message.error(e?.message || '宿舍床位和柜子加载失败');
} finally {
setAvailableResourcesLoading(false);
} }
}; };
const handleTransferRoomChange = async (roomId: number) => { const handleTransferRoomChange = async (roomId: number) => {
transferForm.setFieldValue('newBedId', undefined); transferForm.setFieldValue('newBedId', undefined);
transferForm.setFieldValue('newLockerId', undefined); transferForm.setFieldValue('newLockerId', undefined);
if (!roomId) {
setTransferAvailableBeds([]); setTransferAvailableBeds([]);
setTransferAvailableLockers([]); setTransferAvailableLockers([]);
return; if (!roomId) return;
}
setTransferResourcesLoading(true);
try { try {
const [beds, lockers] = await Promise.all([ const [beds, lockers] = await Promise.all([
api.get<any[]>(`/rooms/${roomId}/beds/available`), api.get<any[]>(`/rooms/${roomId}/beds/available`),
@@ -134,11 +193,14 @@ const OccupanciesPage: React.FC = () => {
setTransferAvailableBeds(beds); setTransferAvailableBeds(beds);
setTransferAvailableLockers(lockers); setTransferAvailableLockers(lockers);
if (beds.length === 1) transferForm.setFieldValue('newBedId', beds[0].id); if (beds.length === 1) transferForm.setFieldValue('newBedId', beds[0].id);
} catch (e) { if (beds.length === 0) message.warning('目标宿舍暂无可用床位,请先在宿舍详情添加或释放床位');
} catch (e: any) {
console.error(e); console.error(e);
setTransferAvailableBeds([]); setTransferAvailableBeds([]);
setTransferAvailableLockers([]); setTransferAvailableLockers([]);
message.error('目标宿舍床位和柜子加载失败'); message.error(e?.message || '目标宿舍床位和柜子加载失败');
} finally {
setTransferResourcesLoading(false);
} }
}; };
@@ -156,18 +218,7 @@ const OccupanciesPage: React.FC = () => {
const values = await checkInForm.validateFields(); const values = await checkInForm.validateFields();
setSaving(true); setSaving(true);
try { try {
await api.post('/occupancies/check-in', { await api.post('/occupancies/check-in', buildCheckInPayload(values));
studentId: values.studentId,
roomId: values.roomId,
checkInDate: values.checkInDate.format('YYYY-MM-DD'),
billingStartDate: values.billingStartDate?.format('YYYY-MM-DD'),
stayType: values.stayType,
collectDeposit: values.collectDeposit,
depositAmount: values.collectDeposit ? values.depositAmount : undefined,
notes: values.notes,
bedId: values.bedId,
lockerId: values.lockerId || undefined,
});
message.success('入住登记成功'); message.success('入住登记成功');
setCheckInModal(false); setCheckInModal(false);
checkInForm.resetFields(); checkInForm.resetFields();
@@ -390,8 +441,14 @@ const OccupanciesPage: React.FC = () => {
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={() => { onClick={() => {
checkInForm.resetFields(); checkInForm.resetFields();
setAvailableBeds([]);
setAvailableLockers([]);
setAvailableResourcesLoading(false);
const today = dayjs();
checkInForm.setFieldsValue({ checkInForm.setFieldsValue({
checkInDate: dayjs(), checkInDate: today,
billingStartDate: today,
stayType: 'short',
collectDeposit: true, collectDeposit: true,
depositAmount: 500, depositAmount: 500,
}); });
@@ -561,7 +618,12 @@ const OccupanciesPage: React.FC = () => {
title="入住登记" title="入住登记"
open={checkInModal} open={checkInModal}
onOk={handleCheckIn} onOk={handleCheckIn}
onCancel={() => setCheckInModal(false)} onCancel={() => {
setCheckInModal(false);
setAvailableBeds([]);
setAvailableLockers([]);
setAvailableResourcesLoading(false);
}}
okText="确认入住" okText="确认入住"
confirmLoading={saving} confirmLoading={saving}
width={500} width={500}
@@ -578,10 +640,15 @@ const OccupanciesPage: React.FC = () => {
placeholder="搜索并选择学生" placeholder="搜索并选择学生"
options={students options={students
.filter((s: any) => s.status === 'active') .filter((s: any) => s.status === 'active')
.map((s: any) => ({ .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, value: s.id,
label: `${s.name} (${s.idNumber ? maskIdNumber(s.idNumber) : s.phone ? maskPhone(s.phone) : ''})`, label: `${s.name} (${identifier})${activeOccupancy ? ` · 已入住${activeOccupancy.room?.roomNumber ? ` ${activeOccupancy.room.roomNumber}` : ''}` : ''}`,
}))} disabled: !!activeOccupancy,
};
})}
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
@@ -596,18 +663,25 @@ const OccupanciesPage: React.FC = () => {
onChange={handleRoomChange} onChange={handleRoomChange}
options={rooms.map((r) => ({ options={rooms.map((r) => ({
value: r.id, value: r.id,
label: `${r.roomNumber} (${r.building || ''}) [${r.currentCount}/${r.capacity}]`, label: roomOptionLabel(r),
disabled: r.currentCount >= r.capacity, disabled: !isRoomSelectable(r),
}))} }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="checkInDate" label="入住日期" rules={[{ required: true }]}> <Form.Item name="checkInDate" label="入住日期" rules={[{ required: true, message: '请选择入住日期' }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择入住日期" format="YYYY-MM-DD" /> <DatePicker style={{ width: '100%' }} placeholder="选择入住日期" format="YYYY-MM-DD" />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="billingStartDate" name="billingStartDate"
label="计费起始日" label="计费起始日"
dependencies={["checkInDate"]}
extra="默认与入住日期相同,可调整(如学生要求从次日开始计费)" extra="默认与入住日期相同,可调整(如学生要求从次日开始计费)"
rules={[
{ required: true, message: '请选择计费起始日' },
({ getFieldValue }) => ({
validator: dateNotBefore(getFieldValue('checkInDate'), '计费起始日不能早于入住日期'),
}),
]}
> >
<DatePicker <DatePicker
style={{ width: '100%' }} style={{ width: '100%' }}
@@ -615,9 +689,8 @@ const OccupanciesPage: React.FC = () => {
format="YYYY-MM-DD" format="YYYY-MM-DD"
/> />
</Form.Item> </Form.Item>
<Form.Item name="stayType" label="入住类型"> <Form.Item name="stayType" label="入住类型" rules={[{ required: true, message: '请选择入住类型' }]}>
<Select <Select
allowClear
options={[ options={[
{ value: 'short', label: '短租' }, { value: 'short', label: '短租' },
{ value: 'long', label: '长租' }, { value: 'long', label: '长租' },
@@ -625,15 +698,28 @@ const OccupanciesPage: React.FC = () => {
placeholder="默认为短租" placeholder="默认为短租"
/> />
</Form.Item> </Form.Item>
<Form.Item name="bedId" label="床位" rules={[{ required: true, message: '请选择床位' }]}> <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 <Select
placeholder="请先选择房间" placeholder={selectedCheckInRoomId ? '请选择床位' : '请先选择房间'}
disabled={availableBeds.length === 0} loading={availableResourcesLoading}
disabled={!selectedCheckInRoomId || availableResourcesLoading || availableBeds.length === 0}
options={availableBeds.map((b) => ({ options={availableBeds.map((b) => ({
value: b.id, value: b.id,
label: b.bedNumber, label: b.bedNumber,
}))} }))}
notFoundContent="该房间暂无可用床位" notFoundContent={selectedCheckInRoomId ? '该房间暂无可用床位' : '请先选择房间'}
/> />
</Form.Item> </Form.Item>
{availableBeds.length > 0 && ( {availableBeds.length > 0 && (
@@ -645,7 +731,8 @@ const OccupanciesPage: React.FC = () => {
<Select <Select
allowClear allowClear
placeholder="可选分配柜子" placeholder="可选分配柜子"
disabled={availableLockers.length === 0} loading={availableResourcesLoading}
disabled={!selectedCheckInRoomId || availableResourcesLoading || availableLockers.length === 0}
options={availableLockers.map((l) => ({ options={availableLockers.map((l) => ({
value: l.id, value: l.id,
label: l.lockerNumber, label: l.lockerNumber,
@@ -692,10 +779,30 @@ const OccupanciesPage: React.FC = () => {
confirmLoading={saving} confirmLoading={saving}
> >
<Form form={checkOutForm} layout="vertical"> <Form form={checkOutForm} layout="vertical">
<Form.Item name="checkOutDate" label="退宿日期" rules={[{ required: true }]}> <Form.Item
name="checkOutDate"
label="退宿日期"
rules={[
{ required: true, message: '请选择退宿日期' },
{ validator: dateNotBefore(checkOutModal?.checkInDate, '退宿日期不能早于入住日期') },
]}
>
<DatePicker style={{ width: '100%' }} placeholder="选择退宿日期" format="YYYY-MM-DD" /> <DatePicker style={{ width: '100%' }} placeholder="选择退宿日期" format="YYYY-MM-DD" />
</Form.Item> </Form.Item>
<Form.Item name="billingEndDate" label="计费截止日" extra="默认与退宿日期相同"> <Form.Item
name="billingEndDate"
label="计费截止日"
dependencies={["checkOutDate"]}
extra="默认与退宿日期相同"
rules={[
({ getFieldValue }) => ({
validator: dateNotBefore(
checkOutModal?.billingStartDate || checkOutModal?.checkInDate || getFieldValue('checkOutDate'),
'计费截止日不能早于计费起始日',
),
}),
]}
>
<DatePicker <DatePicker
style={{ width: '100%' }} style={{ width: '100%' }}
placeholder="选择计费截止日" placeholder="选择计费截止日"
@@ -727,10 +834,34 @@ const OccupanciesPage: React.FC = () => {
width={500} width={500}
> >
<Form form={batchCheckOutForm} layout="vertical"> <Form form={batchCheckOutForm} layout="vertical">
<Form.Item name="checkOutDate" label="退宿日期" rules={[{ required: true }]}> <Form.Item
name="checkOutDate"
label="退宿日期"
rules={[
{ required: true, message: '请选择退宿日期' },
{
validator: dateNotBefore(
latestSelectedCheckInDate,
'退宿日期不能早于所选记录中最晚的入住日期',
),
},
]}
>
<DatePicker style={{ width: '100%' }} placeholder="选择退宿日期" format="YYYY-MM-DD" /> <DatePicker style={{ width: '100%' }} placeholder="选择退宿日期" format="YYYY-MM-DD" />
</Form.Item> </Form.Item>
<Form.Item name="billingEndDate" label="计费截止日" extra="默认与退宿日期相同"> <Form.Item
name="billingEndDate"
label="计费截止日"
extra="默认与退宿日期相同"
rules={[
{
validator: dateNotBefore(
latestSelectedBillingStartDate,
'计费截止日不能早于所选记录中最晚的计费起始日',
),
},
]}
>
<DatePicker <DatePicker
style={{ width: '100%' }} style={{ width: '100%' }}
placeholder="选择计费截止日" placeholder="选择计费截止日"
@@ -796,24 +927,33 @@ const OccupanciesPage: React.FC = () => {
.filter((r: any) => r.id !== transferModal?.roomId) .filter((r: any) => r.id !== transferModal?.roomId)
.map((r: any) => ({ .map((r: any) => ({
value: r.id, value: r.id,
label: `${r.roomNumber} (${r.building || ''}) [${r.currentCount}/${r.capacity}]`, label: roomOptionLabel(r),
disabled: r.currentCount >= r.capacity, disabled: !isRoomSelectable(r),
}))} }))}
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="newBedId" name="newBedId"
label="目标床位" label="目标床位"
rules={[{ required: true, message: '请选择目标床位' }]} rules={[
{ required: true, message: '请选择目标床位' },
{
validator: (_: unknown, value?: number) =>
!value || transferAvailableBeds.some((bed) => bed.id === value)
? Promise.resolve()
: Promise.reject(new Error('请选择目标宿舍下的可用床位')),
},
]}
> >
<Select <Select
placeholder="请先选择目标宿舍" placeholder={selectedTransferRoomId ? '请选择目标床位' : '请先选择目标宿舍'}
disabled={transferAvailableBeds.length === 0} loading={transferResourcesLoading}
disabled={!selectedTransferRoomId || transferResourcesLoading || transferAvailableBeds.length === 0}
options={transferAvailableBeds.map((bed) => ({ options={transferAvailableBeds.map((bed) => ({
value: bed.id, value: bed.id,
label: bed.bedNumber, label: bed.bedNumber,
}))} }))}
notFoundContent="目标宿舍暂无可用床位" notFoundContent={selectedTransferRoomId ? '目标宿舍暂无可用床位' : '请先选择目标宿舍'}
/> />
</Form.Item> </Form.Item>
{transferAvailableBeds.length > 0 && ( {transferAvailableBeds.length > 0 && (
@@ -825,7 +965,8 @@ const OccupanciesPage: React.FC = () => {
<Select <Select
allowClear allowClear
placeholder="可选分配目标宿舍柜子" placeholder="可选分配目标宿舍柜子"
disabled={transferAvailableLockers.length === 0} loading={transferResourcesLoading}
disabled={!selectedTransferRoomId || transferResourcesLoading || transferAvailableLockers.length === 0}
options={transferAvailableLockers.map((locker) => ({ options={transferAvailableLockers.map((locker) => ({
value: locker.id, value: locker.id,
label: locker.lockerNumber, label: locker.lockerNumber,
@@ -833,17 +974,47 @@ const OccupanciesPage: React.FC = () => {
notFoundContent="目标宿舍暂无可用柜子" notFoundContent="目标宿舍暂无可用柜子"
/> />
</Form.Item> </Form.Item>
<Form.Item name="transferDate" label="换房日期" rules={[{ required: true }]}> <Form.Item
name="transferDate"
label="换房日期"
rules={[
{ required: true, message: '请选择换房日期' },
{ validator: dateNotBefore(transferModal?.checkInDate, '换房日期不能早于原入住日期') },
]}
>
<DatePicker style={{ width: '100%' }} placeholder="选择换房日期" format="YYYY-MM-DD" /> <DatePicker style={{ width: '100%' }} placeholder="选择换房日期" format="YYYY-MM-DD" />
</Form.Item> </Form.Item>
<Form.Item name="oldBillingEndDate" label="旧房计费截止日" extra="默认为换房当天"> <Form.Item
name="oldBillingEndDate"
label="旧房计费截止日"
dependencies={["transferDate"]}
extra="默认为换房当天"
rules={[
({ getFieldValue }) => ({
validator: dateNotBefore(
transferModal?.billingStartDate || transferModal?.checkInDate || getFieldValue('transferDate'),
'旧房计费截止日不能早于计费起始日',
),
}),
]}
>
<DatePicker <DatePicker
style={{ width: '100%' }} style={{ width: '100%' }}
placeholder="选择旧房计费截止日" placeholder="选择旧房计费截止日"
format="YYYY-MM-DD" format="YYYY-MM-DD"
/> />
</Form.Item> </Form.Item>
<Form.Item name="newBillingStartDate" label="新房计费起始日" extra="默认为换房次日"> <Form.Item
name="newBillingStartDate"
label="新房计费起始日"
dependencies={["transferDate"]}
extra="默认为换房次日"
rules={[
({ getFieldValue }) => ({
validator: dateNotBefore(getFieldValue('transferDate'), '新房计费起始日不能早于换房日期'),
}),
]}
>
<DatePicker <DatePicker
style={{ width: '100%' }} style={{ width: '100%' }}
placeholder="选择新房计费起始日" placeholder="选择新房计费起始日"

View File

@@ -1,6 +1,54 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { buildTransferPayload } from './occupancy-form'; import { buildCheckInPayload, buildTransferPayload } from './occupancy-form';
describe('occupancy check-in form', () => {
it('submits all required manual check-in fields with defaults', () => {
expect(
buildCheckInPayload({
studentId: 1,
roomId: 2,
checkInDate: dayjs('2026-07-18'),
billingStartDate: dayjs('2026-07-18'),
stayType: 'short',
collectDeposit: true,
depositAmount: 500,
bedId: 3,
notes: ' 备注 ',
}),
).toEqual({
studentId: 1,
roomId: 2,
checkInDate: '2026-07-18',
billingStartDate: '2026-07-18',
stayType: 'short',
collectDeposit: true,
depositAmount: 500,
notes: '备注',
bedId: 3,
lockerId: undefined,
});
});
it('falls back to check-in date and short stay type', () => {
expect(
buildCheckInPayload({
studentId: 1,
roomId: 2,
checkInDate: dayjs('2026-07-18'),
collectDeposit: false,
bedId: 3,
}),
).toEqual(
expect.objectContaining({
billingStartDate: '2026-07-18',
stayType: 'short',
collectDeposit: false,
depositAmount: undefined,
}),
);
});
});
describe('occupancy transfer form', () => { describe('occupancy transfer form', () => {
it('submits the target room resources with the transfer dates', () => { it('submits the target room resources with the transfer dates', () => {

View File

@@ -1,5 +1,34 @@
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
export interface CheckInFormValues {
studentId: number;
roomId: number;
checkInDate: Dayjs;
billingStartDate?: Dayjs;
stayType?: string;
collectDeposit?: boolean;
depositAmount?: number;
notes?: string;
bedId: number;
lockerId?: number;
}
export const buildCheckInPayload = (values: CheckInFormValues) => {
const collectDeposit = values.collectDeposit ?? false;
return {
studentId: values.studentId,
roomId: values.roomId,
checkInDate: values.checkInDate.format('YYYY-MM-DD'),
billingStartDate: (values.billingStartDate || values.checkInDate).format('YYYY-MM-DD'),
stayType: values.stayType || 'short',
collectDeposit,
depositAmount: collectDeposit ? values.depositAmount ?? 500 : undefined,
notes: values.notes?.trim() || undefined,
bedId: values.bedId,
lockerId: values.lockerId || undefined,
};
};
export interface TransferFormValues { export interface TransferFormValues {
newRoomId: number; newRoomId: number;
newBedId: number; newBedId: number;