diff --git a/adminfront/package-lock.json b/adminfront/package-lock.json index fcb8dece..a1198055 100644 --- a/adminfront/package-lock.json +++ b/adminfront/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@radix-ui/react-avatar": "^1.1.4", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-scroll-area": "^1.1.2", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.1.2", @@ -1528,6 +1529,91 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", @@ -1627,6 +1713,102 @@ } } }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", @@ -1827,6 +2009,93 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-scroll-area": { "version": "1.2.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", diff --git a/adminfront/package.json b/adminfront/package.json index 80dd2bb4..0fdd12d0 100644 --- a/adminfront/package.json +++ b/adminfront/package.json @@ -21,6 +21,7 @@ "dependencies": { "@radix-ui/react-avatar": "^1.1.4", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-scroll-area": "^1.1.2", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.1.2", diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 4422e908..55e19ab8 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -53,7 +53,7 @@ export const router = createBrowserRouter( }, { path: "tenants/:tenantId/organization/:id", - element: , + element: , }, { path: "api-keys", element: }, { path: "api-keys/new", element: }, diff --git a/adminfront/src/components/ui/dropdown-menu.tsx b/adminfront/src/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..c58f322e --- /dev/null +++ b/adminfront/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,200 @@ +"use client"; + +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; +import * as React from "react"; + +import { cn } from "../../lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx index 6d7c7837..bf715d01 100644 --- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx +++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx @@ -4,14 +4,18 @@ import { ArrowRight, Briefcase, Building2, - Check, ChevronDown, ChevronRight, CornerDownRight, + ExternalLink, + FolderOpen, + LayoutDashboard, + MoreHorizontal, Network, Plus, RefreshCw, Search, + Settings, Trash2, UserCircle, UserPlus, @@ -38,8 +42,17 @@ import { DialogTitle, DialogTrigger, } from "../../../components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../../../components/ui/dropdown-menu"; import { Input } from "../../../components/ui/input"; import { Label } from "../../../components/ui/label"; +import { ScrollArea } from "../../../components/ui/scroll-area"; import { Table, TableBody, @@ -56,11 +69,9 @@ import { } from "../../../components/ui/tabs"; import { toast } from "../../../components/ui/use-toast"; import { - type GroupSummary, type TenantSummary, type UserSummary, createUser, - fetchGroups, fetchTenants, fetchUsers, updateTenant, @@ -69,6 +80,7 @@ import { import { t } from "../../../lib/i18n"; import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree"; +// --- Icons & Helpers --- const getTenantIcon = (type?: string) => { switch (type?.toUpperCase()) { case "COMPANY_GROUP": @@ -82,254 +94,596 @@ const getTenantIcon = (type?: string) => { } }; -const MemberListDialog: React.FC<{ +// --- Components --- + +const SidebarNode: React.FC<{ node: TenantNode; - trigger?: React.ReactNode; - open?: boolean; - onOpenChange?: (open: boolean) => void; -}> = ({ node, trigger, open, onOpenChange }) => { - const [activeTab, setActiveTab] = useState("direct"); + level: number; + selectedId: string; + onSelect: (id: string) => void; + searchTerm: string; +}> = ({ node, level, selectedId, onSelect, searchTerm }) => { + const [isExpanded, setIsExpanded] = useState(true); + const hasChildren = node.children && node.children.length > 0; + const isSelected = selectedId === node.id; + const TypeIcon = getTenantIcon(node.type); - const { - data: directData, - isLoading: isDirectLoading, - refetch: refetchDirect, - } = useQuery({ - queryKey: ["tenant-members", node.slug], - queryFn: () => fetchUsers(100, 0, undefined, node.slug), - enabled: open && activeTab === "direct", - }); + // Auto-expand on search + React.useEffect(() => { + if (searchTerm) { + const matchInDescendants = (n: TenantNode): boolean => { + return n.children.some( + (c) => + c.name.toLowerCase().includes(searchTerm.toLowerCase()) || + matchInDescendants(c), + ); + }; + if (matchInDescendants(node)) setIsExpanded(true); + } + }, [searchTerm, node]); - const descendantSlugs = useMemo(() => { - const slugs: string[] = []; - const collect = (n: TenantNode) => { - for (const child of n.children) { - slugs.push(child.slug); - collect(child); - } - }; - collect(node); - return slugs; - }, [node]); - - const { - data: descendantData, - isLoading: isDescendantLoading, - refetch: refetchDescendant, - } = useQuery({ - queryKey: ["tenant-descendant-members", node.id], - queryFn: async () => { - if (descendantSlugs.length === 0) return []; - // Fetch users for all descendant slugs in parallel - const results = await Promise.all( - descendantSlugs - .slice(0, 10) - .map((slug) => fetchUsers(50, 0, undefined, slug)), - ); - return results.flatMap((res) => res.items); - }, - enabled: open && activeTab === "descendants" && descendantSlugs.length > 0, - }); - - const directMembers = directData?.items ?? []; - const descendantMembers = descendantData ?? []; + const isMatching = + searchTerm && node.name.toLowerCase().includes(searchTerm.toLowerCase()); return ( - - {trigger && {trigger}} - - - - - {node.name}{" "} - {t("ui.admin.tenants.members.list_title", "구성원 관리")} - - ({isDirectLoading ? "..." : (directData?.total ?? 0)}) - - - - {t( - "msg.admin.tenants.members.desc", - "조직에 소속된 사용자 목록을 확인합니다.", +
+
onSelect(node.id)} + > +
+ {/* Indent & Expander */} +
+
+ {hasChildren ? ( + + ) : ( + level > 0 &&
)} - - - - -
- - - {t("ui.admin.tenants.members.direct", "소속 멤버")} ( - {isDirectLoading ? "..." : (directData?.total ?? 0)}) - - - {t("ui.admin.tenants.members.descendants", "하위 조직 멤버")} ( - {node.recursiveMemberCount - (node.memberCount || 0)}) - -
- - - + + {node.name} +
- - - {descendantSlugs.length > 10 && ( -

- *{" "} - {t( - "msg.admin.tenants.members.limit_notice", - "하위 조직이 많아 상위 10개 조직의 멤버만 표시됩니다.", - )} -

- )} -
- + + {node.recursiveMemberCount} + +
- - - - -
+ {isExpanded && hasChildren && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} + ); }; const MemberTable: React.FC<{ - members: UserSummary[]; - isLoading: boolean; - onRefresh: () => void; - showTenant?: boolean; -}> = ({ members, isLoading, onRefresh, showTenant }) => ( -
- - - - - {t("ui.admin.users.table.name", "NAME")} - - - {t("ui.admin.users.table.email", "EMAIL")} - - {showTenant && ( - - {t("ui.admin.tenants.table.slug", "TENANT")} + tenantSlug: string; + onRefreshTrigger?: number; +}> = ({ tenantSlug, onRefreshTrigger }) => { + const { data, isLoading, refetch } = useQuery({ + queryKey: ["tenant-members-v2", tenantSlug, onRefreshTrigger], + queryFn: () => fetchUsers(100, 0, undefined, tenantSlug), + enabled: !!tenantSlug, + }); + + const members = data?.items ?? []; + + if (isLoading) + return ( +
+ {t("msg.common.loading", "멤버 정보를 불러오는 중...")} +
+ ); + + if (members.length === 0) + return ( +
+ +

+ {t("msg.admin.users.list.empty", "이 조직에 소속된 멤버가 없습니다.")} +

+
+ ); + + return ( +
+
+ + + {t("ui.admin.users.table.name", "이름")} + {t("ui.admin.users.table.email", "이메일")} + + {t("ui.admin.users.table.role", "역할")} - )} - - {t("ui.admin.users.table.role", "ROLE")} - - - - - {isLoading ? ( - - - {t("msg.common.loading", "로딩 중...")} - + - ) : members.length === 0 ? ( - - -
- -

{t("msg.admin.users.list.empty", "멤버가 없습니다.")}

- -
-
-
- ) : ( - members.map((user) => ( + + + {members.map((user) => ( - {user.name} - - {user.email} - - {showTenant && ( - - - {user.tenantSlug} - - - )} - + {user.name} + {user.email} + {user.role} + + + + + + + + + + {t("ui.common.detail", "상세보기")} + + + + + - )) + ))} + +
+
+ ); +}; + +// --- Main Component --- + +function TenantUserGroupsTab() { + const { tenantId } = useParams<{ tenantId: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const [selectedNodeId, setSelectedNodeId] = useState(tenantId || ""); + const [treeSearch, setTreeSearch] = useState(""); + const [refreshMembersCount, setRefreshMembersCount] = useState(0); + + const [isUserAddOpen, setIsUserAddOpen] = useState(false); + const [isAddExistingOpen, setIsAddExistingOpen] = useState(false); + const [existingSearch, setExistingSearch] = useState(""); + + // Data Fetching + const { + data: allTenantsData, + isLoading: isTenantsLoading, + refetch: refetchTree, + } = useQuery({ + queryKey: ["tenants-full-tree-v2"], + queryFn: () => fetchTenants(1000, 0), + }); + + const { currentBase, subTree } = useMemo(() => { + const allItems = allTenantsData?.items ?? []; + return buildTenantFullTree(allItems, tenantId); + }, [allTenantsData, tenantId]); + + // Find selected node in the built tree + const findNode = (nodes: TenantNode[], id: string): TenantNode | null => { + if (!currentBase) return null; + if (currentBase.id === id) return currentBase; + + for (const node of nodes) { + if (node.id === id) return node; + if (node.children.length > 0) { + const found = findNode(node.children, id); + if (found) return found; + } + } + return null; + }; + + const selectedNode = useMemo(() => { + if (!currentBase) return null; + return findNode(currentBase.children, selectedNodeId) || currentBase; + }, [currentBase, selectedNodeId]); + + // Mutations + const updateParentMutation = useMutation({ + mutationFn: ({ + id, + parentId, + }: { id: string; parentId: string | undefined }) => + updateTenant(id, { parentId: parentId || "" }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] }); + toast.success( + t("msg.info.saved_success", "조직 구조가 업데이트되었습니다."), + ); + setIsAddExistingOpen(false); + }, + }); + + const handleRemoveNode = (id: string, name: string) => { + if ( + window.confirm( + t( + "msg.admin.tenants.remove_sub_confirm", + `${name} 조직을 하위에서 제외할까요?`, + { name }, + ), + ) + ) { + updateParentMutation.mutate({ id, parentId: undefined }); + if (selectedNodeId === id) setSelectedNodeId(tenantId || ""); + } + }; + + if (isTenantsLoading) + return ( +
+ {t("msg.common.loading", "조직 정보를 불러오는 중...")} +
+ ); + if (!currentBase) + return ( +
+ 테넌트를 찾을 수 없습니다. +
+ ); + + const candidates = (allTenantsData?.items ?? []).filter( + (t) => + t.id !== tenantId && + t.parentId !== tenantId && + (existingSearch === "" || + t.name.toLowerCase().includes(existingSearch.toLowerCase()) || + t.slug.toLowerCase().includes(existingSearch.toLowerCase())), + ); + + return ( +
+ {/* --- Left Panel: Sidebar Tree --- */} + + +
+ + + 조직도 + + +
+
+ + setTreeSearch(e.target.value)} + /> +
+
+ + + + + +
+ +
+
+ + {/* --- Right Panel: Selected Node Content --- */} +
+ {selectedNode ? ( + + +
+
+ {React.createElement(getTenantIcon(selectedNode.type), { + size: 24, + })} +
+
+
+ + {selectedNode.name} + + + {selectedNode.slug} + +
+ + + {selectedNode.recursiveMemberCount}{" "} + {t("ui.admin.tenants.table.members", "명")} + + | + + {t( + `domain.tenant_type.${selectedNode.type.toLowerCase()}`, + selectedNode.type, + )} + + +
+
+ +
+ + + + + + + + + {t("ui.common.manage", "조직 관리")} + + + + + + 상세 프로필로 이동 + + + {!selectedNode.parentId && ( + + + + {t("ui.admin.tenants.sub.add", "하위 부서 생성")} + + + )} + {selectedNode.id !== tenantId && ( + + handleRemoveNode(selectedNode.id, selectedNode.name) + } + > + + {t("ui.common.remove", "조직 계층에서 제외")} + + )} + + +
+
+ + + +
+ {selectedNode.children.length > 0 && ( +
+

+ + 하위 조직 ({selectedNode.children.length}) +

+
+ {selectedNode.children?.map((child) => ( + setSelectedNodeId(child.id)} + > + +
+
+ {React.createElement( + getTenantIcon(child.type), + { + size: 14, + className: "text-primary", + }, + )} +
+ + {child.recursiveMemberCount}{" "} + {t("ui.admin.tenants.table.members", "명")} + +
+ + {child.name} + + + {child.slug} + +
+
+ ))} +
+
+ )} + +
+

+ + {t("ui.admin.tenants.members.list_title", "소속 멤버")} +

+ +
+
+
+
+
+ ) : ( +
+
+ +

조직을 선택해 주세요.

+
+
)} - - -
-); +
+ + {/* --- Dialogs --- */} + { + setIsUserAddOpen(open); + if (!open) setRefreshMembersCount((prev) => prev + 1); + }} + /> + + + + + + {t("ui.admin.tenants.sub.add_existing", "기존 테넌트 연결")} + + + 기존에 생성된 테넌트를 [{currentBase.name}] 하위로 가져옵니다. + + +
+
+ + setExistingSearch(e.target.value)} + /> +
+ + + + {candidates?.map((tenantItem) => ( + + updateParentMutation.mutate({ + id: tenantItem.id, + parentId: tenantId, + }) + } + > + +
+ {React.createElement(getTenantIcon(tenantItem.type), { + size: 14, + className: "text-muted-foreground", + })} +
+

+ {tenantItem.name} +

+

+ {tenantItem.slug} +

+
+
+
+ + + +
+ ))} +
+
+
+
+
+
+ + ); +} + +// --- Internal Support Components --- const UserAddDialog: React.FC<{ tenantSlug: string; tenantName: string; - trigger?: React.ReactNode; - open?: boolean; - onOpenChange?: (open: boolean) => void; -}> = ({ tenantSlug, tenantName, trigger, open, onOpenChange }) => { + open: boolean; + onOpenChange: (open: boolean) => void; +}> = ({ tenantSlug, tenantName, open, onOpenChange }) => { const queryClient = useQueryClient(); - const [activeTab, setActiveTab] = useState("select"); - - // Create state - const [email, setEmail] = useState(""); - const [name, setName] = useState(""); - - // Select state const [userSearch, setUserSearch] = useState(""); const [isSearching, setIsSearching] = useState(false); const [searchResults, setSearchResults] = useState([]); const [selectedUserId, setSelectedUserId] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); const handleSearch = async () => { @@ -345,71 +699,17 @@ const UserAddDialog: React.FC<{ } }; - const handleCreate = async () => { - if (!email || !name) { - toast.error( - t( - "msg.admin.users.create.form.email_required", - "이메일과 이름은 필수입니다.", - ), - ); - return; - } - setIsSubmitting(true); - try { - const res = await createUser({ - email, - name, - tenantSlug: tenantSlug, - role: "user", - }); - toast.success( - t("msg.admin.users.create.success", "사용자가 생성되었습니다."), - { - description: res.initialPassword - ? `초기 비밀번호: ${res.initialPassword}` - : undefined, - }, - ); - - // Refresh tenant tree to update member counts - setTimeout(() => { - queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] }); - }, 1000); // Wait 1s for backend async sync - - onOpenChange?.(false); - resetFields(); - } catch (err: unknown) { - const error = err as { response?: { data?: { error?: string } } }; - toast.error( - error.response?.data?.error || - t("msg.admin.users.create.error", "사용자 생성 실패"), - ); - } finally { - setIsSubmitting(false); - } - }; - const handleAssign = async () => { if (!selectedUserId) return; setIsSubmitting(true); try { - await updateUser(selectedUserId, { tenantSlug: tenantSlug }); - toast.success( - t("msg.info.saved_success", "사용자가 테넌트에 배정되었습니다."), - ); - - // Refresh tenant tree to update member counts - setTimeout(() => { - queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] }); - }, 1000); // Wait 1s for backend async sync - - onOpenChange?.(false); + await updateUser(selectedUserId, { tenantSlug }); + toast.success(t("msg.info.saved_success", "사용자가 배정되었습니다.")); + onOpenChange(false); resetFields(); - } catch (err: unknown) { - const error = err as { response?: { data?: { error?: string } } }; + } catch (err: any) { toast.error( - error.response?.data?.error || + err.response?.data?.error || t("msg.admin.users.detail.update_error", "배정 실패"), ); } finally { @@ -418,8 +718,6 @@ const UserAddDialog: React.FC<{ }; const resetFields = () => { - setEmail(""); - setName(""); setUserSearch(""); setSearchResults([]); setSelectedUserId(null); @@ -429,727 +727,81 @@ const UserAddDialog: React.FC<{ { - onOpenChange?.(v); + onOpenChange(v); if (!v) resetFields(); }} > - {trigger && {trigger}} - {t("ui.admin.users.create.title", "사용자 추가")} + {t("ui.admin.users.create.title", "멤버 추가")} - [{tenantName}] 테넌트에 사용자를 등록하거나 기존 사용자를 - 배정합니다. + [{tenantName}] 조직에 기존 사용자를 배정합니다. - - - - {t("ui.common.select", "기존 사용자 선택")} - - - {t("ui.common.create", "신규 생성")} - - - - -
- setUserSearch(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - /> - -
- -
- - - {searchResults.length === 0 ? ( - - - {isSearching - ? t("msg.common.loading", "검색 중...") - : t( - "msg.admin.users.list.empty", - "사용자를 검색해 주세요.", - )} - - - ) : ( - searchResults.map((user) => ( - setSelectedUserId(user.id)} - > - -
-
- {user.name} -
-
- {user.email} -
- {user.tenantSlug && ( - - {user.tenantSlug} - - )} -
- {selectedUserId === user.id && ( - - )} -
-
- )) - )} -
-
-
-
- - -
- - setEmail(e.target.value)} - placeholder="user@example.com" - /> -
-
- - setName(e.target.value)} - placeholder="홍길동" - /> -
-
-
- +
+
+ setUserSearch(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + /> + +
+ + + + {searchResults?.map((user) => ( + setSelectedUserId(user.id)} + > + +
+
+

{user.name}

+

+ {user.email} +

+
+ {selectedUserId === user.id && ( + + )} +
+
+
+ ))} +
+
+
+
- - {activeTab === "create" ? ( - - ) : ( - - )} +
); }; -const TenantTreeRow: React.FC<{ - node: TenantNode; - level: number; - isRoot: boolean; - onRemove: (id: string, name: string) => void; - onMove: (id: string, newParentId: string) => void; - isUpdating: boolean; - searchTerm?: string; -}> = ({ node, level, isRoot, onRemove, onMove, isUpdating, searchTerm }) => { - const navigate = useNavigate(); - const [isExpanded, setIsExpanded] = useState(true); - const [isUserAddOpen, setIsUserAddOpen] = useState(false); - const [isMemberListOpen, setIsMemberListOpen] = useState(false); - const [isDragOver, setIsDragOver] = useState(false); - const hasChildren = node.children && node.children.length > 0; - - // Auto expand if search matches children - React.useEffect(() => { - if (searchTerm) { - const hasMatchingChild = (n: TenantNode): boolean => { - return n.children.some( - (c) => - c.name.toLowerCase().includes(searchTerm.toLowerCase()) || - c.slug.toLowerCase().includes(searchTerm.toLowerCase()) || - hasMatchingChild(c), - ); - }; - if (hasMatchingChild(node)) { - setIsExpanded(true); - } - } - }, [searchTerm, node]); - - const isMatching = - searchTerm && - (node.name.toLowerCase().includes(searchTerm.toLowerCase()) || - node.slug.toLowerCase().includes(searchTerm.toLowerCase())); - - const TypeIcon = getTenantIcon(node.type); - - // DnD Handlers - const handleDragStart = (e: React.DragEvent) => { - if (isRoot) return; - e.dataTransfer.setData("nodeId", node.id); - e.dataTransfer.setData("nodeName", node.name); - e.dataTransfer.effectAllowed = "move"; - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - if (isUpdating) return; - setIsDragOver(true); - }; - - const handleDragLeave = () => { - setIsDragOver(false); - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(false); - const draggedId = e.dataTransfer.getData("nodeId"); - if (!draggedId || draggedId === node.id) return; - onMove(draggedId, node.id); - }; - - const hoverTitle = `${node.name} (${node.type})\n${t("ui.admin.tenants.members.direct", "소속 멤버")}: ${node.memberCount || 0}\n${t("ui.admin.tenants.members.total", "총 멤버")}: ${node.recursiveMemberCount || 0}`; - - return ( - <> - - -
- {hasChildren ? ( - - ) : ( - level > 0 && ( -
-
-
- ) - )} - {!isRoot && ( - - )} -
- -
- {node.name} - {isRoot && ( - - Root - - )} - - {t(`domain.tenant_type.${node.type?.toLowerCase()}`, node.type)} - -
- - - {node.slug} - - - - - - - - {t(`ui.common.status.${node.status}`, node.status)} - - - -
- - - {!isRoot && ( - - )} -
- -
- - {isExpanded && - node.children.map((child) => ( - - ))} - - ); -}; - -function TenantUserGroupsTab() { - const { tenantId } = useParams<{ tenantId: string }>(); - const queryClient = useQueryClient(); - const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); - const [isHeaderUserAddOpen, setIsHeaderUserAddOpen] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const [treeSearchTerm, setTreeSearchTerm] = useState(""); - - if (!tenantId) return null; - - const { data, isLoading, refetch } = useQuery({ - queryKey: ["tenants-full-tree-v2"], - queryFn: () => fetchTenants(1000, 0), - }); - - const { data: groupsData, isLoading: isGroupsLoading } = useQuery({ - queryKey: ["tenant-groups", tenantId], - queryFn: () => fetchGroups(tenantId), - enabled: !!tenantId, - }); - - const groupNodes = useMemo(() => { - if (!groupsData) return []; - return groupsData.map((g) => ({ - ...g, - type: "USER_GROUP", - children: [], // Simplified for now, just a list or separate tree - memberCount: g.members?.length || 0, - recursiveMemberCount: g.members?.length || 0, - })) as unknown as TenantNode[]; - }, [groupsData]); - - const updateParentMutation = useMutation({ - mutationFn: ({ - id, - parentId, - }: { id: string; parentId: string | undefined }) => - updateTenant(id, { parentId: parentId || "" }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] }); - toast.success(t("msg.info.saved_success", "저장되었습니다.")); - setIsAddDialogOpen(false); - }, - onError: (error: AxiosError<{ error?: string }>) => { - toast.error(t("msg.common.error", "오류 발생"), { - description: error.response?.data?.error || error.message, - }); - }, - }); - - const allTenants = data?.items ?? []; - - const { currentBase, subTree } = useMemo(() => { - const tree = buildTenantFullTree(allTenants, tenantId); - if (tree.currentBase) { - // Merge backend-provided UserGroups into the tree as virtual children - tree.currentBase.children = [...tree.currentBase.children, ...groupNodes]; - } - return tree; - }, [allTenants, tenantId, groupNodes]); - - const handleAdd = (id: string) => - updateParentMutation.mutate({ id, parentId: tenantId }); - const handleMove = (id: string, newParentId: string) => { - if (id === newParentId) return; - updateParentMutation.mutate({ id, parentId: newParentId }); - }; - const handleRemove = (id: string, name: string) => { - if ( - window.confirm( - t( - "msg.admin.tenants.remove_sub_confirm", - `${name} 테넌트를 하위 조직에서 제외할까요?`, - { name }, - ), - ) - ) { - updateParentMutation.mutate({ id, parentId: undefined }); - } - }; - - if (isLoading) - return ( -
- {t("msg.common.loading", "로딩 중...")} -
- ); - if (!currentBase) - return ( -
- {t("msg.admin.tenants.not_found", "현재 테넌트를 찾을 수 없습니다.")}{" "} - (ID: {tenantId}) -
- ); - - const candidates = allTenants.filter((t) => { - if (t.id === tenantId) return false; - // Check if it's already a child - if (t.parentId === tenantId) return false; - // Basic search - if (searchTerm === "") return true; - return ( - t.name.toLowerCase().includes(searchTerm.toLowerCase()) || - t.slug.toLowerCase().includes(searchTerm.toLowerCase()) - ); - }); - - const BaseIcon = getTenantIcon(currentBase.type); - - return ( -
- - -
- - - {t("ui.admin.tenants.sub.title", "조직 계층 구조", { - count: subTree.length, - })} - - - {t( - "msg.admin.tenants.sub.subtitle", - "하위 조직망을 구성하고 관리합니다.", - )} - -
-
- - - - - - - - - - - - - - - {t( - "ui.admin.tenants.sub.add_dialog_title", - "하위 테넌트 추가", - )} - - - {t( - "ui.admin.tenants.sub.add_dialog_desc", - "기존에 등록된 테넌트를 검색하여 하위 조직으로 추가합니다.", - )} - - -
-
- - setSearchTerm(e.target.value)} - /> -
-
- - - {candidates.length === 0 ? ( - - - {t( - "ui.admin.tenants.sub.no_candidates", - "검색 결과 없음", - )} - - - ) : ( - candidates.map((tenant) => { - const CandidateIcon = getTenantIcon(tenant.type); - return ( - - -
-
- -
-
-
- {tenant.name} -
-
- {tenant.slug} -
-
-
-
- - - {t( - `domain.tenant_type.${tenant.type?.toLowerCase()}`, - tenant.type, - )} - - - - - -
- ); - }) - )} -
-
-
-
-
-
-
-
-
-
- - setTreeSearchTerm(e.target.value)} - /> -
- {treeSearchTerm && ( - - )} -
- -
-
- - - - - {t("ui.admin.tenants.table.name", "NAME")} - - - {t("ui.admin.tenants.table.slug", "SLUG")} - - - {t("ui.admin.tenants.table.members", "MEMBERS")} - - - {t("ui.admin.tenants.table.status", "STATUS")} - - - {t("ui.admin.tenants.table.actions", "ACTIONS")} - - - - - - -
-
-
-
-
-
- ); -} - export default TenantUserGroupsTab; diff --git a/docs/baron-orgchart-data-flow.md b/docs/baron-orgchart-data-flow.md new file mode 100644 index 00000000..f4168b86 --- /dev/null +++ b/docs/baron-orgchart-data-flow.md @@ -0,0 +1,57 @@ +# Baron-Orgchart 데이터 흐름 및 아키텍처 + +이 문서는 조직도 뷰어 프론트엔드(`baron-orgchart`, OrgFront)와 Baron SSO 통합 인증/백엔드 서버 간의 인증 및 데이터 통신 흐름을 설명합니다. + +`baron-orgchart`는 자체적인 데이터베이스나 권한 관리 로직을 가지지 않으며, Baron SSO 백엔드에 전적으로 의존하는 순수 표현 계층(Presentation Layer)으로 동작합니다. + +## 아키텍처 및 데이터 흐름도 + +```mermaid +sequenceDiagram + participant User as 사용자 (Browser) + box rgba(173, 216, 230, 0.2) 외부 클라이언트 (표현 계층) + participant OrgFront as OrgFront
(baron-orgchart) + end + box rgba(255, 228, 196, 0.2) Baron SSO (통합 인증 및 권한 중앙 서버) + participant UI as UserFront
(SSO 로그인 화면) + participant Hydra as Ory Hydra
(OAuth2/OIDC) + participant Kratos as Ory Kratos
(Identity/Session) + participant BE as Backend API
(baron_backend:3000) + participant Keto as Ory Keto
(권한/조직 관계) + participant DB as PostgreSQL
(메타데이터) + end + + %% 1. 인증 흐름 + User->>OrgFront: 1. 조직도 웹사이트 접속 + OrgFront->>Hydra: 2. 로그인되지 않음. Authorization Code 요청 (OIDC) + Hydra-->>User: 3. UserFront(로그인 화면)로 리다이렉트 + User->>UI: 4. ID/비밀번호 입력 + UI->>Kratos: 5. 로그인 검증 및 세션 생성 + UI-->>User: 6. 로그인 성공, OrgFront Callback으로 리다이렉트 + User->>OrgFront: 7. Authorization Code 전달 + OrgFront->>Hydra: 8. Code를 Access Token으로 교환 + Hydra-->>OrgFront: 9. Access Token 발급 완료 + + %% 2. 조직도 데이터 요청 흐름 + User->>OrgFront: 10. 조직도 페이지 접근 + OrgFront->>BE: 11. API 호출: GET /user-groups
(헤더: Authorization Bearer [Token]) + + %% 백엔드 내부 데이터 조합 + Note over BE,DB: 백엔드가 조직도 데이터를 취합하는 과정 + BE->>Keto: 12. Token(사용자 ID) 기반 접근 권한 검증 + BE->>DB: 13. 테넌트/부서 트리 메타데이터 조회 + BE->>Keto: 14. 각 부서별 멤버십(Member) ID 조회 + BE->>Kratos: 15. 멤버 ID로 프로필(이름/직급 등) 정보 조회 + + BE-->>OrgFront: 16. 조합된 최종 조직도 JSON 반환 + Note over OrgFront: 17. 데이터를 바탕으로
트리/차트 UI 렌더링 (순수 표현) + OrgFront-->>User: 18. 조직도 화면 표시 +``` + +## 주요 설명 + +1. **표현 계층 (파란색 영역):** `baron-orgchart` (OrgFront)는 자체 DB를 조회하지 않고, 브라우저의 요청을 받아 화면을 그리는 역할만 담당하는 React/Vite(또는 유사 프레임워크) 애플리케이션입니다. +2. **통합 관리 (주황색 영역):** + * 조직도 웹사이트(OrgFront)에 접근하려면 `Ory Hydra`와 `UserFront`를 통해 먼저 SSO 로그인을 완료해야 합니다. + * 인증 토큰을 받아 API를 호출하면, **`baron-sso` 백엔드**가 DB, Keto(조직 관계망), Kratos(사용자 프로필) 등 흩어진 데이터를 하나로 뭉쳐(Aggregation) JSON 형태로 내려줍니다. +3. **API Proxy 통신:** `docker-compose.yaml` 설정의 `API_PROXY_TARGET=http://baron_backend:3000` 환경 변수를 통해, OrgFront로 들어온 데이터 API 요청은 모두 안전하게 SSO 백엔드로 프록시(Proxy) 처리됩니다.