import { useQuery } from "@tanstack/react-query"; import { Check, ChevronDown, ChevronRight, Search, X } from "lucide-react"; import * as React from "react"; import { useLocation } from "react-router-dom"; import { Button } from "../../../components/ui/button"; import { fetchTenants, fetchUsers } from "../../../lib/adminApi"; import { buildOrgPickerTree, flattenDescendants } from "../pickerTree"; import { type OrgPickerEmbedOptions, type OrgPickerMode, type OrgPickerResult, type OrgPickerSelectableType, type OrgPickerSelection, type OrgPickerTreeNode, buildOrgPickerEmbedSrc, nodeKey, parseOrgPickerEmbedOptions, parseOrgPickerMode, parseOrgPickerSelectableType, } from "../pickerTypes"; function canSelectNode( node: OrgPickerTreeNode, select: OrgPickerSelectableType, ) { return select === "both" || select === node.type; } function toSelection(node: OrgPickerTreeNode): OrgPickerSelection { return { type: node.type, id: node.id, name: node.name, }; } function collectSelectedNodes({ roots, selectedKeys, includeDescendants, select, }: { roots: OrgPickerTreeNode[]; selectedKeys: Set; includeDescendants: boolean; select: OrgPickerSelectableType; }) { const selected = new Map(); const visit = (node: OrgPickerTreeNode) => { const key = nodeKey(node); if (selectedKeys.has(key) && canSelectNode(node, select)) { selected.set(key, node); if (includeDescendants && node.type === "tenant") { for (const descendant of flattenDescendants(node)) { if (canSelectNode(descendant, select)) { selected.set(nodeKey(descendant), descendant); } } } } for (const child of node.children) visit(child); }; for (const root of roots) visit(root); return Array.from(selected.values()).map(toSelection); } function collectCheckedKeys({ roots, selectedKeys, includeDescendants, select, }: { roots: OrgPickerTreeNode[]; selectedKeys: Set; includeDescendants: boolean; select: OrgPickerSelectableType; }) { const checkedKeys = new Set(selectedKeys); if (!includeDescendants) return checkedKeys; const visit = (node: OrgPickerTreeNode) => { const key = nodeKey(node); if (selectedKeys.has(key) && node.type === "tenant") { for (const descendant of flattenDescendants(node)) { if (canSelectNode(descendant, select)) { checkedKeys.add(nodeKey(descendant)); } } } for (const child of node.children) visit(child); }; for (const root of roots) visit(root); return checkedKeys; } function postPickerMessage(message: unknown) { window.parent.postMessage(message, "*"); } function collectSearchValues(value: unknown, depth = 0): string[] { if (value == null || depth > 4) return []; if ( typeof value === "string" || typeof value === "number" || typeof value === "boolean" ) { return [String(value)]; } if (Array.isArray(value)) { return value.flatMap((item) => collectSearchValues(item, depth + 1)); } if (typeof value === "object") { return Object.entries(value as Record).flatMap( ([key, item]) => [key, ...collectSearchValues(item, depth + 1)], ); } return []; } function getNodeSearchValues(node: OrgPickerTreeNode) { const tenantSearchValues = node.tenant ? collectSearchValues({ id: node.tenant.id, type: node.tenant.type, name: node.tenant.name, slug: node.tenant.slug, description: node.tenant.description, status: node.tenant.status, domains: node.tenant.domains, parentId: node.tenant.parentId, config: node.tenant.config, memberCount: node.tenant.memberCount, createdAt: node.tenant.createdAt, updatedAt: node.tenant.updatedAt, }) : []; return [ node.type, node.id, node.name, node.parentId ?? "", ...tenantSearchValues, ...collectSearchValues(node.user), ].map((value) => value.toLowerCase()); } function nodeMatchesSearch(node: OrgPickerTreeNode, query: string) { return getNodeSearchValues(node).some((value) => value.includes(query)); } function filterPickerTree( roots: OrgPickerTreeNode[], rawQuery: string, ): OrgPickerTreeNode[] { const query = rawQuery.trim().toLowerCase(); if (!query) return roots; const filterNode = (node: OrgPickerTreeNode): OrgPickerTreeNode | null => { const filteredChildren = node.children .map(filterNode) .filter((child): child is OrgPickerTreeNode => Boolean(child)); if (nodeMatchesSearch(node, query) || filteredChildren.length > 0) { return { ...node, children: filteredChildren, }; } return null; }; return roots .map(filterNode) .filter((node): node is OrgPickerTreeNode => Boolean(node)); } function OrgPickerTree({ roots, mode, select, selectedKeys, onSingleSelect, onToggle, }: { roots: OrgPickerTreeNode[]; mode: OrgPickerMode; select: OrgPickerSelectableType; selectedKeys: Set; onSingleSelect: (node: OrgPickerTreeNode) => void; onToggle: (node: OrgPickerTreeNode, checked: boolean) => void; }) { return (
{roots.map((node) => ( ))}
); } function OrgPickerTreeItem({ node, mode, select, selectedKeys, onSingleSelect, onToggle, depth = 0, }: { node: OrgPickerTreeNode; mode: OrgPickerMode; select: OrgPickerSelectableType; selectedKeys: Set; onSingleSelect: (node: OrgPickerTreeNode) => void; onToggle: (node: OrgPickerTreeNode, checked: boolean) => void; depth?: number; }) { const [isOpen, setIsOpen] = React.useState(true); const selectable = canSelectNode(node, select); const hasChildren = node.children.length > 0; const key = nodeKey(node); const checked = selectedKeys.has(key); const label = `${node.name} 선택`; const email = node.type === "user" ? node.user?.email : undefined; const nameTestId = node.type === "tenant" ? "org-picker-node-name-tenant" : "org-picker-node-name-user"; const content = ( {node.name} {email ? ( {email} ) : null} ); return (
0 ? "pl-4" : "pl-1"}`} data-selected={mode === "single" && checked ? "true" : undefined} > {hasChildren ? ( ) : (
{isOpen && hasChildren ? (
{node.children.map((child) => ( ))}
) : null}
); } export function OrgPickerEmbedPage() { const location = useLocation(); const searchParams = new URLSearchParams(location.search); const mode = parseOrgPickerMode(searchParams.get("mode")); const select = parseOrgPickerSelectableType(searchParams.get("select")); const rootTenantId = searchParams.get("rootTenantId") || undefined; const tenantId = searchParams.get("tenantSlug") || searchParams.get("tenantId") || searchParams.get("companyTenantId") || undefined; const [includeDescendants, setIncludeDescendants] = React.useState( searchParams.get("includeDescendants") !== "false", ); const showDescendantToggle = searchParams.get("showDescendantToggle") !== "false"; const [searchQuery, setSearchQuery] = React.useState(""); const [selectedKeys, setSelectedKeys] = React.useState>( () => new Set(), ); const tenantsQuery = useQuery({ queryKey: ["org-picker-tenants"], queryFn: () => fetchTenants(10000, 0), }); const usersQuery = useQuery({ queryKey: ["org-picker-users"], queryFn: () => fetchUsers(5000, 0), }); React.useEffect(() => { postPickerMessage({ type: "orgfront:picker:ready" }); }, []); const tree = React.useMemo(() => { return buildOrgPickerTree({ tenants: tenantsQuery.data?.items ?? [], users: select === "tenant" ? [] : (usersQuery.data?.items ?? []), rootTenantId, tenantId, }); }, [rootTenantId, select, tenantId, tenantsQuery.data, usersQuery.data]); const selectedItems = React.useMemo( () => collectSelectedNodes({ roots: tree.roots, selectedKeys, includeDescendants: mode === "multiple" && includeDescendants, select, }), [includeDescendants, mode, select, selectedKeys, tree.roots], ); const checkedKeys = React.useMemo( () => collectCheckedKeys({ roots: tree.roots, selectedKeys, includeDescendants: mode === "multiple" && includeDescendants, select, }), [includeDescendants, mode, select, selectedKeys, tree.roots], ); const filteredRoots = React.useMemo( () => filterPickerTree(tree.roots, searchQuery), [searchQuery, tree.roots], ); const handleSingleSelect = (node: OrgPickerTreeNode) => { setSelectedKeys(new Set([nodeKey(node)])); }; const handleToggle = (node: OrgPickerTreeNode, checked: boolean) => { setSelectedKeys((current) => { const next = new Set(current); const key = nodeKey(node); if (checked) next.add(key); else next.delete(key); return next; }); }; const confirmSelection = () => { const payload: OrgPickerResult = { mode, selections: selectedItems, }; postPickerMessage({ type: "orgfront:picker:confirm", payload }); }; const cancelSelection = () => { postPickerMessage({ type: "orgfront:picker:cancel" }); }; const isLoading = tenantsQuery.isLoading || usersQuery.isLoading; const isError = tenantsQuery.isError || usersQuery.isError; React.useEffect(() => { const htmlOverflow = document.documentElement.style.overflow; const bodyOverflow = document.body.style.overflow; document.documentElement.style.overflow = "hidden"; document.body.style.overflow = "hidden"; return () => { document.documentElement.style.overflow = htmlOverflow; document.body.style.overflow = bodyOverflow; }; }, []); React.useEffect(() => { if (!isError) return; postPickerMessage({ type: "orgfront:picker:error", error: "org_picker_load_failed", }); }, [isError]); if (isLoading) { return (
조직 선택기를 불러오는 중...
); } if (isError) { return (
조직 선택기를 불러올 수 없습니다.
); } return (
{mode === "multiple" && showDescendantToggle ? ( ) : null}
{filteredRoots.length > 0 ? ( ) : (
검색 결과가 없습니다.
)}
{selectedItems.length > 0 ? `${selectedItems.length}개 항목 선택됨` : "선택된 항목이 없습니다."}
); } export function OrgPickerPage() { const location = useLocation(); const shareToken = new URLSearchParams(location.search).get("token"); const [options, setOptions] = React.useState(() => parseOrgPickerEmbedOptions(location.search), ); const pickerSrcBase = buildOrgPickerEmbedSrc(options); const pickerSrc = shareToken ? `${pickerSrcBase}&token=${encodeURIComponent(shareToken)}` : pickerSrcBase; return (

Picker Workbench

조직 선택기

{pickerSrc}