1
0
forked from baron/baron-sso
Files
baron-sso/orgfront/src/features/orgchart/routes/OrgChartPage.tsx

2239 lines
65 KiB
TypeScript

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 { Switch } from "../../../components/ui/switch";
import {
fetchOrgChartSnapshot,
fetchPublicOrgChart,
type TenantSummary,
type UserSummary,
} from "../../../lib/adminApi";
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
import {
orderHanmacFamilyChildren,
orderHanmacFamilyTenants,
} from "../hanmacFamilyOrder";
import { getOrgRankWeight } from "../rankPriority";
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<string>;
companyCode?: string;
companyColorDepth?: number;
companyColorKey?: string;
orgUnitType?: string;
type?: string;
};
type ViewBox = {
x: number;
y: number;
width: number;
height: number;
};
type OrgChartLoadErrorDiagnostics = {
cacheMode: "redis" | "public";
code: string;
message: string;
route: string;
status: number | null;
tenantId: string;
};
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 = 168;
const MEMBER_COLUMN_WIDTH = 128;
const MEMBER_COLUMN_GAP = 8;
const HEADER_HEIGHT = 42;
const MEMBER_ROW_HEIGHT = 24;
const NODE_PADDING_Y = 12;
const MEMBER_CARD_BASE_CHAR_COUNT = 8;
const MEMBER_CARD_CHAR_WIDTH = 12;
const MEMBER_CARD_TEXT_PADDING_X = 28;
const MEMBER_COLUMN_MAX_WIDTH = 280;
const NODE_HEADER_CHAR_WIDTH = 17;
const NODE_HEADER_WRAP_THRESHOLD = 420;
const NODE_HEADER_TEXT_PADDING_X = 112;
const NODE_HEADER_TYPE_BADGE_WIDTH = 48;
const MEMBER_GRID_TARGET_ASPECT_RATIO = 2;
const MAX_MEMBER_COLUMN_COUNT = 8;
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 = 32;
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<DepthColorNodeData, "org-depth-color">;
function getRankWeight(
user: UserSummary,
tenant?: { id: string; slug: string },
) {
const profile = getUserOrgProfile(user, tenant);
return getOrgRankWeight(profile.grade);
}
function getManagerWeight(
user: UserSummary,
tenant?: { id: string; slug: string },
) {
return getUserOrgProfile(user, tenant).isManager ? 0 : 1;
}
function compareOrgMembers(
a: UserSummary,
b: UserSummary,
tenant?: { id: string; slug: string },
) {
return (
getManagerWeight(a, tenant) - getManagerWeight(b, tenant) ||
getRankWeight(a, tenant) - getRankWeight(b, tenant)
);
}
function getComplementaryColor(hexColor: string) {
const normalized = hexColor.trim().replace("#", "");
if (!/^[\da-f]{6}$/i.test(normalized)) return "#f59e0b";
const red = 255 - Number.parseInt(normalized.slice(0, 2), 16);
const green = 255 - Number.parseInt(normalized.slice(2, 4), 16);
const blue = 255 - Number.parseInt(normalized.slice(4, 6), 16);
return `#${[red, green, blue]
.map((value) => value.toString(16).padStart(2, "0"))
.join("")}`;
}
function getDisplayTextWidthUnit(value: string) {
return Array.from(value).reduce((sum, char) => {
if (char === " ") return sum + 0.4;
if (char.charCodeAt(0) <= 0x7f) return sum + 0.55;
return sum + 1;
}, 0);
}
function getMemberColumnWidth(
members: UserSummary[],
tenant?: { id: string; slug: string },
) {
const baselineWidth =
MEMBER_CARD_TEXT_PADDING_X +
MEMBER_CARD_BASE_CHAR_COUNT * MEMBER_CARD_CHAR_WIDTH;
const maxDisplayWidth = members.reduce((maxWidth, member) => {
const displayName = getOrgChartUserDisplayName(member, tenant);
const estimatedWidth =
MEMBER_CARD_TEXT_PADDING_X +
getDisplayTextWidthUnit(displayName) * MEMBER_CARD_CHAR_WIDTH;
return Math.max(maxWidth, estimatedWidth);
}, baselineWidth);
return Math.min(
MEMBER_COLUMN_MAX_WIDTH,
Math.max(MEMBER_COLUMN_WIDTH, Math.ceil(maxDisplayWidth)),
);
}
export function getMemberGridMetrics(
memberCount: number,
memberColumnWidth = MEMBER_COLUMN_WIDTH,
) {
if (memberCount <= 0) return { columnCount: 1, rowCount: 1 };
const maxColumnCount = Math.min(
MAX_MEMBER_COLUMN_COUNT,
Math.max(1, memberCount),
);
let best = { columnCount: 1, rowCount: memberCount };
let bestScore = Number.POSITIVE_INFINITY;
for (let columnCount = 1; columnCount <= maxColumnCount; columnCount += 1) {
const rowCount = Math.ceil(memberCount / columnCount);
const width =
columnCount <= 1
? NODE_WIDTH
: Math.max(
NODE_WIDTH,
NODE_PADDING_Y * 2 +
columnCount * memberColumnWidth +
(columnCount - 1) * MEMBER_COLUMN_GAP,
);
const height =
HEADER_HEIGHT + NODE_PADDING_Y * 2 + rowCount * MEMBER_ROW_HEIGHT;
const aspectRatio = width / height;
const score = Math.abs(
Math.log(aspectRatio / MEMBER_GRID_TARGET_ASPECT_RATIO),
);
if (
score < bestScore ||
(score === bestScore && rowCount < best.rowCount)
) {
best = { columnCount, rowCount };
bestScore = score;
}
}
return best;
}
function getMemberColumnCount(memberCount: number, memberColumnWidth?: number) {
return getMemberGridMetrics(memberCount, memberColumnWidth).columnCount;
}
function getMemberRowCount(memberCount: number, memberColumnWidth?: number) {
return getMemberGridMetrics(memberCount, memberColumnWidth).rowCount;
}
function getNodeHeaderWidth(node: OrgNode) {
const typeBadgeWidth = node.orgUnitType ? NODE_HEADER_TYPE_BADGE_WIDTH : 0;
const titleTextWidth =
getDisplayTextWidthUnit(node.name) * NODE_HEADER_CHAR_WIDTH;
const oneLineWidth =
NODE_HEADER_TEXT_PADDING_X + typeBadgeWidth + titleTextWidth;
if (oneLineWidth <= NODE_HEADER_WRAP_THRESHOLD) {
return Math.ceil(oneLineWidth);
}
const estimatedTitleWidth =
NODE_HEADER_TEXT_PADDING_X + typeBadgeWidth + titleTextWidth / 2;
return Math.ceil(estimatedTitleWidth);
}
function getNodeWidth(
node: OrgNode,
members: UserSummary[],
memberColumnWidth: number,
) {
const columnCount = getMemberColumnCount(members.length, memberColumnWidth);
if (columnCount <= 1) {
return Math.max(
NODE_WIDTH,
getNodeHeaderWidth(node),
NODE_PADDING_Y * 2 + memberColumnWidth,
);
}
return Math.max(
NODE_WIDTH,
getNodeHeaderWidth(node),
NODE_PADDING_Y * 2 +
columnCount * memberColumnWidth +
(columnCount - 1) * MEMBER_COLUMN_GAP,
);
}
function getNodeHeight(members: UserSummary[], memberColumnWidth: number) {
return (
HEADER_HEIGHT +
NODE_PADDING_Y * 2 +
getMemberRowCount(members.length, memberColumnWidth) * 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>,
): 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<string, UserSummary[]>,
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<string>,
options: OrgChartLayoutOptions,
): ChartLayout {
const tenantIdentity = { id: node.id, slug: node.companyCode ?? "" };
const members = [...node.members].sort((a, b) =>
compareOrgMembers(a, b, tenantIdentity),
);
const memberColumnWidth = getMemberColumnWidth(members, tenantIdentity);
const nodeHeight = getNodeHeight(members, memberColumnWidth);
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(node, members, memberColumnWidth);
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<string>,
options?: Partial<OrgChartLayoutOptions>,
): 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<string>();
const parentByChildId = new Map<string, VisualEdge>();
for (const edge of edges) {
parentByChildId.set(edge.childId, edge);
}
const highlighted = new Set<string>();
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<TenantSummary, "name" | "slug" | "type">,
) {
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<TenantSummary, "id" | "slug" | "type" | "name">,
) {
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;
}
export function resolveOrgChartFamilyRoot(rootNodes: TenantNode[]) {
return (
rootNodes.find(
(node) =>
node.slug.toLowerCase() === "hanmac-family" || node.name === "한맥가족",
) ??
rootNodes.find((node) => node.type === "COMPANY_GROUP") ??
rootNodes[0] ??
null
);
}
export function filterSystemGlobalTenants(
tenants: TenantSummary[],
visibilityMode: "internal" | "public" = "public",
) {
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);
}
function filterOrgChartMembershipTenants(tenants: TenantSummary[]) {
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;
}
}
}
return tenants.filter(
(tenant) => !excludedIds.has(tenant.id) && isOrgFrontTenantType(tenant),
);
}
type TenantIndexes = {
byId: Map<string, TenantNode>;
bySlug: Map<string, TenantNode>;
};
type UserOrgAppointmentRef = {
tenantId?: string;
tenantSlug?: string;
};
function buildTenantIndexes(nodes: TenantNode[]): TenantIndexes {
const byId = new Map<string, TenantNode>();
const bySlug = new Map<string, TenantNode>();
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 normalizeOrgSlug(value: unknown) {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function readErrorText(value: unknown): string {
return typeof value === "string" ? value : "";
}
function getOrgChartLoadErrorDiagnostics(
error: unknown,
options: { cacheMode: "redis" | "public"; route: string; tenantId: string },
): OrgChartLoadErrorDiagnostics {
const maybeError = error as {
config?: { url?: string };
message?: string;
response?: {
data?: { code?: unknown; error?: unknown; message?: unknown };
status?: number;
};
};
const responseData = maybeError.response?.data;
return {
cacheMode: options.cacheMode,
code: readErrorText(responseData?.code),
message:
readErrorText(responseData?.error) ||
readErrorText(responseData?.message) ||
readErrorText(maybeError.message),
route: maybeError.config?.url || options.route,
status: maybeError.response?.status ?? null,
tenantId: options.tenantId,
};
}
function getUserOrgAppointmentRefs(user: UserSummary): UserOrgAppointmentRef[] {
const rawAppointments = user.metadata?.additionalAppointments;
if (!Array.isArray(rawAppointments)) return [];
return rawAppointments
.filter(
(item): item is Record<string, unknown> =>
typeof item === "object" && item !== null,
)
.map((item) => ({
tenantId: typeof item.tenantId === "string" ? item.tenantId.trim() : "",
tenantSlug: normalizeOrgSlug(item.tenantSlug),
}));
}
function addTenantSlugCandidate(
tenantIds: Set<string>,
tenantIndexes: TenantIndexes,
slug: string,
) {
const normalizedSlug = normalizeOrgSlug(slug);
if (!normalizedSlug) return;
const tenant = tenantIndexes.bySlug.get(normalizedSlug);
if (!tenant) return;
tenantIds.add(tenant.id);
}
function addTenantIdCandidate(
tenantIds: Set<string>,
tenantIndexes: TenantIndexes,
id: unknown,
) {
if (typeof id !== "string") return;
const normalizedId = id.trim();
if (!normalizedId) return;
if (!tenantIndexes.byId.has(normalizedId)) return;
tenantIds.add(normalizedId);
}
function isDescendantTenant(
candidate: TenantNode,
ancestor: TenantNode,
byId: Map<string, TenantNode>,
) {
const visited = new Set<string>();
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 getLeafMembershipIds(
tenantIds: Set<string>,
tenantIndexes: TenantIndexes,
) {
const memberships = Array.from(tenantIds);
return memberships.filter((id) => {
const tenant = tenantIndexes.byId.get(id);
if (!tenant) return true;
return !memberships.some((otherId) => {
if (otherId === id) return false;
const otherTenant = tenantIndexes.byId.get(otherId);
if (!otherTenant) return false;
return isDescendantTenant(otherTenant, tenant, tenantIndexes.byId);
});
});
}
export function buildUsersMap(
users: UserSummary[],
rootNodes: TenantNode[],
options: { activeOnly: boolean; membershipRootNodes?: TenantNode[] },
) {
const visibleTenantIndexes = buildTenantIndexes(rootNodes);
const membershipTenantIndexes = buildTenantIndexes(
options.membershipRootNodes ?? rootNodes,
);
const map = new Map<string, UserSummary[]>();
for (const user of users) {
if (options.activeOnly && user.status !== "active") continue;
if (!isVisibleOrgChartUser(user)) continue;
const tenantIds = new Set<string>();
const primarySlug = normalizeOrgSlug(user.tenantSlug);
const legacyCompanySlug = normalizeOrgSlug(user.companyCode);
if (
primarySlug &&
!isSystemGlobalTenant({
id: primarySlug,
slug: primarySlug,
type: primarySlug,
name: primarySlug,
})
) {
addTenantSlugCandidate(tenantIds, membershipTenantIndexes, primarySlug);
}
if (
legacyCompanySlug &&
!isSystemGlobalTenant({
id: legacyCompanySlug,
slug: legacyCompanySlug,
type: legacyCompanySlug,
name: legacyCompanySlug,
})
) {
addTenantSlugCandidate(
tenantIds,
membershipTenantIndexes,
legacyCompanySlug,
);
}
if (user.tenant?.slug && !isSystemGlobalTenant(user.tenant)) {
addTenantIdCandidate(tenantIds, membershipTenantIndexes, user.tenant.id);
addTenantSlugCandidate(
tenantIds,
membershipTenantIndexes,
user.tenant.slug,
);
}
for (const joinedTenant of user.joinedTenants || []) {
if (joinedTenant.slug && !isSystemGlobalTenant(joinedTenant)) {
addTenantIdCandidate(
tenantIds,
membershipTenantIndexes,
joinedTenant.id,
);
addTenantSlugCandidate(
tenantIds,
membershipTenantIndexes,
joinedTenant.slug,
);
}
}
for (const appointment of getUserOrgAppointmentRefs(user)) {
const hasTenantIdCandidate =
appointment.tenantId &&
membershipTenantIndexes.byId.has(appointment.tenantId);
if (hasTenantIdCandidate) {
addTenantIdCandidate(
tenantIds,
membershipTenantIndexes,
appointment.tenantId,
);
continue;
}
if (appointment.tenantSlug) {
addTenantSlugCandidate(
tenantIds,
membershipTenantIndexes,
appointment.tenantSlug,
);
continue;
}
const tenantById = appointment.tenantId
? membershipTenantIndexes.byId.get(appointment.tenantId)
: undefined;
if (tenantById) {
addTenantIdCandidate(tenantIds, membershipTenantIndexes, tenantById.id);
}
}
for (const id of getLeafMembershipIds(tenantIds, membershipTenantIndexes)) {
const visibleTenant = visibleTenantIndexes.byId.get(id);
if (!visibleTenant) continue;
const slug = visibleTenant.slug.toLowerCase();
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<HTMLDivElement>(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 [includeInternalTenants, setIncludeInternalTenants] = React.useState(
() => !shareToken && searchParams.get("includeInternal") === "true",
);
const visibilityMode = includeInternalTenants ? "internal" : "public";
const [selectedTenantFilter, setSelectedTenantFilter] =
React.useState(FAMILY_FILTER_ID);
const [collapsedIds, setCollapsedIds] = React.useState<Set<string>>(
() => new Set(),
);
const [viewBox, setViewBox] = React.useState<ViewBox>({
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<ChildLayoutMode>("auto");
const [hoveredNodeId, setHoveredNodeId] = React.useState<string | null>(null);
const publicQuery = useQuery({
queryKey: ["public-orgchart", shareToken],
queryFn: () => {
if (!shareToken) throw new Error("Missing share token");
return fetchPublicOrgChart(shareToken);
},
enabled: !!shareToken,
});
const orgChartSnapshotQuery = useQuery({
queryKey: ["orgchart-snapshot", { cache: "redis" }],
queryFn: () => fetchOrgChartSnapshot(),
enabled: !shareToken,
staleTime: 0,
refetchOnMount: "always",
refetchOnWindowFocus: true,
});
const { rootNodes, usersMap, sharedWith } = React.useMemo(() => {
if (shareToken) {
if (!publicQuery.data) {
return {
rootNodes: [],
usersMap: new Map<string, UserSummary[]>(),
sharedWith: "",
};
}
const rootNodes = buildTenantFullTree(
filterSystemGlobalTenants(publicQuery.data.tenants, "public"),
).subTree;
const membershipRootNodes = buildTenantFullTree(
filterOrgChartMembershipTenants(publicQuery.data.tenants),
).subTree;
return {
rootNodes,
usersMap: buildUsersMap(publicQuery.data.users, rootNodes, {
activeOnly: false,
membershipRootNodes,
}),
sharedWith: publicQuery.data.sharedWith,
};
}
if (!orgChartSnapshotQuery.data) {
return {
rootNodes: [],
usersMap: new Map<string, UserSummary[]>(),
sharedWith: "",
};
}
const rootNodes = buildTenantFullTree(
filterSystemGlobalTenants(
orgChartSnapshotQuery.data.tenants,
visibilityMode,
),
).subTree;
const membershipRootNodes = buildTenantFullTree(
filterOrgChartMembershipTenants(orgChartSnapshotQuery.data.tenants),
).subTree;
return {
rootNodes,
usersMap: buildUsersMap(orgChartSnapshotQuery.data.users, rootNodes, {
activeOnly: true,
membershipRootNodes,
}),
sharedWith: "",
};
}, [
publicQuery.data,
shareToken,
orgChartSnapshotQuery.data,
visibilityMode,
]);
const familyRoot = React.useMemo(() => {
return resolveOrgChartFamilyRoot(rootNodes);
}, [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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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
: orgChartSnapshotQuery.isLoading;
const isError = shareToken
? publicQuery.isError
: orgChartSnapshotQuery.isError;
const currentLoadError = shareToken
? publicQuery.error
: orgChartSnapshotQuery.error;
React.useEffect(() => {
if (!currentLoadError) return;
console.error(
"[orgfront] Org chart load failed",
getOrgChartLoadErrorDiagnostics(currentLoadError, {
cacheMode: shareToken ? "public" : "redis",
route: shareToken
? "/v1/public/orgchart"
: "/v1/admin/orgchart/snapshot",
tenantId:
tenantId ?? window.localStorage.getItem("dev_tenant_id") ?? "",
}),
);
}, [currentLoadError, shareToken, tenantId]);
const totalUsers = React.useMemo(() => {
const ids = new Set<string>();
for (const node of targetNodes) {
for (const memberId of node.totalMemberIds) {
ids.add(memberId);
}
}
return ids.size;
}, [targetNodes]);
if (isLoading) {
return (
<div className="p-8 text-center text-muted-foreground"> ...</div>
);
}
if (isError) {
const errorMessage = shareToken
? "조직도를 불러올 수 없거나 만료된 링크입니다."
: "조직도를 불러올 수 없습니다. 로그인 상태와 조직 권한을 확인해 주세요.";
return <div className="p-8 text-center text-red-500">{errorMessage}</div>;
}
const updateLayoutMode = (value: ChildLayoutMode) => {
setChildLayoutMode(value);
setHasUserMovedCanvas(false);
};
const updateSelectedOrg = (value: string) => {
setSelectedTenantFilter(value);
setCollapsedIds(new Set());
setHoveredNodeId(null);
setHasUserMovedCanvas(false);
};
const updateInternalTenantVisibility = (checked: boolean) => {
setIncludeInternalTenants(checked);
setSelectedTenantFilter(FAMILY_FILTER_ID);
setCollapsedIds(new Set());
setHoveredNodeId(null);
setHasUserMovedCanvas(false);
};
return (
<div
className="flex h-full min-h-0 w-full flex-col overflow-hidden border-[#e0d5c1] bg-[#f6efe6]"
data-testid="orgchart-dashboard-shell"
>
<header className="z-20 grid shrink-0 grid-cols-1 items-center gap-3 border-b border-[#f2c484]/30 bg-[linear-gradient(145deg,rgba(10,42,34,0.98)_0%,rgba(15,58,47,0.98)_52%,rgba(26,86,69,0.98)_100%)] px-6 py-4 lg:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]">
<div className="flex min-w-0 justify-start">
<OrgSelectionPicker
onChange={updateSelectedOrg}
options={orgSelectionOptions}
selectedId={selectedTenantFilter}
selectedLabel={selectedOrgLabel}
/>
</div>
<div className="text-center">
{shareToken ? (
<p className="text-xs font-bold uppercase tracking-wider text-[#f2c484]">
: {sharedWith}
</p>
) : null}
<h2 className="text-xl font-black text-[#f7f0e4]"> </h2>
</div>
<div className="custom-scrollbar flex min-w-0 max-w-full items-center justify-start gap-2 overflow-x-auto lg:justify-end">
<LayoutOptionPicker
label="배치"
onChange={updateLayoutMode}
options={childLayoutModeOptions}
testId="orgchart-layout-mode-option"
value={childLayoutMode}
/>
<TotalUsersControl
canToggleInternal={!shareToken}
includeInternal={includeInternalTenants}
onIncludeInternalChange={updateInternalTenantVisibility}
totalUsers={totalUsers}
/>
</div>
</header>
<div
className={`relative flex-1 touch-none select-none overflow-hidden ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
data-testid="orgchart-viewport"
onPointerCancel={finishDrag}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={finishDrag}
onWheel={handleWheel}
ref={viewportRef}
style={{
background:
"radial-gradient(circle at top left, rgba(214, 138, 58, 0.08), transparent 24%), radial-gradient(circle at top right, rgba(47, 153, 115, 0.05), transparent 20%), linear-gradient(180deg, rgba(246, 239, 230, 0.98), rgba(241, 234, 223, 0.96))",
}}
>
<svg
className="h-full w-full"
data-scale={viewBoxScale.toFixed(3)}
data-semantic-zoom={semanticZoomMode}
data-testid="orgchart-vector-svg"
role="img"
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
>
<title> </title>
<g data-testid="orgchart-canvas">
{layout.edges.map((edge) => (
<path
d={edge.path}
data-highlighted={
hoveredNodeId ? highlightedEdgeKeys.has(edge.key) : undefined
}
data-hidden-default={!edge.visibleByDefault || undefined}
data-muted={
hoveredNodeId && edge.visibleByDefault
? !highlightedEdgeKeys.has(edge.key)
: undefined
}
fill="none"
key={edge.key}
opacity={
hoveredNodeId
? highlightedEdgeKeys.has(edge.key)
? 1
: edge.visibleByDefault
? 0.14
: 0
: edge.visibleByDefault
? 0.72
: 0
}
stroke={
hoveredNodeId && highlightedEdgeKeys.has(edge.key)
? "#0a2a22"
: "#bca58a"
}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={
hoveredNodeId && highlightedEdgeKeys.has(edge.key) ? "3" : "2"
}
vectorEffect="non-scaling-stroke"
/>
))}
{layout.nodes.map((visualNode) => (
<SvgOrgNode
isHovered={hoveredNodeId === visualNode.node.id}
key={visualNode.node.id}
onHoverEnd={() => setHoveredNodeId(null)}
onHoverStart={() => setHoveredNodeId(visualNode.node.id)}
semanticZoomMode={semanticZoomMode}
visualNode={visualNode}
/>
))}
</g>
</svg>
</div>
</div>
);
}
function TotalUsersControl({
canToggleInternal,
includeInternal,
onIncludeInternalChange,
totalUsers,
}: {
canToggleInternal: boolean;
includeInternal: boolean;
onIncludeInternalChange: (checked: boolean) => void;
totalUsers: number;
}) {
if (!canToggleInternal) {
return (
<div
className="ml-2 whitespace-nowrap rounded-full border border-[#f2c484]/30 bg-[#f2c484]/10 px-4 py-2 text-xs font-black text-[#f2c484] shadow-sm"
data-testid="orgchart-total-users-control"
>
{totalUsers}
</div>
);
}
return (
<div className="group/total relative ml-2">
<button
aria-label={`전체 인원: 총 ${totalUsers}`}
className="whitespace-nowrap rounded-full border border-[#f2c484]/30 bg-[#f2c484]/10 px-4 py-2 text-xs font-black text-[#f2c484] shadow-sm transition-colors hover:border-[#f2c484]/60 hover:bg-[#f2c484]/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f2c484]/70"
data-testid="orgchart-total-users-control"
type="button"
>
{totalUsers}
</button>
<div className="absolute right-0 top-full z-40 mt-2 hidden w-[220px] rounded-md border border-[#8bd3b2]/35 bg-[#0d3d33] p-3 text-[#e1fff1] shadow-xl group-hover/total:block group-focus-within/total:block">
<label className="flex items-center justify-between gap-3">
<span className="text-xs font-bold"> </span>
<Switch
aria-label="내부조직 보기"
checked={includeInternal}
className="data-[state=checked]:bg-[#f2c484]"
onCheckedChange={onIncludeInternalChange}
/>
</label>
</div>
</div>
);
}
function LayoutOptionPicker<T extends string>({
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 (
<div
className="group flex items-center gap-1 whitespace-nowrap rounded-full border border-[#f2c484]/30 bg-white/10 p-1 transition-colors hover:border-[#f2c484]/60 hover:bg-white/15 focus-within:border-[#f2c484]/60 focus-within:bg-white/15"
data-testid={testId}
>
<button
aria-label={`${label}: ${selectedOption.label}`}
className="min-w-12 rounded-full bg-[#f2c484] px-3 py-1.5 text-xs font-black text-[#0a2a22] shadow-sm"
type="button"
>
{selectedOption.label}
</button>
<div className="hidden items-center gap-1 group-hover:flex group-focus-within:flex">
<span className="ml-1 border-l border-[#f2c484]/30 pl-3 pr-1 text-[11px] font-bold text-[#f7f0e4]/65">
{label}
</span>
{availableOptions.map((option) => (
<button
className="rounded-full border border-transparent px-3 py-1.5 text-xs font-bold text-[#f7f0e4]/70 transition-all hover:border-[#f2c484]/30 hover:bg-white/10 hover:text-[#f7f0e4]"
key={option.id}
onClick={() => onChange(option.id)}
type="button"
>
{option.label}
</button>
))}
</div>
</div>
);
}
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 (
<div
className="flex min-w-0 flex-wrap items-center gap-1"
data-testid="orgchart-org-selector"
>
<button
aria-current={isFamilySelected ? "true" : undefined}
aria-label={isFamilySelected ? "조직: 한맥가족" : undefined}
className={`${baseButtonClass} ${
isFamilySelected ? selectedButtonClass : optionButtonClass
}`}
onClick={() => onChange(FAMILY_FILTER_ID)}
type="button"
>
</button>
{showSelectedDescendant ? (
<button
aria-current="true"
aria-label={`조직: ${selectedLabel}`}
className={`${baseButtonClass} max-w-[220px] truncate ${selectedButtonClass}`}
onClick={() => onChange(selectedId)}
type="button"
>
{selectedLabel}
</button>
) : null}
{options.map((option) => {
const isSelected = selectedId === option.id;
return (
<div
className="group/company relative shrink-0"
data-testid={`orgchart-company-option-${option.id}`}
key={option.id}
>
<button
aria-current={isSelected ? "true" : undefined}
aria-label={isSelected ? `조직: ${option.label}` : undefined}
className={`${baseButtonClass} ${
isSelected ? selectedButtonClass : optionButtonClass
}`}
onClick={() => onChange(option.id)}
type="button"
>
{option.label}
</button>
{option.descendants.length > 0 ? (
<div className="absolute left-0 top-full z-40 mt-2 hidden min-w-[220px] flex-col gap-1 rounded-md border border-[#8bd3b2]/35 bg-[#0d3d33] p-2 shadow-xl group-hover/company:flex group-focus-within/company:flex">
{option.descendants
.filter((descendant) => descendant.id !== selectedId)
.map((descendant) => (
<button
aria-label={`${descendant.depth}뎁스 ${descendant.label}`}
className="rounded px-3 py-2 text-left text-xs font-bold text-[#e1fff1]/85 hover:bg-[#d6f4e6]/12 hover:text-[#f4fff8]"
key={descendant.id}
onClick={() => onChange(descendant.id)}
type="button"
>
<span className="mr-2 text-[10px] text-[#9ee7c2]/75">
{descendant.depth}
</span>
{descendant.label}
</button>
))}
</div>
) : null}
</div>
);
})}
</div>
);
}
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 tenantIdentity = { id: node.id, slug: node.companyCode ?? "" };
const headerFill = getOrgNodeHeaderFill(
node.companyColorDepth ?? node.level,
node.companyColorKey,
);
const memberColumnCount = getMemberColumnCount(
members.length,
getMemberColumnWidth(members, tenantIdentity),
);
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-[1.05]"
: "text-[14px] font-extrabold leading-[1.05]";
const titleStyle: React.CSSProperties = {
display: "-webkit-box",
overflow: "hidden",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 2,
};
return (
<foreignObject
data-node-id={node.id}
data-node-level={node.level}
data-hovered={isHovered || undefined}
data-testid={`orgchart-node-${node.id}`}
height={height}
onPointerEnter={onHoverStart}
onPointerLeave={onHoverEnd}
width={width}
x={x}
y={y}
>
<div className="flex h-full w-full flex-col overflow-hidden rounded-[10px] border border-[#e0d5c1] bg-white shadow-[0_10px_24px_rgba(31,41,55,0.08)]">
<div
className="flex h-[42px] shrink-0 items-center gap-3 px-4 text-[#f7f0e4]"
style={{ backgroundColor: headerFill }}
>
{showNodeName ? (
<div className="flex min-w-0 flex-1 items-center gap-2">
{node.orgUnitType ? (
<span className="shrink-0 rounded bg-white/20 px-1.5 py-0.5 text-[11px] font-black text-white">
{node.orgUnitType}
</span>
) : null}
<div
className={`${titleClass} min-w-0 break-words`}
style={titleStyle}
>
{node.name}
</div>
</div>
) : (
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="h-3 w-8 rounded-full bg-[#f7f0e4]/65" />
<div className="h-3 w-14 rounded-full bg-[#f7f0e4]/35" />
</div>
)}
<div className="flex h-[22px] min-w-[52px] shrink-0 items-center justify-center rounded-full bg-black/25 px-2 text-xs font-bold text-white">
{collapsed ? "+" : node.totalCount}
</div>
</div>
{showCompactGlyph ? (
<div className="flex flex-1 items-center justify-center px-4">
<div className="flex items-center gap-2 rounded-full border border-[#e0d5c1] bg-[#f8fafc] px-4 py-2 text-xs font-black text-[#64748b]">
<span className="h-2 w-2 rounded-full bg-[#94a3b8]" />
{node.children.length > 0
? node.children.length
: node.totalCount}
</div>
</div>
) : showMemberRows && members.length > 0 ? (
<div
className="grid gap-1 px-3 py-3"
data-member-columns={memberColumnCount}
style={{
gridTemplateColumns: `repeat(${memberColumnCount}, minmax(0, 1fr))`,
}}
>
{members.map((member) => {
const profile = getUserOrgProfile(member, tenantIdentity);
const isHighlighted = profile.isHighlighted === true;
return (
<div
className="flex h-5 items-center overflow-hidden rounded border border-[#e5e7eb] bg-white text-xs font-extrabold text-[#334155]"
data-highlighted={isHighlighted ? "true" : "false"}
data-testid={`orgchart-member-${member.id}`}
key={member.id}
>
<div
className="h-full w-1 shrink-0"
style={{
backgroundColor: isHighlighted
? getComplementaryColor(headerFill)
: "transparent",
}}
/>
<div className="min-w-0 truncate px-2">
{getOrgChartUserDisplayName(member, tenantIdentity)}
</div>
</div>
);
})}
</div>
) : (
<div className="flex flex-1 items-center px-4 text-xs font-bold text-[#94a3b8]">
</div>
)}
</div>
</foreignObject>
);
}