feat(admin): 入住记录新增编辑弹窗与操作列
CI / check (pull_request) Successful in 2m58s
Dependency Check / check (pull_request) Successful in 2m15s

This commit is contained in:
2026-08-11 17:28:16 +08:00
parent 353a6c72e1
commit 1f0b377a7f
7 changed files with 434 additions and 5 deletions
@@ -1,6 +1,6 @@
// aislop-ignore-file: duplicate-block -- 列渲染结构相似且字段不同,逻辑已组件化
import { Button, Popconfirm, Space, Tag } from 'antd';
import { InboxOutlined, LogoutOutlined, SwapOutlined } from '@ant-design/icons';
import { EditOutlined, InboxOutlined, LogoutOutlined, SwapOutlined } from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
@@ -12,6 +12,7 @@ export interface OccupancyRow {
billingStartDate?: string;
billingEndDate?: string;
checkOutDate?: string | null;
checkOutReason?: string;
status?: string;
student?: { id?: number; name?: string; studentNo?: string } | null;
room?: { id?: number; roomNumber?: string; building?: string } | null;
@@ -23,10 +24,12 @@ export interface OccupancyColumnContext {
readonly: boolean;
canPurge: boolean;
canDelete: boolean;
canEdit: boolean;
onPurge: (id: number, name: string) => void;
onArchive: (id: number) => Promise<unknown> | unknown;
onCheckOut: (record: OccupancyRow) => void;
onTransfer: (record: OccupancyRow) => void;
onEdit: (record: OccupancyRow) => void;
}
const buildOccupancyDataColumns = () => {
@@ -65,7 +68,17 @@ const buildOccupancyDataColumns = () => {
};
const buildOccupancyActionColumn = (ctx: OccupancyColumnContext) => {
const { readonly, canPurge, canDelete, onPurge, onArchive, onCheckOut, onTransfer } = ctx;
const {
readonly,
canPurge,
canDelete,
canEdit,
onPurge,
onArchive,
onCheckOut,
onTransfer,
onEdit,
} = ctx;
return {
title: '操作',
width: 220,
@@ -86,6 +99,16 @@ const buildOccupancyActionColumn = (ctx: OccupancyColumnContext) => {
</Space>
) : !record.checkOutDate ? (
<Space>
{canEdit ? (
<PermissionButton
permission="occupancy:edit"
size="small"
icon={<EditOutlined />}
onClick={() => onEdit(record)}
>
</PermissionButton>
) : null}
<PermissionButton
permission="occupancy:checkout"
size="small"
@@ -106,6 +129,16 @@ const buildOccupancyActionColumn = (ctx: OccupancyColumnContext) => {
) : (
<Space>
<Tag>退宿</Tag>
{canEdit ? (
<PermissionButton
permission="occupancy:edit"
size="small"
icon={<EditOutlined />}
onClick={() => onEdit(record)}
>
</PermissionButton>
) : null}
{canDelete ? (
<Popconfirm
title="确定归档此记录?"
@@ -0,0 +1,168 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { App, Form } from 'antd';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import { page } from '@vitest/browser/context';
import dayjs, { type Dayjs } from 'dayjs';
import { EditOccupancyModal } from './OccupancyModals';
import type { OccupancyRow } from './OccupancyColumns';
let container: HTMLDivElement | null = null;
let root: ReturnType<typeof createRoot> | null = null;
beforeAll(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(async () => {
if (root) await act(async () => root?.unmount());
container?.remove();
root = null;
container = null;
});
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();
};
function Harness({
record,
onForm,
}: {
record: OccupancyRow | null;
onForm?: (form: ReturnType<typeof Form.useForm>[0]) => void;
}) {
const [form] = Form.useForm();
React.useEffect(() => {
onForm?.(form);
}, [form, onForm]);
return (
<App>
<EditOccupancyModal
record={record}
canEdit
saving={false}
form={form}
dateNotBefore={dateNotBefore}
onOk={() => undefined}
onCancel={() => undefined}
/>
</App>
);
}
async function mount(record: OccupancyRow | null, onForm?: HarnessProps['onForm']) {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(<Harness record={record} onForm={onForm} />);
});
}
type HarnessProps = React.ComponentProps<typeof Harness>;
describe('EditOccupancyModal', () => {
it('shows only check-in and billing start dates for an active occupancy', async () => {
await mount({
id: 1,
studentId: 1,
roomId: 2,
checkInDate: '2026-07-10',
billingStartDate: '2026-07-10',
});
expect(document.body.textContent).toContain('编辑入住记录');
expect(document.body.textContent).toContain('入住日期');
expect(document.body.textContent).toContain('计费起始日');
expect(document.body.textContent).not.toContain('退宿日期');
expect(document.body.textContent).not.toContain('计费截止日');
expect(document.body.textContent).not.toContain('退宿原因');
// 截图快照:在住记录编辑弹窗
await new Promise((resolve) => setTimeout(resolve, 300));
await page.screenshot();
});
it('shows checkout fields for a checked-out occupancy', async () => {
await mount({
id: 1,
studentId: 1,
roomId: 2,
checkInDate: '2026-07-10',
billingStartDate: '2026-07-10',
checkOutDate: '2026-08-01',
checkOutReason: '结业',
});
expect(document.body.textContent).toContain('退宿日期');
expect(document.body.textContent).toContain('计费截止日');
expect(document.body.textContent).toContain('退宿原因');
// 截图快照:已退宿记录编辑弹窗
await new Promise((resolve) => setTimeout(resolve, 300));
await page.screenshot();
});
it('requires keeping the check-out date for a checked-out occupancy', async () => {
let form: ReturnType<typeof Form.useForm>[0] | undefined;
await mount(
{
id: 1,
studentId: 1,
roomId: 2,
checkInDate: '2026-07-10',
billingStartDate: '2026-07-10',
checkOutDate: '2026-08-01',
},
(f) => {
form = f;
},
);
await act(async () => form?.setFieldsValue({ checkOutDate: undefined }));
await expect(form?.validateFields()).rejects.toMatchObject({
errorFields: expect.arrayContaining([
expect.objectContaining({ name: ['checkOutDate'] }),
]),
});
});
it('rejects a billing start date before the check-in date', async () => {
let form: ReturnType<typeof Form.useForm>[0] | undefined;
await mount(
{
id: 1,
studentId: 1,
roomId: 2,
checkInDate: '2026-07-10',
billingStartDate: '2026-07-10',
},
(f) => {
form = f;
},
);
await act(async () =>
form?.setFieldsValue({
checkInDate: dayjs('2026-07-12'),
billingStartDate: dayjs('2026-07-11'),
}),
);
await expect(form?.validateFields()).rejects.toMatchObject({
errorFields: expect.arrayContaining([
expect.objectContaining({
errors: ['计费起始日不能早于入住日期'],
}),
]),
});
});
});
@@ -10,7 +10,7 @@ import {
Switch,
Tag,
} from 'antd';
import type { Dayjs } from 'dayjs';
import dayjs, { type Dayjs } from 'dayjs';
import { maskIdNumber, maskPhone } from '../../utils/sensitive';
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
import type { OccupancyRow } from './OccupancyColumns';
@@ -574,3 +574,107 @@ export const TransferModal: React.FC<{
</Modal>
);
};
export const EditOccupancyModal: React.FC<{
record: OccupancyRow | null;
canEdit: boolean;
saving: boolean;
form: ReturnType<typeof Form.useForm>[0];
dateNotBefore: (start: string | Dayjs | null | undefined, messageText: string) => unknown;
onOk: () => void;
onCancel: () => void;
}> = ({ record, canEdit, saving, form, dateNotBefore, onOk, onCancel }) => {
const editGuard = useDirtyGuard(form);
const checkedOut = !!record?.checkOutDate;
// 父组件打开编辑弹窗前会用 record 回填表单,这里记录「未修改」基准
useEffect(() => {
if (record && canEdit) editGuard.snapshot();
}, [record, canEdit, editGuard]);
return (
<Modal
title={`编辑入住记录${record?.student?.name ? ` - ${record.student.name}` : ''}`}
open={!!record && canEdit}
onOk={canEdit ? onOk : undefined}
onCancel={() => editGuard.confirmClose(onCancel)}
okText="保存修改"
confirmLoading={saving}
width={500}
>
<Form form={form} layout="vertical">
<DateFormItem name="checkInDate" label="入住日期" placeholder="选择入住日期" required />
<DateFormItem
name="billingStartDate"
label="计费起始日"
placeholder="选择计费起始日"
required
dependencies={['checkInDate']}
extra="默认与入住日期相同,可调整(如学生要求从次日开始计费)"
rules={[
({ getFieldValue }) => ({
validator: dateNotBefore(
getFieldValue('checkInDate'),
'计费起始日不能早于入住日期',
) as never,
}),
]}
/>
{checkedOut ? (
<>
<DateFormItem
name="checkOutDate"
label="退宿日期"
placeholder="选择退宿日期"
required
dependencies={['checkInDate']}
rules={[
({ getFieldValue }) => ({
validator: dateNotBefore(
getFieldValue('checkInDate'),
'退宿日期不能早于入住日期',
) as never,
}),
]}
/>
<DateFormItem
name="billingEndDate"
label="计费截止日"
placeholder="选择计费截止日"
dependencies={['billingStartDate', 'checkOutDate']}
extra="默认与退宿日期相同"
rules={[
({ getFieldValue }) => ({
validator: dateNotBefore(
getFieldValue('billingStartDate') || record?.billingStartDate,
'计费截止日不能早于计费起始日',
) as never,
}),
({ getFieldValue }) => ({
validator: (_: unknown, value?: Dayjs | null) => {
const checkOut = getFieldValue('checkOutDate') || record?.checkOutDate;
if (!value || !checkOut) return Promise.resolve();
return value.isAfter(dayjs(checkOut), 'day')
? Promise.reject(new Error('计费截止日不能晚于退宿日期'))
: Promise.resolve();
},
}),
]}
/>
<Form.Item name="checkOutReason" label="退宿原因">
<Select
allowClear
options={[
{ value: '换房', label: '换房' },
{ value: '退训', label: '退训' },
{ value: '结业', label: '结业' },
{ value: '毕业', label: '毕业' },
{ value: '其他', label: '其他' },
]}
/>
</Form.Item>
</>
) : null}
</Form>
</Modal>
);
};
+46 -1
View File
@@ -5,13 +5,14 @@ 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 { buildCheckInPayload, buildEditPayload, buildTransferPayload } from './occupancy-form';
import { buildOccupancyColumns } from './OccupancyColumns';
import type { OccupancyRow } from './OccupancyColumns';
import {
BatchCheckOutModal,
CheckInModal,
CheckOutModal,
EditOccupancyModal,
TransferModal,
} from './OccupancyModals';
import { usePermission } from '../../hooks/usePermission';
@@ -52,12 +53,14 @@ const OccupanciesPage: React.FC = () => {
const navigate = useNavigate();
const { hasPermission, permissionsReady } = usePermission();
const canCheckIn = permissionsReady && hasPermission('occupancy:checkin');
const canEdit = permissionsReady && hasPermission('occupancy:edit');
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<any>(null);
const [editModal, setEditModal] = useState<any>(null);
const [transferModal, setTransferModal] = useState<any>(null);
// 入住成功后的「下一步」引导提示
const [nextStepHint, setNextStepHint] = useState<'billing' | null>(null);
@@ -125,6 +128,7 @@ const OccupanciesPage: React.FC = () => {
const {
checkInMutation,
checkOutMutation,
updateMutation,
transferMutation,
batchCheckOutMutation,
batchDeleteMutation,
@@ -138,6 +142,7 @@ const OccupanciesPage: React.FC = () => {
const [batchLoading, setBatchLoading] = useState(false);
const [checkInForm] = Form.useForm();
const [checkOutForm] = Form.useForm();
const [editForm] = Form.useForm();
const [transferForm] = Form.useForm();
const [batchCheckOutForm] = Form.useForm();
const [availableBeds, setAvailableBeds] = useState<any[]>([]);
@@ -307,6 +312,24 @@ const OccupanciesPage: React.FC = () => {
}
};
const handleEdit = async () => {
const values = await editForm.validateFields();
setSaving(true);
try {
await updateMutation.mutateAsync({
id: editModal.id,
payload: buildEditPayload(values),
});
message.success('编辑成功');
setEditModal(null);
editForm.resetFields();
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const handleTransfer = async () => {
const values = await transferForm.validateFields();
setSaving(true);
@@ -418,6 +441,7 @@ const OccupanciesPage: React.FC = () => {
readonly: viewPolicy.readonly,
canPurge,
canDelete,
canEdit,
onPurge: handlePurge,
onArchive: (id) => archiveMutation.mutateAsync(id),
onCheckOut: (record) => {
@@ -431,14 +455,26 @@ const OccupanciesPage: React.FC = () => {
setTransferModal(record);
transferForm.setFieldsValue({ transferDate: dayjs() });
},
onEdit: (record) => {
editForm.setFieldsValue({
checkInDate: dayjs(record.checkInDate),
billingStartDate: dayjs(record.billingStartDate || record.checkInDate),
checkOutDate: record.checkOutDate ? dayjs(record.checkOutDate) : undefined,
billingEndDate: record.billingEndDate ? dayjs(record.billingEndDate) : undefined,
checkOutReason: record.checkOutReason || undefined,
});
setEditModal(record);
},
}),
[
viewPolicy.readonly,
canPurge,
canDelete,
canEdit,
handlePurge,
archiveMutation,
checkOutForm,
editForm,
transferForm,
],
);
@@ -602,6 +638,15 @@ const OccupanciesPage: React.FC = () => {
onOk={handleCheckOut}
onCancel={() => setCheckOutModal(null)}
/>
<EditOccupancyModal
record={editModal}
canEdit={canEdit}
saving={saving}
form={editForm}
dateNotBefore={dateNotBefore}
onOk={handleEdit}
onCancel={() => setEditModal(null)}
/>
<BatchCheckOutModal
open={batchCheckOutModal}
canCheckOut={canCheckOut}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import dayjs from 'dayjs';
import { buildCheckInPayload, buildTransferPayload } from './occupancy-form';
import { buildCheckInPayload, buildEditPayload, buildTransferPayload } from './occupancy-form';
describe('occupancy check-in form', () => {
it('submits all required manual check-in fields with defaults', () => {
@@ -73,3 +73,51 @@ describe('occupancy transfer form', () => {
});
});
});
describe('occupancy edit form', () => {
it('submits only the two date fields for an active occupancy', () => {
expect(
buildEditPayload({
checkInDate: dayjs('2026-07-18'),
billingStartDate: dayjs('2026-07-18'),
}),
).toEqual({
checkInDate: '2026-07-18',
billingStartDate: '2026-07-18',
});
});
it('submits checkout fields for a checked-out occupancy and trims the reason', () => {
expect(
buildEditPayload({
checkInDate: dayjs('2026-07-18'),
billingStartDate: dayjs('2026-07-18'),
checkOutDate: dayjs('2026-08-01'),
billingEndDate: dayjs('2026-07-31'),
checkOutReason: ' 结业 ',
}),
).toEqual({
checkInDate: '2026-07-18',
billingStartDate: '2026-07-18',
checkOutDate: '2026-08-01',
billingEndDate: '2026-07-31',
checkOutReason: '结业',
});
});
it('leaves optional checkout fields undefined when cleared', () => {
expect(
buildEditPayload({
checkInDate: dayjs('2026-07-18'),
billingStartDate: dayjs('2026-07-18'),
checkOutDate: dayjs('2026-08-01'),
}),
).toEqual({
checkInDate: '2026-07-18',
billingStartDate: '2026-07-18',
checkOutDate: '2026-08-01',
billingEndDate: undefined,
checkOutReason: undefined,
});
});
});
@@ -48,3 +48,28 @@ export const buildTransferPayload = (values: TransferFormValues) => ({
newBillingStartDate: values.newBillingStartDate?.format('YYYY-MM-DD'),
reason: values.reason,
});
export interface EditFormValues {
checkInDate: Dayjs;
billingStartDate: Dayjs;
checkOutDate?: Dayjs;
billingEndDate?: Dayjs;
checkOutReason?: string;
}
/**
* 编辑入住记录:在住记录只提交入住日期/计费起始日;
* 已退宿记录额外附带退宿日期、计费截止日与退宿原因(reason 去空格)。
*/
export const buildEditPayload = (values: EditFormValues) => {
const payload: Record<string, string | undefined> = {
checkInDate: values.checkInDate.format('YYYY-MM-DD'),
billingStartDate: values.billingStartDate.format('YYYY-MM-DD'),
};
if (values.checkOutDate) {
payload.checkOutDate = values.checkOutDate.format('YYYY-MM-DD');
payload.billingEndDate = values.billingEndDate?.format('YYYY-MM-DD');
payload.checkOutReason = values.checkOutReason?.trim() || undefined;
}
return payload;
};
@@ -12,6 +12,11 @@ export function useOccupancyMutations() {
api.put(`/occupancies/${id}/check-out`, payload),
{ invalidate: invalidateOccupancies },
);
const updateMutation = useApiMutation(
async ({ id, payload }: { id: number; payload: Record<string, unknown> }) =>
api.put(`/occupancies/${id}`, payload),
{ invalidate: invalidateOccupancies },
);
const transferMutation = useApiMutation(
async ({ id, payload }: { id: number; payload: unknown }) =>
api.put(`/occupancies/${id}/transfer`, payload),
@@ -51,6 +56,7 @@ export function useOccupancyMutations() {
return {
checkInMutation,
checkOutMutation,
updateMutation,
transferMutation,
batchCheckOutMutation,
batchDeleteMutation,