feat: replace tenants with organization management
This commit is contained in:
314
apps/admin/src/pages/Organizations/index.tsx
Normal file
314
apps/admin/src/pages/Organizations/index.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
|
||||
import { BankOutlined, InboxOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
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 OrganizationsPage: React.FC = () => {
|
||||
const [data, setData] = useState<OrganizationItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<OrganizationItem | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string>();
|
||||
|
||||
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 fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setData(
|
||||
await api.get<OrganizationItem[]>('/organizations', { params: { includeArchived: true } }),
|
||||
);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '机构数据加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchData();
|
||||
}, []);
|
||||
|
||||
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] });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) await api.put(`/organizations/${editing.id}`, values);
|
||||
else await api.post('/organizations', values);
|
||||
message.success(editing ? '机构已更新' : '机构已创建');
|
||||
setModalOpen(false);
|
||||
await fetchData();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '机构',
|
||||
dataIndex: 'name',
|
||||
width: 220,
|
||||
render: (name: string, record: OrganizationItem) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '机构编码',
|
||||
dataIndex: 'code',
|
||||
width: 130,
|
||||
render: (value: string) => <code>{value}</code>,
|
||||
},
|
||||
{
|
||||
title: '联系人',
|
||||
dataIndex: 'contactName',
|
||||
width: 120,
|
||||
render: (value?: string) => value || '-',
|
||||
},
|
||||
{ title: '电话', dataIndex: 'phone', width: 140, render: (value?: string) => value || '-' },
|
||||
{ title: '备注', dataIndex: 'notes', ellipsis: true, render: (value?: string) => value || '-' },
|
||||
{
|
||||
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>
|
||||
<PermissionButton
|
||||
permission="organization:edit"
|
||||
size="small"
|
||||
onClick={() => openEditor(record)}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
{!record.isHost && record.status === 'active' ? (
|
||||
<Popconfirm
|
||||
title="归档后仍保留历史学生、入住和租赁记录"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await api.delete(`/organizations/${record.id}`);
|
||||
message.success('机构已归档');
|
||||
await fetchData();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '归档失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PermissionButton
|
||||
permission="organization:delete"
|
||||
size="small"
|
||||
icon={<InboxOutlined />}
|
||||
>
|
||||
归档
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="统一机构管理"
|
||||
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>
|
||||
<PermissionButton
|
||||
permission="organization:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => openEditor()}
|
||||
>
|
||||
添加机构
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无机构" /> }}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 个机构` }}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? `编辑机构 · ${editing.name}` : '添加外部机构'}
|
||||
open={modalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{editing?.isHost ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="这是系统本机构,不可归档,也不能改为外部机构。"
|
||||
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;
|
||||
Reference in New Issue
Block a user