From b08516f55756a94e4b3f99a2b41cffff9150687d Mon Sep 17 00:00:00 2001 From: chan Date: Mon, 13 Apr 2026 11:07:26 +0900 Subject: [PATCH] Revert "feat(adminfront): rebuild Org Chart strictly based on Tenant Full Tree" This reverts commit 3bd8724d451430993919932abe75c18e091c6fce. --- .../tenants/routes/TenantOrgChartPage.tsx | 210 +++++++++++------- 1 file changed, 124 insertions(+), 86 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx b/adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx index 294e0880..97be1a17 100644 --- a/adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx @@ -3,26 +3,22 @@ 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, - fetchTenants, - fetchUsers, -} from "../../../lib/adminApi"; +import { type UserSummary, fetchUsers } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; -import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree"; + +type UserWithPath = UserSummary & { _path: { level: number; name: string }[] }; interface OrgNode { - id: string; name: string; level: number; - members: UserSummary[]; + members: UserWithPath[]; + subData: UserWithPath[]; 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< { @@ -36,55 +32,82 @@ export function TenantOrgChartPage() { >([]); const [svgSize, setSvgSize] = React.useState({ width: 0, height: 0 }); - const tenantsQuery = useQuery({ - queryKey: ["tenants-full-tree-v2"], - queryFn: () => fetchTenants(10000, 0), - }); - - const usersQuery = useQuery({ + const query = useQuery({ queryKey: ["users", { limit: 5000, offset: 0 }], queryFn: () => fetchUsers(5000, 0), }); - const { rootNodes, usersMap } = React.useMemo(() => { - if (!tenantsQuery.data?.items || !usersQuery.data?.items) { - return { rootNodes: [], usersMap: new Map() }; + 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); + } + return Array.from(s).sort(); + }, [users]); + + React.useEffect(() => { + if (selectedDept !== "전체" && !depts.includes(selectedDept)) { + setSelectedDept("전체"); + } + }, [selectedDept, depts]); + + const buildHierarchy = (data: UserWithPath[], depth: number): OrgNode[] => { + if (!data.length) return []; + const map: Record = {}; + const groups: OrgNode[] = []; + + 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]); + } + if (m._path.length === depth + 1) { + map[step.name].members.push(m); + } else { + map[step.name].subData.push(m); + } } - const uMap = new Map(); - for (const u of usersQuery.data.items) { - if (u.status !== "active") continue; - const slug = - u.tenantSlug?.toLowerCase() || u.companyCode?.toLowerCase() || ""; - if (!slug) continue; + return groups.map((g) => ({ + ...g, + children: buildHierarchy(g.subData, depth + 1), + })); + }; - const list = uMap.get(slug) || []; - list.push(u); - uMap.set(slug, list); + const calculateTotalCount = (node: OrgNode): number => { + let count = node.members.length; + for (const c of node.children) { + count += calculateTotalCount(c); } - - const allTenants = tenantsQuery.data.items; - const { subTree: roots } = buildTenantFullTree(allTenants); - - 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)); - - return { - id: tNode.id, - name: tNode.name, - level: depth, - members, - children, - totalCount: tNode.recursiveMemberCount, - companyCode: slug, - type: tNode.type, - }; + node.totalCount = count; + return count; }; const drawLines = React.useCallback(() => { @@ -161,22 +184,28 @@ export function TenantOrgChartPage() { }, []); React.useLayoutEffect(() => { - const _forceTrigger = rootNodes.length + usersMap.size; + // 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 timeout = setTimeout(drawLines, 150); window.addEventListener("resize", drawLines); return () => { clearTimeout(timeout); window.removeEventListener("resize", drawLines); }; - }, [drawLines, rootNodes.length, usersMap.size]); + }, [drawLines, selectedDept, users]); - if (tenantsQuery.isLoading || usersQuery.isLoading) { + if (query.isLoading) { return (
로딩 중...
); } - const totalUsers = usersQuery.data?.items?.length || 0; + const targetDepts = selectedDept === "전체" ? depts : [selectedDept]; + const totalUsers = targetDepts.reduce((acc, d) => { + return acc + users.filter((u) => u._path[0]?.name === d).length; + }, 0); return (
@@ -185,11 +214,28 @@ export function TenantOrgChartPage() {

조직도

- 실제 테넌트 계층 구조를 기반으로 조직도를 시각화합니다. + 조직 구조를 효율적인 세로 계층형으로 시각화합니다.

+ {["전체", ...depts].map((d) => ( + + ))}
총 {totalUsers}명
@@ -221,15 +267,17 @@ export function TenantOrgChartPage() {
- {rootNodes.map((tNode) => { - const orgNode = buildHierarchy(tNode, 0); + {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); + return ( -
+
@@ -257,7 +305,7 @@ const ROLE_ORDER = [ "사원", ]; -function getRankWeight(u: UserSummary) { +function getRankWeight(u: UserWithPath) { const role = u.position || ""; let idx = ROLE_ORDER.indexOf(role); if (idx === -1) idx = 99; @@ -275,7 +323,7 @@ function OrgNodeView({ onToggle: () => void; }) { const [collapsed, setCollapsed] = React.useState(false); - const myId = `node-${node.level}-${node.id}`; + const myId = `node-${node.level}-${node.name.replace(/\s/g, "")}`; const toggle = () => { setCollapsed(!collapsed); @@ -292,9 +340,10 @@ function OrgNodeView({ (a, b) => getRankWeight(a) - getRankWeight(b), ); - const isVerticalChildren = node.level >= 1; - const isVerticallyStacked = node.level >= 1; + const isVerticalChildren = node.level >= 1; // Children of Level 1+ are vertical + const isVerticallyStacked = node.level >= 1; // Level 1+ are vertically stacked inside parent + // 하위 조직이 모두 말단(Leaf) 조직일 경우, 부모 박스 내부에 회색 그룹으로 묶어서(임베딩) 표시합니다. const embedChildren = node.children.length > 0 && node.children.every((c) => c.children.length === 0); @@ -324,18 +373,14 @@ function OrgNodeView({ > {node.name} - ({node.totalCount || node.members.length}) + ({node.totalCount}) {!collapsed && membersToShow.length > 0 && (
{membersToShow.map((m) => ( - + ))}
)} @@ -348,23 +393,19 @@ function OrgNodeView({ ); return (
{child.name} - ({child.totalCount || child.members.length}) + ({child.totalCount})
{childMembers.length > 0 && (
{childMembers.map((m) => ( - + ))}
)} @@ -385,7 +426,7 @@ function OrgNodeView({ > {node.children.map((c) => ( { - const c = (companyCode || member.companyCode || "").toLowerCase(); + const c = (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]";