forked from baron/baron-sso
refactor(adminfront): remove internal org-chart and delegate to orgfront
- Remove TenantOrgChartPage and related internal routes - Update AppLayout to render external links for org-chart navigation - Add orgfront service configuration to docker-compose.yaml
This commit is contained in:
@@ -11,7 +11,6 @@ import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdmin
|
|||||||
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
||||||
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
||||||
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
||||||
import { TenantOrgChartPage } from "../features/tenants/routes/TenantOrgChartPage";
|
|
||||||
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
||||||
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
||||||
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
|
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
|
||||||
@@ -41,7 +40,6 @@ export const router = createBrowserRouter(
|
|||||||
{ path: "users/new", element: <UserCreatePage /> },
|
{ path: "users/new", element: <UserCreatePage /> },
|
||||||
{ path: "users/:id", element: <UserDetailPage /> },
|
{ path: "users/:id", element: <UserDetailPage /> },
|
||||||
{ path: "tenants", element: <TenantListPage /> },
|
{ path: "tenants", element: <TenantListPage /> },
|
||||||
{ path: "tenants/org-chart", element: <TenantOrgChartPage /> },
|
|
||||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||||
{
|
{
|
||||||
path: "tenants/:tenantId",
|
path: "tenants/:tenantId",
|
||||||
|
|||||||
@@ -108,8 +108,9 @@ function AppLayout() {
|
|||||||
});
|
});
|
||||||
filteredItems.splice(2, 0, {
|
filteredItems.splice(2, 0, {
|
||||||
label: "ui.admin.nav.org_chart",
|
label: "ui.admin.nav.org_chart",
|
||||||
to: "/tenants/org-chart",
|
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
|
||||||
icon: Network,
|
icon: Network,
|
||||||
|
isExternal: true,
|
||||||
});
|
});
|
||||||
} else if (isTenantAdmin || manageableCount > 0) {
|
} else if (isTenantAdmin || manageableCount > 0) {
|
||||||
if (manageableCount <= 1 && profile?.tenantId) {
|
if (manageableCount <= 1 && profile?.tenantId) {
|
||||||
@@ -130,16 +131,18 @@ function AppLayout() {
|
|||||||
0,
|
0,
|
||||||
{
|
{
|
||||||
label: "ui.admin.nav.org_chart",
|
label: "ui.admin.nav.org_chart",
|
||||||
to: "/tenants/org-chart",
|
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
|
||||||
icon: Network,
|
icon: Network,
|
||||||
|
isExternal: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
|
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
|
||||||
filteredItems.splice(1, 0, {
|
filteredItems.splice(1, 0, {
|
||||||
label: "ui.admin.nav.org_chart",
|
label: "ui.admin.nav.org_chart",
|
||||||
to: "/tenants/org-chart",
|
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
|
||||||
icon: Network,
|
icon: Network,
|
||||||
|
isExternal: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,7 +443,9 @@ function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{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 isOrgChart = location.pathname === "/tenants/org-chart";
|
||||||
const isTenantsRoot = to === "/tenants";
|
const isTenantsRoot = to === "/tenants";
|
||||||
const isCustomActive = isTenantsRoot
|
const isCustomActive = isTenantsRoot
|
||||||
@@ -449,6 +454,21 @@ function AppLayout() {
|
|||||||
? location.pathname === "/"
|
? location.pathname === "/"
|
||||||
: location.pathname.startsWith(to);
|
: location.pathname.startsWith(to);
|
||||||
|
|
||||||
|
if (isExternal) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={to}
|
||||||
|
href={to}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-muted/10 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
<span>{t(label, label)}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
|
|||||||
@@ -1,436 +0,0 @@
|
|||||||
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<HTMLDivElement>(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<string, UserSummary[]>() };
|
|
||||||
}
|
|
||||||
|
|
||||||
const uMap = new Map<string, UserSummary[]>();
|
|
||||||
|
|
||||||
for (const u of usersQuery.data.items) {
|
|
||||||
if (u.status !== "active") continue;
|
|
||||||
|
|
||||||
const slugs = new Set<string>();
|
|
||||||
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<string>("전체");
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="p-8 text-center text-muted-foreground">로딩 중...</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalUniqueUsers = usersQuery.data?.items?.filter((u) => u.status === "active").length || 0;
|
|
||||||
const targetNodes = selectedDept === "전체" ? rootNodes : rootNodes.filter((n) => n.name === selectedDept);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-[calc(100vh-theme(spacing.32))] bg-[#f6efe6] rounded-xl overflow-hidden shadow-sm border border-[#e0d5c1]">
|
|
||||||
<header className="flex flex-col sm:flex-row items-start sm:items-center justify-between px-6 py-4 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%)] border-b border-[#f2c484]/30 z-10 shrink-0">
|
|
||||||
<div className="flex flex-col gap-1 mb-4 sm:mb-0">
|
|
||||||
<p className="text-[#f2c484] text-xs font-bold uppercase tracking-wider">MH Dashboard</p>
|
|
||||||
<h2 className="text-xl font-black text-[#f7f0e4]">조직 현황</h2>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 overflow-x-auto max-w-full custom-scrollbar">
|
|
||||||
{["전체", ...depts].map((d) => (
|
|
||||||
<button
|
|
||||||
key={d}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedDept(d);
|
|
||||||
setLines([]);
|
|
||||||
}}
|
|
||||||
className={`whitespace-nowrap px-4 py-2 text-xs font-bold rounded-full transition-all border ${
|
|
||||||
selectedDept === d
|
|
||||||
? "bg-[linear-gradient(180deg,rgba(255,253,248,0.98),rgba(245,235,221,0.94))] text-[#0a2a22] border-[#f2c484]/40 shadow-sm"
|
|
||||||
: "bg-white/10 text-[#f7f0e4]/70 border-[#f2c484]/30 hover:text-[#f7f0e4] hover:border-[#f2c484]/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{d}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<div className="ml-2 whitespace-nowrap px-4 py-2 bg-[#f2c484]/10 text-[#f2c484] font-black rounded-full border border-[#f2c484]/30 text-xs shadow-sm">
|
|
||||||
총 {totalUniqueUsers}명
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex-1 overflow-auto relative p-8 md:p-12"
|
|
||||||
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))"
|
|
||||||
}}
|
|
||||||
ref={containerRef}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
className="absolute top-0 left-0 pointer-events-none z-0"
|
|
||||||
style={{
|
|
||||||
width: svgSize.width ? `${svgSize.width}px` : "100%",
|
|
||||||
height: svgSize.height ? `${svgSize.height}px` : "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{lines.map((l) => (
|
|
||||||
<path
|
|
||||||
key={l.key}
|
|
||||||
d={l.path}
|
|
||||||
stroke="#bca58a"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
fill="none"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-16 relative z-10 w-max mx-auto items-center pb-32">
|
|
||||||
{targetNodes.map((tNode) => {
|
|
||||||
const orgNode = buildHierarchy(tNode, 0);
|
|
||||||
return (
|
|
||||||
<div key={orgNode.id} className="flex flex-col items-center w-full">
|
|
||||||
<OrgNodeView node={orgNode} parentId={null} onToggle={drawLines} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------------- 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 (
|
|
||||||
<div className={`flex flex-col ${isVerticallyStacked ? "items-start w-full" : "items-center"} mb-4`}>
|
|
||||||
<div
|
|
||||||
id={myId}
|
|
||||||
data-parent={parentId || undefined}
|
|
||||||
data-level={node.level}
|
|
||||||
className={`bg-white border border-[#e0d5c1] rounded-[10px] shadow-sm flex flex-col transition-all shrink-0 ${collapsed ? "opacity-80 scale-[0.98]" : ""}`}
|
|
||||||
style={{ width: "fit-content", minWidth: "320px", maxWidth: "400px" }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${headerBgClass} text-white px-4 py-3 font-black flex justify-center items-center gap-2 cursor-pointer select-none outline-none w-full text-center ${
|
|
||||||
node.level === 0 ? "text-[17px]" : "text-[15px]"
|
|
||||||
} ${membersToShow.length > 0 || embedChildren ? "rounded-t-[9px]" : "rounded-[9px]"}`}
|
|
||||||
onClick={toggle}
|
|
||||||
>
|
|
||||||
<span>{node.name}</span>
|
|
||||||
<span className="text-white/60 font-semibold text-xs bg-black/20 px-2 py-0.5 rounded-full">
|
|
||||||
{node.totalCount || node.members.length}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{!collapsed && membersToShow.length > 0 && (
|
|
||||||
<div className="p-3 grid grid-cols-2 gap-2 w-full">
|
|
||||||
{membersToShow.map((m) => (
|
|
||||||
<MemberCard key={m.id} member={m} companyCode={node.companyCode} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!collapsed && embedChildren && (
|
|
||||||
<div className="flex flex-col gap-3 p-3 pt-0 w-full">
|
|
||||||
{node.children.map((child) => {
|
|
||||||
const childMembers = [...child.members].sort((a, b) => getRankWeight(a) - getRankWeight(b));
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={child.id}
|
|
||||||
className="bg-[#f8f9fa] border border-[#e5e7eb] rounded-[8px] flex flex-col w-full overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="bg-[#7b93ab] text-white text-[13px] font-bold px-3 py-1.5 flex justify-between items-center">
|
|
||||||
<span>{child.name}</span>
|
|
||||||
<span className="bg-black/10 px-1.5 py-0.5 rounded text-[11px] font-medium">{child.totalCount || child.members.length}</span>
|
|
||||||
</div>
|
|
||||||
{childMembers.length > 0 && (
|
|
||||||
<div className="grid grid-cols-2 gap-2 p-2 w-full">
|
|
||||||
{childMembers.map((m) => (
|
|
||||||
<MemberCard key={m.id} member={m} companyCode={child.companyCode} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!collapsed && !embedChildren && node.children.length > 0 && (
|
|
||||||
<div className={`flex ${isVerticalChildren ? "flex-col items-start pl-8 gap-8 w-full" : "flex-row gap-12 justify-center items-start"} relative mt-4`}>
|
|
||||||
{node.children.map((c) => (
|
|
||||||
<OrgNodeView key={c.id} node={c} parentId={myId} onToggle={onToggle} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className={`flex flex-col px-2.5 py-2 rounded-[4px] border border-[#e5e7eb] bg-white border-l-[4px] w-full transition-transform hover:-translate-y-[2px] hover:shadow-md cursor-pointer ${coColor}`}>
|
|
||||||
<div className="flex items-center justify-between min-w-0 w-full mb-1">
|
|
||||||
<div className="flex items-baseline gap-1 truncate shrink-0">
|
|
||||||
<span className="font-black text-[12px] text-[#334155] whitespace-nowrap">
|
|
||||||
{member.name}
|
|
||||||
</span>
|
|
||||||
{roleBadge && (
|
|
||||||
<span className="text-[#2f5547] text-[9px] font-extrabold ml-1 truncate">
|
|
||||||
{roleBadge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center min-w-0 w-full">
|
|
||||||
<span className="text-[#94a3b8] text-[9px] font-medium truncate">
|
|
||||||
{member.position || "사원"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -86,6 +86,26 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- baron_net
|
- 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:
|
userfront:
|
||||||
build:
|
build:
|
||||||
|
|||||||
Reference in New Issue
Block a user