feat: org CRUD (#3380)
* feat: add org schema * feat: org manage UI * feat: OrgInfoModal * feat: org tree view * feat: org management * fix: init root org * feat: org permission for app * feat: org support for dataset * fix: disable org role control * styles: opt type signatures * fix: remove unused permission * feat: delete org collaborator
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import type { IconNameType } from '@fastgpt/web/components/common/Icon/type';
|
||||
|
||||
function IconButton({ name, onClick }: { name: IconNameType; onClick: () => void }) {
|
||||
return (
|
||||
<MyIcon
|
||||
name={name}
|
||||
w={'1rem'}
|
||||
h={'1rem'}
|
||||
transition={'background 0.1s'}
|
||||
cursor={'pointer'}
|
||||
p="1"
|
||||
rounded={'sm'}
|
||||
_hover={{
|
||||
bg: 'myGray.05',
|
||||
color: 'primary.600'
|
||||
}}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default IconButton;
|
||||
@@ -0,0 +1,150 @@
|
||||
import { compressImgFileAndUpload } from '@/web/common/file/controller';
|
||||
import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
|
||||
import { postCreateOrg, putUpdateOrg } from '@/web/support/user/team/org/api';
|
||||
import { Button, HStack, Input, ModalBody, ModalFooter, Textarea } from '@chakra-ui/react';
|
||||
import { MongoImageTypeEnum } from '@fastgpt/global/common/file/image/constants';
|
||||
import { DEFAULT_ORG_AVATAR } from '@fastgpt/global/common/system/constants';
|
||||
import type { OrgType } from '@fastgpt/global/support/user/team/org/type';
|
||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
export type OrgFormType = {
|
||||
avatar: string;
|
||||
description?: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
function OrgInfoModal({
|
||||
editOrg,
|
||||
createOrgParentId: parentId,
|
||||
onClose,
|
||||
onSuccess
|
||||
}: {
|
||||
editOrg?: OrgType;
|
||||
createOrgParentId?: string;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { File: AvatarSelect, onOpen: onOpenSelectAvatar } = useSelectFile({
|
||||
fileType: '.jpg, .jpeg, .png',
|
||||
multiple: false
|
||||
});
|
||||
|
||||
const { register, handleSubmit, getValues, setValue } = useForm<OrgFormType>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
avatar: DEFAULT_ORG_AVATAR,
|
||||
description: undefined
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setValue('name', editOrg?.name ?? '');
|
||||
setValue('avatar', editOrg?.avatar || DEFAULT_ORG_AVATAR);
|
||||
setValue('description', editOrg?.description);
|
||||
}, [editOrg, setValue]);
|
||||
|
||||
const { run: onCreate, loading: isLoadingCreate } = useRequest2(
|
||||
(data: OrgFormType, parentId: string) => {
|
||||
return postCreateOrg({
|
||||
name: data.name,
|
||||
avatar: data.avatar,
|
||||
parentId,
|
||||
description: data.description
|
||||
});
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
onSuccess?.();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const { run: onUpdate, loading: isLoadingUpdate } = useRequest2(
|
||||
(data: OrgFormType, orgId: string) => {
|
||||
return putUpdateOrg({
|
||||
orgId,
|
||||
name: data.name,
|
||||
avatar: data.avatar,
|
||||
description: data.description
|
||||
});
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
onSuccess?.();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const { loading: uploadingAvatar, run: onSelectAvatar } = useRequest2(
|
||||
async (file: File[]) => {
|
||||
const src = await compressImgFileAndUpload({
|
||||
type: MongoImageTypeEnum.groupAvatar,
|
||||
file: file[0],
|
||||
maxW: 300,
|
||||
maxH: 300
|
||||
});
|
||||
return src;
|
||||
},
|
||||
{
|
||||
onSuccess: (src: string) => {
|
||||
setValue('avatar', src);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const isLoading = uploadingAvatar;
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen={!!(editOrg || parentId)}
|
||||
onClose={onClose}
|
||||
title={editOrg ? t('account_team:edit_org_info') : t('account_team:create_org')}
|
||||
iconSrc={editOrg?.avatar || DEFAULT_ORG_AVATAR}
|
||||
>
|
||||
<ModalBody flex={1} overflow={'auto'} display={'flex'} flexDirection={'column'} gap={4}>
|
||||
<FormLabel w="80px">{t('user:team.avatar_and_name')}</FormLabel>
|
||||
<HStack>
|
||||
<Avatar
|
||||
src={getValues('avatar') || DEFAULT_ORG_AVATAR}
|
||||
onClick={onOpenSelectAvatar}
|
||||
cursor={'pointer'}
|
||||
borderRadius={'md'}
|
||||
/>
|
||||
<Input
|
||||
bgColor="myGray.50"
|
||||
{...register('name', { required: true })}
|
||||
placeholder={t('account_team:org_name')}
|
||||
/>
|
||||
</HStack>
|
||||
<FormLabel w="80px">{t('account_team:org_description')}</FormLabel>
|
||||
<Textarea {...register('description')} placeholder={t('account_team:org_description')} />
|
||||
</ModalBody>
|
||||
<ModalFooter alignItems="flex-end">
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
onClick={handleSubmit((data) => {
|
||||
if (editOrg) {
|
||||
onUpdate(data, editOrg._id);
|
||||
} else if (parentId) {
|
||||
onCreate(data, parentId);
|
||||
}
|
||||
})}
|
||||
>
|
||||
{editOrg ? t('common:common.Save') : t('common:new_create')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
<AvatarSelect onSelect={onSelectAvatar} />
|
||||
</MyModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrgInfoModal;
|
||||
@@ -0,0 +1,202 @@
|
||||
import { putUpdateOrgMembers } from '@/web/support/user/team/org/api';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Flex,
|
||||
Grid,
|
||||
HStack,
|
||||
ModalBody,
|
||||
ModalFooter
|
||||
} from '@chakra-ui/react';
|
||||
import type { GroupMemberRole } from '@fastgpt/global/support/permission/memberGroup/constant';
|
||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import type { IconNameType } from '@fastgpt/web/components/common/Icon/type';
|
||||
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import type React from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { TeamContext } from '../context';
|
||||
|
||||
export type GroupFormType = {
|
||||
members: {
|
||||
tmbId: string;
|
||||
role: `${GroupMemberRole}`;
|
||||
}[];
|
||||
};
|
||||
|
||||
function CheckboxIcon({
|
||||
name
|
||||
}: {
|
||||
isChecked?: boolean;
|
||||
isIndeterminate?: boolean;
|
||||
name: IconNameType;
|
||||
}) {
|
||||
return <MyIcon name={name} w="12px" />;
|
||||
}
|
||||
|
||||
function OrgMemberModal({ onClose, editOrgId }: { onClose: () => void; editOrgId?: string }) {
|
||||
// 1. Owner can not be deleted, toast
|
||||
// 2. Owner/Admin can manage members
|
||||
// 3. Owner can add/remove admins
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
members: allMembers,
|
||||
orgs,
|
||||
refetchOrgs,
|
||||
refetchMembers
|
||||
} = useContextSelector(TeamContext, (v) => v);
|
||||
|
||||
const org = useMemo(() => orgs.find((item) => item._id === editOrgId), [editOrgId, orgs]);
|
||||
|
||||
const [members, setMembers] = useState<{ tmbId: string }[]>(org?.members || []);
|
||||
|
||||
useEffect(() => {
|
||||
setMembers(org?.members || []);
|
||||
}, [org]);
|
||||
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
const filtered = useMemo(() => {
|
||||
return [
|
||||
...allMembers.filter((member) => {
|
||||
if (member.memberName.toLowerCase().includes(searchKey.toLowerCase())) return true;
|
||||
return false;
|
||||
})
|
||||
];
|
||||
}, [searchKey, allMembers]);
|
||||
|
||||
const { run: onUpdate, loading: isLoadingUpdate } = useRequest2(
|
||||
async () => {
|
||||
if (!editOrgId) return;
|
||||
return putUpdateOrgMembers({
|
||||
orgId: editOrgId,
|
||||
members
|
||||
});
|
||||
},
|
||||
{
|
||||
onSuccess: () => Promise.all([onClose(), refetchOrgs(), refetchMembers()])
|
||||
}
|
||||
);
|
||||
|
||||
const isSelected = (memberId: string) => {
|
||||
return members.find((item) => item.tmbId === memberId);
|
||||
};
|
||||
|
||||
const handleToggleSelect = (memberId: string) => {
|
||||
if (isSelected(memberId)) {
|
||||
setMembers(members.filter((item) => item.tmbId !== memberId));
|
||||
} else {
|
||||
setMembers([...members, { tmbId: memberId }]);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = isLoadingUpdate;
|
||||
return (
|
||||
<MyModal
|
||||
onClose={onClose}
|
||||
isOpen={!!editOrgId}
|
||||
title={t('user:team.group.manage_member')}
|
||||
iconSrc={org?.avatar}
|
||||
iconColor="primary.600"
|
||||
minW="800px"
|
||||
h={'100%'}
|
||||
isCentered
|
||||
>
|
||||
<ModalBody flex={1}>
|
||||
<Grid
|
||||
border="1px solid"
|
||||
borderColor="myGray.200"
|
||||
borderRadius="0.5rem"
|
||||
gridTemplateColumns="1fr 1fr"
|
||||
h={'100%'}
|
||||
>
|
||||
<Flex flexDirection="column" p="4">
|
||||
<SearchInput
|
||||
placeholder={t('user:search_user')}
|
||||
fontSize="sm"
|
||||
bg={'myGray.50'}
|
||||
onChange={(e) => {
|
||||
setSearchKey(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<Flex flexDirection="column" mt={3} flexGrow="1" overflow={'auto'} maxH={'400px'}>
|
||||
{filtered.map((member) => {
|
||||
return (
|
||||
<HStack
|
||||
py="2"
|
||||
px={3}
|
||||
borderRadius={'md'}
|
||||
alignItems="center"
|
||||
key={member.tmbId}
|
||||
cursor={'pointer'}
|
||||
_hover={{
|
||||
bg: 'myGray.50',
|
||||
...(!isSelected(member.tmbId) ? { svg: { color: 'myGray.50' } } : {})
|
||||
}}
|
||||
_notLast={{ mb: 2 }}
|
||||
onClick={() => handleToggleSelect(member.tmbId)}
|
||||
>
|
||||
<Checkbox
|
||||
isChecked={!!isSelected(member.tmbId)}
|
||||
icon={<CheckboxIcon name={'common/check'} />}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<Avatar src={member.avatar} w="1.5rem" borderRadius={'50%'} />
|
||||
<Box>{member.memberName}</Box>
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex borderLeft="1px" borderColor="myGray.200" flexDirection="column" p="4" h={'100%'}>
|
||||
<Box mt={2}>{`${t('common:chosen')}:${members.length}`}</Box>
|
||||
<Flex mt={3} flexDirection="column" flexGrow="1" overflow={'auto'} maxH={'400px'}>
|
||||
{members.map((member) => {
|
||||
return (
|
||||
<HStack
|
||||
justifyContent="space-between"
|
||||
py="2"
|
||||
px={3}
|
||||
borderRadius={'md'}
|
||||
key={member.tmbId}
|
||||
_hover={{ bg: 'myGray.50' }}
|
||||
_notLast={{ mb: 2 }}
|
||||
>
|
||||
<HStack>
|
||||
<Avatar
|
||||
src={allMembers.find((item) => item.tmbId === member.tmbId)?.avatar}
|
||||
w="1.5rem"
|
||||
borderRadius={'md'}
|
||||
/>
|
||||
<Box>
|
||||
{allMembers.find((item) => item.tmbId === member.tmbId)?.memberName}
|
||||
</Box>
|
||||
</HStack>
|
||||
<MyIcon
|
||||
name={'common/closeLight'}
|
||||
w={'1rem'}
|
||||
cursor={'pointer'}
|
||||
_hover={{ color: 'red.600' }}
|
||||
onClick={() => handleToggleSelect(member.tmbId)}
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Grid>
|
||||
</ModalBody>
|
||||
<ModalFooter alignItems="flex-end">
|
||||
<Button isLoading={isLoading} onClick={onUpdate}>
|
||||
{t('common:common.Save')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrgMemberModal;
|
||||
@@ -0,0 +1,80 @@
|
||||
import { putMoveOrg, putMoveOrgMember } from '@/web/support/user/team/org/api';
|
||||
import { Button, ModalBody, ModalFooter } from '@chakra-ui/react';
|
||||
import type { OrgType } from '@fastgpt/global/support/user/team/org/type';
|
||||
import type { TeamTmbItemType } from '@fastgpt/global/support/user/team/type';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
import OrgTree from './OrgTree';
|
||||
|
||||
function OrgMoveModal({
|
||||
movingOrg,
|
||||
movingTmb,
|
||||
orgs,
|
||||
team,
|
||||
onClose,
|
||||
onSuccess
|
||||
}: {
|
||||
movingOrg?: OrgType;
|
||||
movingTmb?: { tmbId: string; orgId: string };
|
||||
orgs: OrgType[];
|
||||
team: TeamTmbItemType;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedOrg, selectOrg] = useState<OrgType>();
|
||||
|
||||
const { runAsync: moveOrg, loading: loadingOrg } = useRequest2(putMoveOrg, {
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
onSuccess();
|
||||
}
|
||||
});
|
||||
|
||||
const { runAsync: moveTmb, loading: loadingTmb } = useRequest2(putMoveOrgMember, {
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
onSuccess();
|
||||
}
|
||||
});
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!selectedOrg) return;
|
||||
if (movingTmb) {
|
||||
moveTmb({ orgId: movingTmb.orgId, tmbId: movingTmb.tmbId, newOrgId: selectedOrg._id });
|
||||
} else if (movingOrg) {
|
||||
moveOrg(movingOrg._id, selectedOrg._id);
|
||||
}
|
||||
};
|
||||
|
||||
const loading = loadingOrg || loadingTmb;
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen={!!movingOrg || !!movingTmb}
|
||||
onClose={onClose}
|
||||
title={movingOrg ? t('account_team:move_org') : t('account_team:move_member')}
|
||||
iconSrc="common/file/move"
|
||||
iconColor="blue.600"
|
||||
>
|
||||
<ModalBody>
|
||||
<OrgTree
|
||||
orgs={orgs}
|
||||
teamName={team.teamName}
|
||||
teamAvatar={team.avatar}
|
||||
selectedOrg={selectedOrg}
|
||||
selectOrg={selectOrg}
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button isDisabled={!selectedOrg} isLoading={loading} onClick={() => handleConfirm()}>
|
||||
{t('common:common.Confirm')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrgMoveModal;
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Box, HStack, Text, VStack } from '@chakra-ui/react';
|
||||
import type { OrgType } from '@fastgpt/global/support/user/team/org/type';
|
||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import { useToggle } from 'ahooks';
|
||||
import { useMemo, useState } from 'react';
|
||||
import IconButton from './IconButton';
|
||||
|
||||
function OrgTreeNode({
|
||||
org,
|
||||
list,
|
||||
selectedOrg,
|
||||
selectOrg,
|
||||
indent = 0
|
||||
}: {
|
||||
org: OrgType;
|
||||
list: OrgType[];
|
||||
selectedOrg?: OrgType;
|
||||
selectOrg?: (org?: OrgType) => void;
|
||||
indent?: number;
|
||||
}) {
|
||||
const children = useMemo(
|
||||
() => list.filter((item) => item.path === `${org.path}/${org._id}`),
|
||||
[org, list]
|
||||
);
|
||||
const [isExpanded, toggleIsExpanded] = useToggle(false);
|
||||
|
||||
return (
|
||||
<VStack alignItems={'start'} w="full" gap={'8px'}>
|
||||
<HStack
|
||||
w="full"
|
||||
_hover={{ bgColor: selectedOrg === org ? 'blue.200' : 'gray.100' }}
|
||||
borderRadius="4px"
|
||||
boxSizing="border-box"
|
||||
py="4px"
|
||||
pl={`calc(${indent}rem + 4px)`}
|
||||
transition={'background 0.1s'}
|
||||
{...(selectedOrg === org ? { bgColor: 'blue.100' } : {})}
|
||||
>
|
||||
{children.length > 0 ? (
|
||||
<IconButton
|
||||
name={isExpanded ? 'common/downArrowFill' : 'common/rightArrowFill'}
|
||||
onClick={() => toggleIsExpanded.toggle()}
|
||||
/>
|
||||
) : (
|
||||
<Box w={'1rem'} h={'1rem'} m="1" />
|
||||
)}
|
||||
<HStack onClick={() => selectOrg?.(org)} cursor="pointer">
|
||||
<Avatar src={org.avatar} w="20px" h="20px" rounded={'50%'} />
|
||||
<Text>{org.name}</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
{isExpanded &&
|
||||
children.length > 0 &&
|
||||
children.map((child) => (
|
||||
<OrgTreeNode
|
||||
key={child._id}
|
||||
org={child}
|
||||
indent={indent + 1}
|
||||
list={list}
|
||||
selectedOrg={selectedOrg}
|
||||
selectOrg={selectOrg}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function OrgTree({
|
||||
orgs,
|
||||
teamName,
|
||||
teamAvatar,
|
||||
selectedOrg,
|
||||
selectOrg
|
||||
}: {
|
||||
orgs: OrgType[];
|
||||
teamAvatar: string;
|
||||
teamName: string;
|
||||
selectedOrg?: OrgType;
|
||||
selectOrg?: (org?: OrgType) => void;
|
||||
}) {
|
||||
const root = orgs[0];
|
||||
if (!root) return null;
|
||||
const children = useMemo(
|
||||
() => orgs.filter((item) => item.path === `${root.path}/${root._id}`),
|
||||
[root, orgs]
|
||||
);
|
||||
return (
|
||||
<VStack alignItems={'start'} gap={'8px'}>
|
||||
<HStack
|
||||
w="full"
|
||||
onClick={() => selectOrg?.(root)}
|
||||
cursor="pointer"
|
||||
_hover={{ bgColor: selectedOrg === root ? 'blue.200' : 'gray.100' }}
|
||||
borderRadius="4px"
|
||||
p="4px"
|
||||
transition={'background 0.1s'}
|
||||
{...(selectedOrg === root ? { bgColor: 'blue.100' } : {})}
|
||||
>
|
||||
<Avatar src={teamAvatar} w="20px" h="20px" rounded={'50%'} />
|
||||
<Text>{teamName}</Text>
|
||||
</HStack>
|
||||
{children.map((child) => (
|
||||
<OrgTreeNode
|
||||
key={child._id}
|
||||
org={child}
|
||||
list={orgs}
|
||||
selectOrg={selectOrg}
|
||||
selectedOrg={selectedOrg}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrgTree;
|
||||
@@ -0,0 +1,370 @@
|
||||
import { deleteOrg, deleteOrgMember } from '@/web/support/user/team/org/api';
|
||||
import { useUserStore } from '@/web/support/user/useUserStore';
|
||||
import {
|
||||
Box,
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
Divider,
|
||||
HStack,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tag,
|
||||
Tbody,
|
||||
Td,
|
||||
Text,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
VStack,
|
||||
useDisclosure
|
||||
} from '@chakra-ui/react';
|
||||
import type { OrgType } from '@fastgpt/global/support/user/team/org/type';
|
||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import type { IconNameType } from '@fastgpt/web/components/common/Icon/type';
|
||||
import MyMenu from '@fastgpt/web/components/common/MyMenu';
|
||||
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import MemberTag from '../../../../../components/support/user/team/Info/MemberTag';
|
||||
import { TeamContext } from '../context';
|
||||
import IconButton from './IconButton';
|
||||
import OrgInfoModal from './OrgInfoModal';
|
||||
import OrgMemberModal from './OrgMemberModal';
|
||||
import OrgMoveModal from './OrgMoveModal';
|
||||
|
||||
function ActionButton({
|
||||
icon,
|
||||
text,
|
||||
onClick
|
||||
}: {
|
||||
icon: IconNameType;
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<HStack
|
||||
gap={'8px'}
|
||||
w="100%"
|
||||
transition={'background 0.1s'}
|
||||
cursor={'pointer'}
|
||||
p="4px"
|
||||
rounded={'sm'}
|
||||
_hover={{
|
||||
bg: 'myGray.05',
|
||||
color: 'primary.600'
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<MyIcon name={icon} w="16px" h="16px" p="1" />
|
||||
<Text fontSize={'12px'} lineHeight={'16px'}>
|
||||
{text}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberTable() {
|
||||
const { t } = useTranslation();
|
||||
const { userInfo } = useUserStore();
|
||||
|
||||
const { orgs, refetchOrgs, members, refetchMembers, isLoading } = useContextSelector(
|
||||
TeamContext,
|
||||
(v) => v
|
||||
);
|
||||
const [currentOrg, setCurrentOrg] = useState<OrgType>();
|
||||
|
||||
// Set current org by hash
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.substring(1);
|
||||
const initialOrg = orgs.find((org) => org._id === hash) || orgs[0];
|
||||
setCurrentOrg(initialOrg);
|
||||
}, [orgs, isLoading]);
|
||||
// Update hash when current org changes
|
||||
useEffect(() => {
|
||||
if (currentOrg) {
|
||||
window.location.hash = currentOrg._id;
|
||||
}
|
||||
}, [currentOrg]);
|
||||
|
||||
const currentPath = useMemo<{ path: string; parents: OrgType[] }>(
|
||||
() => ({
|
||||
path: currentOrg ? `${currentOrg.path}/${currentOrg._id}` : '',
|
||||
parents: currentOrg
|
||||
? currentOrg.path
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((orgId) => orgs.find((org) => org._id === orgId)!)
|
||||
: []
|
||||
}),
|
||||
[orgs, currentOrg]
|
||||
);
|
||||
|
||||
const orgList = useMemo(
|
||||
() =>
|
||||
orgs
|
||||
.filter((org) => org.path === currentPath.path)
|
||||
.map((org) => {
|
||||
// calc org members count
|
||||
let count = org.members.length;
|
||||
for (const item of orgs.filter((item) =>
|
||||
item.path.startsWith(`${org.path}/${org._id}`)
|
||||
)) {
|
||||
count += item.members.length;
|
||||
}
|
||||
|
||||
return { ...org, count };
|
||||
}),
|
||||
[orgs, currentPath]
|
||||
);
|
||||
|
||||
const [editOrg, setEditOrg] = useState<OrgType | undefined>();
|
||||
const [editMemberOrgId, setEditMemberOrgId] = useState<string | undefined>();
|
||||
const [movingOrg, setMovingOrg] = useState<OrgType | undefined>();
|
||||
const [movingTmb, setMovingTmb] = useState<{ tmbId: string; orgId: string } | undefined>();
|
||||
const [createOrgParentId, setCreateOrgParentId] = useState<string | undefined>();
|
||||
|
||||
const { ConfirmModal: ConfirmDeleteOrgModal, openConfirm: openDeleteOrgModal } = useConfirm({
|
||||
type: 'delete',
|
||||
content: t('account_team:confirm_delete_org')
|
||||
});
|
||||
|
||||
const { ConfirmModal: ConfirmDeleteMember, openConfirm: openDeleteMemberModal } = useConfirm({
|
||||
type: 'delete',
|
||||
content: t('account_team:confirm_delete_member')
|
||||
});
|
||||
|
||||
const { runAsync: deleteOrgReq } = useRequest2(deleteOrg, {
|
||||
onSuccess: () => {
|
||||
refetchOrgs();
|
||||
refetchMembers();
|
||||
}
|
||||
});
|
||||
const { runAsync: deleteMemberReq } = useRequest2(deleteOrgMember, {
|
||||
onSuccess: () => {
|
||||
refetchOrgs();
|
||||
refetchMembers();
|
||||
}
|
||||
});
|
||||
|
||||
const deleteOrgHandler = (orgId: string) => openDeleteOrgModal(() => deleteOrgReq(orgId))();
|
||||
const deleteMemberHandler = (orgId: string, tmbId: string) =>
|
||||
openDeleteMemberModal(() => deleteMemberReq(orgId, tmbId))();
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
<Breadcrumb mr={'auto'}>
|
||||
{currentPath.parents.map((parent) => (
|
||||
<BreadcrumbItem key={parent._id}>
|
||||
<BreadcrumbLink onClick={() => setCurrentOrg(parent)}>
|
||||
{parent.path === '' ? userInfo?.team.teamName : parent.name}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
<BreadcrumbItem isCurrentPage>
|
||||
<BreadcrumbLink color="myGray.900" fontWeight={500}>
|
||||
{currentOrg?.path === '' ? userInfo?.team.teamName : currentOrg?.name}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
<HStack w={'100%'} gap={'16px'} alignItems={'start'}>
|
||||
<TableContainer overflow={'unset'} fontSize={'sm'} flexGrow={1}>
|
||||
<Table overflow={'unset'}>
|
||||
<Thead>
|
||||
<Tr bg={'white !important'}>
|
||||
<Th bg="myGray.100" borderLeftRadius="6px">
|
||||
{t('common:Name')}
|
||||
</Th>
|
||||
<Th bg="myGray.100" borderRightRadius="6px">
|
||||
{t('common:common.Action')}
|
||||
</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{orgList.map((org) => (
|
||||
<Tr key={org._id} overflow={'unset'}>
|
||||
<Td>
|
||||
<HStack w="fit-content" cursor={'pointer'} onClick={() => setCurrentOrg(org)}>
|
||||
<MemberTag name={org.name} avatar={org.avatar} />
|
||||
<Tag size="sm">{org.count}</Tag>
|
||||
<MyIcon
|
||||
name="core/chat/chevronRight"
|
||||
w={'12px'}
|
||||
h={'12px'}
|
||||
color={'myGray.400'}
|
||||
/>
|
||||
</HStack>
|
||||
</Td>
|
||||
|
||||
<Td w={'6rem'}>
|
||||
<MyMenu
|
||||
trigger="click"
|
||||
Button={
|
||||
<MyIcon name="more" w={'1rem'} cursor={'pointer'} p="1" rounded={'sm'} />
|
||||
}
|
||||
menuList={[
|
||||
{
|
||||
children: [
|
||||
{
|
||||
icon: 'edit',
|
||||
label: t('account_team:edit_info'),
|
||||
onClick: () => setEditOrg(org)
|
||||
},
|
||||
{
|
||||
icon: 'common/file/move',
|
||||
label: t('common:Move'),
|
||||
onClick: () => setMovingOrg(org)
|
||||
},
|
||||
{
|
||||
icon: 'delete',
|
||||
label: t('account_team:delete'),
|
||||
type: 'danger',
|
||||
onClick: () => deleteOrgHandler(org._id)
|
||||
}
|
||||
]
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
{currentOrg?.members.map((member) => {
|
||||
const memberInfo = members.find((m) => m.tmbId === member.tmbId);
|
||||
if (!memberInfo) return null;
|
||||
return (
|
||||
<Tr key={member.tmbId} overflow={'unset'}>
|
||||
<Td>
|
||||
<MemberTag name={memberInfo.memberName} avatar={memberInfo.avatar} />
|
||||
</Td>
|
||||
<Td w={'6rem'}>
|
||||
<MyMenu
|
||||
trigger={'click'}
|
||||
Button={
|
||||
<MyIcon name="more" w={'1rem'} cursor={'pointer'} p="1" rounded={'sm'} />
|
||||
}
|
||||
menuList={[
|
||||
{
|
||||
children: [
|
||||
// {
|
||||
// icon: 'edit',
|
||||
// label: t('account_team:remark'),
|
||||
// onClick: () => {
|
||||
// // TODO
|
||||
// console.log(member.tmbId);
|
||||
// }
|
||||
// },
|
||||
{
|
||||
icon: 'common/file/move',
|
||||
label: t('common:Move'),
|
||||
onClick: () =>
|
||||
setMovingTmb({ tmbId: member.tmbId, orgId: currentOrg!._id })
|
||||
},
|
||||
{
|
||||
icon: 'delete',
|
||||
label: t('account_team:delete'),
|
||||
type: 'danger',
|
||||
onClick: () => deleteMemberHandler(currentOrg!._id, member.tmbId)
|
||||
}
|
||||
]
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<VStack w={'220px'} alignItems={'start'}>
|
||||
<HStack gap={'6px'}>
|
||||
<Avatar
|
||||
src={currentOrg?.path === '' ? userInfo?.team.avatar : currentOrg?.avatar}
|
||||
w={'16px'}
|
||||
h={'16px'}
|
||||
rounded={'20%'}
|
||||
/>
|
||||
<Text fontWeight={500} fontSize={'14px'} color={'myGray.900'} lineHeight={'20px'}>
|
||||
{currentOrg?.path === '' ? userInfo?.team.teamName : currentOrg?.name}
|
||||
</Text>
|
||||
{currentOrg?.path !== '' && (
|
||||
<IconButton name="edit" onClick={() => setEditOrg(currentOrg)} />
|
||||
)}
|
||||
</HStack>
|
||||
<Text fontSize={12} lineHeight={'16px'} w={'full'}>
|
||||
{currentOrg?.description ?? t('common:common.no_intro')}
|
||||
</Text>
|
||||
|
||||
<Divider my={'20px'} />
|
||||
|
||||
<Text fontWeight={500} mb="13px" fontSize="14px" color="myGray.900" lineHeight="20px">
|
||||
{t('common:common.Action')}
|
||||
</Text>
|
||||
<VStack gap="13px" w="100%">
|
||||
<ActionButton
|
||||
icon="common/add2"
|
||||
text={t('account_team:create_sub_org')}
|
||||
onClick={() => {
|
||||
setCreateOrgParentId(currentOrg?._id);
|
||||
}}
|
||||
/>
|
||||
<ActionButton
|
||||
icon="common/administrator"
|
||||
text={t('account_team:manage_member')}
|
||||
onClick={() => setEditMemberOrgId(currentOrg?._id)}
|
||||
/>
|
||||
{currentOrg?.path !== '' && (
|
||||
<>
|
||||
<ActionButton
|
||||
icon="common/file/move"
|
||||
text={t('account_team:move_org')}
|
||||
onClick={() => setMovingOrg(currentOrg)}
|
||||
/>
|
||||
<ActionButton
|
||||
icon="delete"
|
||||
text={t('account_team:delete_org')}
|
||||
onClick={() => deleteOrgHandler(currentOrg?._id ?? '')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<OrgInfoModal
|
||||
editOrg={editOrg}
|
||||
createOrgParentId={createOrgParentId}
|
||||
onClose={() => {
|
||||
setEditOrg(undefined);
|
||||
setCreateOrgParentId(undefined);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
refetchOrgs();
|
||||
refetchMembers();
|
||||
}}
|
||||
/>
|
||||
<OrgMoveModal
|
||||
orgs={orgs}
|
||||
team={userInfo?.team!}
|
||||
movingOrg={movingOrg}
|
||||
movingTmb={movingTmb}
|
||||
onClose={() => {
|
||||
setMovingOrg(undefined);
|
||||
setMovingTmb(undefined);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
refetchOrgs();
|
||||
refetchMembers();
|
||||
}}
|
||||
/>
|
||||
<OrgMemberModal editOrgId={editMemberOrgId} onClose={() => setEditMemberOrgId(undefined)} />
|
||||
<ConfirmDeleteOrgModal />
|
||||
<ConfirmDeleteMember />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
export default MemberTable;
|
||||
@@ -9,7 +9,9 @@ import type { TeamTmbItemType, TeamMemberItemType } from '@fastgpt/global/suppor
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { getGroupList } from '@/web/support/user/team/group/api';
|
||||
import { getOrgList } from '@/web/support/user/team/org/api';
|
||||
import { MemberGroupListType } from '@fastgpt/global/support/permission/memberGroup/type';
|
||||
import { OrgType } from '@fastgpt/global/support/user/team/org/type';
|
||||
|
||||
const EditInfoModal = dynamic(() => import('./EditInfoModal'));
|
||||
|
||||
@@ -17,6 +19,7 @@ type TeamModalContextType = {
|
||||
myTeams: TeamTmbItemType[];
|
||||
members: TeamMemberItemType[];
|
||||
groups: MemberGroupListType;
|
||||
orgs: OrgType[];
|
||||
isLoading: boolean;
|
||||
onSwitchTeam: (teamId: string) => void;
|
||||
setEditTeamData: React.Dispatch<React.SetStateAction<EditTeamFormDataType | undefined>>;
|
||||
@@ -24,6 +27,7 @@ type TeamModalContextType = {
|
||||
refetchMembers: () => void;
|
||||
refetchTeams: () => void;
|
||||
refetchGroups: () => void;
|
||||
refetchOrgs: () => void;
|
||||
searchKey: string;
|
||||
setSearchKey: React.Dispatch<React.SetStateAction<string>>;
|
||||
teamSize: number;
|
||||
@@ -33,6 +37,7 @@ export const TeamContext = createContext<TeamModalContextType>({
|
||||
myTeams: [],
|
||||
groups: [],
|
||||
members: [],
|
||||
orgs: [],
|
||||
isLoading: false,
|
||||
onSwitchTeam: function (_teamId: string): void {
|
||||
throw new Error('Function not implemented.');
|
||||
@@ -49,6 +54,9 @@ export const TeamContext = createContext<TeamModalContextType>({
|
||||
refetchGroups: function (): void {
|
||||
throw new Error('Function not implemented.');
|
||||
},
|
||||
refetchOrgs: function (): void {
|
||||
throw new Error('Function not implemented.');
|
||||
},
|
||||
|
||||
searchKey: '',
|
||||
setSearchKey: function (_value: React.SetStateAction<string>): void {
|
||||
@@ -107,7 +115,17 @@ export const TeamModalContextProvider = ({ children }: { children: ReactNode })
|
||||
refreshDeps: [userInfo?.team?.teamId]
|
||||
});
|
||||
|
||||
const isLoading = isLoadingTeams || isSwitchingTeam || loadingMembers || isLoadingGroups;
|
||||
const {
|
||||
data: orgs = [],
|
||||
loading: isLoadingOrgs,
|
||||
refresh: refetchOrgs
|
||||
} = useRequest2(getOrgList, {
|
||||
manual: false,
|
||||
refreshDeps: [userInfo?.team?.teamId]
|
||||
});
|
||||
|
||||
const isLoading =
|
||||
isLoadingTeams || isSwitchingTeam || loadingMembers || isLoadingGroups || isLoadingOrgs;
|
||||
|
||||
const contextValue = {
|
||||
myTeams,
|
||||
@@ -123,6 +141,8 @@ export const TeamModalContextProvider = ({ children }: { children: ReactNode })
|
||||
refetchMembers,
|
||||
groups,
|
||||
refetchGroups,
|
||||
orgs,
|
||||
refetchOrgs,
|
||||
teamSize: members.length
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user