1
0
forked from baron/baron-sso

조직현황 구조변경. 총괄센터삼안 실 조직 삽입확인

This commit is contained in:
2026-05-11 20:13:54 +09:00
parent d3853fac2a
commit 3063450ee0
59 changed files with 5086 additions and 549 deletions

View 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"]);
});
});

View File

@@ -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);

View 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",
);
});
});

View File

@@ -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") {

View 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

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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}
/>

View 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() : "";
}

View 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("홍길동 수석(센터장)");
});
});

View File

@@ -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;
}

View File

@@ -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>;

View File

@@ -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 = "시스템 전역"