Files
erp/apps/web/src/pages/Organizations.tsx
iven 9568dd7875 chore: apply cargo fmt across workspace and update docs
- Run cargo fmt on all Rust crates for consistent formatting
- Update CLAUDE.md with WASM plugin commands and dev.ps1 instructions
- Update wiki: add WASM plugin architecture, rewrite dev environment docs
- Minor frontend cleanup (unused imports)
2026-04-15 00:49:20 +08:00

624 lines
18 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import {
Tree,
Button,
Space,
Modal,
Form,
Input,
InputNumber,
Table,
Popconfirm,
message,
Empty,
Tag,
theme,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
EditOutlined,
ApartmentOutlined,
} from '@ant-design/icons';
import type { DataNode } from 'antd/es/tree';
import {
listOrgTree,
createOrg,
updateOrg,
deleteOrg,
listDeptTree,
createDept,
deleteDept,
listPositions,
createPosition,
deletePosition,
type OrganizationInfo,
type DepartmentInfo,
type PositionInfo,
} from '../api/orgs';
export default function Organizations() {
const { token } = theme.useToken();
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
const cardStyle = {
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
};
// --- Org tree state ---
const [orgTree, setOrgTree] = useState<OrganizationInfo[]>([]);
const [selectedOrg, setSelectedOrg] = useState<OrganizationInfo | null>(null);
const [, setLoading] = useState(false);
// --- Department tree state ---
const [deptTree, setDeptTree] = useState<DepartmentInfo[]>([]);
const [selectedDept, setSelectedDept] = useState<DepartmentInfo | null>(null);
// --- Position list state ---
const [positions, setPositions] = useState<PositionInfo[]>([]);
// --- Modal state ---
const [orgModalOpen, setOrgModalOpen] = useState(false);
const [deptModalOpen, setDeptModalOpen] = useState(false);
const [positionModalOpen, setPositionModalOpen] = useState(false);
const [editOrg, setEditOrg] = useState<OrganizationInfo | null>(null);
const [orgForm] = Form.useForm();
const [deptForm] = Form.useForm();
const [positionForm] = Form.useForm();
// --- Fetch org tree ---
const fetchOrgTree = useCallback(async () => {
setLoading(true);
try {
const tree = await listOrgTree();
setOrgTree(tree);
if (selectedOrg) {
const stillExists = findOrgInTree(tree, selectedOrg.id);
if (!stillExists) {
setSelectedOrg(null);
setDeptTree([]);
setPositions([]);
}
}
} catch {
message.error('加载组织树失败');
}
setLoading(false);
}, [selectedOrg]);
useEffect(() => {
fetchOrgTree();
}, [fetchOrgTree]);
// --- Fetch dept tree when org selected ---
const fetchDeptTree = useCallback(async () => {
if (!selectedOrg) return;
try {
const tree = await listDeptTree(selectedOrg.id);
setDeptTree(tree);
if (selectedDept) {
const stillExists = findDeptInTree(tree, selectedDept.id);
if (!stillExists) {
setSelectedDept(null);
setPositions([]);
}
}
} catch {
message.error('加载部门树失败');
}
}, [selectedOrg, selectedDept]);
useEffect(() => {
fetchDeptTree();
}, [fetchDeptTree]);
// --- Fetch positions when dept selected ---
const fetchPositions = useCallback(async () => {
if (!selectedDept) return;
try {
const list = await listPositions(selectedDept.id);
setPositions(list);
} catch {
message.error('加载岗位列表失败');
}
}, [selectedDept]);
useEffect(() => {
fetchPositions();
}, [fetchPositions]);
// --- Org handlers ---
const handleCreateOrg = async (values: {
name: string;
code?: string;
sort_order?: number;
}) => {
try {
if (editOrg) {
await updateOrg(editOrg.id, {
name: values.name,
code: values.code,
sort_order: values.sort_order,
version: editOrg.version,
});
message.success('组织更新成功');
} else {
await createOrg({
name: values.name,
code: values.code,
parent_id: selectedOrg?.id,
sort_order: values.sort_order,
});
message.success('组织创建成功');
}
setOrgModalOpen(false);
setEditOrg(null);
orgForm.resetFields();
fetchOrgTree();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '操作失败';
message.error(errorMsg);
}
};
const handleDeleteOrg = async (id: string) => {
try {
await deleteOrg(id);
message.success('组织已删除');
setSelectedOrg(null);
setDeptTree([]);
setPositions([]);
fetchOrgTree();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '删除失败';
message.error(errorMsg);
}
};
// --- Dept handlers ---
const handleCreateDept = async (values: {
name: string;
code?: string;
sort_order?: number;
}) => {
if (!selectedOrg) return;
try {
await createDept(selectedOrg.id, {
name: values.name,
code: values.code,
parent_id: selectedDept?.id,
sort_order: values.sort_order,
});
message.success('部门创建成功');
setDeptModalOpen(false);
deptForm.resetFields();
fetchDeptTree();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '操作失败';
message.error(errorMsg);
}
};
const handleDeleteDept = async (id: string) => {
try {
await deleteDept(id);
message.success('部门已删除');
setSelectedDept(null);
setPositions([]);
fetchDeptTree();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '删除失败';
message.error(errorMsg);
}
};
// --- Position handlers ---
const handleCreatePosition = async (values: {
name: string;
code?: string;
level?: number;
sort_order?: number;
}) => {
if (!selectedDept) return;
try {
await createPosition(selectedDept.id, {
name: values.name,
code: values.code,
level: values.level,
sort_order: values.sort_order,
});
message.success('岗位创建成功');
setPositionModalOpen(false);
positionForm.resetFields();
fetchPositions();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '操作失败';
message.error(errorMsg);
}
};
const handleDeletePosition = async (id: string) => {
try {
await deletePosition(id);
message.success('岗位已删除');
fetchPositions();
} catch {
message.error('删除失败');
}
};
// --- Tree node converters ---
const convertOrgTree = (items: OrganizationInfo[]): DataNode[] =>
items.map((item) => ({
key: item.id,
title: (
<span>
{item.name}{' '}
{item.code && <Tag style={{
marginLeft: 4,
background: isDark ? '#1E293B' : '#EEF2FF',
border: 'none',
color: '#4F46E5',
fontSize: 11,
}}>{item.code}</Tag>}
</span>
),
children: convertOrgTree(item.children),
}));
const convertDeptTree = (items: DepartmentInfo[]): DataNode[] =>
items.map((item) => ({
key: item.id,
title: (
<span>
{item.name}{' '}
{item.code && <Tag style={{
marginLeft: 4,
background: isDark ? '#1E293B' : '#ECFDF5',
border: 'none',
color: '#059669',
fontSize: 11,
}}>{item.code}</Tag>}
</span>
),
children: convertDeptTree(item.children),
}));
const onSelectOrg = (selectedKeys: React.Key[]) => {
if (selectedKeys.length === 0) {
setSelectedOrg(null);
setDeptTree([]);
setSelectedDept(null);
setPositions([]);
return;
}
const org = findOrgInTree(orgTree, selectedKeys[0] as string);
setSelectedOrg(org);
setSelectedDept(null);
setPositions([]);
};
const onSelectDept = (selectedKeys: React.Key[]) => {
if (selectedKeys.length === 0) {
setSelectedDept(null);
setPositions([]);
return;
}
const dept = findDeptInTree(deptTree, selectedKeys[0] as string);
setSelectedDept(dept);
};
const positionColumns = [
{ title: '岗位名称', dataIndex: 'name', key: 'name' },
{ title: '编码', dataIndex: 'code', key: 'code', render: (v?: string) => v || '-' },
{ title: '级别', dataIndex: 'level', key: 'level' },
{ title: '排序', dataIndex: 'sort_order', key: 'sort_order' },
{
title: '操作',
key: 'actions',
render: (_: unknown, record: PositionInfo) => (
<Popconfirm
title="确定删除此岗位?"
onConfirm={() => handleDeletePosition(record.id)}
>
<Button size="small" type="text" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
),
},
];
return (
<div>
{/* 页面标题 */}
<div className="erp-page-header">
<div>
<h4>
<ApartmentOutlined style={{ marginRight: 8, color: '#4F46E5' }} />
</h4>
<div className="erp-page-subtitle"></div>
</div>
</div>
{/* 三栏布局 */}
<div style={{ display: 'flex', gap: 16, minHeight: 500 }}>
{/* 左栏:组织树 */}
<div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}>
<div style={{
padding: '14px 20px',
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}></span>
<Space size={4}>
<Button
size="small"
type="text"
icon={<PlusOutlined />}
onClick={() => {
setEditOrg(null);
orgForm.resetFields();
setOrgModalOpen(true);
}}
/>
{selectedOrg && (
<>
<Button
size="small"
type="text"
icon={<EditOutlined />}
onClick={() => {
setEditOrg(selectedOrg);
orgForm.setFieldsValue({
name: selectedOrg.name,
code: selectedOrg.code,
sort_order: selectedOrg.sort_order,
});
setOrgModalOpen(true);
}}
/>
<Popconfirm
title="确定删除此组织?"
onConfirm={() => handleDeleteOrg(selectedOrg.id)}
>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</>
)}
</Space>
</div>
<div style={{ padding: 12 }}>
{orgTree.length > 0 ? (
<Tree
showLine
defaultExpandAll
treeData={convertOrgTree(orgTree)}
onSelect={onSelectOrg}
selectedKeys={selectedOrg ? [selectedOrg.id] : []}
/>
) : (
<Empty description="暂无组织" />
)}
</div>
</div>
{/* 中栏:部门树 */}
<div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}>
<div style={{
padding: '14px 20px',
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}>
{selectedOrg ? `${selectedOrg.name} · 部门` : '部门'}
</span>
{selectedOrg && (
<Space size={4}>
<Button
size="small"
type="text"
icon={<PlusOutlined />}
onClick={() => {
deptForm.resetFields();
setDeptModalOpen(true);
}}
/>
{selectedDept && (
<Popconfirm
title="确定删除此部门?"
onConfirm={() => handleDeleteDept(selectedDept.id)}
>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
)}
</div>
<div style={{ padding: 12 }}>
{selectedOrg ? (
deptTree.length > 0 ? (
<Tree
showLine
defaultExpandAll
treeData={convertDeptTree(deptTree)}
onSelect={onSelectDept}
selectedKeys={selectedDept ? [selectedDept.id] : []}
/>
) : (
<Empty description="暂无部门" />
)
) : (
<Empty description="请先选择组织" />
)}
</div>
</div>
{/* 右栏:岗位表 */}
<div style={{ flex: 1, ...cardStyle, overflow: 'hidden' }}>
<div style={{
padding: '14px 20px',
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}>
{selectedDept ? `${selectedDept.name} · 岗位` : '岗位'}
</span>
{selectedDept && (
<Button
size="small"
type="text"
icon={<PlusOutlined />}
onClick={() => {
positionForm.resetFields();
setPositionModalOpen(true);
}}
>
</Button>
)}
</div>
<div style={{ padding: '0 4px' }}>
{selectedDept ? (
<Table
columns={positionColumns}
dataSource={positions}
rowKey="id"
size="small"
pagination={false}
/>
) : (
<div style={{ padding: 24 }}>
<Empty description="请先选择部门" />
</div>
)}
</div>
</div>
</div>
{/* Org Modal */}
<Modal
title={editOrg ? '编辑组织' : selectedOrg ? `${selectedOrg.name} 下新建子组织` : '新建根组织'}
open={orgModalOpen}
onCancel={() => {
setOrgModalOpen(false);
setEditOrg(null);
}}
onOk={() => orgForm.submit()}
>
<Form form={orgForm} onFinish={handleCreateOrg} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="name"
label="名称"
rules={[{ required: true, message: '请输入组织名称' }]}
>
<Input />
</Form.Item>
<Form.Item name="code" label="编码">
<Input />
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
{/* Dept Modal */}
<Modal
title={
selectedDept
? `${selectedDept.name} 下新建子部门`
: `${selectedOrg?.name} 下新建部门`
}
open={deptModalOpen}
onCancel={() => setDeptModalOpen(false)}
onOk={() => deptForm.submit()}
>
<Form form={deptForm} onFinish={handleCreateDept} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="name"
label="名称"
rules={[{ required: true, message: '请输入部门名称' }]}
>
<Input />
</Form.Item>
<Form.Item name="code" label="编码">
<Input />
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
{/* Position Modal */}
<Modal
title={`${selectedDept?.name} 下新建岗位`}
open={positionModalOpen}
onCancel={() => setPositionModalOpen(false)}
onOk={() => positionForm.submit()}
>
<Form form={positionForm} onFinish={handleCreatePosition} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="name"
label="岗位名称"
rules={[{ required: true, message: '请输入岗位名称' }]}
>
<Input />
</Form.Item>
<Form.Item name="code" label="编码">
<Input />
</Form.Item>
<Form.Item name="level" label="级别" initialValue={1}>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
// --- Helpers ---
function findOrgInTree(
tree: OrganizationInfo[],
id: string,
): OrganizationInfo | null {
for (const item of tree) {
if (item.id === id) return item;
const found = findOrgInTree(item.children, id);
if (found) return found;
}
return null;
}
function findDeptInTree(
tree: DepartmentInfo[],
id: string,
): DepartmentInfo | null {
for (const item of tree) {
if (item.id === id) return item;
const found = findDeptInTree(item.children, id);
if (found) return found;
}
return null;
}