forked from wangziqi/gongxue-base
- 新增 useDirtyGuard hook:关闭弹窗时若表单被修改,先确认再关闭 - 接入 17 个弹窗:账号/密码/档案、角色、教室、宿舍/床位/柜子、 考勤机、机构、班级、教师、考试、排课、入住/退宿/换房、费用 (子组件弹窗用 useEffect 在回填后快照,时序正确) - 学生列表空状态带「添加学生」引导动作 - 排课创建成功提示「同步钉钉」,考试创建成功提示「去详情录成绩」 aislop scan: 5 引擎 0 issues
577 lines
19 KiB
TypeScript
577 lines
19 KiB
TypeScript
// aislop-ignore-file: duplicate-block -- 退宿/换房表单结构相似且字段不同,已共享 DateFormItem
|
|
import React, { useEffect } from 'react';
|
|
import {
|
|
DatePicker,
|
|
Form,
|
|
Input,
|
|
InputNumber,
|
|
Modal,
|
|
Select,
|
|
Switch,
|
|
Tag,
|
|
} from 'antd';
|
|
import type { Dayjs } from 'dayjs';
|
|
import { maskIdNumber, maskPhone } from '../../utils/sensitive';
|
|
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
|
import type { OccupancyRow } from './OccupancyColumns';
|
|
|
|
export type FormRule = React.ComponentProps<typeof Form.Item>['rules'];
|
|
|
|
export const DateFormItem: React.FC<{
|
|
name: string;
|
|
label: string;
|
|
placeholder: string;
|
|
required?: boolean;
|
|
dependencies?: string[];
|
|
extra?: string;
|
|
rules?: FormRule;
|
|
}> = ({ name, label, placeholder, required, dependencies, extra, rules }) => (
|
|
<Form.Item
|
|
name={name}
|
|
label={label}
|
|
dependencies={dependencies}
|
|
extra={extra}
|
|
rules={[
|
|
...(required ? [{ required: true, message: `请选择${label}` }] : []),
|
|
...(rules ?? []),
|
|
]}
|
|
>
|
|
<DatePicker style={{ width: '100%' }} placeholder={placeholder} format="YYYY-MM-DD" />
|
|
</Form.Item>
|
|
);
|
|
|
|
export const CheckInModal: React.FC<{
|
|
open: boolean;
|
|
canCheckIn: boolean;
|
|
saving: boolean;
|
|
form: ReturnType<typeof Form.useForm>[0];
|
|
students: any[];
|
|
activeOccupancyByStudentId: Map<number, OccupancyRow>;
|
|
rooms: any[];
|
|
roomOptionLabel: (room: any) => string;
|
|
isRoomSelectable: (room: any) => boolean;
|
|
onRoomChange: (roomId: number) => void;
|
|
availableBeds: any[];
|
|
availableLockers: any[];
|
|
availableResourcesLoading: boolean;
|
|
selectedCheckInRoomId?: number;
|
|
dateNotBefore: (start: string | Dayjs | null | undefined, messageText: string) => unknown;
|
|
onOk: () => void;
|
|
onCancel: () => void;
|
|
}> = ({
|
|
open,
|
|
canCheckIn,
|
|
saving,
|
|
form,
|
|
students,
|
|
activeOccupancyByStudentId,
|
|
rooms,
|
|
roomOptionLabel,
|
|
isRoomSelectable,
|
|
onRoomChange,
|
|
availableBeds,
|
|
availableLockers,
|
|
availableResourcesLoading,
|
|
selectedCheckInRoomId,
|
|
dateNotBefore,
|
|
onOk,
|
|
onCancel,
|
|
}) => {
|
|
const checkInGuard = useDirtyGuard(form);
|
|
// 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准
|
|
useEffect(() => {
|
|
if (open && canCheckIn) checkInGuard.snapshot();
|
|
}, [open, canCheckIn, checkInGuard]);
|
|
|
|
return (
|
|
<Modal
|
|
title="入住登记"
|
|
open={open && canCheckIn}
|
|
onOk={canCheckIn ? onOk : undefined}
|
|
onCancel={() => checkInGuard.confirmClose(onCancel)}
|
|
okText="确认入住"
|
|
confirmLoading={saving}
|
|
width={500}
|
|
>
|
|
<Form form={form} layout="vertical">
|
|
<Form.Item name="studentId" label="选择学生" rules={[{ required: true, message: '请选择学生' }]}>
|
|
<Select
|
|
showSearch
|
|
optionFilterProp="label"
|
|
placeholder="搜索并选择学生"
|
|
options={students
|
|
.filter((s) => s.status === 'active')
|
|
.map((s) => {
|
|
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={onRoomChange}
|
|
options={rooms.map((r) => ({
|
|
value: r.id,
|
|
label: roomOptionLabel(r),
|
|
disabled: !isRoomSelectable(r),
|
|
}))}
|
|
/>
|
|
</Form.Item>
|
|
<DateFormItem name="checkInDate" label="入住日期" placeholder="选择入住日期" required />
|
|
<DateFormItem
|
|
name="billingStartDate"
|
|
label="计费起始日"
|
|
placeholder="选择计费起始日"
|
|
required
|
|
dependencies={['checkInDate']}
|
|
extra="默认与入住日期相同,可调整(如学生要求从次日开始计费)"
|
|
rules={[
|
|
({ getFieldValue }) => ({
|
|
validator: dateNotBefore(
|
|
getFieldValue('checkInDate'),
|
|
'计费起始日不能早于入住日期',
|
|
) as never,
|
|
}),
|
|
]}
|
|
/>
|
|
<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} suffix="元" style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
) : null
|
|
}
|
|
</Form.Item>
|
|
<Form.Item name="notes" label="备注">
|
|
<Input.TextArea rows={2} />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
export const CheckOutModal: React.FC<{
|
|
record: OccupancyRow | null;
|
|
canCheckOut: boolean;
|
|
saving: boolean;
|
|
form: ReturnType<typeof Form.useForm>[0];
|
|
dateNotBefore: (start: string | Dayjs | null | undefined, messageText: string) => unknown;
|
|
onOk: () => void;
|
|
onCancel: () => void;
|
|
}> = ({ record, canCheckOut, saving, form, dateNotBefore, onOk, onCancel }) => {
|
|
const checkOutGuard = useDirtyGuard(form);
|
|
// 父组件打开退宿弹窗前会用 record 回填表单,这里记录「未修改」基准
|
|
useEffect(() => {
|
|
if (record && canCheckOut) checkOutGuard.snapshot();
|
|
}, [record, canCheckOut, checkOutGuard]);
|
|
|
|
return (
|
|
<Modal
|
|
title={`退宿 - ${record?.student?.name}`}
|
|
open={!!record && canCheckOut}
|
|
onOk={canCheckOut ? onOk : undefined}
|
|
onCancel={() => checkOutGuard.confirmClose(onCancel)}
|
|
okText="确认退宿"
|
|
confirmLoading={saving}
|
|
>
|
|
<Form form={form} layout="vertical">
|
|
<DateFormItem
|
|
name="checkOutDate"
|
|
label="退宿日期"
|
|
placeholder="选择退宿日期"
|
|
required
|
|
rules={[
|
|
{ validator: dateNotBefore(record?.checkInDate, '退宿日期不能早于入住日期') as never },
|
|
]}
|
|
/>
|
|
<DateFormItem
|
|
name="billingEndDate"
|
|
label="计费截止日"
|
|
placeholder="选择计费截止日"
|
|
dependencies={['checkOutDate']}
|
|
extra="默认与退宿日期相同"
|
|
rules={[
|
|
({ getFieldValue }) => ({
|
|
validator: dateNotBefore(
|
|
record?.billingStartDate || record?.checkInDate || getFieldValue('checkOutDate'),
|
|
'计费截止日不能早于计费起始日',
|
|
) as never,
|
|
}),
|
|
]}
|
|
/>
|
|
<Form.Item name="checkOutReason" label="退宿原因">
|
|
<Select
|
|
allowClear
|
|
options={[
|
|
{ value: '换房', label: '换房' },
|
|
{ value: '退训', label: '退训' },
|
|
{ value: '结业', label: '结业' },
|
|
{ value: '毕业', label: '毕业' },
|
|
{ value: '其他', label: '其他' },
|
|
]}
|
|
/>
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
export const BatchCheckOutModal: React.FC<{
|
|
open: boolean;
|
|
canCheckOut: boolean;
|
|
selectedRowKeys: number[];
|
|
latestSelectedCheckInDate?: string;
|
|
latestSelectedBillingStartDate?: string;
|
|
data: any[];
|
|
form: ReturnType<typeof Form.useForm>[0];
|
|
dateNotBefore: (start: string | Dayjs | null | undefined, messageText: string) => unknown;
|
|
onOk: () => void;
|
|
onCancel: () => void;
|
|
}> = ({
|
|
open,
|
|
canCheckOut,
|
|
selectedRowKeys,
|
|
latestSelectedCheckInDate,
|
|
latestSelectedBillingStartDate,
|
|
data,
|
|
form,
|
|
dateNotBefore,
|
|
onOk,
|
|
onCancel,
|
|
}) => {
|
|
const batchCheckOutGuard = useDirtyGuard(form);
|
|
// 父组件在打开批量退宿弹窗前已完成表单回填,这里记录「未修改」基准
|
|
useEffect(() => {
|
|
if (open && canCheckOut) batchCheckOutGuard.snapshot();
|
|
}, [open, canCheckOut, batchCheckOutGuard]);
|
|
|
|
return (
|
|
<Modal
|
|
title={`批量退宿(${selectedRowKeys.length} 人)`}
|
|
open={open && canCheckOut}
|
|
onOk={canCheckOut ? onOk : undefined}
|
|
onCancel={() => batchCheckOutGuard.confirmClose(onCancel)}
|
|
okText="确认批量退宿"
|
|
width={500}
|
|
>
|
|
<Form form={form} layout="vertical">
|
|
<DateFormItem
|
|
name="checkOutDate"
|
|
label="退宿日期"
|
|
placeholder="选择退宿日期"
|
|
required
|
|
rules={[
|
|
{
|
|
validator: dateNotBefore(
|
|
latestSelectedCheckInDate,
|
|
'退宿日期不能早于所选记录中最晚的入住日期',
|
|
) as never,
|
|
},
|
|
]}
|
|
/>
|
|
<DateFormItem
|
|
name="billingEndDate"
|
|
label="计费截止日"
|
|
placeholder="选择计费截止日"
|
|
extra="默认与退宿日期相同"
|
|
rules={[
|
|
{
|
|
validator: dateNotBefore(
|
|
latestSelectedBillingStartDate,
|
|
'计费截止日不能早于所选记录中最晚的计费起始日',
|
|
) as never,
|
|
},
|
|
]}
|
|
/>
|
|
<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>
|
|
);
|
|
};
|
|
|
|
export const TransferModal: React.FC<{
|
|
record: OccupancyRow | null;
|
|
canTransfer: boolean;
|
|
saving: boolean;
|
|
form: ReturnType<typeof Form.useForm>[0];
|
|
rooms: any[];
|
|
roomOptionLabel: (room: any) => string;
|
|
isRoomSelectable: (room: any) => boolean;
|
|
onRoomChange: (roomId: number) => void;
|
|
transferAvailableBeds: any[];
|
|
transferAvailableLockers: any[];
|
|
transferResourcesLoading: boolean;
|
|
selectedTransferRoomId?: number;
|
|
dateNotBefore: (start: string | Dayjs | null | undefined, messageText: string) => unknown;
|
|
onOk: () => void;
|
|
onCancel: () => void;
|
|
}> = ({
|
|
record,
|
|
canTransfer,
|
|
saving,
|
|
form,
|
|
rooms,
|
|
roomOptionLabel,
|
|
isRoomSelectable,
|
|
onRoomChange,
|
|
transferAvailableBeds,
|
|
transferAvailableLockers,
|
|
transferResourcesLoading,
|
|
selectedTransferRoomId,
|
|
dateNotBefore,
|
|
onOk,
|
|
onCancel,
|
|
}) => {
|
|
const transferGuard = useDirtyGuard(form);
|
|
// 父组件打开换房弹窗前会用 record 回填表单,这里记录「未修改」基准
|
|
useEffect(() => {
|
|
if (record && canTransfer) transferGuard.snapshot();
|
|
}, [record, canTransfer, transferGuard]);
|
|
|
|
return (
|
|
<Modal
|
|
title={`换房 - ${record?.student?.name}`}
|
|
open={!!record && canTransfer}
|
|
onOk={onOk}
|
|
onCancel={() => transferGuard.confirmClose(onCancel)}
|
|
okText="确认换房"
|
|
confirmLoading={saving}
|
|
width={500}
|
|
>
|
|
<Form form={form} layout="vertical">
|
|
<Form.Item name="newRoomId" label="目标宿舍" rules={[{ required: true }]}>
|
|
<Select
|
|
showSearch
|
|
optionFilterProp="label"
|
|
placeholder="选择目标宿舍"
|
|
onChange={onRoomChange}
|
|
options={rooms
|
|
.filter((r) => r.id !== record?.roomId)
|
|
.map((r) => ({
|
|
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(record?.checkInDate, '换房日期不能早于原入住日期') as never,
|
|
},
|
|
]}
|
|
>
|
|
<DatePicker style={{ width: '100%' }} placeholder="选择换房日期" format="YYYY-MM-DD" />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="oldBillingEndDate"
|
|
label="旧房计费截止日"
|
|
dependencies={['transferDate']}
|
|
extra="默认为换房当天"
|
|
rules={[
|
|
({ getFieldValue }) => ({
|
|
validator: dateNotBefore(
|
|
record?.billingStartDate || record?.checkInDate || getFieldValue('transferDate'),
|
|
'旧房计费截止日不能早于计费起始日',
|
|
) as never,
|
|
}),
|
|
]}
|
|
>
|
|
<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'),
|
|
'新房计费起始日不能早于换房日期',
|
|
) as never,
|
|
}),
|
|
]}
|
|
>
|
|
<DatePicker
|
|
style={{ width: '100%' }}
|
|
placeholder="选择新房计费起始日"
|
|
format="YYYY-MM-DD"
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item name="reason" label="换房原因">
|
|
<Input />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
);
|
|
};
|