import { useQuery } from "@tanstack/react-query"; import { ChevronLeft } from "lucide-react"; import * as React from "react"; import { Link } from "react-router-dom"; import { Button } from "../../../components/ui/button"; import { type UserSummary, fetchTenants, fetchUsers, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree"; interface OrgNode { id: string; name: string; level: number; members: UserSummary[]; children: OrgNode[]; totalCount?: number; companyCode?: string; type?: string; } export function TenantOrgChartPage() { const containerRef = React.useRef(null); const [lines, setLines] = React.useState< { x1: number; y1: number; x2: number; y2: number; key: string; path: string; }[] >([]); const [svgSize, setSvgSize] = React.useState({ width: 0, height: 0 }); const tenantsQuery = useQuery({ queryKey: ["tenants-full-tree-v2"], queryFn: () => fetchTenants(10000, 0), }); const usersQuery = useQuery({ queryKey: ["users", { limit: 5000, offset: 0 }], queryFn: () => fetchUsers(5000, 0), }); const { rootNodes, usersMap } = React.useMemo(() => { if (!tenantsQuery.data?.items || !usersQuery.data?.items) { return { rootNodes: [], usersMap: new Map() }; } const uMap = new Map(); for (const u of usersQuery.data.items) { if (u.status !== "active") continue; const slugs = new Set(); const primarySlug = u.tenantSlug?.toLowerCase() || u.companyCode?.toLowerCase() || ""; if (primarySlug) slugs.add(primarySlug); if (u.joinedTenants && Array.isArray(u.joinedTenants)) { for (const jt of u.joinedTenants) { if (jt.slug) slugs.add(jt.slug.toLowerCase()); } } for (const slug of slugs) { const list = uMap.get(slug) || []; if (!list.some((existing) => existing.id === u.id)) { list.push(u); } uMap.set(slug, list); } } const allTenants = tenantsQuery.data.items; const { subTree: roots } = buildTenantFullTree(allTenants); return { rootNodes: roots, usersMap: uMap }; }, [tenantsQuery.data, usersQuery.data]); const [selectedDept, setSelectedDept] = React.useState("전체"); const depts = React.useMemo(() => { return rootNodes.map((n) => n.name).sort(); }, [rootNodes]); React.useEffect(() => { if (selectedDept !== "전체" && !depts.includes(selectedDept)) { setSelectedDept("전체"); } }, [selectedDept, depts]); const buildHierarchy = (tNode: TenantNode, depth: number): OrgNode => { const slug = tNode.slug.toLowerCase(); const members = usersMap.get(slug) || []; const children = tNode.children.map((c) => buildHierarchy(c, depth + 1)); let recursiveTotal = members.length; for (const child of children) { recursiveTotal += child.totalCount || 0; } return { id: tNode.id, name: tNode.name, level: depth, members, children, totalCount: recursiveTotal, companyCode: slug, type: tNode.type, }; }; const drawLines = React.useCallback(() => { if (!containerRef.current) return; const container = containerRef.current; const rect = container.getBoundingClientRect(); const scrollTop = container.scrollTop; const scrollLeft = container.scrollLeft; const childBoxes = container.querySelectorAll("[data-parent]"); const newLines: { x1: number; y1: number; x2: number; y2: number; key: string; path: string; }[] = []; for (const box of Array.from(childBoxes)) { const parentId = box.getAttribute("data-parent"); if (!parentId) continue; const parent = document.getElementById(parentId); if (!parent) continue; const pRect = parent.getBoundingClientRect(); const cRect = box.getBoundingClientRect(); if (pRect.width === 0 || cRect.width === 0) continue; const parentLevel = Number.parseInt(parent.getAttribute("data-level") || "0", 10); if (parentLevel === 0) { const px = pRect.left + pRect.width / 2 - rect.left + scrollLeft; const py = pRect.bottom - rect.top + scrollTop; const cx = cRect.left + cRect.width / 2 - rect.left + scrollLeft; const cy = cRect.top - rect.top + scrollTop; const midY = py + (cy - py) / 2; newLines.push({ key: `${parentId}->${box.id}`, x1: px, y1: py, x2: cx, y2: cy, path: `M ${px} ${py} L ${px} ${midY} L ${cx} ${midY} L ${cx} ${cy}`, }); } else { const spineX = pRect.left + 24 - rect.left + scrollLeft; const py = pRect.bottom - rect.top + scrollTop; const cx = cRect.left - rect.left + scrollLeft; const cy = cRect.top + 20 - rect.top + scrollTop; newLines.push({ key: `${parentId}->${box.id}`, x1: spineX, y1: py, x2: cx, y2: cy, path: `M ${spineX} ${py} L ${spineX} ${cy} L ${cx} ${cy}`, }); } } setLines(newLines); setSvgSize({ width: Math.max(container.scrollWidth, rect.width), height: Math.max(container.scrollHeight, rect.height), }); }, []); React.useLayoutEffect(() => { const timeout = setTimeout(drawLines, 150); window.addEventListener("resize", drawLines); return () => { clearTimeout(timeout); window.removeEventListener("resize", drawLines); }; }, [drawLines, rootNodes.length, usersMap.size]); if (tenantsQuery.isLoading || usersQuery.isLoading) { return (
로딩 중...
); } const totalUniqueUsers = usersQuery.data?.items?.filter((u) => u.status === "active").length || 0; const targetNodes = selectedDept === "전체" ? rootNodes : rootNodes.filter((n) => n.name === selectedDept); return (

MH Dashboard

조직 현황

{["전체", ...depts].map((d) => ( ))}
총 {totalUniqueUsers}명
{targetNodes.map((tNode) => { const orgNode = buildHierarchy(tNode, 0); return (
); })}
); } // --------------------- Node Rendering --------------------- // const ROLE_ORDER = [ "사장", "부사장", "전무", "상무", "이사", "수석", "책임", "선임", "주임", "사원", ]; function getRankWeight(u: UserSummary) { const role = u.position || ""; let idx = ROLE_ORDER.indexOf(role); if (idx === -1) idx = 99; const isLeader = u.position?.endsWith("장") || u.jobTitle?.endsWith("장"); return (isLeader ? -100 : 0) + idx; } function OrgNodeView({ node, parentId, onToggle, }: { node: OrgNode; parentId: string | null; onToggle: () => void; }) { const [collapsed, setCollapsed] = React.useState(false); const myId = `node-${node.level}-${node.id}`; const toggle = () => { setCollapsed(!collapsed); setTimeout(onToggle, 100); }; const membersToShow = [...node.members].sort((a, b) => getRankWeight(a) - getRankWeight(b)); const isVerticalChildren = node.level >= 1; const isVerticallyStacked = node.level >= 1; const embedChildren = node.children.length > 0 && node.children.every((c) => c.children.length === 0); // Determine header color based on level const headerBgClass = node.level === 0 ? "bg-[#0a2a22]" : "bg-[#2f5547]"; return (
{!collapsed && membersToShow.length > 0 && (
{membersToShow.map((m) => ( ))}
)} {!collapsed && embedChildren && (
{node.children.map((child) => { const childMembers = [...child.members].sort((a, b) => getRankWeight(a) - getRankWeight(b)); return (
{child.name} {child.totalCount || child.members.length}
{childMembers.length > 0 && (
{childMembers.map((m) => ( ))}
)}
); })}
)}
{!collapsed && !embedChildren && node.children.length > 0 && (
{node.children.map((c) => ( ))}
)}
); } function MemberCard({ member, companyCode, }: { member: UserSummary; companyCode?: string }) { const coColor = (() => { const c = (companyCode || member.companyCode || "").toLowerCase(); if (c.includes("hanmac")) return "border-l-[#ef4444]"; if (c.includes("saman")) return "border-l-[#ffb366]"; if (c.includes("ptc")) return "border-l-[#a855f7]"; if (c.includes("baron")) return "border-l-[#3b82f6]"; return "border-l-slate-400"; })(); const roleBadge = member.jobTitle && member.jobTitle !== member.position ? member.jobTitle : member.position?.endsWith("장") ? member.position : null; return (
{member.name} {roleBadge && ( {roleBadge} )}
{member.position || "사원"}
); }