From 731ae9251e237f55f71c1636c06681c87a7fcd7b Mon Sep 17 00:00:00 2001 From: Lectom Date: Fri, 29 May 2026 08:38:05 +0900 Subject: [PATCH] fix(orgfront): place GPDTDC users on leaf appointments --- .../orgchart/routes/OrgChartPage.test.tsx | 49 ++++++++++++++ .../features/orgchart/routes/OrgChartPage.tsx | 60 +++++++++++++++-- orgfront/tests/orgchart-vector-render.spec.ts | 65 +++++++++++++++++++ 3 files changed, 168 insertions(+), 6 deletions(-) diff --git a/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx b/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx index 0b0d7ea5..e25c9811 100644 --- a/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx +++ b/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx @@ -448,4 +448,53 @@ describe("org chart layout", () => { "engineering-user", ]); }); + + it("maps GPDTDC representative users to their visible leaf appointment", () => { + const gpdtdc = tenantNode("gpdtdc", "COMPANY", "GPDTDC", "gpdtdc"); + const tdc = { + ...tenantNode("tdc", "ORGANIZATION", "기술개발센터", "tdc"), + parentId: "gpdtdc", + }; + const leaf = { + ...tenantNode("tdc-leaf", "USER_GROUP", "기술개발센터 1팀", "tdc-leaf"), + parentId: "tdc", + }; + const rootNode = { + ...gpdtdc, + children: [{ ...tdc, children: [leaf] }], + }; + + const usersMap = buildUsersMap( + [ + { + ...member("gpdtdc-user"), + companyCode: undefined, + tenantSlug: "gpdtdc", + metadata: { + additionalAppointments: [ + { + tenantSlug: "internal-planning", + isPrimary: true, + }, + { + tenantSlug: "tdc-leaf", + isPrimary: false, + grade: "책임", + position: "팀장", + }, + ], + }, + joinedTenants: undefined, + }, + ], + [rootNode], + { activeOnly: true }, + ); + + expect(usersMap.get("gpdtdc")).toBeUndefined(); + expect(usersMap.get("internal-planning")).toBeUndefined(); + expect(usersMap.get("tdc-leaf")?.map((user) => user.id)).toEqual([ + "gpdtdc-user", + ]); + }); }); diff --git a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx index 172b0eda..4ffa97c5 100644 --- a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx +++ b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx @@ -1121,6 +1121,11 @@ type TenantIndexes = { bySlug: Map; }; +type UserOrgAppointmentRef = { + tenantId?: string; + tenantSlug?: string; +}; + function buildTenantIndexes(nodes: TenantNode[]): TenantIndexes { const byId = new Map(); const bySlug = new Map(); @@ -1134,6 +1139,36 @@ function buildTenantIndexes(nodes: TenantNode[]): TenantIndexes { return { byId, bySlug }; } +function normalizeOrgSlug(value: unknown) { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function getUserOrgAppointmentRefs(user: UserSummary): UserOrgAppointmentRef[] { + const rawAppointments = user.metadata?.additionalAppointments; + if (!Array.isArray(rawAppointments)) return []; + + return rawAppointments + .filter( + (item): item is Record => + typeof item === "object" && item !== null, + ) + .map((item) => ({ + tenantId: typeof item.tenantId === "string" ? item.tenantId.trim() : "", + tenantSlug: normalizeOrgSlug(item.tenantSlug), + })); +} + +function addTenantSlugCandidate( + slugs: Set, + tenantIndexes: TenantIndexes, + slug: string, +) { + const normalizedSlug = normalizeOrgSlug(slug); + if (!normalizedSlug) return; + if (!tenantIndexes.bySlug.has(normalizedSlug)) return; + slugs.add(normalizedSlug); +} + function isDescendantTenant( candidate: TenantNode, ancestor: TenantNode, @@ -1184,8 +1219,8 @@ export function buildUsersMap( if (!isVisibleOrgChartUser(user)) continue; const slugs = new Set(); - const primarySlug = user.tenantSlug?.toLowerCase() || ""; - const legacyCompanySlug = user.companyCode?.toLowerCase() || ""; + const primarySlug = normalizeOrgSlug(user.tenantSlug); + const legacyCompanySlug = normalizeOrgSlug(user.companyCode); if ( primarySlug && !isSystemGlobalTenant({ @@ -1195,7 +1230,7 @@ export function buildUsersMap( name: primarySlug, }) ) { - slugs.add(primarySlug); + addTenantSlugCandidate(slugs, tenantIndexes, primarySlug); } if ( legacyCompanySlug && @@ -1206,14 +1241,27 @@ export function buildUsersMap( name: legacyCompanySlug, }) ) { - slugs.add(legacyCompanySlug); + addTenantSlugCandidate(slugs, tenantIndexes, legacyCompanySlug); } if (user.tenant?.slug && !isSystemGlobalTenant(user.tenant)) { - slugs.add(user.tenant.slug.toLowerCase()); + addTenantSlugCandidate(slugs, tenantIndexes, user.tenant.slug); } for (const joinedTenant of user.joinedTenants || []) { if (joinedTenant.slug && !isSystemGlobalTenant(joinedTenant)) { - slugs.add(joinedTenant.slug.toLowerCase()); + addTenantSlugCandidate(slugs, tenantIndexes, joinedTenant.slug); + } + } + for (const appointment of getUserOrgAppointmentRefs(user)) { + if (appointment.tenantSlug) { + addTenantSlugCandidate(slugs, tenantIndexes, appointment.tenantSlug); + continue; + } + + const tenantById = appointment.tenantId + ? tenantIndexes.byId.get(appointment.tenantId) + : undefined; + if (tenantById) { + addTenantSlugCandidate(slugs, tenantIndexes, tenantById.slug); } } diff --git a/orgfront/tests/orgchart-vector-render.spec.ts b/orgfront/tests/orgchart-vector-render.spec.ts index 08be240c..f4661f7a 100644 --- a/orgfront/tests/orgchart-vector-render.spec.ts +++ b/orgfront/tests/orgchart-vector-render.spec.ts @@ -385,6 +385,71 @@ test("org chart places multi-tenant users only on leaf memberships without dupli await expect(svg.getByText(/^1$/)).toHaveCount(4); }); +test("org chart places GPDTDC representative users on visible leaf appointments", 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("gpdtdc", "GPDTDC", "gpdtdc", "group", "COMPANY"), + tenant("tdc", "기술개발센터", "tdc", "gpdtdc", "ORGANIZATION"), + tenant("tdc-leaf", "기술개발센터 1팀", "tdc-leaf", "tdc"), + tenant( + "internal-planning", + "내부 구성 조직", + "internal-planning", + "group", + "ORGANIZATION", + { visibility: "internal" }, + ), + ], + users: [ + { + ...user("u-gpdtdc-leaf", "GPDTDC Leaf User", "gpdtdc"), + tenantSlug: "gpdtdc", + companyCode: undefined, + metadata: { + additionalAppointments: [ + { + tenantSlug: "internal-planning", + isPrimary: true, + }, + { + tenantSlug: "tdc-leaf", + isPrimary: false, + grade: "책임", + position: "팀장", + }, + ], + }, + }, + ], + }), + }); + }); + + await page.goto("/chart?token=gpdtdc-leaf"); + + await expect(page.getByText("총 1명")).toBeVisible(); + const svg = page.locator('[data-testid="orgchart-vector-svg"]'); + await expect(svg).toBeVisible(); + await expect(svg.getByText("내부 구성 조직")).toHaveCount(0); + await expect( + page + .locator('[data-testid="orgchart-node-gpdtdc"]') + .getByText(/GPDTDC Leaf User/), + ).toHaveCount(0); + await expect( + page + .locator('[data-testid="orgchart-node-tdc-leaf"]') + .getByText("GPDTDC Leaf User 책임(팀장)"), + ).toBeVisible(); +}); + test("org chart counts multi-leaf tenant users once in ancestor totals", async ({ page, }) => {