From 9ff49230fce52602fcff953d3634ce58136ebc3a Mon Sep 17 00:00:00 2001 From: chan Date: Mon, 13 Apr 2026 11:20:41 +0900 Subject: [PATCH] feat(adminfront): map multi-tenant users onto Tenant Full Tree Reimplements the Org Chart using the Tenant Full Tree API and maps users to all their respective tenants (including joinedTenants). Multi-tenant users are duplicated correctly across nodes they belong to. --- .../tenants/routes/TenantOrgChartPage.tsx | 230 +++++++++--------- 1 file changed, 111 insertions(+), 119 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx b/adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx index 97be1a17..ed8cbcd3 100644 --- a/adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx @@ -3,22 +3,26 @@ import { ChevronLeft } from "lucide-react"; import * as React from "react"; import { Link } from "react-router-dom"; import { Button } from "../../../components/ui/button"; -import { type UserSummary, fetchUsers } from "../../../lib/adminApi"; +import { + type UserSummary, + fetchTenants, + fetchUsers, +} from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; - -type UserWithPath = UserSummary & { _path: { level: number; name: string }[] }; +import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree"; interface OrgNode { + id: string; name: string; level: number; - members: UserWithPath[]; - subData: UserWithPath[]; + members: UserSummary[]; children: OrgNode[]; totalCount?: number; + companyCode?: string; + type?: string; } export function TenantOrgChartPage() { - const [selectedDept, setSelectedDept] = React.useState("전체"); const containerRef = React.useRef(null); const [lines, setLines] = React.useState< { @@ -32,82 +36,83 @@ export function TenantOrgChartPage() { >([]); const [svgSize, setSvgSize] = React.useState({ width: 0, height: 0 }); - const query = useQuery({ + const tenantsQuery = useQuery({ + queryKey: ["tenants-full-tree-v2"], + queryFn: () => fetchTenants(10000, 0), + }); + + const usersQuery = useQuery({ queryKey: ["users", { limit: 5000, offset: 0 }], queryFn: () => fetchUsers(5000, 0), }); - const users = React.useMemo(() => { - if (!query.data?.items) return []; - return query.data.items - .filter((u) => u.status === "active") - .map((u) => { - const deptStr = u.department || ""; - const parts = deptStr.includes(" > ") - ? deptStr.split(" > ") - : deptStr.split("/"); - - return { - ...u, - _path: parts - .map((name, i) => ({ level: i, name: name.trim() })) - .filter((p) => p.name), - }; - }); - }, [query.data]); - - const depts = React.useMemo(() => { - const s = new Set(); - for (const u of users) { - if (u._path[0]) s.add(u._path[0].name); + const { rootNodes, usersMap } = React.useMemo(() => { + if (!tenantsQuery.data?.items || !usersQuery.data?.items) { + return { rootNodes: [], usersMap: new Map() }; } - return Array.from(s).sort(); - }, [users]); - React.useEffect(() => { - if (selectedDept !== "전체" && !depts.includes(selectedDept)) { - setSelectedDept("전체"); - } - }, [selectedDept, depts]); + const uMap = new Map(); - const buildHierarchy = (data: UserWithPath[], depth: number): OrgNode[] => { - if (!data.length) return []; - const map: Record = {}; - const groups: OrgNode[] = []; + // Process users to map them to multiple tenants if applicable + for (const u of usersQuery.data.items) { + if (u.status !== "active") continue; - for (const m of data) { - const step = m._path[depth]; - if (!step) continue; - if (!map[step.name]) { - map[step.name] = { - name: step.name, - level: step.level, - members: [], - subData: [], - children: [], - }; - groups.push(map[step.name]); + // Extract all associated tenant slugs + const slugs = new Set(); + + const primarySlug = + u.tenantSlug?.toLowerCase() || u.companyCode?.toLowerCase() || ""; + if (primarySlug) { + slugs.add(primarySlug); } - if (m._path.length === depth + 1) { - map[step.name].members.push(m); - } else { - map[step.name].subData.push(m); + + if (u.joinedTenants && Array.isArray(u.joinedTenants)) { + for (const jt of u.joinedTenants) { + if (jt.slug) { + slugs.add(jt.slug.toLowerCase()); + } + } + } + + // Add user to all matching slugs in the map + for (const slug of slugs) { + const list = uMap.get(slug) || []; + // Prevent duplicate user references in the same list + if (!list.some((existing) => existing.id === u.id)) { + list.push(u); + } + uMap.set(slug, list); } } - return groups.map((g) => ({ - ...g, - children: buildHierarchy(g.subData, depth + 1), - })); - }; + const allTenants = tenantsQuery.data.items; + const { subTree: roots } = buildTenantFullTree(allTenants); - const calculateTotalCount = (node: OrgNode): number => { - let count = node.members.length; - for (const c of node.children) { - count += calculateTotalCount(c); + return { rootNodes: roots, usersMap: uMap }; + }, [tenantsQuery.data, usersQuery.data]); + + const buildHierarchy = (tNode: TenantNode, depth: number): OrgNode => { + const slug = tNode.slug.toLowerCase(); + const members = usersMap.get(slug) || []; + + const children = tNode.children.map((c) => buildHierarchy(c, depth + 1)); + + // Calculate recursive total users instead of simple tenant count to account for actual mapped members + let recursiveTotal = members.length; + for (const child of children) { + recursiveTotal += child.totalCount || 0; } - node.totalCount = count; - return count; + + return { + id: tNode.id, + name: tNode.name, + level: depth, + members, + children, + totalCount: recursiveTotal, + companyCode: slug, + type: tNode.type, + }; }; const drawLines = React.useCallback(() => { @@ -184,28 +189,24 @@ export function TenantOrgChartPage() { }, []); React.useLayoutEffect(() => { - // Biome requires used variables. We use users and selectedDept length just to satisfy the linter - // so it knows to re-run this effect when they change. - const _forceTrigger = selectedDept + users.length; - + const _forceTrigger = rootNodes.length + usersMap.size; const timeout = setTimeout(drawLines, 150); window.addEventListener("resize", drawLines); return () => { clearTimeout(timeout); window.removeEventListener("resize", drawLines); }; - }, [drawLines, selectedDept, users]); + }, [drawLines, rootNodes.length, usersMap.size]); - if (query.isLoading) { + if (tenantsQuery.isLoading || usersQuery.isLoading) { return (
로딩 중...
); } - const targetDepts = selectedDept === "전체" ? depts : [selectedDept]; - const totalUsers = targetDepts.reduce((acc, d) => { - return acc + users.filter((u) => u._path[0]?.name === d).length; - }, 0); + // Count unique users across the fetched payload + const totalUniqueUsers = + usersQuery.data?.items?.filter((u) => u.status === "active").length || 0; return (
@@ -214,30 +215,13 @@ export function TenantOrgChartPage() {

조직도

- 조직 구조를 효율적인 세로 계층형으로 시각화합니다. + 조직(테넌트) 계층 구조를 기반으로 사용자들의 소속을 시각화합니다.

- {["전체", ...depts].map((d) => ( - - ))}
- 총 {totalUsers}명 + 총 {totalUniqueUsers}명
@@ -267,17 +251,15 @@ export function TenantOrgChartPage() {
- {targetDepts.map((dName) => { - const dData = users.filter((u) => u._path[0]?.name === dName); - const hierarchy = buildHierarchy(dData, 0); - const dNode = hierarchy[0]; - if (!dNode) return null; - calculateTotalCount(dNode); - + {rootNodes.map((tNode) => { + const orgNode = buildHierarchy(tNode, 0); return ( -
+
@@ -305,7 +287,7 @@ const ROLE_ORDER = [ "사원", ]; -function getRankWeight(u: UserWithPath) { +function getRankWeight(u: UserSummary) { const role = u.position || ""; let idx = ROLE_ORDER.indexOf(role); if (idx === -1) idx = 99; @@ -323,7 +305,7 @@ function OrgNodeView({ onToggle: () => void; }) { const [collapsed, setCollapsed] = React.useState(false); - const myId = `node-${node.level}-${node.name.replace(/\s/g, "")}`; + const myId = `node-${node.level}-${node.id}`; const toggle = () => { setCollapsed(!collapsed); @@ -340,10 +322,9 @@ function OrgNodeView({ (a, b) => getRankWeight(a) - getRankWeight(b), ); - const isVerticalChildren = node.level >= 1; // Children of Level 1+ are vertical - const isVerticallyStacked = node.level >= 1; // Level 1+ are vertically stacked inside parent + const isVerticalChildren = node.level >= 1; + const isVerticallyStacked = node.level >= 1; - // 하위 조직이 모두 말단(Leaf) 조직일 경우, 부모 박스 내부에 회색 그룹으로 묶어서(임베딩) 표시합니다. const embedChildren = node.children.length > 0 && node.children.every((c) => c.children.length === 0); @@ -373,14 +354,18 @@ function OrgNodeView({ > {node.name} - ({node.totalCount}) + ({node.totalCount || node.members.length}) {!collapsed && membersToShow.length > 0 && (
{membersToShow.map((m) => ( - + ))}
)} @@ -393,19 +378,23 @@ function OrgNodeView({ ); return (
{child.name} - ({child.totalCount}) + ({child.totalCount || child.members.length})
{childMembers.length > 0 && (
{childMembers.map((m) => ( - + ))}
)} @@ -426,7 +415,7 @@ function OrgNodeView({ > {node.children.map((c) => ( { - const c = (member.companyCode || "").toLowerCase(); + const c = (companyCode || member.companyCode || "").toLowerCase(); if (c.includes("hanmac")) return "bg-[#1E3A8A] text-white border-[#1E3A8A]"; if (c.includes("saman")) return "bg-[#047857] text-white border-[#047857]"; if (c.includes("ptc")) return "bg-[#C2410C] text-white border-[#C2410C]";