1
0
forked from baron/baron-sso

Fix org chart manager ordering and title wrapping

This commit is contained in:
2026-06-15 21:11:03 +09:00
parent 44726e5a54
commit b2808759d2
8 changed files with 179 additions and 18 deletions

View File

@@ -217,6 +217,63 @@ describe("org chart layout", () => {
]);
});
it("places organization managers before higher rank members in the same organization", () => {
const members = [
{ ...member("executive"), name: "임원", grade: "전무이사" },
{
...member("manager"),
name: "조직장",
grade: "사원",
metadata: {
additionalAppointments: [
{
tenantSlug: "root",
isManager: true,
},
],
},
},
{ ...member("principal"), name: "수석", grade: "수석연구원" },
];
const layout = layoutForest(
[
{
...orgNode("root"),
members,
totalCount: members.length,
totalMemberIds: new Set(members.map((item) => item.id)),
},
],
new Set(),
);
const rootNode = layout.nodes.find((item) => item.node.id === "root");
expect(rootNode?.members.map((item) => item.id)).toEqual([
"manager",
"executive",
"principal",
]);
});
it("expands node width for long organization names even without members", () => {
const shortLayout = layoutForest([orgNode("short")], new Set());
const longLayout = layoutForest(
[
{
...orgNode("long"),
name: "초장문 조직 명칭 표시 검증을 위한 구조물 디지털 전환 통합 운영 센터",
},
],
new Set(),
);
const shortNode = shortLayout.nodes.find(
(item) => item.node.id === "short",
);
const longNode = longLayout.nodes.find((item) => item.node.id === "long");
expect(longNode?.width).toBeGreaterThan(shortNode?.width ?? 0);
});
it("uses multi-column layout by default when sibling width crosses the threshold", () => {
const children = Array.from({ length: 13 }, (_, index) =>
orgNode(`child-${index + 1}`, [], 1),

View File

@@ -104,6 +104,10 @@ 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;
@@ -154,6 +158,24 @@ function getRankWeight(
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";
@@ -246,14 +268,37 @@ function getMemberRowCount(memberCount: number, memberColumnWidth?: number) {
return getMemberGridMetrics(memberCount, memberColumnWidth).rowCount;
}
function getNodeWidth(members: UserSummary[], memberColumnWidth: number) {
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, NODE_PADDING_Y * 2 + memberColumnWidth);
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,
@@ -775,9 +820,8 @@ function layoutTree(
options: OrgChartLayoutOptions,
): ChartLayout {
const tenantIdentity = { id: node.id, slug: node.companyCode ?? "" };
const members = [...node.members].sort(
(a, b) =>
getRankWeight(a, tenantIdentity) - getRankWeight(b, tenantIdentity),
const members = [...node.members].sort((a, b) =>
compareOrgMembers(a, b, tenantIdentity),
);
const memberColumnWidth = getMemberColumnWidth(members, tenantIdentity);
const nodeHeight = getNodeHeight(members, memberColumnWidth);
@@ -802,7 +846,7 @@ function layoutTree(
const childCenters = childRoots.map(
(childRoot) => childRoot.x + childRoot.width / 2,
);
const nodeWidth = getNodeWidth(members, memberColumnWidth);
const nodeWidth = getNodeWidth(node, members, memberColumnWidth);
const firstChildCenter =
childCenters.length > 0 ? Math.min(...childCenters) : nodeWidth / 2;
const lastChildCenter =
@@ -2089,8 +2133,14 @@ function SvgOrgNode({
const showCompactGlyph = !showNodeName;
const titleClass =
node.level <= 1
? "text-[17px] font-black leading-tight"
: "text-[14px] font-extrabold leading-tight";
? "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
@@ -2117,7 +2167,10 @@ function SvgOrgNode({
{node.orgUnitType}
</span>
) : null}
<div className={`${titleClass} min-w-0 truncate`}>
<div
className={`${titleClass} min-w-0 break-words`}
style={titleStyle}
>
{node.name}
</div>
</div>

View File

@@ -58,6 +58,7 @@ export function getUserOrgProfile(user: UserSummary, tenant?: TenantIdentity) {
appointment?.isAdmin === true ||
appointment?.isManager === true ||
appointment?.isOwner === true,
isManager: appointment?.isManager === true,
jobTitle: appointment?.jobTitle || normalizeText(user.jobTitle),
position: appointment?.position || normalizeText(user.position),
};