- {navItems.map(({ label, to, icon: Icon }) => {
+ {navItems.map((item) => {
+ const { label, to, icon: Icon } = item;
+ const isExternal = (item as any).isExternal;
const isOrgChart = location.pathname === "/tenants/org-chart";
const isTenantsRoot = to === "/tenants";
const isCustomActive = isTenantsRoot
@@ -449,6 +454,21 @@ function AppLayout() {
? location.pathname === "/"
: location.pathname.startsWith(to);
+ if (isExternal) {
+ return (
+
+
+ {t(label, label)}
+
+ );
+ }
+
return (
(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 (
-
-
-
-
-
-
-
- {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 || "사원"}
-
-
-
- );
-}
-
diff --git a/docker-compose.yaml b/docker-compose.yaml
index e28bce48..b4920b0b 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -86,6 +86,26 @@ services:
networks:
- baron_net
+ orgfront:
+ build:
+ context: ../baron-orgchart
+ dockerfile: Dockerfile
+ container_name: baron_orgfront
+ env_file:
+ - .env
+ environment:
+ - APP_ENV=${APP_ENV:-development}
+ - API_PROXY_TARGET=http://baron_backend:3000
+ - USERFRONT_URL=${USERFRONT_URL}
+ ports:
+ - "${ORGFRONT_PORT:-5175}:5175"
+ volumes:
+ - ../baron-orgchart:/app
+ - ./locales:/locales
+ - /app/node_modules
+ networks:
+ - baron_net
+
userfront:
build: