import { useQuery } from "@tanstack/react-query"; import type { Node as ReactFlowNode } from "@xyflow/react"; import * as React from "react"; import { useLocation, useParams } from "react-router-dom"; import { type TenantSummary, type UserSummary, fetchAllTenants, fetchPublicOrgChart, fetchUsers, } from "../../../lib/adminApi"; import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree"; import { orderHanmacFamilyChildren, orderHanmacFamilyTenants, } from "../hanmacFamilyOrder"; import { filterTenantsByVisibility, getOrgUnitType } from "../tenantVisibility"; import { getOrgChartUserDisplayName, getUserOrgProfile } from "../userDisplay"; export type OrgNode = { id: string; name: string; level: number; members: UserSummary[]; children: OrgNode[]; totalCount: number; totalMemberIds: Set; companyCode?: string; companyColorDepth?: number; companyColorKey?: string; orgUnitType?: string; type?: string; }; type ViewBox = { x: number; y: number; width: number; height: number; }; type OrgSelectionDescendantOption = { depth: 1 | 2; id: string; label: string; }; type OrgSelectionOption = { descendants: OrgSelectionDescendantOption[]; id: string; label: string; }; type VisualNode = { node: OrgNode; x: number; y: number; width: number; height: number; members: UserSummary[]; collapsed: boolean; }; type VisualEdge = { childId: string; key: string; parentId: string; path: string; visibleByDefault: boolean; }; export type ChartLayout = { nodes: VisualNode[]; edges: VisualEdge[]; width: number; height: number; }; export type ChildLayoutMode = "auto" | "topDown" | "threeColumn"; export type OrgChartLayoutOptions = { childLayoutMode: ChildLayoutMode; }; export type SemanticZoomMode = "overview" | "compact" | "detail"; const NODE_WIDTH = 340; const MEMBER_COLUMN_WIDTH = 300; const MEMBER_COLUMN_GAP = 8; const HEADER_HEIGHT = 42; const MEMBER_ROW_HEIGHT = 24; const NODE_PADDING_Y = 12; const ROOT_GAP_X = 120; const CHILD_GAP_Y = 96; const SIBLING_GAP_X = 80; const MIN_COMPRESSED_GAP_X = 28; const MULTI_COLUMN_GAP_Y = 72; const MIN_MULTI_COLUMN_GAP_X = 28; const MAX_MULTI_COLUMN_GAP_X = 180; const MIN_MULTI_COLUMN_GAP_Y = 48; const MAX_MULTI_COLUMN_GAP_Y = 160; const MAX_WIDTH_LIMIT = NODE_WIDTH * 4 + SIBLING_GAP_X * 3; const TARGET_ASPECT_RATIO = 1.5; const MIN_TARGET_ASPECT_RATIO = 1.41; const MAX_TARGET_ASPECT_RATIO = 1.61; const WIDE_ASPECT_RATIO = MAX_TARGET_ASPECT_RATIO; const TALL_ASPECT_RATIO = 0.5; const CHART_MARGIN = 72; const MIN_SCALE = 0.08; const MAX_SCALE = 5; const ZOOM_SENSITIVITY = 0.0015; const FAMILY_FILTER_ID = "hanmac-family"; const DEFAULT_LAYOUT_OPTIONS: OrgChartLayoutOptions = { childLayoutMode: "auto", }; const childLayoutModeOptions: Array<{ id: ChildLayoutMode; label: string; }> = [ { id: "auto", label: "자동" }, { id: "topDown", label: "Top-down" }, { id: "threeColumn", label: "3열" }, ]; type DepthColorNodeData = { depth: number; headerFill: string; }; type DepthColorNode = ReactFlowNode; const ROLE_ORDER = [ "사장", "부사장", "전무", "상무", "이사", "수석", "책임", "선임", "주임", "사원", ]; function getRankWeight( user: UserSummary, tenant?: { id: string; slug: string }, ) { const profile = getUserOrgProfile(user, tenant); const role = profile.grade || ""; const order = ROLE_ORDER.indexOf(role); const isLeader = profile.position.endsWith("장") || profile.jobTitle.endsWith("장"); return (isLeader ? -100 : 0) + (order === -1 ? 99 : order); } function getMemberColumnCount(memberCount: number) { return memberCount > 5 ? Math.floor(memberCount / 5) + 1 : 1; } function getMemberRowCount(memberCount: number) { return Math.max( 1, Math.ceil(memberCount / getMemberColumnCount(memberCount)), ); } function getNodeWidth(members: UserSummary[]) { const columnCount = getMemberColumnCount(members.length); if (columnCount <= 1) return NODE_WIDTH; return Math.max( NODE_WIDTH, NODE_PADDING_Y * 2 + columnCount * MEMBER_COLUMN_WIDTH + (columnCount - 1) * MEMBER_COLUMN_GAP, ); } function getNodeHeight(members: UserSummary[]) { return ( HEADER_HEIGHT + NODE_PADDING_Y * 2 + getMemberRowCount(members.length) * MEMBER_ROW_HEIGHT ); } export function clampScale(scale: number) { return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale)); } function getCompressedSiblingGap(siblingCount: number) { if (siblingCount >= 12) return MIN_COMPRESSED_GAP_X; if (siblingCount >= 8) return 40; if (siblingCount >= 4) return 56; return SIBLING_GAP_X; } function resolveLayoutOptions( options?: Partial, ): OrgChartLayoutOptions { return { ...DEFAULT_LAYOUT_OPTIONS, ...options, }; } function getExpectedHorizontalWidth(childLayouts: ChartLayout[], gap: number) { return ( childLayouts.reduce((sum, layout) => sum + layout.width, 0) + gap * Math.max(childLayouts.length - 1, 0) ); } function getExpectedHorizontalHeight( nodeHeight: number, childLayouts: ChartLayout[], ) { if (childLayouts.length === 0) return nodeHeight; return ( nodeHeight + CHILD_GAP_Y + Math.max(...childLayouts.map((layout) => layout.height)) ); } function sumLayout(values: number[], gap: number) { return ( values.reduce((sum, value) => sum + value, 0) + gap * Math.max(values.length - 1, 0) ); } function shouldUseMultiColumnLayout( nodeHeight: number, childLayouts: ChartLayout[], options: OrgChartLayoutOptions, ) { if (childLayouts.length <= 1) return false; if (options.childLayoutMode === "topDown") return false; if (options.childLayoutMode === "threeColumn") return true; if (childLayouts.length <= 4) return false; const siblingGap = getCompressedSiblingGap(childLayouts.length); const expectedWidth = getExpectedHorizontalWidth(childLayouts, siblingGap); const expectedHeight = getExpectedHorizontalHeight(nodeHeight, childLayouts); const aspectRatio = expectedWidth / Math.max(expectedHeight, 1); if (aspectRatio < TALL_ASPECT_RATIO) return false; return expectedWidth > MAX_WIDTH_LIMIT || aspectRatio > WIDE_ASPECT_RATIO; } function getMultiColumnDimensions( childLayouts: ChartLayout[], columnCount: number, nodeHeight: number, columnGap = SIBLING_GAP_X, rowGap = MULTI_COLUMN_GAP_Y, ) { const rowCount = Math.ceil(childLayouts.length / columnCount); const columnWidths = Array.from({ length: columnCount }, (_, columnIndex) => Math.max( ...childLayouts .filter((_, index) => index % columnCount === columnIndex) .map((layout) => layout.width), ), ); const rowHeights = Array.from({ length: rowCount }, (_, rowIndex) => Math.max( ...childLayouts .filter((_, index) => Math.floor(index / columnCount) === rowIndex) .map((layout) => layout.height), ), ); const childrenWidth = sumLayout(columnWidths, columnGap); const childrenHeight = sumLayout(rowHeights, rowGap); return { columnGap, columnWidths, height: nodeHeight + CHILD_GAP_Y + childrenHeight, rowGap, rowHeights, width: Math.max(NODE_WIDTH, childrenWidth), }; } function clampNumber(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } function tuneMultiColumnGaps( childLayouts: ChartLayout[], columnCount: number, nodeHeight: number, ) { let dimensions = getMultiColumnDimensions( childLayouts, columnCount, nodeHeight, ); let aspectRatio = dimensions.width / Math.max(dimensions.height, 1); if ( aspectRatio < MIN_TARGET_ASPECT_RATIO || aspectRatio > MAX_TARGET_ASPECT_RATIO ) { const columnGapCount = Math.max(columnCount - 1, 0); if (columnGapCount > 0) { const columnWidthSum = dimensions.columnWidths.reduce( (sum, width) => sum + width, 0, ); const targetWidth = dimensions.height * TARGET_ASPECT_RATIO; const nextColumnGap = clampNumber( (targetWidth - columnWidthSum) / columnGapCount, MIN_MULTI_COLUMN_GAP_X, MAX_MULTI_COLUMN_GAP_X, ); dimensions = getMultiColumnDimensions( childLayouts, columnCount, nodeHeight, nextColumnGap, dimensions.rowGap, ); aspectRatio = dimensions.width / Math.max(dimensions.height, 1); } } if ( aspectRatio < MIN_TARGET_ASPECT_RATIO || aspectRatio > MAX_TARGET_ASPECT_RATIO ) { const rowGapCount = Math.max(dimensions.rowHeights.length - 1, 0); if (rowGapCount > 0) { const fixedHeight = nodeHeight + CHILD_GAP_Y + dimensions.rowHeights.reduce((sum, height) => sum + height, 0); const targetHeight = dimensions.width / TARGET_ASPECT_RATIO; const nextRowGap = clampNumber( (targetHeight - fixedHeight) / rowGapCount, MIN_MULTI_COLUMN_GAP_Y, MAX_MULTI_COLUMN_GAP_Y, ); dimensions = getMultiColumnDimensions( childLayouts, columnCount, nodeHeight, dimensions.columnGap, nextRowGap, ); } } return dimensions; } function getAspectRatioPenalty(aspectRatio: number) { if ( aspectRatio >= MIN_TARGET_ASPECT_RATIO && aspectRatio <= MAX_TARGET_ASPECT_RATIO ) { return 0; } if (aspectRatio < MIN_TARGET_ASPECT_RATIO) { return MIN_TARGET_ASPECT_RATIO - aspectRatio; } return aspectRatio - MAX_TARGET_ASPECT_RATIO; } function getMultiColumnCount( childLayouts: ChartLayout[], nodeHeight: number, options: OrgChartLayoutOptions, ) { if (options.childLayoutMode === "threeColumn") { return Math.min(3, childLayouts.length); } let bestColumnCount = Math.max(2, Math.round(Math.sqrt(childLayouts.length))); let bestScore = Number.POSITIVE_INFINITY; for ( let columnCount = 2; columnCount <= childLayouts.length; columnCount += 1 ) { const dimensions = tuneMultiColumnGaps( childLayouts, columnCount, nodeHeight, ); const aspectRatio = dimensions.width / Math.max(dimensions.height, 1); const penalty = getAspectRatioPenalty(aspectRatio); const targetDistance = Math.abs(aspectRatio - TARGET_ASPECT_RATIO); const score = penalty * 100 + targetDistance; if (score < bestScore) { bestScore = score; bestColumnCount = columnCount; } } return bestColumnCount; } function getColumnOffsets(widths: number[], gap: number) { const offsets: number[] = []; let cursor = 0; for (const width of widths) { offsets.push(cursor); cursor += width + gap; } return offsets; } function getRowOffsets(heights: number[], gap: number) { const offsets: number[] = []; let cursor = 0; for (const height of heights) { offsets.push(cursor); cursor += height + gap; } return offsets; } function getLayoutMaxDepth(layout: ChartLayout) { return Math.max(...layout.nodes.map((visualNode) => visualNode.node.level)); } function orderChildLayoutsForMultiColumn(childLayouts: ChartLayout[]) { return childLayouts .map((layout, index) => ({ depth: getLayoutMaxDepth(layout), index, layout, })) .sort((a, b) => b.depth - a.depth || a.index - b.index) .map((entry) => entry.layout); } function rangesOverlap( startA: number, endA: number, startB: number, endB: number, ) { return startA < endB && startB < endA; } function getRequiredShift( placedNodes: VisualNode[], candidateNodes: VisualNode[], gap: number, ) { let requiredShift = 0; for (const placed of placedNodes) { for (const candidate of candidateNodes) { if ( rangesOverlap( placed.y, placed.y + placed.height, candidate.y, candidate.y + candidate.height, ) ) { requiredShift = Math.max( requiredShift, placed.x + placed.width + gap - candidate.x, ); } } } return Math.max(0, requiredShift); } function offsetNodes(nodes: VisualNode[], offsetX: number, offsetY: number) { return nodes.map((node) => ({ ...node, x: node.x + offsetX, y: node.y + offsetY, })); } function offsetEdges(edges: VisualEdge[], offsetX: number, offsetY: number) { return edges.map((edge) => ({ ...edge, path: offsetPath(edge.path, offsetX, offsetY), })); } function getEdgeBounds(edges: VisualEdge[]) { const xValues: number[] = []; const yValues: number[] = []; for (const edge of edges) { const numbers = edge.path.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? []; numbers.forEach((value, index) => { if (index % 2 === 0) { xValues.push(value); } else { yValues.push(value); } }); } return { minX: Math.min(0, ...xValues), minY: Math.min(0, ...yValues), maxX: Math.max(0, ...xValues), maxY: Math.max(0, ...yValues), }; } function normalizeLayout(nodes: VisualNode[], edges: VisualEdge[]) { const edgeBounds = getEdgeBounds(edges); const minX = Math.min(0, edgeBounds.minX, ...nodes.map((node) => node.x)); const minY = Math.min(0, ...nodes.map((node) => node.y)); const normalizedNodes = minX === 0 && minY === 0 ? nodes : offsetNodes(nodes, -minX, -minY); const normalizedEdges = minX === 0 && minY === 0 ? edges : offsetEdges(edges, -minX, -minY); const maxX = Math.max( NODE_WIDTH, edgeBounds.maxX - minX, ...normalizedNodes.map((node) => node.x + node.width), ); const maxY = Math.max( 1, edgeBounds.maxY - minY, ...normalizedNodes.map((node) => node.y + node.height), ); return { nodes: normalizedNodes, edges: normalizedEdges, width: maxX, height: maxY, }; } function buildOrgNode( tenantNode: TenantNode, usersMap: Map, depth: number, inheritedCompanyColorKey = "", inheritedCompanyColorDepth = 0, ): OrgNode { const slug = tenantNode.slug.toLowerCase(); const shouldResetCompanyColor = tenantNode.type === "COMPANY" || tenantNode.type === "COMPANY_GROUP"; const companyColorKey = shouldResetCompanyColor ? getCompanyColorKey(tenantNode) : inheritedCompanyColorKey || getCompanyColorKey(tenantNode); const companyColorDepth = shouldResetCompanyColor ? 0 : inheritedCompanyColorDepth + 1; const members = usersMap.get(slug) || []; const children = orderHanmacFamilyChildren( tenantNode, tenantNode.children, ).map((child) => buildOrgNode( child, usersMap, depth + 1, companyColorKey, companyColorDepth, ), ); const totalMemberIds = new Set(members.map((member) => member.id)); for (const child of children) { for (const memberId of child.totalMemberIds) { totalMemberIds.add(memberId); } } return { id: tenantNode.id, name: tenantNode.name, level: depth, members, children, totalCount: totalMemberIds.size, totalMemberIds, companyCode: slug, companyColorDepth, companyColorKey, orgUnitType: getOrgUnitType(tenantNode.config), type: tenantNode.type, }; } function createEdgePath( parentCenterX: number, parentBottomY: number, childRoot: VisualNode, ) { const childCenterX = childRoot.x + childRoot.width / 2; const childTopY = childRoot.y; const midY = parentBottomY + (childTopY - parentBottomY) / 2; return `M ${parentCenterX} ${parentBottomY} L ${parentCenterX} ${midY} L ${childCenterX} ${midY} L ${childCenterX} ${childTopY}`; } function layoutMultiColumnChildren( childLayouts: ChartLayout[], nodeHeight: number, options: OrgChartLayoutOptions, ) { const orderedChildLayouts = orderChildLayoutsForMultiColumn(childLayouts); const columnCount = getMultiColumnCount( orderedChildLayouts, nodeHeight, options, ); const { columnGap, columnWidths, rowGap, rowHeights } = tuneMultiColumnGaps( orderedChildLayouts, columnCount, nodeHeight, ); const columnOffsets = getColumnOffsets(columnWidths, columnGap); const rowOffsets = getRowOffsets(rowHeights, rowGap); const childOffsetY = nodeHeight + CHILD_GAP_Y; const nodes: VisualNode[] = []; const edges: VisualEdge[] = []; const roots: VisualNode[] = []; for (const [index, childLayout] of orderedChildLayouts.entries()) { const columnIndex = index % columnCount; const rowIndex = Math.floor(index / columnCount); const offsetX = columnOffsets[columnIndex]; const offsetY = childOffsetY + rowOffsets[rowIndex]; const shiftedNodes = offsetNodes(childLayout.nodes, offsetX, offsetY); const shiftedEdges = offsetEdges(childLayout.edges, offsetX, offsetY); nodes.push(...shiftedNodes); edges.push(...shiftedEdges); roots.push(shiftedNodes[0]); } return { nodes, edges, roots }; } function layoutTopDownChildren( childLayouts: ChartLayout[], nodeHeight: number, ) { const childOffsetY = nodeHeight + CHILD_GAP_Y; const siblingGap = getCompressedSiblingGap(childLayouts.length); const nodes: VisualNode[] = []; const edges: VisualEdge[] = []; const roots: VisualNode[] = []; for (const childLayout of childLayouts) { const candidateNodes = offsetNodes(childLayout.nodes, 0, childOffsetY); const requiredShift = getRequiredShift(nodes, candidateNodes, siblingGap); const shiftedNodes = offsetNodes( childLayout.nodes, requiredShift, childOffsetY, ); const shiftedEdges = offsetEdges( childLayout.edges, requiredShift, childOffsetY, ); nodes.push(...shiftedNodes); edges.push(...shiftedEdges); roots.push(shiftedNodes[0]); } return { nodes, edges, roots }; } function layoutTree( node: OrgNode, collapsedIds: Set, options: OrgChartLayoutOptions, ): ChartLayout { const members = [...node.members].sort( (a, b) => getRankWeight(a, { id: node.id, slug: node.companyCode ?? "" }) - getRankWeight(b, { id: node.id, slug: node.companyCode ?? "" }), ); const nodeHeight = getNodeHeight(members); const collapsed = collapsedIds.has(node.id); const childLayouts = collapsed ? [] : node.children.map((child) => layoutTree(child, collapsedIds, options)); const useMultiColumn = shouldUseMultiColumnLayout( nodeHeight, childLayouts, options, ); const childLayoutResult = useMultiColumn ? layoutMultiColumnChildren(childLayouts, nodeHeight, options) : layoutTopDownChildren(childLayouts, nodeHeight); const childRoots = childLayoutResult.roots; const firstChildSectionY = Math.min( Number.POSITIVE_INFINITY, ...childRoots.map((childRoot) => childRoot.y), ); const childCenters = childRoots.map( (childRoot) => childRoot.x + childRoot.width / 2, ); const nodeWidth = getNodeWidth(members); const firstChildCenter = childCenters.length > 0 ? Math.min(...childCenters) : nodeWidth / 2; const lastChildCenter = childCenters.length > 0 ? Math.max(...childCenters) : nodeWidth / 2; const nodeX = childRoots.length > 0 ? (firstChildCenter + lastChildCenter) / 2 - nodeWidth / 2 : 0; const nodeY = 0; const visualNode: VisualNode = { node, x: nodeX, y: nodeY, width: nodeWidth, height: nodeHeight, members, collapsed, }; const nodes: VisualNode[] = [visualNode]; const edges: VisualEdge[] = []; for (const childRoot of childRoots) { const parentCenterX = nodeX + nodeWidth / 2; const parentBottomY = nodeY + nodeHeight; edges.push({ childId: childRoot.node.id, key: `${node.id}->${childRoot.node.id}`, parentId: node.id, path: createEdgePath(parentCenterX, parentBottomY, childRoot), visibleByDefault: !useMultiColumn || childRoot.y === firstChildSectionY, }); } nodes.push(...childLayoutResult.nodes); edges.push(...childLayoutResult.edges); return normalizeLayout(nodes, edges); } function offsetPath(path: string, offsetX: number, offsetY: number) { const numbers = path.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? []; let index = 0; return path.replace(/-?\d+(\.\d+)?/g, () => { const value = numbers[index] ?? 0; const nextValue = index % 2 === 0 ? value + offsetX : value + offsetY; index += 1; return String(nextValue); }); } export function layoutForest( nodes: OrgNode[], collapsedIds: Set, options?: Partial, ): ChartLayout { const resolvedOptions = resolveLayoutOptions(options); const layouts = nodes.map((node) => layoutTree(node, collapsedIds, resolvedOptions), ); const contentWidth = layouts.reduce((sum, layout) => sum + layout.width, 0) + ROOT_GAP_X * Math.max(layouts.length - 1, 0); const contentHeight = Math.max(1, ...layouts.map((layout) => layout.height)); const visualNodes: VisualNode[] = []; const edges: VisualEdge[] = []; let cursorX = CHART_MARGIN; for (const layout of layouts) { visualNodes.push( ...layout.nodes.map((node) => ({ ...node, x: node.x + cursorX, y: node.y + CHART_MARGIN, })), ); edges.push( ...layout.edges.map((edge) => ({ ...edge, path: offsetPath(edge.path, cursorX, CHART_MARGIN), })), ); cursorX += layout.width + ROOT_GAP_X; } return { nodes: visualNodes, edges, width: Math.max(contentWidth + CHART_MARGIN * 2, 960), height: Math.max(contentHeight + CHART_MARGIN * 2, 640), }; } function makeInitialViewBox( layout: ChartLayout, viewport: DOMRect | null, ): ViewBox { const aspect = viewport && viewport.height > 0 ? viewport.width / viewport.height : 16 / 9; const width = Math.max(layout.width, 960); const height = Math.max(width / aspect, layout.height); return { x: 0, y: 0, width, height, }; } function getViewBoxScale(layout: ChartLayout, viewBox: ViewBox) { return layout.width / viewBox.width; } function getHighlightedEdgeKeys( edges: VisualEdge[], hoveredNodeId: string | null, ) { if (!hoveredNodeId) return new Set(); const parentByChildId = new Map(); for (const edge of edges) { parentByChildId.set(edge.childId, edge); } const highlighted = new Set(); let currentNodeId: string | null = hoveredNodeId; while (currentNodeId) { const edge = parentByChildId.get(currentNodeId); if (!edge) break; highlighted.add(edge.key); currentNodeId = edge.parentId; } return highlighted; } export function getSemanticZoomMode(scale: number): SemanticZoomMode { if (scale < 0.25) return "overview"; if (scale < 0.55) return "compact"; return "detail"; } function getCompanyColorKey( tenant?: Pick, ) { const text = `${tenant?.slug ?? ""} ${tenant?.name ?? ""}`.toLowerCase(); if (text.includes("hanmac-family") || text.includes("한맥가족")) { return "family"; } if (text.includes("saman") || text.includes("삼안")) return "saman"; if ( (text.includes("hanmac") || text.includes("한맥기술")) && !text.includes("hanmac-family") ) { return "hanmac"; } if (text.includes("gpdtdc")) return "gpdtdc"; return "baron"; } function hexToRgb(hex: string) { const normalized = hex.replace("#", ""); return { b: Number.parseInt(normalized.slice(4, 6), 16), g: Number.parseInt(normalized.slice(2, 4), 16), r: Number.parseInt(normalized.slice(0, 2), 16), }; } function rgbToHex({ r, g, b }: { r: number; g: number; b: number }) { return `#${[r, g, b] .map((value) => Math.max(0, Math.min(255, Math.round(value))) .toString(16) .padStart(2, "0"), ) .join("")}`; } function mixHexColor(hex: string, target: string, amount: number) { const sourceRgb = hexToRgb(hex); const targetRgb = hexToRgb(target); return rgbToHex({ b: sourceRgb.b + (targetRgb.b - sourceRgb.b) * amount, g: sourceRgb.g + (targetRgb.g - sourceRgb.g) * amount, r: sourceRgb.r + (targetRgb.r - sourceRgb.r) * amount, }); } export function getOrgNodeHeaderFill(level: number, companyColorKey = "baron") { const baseColor = companyColorKey === "family" ? "#000000" : companyColorKey === "saman" ? "#f58220" : companyColorKey === "hanmac" ? "#1e489d" : companyColorKey === "gpdtdc" ? "#4b746d" : "#004cbf"; const variants = [ baseColor, mixHexColor(baseColor, "#000000", 0.16), mixHexColor(baseColor, "#000000", 0.28), mixHexColor(baseColor, "#ffffff", 0.14), mixHexColor(baseColor, "#000000", 0.4), mixHexColor(baseColor, "#ffffff", 0.24), ]; return variants[Math.min(level, variants.length - 1)] ?? baseColor; } export function getReactFlowDepthColorNodes(): DepthColorNode[] { const fills = [ "#0a2a22", "#1e4d3f", "#355e48", "#4d5f35", "#5b4d37", "#4f465e", ]; return fills.map((headerFill, depth) => ({ data: { depth, headerFill }, id: `depth-color-${depth}`, position: { x: depth * 160, y: 0 }, style: { backgroundColor: headerFill }, type: "org-depth-color", })); } function isVisibleOrgChartUser(user: UserSummary) { return ( !user.email.toLowerCase().endsWith("@hanmac.kr") && !isSystemGlobalUser(user) ); } function isOrgFrontTenantType(tenant: TenantSummary) { return ["COMPANY_GROUP", "COMPANY", "ORGANIZATION", "USER_GROUP"].includes( tenant.type.toUpperCase(), ); } function isSystemGlobalTenant( tenant?: Pick, ) { if (!tenant) return false; const values = [tenant.id, tenant.slug, tenant.type, tenant.name].map( (value) => value.toLowerCase().replaceAll("_", "-"), ); return values.some( (value) => value === "system" || value === "global" || value === "system-global" || value === "tenant-global" || value === "시스템 전역", ); } function isSystemGlobalUser(user: UserSummary) { const normalizedRole = user.role.toLowerCase().replaceAll("_", "-"); return ( normalizedRole === "super-admin" || normalizedRole === "superadmin" || normalizedRole === "system-admin" || isSystemGlobalTenant(user.tenant) || isSystemGlobalTenant({ id: user.tenantSlug || "", slug: user.tenantSlug || "", type: user.role, name: user.role, }) ); } function findNodeByTenantId( nodes: TenantNode[], tenantId: string, ): TenantNode | null { for (const node of nodes) { if (node.id === tenantId) return node; const child = findNodeByTenantId(node.children, tenantId); if (child) return child; } return null; } function collectOrgSelectionDescendants( node: TenantNode, maxDepth: 1 | 2, depth: 1 | 2 = 1, ): OrgSelectionDescendantOption[] { if (depth > maxDepth) return []; return node.children.flatMap((child) => [ { depth, id: child.id, label: child.name }, ...(depth < maxDepth ? collectOrgSelectionDescendants(child, maxDepth, 2) : []), ]); } export function buildOrgSelectionOptions( familyRoot: TenantNode | null, ): OrgSelectionOption[] { return orderHanmacFamilyTenants( (familyRoot?.children ?? []).filter((node) => ["COMPANY_GROUP", "COMPANY", "ORGANIZATION"].includes(node.type), ), ).map((node) => ({ descendants: collectOrgSelectionDescendants(node, 2), id: node.id, label: node.name, })); } function getOrgSelectionLabel( familyRoot: TenantNode | null, selectedTenantId: string, ) { if (selectedTenantId === FAMILY_FILTER_ID) return "한맥가족"; return findNodeByTenantId(familyRoot ? [familyRoot] : [], selectedTenantId) ?.name; } function filterSystemGlobalTenants( tenants: TenantSummary[], visibilityMode: "internal" | "public" = "internal", ) { const excludedIds = new Set( tenants.filter(isSystemGlobalTenant).map((tenant) => tenant.id), ); let changed = true; while (changed) { changed = false; for (const tenant of tenants) { if ( tenant.parentId && excludedIds.has(tenant.parentId) && !excludedIds.has(tenant.id) ) { excludedIds.add(tenant.id); changed = true; } } } const filtered = tenants.filter( (tenant) => !excludedIds.has(tenant.id) && isOrgFrontTenantType(tenant), ); return filterTenantsByVisibility(filtered, visibilityMode); } type TenantIndexes = { byId: Map; bySlug: Map; }; function buildTenantIndexes(nodes: TenantNode[]): TenantIndexes { const byId = new Map(); const bySlug = new Map(); const visit = (node: TenantNode) => { byId.set(node.id, node); bySlug.set(node.slug.toLowerCase(), node); for (const child of node.children) visit(child); }; for (const node of nodes) visit(node); return { byId, bySlug }; } function isDescendantTenant( candidate: TenantNode, ancestor: TenantNode, byId: Map, ) { const visited = new Set(); let currentParentId = candidate.parentId; while (currentParentId) { if (currentParentId === ancestor.id) return true; if (visited.has(currentParentId)) return false; visited.add(currentParentId); currentParentId = byId.get(currentParentId)?.parentId; } return false; } function getLeafMembershipSlugs( slugs: Set, tenantIndexes: TenantIndexes, ) { const memberships = Array.from(slugs); return memberships.filter((slug) => { const tenant = tenantIndexes.bySlug.get(slug); if (!tenant) return true; return !memberships.some((otherSlug) => { if (otherSlug === slug) return false; const otherTenant = tenantIndexes.bySlug.get(otherSlug); if (!otherTenant) return false; return isDescendantTenant(otherTenant, tenant, tenantIndexes.byId); }); }); } function buildUsersMap( users: UserSummary[], rootNodes: TenantNode[], options: { activeOnly: boolean }, ) { const tenantIndexes = buildTenantIndexes(rootNodes); const map = new Map(); for (const user of users) { if (options.activeOnly && user.status !== "active") continue; if (!isVisibleOrgChartUser(user)) continue; const slugs = new Set(); const primarySlug = user.tenantSlug?.toLowerCase() || ""; if ( primarySlug && !isSystemGlobalTenant({ id: primarySlug, slug: primarySlug, type: primarySlug, name: primarySlug, }) ) { slugs.add(primarySlug); } if (user.tenant?.slug && !isSystemGlobalTenant(user.tenant)) { slugs.add(user.tenant.slug.toLowerCase()); } for (const joinedTenant of user.joinedTenants || []) { if (joinedTenant.slug && !isSystemGlobalTenant(joinedTenant)) { slugs.add(joinedTenant.slug.toLowerCase()); } } for (const slug of getLeafMembershipSlugs(slugs, tenantIndexes)) { const list = map.get(slug) || []; if (!list.some((existing) => existing.id === user.id)) list.push(user); map.set(slug, list); } } return map; } export function TenantOrgChartPage() { const viewportRef = React.useRef(null); const dragRef = React.useRef<{ pointerId: number; startX: number; startY: number; startViewBox: ViewBox; } | null>(null); const { tenantId } = useParams<{ tenantId?: string }>(); const location = useLocation(); const searchParams = new URLSearchParams(location.search); const shareToken = searchParams.get("token"); const [selectedTenantFilter, setSelectedTenantFilter] = React.useState(FAMILY_FILTER_ID); const [collapsedIds, setCollapsedIds] = React.useState>( () => new Set(), ); const [viewBox, setViewBox] = React.useState({ x: 0, y: 0, width: 1280, height: 720, }); const [hasUserMovedCanvas, setHasUserMovedCanvas] = React.useState(false); const [isDragging, setIsDragging] = React.useState(false); const [childLayoutMode, setChildLayoutMode] = React.useState("auto"); const [hoveredNodeId, setHoveredNodeId] = React.useState(null); const publicQuery = useQuery({ queryKey: ["public-orgchart", shareToken], queryFn: () => { if (!shareToken) throw new Error("Missing share token"); return fetchPublicOrgChart(shareToken); }, enabled: !!shareToken, }); const tenantsQuery = useQuery({ queryKey: ["tenants-full-tree-v2"], queryFn: () => fetchAllTenants(), enabled: !shareToken, }); const usersQuery = useQuery({ queryKey: ["users", { limit: 5000, offset: 0 }], queryFn: () => fetchUsers(5000, 0), enabled: !shareToken, }); const { rootNodes, usersMap, sharedWith } = React.useMemo(() => { if (shareToken) { if (!publicQuery.data) { return { rootNodes: [], usersMap: new Map(), sharedWith: "", }; } const rootNodes = buildTenantFullTree( filterSystemGlobalTenants(publicQuery.data.tenants, "public"), ).subTree; return { rootNodes, usersMap: buildUsersMap(publicQuery.data.users, rootNodes, { activeOnly: false, }), sharedWith: publicQuery.data.sharedWith, }; } if (!tenantsQuery.data?.items || !usersQuery.data?.items) { return { rootNodes: [], usersMap: new Map(), sharedWith: "", }; } const rootNodes = buildTenantFullTree( filterSystemGlobalTenants(tenantsQuery.data.items, "internal"), ).subTree; return { rootNodes, usersMap: buildUsersMap(usersQuery.data.items, rootNodes, { activeOnly: true, }), sharedWith: "", }; }, [publicQuery.data, shareToken, tenantsQuery.data, usersQuery.data]); const familyRoot = React.useMemo(() => { return ( rootNodes.find((node) => node.type === "COMPANY_GROUP") ?? rootNodes[0] ?? null ); }, [rootNodes]); const orgSelectionOptions = React.useMemo( () => buildOrgSelectionOptions(familyRoot), [familyRoot], ); const selectedOrgLabel = React.useMemo( () => getOrgSelectionLabel(familyRoot, selectedTenantFilter) ?? "한맥가족", [familyRoot, selectedTenantFilter], ); React.useEffect(() => { if (!tenantId) return; const searchRoots = familyRoot ? [familyRoot] : rootNodes; const match = searchRoots .flatMap((node) => [node, ...node.children]) .find( (node) => node.id === tenantId || node.slug.toLowerCase() === tenantId.toLowerCase() || node.name === tenantId, ); if (match?.type === "COMPANY" || match?.type === "ORGANIZATION") { setSelectedTenantFilter(match.id); } }, [familyRoot, rootNodes, tenantId]); const targetNodes = React.useMemo(() => { if (!familyRoot) return []; if (selectedTenantFilter === FAMILY_FILTER_ID) { return [buildOrgNode(familyRoot, usersMap, 0)]; } const companyNode = findNodeByTenantId([familyRoot], selectedTenantFilter); if (!companyNode) return []; return [buildOrgNode(companyNode, usersMap, 0)]; }, [familyRoot, selectedTenantFilter, usersMap]); const layout = React.useMemo( () => layoutForest(targetNodes, collapsedIds, { childLayoutMode, }), [childLayoutMode, collapsedIds, targetNodes], ); const highlightedEdgeKeys = React.useMemo( () => getHighlightedEdgeKeys(layout.edges, hoveredNodeId), [hoveredNodeId, layout.edges], ); const viewBoxScale = getViewBoxScale(layout, viewBox); const semanticZoomMode = getSemanticZoomMode(viewBoxScale); React.useLayoutEffect(() => { if (hasUserMovedCanvas) return; setViewBox( makeInitialViewBox( layout, viewportRef.current?.getBoundingClientRect() ?? null, ), ); }, [hasUserMovedCanvas, layout]); const handlePointerDown = (event: React.PointerEvent) => { if (event.button !== 0) return; dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, startViewBox: viewBox, }; event.currentTarget.setPointerCapture(event.pointerId); setIsDragging(true); }; const handlePointerMove = (event: React.PointerEvent) => { const dragState = dragRef.current; const rect = viewportRef.current?.getBoundingClientRect(); if (!dragState || !rect || dragState.pointerId !== event.pointerId) return; const dx = ((event.clientX - dragState.startX) / rect.width) * dragState.startViewBox.width; const dy = ((event.clientY - dragState.startY) / rect.height) * dragState.startViewBox.height; setHasUserMovedCanvas(true); setViewBox({ ...dragState.startViewBox, x: dragState.startViewBox.x - dx, y: dragState.startViewBox.y - dy, }); }; const finishDrag = (event: React.PointerEvent) => { const dragState = dragRef.current; if (!dragState || dragState.pointerId !== event.pointerId) return; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } dragRef.current = null; setIsDragging(false); }; const handleWheel = (event: React.WheelEvent) => { event.preventDefault(); const rect = event.currentTarget.getBoundingClientRect(); const currentScale = getViewBoxScale(layout, viewBox); const nextScale = clampScale( currentScale * Math.exp(-event.deltaY * ZOOM_SENSITIVITY), ); const nextWidth = layout.width / nextScale; const nextHeight = nextWidth / (rect.width / rect.height); const pointX = viewBox.x + ((event.clientX - rect.left) / rect.width) * viewBox.width; const pointY = viewBox.y + ((event.clientY - rect.top) / rect.height) * viewBox.height; const ratioX = (pointX - viewBox.x) / viewBox.width; const ratioY = (pointY - viewBox.y) / viewBox.height; setHasUserMovedCanvas(true); setViewBox({ x: pointX - nextWidth * ratioX, y: pointY - nextHeight * ratioY, width: nextWidth, height: nextHeight, }); }; const isLoading = shareToken ? publicQuery.isLoading : tenantsQuery.isLoading || usersQuery.isLoading; const isError = shareToken ? publicQuery.isError : tenantsQuery.isError || usersQuery.isError; const totalUsers = React.useMemo(() => { const ids = new Set(); for (const node of targetNodes) { for (const memberId of node.totalMemberIds) { ids.add(memberId); } } return ids.size; }, [targetNodes]); if (isLoading) { return (
로딩 중...
); } if (isError) { return (
조직도를 불러올 수 없거나 만료된 링크입니다.
); } const updateLayoutMode = (value: ChildLayoutMode) => { setChildLayoutMode(value); setHasUserMovedCanvas(false); }; const updateSelectedOrg = (value: string) => { setSelectedTenantFilter(value); setCollapsedIds(new Set()); setHoveredNodeId(null); setHasUserMovedCanvas(false); }; return (
{shareToken ? (

공유된 조직도: {sharedWith}

) : null}

조직 현황

총 {totalUsers}명
조직도 벡터 렌더링 {layout.edges.map((edge) => ( ))} {layout.nodes.map((visualNode) => ( setHoveredNodeId(null)} onHoverStart={() => setHoveredNodeId(visualNode.node.id)} semanticZoomMode={semanticZoomMode} visualNode={visualNode} /> ))}
); } function LayoutOptionPicker({ label, onChange, options, testId, value, }: { label: string; onChange: (value: T) => void; options: Array<{ id: T; label: string }>; testId: string; value: T; }) { const selectedOption = options.find((option) => option.id === value) ?? options[0]; const availableOptions = options.filter((option) => option.id !== value); return (
{label} {availableOptions.map((option) => ( ))}
); } function OrgSelectionPicker({ onChange, options, selectedId, selectedLabel, }: { onChange: (value: string) => void; options: OrgSelectionOption[]; selectedId: string; selectedLabel: string; }) { const isFamilySelected = selectedId === FAMILY_FILTER_ID; const selectedCompany = options.find((option) => option.id === selectedId); const showSelectedDescendant = !isFamilySelected && !selectedCompany; const baseButtonClass = "shrink-0 rounded-full border px-3 py-1.5 text-xs font-bold transition-all"; const selectedButtonClass = "border-[#d6f4e6] bg-[#d6f4e6] text-[#073b2d] shadow-sm"; const optionButtonClass = "border-[#8bd3b2]/28 bg-[#0c4b3c]/45 text-[#e1fff1]/85 hover:border-[#8bd3b2]/70 hover:bg-[#0e5a48]/75 hover:text-[#f4fff8]"; return (
{showSelectedDescendant ? ( ) : null} {options.map((option) => { const isSelected = selectedId === option.id; return (
{option.descendants.length > 0 ? (
{option.descendants .filter((descendant) => descendant.id !== selectedId) .map((descendant) => ( ))}
) : null}
); })}
); } function SvgOrgNode({ isHovered, onHoverEnd, onHoverStart, semanticZoomMode, visualNode, }: { isHovered: boolean; onHoverEnd: () => void; onHoverStart: () => void; semanticZoomMode: SemanticZoomMode; visualNode: VisualNode; }) { const { node, x, y, width, height, members, collapsed } = visualNode; const headerFill = getOrgNodeHeaderFill( node.companyColorDepth ?? node.level, node.companyColorKey, ); const memberColumnCount = getMemberColumnCount(members.length); const showMemberRows = semanticZoomMode === "detail"; const showNodeName = semanticZoomMode === "detail" || node.level <= 1 || (semanticZoomMode === "compact" && node.level <= 2); const showCompactGlyph = !showNodeName; const titleClass = node.level <= 1 ? "text-[17px] font-black leading-tight" : "text-[14px] font-extrabold leading-tight"; return (
{showNodeName ? (
{node.orgUnitType ? ( {node.orgUnitType} ) : null}
{node.name}
) : (
)}
{collapsed ? "+" : node.totalCount}
{showCompactGlyph ? (
{node.children.length > 0 ? node.children.length : node.totalCount}
) : showMemberRows && members.length > 0 ? (
{members.map((member) => (
{getOrgChartUserDisplayName(member, { id: node.id, slug: node.companyCode ?? "", })}
))}
) : (
구성원 없음
)}
); }