diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 77494dc9..2a44b16a 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -214,7 +214,7 @@ function AppLayout() { }); const orgfrontUrl = buildAuthenticatedOrgChartUrl( import.meta.env.ORGFRONT_URL || "http://localhost:5175", - { includeInternal: true }, + { includeInternal: false }, ); // Splice optional menus in a standard order diff --git a/adminfront/src/features/users/orgChartPicker.test.ts b/adminfront/src/features/users/orgChartPicker.test.ts index b0a696ae..67462986 100644 --- a/adminfront/src/features/users/orgChartPicker.test.ts +++ b/adminfront/src/features/users/orgChartPicker.test.ts @@ -71,18 +71,20 @@ describe("orgChartPicker", () => { ); }); - it("builds the admin chart navigation URL with internal visibility enabled", () => { + it("builds the admin chart navigation URL without internal visibility by default", () => { expect(buildAuthenticatedOrgChartUrl("https://orgchart.example.com/")).toBe( - "https://orgchart.example.com/login?auto=1&returnTo=%2Fchart%3FincludeInternal%3Dtrue", + "https://orgchart.example.com/login?auto=1&returnTo=%2Fchart", ); }); - it("can build chart navigation URL without internal visibility", () => { + it("can build chart navigation URL with internal visibility when explicitly requested", () => { expect( buildAuthenticatedOrgChartUrl("https://orgchart.example.com/", { - includeInternal: false, + includeInternal: true, }), - ).toBe("https://orgchart.example.com/login?auto=1&returnTo=%2Fchart"); + ).toBe( + "https://orgchart.example.com/login?auto=1&returnTo=%2Fchart%3FincludeInternal%3Dtrue", + ); }); it("parses the first tenant id and name from orgfront confirm messages", () => { diff --git a/adminfront/src/features/users/orgChartPicker.ts b/adminfront/src/features/users/orgChartPicker.ts index ffa9ac30..d92e3482 100644 --- a/adminfront/src/features/users/orgChartPicker.ts +++ b/adminfront/src/features/users/orgChartPicker.ts @@ -348,7 +348,7 @@ export function buildAuthenticatedOrgChartUserMultiPickerUrl(baseUrl?: string) { export function buildAuthenticatedOrgChartUrl( baseUrl?: string, - options: OrgChartLoginOptions = { includeInternal: true }, + options: OrgChartLoginOptions = { includeInternal: false }, ) { const normalizedBase = baseUrl?.trim().replace(/\/+$/, "") || DEFAULT_ORGFRONT_BASE_URL; diff --git a/adminfront/tests/auth.spec.ts b/adminfront/tests/auth.spec.ts index 8bb37766..3ea4cca4 100644 --- a/adminfront/tests/auth.spec.ts +++ b/adminfront/tests/auth.spec.ts @@ -146,7 +146,7 @@ test.describe("Authentication", () => { await page.goto("/"); await expect(page.getByRole("link", { name: "조직도" })).toHaveAttribute( "href", - /\/login\?auto=1&returnTo=%2Fchart%3FincludeInternal%3Dtrue$/, + /\/login\?auto=1&returnTo=%2Fchart$/, ); }); diff --git a/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx b/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx index 58fe2886..41d0128f 100644 --- a/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx +++ b/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx @@ -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), diff --git a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx index 364b6a48..a332f07d 100644 --- a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx +++ b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx @@ -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 ( ) : null} -
+
{node.name}
diff --git a/orgfront/src/features/orgchart/userDisplay.ts b/orgfront/src/features/orgchart/userDisplay.ts index c7e6d085..25018542 100644 --- a/orgfront/src/features/orgchart/userDisplay.ts +++ b/orgfront/src/features/orgchart/userDisplay.ts @@ -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), }; diff --git a/orgfront/tests/orgchart-vector-render.spec.ts b/orgfront/tests/orgchart-vector-render.spec.ts index 2bbc81bd..735c69b2 100644 --- a/orgfront/tests/orgchart-vector-render.spec.ts +++ b/orgfront/tests/orgchart-vector-render.spec.ts @@ -269,7 +269,7 @@ test("org chart displays user names with short grade aliases and no job details" await expect(svg.getByText(/팀장|Platform Engineer/)).toHaveCount(0); }); -test("org chart orders top executive members by rank priority", async ({ +test("org chart orders managers before top executive members by rank priority", async ({ page, }) => { const executiveUser = (id: string, name: string, grade: string) => ({ @@ -293,6 +293,17 @@ test("org chart orders top executive members by rank priority", async ({ executiveUser("u-president", "President", "사장"), executiveUser("u-chair", "Chair", "회장"), executiveUser("u-director", "Director", "전무"), + { + ...executiveUser("u-manager", "Team Manager", "사원"), + metadata: { + additionalAppointments: [ + { + tenantSlug: "engineering", + isManager: true, + }, + ], + }, + }, ], }), }); @@ -312,6 +323,7 @@ test("org chart orders top executive members by rank priority", async ({ ); expect(orderedMemberIds).toEqual([ + "orgchart-member-u-manager", "orgchart-member-u-chair", "orgchart-member-u-president", "orgchart-member-u-vice-chair", @@ -321,6 +333,42 @@ test("org chart orders top executive members by rank priority", async ({ ]); }); +test("org chart expands organization node width so long names are not clipped", async ({ + page, +}) => { + const longName = + "초장문 조직 명칭 표시 검증을 위한 구조물 디지털 전환 통합 운영 센터"; + + 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("long-name", longName, "long-name", "group"), + ], + users: [user("u-long", "Long Name User", "long-name")], + }), + }); + }); + + await page.goto("/chart?token=long-org-name"); + + const longNode = page.locator('[data-testid="orgchart-node-long-name"]'); + await expect(longNode).toBeVisible(); + const title = longNode.getByText(longName, { exact: true }); + await expect(title).toBeVisible(); + + const titleMetrics = await title.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })); + expect(titleMetrics.scrollWidth).toBeLessThanOrEqual( + titleMetrics.clientWidth + 1, + ); +}); + test("org chart only highlights flagged member cards", async ({ page }) => { await page.route("**/api/v1/public/orgchart**", async (route) => { await route.fulfill({