forked from baron/baron-sso
조직도 표현 개선
This commit is contained in:
30
orgfront/src/features/orgchart/rankPriority.test.ts
Normal file
30
orgfront/src/features/orgchart/rankPriority.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compareOrgRanks,
|
||||
getOrgRankDisplayName,
|
||||
getOrgRankWeight,
|
||||
} from "./rankPriority";
|
||||
|
||||
describe("org chart rank priority", () => {
|
||||
it("normalizes long rank aliases to short display labels", () => {
|
||||
expect(getOrgRankDisplayName("전무이사")).toBe("전무");
|
||||
expect(getOrgRankDisplayName("상무이사")).toBe("상무");
|
||||
expect(getOrgRankDisplayName("수석연구원")).toBe("수석");
|
||||
expect(getOrgRankDisplayName("책임연구원")).toBe("책임");
|
||||
expect(getOrgRankDisplayName("선임연구원")).toBe("선임");
|
||||
});
|
||||
|
||||
it("orders executive and research ranks with shared priority weights", () => {
|
||||
expect(getOrgRankWeight("사장")).toBeLessThan(getOrgRankWeight("부사장"));
|
||||
expect(getOrgRankWeight("전무이사")).toBeLessThan(
|
||||
getOrgRankWeight("상무"),
|
||||
);
|
||||
expect(getOrgRankWeight("수석연구원")).toBeLessThan(
|
||||
getOrgRankWeight("책임"),
|
||||
);
|
||||
expect(getOrgRankWeight("책임연구원")).toBeLessThan(
|
||||
getOrgRankWeight("선임"),
|
||||
);
|
||||
expect(compareOrgRanks("부장", "차장")).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
54
orgfront/src/features/orgchart/rankPriority.ts
Normal file
54
orgfront/src/features/orgchart/rankPriority.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export type OrgRankDefinition = {
|
||||
aliases: string[];
|
||||
label: string;
|
||||
weight: number;
|
||||
};
|
||||
|
||||
export const ORG_RANK_DEFINITIONS: OrgRankDefinition[] = [
|
||||
{ label: "사장", weight: 0, aliases: ["사장"] },
|
||||
{ label: "부사장", weight: 10, aliases: ["부사장"] },
|
||||
{ label: "전무", weight: 20, aliases: ["전무", "전무이사"] },
|
||||
{ label: "상무", weight: 30, aliases: ["상무", "상무이사"] },
|
||||
{ label: "이사", weight: 40, aliases: ["이사"] },
|
||||
{ label: "부장", weight: 50, aliases: ["부장"] },
|
||||
{ label: "수석", weight: 50, aliases: ["수석", "수석연구원"] },
|
||||
{ label: "차장", weight: 60, aliases: ["차장"] },
|
||||
{ label: "과장", weight: 70, aliases: ["과장"] },
|
||||
{ label: "책임", weight: 70, aliases: ["책임", "책임연구원"] },
|
||||
{ label: "대리", weight: 80, aliases: ["대리"] },
|
||||
{ label: "선임", weight: 80, aliases: ["선임", "선임연구원"] },
|
||||
{ label: "연구원", weight: 90, aliases: ["연구원"] },
|
||||
{ label: "사원", weight: 90, aliases: ["사원"] },
|
||||
];
|
||||
|
||||
const UNKNOWN_RANK_WEIGHT = 999;
|
||||
|
||||
function normalizeRankText(value: unknown) {
|
||||
return typeof value === "string" ? value.trim().replace(/\s+/g, "") : "";
|
||||
}
|
||||
|
||||
export function getOrgRankDefinition(rank: unknown) {
|
||||
const normalizedRank = normalizeRankText(rank);
|
||||
if (!normalizedRank) return undefined;
|
||||
|
||||
return ORG_RANK_DEFINITIONS.find((definition) =>
|
||||
definition.aliases.some(
|
||||
(alias) => normalizeRankText(alias) === normalizedRank,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getOrgRankDisplayName(rank: unknown) {
|
||||
return (
|
||||
getOrgRankDefinition(rank)?.label ??
|
||||
(typeof rank === "string" ? rank.trim() : "")
|
||||
);
|
||||
}
|
||||
|
||||
export function getOrgRankWeight(rank: unknown) {
|
||||
return getOrgRankDefinition(rank)?.weight ?? UNKNOWN_RANK_WEIGHT;
|
||||
}
|
||||
|
||||
export function compareOrgRanks(a: unknown, b: unknown) {
|
||||
return getOrgRankWeight(a) - getOrgRankWeight(b);
|
||||
}
|
||||
@@ -99,12 +99,52 @@ describe("org chart layout", () => {
|
||||
const rootNode = layout.nodes.find((item) => item.node.id === "root");
|
||||
|
||||
expect(rootNode).toBeDefined();
|
||||
expect(rootNode?.width).toBeGreaterThan(340);
|
||||
expect(rootNode?.width).toBeGreaterThan(240);
|
||||
expect(rootNode?.height).toBeLessThan(42 + 24 + 10 * 24);
|
||||
expect(layout.width).toBeGreaterThan((rootNode?.width ?? 0) + 72 * 2 - 1);
|
||||
});
|
||||
|
||||
it("keeps modest member groups in one column until another column improves the rendered ratio", () => {
|
||||
it("sizes member cards from an eight-character baseline and expands for long display names", () => {
|
||||
const shortMembers = Array.from({ length: 6 }, (_, index) => ({
|
||||
...member(`short-${index + 1}`),
|
||||
name: `홍길${index + 1}`,
|
||||
grade: "책임",
|
||||
}));
|
||||
const longMembers = shortMembers.map((item, index) => ({
|
||||
...item,
|
||||
id: `long-${index + 1}`,
|
||||
name: `매우긴사용자이름${index + 1}`,
|
||||
}));
|
||||
const shortLayout = layoutForest(
|
||||
[
|
||||
{
|
||||
...orgNode("short"),
|
||||
members: shortMembers,
|
||||
totalCount: shortMembers.length,
|
||||
totalMemberIds: new Set(shortMembers.map((item) => item.id)),
|
||||
},
|
||||
],
|
||||
new Set(),
|
||||
);
|
||||
const longLayout = layoutForest(
|
||||
[
|
||||
{
|
||||
...orgNode("long"),
|
||||
members: longMembers,
|
||||
totalCount: longMembers.length,
|
||||
totalMemberIds: new Set(longMembers.map((item) => item.id)),
|
||||
},
|
||||
],
|
||||
new Set(),
|
||||
);
|
||||
const shortNode = shortLayout.nodes.find((item) => item.node.id === "short");
|
||||
const longNode = longLayout.nodes.find((item) => item.node.id === "long");
|
||||
|
||||
expect(shortNode?.width).toBeLessThan(320);
|
||||
expect(longNode?.width).toBeGreaterThan(shortNode?.width ?? 0);
|
||||
});
|
||||
|
||||
it("uses compact member columns when another column improves the rendered ratio", () => {
|
||||
const tenMembers = Array.from({ length: 10 }, (_, index) =>
|
||||
member(`member-${index + 1}`),
|
||||
);
|
||||
@@ -134,15 +174,44 @@ describe("org chart layout", () => {
|
||||
const sixNode = sixLayout.nodes.find((item) => item.node.id === "six");
|
||||
const tenNode = tenLayout.nodes.find((item) => item.node.id === "ten");
|
||||
|
||||
expect(sixNode?.width).toBe(340);
|
||||
expect(tenNode?.width).toBeGreaterThan(sixNode?.width ?? 0);
|
||||
expect(sixNode?.width).toBeGreaterThan(240);
|
||||
expect(tenNode?.width).toBe(sixNode?.width);
|
||||
expect(sixNode?.height).toBeLessThan(42 + 24 + 6 * 24);
|
||||
expect(tenNode?.height).toBeLessThan(42 + 24 + 10 * 24);
|
||||
});
|
||||
|
||||
it("chooses member columns from the rendered node aspect ratio instead of fixed five-member buckets", () => {
|
||||
expect(getMemberGridMetrics(6)).toEqual({ columnCount: 1, rowCount: 6 });
|
||||
expect(getMemberGridMetrics(6)).toEqual({ columnCount: 2, rowCount: 3 });
|
||||
expect(getMemberGridMetrics(10)).toEqual({ columnCount: 2, rowCount: 5 });
|
||||
expect(getMemberGridMetrics(25)).toEqual({ columnCount: 2, rowCount: 13 });
|
||||
expect(getMemberGridMetrics(25)).toEqual({ columnCount: 4, rowCount: 7 });
|
||||
});
|
||||
|
||||
it("sorts members by normalized rank inside the same organization", () => {
|
||||
const members = [
|
||||
{ ...member("staff"), name: "사원", grade: "사원" },
|
||||
{ ...member("principal"), name: "수석", grade: "수석연구원" },
|
||||
{ ...member("director"), name: "전무", grade: "전무이사" },
|
||||
{ ...member("lead"), 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([
|
||||
"director",
|
||||
"principal",
|
||||
"lead",
|
||||
"staff",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses multi-column layout by default when sibling width crosses the threshold", () => {
|
||||
@@ -163,7 +232,7 @@ describe("org chart layout", () => {
|
||||
expect(uniqueChildRows.size).toBeGreaterThan(1);
|
||||
expect(aspectRatio).toBeGreaterThanOrEqual(1.41);
|
||||
expect(aspectRatio).toBeLessThanOrEqual(1.61);
|
||||
expect(childSpan).toBeLessThan(13 * 340 + 12 * 80);
|
||||
expect(childSpan).toBeLessThan(13 * 240 + 12 * 80);
|
||||
expect(
|
||||
layout.edges.filter((edge) => edge.key.startsWith("root->")),
|
||||
).toHaveLength(13);
|
||||
@@ -184,7 +253,7 @@ describe("org chart layout", () => {
|
||||
);
|
||||
const aspectRatio = getNodeBoundsAspectRatio(layout.nodes);
|
||||
|
||||
expect(new Set(childNodes.map((node) => node.x)).size).toBe(2);
|
||||
expect(new Set(childNodes.map((node) => node.x)).size).toBe(4);
|
||||
expect(aspectRatio).toBeGreaterThanOrEqual(1.41);
|
||||
expect(aspectRatio).toBeLessThanOrEqual(1.61);
|
||||
});
|
||||
@@ -497,4 +566,44 @@ describe("org chart layout", () => {
|
||||
"gpdtdc-user",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not fall back to a visible parent for hidden leaf memberships", () => {
|
||||
const gpdtdc = tenantNode("gpdtdc", "COMPANY", "GPDTDC", "gpdtdc");
|
||||
const internalLeaf = {
|
||||
...tenantNode(
|
||||
"internal-leaf",
|
||||
"USER_GROUP",
|
||||
"내부 구성 조직",
|
||||
"internal-leaf",
|
||||
),
|
||||
parentId: "gpdtdc",
|
||||
};
|
||||
|
||||
const usersMap = buildUsersMap(
|
||||
[
|
||||
{
|
||||
...member("hidden-only-user"),
|
||||
companyCode: undefined,
|
||||
tenantSlug: "gpdtdc",
|
||||
metadata: {
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantSlug: "internal-leaf",
|
||||
isPrimary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
joinedTenants: undefined,
|
||||
},
|
||||
],
|
||||
[gpdtdc],
|
||||
{
|
||||
activeOnly: true,
|
||||
membershipRootNodes: [{ ...gpdtdc, children: [internalLeaf] }],
|
||||
},
|
||||
);
|
||||
|
||||
expect(usersMap.get("gpdtdc")).toBeUndefined();
|
||||
expect(usersMap.get("internal-leaf")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
orderHanmacFamilyTenants,
|
||||
} from "../hanmacFamilyOrder";
|
||||
import { filterTenantsByVisibility, getOrgUnitType } from "../tenantVisibility";
|
||||
import { getOrgRankWeight } from "../rankPriority";
|
||||
import { getOrgChartUserDisplayName, getUserOrgProfile } from "../userDisplay";
|
||||
|
||||
export type OrgNode = {
|
||||
@@ -84,12 +85,16 @@ export type OrgChartLayoutOptions = {
|
||||
|
||||
export type SemanticZoomMode = "overview" | "compact" | "detail";
|
||||
|
||||
const NODE_WIDTH = 340;
|
||||
const MEMBER_COLUMN_WIDTH = 300;
|
||||
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 MEMBER_GRID_TARGET_ASPECT_RATIO = 2;
|
||||
const MAX_MEMBER_COLUMN_COUNT = 8;
|
||||
const ROOT_GAP_X = 120;
|
||||
@@ -132,32 +137,59 @@ type DepthColorNodeData = {
|
||||
|
||||
type DepthColorNode = ReactFlowNode<DepthColorNodeData, "org-depth-color">;
|
||||
|
||||
const ROLE_ORDER = [
|
||||
"사장",
|
||||
"부사장",
|
||||
"전무",
|
||||
"상무",
|
||||
"이사",
|
||||
"수석",
|
||||
"책임",
|
||||
"선임",
|
||||
"주임",
|
||||
"사원",
|
||||
];
|
||||
|
||||
function getRankWeight(
|
||||
user: UserSummary,
|
||||
tenant?: { id: string; slug: string },
|
||||
) {
|
||||
const profile = getUserOrgProfile(user, tenant);
|
||||
const role = profile.grade || "";
|
||||
const order = ROLE_ORDER.indexOf(role);
|
||||
const isLeader =
|
||||
profile.position.endsWith("장") || profile.jobTitle.endsWith("장");
|
||||
return (isLeader ? -100 : 0) + (order === -1 ? 99 : order);
|
||||
return getOrgRankWeight(profile.grade);
|
||||
}
|
||||
|
||||
export function getMemberGridMetrics(memberCount: number) {
|
||||
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 (/^[\x00-\x7f]$/.test(char)) 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(
|
||||
@@ -174,8 +206,8 @@ export function getMemberGridMetrics(memberCount: number) {
|
||||
? NODE_WIDTH
|
||||
: Math.max(
|
||||
NODE_WIDTH,
|
||||
NODE_PADDING_Y * 2 +
|
||||
columnCount * MEMBER_COLUMN_WIDTH +
|
||||
NODE_PADDING_Y * 2 +
|
||||
columnCount * memberColumnWidth +
|
||||
(columnCount - 1) * MEMBER_COLUMN_GAP,
|
||||
);
|
||||
const height =
|
||||
@@ -197,31 +229,33 @@ export function getMemberGridMetrics(memberCount: number) {
|
||||
return best;
|
||||
}
|
||||
|
||||
function getMemberColumnCount(memberCount: number) {
|
||||
return getMemberGridMetrics(memberCount).columnCount;
|
||||
function getMemberColumnCount(memberCount: number, memberColumnWidth?: number) {
|
||||
return getMemberGridMetrics(memberCount, memberColumnWidth).columnCount;
|
||||
}
|
||||
|
||||
function getMemberRowCount(memberCount: number) {
|
||||
return getMemberGridMetrics(memberCount).rowCount;
|
||||
function getMemberRowCount(memberCount: number, memberColumnWidth?: number) {
|
||||
return getMemberGridMetrics(memberCount, memberColumnWidth).rowCount;
|
||||
}
|
||||
|
||||
function getNodeWidth(members: UserSummary[]) {
|
||||
const columnCount = getMemberColumnCount(members.length);
|
||||
if (columnCount <= 1) return NODE_WIDTH;
|
||||
function getNodeWidth(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,
|
||||
NODE_PADDING_Y * 2 +
|
||||
columnCount * MEMBER_COLUMN_WIDTH +
|
||||
columnCount * memberColumnWidth +
|
||||
(columnCount - 1) * MEMBER_COLUMN_GAP,
|
||||
);
|
||||
}
|
||||
|
||||
function getNodeHeight(members: UserSummary[]) {
|
||||
function getNodeHeight(members: UserSummary[], memberColumnWidth: number) {
|
||||
return (
|
||||
HEADER_HEIGHT +
|
||||
NODE_PADDING_Y * 2 +
|
||||
getMemberRowCount(members.length) * MEMBER_ROW_HEIGHT
|
||||
getMemberRowCount(members.length, memberColumnWidth) * MEMBER_ROW_HEIGHT
|
||||
);
|
||||
}
|
||||
|
||||
@@ -731,12 +765,13 @@ function layoutTree(
|
||||
collapsedIds: Set<string>,
|
||||
options: OrgChartLayoutOptions,
|
||||
): ChartLayout {
|
||||
const tenantIdentity = { id: node.id, slug: node.companyCode ?? "" };
|
||||
const members = [...node.members].sort(
|
||||
(a, b) =>
|
||||
getRankWeight(a, { id: node.id, slug: node.companyCode ?? "" }) -
|
||||
getRankWeight(b, { id: node.id, slug: node.companyCode ?? "" }),
|
||||
getRankWeight(a, tenantIdentity) - getRankWeight(b, tenantIdentity),
|
||||
);
|
||||
const nodeHeight = getNodeHeight(members);
|
||||
const memberColumnWidth = getMemberColumnWidth(members, tenantIdentity);
|
||||
const nodeHeight = getNodeHeight(members, memberColumnWidth);
|
||||
const collapsed = collapsedIds.has(node.id);
|
||||
const childLayouts = collapsed
|
||||
? []
|
||||
@@ -758,7 +793,7 @@ function layoutTree(
|
||||
const childCenters = childRoots.map(
|
||||
(childRoot) => childRoot.x + childRoot.width / 2,
|
||||
);
|
||||
const nodeWidth = getNodeWidth(members);
|
||||
const nodeWidth = getNodeWidth(members, memberColumnWidth);
|
||||
const firstChildCenter =
|
||||
childCenters.length > 0 ? Math.min(...childCenters) : nodeWidth / 2;
|
||||
const lastChildCenter =
|
||||
@@ -1116,6 +1151,31 @@ export function filterSystemGlobalTenants(
|
||||
return filterTenantsByVisibility(filtered, "public");
|
||||
}
|
||||
|
||||
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>;
|
||||
@@ -1209,9 +1269,12 @@ function getLeafMembershipSlugs(
|
||||
export function buildUsersMap(
|
||||
users: UserSummary[],
|
||||
rootNodes: TenantNode[],
|
||||
options: { activeOnly: boolean },
|
||||
options: { activeOnly: boolean; membershipRootNodes?: TenantNode[] },
|
||||
) {
|
||||
const tenantIndexes = buildTenantIndexes(rootNodes);
|
||||
const visibleTenantIndexes = buildTenantIndexes(rootNodes);
|
||||
const membershipTenantIndexes = buildTenantIndexes(
|
||||
options.membershipRootNodes ?? rootNodes,
|
||||
);
|
||||
const map = new Map<string, UserSummary[]>();
|
||||
|
||||
for (const user of users) {
|
||||
@@ -1230,7 +1293,7 @@ export function buildUsersMap(
|
||||
name: primarySlug,
|
||||
})
|
||||
) {
|
||||
addTenantSlugCandidate(slugs, tenantIndexes, primarySlug);
|
||||
addTenantSlugCandidate(slugs, membershipTenantIndexes, primarySlug);
|
||||
}
|
||||
if (
|
||||
legacyCompanySlug &&
|
||||
@@ -1241,31 +1304,40 @@ export function buildUsersMap(
|
||||
name: legacyCompanySlug,
|
||||
})
|
||||
) {
|
||||
addTenantSlugCandidate(slugs, tenantIndexes, legacyCompanySlug);
|
||||
addTenantSlugCandidate(slugs, membershipTenantIndexes, legacyCompanySlug);
|
||||
}
|
||||
if (user.tenant?.slug && !isSystemGlobalTenant(user.tenant)) {
|
||||
addTenantSlugCandidate(slugs, tenantIndexes, user.tenant.slug);
|
||||
addTenantSlugCandidate(slugs, membershipTenantIndexes, user.tenant.slug);
|
||||
}
|
||||
for (const joinedTenant of user.joinedTenants || []) {
|
||||
if (joinedTenant.slug && !isSystemGlobalTenant(joinedTenant)) {
|
||||
addTenantSlugCandidate(slugs, tenantIndexes, joinedTenant.slug);
|
||||
addTenantSlugCandidate(
|
||||
slugs,
|
||||
membershipTenantIndexes,
|
||||
joinedTenant.slug,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const appointment of getUserOrgAppointmentRefs(user)) {
|
||||
if (appointment.tenantSlug) {
|
||||
addTenantSlugCandidate(slugs, tenantIndexes, appointment.tenantSlug);
|
||||
addTenantSlugCandidate(
|
||||
slugs,
|
||||
membershipTenantIndexes,
|
||||
appointment.tenantSlug,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tenantById = appointment.tenantId
|
||||
? tenantIndexes.byId.get(appointment.tenantId)
|
||||
? membershipTenantIndexes.byId.get(appointment.tenantId)
|
||||
: undefined;
|
||||
if (tenantById) {
|
||||
addTenantSlugCandidate(slugs, tenantIndexes, tenantById.slug);
|
||||
addTenantSlugCandidate(slugs, membershipTenantIndexes, tenantById.slug);
|
||||
}
|
||||
}
|
||||
|
||||
for (const slug of getLeafMembershipSlugs(slugs, tenantIndexes)) {
|
||||
for (const slug of getLeafMembershipSlugs(slugs, membershipTenantIndexes)) {
|
||||
if (!visibleTenantIndexes.bySlug.has(slug)) continue;
|
||||
const list = map.get(slug) || [];
|
||||
if (!list.some((existing) => existing.id === user.id)) list.push(user);
|
||||
map.set(slug, list);
|
||||
@@ -1340,11 +1412,15 @@ export function TenantOrgChartPage() {
|
||||
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,
|
||||
};
|
||||
@@ -1361,11 +1437,15 @@ export function TenantOrgChartPage() {
|
||||
const rootNodes = buildTenantFullTree(
|
||||
filterSystemGlobalTenants(tenantsQuery.data.items, visibilityMode),
|
||||
).subTree;
|
||||
const membershipRootNodes = buildTenantFullTree(
|
||||
filterOrgChartMembershipTenants(tenantsQuery.data.items),
|
||||
).subTree;
|
||||
|
||||
return {
|
||||
rootNodes,
|
||||
usersMap: buildUsersMap(usersQuery.data.items, rootNodes, {
|
||||
activeOnly: true,
|
||||
membershipRootNodes,
|
||||
}),
|
||||
sharedWith: "",
|
||||
};
|
||||
@@ -1827,11 +1907,15 @@ function SvgOrgNode({
|
||||
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);
|
||||
const memberColumnCount = getMemberColumnCount(
|
||||
members.length,
|
||||
getMemberColumnWidth(members, tenantIdentity),
|
||||
);
|
||||
const showMemberRows = semanticZoomMode === "detail";
|
||||
const showNodeName =
|
||||
semanticZoomMode === "detail" ||
|
||||
@@ -1899,23 +1983,31 @@ function SvgOrgNode({
|
||||
gridTemplateColumns: `repeat(${memberColumnCount}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{members.map((member) => (
|
||||
<div
|
||||
className="flex h-5 items-center overflow-hidden rounded border border-[#e5e7eb] bg-white text-xs font-extrabold text-[#334155]"
|
||||
key={member.id}
|
||||
>
|
||||
{members.map((member) => {
|
||||
const profile = getUserOrgProfile(member, tenantIdentity);
|
||||
const isHighlighted = profile.isHighlighted === true;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full w-1 shrink-0"
|
||||
style={{ backgroundColor: headerFill }}
|
||||
/>
|
||||
<div className="min-w-0 truncate px-2">
|
||||
{getOrgChartUserDisplayName(member, {
|
||||
id: node.id,
|
||||
slug: node.companyCode ?? "",
|
||||
})}
|
||||
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>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center px-4 text-xs font-bold text-[#94a3b8]">
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UserSummary } from "../../lib/adminApi";
|
||||
import { getOrgChartUserDisplayName } from "./userDisplay";
|
||||
import {
|
||||
getOrgChartUserDisplayName,
|
||||
getUserOrgProfile,
|
||||
} from "./userDisplay";
|
||||
|
||||
function user(overrides: Partial<UserSummary>): UserSummary {
|
||||
return {
|
||||
@@ -16,15 +19,16 @@ function user(overrides: Partial<UserSummary>): UserSummary {
|
||||
}
|
||||
|
||||
describe("getOrgChartUserDisplayName", () => {
|
||||
it("renders name with grade and optional position", () => {
|
||||
it("renders name with grade and without job details", () => {
|
||||
expect(
|
||||
getOrgChartUserDisplayName(
|
||||
user({
|
||||
grade: "수석",
|
||||
position: "팀장",
|
||||
jobTitle: "구조",
|
||||
}),
|
||||
),
|
||||
).toBe("홍길동 수석(팀장)");
|
||||
).toBe("홍길동 수석");
|
||||
});
|
||||
|
||||
it("uses tenant appointment grade before the user grade", () => {
|
||||
@@ -44,6 +48,123 @@ describe("getOrgChartUserDisplayName", () => {
|
||||
}),
|
||||
{ id: "tenant-1", slug: "hanmac" },
|
||||
),
|
||||
).toBe("홍길동 수석(센터장)");
|
||||
).toBe("홍길동 수석");
|
||||
});
|
||||
|
||||
it("uses short grade aliases in the display name", () => {
|
||||
expect(
|
||||
getOrgChartUserDisplayName(
|
||||
user({
|
||||
grade: "책임연구원",
|
||||
jobTitle: "구조",
|
||||
}),
|
||||
),
|
||||
).toBe("홍길동 책임");
|
||||
});
|
||||
|
||||
it("does not add leader text to the display name", () => {
|
||||
expect(
|
||||
getOrgChartUserDisplayName(
|
||||
user({
|
||||
grade: "책임",
|
||||
metadata: {
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantSlug: "hanmac",
|
||||
isOwner: true,
|
||||
grade: "수석",
|
||||
position: "센터장",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ id: "tenant-1", slug: "hanmac" },
|
||||
),
|
||||
).toBe("홍길동 수석");
|
||||
});
|
||||
|
||||
it("does not leak an owner appointment flag into another tenant display", () => {
|
||||
expect(
|
||||
getOrgChartUserDisplayName(
|
||||
user({
|
||||
grade: "책임",
|
||||
position: "팀원",
|
||||
metadata: {
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantSlug: "hanmac",
|
||||
isOwner: true,
|
||||
grade: "수석",
|
||||
position: "센터장",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ id: "tenant-2", slug: "baron" },
|
||||
),
|
||||
).toBe("홍길동 책임");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserOrgProfile", () => {
|
||||
it("marks owner, manager, and admin flags as highlighted profiles", () => {
|
||||
expect(
|
||||
getUserOrgProfile(
|
||||
user({
|
||||
metadata: {
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantSlug: "owner",
|
||||
isOwner: true,
|
||||
},
|
||||
{
|
||||
tenantSlug: "manager",
|
||||
isManager: true,
|
||||
},
|
||||
{
|
||||
tenantSlug: "admin",
|
||||
isAdmin: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ id: "tenant-1", slug: "owner" },
|
||||
).isHighlighted,
|
||||
).toBe(true);
|
||||
expect(
|
||||
getUserOrgProfile(
|
||||
user({
|
||||
metadata: {
|
||||
additionalAppointments: [{ tenantSlug: "leader", isLeader: true }],
|
||||
},
|
||||
}),
|
||||
{ id: "tenant-2", slug: "leader" },
|
||||
).isHighlighted,
|
||||
).toBe(false);
|
||||
expect(
|
||||
getUserOrgProfile(
|
||||
user({
|
||||
metadata: {
|
||||
additionalAppointments: [
|
||||
{ tenantSlug: "manager", isManager: true },
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ id: "tenant-2", slug: "manager" },
|
||||
).isHighlighted,
|
||||
).toBe(true);
|
||||
expect(
|
||||
getUserOrgProfile(
|
||||
user({
|
||||
metadata: {
|
||||
additionalAppointments: [{ tenantSlug: "admin", isAdmin: true }],
|
||||
},
|
||||
}),
|
||||
{ id: "tenant-3", slug: "admin" },
|
||||
).isHighlighted,
|
||||
).toBe(true);
|
||||
expect(getUserOrgProfile(user({ grade: "책임" })).isHighlighted).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
|
||||
import { getOrgRankDisplayName } from "./rankPriority";
|
||||
|
||||
type UserAppointment = {
|
||||
tenantId?: string;
|
||||
tenantSlug?: string;
|
||||
isAdmin?: boolean;
|
||||
isManager?: boolean;
|
||||
isOwner?: boolean;
|
||||
grade?: string;
|
||||
jobTitle?: string;
|
||||
position?: string;
|
||||
@@ -26,6 +30,9 @@ function getUserAppointments(user: UserSummary): UserAppointment[] {
|
||||
.map((item) => ({
|
||||
tenantId: normalizeText(item.tenantId),
|
||||
tenantSlug: normalizeText(item.tenantSlug),
|
||||
isAdmin: item.isAdmin === true,
|
||||
isManager: item.isManager === true,
|
||||
isOwner: item.isOwner === true,
|
||||
grade: normalizeText(item.grade),
|
||||
jobTitle: normalizeText(item.jobTitle),
|
||||
position: normalizeText(item.position),
|
||||
@@ -47,6 +54,10 @@ export function getUserOrgProfile(user: UserSummary, tenant?: TenantIdentity) {
|
||||
|
||||
return {
|
||||
grade: appointment?.grade || normalizeText(user.grade),
|
||||
isHighlighted:
|
||||
appointment?.isAdmin === true ||
|
||||
appointment?.isManager === true ||
|
||||
appointment?.isOwner === true,
|
||||
jobTitle: appointment?.jobTitle || normalizeText(user.jobTitle),
|
||||
position: appointment?.position || normalizeText(user.position),
|
||||
};
|
||||
@@ -56,12 +67,11 @@ export function getOrgChartUserDisplayName(
|
||||
user: UserSummary,
|
||||
tenant?: TenantIdentity,
|
||||
) {
|
||||
const { grade, jobTitle, position } = getUserOrgProfile(user, tenant);
|
||||
const { grade } = getUserOrgProfile(user, tenant);
|
||||
const baseName = user.name.trim();
|
||||
const detail = position || jobTitle;
|
||||
const displayGrade = getOrgRankDisplayName(grade);
|
||||
|
||||
if (grade && detail) return `${baseName} ${grade}(${detail})`;
|
||||
if (grade) return `${baseName} ${grade}`;
|
||||
if (detail) return `${baseName}(${detail})`;
|
||||
return baseName;
|
||||
let displayName = baseName;
|
||||
if (displayGrade) displayName = `${baseName} ${displayGrade}`;
|
||||
return displayName;
|
||||
}
|
||||
|
||||
@@ -976,7 +976,12 @@ function selectionKey(selection: OrgPickerSelection) {
|
||||
}
|
||||
|
||||
function formatMember(member: OrgContextMember) {
|
||||
return [member.name, member.position, member.jobTitle]
|
||||
return [
|
||||
member.name,
|
||||
member.position,
|
||||
member.jobTitle,
|
||||
member.isLeader || member.isOwner ? "조직장" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ describe("org-context chart SDK", () => {
|
||||
expect(
|
||||
chartContainer.querySelectorAll("[data-baron-org-node]"),
|
||||
).toHaveLength(3);
|
||||
expect(chartContainer.textContent).toContain("Leader · 팀장 · 조직장");
|
||||
const platformCheckbox = pickerContainer.querySelector<HTMLInputElement>(
|
||||
'input[value="tenant:team-platform"]',
|
||||
);
|
||||
|
||||
@@ -252,7 +252,7 @@ test("org chart renders dense member nodes with calculated member columns", asyn
|
||||
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
|
||||
|
||||
const rootNode = page.locator('[data-testid="orgchart-node-root"]');
|
||||
await expect(rootNode).toHaveAttribute("width", /[4-9]\d{2,}/);
|
||||
await expect(rootNode).toHaveAttribute("width", /3\d{2}/);
|
||||
await expect(rootNode.locator('[data-member-columns="2"]')).toBeVisible();
|
||||
await expect(rootNode.getByText("Dense User 10")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -230,7 +230,7 @@ test("org chart balances large member groups with automatic member columns", asy
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("org chart displays user names with grade and optional position", async ({
|
||||
test("org chart displays user names with short grade aliases and no job details", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/v1/public/orgchart**", async (route) => {
|
||||
@@ -247,7 +247,7 @@ test("org chart displays user names with grade and optional position", async ({
|
||||
{
|
||||
...user("u-eng", "Engineering User", "engineering"),
|
||||
jobTitle: "Platform Engineer",
|
||||
grade: "책임",
|
||||
grade: "책임연구원",
|
||||
position: "팀장",
|
||||
},
|
||||
],
|
||||
@@ -258,7 +258,77 @@ test("org chart displays user names with grade and optional position", async ({
|
||||
await page.goto("/chart?token=display-name");
|
||||
|
||||
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
||||
await expect(svg.getByText("Engineering User 책임(팀장)")).toBeVisible();
|
||||
await expect(svg.getByText("Engineering User 책임")).toBeVisible();
|
||||
await expect(svg.getByText(/팀장|Platform Engineer/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("org chart only highlights flagged member cards", async ({ page }) => {
|
||||
await page.route("**/api/v1/public/orgchart**", async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
sharedWith: "Playwright",
|
||||
tenants: [
|
||||
tenant("group", "HMAC Group", "hmac"),
|
||||
tenant("engineering", "Engineering", "engineering", "group"),
|
||||
],
|
||||
users: [
|
||||
user("u-normal", "Normal User", "engineering"),
|
||||
{
|
||||
...user("u-owner", "Owner User", "engineering"),
|
||||
metadata: {
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantSlug: "engineering",
|
||||
isOwner: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
...user("u-admin", "Admin User", "engineering"),
|
||||
metadata: {
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantSlug: "engineering",
|
||||
isAdmin: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
...user("u-manager", "Manager User", "engineering"),
|
||||
metadata: {
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantSlug: "engineering",
|
||||
isManager: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/chart?token=highlighted-members");
|
||||
|
||||
const engineeringNode = page.locator(
|
||||
'[data-testid="orgchart-node-engineering"]',
|
||||
);
|
||||
await expect(
|
||||
engineeringNode.locator('[data-testid="orgchart-member-u-normal"]'),
|
||||
).toHaveAttribute("data-highlighted", "false");
|
||||
await expect(
|
||||
engineeringNode.locator('[data-testid="orgchart-member-u-owner"]'),
|
||||
).toHaveAttribute("data-highlighted", "true");
|
||||
await expect(
|
||||
engineeringNode.locator('[data-testid="orgchart-member-u-admin"]'),
|
||||
).toHaveAttribute("data-highlighted", "true");
|
||||
await expect(
|
||||
engineeringNode.locator('[data-testid="orgchart-member-u-manager"]'),
|
||||
).toHaveAttribute("data-highlighted", "true");
|
||||
});
|
||||
|
||||
test("org chart places multi-tenant users only on leaf memberships without duplicate rendering", async ({
|
||||
@@ -406,6 +476,14 @@ test("org chart places GPDTDC representative users on visible leaf appointments"
|
||||
"ORGANIZATION",
|
||||
{ visibility: "internal" },
|
||||
),
|
||||
tenant(
|
||||
"internal-leaf",
|
||||
"내부 구성 하위 조직",
|
||||
"internal-leaf",
|
||||
"gpdtdc",
|
||||
"USER_GROUP",
|
||||
{ visibility: "internal" },
|
||||
),
|
||||
],
|
||||
users: [
|
||||
{
|
||||
@@ -427,6 +505,19 @@ test("org chart places GPDTDC representative users on visible leaf appointments"
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
...user("u-hidden-only", "Hidden Only User", "gpdtdc"),
|
||||
tenantSlug: "gpdtdc",
|
||||
companyCode: undefined,
|
||||
metadata: {
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantSlug: "internal-leaf",
|
||||
isPrimary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
@@ -438,6 +529,8 @@ test("org chart places GPDTDC representative users on visible leaf appointments"
|
||||
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
||||
await expect(svg).toBeVisible();
|
||||
await expect(svg.getByText("내부 구성 조직")).toHaveCount(0);
|
||||
await expect(svg.getByText("내부 구성 하위 조직")).toHaveCount(0);
|
||||
await expect(svg.getByText(/Hidden Only User/)).toHaveCount(0);
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="orgchart-node-gpdtdc"]')
|
||||
@@ -446,7 +539,7 @@ test("org chart places GPDTDC representative users on visible leaf appointments"
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="orgchart-node-tdc-leaf"]')
|
||||
.getByText("GPDTDC Leaf User 책임(팀장)"),
|
||||
.getByText("GPDTDC Leaf User 책임"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user