diff --git a/adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx b/adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx index 97be1a17..294e0880 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,55 @@ 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); - } - 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 { rootNodes, usersMap } = React.useMemo(() => { + if (!tenantsQuery.data?.items || !usersQuery.data?.items) { + return { rootNodes: [], usersMap: new Map() }; } - return groups.map((g) => ({ - ...g, - children: buildHierarchy(g.subData, depth + 1), - })); - }; + 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; - const calculateTotalCount = (node: OrgNode): number => { - let count = node.members.length; - for (const c of node.children) { - count += calculateTotalCount(c); + const list = uMap.get(slug) || []; + list.push(u); + uMap.set(slug, list); } - node.totalCount = count; - return count; + + 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, + }; }; const drawLines = React.useCallback(() => { @@ -184,28 +161,22 @@ 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); + const totalUsers = usersQuery.data?.items?.length || 0; return (
@@ -214,28 +185,11 @@ export function TenantOrgChartPage() {

조직도

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

- {["전체", ...depts].map((d) => ( - - ))}
총 {totalUsers}명
@@ -267,17 +221,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 +257,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 +275,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 +292,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 +324,18 @@ function OrgNodeView({ > {node.name} - ({node.totalCount}) + ({node.totalCount || node.members.length}) {!collapsed && membersToShow.length > 0 && (
{membersToShow.map((m) => ( - + ))}
)} @@ -393,19 +348,23 @@ function OrgNodeView({ ); return (
{child.name} - ({child.totalCount}) + ({child.totalCount || child.members.length})
{childMembers.length > 0 && (
{childMembers.map((m) => ( - + ))}
)} @@ -426,7 +385,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]";