1
0
forked from baron/baron-sso

조직도 표현 개선

This commit is contained in:
2026-05-29 10:33:15 +09:00
parent 6a6730b544
commit c489c7c38f
34 changed files with 1872 additions and 391 deletions

View File

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