forked from baron/baron-sso
feat: integrate orgfront and expose internal ids
This commit is contained in:
881
orgfront/src/features/orgchart/routes/OrgChartPage.tsx
Normal file
881
orgfront/src/features/orgchart/routes/OrgChartPage.tsx
Normal file
@@ -0,0 +1,881 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { useLocation, useParams } from "react-router-dom";
|
||||
import {
|
||||
type TenantSummary,
|
||||
type UserSummary,
|
||||
fetchPublicOrgChart,
|
||||
fetchTenants,
|
||||
fetchUsers,
|
||||
} from "../../../lib/adminApi";
|
||||
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||
import { getOrgChartUserDisplayName, getUserOrgProfile } from "../userDisplay";
|
||||
|
||||
type OrgNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
level: number;
|
||||
members: UserSummary[];
|
||||
children: OrgNode[];
|
||||
totalCount: number;
|
||||
totalMemberIds: Set<string>;
|
||||
companyCode?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
type ViewBox = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type VisualNode = {
|
||||
node: OrgNode;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
members: UserSummary[];
|
||||
collapsed: boolean;
|
||||
};
|
||||
|
||||
type VisualEdge = {
|
||||
key: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type ChartLayout = {
|
||||
nodes: VisualNode[];
|
||||
edges: VisualEdge[];
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const NODE_WIDTH = 340;
|
||||
const HEADER_HEIGHT = 42;
|
||||
const MEMBER_ROW_HEIGHT = 24;
|
||||
const NODE_PADDING_Y = 12;
|
||||
const ROOT_GAP_X = 120;
|
||||
const CHILD_GAP_X = 80;
|
||||
const CHILD_GAP_Y = 96;
|
||||
const CHART_MARGIN = 72;
|
||||
const MIN_SCALE = 0.45;
|
||||
const MAX_SCALE = 2.4;
|
||||
const ZOOM_SENSITIVITY = 0.0015;
|
||||
const FAMILY_FILTER_ID = "hanmac-family";
|
||||
|
||||
const ROLE_ORDER = [
|
||||
"사장",
|
||||
"부사장",
|
||||
"전무",
|
||||
"상무",
|
||||
"이사",
|
||||
"수석",
|
||||
"책임",
|
||||
"선임",
|
||||
"주임",
|
||||
"사원",
|
||||
];
|
||||
|
||||
function getRankWeight(
|
||||
user: UserSummary,
|
||||
tenant?: { id: string; slug: string },
|
||||
) {
|
||||
const profile = getUserOrgProfile(user, tenant);
|
||||
const role = profile.position || "";
|
||||
const order = ROLE_ORDER.indexOf(role);
|
||||
const isLeader =
|
||||
profile.position.endsWith("장") || profile.jobTitle.endsWith("장");
|
||||
return (isLeader ? -100 : 0) + (order === -1 ? 99 : order);
|
||||
}
|
||||
|
||||
function getNodeHeight(members: UserSummary[]) {
|
||||
return (
|
||||
HEADER_HEIGHT +
|
||||
NODE_PADDING_Y * 2 +
|
||||
Math.max(members.length, 1) * MEMBER_ROW_HEIGHT
|
||||
);
|
||||
}
|
||||
|
||||
function clampScale(scale: number) {
|
||||
return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale));
|
||||
}
|
||||
|
||||
function buildOrgNode(
|
||||
tenantNode: TenantNode,
|
||||
usersMap: Map<string, UserSummary[]>,
|
||||
depth: number,
|
||||
): OrgNode {
|
||||
const slug = tenantNode.slug.toLowerCase();
|
||||
const members = usersMap.get(slug) || [];
|
||||
const children = tenantNode.children.map((child) =>
|
||||
buildOrgNode(child, usersMap, depth + 1),
|
||||
);
|
||||
const totalMemberIds = new Set(members.map((member) => member.id));
|
||||
for (const child of children) {
|
||||
for (const memberId of child.totalMemberIds) {
|
||||
totalMemberIds.add(memberId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: tenantNode.id,
|
||||
name: tenantNode.name,
|
||||
level: depth,
|
||||
members,
|
||||
children,
|
||||
totalCount: totalMemberIds.size,
|
||||
totalMemberIds,
|
||||
companyCode: slug,
|
||||
type: tenantNode.type,
|
||||
};
|
||||
}
|
||||
|
||||
function layoutTree(
|
||||
node: OrgNode,
|
||||
collapsedIds: Set<string>,
|
||||
originX = 0,
|
||||
originY = 0,
|
||||
): ChartLayout {
|
||||
const members = [...node.members].sort(
|
||||
(a, b) =>
|
||||
getRankWeight(a, { id: node.id, slug: node.companyCode ?? "" }) -
|
||||
getRankWeight(b, { id: node.id, slug: node.companyCode ?? "" }),
|
||||
);
|
||||
const nodeHeight = getNodeHeight(members);
|
||||
const collapsed = collapsedIds.has(node.id);
|
||||
const childLayouts = collapsed
|
||||
? []
|
||||
: node.children.map((child) => layoutTree(child, collapsedIds));
|
||||
const childrenWidth =
|
||||
childLayouts.length > 0
|
||||
? childLayouts.reduce((sum, child) => sum + child.width, 0) +
|
||||
CHILD_GAP_X * (childLayouts.length - 1)
|
||||
: 0;
|
||||
const subtreeWidth = Math.max(NODE_WIDTH, childrenWidth);
|
||||
const nodeX = originX + (subtreeWidth - NODE_WIDTH) / 2;
|
||||
const nodeY = originY;
|
||||
const visualNode: VisualNode = {
|
||||
node,
|
||||
x: nodeX,
|
||||
y: nodeY,
|
||||
width: NODE_WIDTH,
|
||||
height: nodeHeight,
|
||||
members,
|
||||
collapsed,
|
||||
};
|
||||
|
||||
let cursorX = originX;
|
||||
let maxChildHeight = 0;
|
||||
const nodes: VisualNode[] = [visualNode];
|
||||
const edges: VisualEdge[] = [];
|
||||
|
||||
for (const childLayout of childLayouts) {
|
||||
const childOffsetY = originY + nodeHeight + CHILD_GAP_Y;
|
||||
const shiftedNodes = childLayout.nodes.map((childNode) => ({
|
||||
...childNode,
|
||||
x: childNode.x + cursorX,
|
||||
y: childNode.y + childOffsetY,
|
||||
}));
|
||||
const shiftedEdges = childLayout.edges.map((edge) => ({
|
||||
...edge,
|
||||
path: offsetPath(edge.path, cursorX, childOffsetY),
|
||||
}));
|
||||
const childRoot = shiftedNodes[0];
|
||||
const parentCenterX = nodeX + NODE_WIDTH / 2;
|
||||
const parentBottomY = nodeY + nodeHeight;
|
||||
const childCenterX = childRoot.x + childRoot.width / 2;
|
||||
const childTopY = childRoot.y;
|
||||
const midY = parentBottomY + CHILD_GAP_Y / 2;
|
||||
|
||||
nodes.push(...shiftedNodes);
|
||||
edges.push({
|
||||
key: `${node.id}->${childRoot.node.id}`,
|
||||
path: `M ${parentCenterX} ${parentBottomY} L ${parentCenterX} ${midY} L ${childCenterX} ${midY} L ${childCenterX} ${childTopY}`,
|
||||
});
|
||||
edges.push(...shiftedEdges);
|
||||
cursorX += childLayout.width + CHILD_GAP_X;
|
||||
maxChildHeight = Math.max(maxChildHeight, childLayout.height);
|
||||
}
|
||||
|
||||
const height =
|
||||
childLayouts.length > 0
|
||||
? nodeHeight + CHILD_GAP_Y + maxChildHeight
|
||||
: nodeHeight;
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
width: subtreeWidth,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
function offsetPath(path: string, offsetX: number, offsetY: number) {
|
||||
const numbers = path.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
|
||||
let index = 0;
|
||||
return path.replace(/-?\d+(\.\d+)?/g, () => {
|
||||
const value = numbers[index] ?? 0;
|
||||
const nextValue = index % 2 === 0 ? value + offsetX : value + offsetY;
|
||||
index += 1;
|
||||
return String(nextValue);
|
||||
});
|
||||
}
|
||||
|
||||
function layoutForest(
|
||||
nodes: OrgNode[],
|
||||
collapsedIds: Set<string>,
|
||||
): ChartLayout {
|
||||
const layouts = nodes.map((node) => layoutTree(node, collapsedIds));
|
||||
const contentWidth =
|
||||
layouts.reduce((sum, layout) => sum + layout.width, 0) +
|
||||
ROOT_GAP_X * Math.max(layouts.length - 1, 0);
|
||||
const contentHeight = Math.max(1, ...layouts.map((layout) => layout.height));
|
||||
const visualNodes: VisualNode[] = [];
|
||||
const edges: VisualEdge[] = [];
|
||||
let cursorX = CHART_MARGIN;
|
||||
|
||||
for (const layout of layouts) {
|
||||
visualNodes.push(
|
||||
...layout.nodes.map((node) => ({
|
||||
...node,
|
||||
x: node.x + cursorX,
|
||||
y: node.y + CHART_MARGIN,
|
||||
})),
|
||||
);
|
||||
edges.push(
|
||||
...layout.edges.map((edge) => ({
|
||||
...edge,
|
||||
path: offsetPath(edge.path, cursorX, CHART_MARGIN),
|
||||
})),
|
||||
);
|
||||
cursorX += layout.width + ROOT_GAP_X;
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: visualNodes,
|
||||
edges,
|
||||
width: Math.max(contentWidth + CHART_MARGIN * 2, 960),
|
||||
height: Math.max(contentHeight + CHART_MARGIN * 2, 640),
|
||||
};
|
||||
}
|
||||
|
||||
function makeInitialViewBox(
|
||||
layout: ChartLayout,
|
||||
viewport: DOMRect | null,
|
||||
): ViewBox {
|
||||
const aspect =
|
||||
viewport && viewport.height > 0 ? viewport.width / viewport.height : 16 / 9;
|
||||
const width = Math.max(layout.width, 960);
|
||||
const height = Math.max(width / aspect, layout.height);
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
function getViewBoxScale(layout: ChartLayout, viewBox: ViewBox) {
|
||||
return layout.width / viewBox.width;
|
||||
}
|
||||
|
||||
function getColorForCompany(companyCode?: string) {
|
||||
const code = (companyCode || "").toLowerCase();
|
||||
if (code.includes("hanmac")) return "#ef4444";
|
||||
if (code.includes("saman")) return "#ffb366";
|
||||
if (code.includes("ptc")) return "#a855f7";
|
||||
if (code.includes("baron")) return "#3b82f6";
|
||||
return "#64748b";
|
||||
}
|
||||
|
||||
function isVisibleOrgChartUser(user: UserSummary) {
|
||||
return (
|
||||
!user.email.toLowerCase().endsWith("@hanmac.kr") &&
|
||||
!isSystemGlobalUser(user)
|
||||
);
|
||||
}
|
||||
|
||||
function isSystemGlobalTenant(
|
||||
tenant?: Pick<TenantSummary, "id" | "slug" | "type" | "name">,
|
||||
) {
|
||||
if (!tenant) return false;
|
||||
const values = [tenant.id, tenant.slug, tenant.type, tenant.name].map(
|
||||
(value) => value.toLowerCase().replaceAll("_", "-"),
|
||||
);
|
||||
return values.some(
|
||||
(value) =>
|
||||
value === "system" ||
|
||||
value === "global" ||
|
||||
value === "system-global" ||
|
||||
value === "tenant-global" ||
|
||||
value === "시스템 전역",
|
||||
);
|
||||
}
|
||||
|
||||
function isSystemGlobalUser(user: UserSummary) {
|
||||
const normalizedRole = user.role.toLowerCase().replaceAll("_", "-");
|
||||
|
||||
return (
|
||||
normalizedRole === "super-admin" ||
|
||||
normalizedRole === "superadmin" ||
|
||||
normalizedRole === "system-admin" ||
|
||||
isSystemGlobalTenant(user.tenant) ||
|
||||
isSystemGlobalTenant({
|
||||
id: user.companyCode || user.tenantSlug || "",
|
||||
slug: user.companyCode || user.tenantSlug || "",
|
||||
type: user.role,
|
||||
name: user.role,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function findNodeByTenantId(
|
||||
nodes: TenantNode[],
|
||||
tenantId: string,
|
||||
): TenantNode | null {
|
||||
for (const node of nodes) {
|
||||
if (node.id === tenantId) return node;
|
||||
const child = findNodeByTenantId(node.children, tenantId);
|
||||
if (child) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function filterSystemGlobalTenants(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));
|
||||
}
|
||||
|
||||
type TenantIndexes = {
|
||||
byId: Map<string, TenantNode>;
|
||||
bySlug: Map<string, TenantNode>;
|
||||
};
|
||||
|
||||
function buildTenantIndexes(nodes: TenantNode[]): TenantIndexes {
|
||||
const byId = new Map<string, TenantNode>();
|
||||
const bySlug = new Map<string, TenantNode>();
|
||||
const visit = (node: TenantNode) => {
|
||||
byId.set(node.id, node);
|
||||
bySlug.set(node.slug.toLowerCase(), node);
|
||||
for (const child of node.children) visit(child);
|
||||
};
|
||||
|
||||
for (const node of nodes) visit(node);
|
||||
return { byId, bySlug };
|
||||
}
|
||||
|
||||
function isDescendantTenant(
|
||||
candidate: TenantNode,
|
||||
ancestor: TenantNode,
|
||||
byId: Map<string, TenantNode>,
|
||||
) {
|
||||
const visited = new Set<string>();
|
||||
let currentParentId = candidate.parentId;
|
||||
|
||||
while (currentParentId) {
|
||||
if (currentParentId === ancestor.id) return true;
|
||||
if (visited.has(currentParentId)) return false;
|
||||
visited.add(currentParentId);
|
||||
currentParentId = byId.get(currentParentId)?.parentId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getLeafMembershipSlugs(
|
||||
slugs: Set<string>,
|
||||
tenantIndexes: TenantIndexes,
|
||||
) {
|
||||
const memberships = Array.from(slugs);
|
||||
|
||||
return memberships.filter((slug) => {
|
||||
const tenant = tenantIndexes.bySlug.get(slug);
|
||||
if (!tenant) return true;
|
||||
|
||||
return !memberships.some((otherSlug) => {
|
||||
if (otherSlug === slug) return false;
|
||||
const otherTenant = tenantIndexes.bySlug.get(otherSlug);
|
||||
if (!otherTenant) return false;
|
||||
return isDescendantTenant(otherTenant, tenant, tenantIndexes.byId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildUsersMap(
|
||||
users: UserSummary[],
|
||||
rootNodes: TenantNode[],
|
||||
options: { activeOnly: boolean },
|
||||
) {
|
||||
const tenantIndexes = buildTenantIndexes(rootNodes);
|
||||
const map = new Map<string, UserSummary[]>();
|
||||
|
||||
for (const user of users) {
|
||||
if (options.activeOnly && user.status !== "active") continue;
|
||||
if (!isVisibleOrgChartUser(user)) continue;
|
||||
|
||||
const slugs = new Set<string>();
|
||||
const primarySlug =
|
||||
user.companyCode?.toLowerCase() || user.tenantSlug?.toLowerCase() || "";
|
||||
if (
|
||||
primarySlug &&
|
||||
!isSystemGlobalTenant({
|
||||
id: primarySlug,
|
||||
slug: primarySlug,
|
||||
type: primarySlug,
|
||||
name: primarySlug,
|
||||
})
|
||||
) {
|
||||
slugs.add(primarySlug);
|
||||
}
|
||||
if (user.tenant?.slug && !isSystemGlobalTenant(user.tenant)) {
|
||||
slugs.add(user.tenant.slug.toLowerCase());
|
||||
}
|
||||
for (const joinedTenant of user.joinedTenants || []) {
|
||||
if (joinedTenant.slug && !isSystemGlobalTenant(joinedTenant)) {
|
||||
slugs.add(joinedTenant.slug.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
for (const slug of getLeafMembershipSlugs(slugs, tenantIndexes)) {
|
||||
const list = map.get(slug) || [];
|
||||
if (!list.some((existing) => existing.id === user.id)) list.push(user);
|
||||
map.set(slug, list);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export function TenantOrgChartPage() {
|
||||
const viewportRef = React.useRef<HTMLDivElement>(null);
|
||||
const dragRef = React.useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startViewBox: ViewBox;
|
||||
} | null>(null);
|
||||
const { tenantId } = useParams<{ tenantId?: string }>();
|
||||
const location = useLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const shareToken = searchParams.get("token");
|
||||
const [selectedTenantFilter, setSelectedTenantFilter] =
|
||||
React.useState(FAMILY_FILTER_ID);
|
||||
const [collapsedIds, setCollapsedIds] = React.useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [viewBox, setViewBox] = React.useState<ViewBox>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
});
|
||||
const [hasUserMovedCanvas, setHasUserMovedCanvas] = React.useState(false);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
|
||||
const publicQuery = useQuery({
|
||||
queryKey: ["public-orgchart", shareToken],
|
||||
queryFn: () => {
|
||||
if (!shareToken) throw new Error("Missing share token");
|
||||
return fetchPublicOrgChart(shareToken);
|
||||
},
|
||||
enabled: !!shareToken,
|
||||
});
|
||||
|
||||
const tenantsQuery = useQuery({
|
||||
queryKey: ["tenants-full-tree-v2"],
|
||||
queryFn: () => fetchTenants(10000, 0),
|
||||
enabled: !shareToken,
|
||||
});
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ["users", { limit: 5000, offset: 0 }],
|
||||
queryFn: () => fetchUsers(5000, 0),
|
||||
enabled: !shareToken,
|
||||
});
|
||||
|
||||
const { rootNodes, usersMap, sharedWith } = React.useMemo(() => {
|
||||
if (shareToken) {
|
||||
if (!publicQuery.data) {
|
||||
return {
|
||||
rootNodes: [],
|
||||
usersMap: new Map<string, UserSummary[]>(),
|
||||
sharedWith: "",
|
||||
};
|
||||
}
|
||||
|
||||
const rootNodes = buildTenantFullTree(
|
||||
filterSystemGlobalTenants(publicQuery.data.tenants),
|
||||
).subTree;
|
||||
|
||||
return {
|
||||
rootNodes,
|
||||
usersMap: buildUsersMap(publicQuery.data.users, rootNodes, {
|
||||
activeOnly: false,
|
||||
}),
|
||||
sharedWith: publicQuery.data.sharedWith,
|
||||
};
|
||||
}
|
||||
|
||||
if (!tenantsQuery.data?.items || !usersQuery.data?.items) {
|
||||
return {
|
||||
rootNodes: [],
|
||||
usersMap: new Map<string, UserSummary[]>(),
|
||||
sharedWith: "",
|
||||
};
|
||||
}
|
||||
|
||||
const rootNodes = buildTenantFullTree(
|
||||
filterSystemGlobalTenants(tenantsQuery.data.items),
|
||||
).subTree;
|
||||
|
||||
return {
|
||||
rootNodes,
|
||||
usersMap: buildUsersMap(usersQuery.data.items, rootNodes, {
|
||||
activeOnly: true,
|
||||
}),
|
||||
sharedWith: "",
|
||||
};
|
||||
}, [publicQuery.data, shareToken, tenantsQuery.data, usersQuery.data]);
|
||||
|
||||
const familyRoot = React.useMemo(() => {
|
||||
return (
|
||||
rootNodes.find((node) => node.type === "COMPANY_GROUP") ??
|
||||
rootNodes[0] ??
|
||||
null
|
||||
);
|
||||
}, [rootNodes]);
|
||||
|
||||
const companyFilters = React.useMemo(() => {
|
||||
return (familyRoot?.children ?? [])
|
||||
.filter((node) => node.type === "COMPANY")
|
||||
.map((node) => ({ id: node.id, label: node.name }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [familyRoot]);
|
||||
|
||||
const filterOptions = React.useMemo(
|
||||
() => [{ id: FAMILY_FILTER_ID, label: "한맥가족" }, ...companyFilters],
|
||||
[companyFilters],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!tenantId) return;
|
||||
const searchRoots = familyRoot ? [familyRoot] : rootNodes;
|
||||
const match = searchRoots
|
||||
.flatMap((node) => [node, ...node.children])
|
||||
.find(
|
||||
(node) =>
|
||||
node.id === tenantId ||
|
||||
node.slug.toLowerCase() === tenantId.toLowerCase() ||
|
||||
node.name === tenantId,
|
||||
);
|
||||
if (match?.type === "COMPANY") setSelectedTenantFilter(match.id);
|
||||
}, [familyRoot, rootNodes, tenantId]);
|
||||
|
||||
const targetNodes = React.useMemo(() => {
|
||||
if (!familyRoot) return [];
|
||||
if (selectedTenantFilter === FAMILY_FILTER_ID) {
|
||||
return [buildOrgNode(familyRoot, usersMap, 0)];
|
||||
}
|
||||
|
||||
const companyNode = findNodeByTenantId([familyRoot], selectedTenantFilter);
|
||||
return companyNode ? [buildOrgNode(companyNode, usersMap, 0)] : [];
|
||||
}, [familyRoot, selectedTenantFilter, usersMap]);
|
||||
|
||||
const layout = React.useMemo(
|
||||
() => layoutForest(targetNodes, collapsedIds),
|
||||
[collapsedIds, targetNodes],
|
||||
);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (hasUserMovedCanvas) return;
|
||||
setViewBox(
|
||||
makeInitialViewBox(
|
||||
layout,
|
||||
viewportRef.current?.getBoundingClientRect() ?? null,
|
||||
),
|
||||
);
|
||||
}, [hasUserMovedCanvas, layout]);
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startViewBox: viewBox,
|
||||
};
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const dragState = dragRef.current;
|
||||
const rect = viewportRef.current?.getBoundingClientRect();
|
||||
if (!dragState || !rect || dragState.pointerId !== event.pointerId) return;
|
||||
|
||||
const dx =
|
||||
((event.clientX - dragState.startX) / rect.width) *
|
||||
dragState.startViewBox.width;
|
||||
const dy =
|
||||
((event.clientY - dragState.startY) / rect.height) *
|
||||
dragState.startViewBox.height;
|
||||
setHasUserMovedCanvas(true);
|
||||
setViewBox({
|
||||
...dragState.startViewBox,
|
||||
x: dragState.startViewBox.x - dx,
|
||||
y: dragState.startViewBox.y - dy,
|
||||
});
|
||||
};
|
||||
|
||||
const finishDrag = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const dragState = dragRef.current;
|
||||
if (!dragState || dragState.pointerId !== event.pointerId) return;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
dragRef.current = null;
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const currentScale = getViewBoxScale(layout, viewBox);
|
||||
const nextScale = clampScale(
|
||||
currentScale * Math.exp(-event.deltaY * ZOOM_SENSITIVITY),
|
||||
);
|
||||
const nextWidth = layout.width / nextScale;
|
||||
const nextHeight = nextWidth / (rect.width / rect.height);
|
||||
const pointX =
|
||||
viewBox.x + ((event.clientX - rect.left) / rect.width) * viewBox.width;
|
||||
const pointY =
|
||||
viewBox.y + ((event.clientY - rect.top) / rect.height) * viewBox.height;
|
||||
const ratioX = (pointX - viewBox.x) / viewBox.width;
|
||||
const ratioY = (pointY - viewBox.y) / viewBox.height;
|
||||
|
||||
setHasUserMovedCanvas(true);
|
||||
setViewBox({
|
||||
x: pointX - nextWidth * ratioX,
|
||||
y: pointY - nextHeight * ratioY,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
});
|
||||
};
|
||||
|
||||
const isLoading = shareToken
|
||||
? publicQuery.isLoading
|
||||
: tenantsQuery.isLoading || usersQuery.isLoading;
|
||||
const isError = shareToken
|
||||
? publicQuery.isError
|
||||
: tenantsQuery.isError || usersQuery.isError;
|
||||
|
||||
const totalUsers = React.useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const node of targetNodes) {
|
||||
for (const memberId of node.totalMemberIds) {
|
||||
ids.add(memberId);
|
||||
}
|
||||
}
|
||||
return ids.size;
|
||||
}, [targetNodes]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center text-muted-foreground">로딩 중...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
조직도를 불러올 수 없거나 만료된 링크입니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-theme(spacing.32))] flex-col overflow-hidden rounded-xl border border-[#e0d5c1] bg-[#f6efe6] shadow-sm">
|
||||
<header className="z-10 flex shrink-0 flex-col items-start justify-between border-b border-[#f2c484]/30 bg-[linear-gradient(145deg,rgba(10,42,34,0.98)_0%,rgba(15,58,47,0.98)_52%,rgba(26,86,69,0.98)_100%)] px-6 py-4 sm:flex-row sm:items-center">
|
||||
<div className="mb-4 flex flex-col gap-1 sm:mb-0">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-[#f2c484]">
|
||||
{shareToken ? `공유된 조직도: ${sharedWith}` : "MH Dashboard"}
|
||||
</p>
|
||||
<h2 className="text-xl font-black text-[#f7f0e4]">조직 현황</h2>
|
||||
</div>
|
||||
<div className="custom-scrollbar flex max-w-full items-center gap-2 overflow-x-auto">
|
||||
{filterOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedTenantFilter(option.id);
|
||||
setCollapsedIds(new Set());
|
||||
setHasUserMovedCanvas(false);
|
||||
}}
|
||||
className={`whitespace-nowrap rounded-full border px-4 py-2 text-xs font-bold transition-all ${
|
||||
selectedTenantFilter === option.id
|
||||
? "border-[#f2c484]/40 bg-[linear-gradient(180deg,rgba(255,253,248,0.98),rgba(245,235,221,0.94))] text-[#0a2a22] shadow-sm"
|
||||
: "border-[#f2c484]/30 bg-white/10 text-[#f7f0e4]/70 hover:border-[#f2c484]/50 hover:text-[#f7f0e4]"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-2 whitespace-nowrap rounded-full border border-[#f2c484]/30 bg-[#f2c484]/10 px-4 py-2 text-xs font-black text-[#f2c484] shadow-sm">
|
||||
총 {totalUsers}명
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className={`relative flex-1 touch-none select-none overflow-hidden ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
|
||||
data-testid="orgchart-viewport"
|
||||
onPointerCancel={finishDrag}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={finishDrag}
|
||||
onWheel={handleWheel}
|
||||
ref={viewportRef}
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle at top left, rgba(214, 138, 58, 0.08), transparent 24%), radial-gradient(circle at top right, rgba(47, 153, 115, 0.05), transparent 20%), linear-gradient(180deg, rgba(246, 239, 230, 0.98), rgba(241, 234, 223, 0.96))",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="h-full w-full"
|
||||
data-scale={getViewBoxScale(layout, viewBox).toFixed(3)}
|
||||
data-testid="orgchart-vector-svg"
|
||||
role="img"
|
||||
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
|
||||
>
|
||||
<title>조직도 벡터 렌더링</title>
|
||||
<g data-testid="orgchart-canvas">
|
||||
{layout.edges.map((edge) => (
|
||||
<path
|
||||
d={edge.path}
|
||||
fill="none"
|
||||
key={edge.key}
|
||||
stroke="#bca58a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
{layout.nodes.map((visualNode) => (
|
||||
<SvgOrgNode key={visualNode.node.id} visualNode={visualNode} />
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SvgOrgNode({
|
||||
visualNode,
|
||||
}: {
|
||||
visualNode: VisualNode;
|
||||
}) {
|
||||
const { node, x, y, width, height, members, collapsed } = visualNode;
|
||||
const headerFill = node.level === 0 ? "#0a2a22" : "#2f5547";
|
||||
const accent = getColorForCompany(node.companyCode);
|
||||
|
||||
return (
|
||||
<g transform={`translate(${x} ${y})`}>
|
||||
<rect
|
||||
fill="#ffffff"
|
||||
height={height}
|
||||
rx="10"
|
||||
stroke="#e0d5c1"
|
||||
strokeWidth="1.5"
|
||||
width={width}
|
||||
/>
|
||||
<rect fill={headerFill} height={HEADER_HEIGHT} rx="10" width={width} />
|
||||
<rect
|
||||
fill={headerFill}
|
||||
height="18"
|
||||
width={width}
|
||||
y={HEADER_HEIGHT - 18}
|
||||
/>
|
||||
<text
|
||||
fill="#f7f0e4"
|
||||
fontSize={node.level === 0 ? 17 : 15}
|
||||
fontWeight="800"
|
||||
textAnchor="middle"
|
||||
x={width / 2}
|
||||
y="27"
|
||||
>
|
||||
{node.name}
|
||||
</text>
|
||||
<g>
|
||||
<rect
|
||||
fill="rgba(0,0,0,0.22)"
|
||||
height="22"
|
||||
rx="11"
|
||||
width="52"
|
||||
x={width - 66}
|
||||
y="10"
|
||||
/>
|
||||
<text
|
||||
fill="#ffffff"
|
||||
fontSize="12"
|
||||
fontWeight="700"
|
||||
textAnchor="middle"
|
||||
x={width - 40}
|
||||
y="25"
|
||||
>
|
||||
{collapsed ? "+" : node.totalCount}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{members.length > 0 ? (
|
||||
members.map((member, index) => (
|
||||
<g
|
||||
key={member.id}
|
||||
transform={`translate(14 ${HEADER_HEIGHT + NODE_PADDING_Y + index * MEMBER_ROW_HEIGHT})`}
|
||||
>
|
||||
<rect
|
||||
fill="#ffffff"
|
||||
height="20"
|
||||
rx="4"
|
||||
stroke="#e5e7eb"
|
||||
width={width - 28}
|
||||
/>
|
||||
<rect fill={accent} height="20" rx="4" width="4" />
|
||||
<text fill="#334155" fontSize="12" fontWeight="800" x="12" y="14">
|
||||
{getOrgChartUserDisplayName(member, {
|
||||
id: node.id,
|
||||
slug: node.companyCode ?? "",
|
||||
})}
|
||||
</text>
|
||||
</g>
|
||||
))
|
||||
) : (
|
||||
<text fill="#94a3b8" fontSize="12" x="16" y={HEADER_HEIGHT + 28}>
|
||||
구성원 없음
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user