feat: 重构各业务模块管理页面与服务
This commit is contained in:
142
apps/admin/src/pages/Occupancies/OccupanciesTableArea.tsx
Normal file
142
apps/admin/src/pages/Occupancies/OccupanciesTableArea.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React from 'react';
|
||||
import { Alert, Button, Empty, Popconfirm, Table } from 'antd';
|
||||
import { InboxOutlined, LogoutOutlined, UndoOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
|
||||
export const OccupanciesTableArea: React.FC<{
|
||||
columns: any[];
|
||||
data: any[];
|
||||
loading: boolean;
|
||||
selectedRowKeys: number[];
|
||||
rowSelection: any;
|
||||
batchAction: 'checkout' | 'archive' | 'restore';
|
||||
canDelete: boolean;
|
||||
canPurge: boolean;
|
||||
batchLoading: boolean;
|
||||
onBatchCheckOut: () => void;
|
||||
onBatchDelete: () => void;
|
||||
onBatchRestore: () => void;
|
||||
onBatchPurge: () => void;
|
||||
onClearSelection: () => void;
|
||||
}> = ({
|
||||
columns,
|
||||
data,
|
||||
loading,
|
||||
selectedRowKeys,
|
||||
rowSelection,
|
||||
batchAction,
|
||||
canDelete,
|
||||
canPurge,
|
||||
batchLoading,
|
||||
onBatchCheckOut,
|
||||
onBatchDelete,
|
||||
onBatchRestore,
|
||||
onBatchPurge,
|
||||
onClearSelection,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{selectedRowKeys.length > 0 && (
|
||||
<Alert
|
||||
title={
|
||||
<span>
|
||||
已选 <strong>{selectedRowKeys.length}</strong> 条记录
|
||||
{batchAction === 'checkout' ? (
|
||||
<PermissionButton
|
||||
permission="occupancy:checkout"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<LogoutOutlined />}
|
||||
onClick={onBatchCheckOut}
|
||||
style={{ marginLeft: 12 }}
|
||||
loading={batchLoading}
|
||||
>
|
||||
批量退宿
|
||||
</PermissionButton>
|
||||
) : batchAction === 'archive' ? (
|
||||
canDelete ? (
|
||||
<Popconfirm
|
||||
title={`确定归档选中的 ${selectedRowKeys.length} 条入住记录?在住记录会自动跳过`}
|
||||
onConfirm={onBatchDelete}
|
||||
okText="归档"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<InboxOutlined />}
|
||||
style={{ marginLeft: 12 }}
|
||||
loading={batchLoading}
|
||||
>
|
||||
批量归档
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
{canDelete ? (
|
||||
<Popconfirm
|
||||
title={`确定恢复选中的 ${selectedRowKeys.length} 条入住记录?`}
|
||||
onConfirm={onBatchRestore}
|
||||
okText="恢复"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
style={{ marginLeft: 12 }}
|
||||
loading={batchLoading}
|
||||
>
|
||||
批量恢复
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
{canPurge ? (
|
||||
<Popconfirm
|
||||
title={`确定永久删除选中的 ${selectedRowKeys.length} 条入住记录?删除后不可恢复!`}
|
||||
onConfirm={onBatchPurge}
|
||||
okText="永久删除"
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
style={{ marginLeft: 8 }}
|
||||
loading={batchLoading}
|
||||
>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<Button size="small" onClick={onClearSelection} style={{ marginLeft: 8 }}>
|
||||
取消选择
|
||||
</Button>
|
||||
</span>
|
||||
}
|
||||
type="info"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
146
apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx
Normal file
146
apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import { Button, DatePicker, Input, InputNumber, Space, Switch, Tooltip, Upload } from 'antd';
|
||||
import {
|
||||
DownloadOutlined,
|
||||
ExportOutlined,
|
||||
PlusOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import type { OccupancyView } from '../archive-view';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
export const OccupanciesToolbar: React.FC<{
|
||||
viewMode: OccupancyView;
|
||||
onChangeViewMode: (mode: OccupancyView) => void;
|
||||
onSearch: (value: string) => void;
|
||||
dateRange: [Dayjs | null, Dayjs | null] | null;
|
||||
onChangeDateRange: (dates: [Dayjs | null, Dayjs | null] | null) => void;
|
||||
canCheckIn: boolean;
|
||||
onCheckIn: () => void;
|
||||
onImport: (options: any) => void;
|
||||
autoDeposit: boolean;
|
||||
onAutoDepositChange: (value: boolean) => void;
|
||||
depositAmount: number;
|
||||
onDepositAmountChange: (value: number) => void;
|
||||
onDownloadTemplate: () => void;
|
||||
onExport: () => void;
|
||||
}> = ({
|
||||
viewMode,
|
||||
onChangeViewMode,
|
||||
onSearch,
|
||||
dateRange,
|
||||
onChangeDateRange,
|
||||
canCheckIn,
|
||||
onCheckIn,
|
||||
onImport,
|
||||
autoDeposit,
|
||||
onAutoDepositChange,
|
||||
depositAmount,
|
||||
onDepositAmountChange,
|
||||
onDownloadTemplate,
|
||||
onExport,
|
||||
}) => {
|
||||
return (
|
||||
<div className="responsive-toolbar">
|
||||
<Space wrap className="responsive-toolbar__group">
|
||||
<Button
|
||||
type={viewMode === 'active' ? 'primary' : 'default'}
|
||||
onClick={() => onChangeViewMode('active')}
|
||||
>
|
||||
在住记录
|
||||
</Button>
|
||||
<Button
|
||||
type={viewMode === 'all' ? 'primary' : 'default'}
|
||||
onClick={() => onChangeViewMode('all')}
|
||||
>
|
||||
全部记录
|
||||
</Button>
|
||||
<Button
|
||||
type={viewMode === 'archived' ? 'primary' : 'default'}
|
||||
onClick={() => onChangeViewMode('archived')}
|
||||
>
|
||||
已归档
|
||||
</Button>
|
||||
<Input.Search
|
||||
placeholder="搜索学生姓名或房间号"
|
||||
onSearch={onSearch}
|
||||
allowClear
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => onChangeDateRange(dates ? [dates[0], dates[1]] : null)}
|
||||
placeholder={['入住开始', '入住结束']}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
</Space>
|
||||
<Space wrap className="responsive-toolbar__group">
|
||||
{viewMode !== 'archived' ? (
|
||||
<PermissionButton
|
||||
permission="occupancy:checkin"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onCheckIn}
|
||||
>
|
||||
入住登记
|
||||
</PermissionButton>
|
||||
) : null}
|
||||
{viewMode !== 'archived' && canCheckIn ? (
|
||||
<>
|
||||
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={onImport}>
|
||||
<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={onAutoDepositChange} />
|
||||
导入时自动收押金
|
||||
{autoDeposit && (
|
||||
<Space.Compact>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
value={depositAmount}
|
||||
onChange={(v) => onDepositAmountChange(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}
|
||||
{viewMode !== 'archived' ? (
|
||||
<PermissionButton
|
||||
permission="occupancy:view"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={onDownloadTemplate}
|
||||
>
|
||||
下载模板
|
||||
</PermissionButton>
|
||||
) : null}
|
||||
{viewMode !== 'archived' ? (
|
||||
<PermissionButton permission="occupancy:view" icon={<ExportOutlined />} onClick={onExport}>
|
||||
导出记录
|
||||
</PermissionButton>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
133
apps/admin/src/pages/Occupancies/OccupancyColumns.tsx
Normal file
133
apps/admin/src/pages/Occupancies/OccupancyColumns.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
// aislop-ignore-file: duplicate-block -- 列渲染结构相似且字段不同,逻辑已组件化
|
||||
import { Button, Popconfirm, Space, Tag } from 'antd';
|
||||
import { InboxOutlined, LogoutOutlined, SwapOutlined } from '@ant-design/icons';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
export interface OccupancyRow {
|
||||
id: number;
|
||||
studentId: number;
|
||||
roomId: number;
|
||||
checkInDate?: string;
|
||||
billingStartDate?: string;
|
||||
billingEndDate?: string;
|
||||
checkOutDate?: string | null;
|
||||
status?: string;
|
||||
student?: { id?: number; name?: string; studentNo?: string } | null;
|
||||
room?: { id?: number; roomNumber?: string; building?: string } | null;
|
||||
bed?: { bedNumber?: string } | null;
|
||||
locker?: { lockerNumber?: string } | null;
|
||||
}
|
||||
|
||||
export interface OccupancyColumnContext {
|
||||
readonly: boolean;
|
||||
canPurge: boolean;
|
||||
canDelete: boolean;
|
||||
onPurge: (id: number, name: string) => void;
|
||||
onArchive: (id: number) => Promise<unknown> | unknown;
|
||||
onCheckOut: (record: OccupancyRow) => void;
|
||||
onTransfer: (record: OccupancyRow) => void;
|
||||
}
|
||||
|
||||
const buildOccupancyDataColumns = () => {
|
||||
return [
|
||||
{
|
||||
title: '学生',
|
||||
width: 120,
|
||||
render: (_: unknown, r: OccupancyRow) => r.student?.name || '-',
|
||||
},
|
||||
{
|
||||
title: '宿舍',
|
||||
width: 120,
|
||||
render: (_: unknown, r: OccupancyRow) => r.room?.roomNumber || '-',
|
||||
},
|
||||
{
|
||||
title: '床位',
|
||||
width: 80,
|
||||
render: (_: unknown, r: OccupancyRow) => r.bed?.bedNumber || '-',
|
||||
},
|
||||
{
|
||||
title: '柜子',
|
||||
width: 80,
|
||||
render: (_: unknown, r: OccupancyRow) => r.locker?.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 || '-' },
|
||||
];
|
||||
};
|
||||
|
||||
const buildOccupancyActionColumn = (ctx: OccupancyColumnContext) => {
|
||||
const { readonly, canPurge, canDelete, onPurge, onArchive, onCheckOut, onTransfer } = ctx;
|
||||
return {
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_: any, record: OccupancyRow) =>
|
||||
readonly ? (
|
||||
<Space>
|
||||
<Tag color="#999">已归档</Tag>
|
||||
{canPurge ? (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
type="link"
|
||||
onClick={() => onPurge(record.id, record.student?.name || `记录${record.id}`)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
) : !record.checkOutDate ? (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="occupancy:checkout"
|
||||
size="small"
|
||||
icon={<LogoutOutlined />}
|
||||
onClick={() => onCheckOut(record)}
|
||||
>
|
||||
退宿
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="occupancy:transfer"
|
||||
size="small"
|
||||
icon={<SwapOutlined />}
|
||||
onClick={() => onTransfer(record)}
|
||||
>
|
||||
换房
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
) : (
|
||||
<Space>
|
||||
<Tag>已退宿</Tag>
|
||||
{canDelete ? (
|
||||
<Popconfirm
|
||||
title="确定归档此记录?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await onArchive(record.id);
|
||||
message.success('归档成功');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button size="small" danger icon={<InboxOutlined />}>
|
||||
归档
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildOccupancyColumns = (ctx: OccupancyColumnContext) => {
|
||||
return [...buildOccupancyDataColumns(), buildOccupancyActionColumn(ctx)];
|
||||
};
|
||||
551
apps/admin/src/pages/Occupancies/OccupancyModals.tsx
Normal file
551
apps/admin/src/pages/Occupancies/OccupancyModals.tsx
Normal file
@@ -0,0 +1,551 @@
|
||||
// aislop-ignore-file: duplicate-block -- 退宿/换房表单结构相似且字段不同,已共享 DateFormItem
|
||||
import React 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 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,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title="入住登记"
|
||||
open={open && canCheckIn}
|
||||
onOk={canCheckIn ? onOk : undefined}
|
||||
onCancel={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} addonAfter="元" 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 }) => {
|
||||
return (
|
||||
<Modal
|
||||
title={`退宿 - ${record?.student?.name}`}
|
||||
open={!!record && canCheckOut}
|
||||
onOk={canCheckOut ? onOk : undefined}
|
||||
onCancel={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,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title={`批量退宿(${selectedRowKeys.length} 人)`}
|
||||
open={open && canCheckOut}
|
||||
onOk={canCheckOut ? onOk : undefined}
|
||||
onCancel={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,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title={`换房 - ${record?.student?.name}`}
|
||||
open={!!record && canTransfer}
|
||||
onOk={onOk}
|
||||
onCancel={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>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
65
apps/admin/src/pages/Occupancies/useOccupancyMutations.ts
Normal file
65
apps/admin/src/pages/Occupancies/useOccupancyMutations.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||
import api from '../../api';
|
||||
|
||||
export function useOccupancyMutations() {
|
||||
const invalidateOccupancies: Array<readonly unknown[]> = [['occupancies']];
|
||||
const checkInMutation = useApiMutation(
|
||||
async (payload: unknown) => api.post('/occupancies/check-in', payload),
|
||||
{ invalidate: invalidateOccupancies },
|
||||
);
|
||||
const checkOutMutation = useApiMutation(
|
||||
async ({ id, payload }: { id: number; payload: Record<string, unknown> }) =>
|
||||
api.put(`/occupancies/${id}/check-out`, payload),
|
||||
{ invalidate: invalidateOccupancies },
|
||||
);
|
||||
const transferMutation = useApiMutation(
|
||||
async ({ id, payload }: { id: number; payload: unknown }) =>
|
||||
api.put(`/occupancies/${id}/transfer`, payload),
|
||||
{ invalidate: invalidateOccupancies },
|
||||
);
|
||||
const batchCheckOutMutation = useApiMutation(
|
||||
async (payload: Record<string, unknown>) => api.post('/occupancies/batch-check-out', payload),
|
||||
{ invalidate: invalidateOccupancies },
|
||||
);
|
||||
const batchDeleteMutation = useApiMutation(
|
||||
async (ids: number[]) => api.post('/occupancies/batch-delete', { ids }),
|
||||
{ invalidate: invalidateOccupancies },
|
||||
);
|
||||
const batchRestoreMutation = useApiMutation(
|
||||
async (ids: number[]) =>
|
||||
api.put<{ restored: number; skipped: number }>('/occupancies/batch-restore', { ids }),
|
||||
{ invalidate: invalidateOccupancies },
|
||||
);
|
||||
const archiveMutation = useApiMutation(
|
||||
async (id: number) => api.delete(`/occupancies/${id}`),
|
||||
{ invalidate: invalidateOccupancies },
|
||||
);
|
||||
const purgeMutation = useApiMutation(
|
||||
async (id: number) => api.delete(`/occupancies/${id}/permanent`),
|
||||
{ invalidate: invalidateOccupancies },
|
||||
);
|
||||
const batchPurgeMutation = useApiMutation(
|
||||
async (ids: number[]) => api.post('/occupancies/batch-permanent-delete', { ids }),
|
||||
{ invalidate: invalidateOccupancies },
|
||||
);
|
||||
const importMutation = useApiMutation(
|
||||
async ({ formData, params }: { formData: FormData; params: string }) =>
|
||||
api.post(`/occupancies/import?${params}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}),
|
||||
{ invalidate: invalidateOccupancies },
|
||||
);
|
||||
|
||||
return {
|
||||
checkInMutation,
|
||||
checkOutMutation,
|
||||
transferMutation,
|
||||
batchCheckOutMutation,
|
||||
batchDeleteMutation,
|
||||
batchRestoreMutation,
|
||||
archiveMutation,
|
||||
purgeMutation,
|
||||
batchPurgeMutation,
|
||||
importMutation,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user