forked from baron/baron-sso
2239 lines
65 KiB
TypeScript
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>
|
|
);
|
|
}
|