501 lines
16 KiB
TypeScript
501 lines
16 KiB
TypeScript
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
|
|
import React, { useMemo, useState } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { App, Alert, Button, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
|
|
import { BankOutlined, InboxOutlined, PlusOutlined, UndoOutlined } from '@ant-design/icons';
|
|
import api from '../../api';
|
|
import PermissionButton from '../../components/PermissionButton';
|
|
import EditableCell from '../../components/EditableCell';
|
|
import { message } from '../../ui/app-message';
|
|
import { RefreshButton } from '../../components/RefreshButton';
|
|
import { usePermission } from '../../hooks/usePermission';
|
|
import { useApiMutation } from '../../hooks/useApiMutation';
|
|
import { validateResponse } from '../../utils/validate';
|
|
import { organizationsSchema } from '../../api/schemas';
|
|
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
|
import { QueryEmpty, QueryErrorState } from '../../components/QueryState';
|
|
|
|
const PRESET_COLORS = [
|
|
'#ff7875',
|
|
'#ffa940',
|
|
'#ffc53d',
|
|
'#73d13d',
|
|
'#36cfc9',
|
|
'#40a9ff',
|
|
'#597ef7',
|
|
'#9254de',
|
|
'#f759ab',
|
|
'#8c8c8c',
|
|
];
|
|
|
|
interface OrganizationItem {
|
|
id: number;
|
|
code: string;
|
|
name: string;
|
|
isHost: boolean;
|
|
contactName?: string;
|
|
phone?: string;
|
|
color?: string;
|
|
notes?: string;
|
|
status: 'active' | 'archived';
|
|
}
|
|
|
|
const ORGANIZATION_FIELDS = {
|
|
name: 'name',
|
|
code: 'code',
|
|
contactName: 'contactName',
|
|
phone: 'phone',
|
|
notes: 'notes',
|
|
} as const;
|
|
|
|
const OrganizationsPage: React.FC = () => {
|
|
const { modal } = App.useApp();
|
|
const { hasPermission } = usePermission();
|
|
const canPurgeOrganization = hasPermission('organization:purge');
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editing, setEditing] = useState<OrganizationItem | null>(null);
|
|
const [form] = Form.useForm();
|
|
const orgGuard = useDirtyGuard(form);
|
|
const [saving, setSaving] = useState(false);
|
|
const [searchText, setSearchText] = useState('');
|
|
const [filterStatus, setFilterStatus] = useState<string>();
|
|
|
|
const { data = [], isLoading, isFetching, isError, refetch } = useQuery({
|
|
queryKey: ['organizations'],
|
|
queryFn: async () =>
|
|
validateResponse<OrganizationItem[]>(
|
|
organizationsSchema,
|
|
await api.get<OrganizationItem[]>('/organizations', {
|
|
params: { includeArchived: true },
|
|
}),
|
|
),
|
|
});
|
|
const loading = isLoading || isFetching;
|
|
|
|
const saveMutation = useApiMutation(
|
|
async (values: { name: string; code: string; color?: string; notes?: string }) =>
|
|
editing
|
|
? api.put(`/organizations/${editing.id}`, values)
|
|
: api.post('/organizations', values),
|
|
{ invalidate: [['organizations']] },
|
|
);
|
|
const saveCellMutation = useApiMutation(
|
|
async ({ record, field, value }: { record: OrganizationItem; field: string; value: unknown }) =>
|
|
api.put(`/organizations/${record.id}`, { [field]: value }),
|
|
{ invalidate: [['organizations']] },
|
|
);
|
|
const statusMutation = useApiMutation(
|
|
async ({ id, status }: { id: number; status: 'active' | 'archived' }) =>
|
|
status === 'active'
|
|
? api.put(`/organizations/${id}`, { status: 'active' })
|
|
: api.delete(`/organizations/${id}`),
|
|
{ invalidate: [['organizations']] },
|
|
);
|
|
const purgeMutation = useApiMutation(
|
|
async (id: number) => api.delete(`/organizations/${id}/permanent`),
|
|
{ invalidate: [['organizations']] },
|
|
);
|
|
|
|
const filteredData = useMemo(() => {
|
|
const keyword = searchText.trim().toLowerCase();
|
|
return data.filter((item) => {
|
|
const matchesKeyword =
|
|
!keyword ||
|
|
item.name.toLowerCase().includes(keyword) ||
|
|
item.code.toLowerCase().includes(keyword) ||
|
|
item.contactName?.toLowerCase().includes(keyword);
|
|
return matchesKeyword && (!filterStatus || item.status === filterStatus);
|
|
});
|
|
}, [data, searchText, filterStatus]);
|
|
|
|
const handlePurge = (record: OrganizationItem) => {
|
|
modal.confirm({
|
|
title: `永久删除机构「${record.name}」?`,
|
|
content: '删除后不可恢复,存在学生归属、入住或租赁关联时将无法删除。确定继续?',
|
|
okText: '永久删除',
|
|
okButtonProps: { danger: true },
|
|
cancelText: '取消',
|
|
onOk: async () => {
|
|
try {
|
|
await purgeMutation.mutateAsync(record.id);
|
|
message.success('已永久删除(不可恢复)');
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
}
|
|
},
|
|
});
|
|
};
|
|
|
|
const openEditor = (record?: OrganizationItem) => {
|
|
setEditing(record ?? null);
|
|
form.resetFields();
|
|
if (record) form.setFieldsValue(record);
|
|
else form.setFieldsValue({ color: PRESET_COLORS[data.length % PRESET_COLORS.length] });
|
|
orgGuard.snapshot();
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
const values = await form.validateFields();
|
|
setSaving(true);
|
|
try {
|
|
await saveMutation.mutateAsync(values);
|
|
message.success(editing ? '机构已更新' : '机构已创建');
|
|
setModalOpen(false);
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const saveCell = async (record: OrganizationItem, field: string, value: unknown) => {
|
|
try {
|
|
await saveCellMutation.mutateAsync({ record, field, value });
|
|
message.success('已保存');
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
}
|
|
};
|
|
|
|
const EditableOrganizationCell = <R extends { id: number; status?: string }>({
|
|
value,
|
|
field,
|
|
record,
|
|
editor,
|
|
required,
|
|
onSave,
|
|
children,
|
|
}: {
|
|
value: unknown;
|
|
field: string;
|
|
record: R;
|
|
editor?: React.ComponentProps<typeof EditableCell>['editor'];
|
|
required?: boolean;
|
|
onSave: (record: R, field: string, value: unknown) => Promise<void> | void;
|
|
children?: React.ReactNode;
|
|
}) => (
|
|
<EditableCell
|
|
value={value}
|
|
editor={editor}
|
|
required={required}
|
|
permission="organization:edit"
|
|
disabled={record.status === 'archived'}
|
|
onSave={async (next) => {
|
|
await onSave(record, field, next);
|
|
}}
|
|
>
|
|
{children ?? String(value ?? '-')}
|
|
</EditableCell>
|
|
);
|
|
|
|
const columns = [
|
|
{
|
|
title: '机构',
|
|
dataIndex: 'name',
|
|
width: 220,
|
|
render: (name: string, record: OrganizationItem) => (
|
|
<EditableOrganizationCell value={name} field={ORGANIZATION_FIELDS.name} record={record} required onSave={saveCell}>
|
|
<Space>
|
|
<span
|
|
style={{
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: '50%',
|
|
background: record.color || '#8c8c8c',
|
|
}}
|
|
/>
|
|
<strong>{name}</strong>
|
|
{record.isHost ? (
|
|
<Tag color="blue" icon={<BankOutlined />}>
|
|
本机构
|
|
</Tag>
|
|
) : (
|
|
<Tag>外部机构</Tag>
|
|
)}
|
|
</Space>
|
|
</EditableOrganizationCell>
|
|
),
|
|
},
|
|
|
|
{
|
|
title: '机构编码',
|
|
dataIndex: 'code',
|
|
width: 130,
|
|
render: (value: string, record: OrganizationItem) => (
|
|
<EditableOrganizationCell value={value} field={ORGANIZATION_FIELDS.code} record={record} required onSave={saveCell}>
|
|
<code>{value}</code>
|
|
</EditableOrganizationCell>
|
|
),
|
|
},
|
|
|
|
{
|
|
title: '联系人',
|
|
dataIndex: 'contactName',
|
|
width: 120,
|
|
render: (value: string | undefined, record: OrganizationItem) => (
|
|
<EditableOrganizationCell value={value} field={ORGANIZATION_FIELDS.contactName} record={record} onSave={saveCell}>
|
|
{value || '-'}
|
|
</EditableOrganizationCell>
|
|
),
|
|
},
|
|
|
|
{
|
|
title: '电话',
|
|
dataIndex: 'phone',
|
|
width: 140,
|
|
render: (value: string | undefined, record: OrganizationItem) => (
|
|
<EditableOrganizationCell value={value} field={ORGANIZATION_FIELDS.phone} record={record} onSave={saveCell}>
|
|
{value || '-'}
|
|
</EditableOrganizationCell>
|
|
),
|
|
},
|
|
|
|
{
|
|
title: '备注',
|
|
dataIndex: 'notes',
|
|
ellipsis: true,
|
|
render: (value: string | undefined, record: OrganizationItem) => (
|
|
<EditableOrganizationCell value={value} field={ORGANIZATION_FIELDS.notes} record={record} editor="textarea" onSave={saveCell}>
|
|
{value || '-'}
|
|
</EditableOrganizationCell>
|
|
),
|
|
},
|
|
|
|
{
|
|
title: '状态',
|
|
dataIndex: 'status',
|
|
width: 90,
|
|
render: (status: string) => (
|
|
<Tag color={status === 'active' ? 'green' : 'default'}>
|
|
{status === 'active' ? '正常' : '已归档'}
|
|
</Tag>
|
|
),
|
|
},
|
|
|
|
{
|
|
title: '操作',
|
|
width: 160,
|
|
render: (_: unknown, record: OrganizationItem) => (
|
|
<Space>
|
|
{record.status === 'archived' ? (
|
|
<>
|
|
<Popconfirm
|
|
title="确定恢复此机构?"
|
|
onConfirm={async () => {
|
|
try {
|
|
await statusMutation.mutateAsync({ id: record.id, status: 'active' });
|
|
message.success('机构已恢复');
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
}
|
|
}}
|
|
>
|
|
<PermissionButton
|
|
permission="organization:edit"
|
|
size="small"
|
|
type="link"
|
|
icon={<UndoOutlined />}
|
|
>
|
|
恢复
|
|
</PermissionButton>
|
|
</Popconfirm>
|
|
{canPurgeOrganization && !record.isHost ? (
|
|
<Button size="small" danger type="link" onClick={() => handlePurge(record)}>
|
|
删除
|
|
</Button>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<>
|
|
<PermissionButton
|
|
permission="organization:edit"
|
|
size="small"
|
|
onClick={() => openEditor(record)}
|
|
>
|
|
编辑
|
|
</PermissionButton>
|
|
{!record.isHost ? (
|
|
<Popconfirm
|
|
title="归档后仍保留历史学生、入住和租赁记录"
|
|
onConfirm={async () => {
|
|
try {
|
|
await statusMutation.mutateAsync({ id: record.id, status: 'archived' });
|
|
message.success('机构已归档');
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
}
|
|
}}
|
|
>
|
|
<PermissionButton
|
|
permission="organization:delete"
|
|
size="small"
|
|
icon={<InboxOutlined />}
|
|
>
|
|
归档
|
|
</PermissionButton>
|
|
</Popconfirm>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</Space>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
title="统一机构管理"
|
|
description="本机构与外部机构使用同一套资料。学生明确归属机构;教室租赁则单独记录出租机构和承租机构。"
|
|
style={{ marginBottom: 16 }}
|
|
/>
|
|
<div
|
|
style={{
|
|
marginBottom: 16,
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
flexWrap: 'wrap',
|
|
gap: 8,
|
|
}}
|
|
>
|
|
<Space wrap>
|
|
<Input.Search
|
|
placeholder="搜索名称、编码或联系人"
|
|
allowClear
|
|
style={{ width: 260 }}
|
|
onChange={(event) => setSearchText(event.target.value)}
|
|
/>
|
|
<Select
|
|
placeholder="全部状态"
|
|
allowClear
|
|
style={{ width: 120 }}
|
|
value={filterStatus}
|
|
onChange={setFilterStatus}
|
|
options={[
|
|
{ value: 'active', label: '正常' },
|
|
{ value: 'archived', label: '已归档' },
|
|
]}
|
|
/>
|
|
</Space>
|
|
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
|
|
<PermissionButton
|
|
permission="organization:create"
|
|
type="primary"
|
|
icon={<PlusOutlined />}
|
|
onClick={() => openEditor()}
|
|
>
|
|
添加机构
|
|
</PermissionButton>
|
|
</div>
|
|
{isError ? (
|
|
<QueryErrorState
|
|
title="机构数据加载失败"
|
|
description="请检查网络后重试。"
|
|
onRetry={() => void refetch()}
|
|
/>
|
|
) : (
|
|
<Table
|
|
columns={columns}
|
|
dataSource={filteredData}
|
|
rowKey="id"
|
|
loading={loading}
|
|
locale={{
|
|
emptyText: (
|
|
<QueryEmpty
|
|
description="暂无机构"
|
|
action={
|
|
hasPermission('organization:create')
|
|
? { label: '添加机构', icon: <PlusOutlined />, onClick: () => openEditor() }
|
|
: undefined
|
|
}
|
|
/>
|
|
),
|
|
}}
|
|
scroll={{ x: 1100 }}
|
|
pagination={{
|
|
defaultPageSize: 20,
|
|
showSizeChanger: true,
|
|
pageSizeOptions: [20, 50, 100],
|
|
showTotal: (total) => `共 ${total} 个机构`,
|
|
}}
|
|
/>
|
|
)}
|
|
<Modal
|
|
title={editing ? `编辑机构 · ${editing.name}` : '添加外部机构'}
|
|
open={modalOpen}
|
|
onOk={handleSave}
|
|
onCancel={() => orgGuard.confirmClose(() => setModalOpen(false))}
|
|
confirmLoading={saving}
|
|
okText="保存"
|
|
>
|
|
<Form form={form} layout="vertical">
|
|
{editing?.isHost ? (
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
title="这是系统本机构,不可归档,也不能改为外部机构。"
|
|
style={{ marginBottom: 16 }}
|
|
/>
|
|
) : null}
|
|
<Form.Item
|
|
name="name"
|
|
label="机构名称"
|
|
rules={[{ required: true, message: '请输入机构名称' }]}
|
|
>
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="code"
|
|
label="机构编码"
|
|
tooltip="用于导入和系统识别,建议使用大写英文、数字、下划线或短横线"
|
|
rules={[
|
|
{ required: true },
|
|
{ pattern: /^[A-Z0-9_-]+$/, message: '仅支持大写英文、数字、下划线和短横线' },
|
|
]}
|
|
>
|
|
<Input
|
|
disabled={editing?.isHost}
|
|
placeholder="如 PARTNER_A"
|
|
onChange={(event) => form.setFieldValue('code', event.target.value.toUpperCase())}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item name="contactName" label="联系人">
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name="phone" label="电话">
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name="color" label="识别颜色">
|
|
<Space wrap>
|
|
{PRESET_COLORS.map((color) => (
|
|
<button
|
|
type="button"
|
|
key={color}
|
|
aria-label={`选择 ${color}`}
|
|
onClick={() => form.setFieldValue('color', color)}
|
|
style={{
|
|
width: 30,
|
|
height: 30,
|
|
borderRadius: 6,
|
|
border: '1px solid #d9d9d9',
|
|
background: color,
|
|
cursor: 'pointer',
|
|
}}
|
|
/>
|
|
))}
|
|
</Space>
|
|
</Form.Item>
|
|
<Form.Item name="notes" label="备注">
|
|
<Input.TextArea rows={3} />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default OrganizationsPage;
|