1
0
forked from baron/baron-sso

feat: integrate orgfront and expose internal ids

This commit is contained in:
2026-04-30 09:33:39 +09:00
parent 02375af08d
commit 9ce7a67f58
116 changed files with 22992 additions and 33 deletions

View File

@@ -0,0 +1,881 @@
import { useQuery } from "@tanstack/react-query";
import * as React from "react";
import { useLocation, useParams } from "react-router-dom";
import {
type TenantSummary,
type UserSummary,
fetchPublicOrgChart,
fetchTenants,
fetchUsers,
} from "../../../lib/adminApi";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
import { getOrgChartUserDisplayName, getUserOrgProfile } from "../userDisplay";
type OrgNode = {
id: string;
name: string;
level: number;
members: UserSummary[];
children: OrgNode[];
totalCount: number;
totalMemberIds: Set<string>;
companyCode?: string;
type?: string;
};
type ViewBox = {
x: number;
y: number;
width: number;
height: number;
};
type VisualNode = {
node: OrgNode;
x: number;
y: number;
width: number;
height: number;
members: UserSummary[];
collapsed: boolean;
};
type VisualEdge = {
key: string;
path: string;
};
type ChartLayout = {
nodes: VisualNode[];
edges: VisualEdge[];
width: number;
height: number;
};
const NODE_WIDTH = 340;
const HEADER_HEIGHT = 42;
const MEMBER_ROW_HEIGHT = 24;
const NODE_PADDING_Y = 12;
const ROOT_GAP_X = 120;
const CHILD_GAP_X = 80;
const CHILD_GAP_Y = 96;
const CHART_MARGIN = 72;
const MIN_SCALE = 0.45;
const MAX_SCALE = 2.4;
const ZOOM_SENSITIVITY = 0.0015;
const FAMILY_FILTER_ID = "hanmac-family";
const ROLE_ORDER = [
"사장",
"부사장",
"전무",
"상무",
"이사",
"수석",
"책임",
"선임",
"주임",
"사원",
];
function getRankWeight(
user: UserSummary,
tenant?: { id: string; slug: string },
) {
const profile = getUserOrgProfile(user, tenant);
const role = profile.position || "";
const order = ROLE_ORDER.indexOf(role);
const isLeader =
profile.position.endsWith("장") || profile.jobTitle.endsWith("장");
return (isLeader ? -100 : 0) + (order === -1 ? 99 : order);
}
function getNodeHeight(members: UserSummary[]) {
return (
HEADER_HEIGHT +
NODE_PADDING_Y * 2 +
Math.max(members.length, 1) * MEMBER_ROW_HEIGHT
);
}
function clampScale(scale: number) {
return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale));
}
function buildOrgNode(
tenantNode: TenantNode,
usersMap: Map<string, UserSummary[]>,
depth: number,
): OrgNode {
const slug = tenantNode.slug.toLowerCase();
const members = usersMap.get(slug) || [];
const children = tenantNode.children.map((child) =>
buildOrgNode(child, usersMap, depth + 1),
);
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,
type: tenantNode.type,
};
}
function layoutTree(
node: OrgNode,
collapsedIds: Set<string>,
originX = 0,
originY = 0,
): ChartLayout {
const members = [...node.members].sort(
(a, b) =>
getRankWeight(a, { id: node.id, slug: node.companyCode ?? "" }) -
getRankWeight(b, { id: node.id, slug: node.companyCode ?? "" }),
);
const nodeHeight = getNodeHeight(members);
const collapsed = collapsedIds.has(node.id);
const childLayouts = collapsed
? []
: node.children.map((child) => layoutTree(child, collapsedIds));
const childrenWidth =
childLayouts.length > 0
? childLayouts.reduce((sum, child) => sum + child.width, 0) +
CHILD_GAP_X * (childLayouts.length - 1)
: 0;
const subtreeWidth = Math.max(NODE_WIDTH, childrenWidth);
const nodeX = originX + (subtreeWidth - NODE_WIDTH) / 2;
const nodeY = originY;
const visualNode: VisualNode = {
node,
x: nodeX,
y: nodeY,
width: NODE_WIDTH,
height: nodeHeight,
members,
collapsed,
};
let cursorX = originX;
let maxChildHeight = 0;
const nodes: VisualNode[] = [visualNode];
const edges: VisualEdge[] = [];
for (const childLayout of childLayouts) {
const childOffsetY = originY + nodeHeight + CHILD_GAP_Y;
const shiftedNodes = childLayout.nodes.map((childNode) => ({
...childNode,
x: childNode.x + cursorX,
y: childNode.y + childOffsetY,
}));
const shiftedEdges = childLayout.edges.map((edge) => ({
...edge,
path: offsetPath(edge.path, cursorX, childOffsetY),
}));
const childRoot = shiftedNodes[0];
const parentCenterX = nodeX + NODE_WIDTH / 2;
const parentBottomY = nodeY + nodeHeight;
const childCenterX = childRoot.x + childRoot.width / 2;
const childTopY = childRoot.y;
const midY = parentBottomY + CHILD_GAP_Y / 2;
nodes.push(...shiftedNodes);
edges.push({
key: `${node.id}->${childRoot.node.id}`,
path: `M ${parentCenterX} ${parentBottomY} L ${parentCenterX} ${midY} L ${childCenterX} ${midY} L ${childCenterX} ${childTopY}`,
});
edges.push(...shiftedEdges);
cursorX += childLayout.width + CHILD_GAP_X;
maxChildHeight = Math.max(maxChildHeight, childLayout.height);
}
const height =
childLayouts.length > 0
? nodeHeight + CHILD_GAP_Y + maxChildHeight
: nodeHeight;
return {
nodes,
edges,
width: subtreeWidth,
height,
};
}
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);
});
}
function layoutForest(
nodes: OrgNode[],
collapsedIds: Set<string>,
): ChartLayout {
const layouts = nodes.map((node) => layoutTree(node, collapsedIds));
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 getColorForCompany(companyCode?: string) {
const code = (companyCode || "").toLowerCase();
if (code.includes("hanmac")) return "#ef4444";
if (code.includes("saman")) return "#ffb366";
if (code.includes("ptc")) return "#a855f7";
if (code.includes("baron")) return "#3b82f6";
return "#64748b";
}
function isVisibleOrgChartUser(user: UserSummary) {
return (
!user.email.toLowerCase().endsWith("@hanmac.kr") &&
!isSystemGlobalUser(user)
);
}
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.companyCode || user.tenantSlug || "",
slug: user.companyCode || 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 filterSystemGlobalTenants(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));
}
type TenantIndexes = {
byId: Map<string, TenantNode>;
bySlug: Map<string, TenantNode>;
};
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 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 getLeafMembershipSlugs(
slugs: Set<string>,
tenantIndexes: TenantIndexes,
) {
const memberships = Array.from(slugs);
return memberships.filter((slug) => {
const tenant = tenantIndexes.bySlug.get(slug);
if (!tenant) return true;
return !memberships.some((otherSlug) => {
if (otherSlug === slug) return false;
const otherTenant = tenantIndexes.bySlug.get(otherSlug);
if (!otherTenant) return false;
return isDescendantTenant(otherTenant, tenant, tenantIndexes.byId);
});
});
}
function buildUsersMap(
users: UserSummary[],
rootNodes: TenantNode[],
options: { activeOnly: boolean },
) {
const tenantIndexes = buildTenantIndexes(rootNodes);
const map = new Map<string, UserSummary[]>();
for (const user of users) {
if (options.activeOnly && user.status !== "active") continue;
if (!isVisibleOrgChartUser(user)) continue;
const slugs = new Set<string>();
const primarySlug =
user.companyCode?.toLowerCase() || user.tenantSlug?.toLowerCase() || "";
if (
primarySlug &&
!isSystemGlobalTenant({
id: primarySlug,
slug: primarySlug,
type: primarySlug,
name: primarySlug,
})
) {
slugs.add(primarySlug);
}
if (user.tenant?.slug && !isSystemGlobalTenant(user.tenant)) {
slugs.add(user.tenant.slug.toLowerCase());
}
for (const joinedTenant of user.joinedTenants || []) {
if (joinedTenant.slug && !isSystemGlobalTenant(joinedTenant)) {
slugs.add(joinedTenant.slug.toLowerCase());
}
}
for (const slug of getLeafMembershipSlugs(slugs, tenantIndexes)) {
const list = map.get(slug) || [];
if (!list.some((existing) => existing.id === user.id)) list.push(user);
map.set(slug, list);
}
}
return map;
}
export function TenantOrgChartPage() {
const viewportRef = React.useRef<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 [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 publicQuery = useQuery({
queryKey: ["public-orgchart", shareToken],
queryFn: () => {
if (!shareToken) throw new Error("Missing share token");
return fetchPublicOrgChart(shareToken);
},
enabled: !!shareToken,
});
const tenantsQuery = useQuery({
queryKey: ["tenants-full-tree-v2"],
queryFn: () => fetchTenants(10000, 0),
enabled: !shareToken,
});
const usersQuery = useQuery({
queryKey: ["users", { limit: 5000, offset: 0 }],
queryFn: () => fetchUsers(5000, 0),
enabled: !shareToken,
});
const { rootNodes, usersMap, sharedWith } = React.useMemo(() => {
if (shareToken) {
if (!publicQuery.data) {
return {
rootNodes: [],
usersMap: new Map<string, UserSummary[]>(),
sharedWith: "",
};
}
const rootNodes = buildTenantFullTree(
filterSystemGlobalTenants(publicQuery.data.tenants),
).subTree;
return {
rootNodes,
usersMap: buildUsersMap(publicQuery.data.users, rootNodes, {
activeOnly: false,
}),
sharedWith: publicQuery.data.sharedWith,
};
}
if (!tenantsQuery.data?.items || !usersQuery.data?.items) {
return {
rootNodes: [],
usersMap: new Map<string, UserSummary[]>(),
sharedWith: "",
};
}
const rootNodes = buildTenantFullTree(
filterSystemGlobalTenants(tenantsQuery.data.items),
).subTree;
return {
rootNodes,
usersMap: buildUsersMap(usersQuery.data.items, rootNodes, {
activeOnly: true,
}),
sharedWith: "",
};
}, [publicQuery.data, shareToken, tenantsQuery.data, usersQuery.data]);
const familyRoot = React.useMemo(() => {
return (
rootNodes.find((node) => node.type === "COMPANY_GROUP") ??
rootNodes[0] ??
null
);
}, [rootNodes]);
const companyFilters = React.useMemo(() => {
return (familyRoot?.children ?? [])
.filter((node) => node.type === "COMPANY")
.map((node) => ({ id: node.id, label: node.name }))
.sort((a, b) => a.label.localeCompare(b.label));
}, [familyRoot]);
const filterOptions = React.useMemo(
() => [{ id: FAMILY_FILTER_ID, label: "한맥가족" }, ...companyFilters],
[companyFilters],
);
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") 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);
return companyNode ? [buildOrgNode(companyNode, usersMap, 0)] : [];
}, [familyRoot, selectedTenantFilter, usersMap]);
const layout = React.useMemo(
() => layoutForest(targetNodes, collapsedIds),
[collapsedIds, targetNodes],
);
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
: tenantsQuery.isLoading || usersQuery.isLoading;
const isError = shareToken
? publicQuery.isError
: tenantsQuery.isError || usersQuery.isError;
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) {
return (
<div className="p-8 text-center text-red-500">
.
</div>
);
}
return (
<div className="flex h-[calc(100vh-theme(spacing.32))] flex-col overflow-hidden rounded-xl border border-[#e0d5c1] bg-[#f6efe6] shadow-sm">
<header className="z-10 flex shrink-0 flex-col items-start justify-between 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 sm:flex-row sm:items-center">
<div className="mb-4 flex flex-col gap-1 sm:mb-0">
<p className="text-xs font-bold uppercase tracking-wider text-[#f2c484]">
{shareToken ? `공유된 조직도: ${sharedWith}` : "MH Dashboard"}
</p>
<h2 className="text-xl font-black text-[#f7f0e4]"> </h2>
</div>
<div className="custom-scrollbar flex max-w-full items-center gap-2 overflow-x-auto">
{filterOptions.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
setSelectedTenantFilter(option.id);
setCollapsedIds(new Set());
setHasUserMovedCanvas(false);
}}
className={`whitespace-nowrap rounded-full border px-4 py-2 text-xs font-bold transition-all ${
selectedTenantFilter === option.id
? "border-[#f2c484]/40 bg-[linear-gradient(180deg,rgba(255,253,248,0.98),rgba(245,235,221,0.94))] text-[#0a2a22] shadow-sm"
: "border-[#f2c484]/30 bg-white/10 text-[#f7f0e4]/70 hover:border-[#f2c484]/50 hover:text-[#f7f0e4]"
}`}
>
{option.label}
</button>
))}
<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">
{totalUsers}
</div>
</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={getViewBoxScale(layout, viewBox).toFixed(3)}
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}
fill="none"
key={edge.key}
stroke="#bca58a"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
))}
{layout.nodes.map((visualNode) => (
<SvgOrgNode key={visualNode.node.id} visualNode={visualNode} />
))}
</g>
</svg>
</div>
</div>
);
}
function SvgOrgNode({
visualNode,
}: {
visualNode: VisualNode;
}) {
const { node, x, y, width, height, members, collapsed } = visualNode;
const headerFill = node.level === 0 ? "#0a2a22" : "#2f5547";
const accent = getColorForCompany(node.companyCode);
return (
<g transform={`translate(${x} ${y})`}>
<rect
fill="#ffffff"
height={height}
rx="10"
stroke="#e0d5c1"
strokeWidth="1.5"
width={width}
/>
<rect fill={headerFill} height={HEADER_HEIGHT} rx="10" width={width} />
<rect
fill={headerFill}
height="18"
width={width}
y={HEADER_HEIGHT - 18}
/>
<text
fill="#f7f0e4"
fontSize={node.level === 0 ? 17 : 15}
fontWeight="800"
textAnchor="middle"
x={width / 2}
y="27"
>
{node.name}
</text>
<g>
<rect
fill="rgba(0,0,0,0.22)"
height="22"
rx="11"
width="52"
x={width - 66}
y="10"
/>
<text
fill="#ffffff"
fontSize="12"
fontWeight="700"
textAnchor="middle"
x={width - 40}
y="25"
>
{collapsed ? "+" : node.totalCount}
</text>
</g>
{members.length > 0 ? (
members.map((member, index) => (
<g
key={member.id}
transform={`translate(14 ${HEADER_HEIGHT + NODE_PADDING_Y + index * MEMBER_ROW_HEIGHT})`}
>
<rect
fill="#ffffff"
height="20"
rx="4"
stroke="#e5e7eb"
width={width - 28}
/>
<rect fill={accent} height="20" rx="4" width="4" />
<text fill="#334155" fontSize="12" fontWeight="800" x="12" y="14">
{getOrgChartUserDisplayName(member, {
id: node.id,
slug: node.companyCode ?? "",
})}
</text>
</g>
))
) : (
<text fill="#94a3b8" fontSize="12" x="16" y={HEADER_HEIGHT + 28}>
</text>
)}
</g>
);
}