forked from baron/baron-sso
조직현황 구조변경. 총괄센터삼안 실 조직 삽입확인
This commit is contained in:
107
orgfront/src/features/orgchart/pickerTree.test.ts
Normal file
107
orgfront/src/features/orgchart/pickerTree.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
|
||||
import { buildOrgPickerTree } from "./pickerTree";
|
||||
|
||||
function tenant(
|
||||
id: string,
|
||||
type: string,
|
||||
name: string,
|
||||
slug: string,
|
||||
parentId?: string,
|
||||
): TenantSummary {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
slug,
|
||||
description: "",
|
||||
status: "active",
|
||||
parentId,
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-11T00:00:00.000Z",
|
||||
updatedAt: "2026-05-11T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildOrgPickerTree", () => {
|
||||
it("uses the hanmac-family company-group as the default picker root", () => {
|
||||
const tenants = [
|
||||
tenant("wrong-group", "COMPANY_GROUP", "Wrong Group", "wrong-group"),
|
||||
tenant(
|
||||
"wrong-company",
|
||||
"COMPANY",
|
||||
"Wrong Company",
|
||||
"wrong-company",
|
||||
"wrong-group",
|
||||
),
|
||||
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
|
||||
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
|
||||
];
|
||||
|
||||
const tree = buildOrgPickerTree({
|
||||
tenants,
|
||||
users: [] satisfies UserSummary[],
|
||||
});
|
||||
|
||||
expect(tree.companyGroupId).toBe("hanmac-family-id");
|
||||
expect(tree.roots).toHaveLength(1);
|
||||
expect(tree.roots[0]?.id).toBe("hanmac-family-id");
|
||||
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
|
||||
"saman-id",
|
||||
]);
|
||||
});
|
||||
|
||||
it("scopes descendant filtering by tenant slug", () => {
|
||||
const tenants = [
|
||||
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
|
||||
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
|
||||
tenant("planning-id", "ORGANIZATION", "기획팀", "planning", "saman-id"),
|
||||
tenant("hanmac-id", "COMPANY", "한맥기술", "hanmac", "hanmac-family-id"),
|
||||
];
|
||||
|
||||
const tree = buildOrgPickerTree({
|
||||
tenants,
|
||||
users: [] satisfies UserSummary[],
|
||||
tenantId: "saman",
|
||||
});
|
||||
|
||||
expect(tree.roots).toHaveLength(1);
|
||||
expect(tree.roots[0]?.id).toBe("saman-id");
|
||||
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
|
||||
"planning-id",
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes private tenants and their descendants from picker choices", () => {
|
||||
const tenants = [
|
||||
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
|
||||
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
|
||||
{
|
||||
...tenant(
|
||||
"secret-id",
|
||||
"ORGANIZATION",
|
||||
"비공개 조직",
|
||||
"secret",
|
||||
"saman-id",
|
||||
),
|
||||
config: { visibility: "private" },
|
||||
},
|
||||
tenant(
|
||||
"secret-child-id",
|
||||
"USER_GROUP",
|
||||
"비공개 하위",
|
||||
"secret-child",
|
||||
"secret-id",
|
||||
),
|
||||
tenant("open-id", "ORGANIZATION", "공개 조직", "open", "saman-id"),
|
||||
];
|
||||
|
||||
const tree = buildOrgPickerTree({
|
||||
tenants,
|
||||
users: [] satisfies UserSummary[],
|
||||
tenantId: "saman",
|
||||
});
|
||||
|
||||
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual(["open-id"]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
|
||||
import { type TenantNode, buildTenantFullTree } from "../../lib/tenantTree";
|
||||
import type { OrgPickerTreeNode } from "./pickerTypes";
|
||||
import { filterTenantsByVisibility } from "./tenantVisibility";
|
||||
import { getOrgChartUserDisplayName } from "./userDisplay";
|
||||
|
||||
function getUserTenantSlug(user: UserSummary) {
|
||||
@@ -28,6 +29,23 @@ function getCompanyGroupId(node: TenantNode, allTenants: TenantSummary[]) {
|
||||
return cursor?.type === "COMPANY_GROUP" ? cursor.id : node.id;
|
||||
}
|
||||
|
||||
function isHanmacFamilyCompanyGroup(tenant: TenantSummary) {
|
||||
return (
|
||||
tenant.type.toUpperCase() === "COMPANY_GROUP" &&
|
||||
tenant.slug.toLowerCase() === "hanmac-family"
|
||||
);
|
||||
}
|
||||
|
||||
function findTenantByRef(tenants: TenantSummary[], ref?: string) {
|
||||
const normalizedRef = ref?.trim().toLowerCase();
|
||||
if (!normalizedRef) return undefined;
|
||||
|
||||
return (
|
||||
tenants.find((tenant) => tenant.slug.toLowerCase() === normalizedRef) ??
|
||||
tenants.find((tenant) => tenant.id === ref)
|
||||
);
|
||||
}
|
||||
|
||||
function tenantToPickerNode(
|
||||
tenant: TenantNode,
|
||||
usersBySlug: Map<string, UserSummary[]>,
|
||||
@@ -58,12 +76,34 @@ function tenantToPickerNode(
|
||||
|
||||
function findTenantNode(
|
||||
roots: TenantNode[],
|
||||
tenantId: string,
|
||||
tenantRef: string,
|
||||
): TenantNode | undefined {
|
||||
const findBySlug = (node: TenantNode): TenantNode | undefined => {
|
||||
if (node.slug.toLowerCase() === tenantRef.trim().toLowerCase()) {
|
||||
return node;
|
||||
}
|
||||
for (const child of node.children) {
|
||||
const match = findBySlug(child);
|
||||
if (match) return match;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const findById = (node: TenantNode): TenantNode | undefined => {
|
||||
if (node.id === tenantRef) return node;
|
||||
for (const child of node.children) {
|
||||
const match = findById(child);
|
||||
if (match) return match;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
for (const root of roots) {
|
||||
if (root.id === tenantId) return root;
|
||||
const child = findTenantNode(root.children, tenantId);
|
||||
if (child) return child;
|
||||
const slugMatch = findBySlug(root);
|
||||
if (slugMatch) return slugMatch;
|
||||
}
|
||||
for (const root of roots) {
|
||||
const idMatch = findById(root);
|
||||
if (idMatch) return idMatch;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -79,7 +119,10 @@ export function buildOrgPickerTree({
|
||||
rootTenantId?: string;
|
||||
tenantId?: string;
|
||||
}) {
|
||||
const visibleTenants = tenants.filter(isOrgFrontTenantType);
|
||||
const visibleTenants = filterTenantsByVisibility(
|
||||
tenants.filter(isOrgFrontTenantType),
|
||||
"internal",
|
||||
);
|
||||
const usersBySlug = new Map<string, UserSummary[]>();
|
||||
for (const user of users) {
|
||||
if (user.status !== "active") continue;
|
||||
@@ -91,7 +134,8 @@ export function buildOrgPickerTree({
|
||||
}
|
||||
|
||||
const companyGroup =
|
||||
visibleTenants.find((tenant) => tenant.id === rootTenantId) ??
|
||||
findTenantByRef(visibleTenants, rootTenantId) ??
|
||||
visibleTenants.find(isHanmacFamilyCompanyGroup) ??
|
||||
visibleTenants.find((tenant) => tenant.type === "COMPANY_GROUP") ??
|
||||
visibleTenants.find((tenant) => !tenant.parentId);
|
||||
|
||||
|
||||
34
orgfront/src/features/orgchart/pickerTypes.test.ts
Normal file
34
orgfront/src/features/orgchart/pickerTypes.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildOrgPickerEmbedSrc,
|
||||
parseOrgPickerEmbedOptions,
|
||||
} from "./pickerTypes";
|
||||
|
||||
describe("org picker embed options", () => {
|
||||
it("builds slug-based tenant scope urls", () => {
|
||||
expect(
|
||||
buildOrgPickerEmbedSrc({
|
||||
mode: "single",
|
||||
select: "tenant",
|
||||
includeDescendants: true,
|
||||
showDescendantToggle: true,
|
||||
tenantId: "saman",
|
||||
width: 400,
|
||||
height: 600,
|
||||
}),
|
||||
).toBe(
|
||||
"/embed/picker?mode=single&select=tenant&width=400&height=600&tenantSlug=saman",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses tenantSlug first and keeps legacy tenantId compatibility", () => {
|
||||
expect(
|
||||
parseOrgPickerEmbedOptions(
|
||||
"?tenantId=legacy-id&tenantSlug=saman&companyTenantId=legacy-company",
|
||||
).tenantId,
|
||||
).toBe("saman");
|
||||
expect(parseOrgPickerEmbedOptions("?tenantId=legacy-id").tenantId).toBe(
|
||||
"legacy-id",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -70,7 +70,11 @@ export function parseOrgPickerEmbedOptions(search: string) {
|
||||
select: parseOrgPickerSelectableType(params.get("select")),
|
||||
includeDescendants: params.get("includeDescendants") !== "false",
|
||||
showDescendantToggle: params.get("showDescendantToggle") !== "false",
|
||||
tenantId: params.get("tenantId") ?? params.get("companyTenantId") ?? "",
|
||||
tenantId:
|
||||
params.get("tenantSlug") ??
|
||||
params.get("tenantId") ??
|
||||
params.get("companyTenantId") ??
|
||||
"",
|
||||
width: parseEmbedDimension(params.get("width"), 400),
|
||||
height: parseEmbedDimension(params.get("height"), 600),
|
||||
};
|
||||
@@ -84,9 +88,9 @@ export function buildOrgPickerEmbedSrc(options: OrgPickerEmbedOptions) {
|
||||
height: String(options.height),
|
||||
});
|
||||
|
||||
const tenantId = options.tenantId.trim();
|
||||
if (tenantId) {
|
||||
params.set("tenantId", tenantId);
|
||||
const tenantSlug = options.tenantId.trim();
|
||||
if (tenantSlug) {
|
||||
params.set("tenantSlug", tenantSlug);
|
||||
}
|
||||
|
||||
if (options.mode === "multiple") {
|
||||
|
||||
388
orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx
Normal file
388
orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type OrgNode,
|
||||
buildOrgSelectionOptions,
|
||||
clampScale,
|
||||
getOrgNodeHeaderFill,
|
||||
getSemanticZoomMode,
|
||||
layoutForest,
|
||||
} from "./OrgChartPage";
|
||||
|
||||
function orgNode(id: string, children: OrgNode[] = [], level = 0): OrgNode {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
level,
|
||||
members: [],
|
||||
children,
|
||||
totalCount: 0,
|
||||
totalMemberIds: new Set<string>(),
|
||||
companyCode: id,
|
||||
type: level === 0 ? "COMPANY" : "USER_GROUP",
|
||||
};
|
||||
}
|
||||
|
||||
function member(id: string) {
|
||||
return {
|
||||
id,
|
||||
email: `${id}@example.com`,
|
||||
name: id,
|
||||
role: "user",
|
||||
status: "active",
|
||||
companyCode: "root",
|
||||
grade: "사원",
|
||||
createdAt: "2026-05-11T00:00:00.000Z",
|
||||
updatedAt: "2026-05-11T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function tenantNode(
|
||||
id: string,
|
||||
type: string,
|
||||
name: string,
|
||||
slug: string,
|
||||
children = [],
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
slug,
|
||||
children,
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
recursiveMemberCount: 0,
|
||||
createdAt: "2026-05-11T00:00:00.000Z",
|
||||
updatedAt: "2026-05-11T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function getNodeBoundsAspectRatio(
|
||||
nodes: ReturnType<typeof layoutForest>["nodes"],
|
||||
) {
|
||||
const minX = Math.min(...nodes.map((node) => node.x));
|
||||
const maxX = Math.max(...nodes.map((node) => node.x + node.width));
|
||||
const minY = Math.min(...nodes.map((node) => node.y));
|
||||
const maxY = Math.max(...nodes.map((node) => node.y + node.height));
|
||||
|
||||
return (maxX - minX) / (maxY - minY);
|
||||
}
|
||||
|
||||
describe("org chart layout", () => {
|
||||
it("keeps small sibling groups horizontal in automatic mode", () => {
|
||||
const children = Array.from({ length: 4 }, (_, index) =>
|
||||
orgNode(`child-${index + 1}`, [], 1),
|
||||
);
|
||||
const layout = layoutForest([orgNode("root", children)], new Set());
|
||||
const childNodes = layout.nodes.filter((node) =>
|
||||
node.node.id.startsWith("child-"),
|
||||
);
|
||||
|
||||
expect(new Set(childNodes.map((node) => node.y)).size).toBe(1);
|
||||
});
|
||||
|
||||
it("uses member columns in node bounds when member count exceeds five", () => {
|
||||
const compactMembers = Array.from({ length: 6 }, (_, index) =>
|
||||
member(`member-${index + 1}`),
|
||||
);
|
||||
const node = {
|
||||
...orgNode("root"),
|
||||
members: compactMembers,
|
||||
totalCount: compactMembers.length,
|
||||
totalMemberIds: new Set(compactMembers.map((item) => item.id)),
|
||||
};
|
||||
const layout = layoutForest([node], new Set());
|
||||
const rootNode = layout.nodes.find((item) => item.node.id === "root");
|
||||
|
||||
expect(rootNode).toBeDefined();
|
||||
expect(rootNode?.width).toBeGreaterThan(340);
|
||||
expect(rootNode?.height).toBeLessThan(42 + 24 + 6 * 24);
|
||||
expect(layout.width).toBeGreaterThan((rootNode?.width ?? 0) + 72 * 2 - 1);
|
||||
});
|
||||
|
||||
it("adds one member column per five-member quotient", () => {
|
||||
const tenMembers = Array.from({ length: 10 }, (_, index) =>
|
||||
member(`member-${index + 1}`),
|
||||
);
|
||||
const sixMembers = tenMembers.slice(0, 6);
|
||||
const sixLayout = layoutForest(
|
||||
[
|
||||
{
|
||||
...orgNode("six"),
|
||||
members: sixMembers,
|
||||
totalCount: sixMembers.length,
|
||||
totalMemberIds: new Set(sixMembers.map((item) => item.id)),
|
||||
},
|
||||
],
|
||||
new Set(),
|
||||
);
|
||||
const tenLayout = layoutForest(
|
||||
[
|
||||
{
|
||||
...orgNode("ten"),
|
||||
members: tenMembers,
|
||||
totalCount: tenMembers.length,
|
||||
totalMemberIds: new Set(tenMembers.map((item) => item.id)),
|
||||
},
|
||||
],
|
||||
new Set(),
|
||||
);
|
||||
const sixNode = sixLayout.nodes.find((item) => item.node.id === "six");
|
||||
const tenNode = tenLayout.nodes.find((item) => item.node.id === "ten");
|
||||
|
||||
expect(sixNode?.width).toBeGreaterThan(340);
|
||||
expect(tenNode?.width).toBeGreaterThan(sixNode?.width ?? 0);
|
||||
expect(tenNode?.height).toBeLessThan(42 + 24 + 10 * 24);
|
||||
expect(tenLayout.width).toBeGreaterThan(sixLayout.width);
|
||||
});
|
||||
|
||||
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),
|
||||
);
|
||||
const layout = layoutForest([orgNode("root", children)], new Set());
|
||||
const childNodes = layout.nodes.filter((node) =>
|
||||
node.node.id.startsWith("child-"),
|
||||
);
|
||||
const uniqueChildRows = new Set(childNodes.map((node) => node.y));
|
||||
const childSpan =
|
||||
Math.max(...childNodes.map((node) => node.x + node.width)) -
|
||||
Math.min(...childNodes.map((node) => node.x));
|
||||
const aspectRatio = getNodeBoundsAspectRatio(layout.nodes);
|
||||
|
||||
expect(childNodes).toHaveLength(13);
|
||||
expect(uniqueChildRows.size).toBeGreaterThan(1);
|
||||
expect(aspectRatio).toBeGreaterThanOrEqual(1.41);
|
||||
expect(aspectRatio).toBeLessThanOrEqual(1.61);
|
||||
expect(childSpan).toBeLessThan(13 * 340 + 12 * 80);
|
||||
expect(
|
||||
layout.edges.filter((edge) => edge.key.startsWith("root->")),
|
||||
).toHaveLength(13);
|
||||
expect(
|
||||
layout.edges.filter(
|
||||
(edge) => edge.key.startsWith("root->") && edge.visibleByDefault,
|
||||
),
|
||||
).toHaveLength(new Set(childNodes.map((node) => node.x)).size);
|
||||
});
|
||||
|
||||
it("tunes column and row gaps after column selection to keep auto layout near the target aspect ratio", () => {
|
||||
const children = Array.from({ length: 5 }, (_, index) =>
|
||||
orgNode(`child-${index + 1}`, [], 1),
|
||||
);
|
||||
const layout = layoutForest([orgNode("root", children)], new Set());
|
||||
const childNodes = layout.nodes.filter((node) =>
|
||||
node.node.id.startsWith("child-"),
|
||||
);
|
||||
const aspectRatio = getNodeBoundsAspectRatio(layout.nodes);
|
||||
|
||||
expect(new Set(childNodes.map((node) => node.x)).size).toBe(2);
|
||||
expect(aspectRatio).toBeGreaterThanOrEqual(1.41);
|
||||
expect(aspectRatio).toBeLessThanOrEqual(1.61);
|
||||
});
|
||||
|
||||
it("keeps direct siblings on one level in top-down mode", () => {
|
||||
const children = Array.from({ length: 13 }, (_, index) =>
|
||||
orgNode(`child-${index + 1}`, [], 1),
|
||||
);
|
||||
const layout = layoutForest([orgNode("root", children)], new Set(), {
|
||||
childLayoutMode: "topDown",
|
||||
});
|
||||
const childNodes = layout.nodes.filter((node) =>
|
||||
node.node.id.startsWith("child-"),
|
||||
);
|
||||
const uniqueChildRows = new Set(childNodes.map((node) => node.y));
|
||||
|
||||
expect(childNodes).toHaveLength(13);
|
||||
expect(uniqueChildRows.size).toBe(1);
|
||||
});
|
||||
|
||||
it("places children in three fixed columns with centered parent edges", () => {
|
||||
const children = Array.from({ length: 10 }, (_, index) =>
|
||||
orgNode(`child-${index + 1}`, [], 1),
|
||||
);
|
||||
const layout = layoutForest([orgNode("root", children)], new Set(), {
|
||||
childLayoutMode: "threeColumn",
|
||||
});
|
||||
const childNodes = layout.nodes.filter((node) =>
|
||||
node.node.id.startsWith("child-"),
|
||||
);
|
||||
const uniqueChildColumns = new Set(childNodes.map((node) => node.x));
|
||||
const uniqueChildRows = new Set(childNodes.map((node) => node.y));
|
||||
const rootEdges = layout.edges.filter((edge) =>
|
||||
edge.key.startsWith("root->"),
|
||||
);
|
||||
|
||||
expect(uniqueChildColumns.size).toBe(3);
|
||||
expect(uniqueChildRows.size).toBe(4);
|
||||
expect(rootEdges).toHaveLength(10);
|
||||
expect(rootEdges.filter((edge) => edge.visibleByDefault)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("places the deepest child subtree in the first multi-column section", () => {
|
||||
const children = [
|
||||
orgNode("shallow-1", [], 1),
|
||||
orgNode("shallow-2", [], 1),
|
||||
orgNode("shallow-3", [], 1),
|
||||
orgNode(
|
||||
"deep",
|
||||
[
|
||||
orgNode(
|
||||
"deep-branch",
|
||||
[orgNode("deep-leaf", [orgNode("deep-tail", [], 4)], 3)],
|
||||
2,
|
||||
),
|
||||
],
|
||||
1,
|
||||
),
|
||||
orgNode("shallow-4", [], 1),
|
||||
orgNode("shallow-5", [], 1),
|
||||
];
|
||||
const layout = layoutForest([orgNode("root", children)], new Set(), {
|
||||
childLayoutMode: "threeColumn",
|
||||
});
|
||||
const rootEdges = layout.edges.filter((edge) =>
|
||||
edge.key.startsWith("root->"),
|
||||
);
|
||||
|
||||
expect(rootEdges.map((edge) => edge.key)).toContain("root->deep");
|
||||
});
|
||||
|
||||
it("centers a parent over the full child span in multi-column mode", () => {
|
||||
const children = [
|
||||
orgNode(
|
||||
"deep",
|
||||
[
|
||||
orgNode(
|
||||
"deep-branch",
|
||||
[orgNode("deep-leaf", [orgNode("deep-tail", [], 4)], 3)],
|
||||
2,
|
||||
),
|
||||
],
|
||||
1,
|
||||
),
|
||||
...Array.from({ length: 9 }, (_, index) =>
|
||||
orgNode(`shallow-${index + 1}`, [], 1),
|
||||
),
|
||||
];
|
||||
const layout = layoutForest([orgNode("root", children)], new Set(), {
|
||||
childLayoutMode: "threeColumn",
|
||||
});
|
||||
const rootNode = layout.nodes.find((node) => node.node.id === "root");
|
||||
const directChildren = layout.nodes.filter((node) => node.node.level === 1);
|
||||
const childSpanCenter =
|
||||
(Math.min(...directChildren.map((node) => node.x + node.width / 2)) +
|
||||
Math.max(...directChildren.map((node) => node.x + node.width / 2))) /
|
||||
2;
|
||||
const rootCenter = rootNode ? rootNode.x + rootNode.width / 2 : 0;
|
||||
|
||||
expect(rootNode).toBeDefined();
|
||||
expect(rootCenter).toBeCloseTo(childSpanCenter, 5);
|
||||
});
|
||||
|
||||
it("centers parents above the tidy child span", () => {
|
||||
const children = [
|
||||
orgNode("left", [orgNode("left-a", [], 2), orgNode("left-b", [], 2)], 1),
|
||||
orgNode("middle", [], 1),
|
||||
orgNode(
|
||||
"right",
|
||||
[orgNode("right-a", [], 2), orgNode("right-b", [], 2)],
|
||||
1,
|
||||
),
|
||||
];
|
||||
const layout = layoutForest([orgNode("root", children)], new Set(), {
|
||||
childLayoutMode: "topDown",
|
||||
});
|
||||
const rootNode = layout.nodes.find((node) => node.node.id === "root");
|
||||
const directChildren = layout.nodes.filter((node) =>
|
||||
["left", "middle", "right"].includes(node.node.id),
|
||||
);
|
||||
const childSpanCenter =
|
||||
(Math.min(...directChildren.map((node) => node.x + node.width / 2)) +
|
||||
Math.max(...directChildren.map((node) => node.x + node.width / 2))) /
|
||||
2;
|
||||
const rootCenter = rootNode ? rootNode.x + rootNode.width / 2 : 0;
|
||||
|
||||
expect(rootNode).toBeDefined();
|
||||
expect(rootCenter).toBeCloseTo(childSpanCenter, 5);
|
||||
});
|
||||
|
||||
it("keeps compressed subtrees from overlapping on shared vertical bands", () => {
|
||||
const layout = layoutForest(
|
||||
[
|
||||
orgNode("root", [
|
||||
orgNode(
|
||||
"left",
|
||||
[orgNode("left-a", [], 2), orgNode("left-b", [], 2)],
|
||||
1,
|
||||
),
|
||||
orgNode(
|
||||
"right",
|
||||
[orgNode("right-a", [], 2), orgNode("right-b", [], 2)],
|
||||
1,
|
||||
),
|
||||
]),
|
||||
],
|
||||
new Set(),
|
||||
);
|
||||
|
||||
for (const node of layout.nodes) {
|
||||
for (const other of layout.nodes) {
|
||||
if (node.node.id >= other.node.id) continue;
|
||||
const verticalOverlap =
|
||||
node.y < other.y + other.height && other.y < node.y + node.height;
|
||||
const horizontalOverlap =
|
||||
node.x < other.x + other.width && other.x < node.x + node.width;
|
||||
|
||||
expect(
|
||||
verticalOverlap && horizontalOverlap,
|
||||
`${node.node.id} overlaps ${other.node.id}`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps zoom limits wide enough for large SVG organization charts", () => {
|
||||
expect(clampScale(0.08)).toBe(0.08);
|
||||
expect(clampScale(5)).toBe(5);
|
||||
});
|
||||
|
||||
it("switches semantic zoom modes from overview to detail", () => {
|
||||
expect(getSemanticZoomMode(0.12)).toBe("overview");
|
||||
expect(getSemanticZoomMode(0.4)).toBe("compact");
|
||||
expect(getSemanticZoomMode(0.8)).toBe("detail");
|
||||
});
|
||||
|
||||
it("uses distinct header fills by organization depth", () => {
|
||||
expect(getOrgNodeHeaderFill(0, "family")).toBe("#000000");
|
||||
expect(getOrgNodeHeaderFill(0, "saman")).toBe("#f58220");
|
||||
expect(getOrgNodeHeaderFill(0, "hanmac")).toBe("#1e489d");
|
||||
expect(getOrgNodeHeaderFill(0, "gpdtdc")).toBe("#4b746d");
|
||||
expect(getOrgNodeHeaderFill(0, "baron")).toBe("#004cbf");
|
||||
expect(getOrgNodeHeaderFill(1, "saman")).not.toBe(
|
||||
getOrgNodeHeaderFill(0, "saman"),
|
||||
);
|
||||
expect(getOrgNodeHeaderFill(2, "saman")).not.toBe(
|
||||
getOrgNodeHeaderFill(1, "saman"),
|
||||
);
|
||||
});
|
||||
|
||||
it("orders top organization choices by the hanmac family policy", () => {
|
||||
const familyRoot = tenantNode(
|
||||
"family",
|
||||
"COMPANY_GROUP",
|
||||
"한맥가족",
|
||||
"hanmac-family",
|
||||
[
|
||||
tenantNode("saman", "COMPANY", "삼안", "saman"),
|
||||
tenantNode("baron", "COMPANY_GROUP", "바론그룹", "baron-group"),
|
||||
tenantNode("hanmac", "COMPANY", "한맥기술", "hanmac"),
|
||||
tenantNode("gpdtdc", "ORGANIZATION", "총괄기획&기술개발센터", "gpdtdc"),
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
buildOrgSelectionOptions(familyRoot).map((option) => option.label),
|
||||
).toEqual(["총괄기획&기술개발센터", "삼안", "한맥기술", "바론그룹"]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { GitBranch, Network, PanelTop } from "lucide-react";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/chart", label: "조직도", icon: Network },
|
||||
@@ -8,9 +8,22 @@ const navItems = [
|
||||
];
|
||||
|
||||
export function OrgFrontLayout() {
|
||||
const location = useLocation();
|
||||
const isChartRoute =
|
||||
location.pathname === "/chart" || location.pathname.startsWith("/chart/");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<header className="sticky top-0 z-30 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div
|
||||
className={
|
||||
isChartRoute
|
||||
? "flex h-screen flex-col overflow-hidden bg-background text-foreground"
|
||||
: "min-h-screen bg-background text-foreground"
|
||||
}
|
||||
>
|
||||
<header
|
||||
className="sticky top-0 z-30 shrink-0 border-b border-border bg-background/95 backdrop-blur"
|
||||
data-testid="orgfront-topbar"
|
||||
>
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
@@ -40,7 +53,14 @@ export function OrgFrontLayout() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-7xl px-4 py-5">
|
||||
<main
|
||||
className={
|
||||
isChartRoute
|
||||
? "min-h-0 flex-1 overflow-hidden"
|
||||
: "mx-auto max-w-7xl px-4 py-5"
|
||||
}
|
||||
data-testid="orgfront-main"
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -59,7 +59,7 @@ function PickerScenarioControls({
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-medium">
|
||||
<span className="block text-muted-foreground">tenant ID</span>
|
||||
<span className="block text-muted-foreground">tenant slug</span>
|
||||
<input
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||
onChange={(event) =>
|
||||
@@ -68,7 +68,7 @@ function PickerScenarioControls({
|
||||
tenantId: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="company-baron"
|
||||
placeholder="saman"
|
||||
type="text"
|
||||
value={options.tenantId}
|
||||
/>
|
||||
|
||||
@@ -334,6 +334,7 @@ export function OrgPickerEmbedPage() {
|
||||
const select = parseOrgPickerSelectableType(searchParams.get("select"));
|
||||
const rootTenantId = searchParams.get("rootTenantId") || undefined;
|
||||
const tenantId =
|
||||
searchParams.get("tenantSlug") ||
|
||||
searchParams.get("tenantId") ||
|
||||
searchParams.get("companyTenantId") ||
|
||||
undefined;
|
||||
@@ -615,7 +616,7 @@ export function OrgPickerPage() {
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-medium">
|
||||
<span className="block text-muted-foreground">tenant ID</span>
|
||||
<span className="block text-muted-foreground">tenant slug</span>
|
||||
<input
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||
onChange={(event) =>
|
||||
@@ -624,7 +625,7 @@ export function OrgPickerPage() {
|
||||
tenantId: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="company-baron"
|
||||
placeholder="saman"
|
||||
type="text"
|
||||
value={options.tenantId}
|
||||
/>
|
||||
|
||||
45
orgfront/src/features/orgchart/tenantVisibility.ts
Normal file
45
orgfront/src/features/orgchart/tenantVisibility.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { TenantSummary } from "../../lib/adminApi";
|
||||
|
||||
export function getTenantVisibility(tenant: Pick<TenantSummary, "config">) {
|
||||
const raw = String(tenant.config?.visibility ?? "public").toLowerCase();
|
||||
if (raw === "internal" || raw === "private") return raw;
|
||||
return "public";
|
||||
}
|
||||
|
||||
export function filterTenantsByVisibility(
|
||||
tenants: TenantSummary[],
|
||||
mode: "internal" | "public",
|
||||
) {
|
||||
const excludedIds = new Set<string>();
|
||||
for (const tenant of tenants) {
|
||||
const visibility = getTenantVisibility(tenant);
|
||||
if (
|
||||
visibility === "private" ||
|
||||
(mode === "public" && visibility === "internal")
|
||||
) {
|
||||
excludedIds.add(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));
|
||||
}
|
||||
|
||||
export function getOrgUnitType(config: Record<string, unknown> | undefined) {
|
||||
const value = config?.orgUnitType;
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
49
orgfront/src/features/orgchart/userDisplay.test.ts
Normal file
49
orgfront/src/features/orgchart/userDisplay.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UserSummary } from "../../lib/adminApi";
|
||||
import { getOrgChartUserDisplayName } from "./userDisplay";
|
||||
|
||||
function user(overrides: Partial<UserSummary>): UserSummary {
|
||||
return {
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
name: "홍길동",
|
||||
role: "user",
|
||||
status: "active",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getOrgChartUserDisplayName", () => {
|
||||
it("renders name with grade and optional position", () => {
|
||||
expect(
|
||||
getOrgChartUserDisplayName(
|
||||
user({
|
||||
grade: "수석",
|
||||
position: "팀장",
|
||||
}),
|
||||
),
|
||||
).toBe("홍길동 수석(팀장)");
|
||||
});
|
||||
|
||||
it("uses tenant appointment grade before the user grade", () => {
|
||||
expect(
|
||||
getOrgChartUserDisplayName(
|
||||
user({
|
||||
grade: "책임",
|
||||
metadata: {
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantSlug: "hanmac",
|
||||
grade: "수석",
|
||||
position: "센터장",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ id: "tenant-1", slug: "hanmac" },
|
||||
),
|
||||
).toBe("홍길동 수석(센터장)");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type { TenantSummary, UserSummary } from "../../lib/adminApi";
|
||||
type UserAppointment = {
|
||||
tenantId?: string;
|
||||
tenantSlug?: string;
|
||||
grade?: string;
|
||||
jobTitle?: string;
|
||||
position?: string;
|
||||
};
|
||||
@@ -25,6 +26,7 @@ function getUserAppointments(user: UserSummary): UserAppointment[] {
|
||||
.map((item) => ({
|
||||
tenantId: normalizeText(item.tenantId),
|
||||
tenantSlug: normalizeText(item.tenantSlug),
|
||||
grade: normalizeText(item.grade),
|
||||
jobTitle: normalizeText(item.jobTitle),
|
||||
position: normalizeText(item.position),
|
||||
}));
|
||||
@@ -44,6 +46,7 @@ export function getUserOrgProfile(user: UserSummary, tenant?: TenantIdentity) {
|
||||
});
|
||||
|
||||
return {
|
||||
grade: appointment?.grade || normalizeText(user.grade),
|
||||
jobTitle: appointment?.jobTitle || normalizeText(user.jobTitle),
|
||||
position: appointment?.position || normalizeText(user.position),
|
||||
};
|
||||
@@ -53,11 +56,12 @@ export function getOrgChartUserDisplayName(
|
||||
user: UserSummary,
|
||||
tenant?: TenantIdentity,
|
||||
) {
|
||||
const { jobTitle, position } = getUserOrgProfile(user, tenant);
|
||||
const { grade, jobTitle, position } = getUserOrgProfile(user, tenant);
|
||||
const baseName = user.name.trim();
|
||||
const detail = position || jobTitle;
|
||||
|
||||
if (jobTitle && position) return `${baseName}(${jobTitle}) ${position}`;
|
||||
if (jobTitle) return `${baseName}(${jobTitle})`;
|
||||
if (position) return `${baseName} ${position}`;
|
||||
if (grade && detail) return `${baseName} ${grade}(${detail})`;
|
||||
if (grade) return `${baseName} ${grade}`;
|
||||
if (detail) return `${baseName}(${detail})`;
|
||||
return baseName;
|
||||
}
|
||||
|
||||
@@ -388,6 +388,7 @@ export type UserSummary = {
|
||||
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
|
||||
metadata?: Record<string, unknown>;
|
||||
department?: string;
|
||||
grade?: string;
|
||||
position?: string;
|
||||
jobTitle?: string;
|
||||
createdAt: string;
|
||||
@@ -410,6 +411,7 @@ export type UserCreateRequest = {
|
||||
role?: string;
|
||||
tenantSlug?: string;
|
||||
department?: string;
|
||||
grade?: string;
|
||||
position?: string;
|
||||
jobTitle?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
@@ -428,6 +430,7 @@ export type UserUpdateRequest = {
|
||||
status?: string;
|
||||
tenantSlug?: string;
|
||||
department?: string;
|
||||
grade?: string;
|
||||
position?: string;
|
||||
jobTitle?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
@@ -441,6 +444,7 @@ export type BulkUserItem = {
|
||||
role?: string;
|
||||
tenantSlug?: string;
|
||||
department?: string;
|
||||
grade?: string;
|
||||
position?: string;
|
||||
jobTitle?: string;
|
||||
metadata: Record<string, string>;
|
||||
|
||||
@@ -1091,14 +1091,16 @@ email = "이메일"
|
||||
email_placeholder = "user@example.com"
|
||||
job_title = "직무"
|
||||
job_title_placeholder = "프론트엔드 개발"
|
||||
grade = "직급"
|
||||
grade_placeholder = "수석/책임/선임"
|
||||
name = "이름"
|
||||
name_placeholder = "홍길동"
|
||||
password = "비밀번호"
|
||||
password_placeholder = "********"
|
||||
phone = "전화번호"
|
||||
phone_placeholder = "010-1234-5678"
|
||||
position = "직급"
|
||||
position_placeholder = "수석/책임/선임"
|
||||
position = "직책"
|
||||
position_placeholder = "팀장/센터장"
|
||||
role = "역할"
|
||||
tenant = "테넌트"
|
||||
tenant_global = "시스템 전역"
|
||||
|
||||
Reference in New Issue
Block a user