forked from baron/baron-sso
feat(adminfront): rebuild Org Chart strictly based on Tenant Full Tree
Replaces the legacy user-department-based org chart generation with one driven by the actual Tenant hierarchy from the database (via fetchTenants) while still mapping users dynamically to their respective nodes.
This commit is contained in:
@@ -3,22 +3,26 @@ 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, fetchUsers } from "../../../lib/adminApi";
|
||||
import {
|
||||
type UserSummary,
|
||||
fetchTenants,
|
||||
fetchUsers,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
type UserWithPath = UserSummary & { _path: { level: number; name: string }[] };
|
||||
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||
|
||||
interface OrgNode {
|
||||
id: string;
|
||||
name: string;
|
||||
level: number;
|
||||
members: UserWithPath[];
|
||||
subData: UserWithPath[];
|
||||
members: UserSummary[];
|
||||
children: OrgNode[];
|
||||
totalCount?: number;
|
||||
companyCode?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export function TenantOrgChartPage() {
|
||||
const [selectedDept, setSelectedDept] = React.useState<string>("전체");
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [lines, setLines] = React.useState<
|
||||
{
|
||||
@@ -32,82 +36,55 @@ export function TenantOrgChartPage() {
|
||||
>([]);
|
||||
const [svgSize, setSvgSize] = React.useState({ width: 0, height: 0 });
|
||||
|
||||
const query = useQuery({
|
||||
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 users = React.useMemo(() => {
|
||||
if (!query.data?.items) return [];
|
||||
return query.data.items
|
||||
.filter((u) => u.status === "active")
|
||||
.map((u) => {
|
||||
const deptStr = u.department || "";
|
||||
const parts = deptStr.includes(" > ")
|
||||
? deptStr.split(" > ")
|
||||
: deptStr.split("/");
|
||||
|
||||
return {
|
||||
...u,
|
||||
_path: parts
|
||||
.map((name, i) => ({ level: i, name: name.trim() }))
|
||||
.filter((p) => p.name),
|
||||
};
|
||||
});
|
||||
}, [query.data]);
|
||||
|
||||
const depts = React.useMemo(() => {
|
||||
const s = new Set<string>();
|
||||
for (const u of users) {
|
||||
if (u._path[0]) s.add(u._path[0].name);
|
||||
}
|
||||
return Array.from(s).sort();
|
||||
}, [users]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedDept !== "전체" && !depts.includes(selectedDept)) {
|
||||
setSelectedDept("전체");
|
||||
}
|
||||
}, [selectedDept, depts]);
|
||||
|
||||
const buildHierarchy = (data: UserWithPath[], depth: number): OrgNode[] => {
|
||||
if (!data.length) return [];
|
||||
const map: Record<string, OrgNode> = {};
|
||||
const groups: OrgNode[] = [];
|
||||
|
||||
for (const m of data) {
|
||||
const step = m._path[depth];
|
||||
if (!step) continue;
|
||||
if (!map[step.name]) {
|
||||
map[step.name] = {
|
||||
name: step.name,
|
||||
level: step.level,
|
||||
members: [],
|
||||
subData: [],
|
||||
children: [],
|
||||
};
|
||||
groups.push(map[step.name]);
|
||||
}
|
||||
if (m._path.length === depth + 1) {
|
||||
map[step.name].members.push(m);
|
||||
} else {
|
||||
map[step.name].subData.push(m);
|
||||
}
|
||||
const { rootNodes, usersMap } = React.useMemo(() => {
|
||||
if (!tenantsQuery.data?.items || !usersQuery.data?.items) {
|
||||
return { rootNodes: [], usersMap: new Map<string, UserSummary[]>() };
|
||||
}
|
||||
|
||||
return groups.map((g) => ({
|
||||
...g,
|
||||
children: buildHierarchy(g.subData, depth + 1),
|
||||
}));
|
||||
};
|
||||
const uMap = new Map<string, UserSummary[]>();
|
||||
for (const u of usersQuery.data.items) {
|
||||
if (u.status !== "active") continue;
|
||||
const slug =
|
||||
u.tenantSlug?.toLowerCase() || u.companyCode?.toLowerCase() || "";
|
||||
if (!slug) continue;
|
||||
|
||||
const calculateTotalCount = (node: OrgNode): number => {
|
||||
let count = node.members.length;
|
||||
for (const c of node.children) {
|
||||
count += calculateTotalCount(c);
|
||||
const list = uMap.get(slug) || [];
|
||||
list.push(u);
|
||||
uMap.set(slug, list);
|
||||
}
|
||||
node.totalCount = count;
|
||||
return count;
|
||||
|
||||
const allTenants = tenantsQuery.data.items;
|
||||
const { subTree: roots } = buildTenantFullTree(allTenants);
|
||||
|
||||
return { rootNodes: roots, usersMap: uMap };
|
||||
}, [tenantsQuery.data, usersQuery.data]);
|
||||
|
||||
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));
|
||||
|
||||
return {
|
||||
id: tNode.id,
|
||||
name: tNode.name,
|
||||
level: depth,
|
||||
members,
|
||||
children,
|
||||
totalCount: tNode.recursiveMemberCount,
|
||||
companyCode: slug,
|
||||
type: tNode.type,
|
||||
};
|
||||
};
|
||||
|
||||
const drawLines = React.useCallback(() => {
|
||||
@@ -184,28 +161,22 @@ export function TenantOrgChartPage() {
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
// Biome requires used variables. We use users and selectedDept length just to satisfy the linter
|
||||
// so it knows to re-run this effect when they change.
|
||||
const _forceTrigger = selectedDept + users.length;
|
||||
|
||||
const _forceTrigger = rootNodes.length + usersMap.size;
|
||||
const timeout = setTimeout(drawLines, 150);
|
||||
window.addEventListener("resize", drawLines);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
window.removeEventListener("resize", drawLines);
|
||||
};
|
||||
}, [drawLines, selectedDept, users]);
|
||||
}, [drawLines, rootNodes.length, usersMap.size]);
|
||||
|
||||
if (query.isLoading) {
|
||||
if (tenantsQuery.isLoading || usersQuery.isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center text-muted-foreground">로딩 중...</div>
|
||||
);
|
||||
}
|
||||
|
||||
const targetDepts = selectedDept === "전체" ? depts : [selectedDept];
|
||||
const totalUsers = targetDepts.reduce((acc, d) => {
|
||||
return acc + users.filter((u) => u._path[0]?.name === d).length;
|
||||
}, 0);
|
||||
const totalUsers = usersQuery.data?.items?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-theme(spacing.32))] bg-slate-50 rounded-xl overflow-hidden shadow-sm border border-slate-200">
|
||||
@@ -214,28 +185,11 @@ export function TenantOrgChartPage() {
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-800">조직도</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
조직 구조를 효율적인 세로 계층형으로 시각화합니다.
|
||||
실제 테넌트 계층 구조를 기반으로 조직도를 시각화합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto max-w-2xl custom-scrollbar pb-1">
|
||||
{["전체", ...depts].map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedDept(d);
|
||||
setLines([]); // Reset lines during switch
|
||||
}}
|
||||
className={`whitespace-nowrap px-4 py-1.5 text-sm font-semibold rounded-full border transition-colors ${
|
||||
selectedDept === d
|
||||
? "bg-slate-800 text-white border-slate-800"
|
||||
: "bg-white text-slate-600 border-slate-200 hover:bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-2 whitespace-nowrap px-4 py-1.5 bg-blue-50 text-blue-700 font-bold rounded-full border border-blue-200 text-sm flex items-center">
|
||||
총 {totalUsers}명
|
||||
</div>
|
||||
@@ -267,17 +221,15 @@ export function TenantOrgChartPage() {
|
||||
</svg>
|
||||
|
||||
<div className="flex flex-col gap-16 relative z-10 w-max mx-auto items-center pb-24">
|
||||
{targetDepts.map((dName) => {
|
||||
const dData = users.filter((u) => u._path[0]?.name === dName);
|
||||
const hierarchy = buildHierarchy(dData, 0);
|
||||
const dNode = hierarchy[0];
|
||||
if (!dNode) return null;
|
||||
calculateTotalCount(dNode);
|
||||
|
||||
{rootNodes.map((tNode) => {
|
||||
const orgNode = buildHierarchy(tNode, 0);
|
||||
return (
|
||||
<div key={dName} className="flex flex-col items-center w-full">
|
||||
<div
|
||||
key={orgNode.id}
|
||||
className="flex flex-col items-center w-full"
|
||||
>
|
||||
<OrgNodeView
|
||||
node={dNode}
|
||||
node={orgNode}
|
||||
parentId={null}
|
||||
onToggle={drawLines}
|
||||
/>
|
||||
@@ -305,7 +257,7 @@ const ROLE_ORDER = [
|
||||
"사원",
|
||||
];
|
||||
|
||||
function getRankWeight(u: UserWithPath) {
|
||||
function getRankWeight(u: UserSummary) {
|
||||
const role = u.position || "";
|
||||
let idx = ROLE_ORDER.indexOf(role);
|
||||
if (idx === -1) idx = 99;
|
||||
@@ -323,7 +275,7 @@ function OrgNodeView({
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = React.useState(false);
|
||||
const myId = `node-${node.level}-${node.name.replace(/\s/g, "")}`;
|
||||
const myId = `node-${node.level}-${node.id}`;
|
||||
|
||||
const toggle = () => {
|
||||
setCollapsed(!collapsed);
|
||||
@@ -340,10 +292,9 @@ function OrgNodeView({
|
||||
(a, b) => getRankWeight(a) - getRankWeight(b),
|
||||
);
|
||||
|
||||
const isVerticalChildren = node.level >= 1; // Children of Level 1+ are vertical
|
||||
const isVerticallyStacked = node.level >= 1; // Level 1+ are vertically stacked inside parent
|
||||
const isVerticalChildren = node.level >= 1;
|
||||
const isVerticallyStacked = node.level >= 1;
|
||||
|
||||
// 하위 조직이 모두 말단(Leaf) 조직일 경우, 부모 박스 내부에 회색 그룹으로 묶어서(임베딩) 표시합니다.
|
||||
const embedChildren =
|
||||
node.children.length > 0 &&
|
||||
node.children.every((c) => c.children.length === 0);
|
||||
@@ -373,14 +324,18 @@ function OrgNodeView({
|
||||
>
|
||||
<span>{node.name}</span>
|
||||
<span className="text-slate-400 font-normal text-xs ml-4">
|
||||
({node.totalCount})
|
||||
({node.totalCount || node.members.length})
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{!collapsed && membersToShow.length > 0 && (
|
||||
<div className="p-1.5 pt-0 grid grid-cols-2 gap-1 w-full">
|
||||
{membersToShow.map((m) => (
|
||||
<MemberCard key={m.id} member={m} />
|
||||
<MemberCard
|
||||
key={m.id}
|
||||
member={m}
|
||||
companyCode={node.companyCode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -393,19 +348,23 @@ function OrgNodeView({
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={child.name}
|
||||
key={child.id}
|
||||
className="bg-slate-50 border border-slate-200 rounded-lg p-1.5 flex flex-col gap-1.5 w-full"
|
||||
>
|
||||
<div className="text-[11px] font-bold text-slate-600 flex justify-between px-1">
|
||||
<span>{child.name}</span>
|
||||
<span className="text-slate-400 font-normal">
|
||||
({child.totalCount})
|
||||
({child.totalCount || child.members.length})
|
||||
</span>
|
||||
</div>
|
||||
{childMembers.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-1 w-full">
|
||||
{childMembers.map((m) => (
|
||||
<MemberCard key={m.id} member={m} />
|
||||
<MemberCard
|
||||
key={m.id}
|
||||
member={m}
|
||||
companyCode={child.companyCode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -426,7 +385,7 @@ function OrgNodeView({
|
||||
>
|
||||
{node.children.map((c) => (
|
||||
<OrgNodeView
|
||||
key={c.name}
|
||||
key={c.id}
|
||||
node={c}
|
||||
parentId={myId}
|
||||
onToggle={onToggle}
|
||||
@@ -438,9 +397,12 @@ function OrgNodeView({
|
||||
);
|
||||
}
|
||||
|
||||
function MemberCard({ member }: { member: UserWithPath }) {
|
||||
function MemberCard({
|
||||
member,
|
||||
companyCode,
|
||||
}: { member: UserSummary; companyCode?: string }) {
|
||||
const coColor = (() => {
|
||||
const c = (member.companyCode || "").toLowerCase();
|
||||
const c = (companyCode || member.companyCode || "").toLowerCase();
|
||||
if (c.includes("hanmac")) return "bg-[#1E3A8A] text-white border-[#1E3A8A]";
|
||||
if (c.includes("saman")) return "bg-[#047857] text-white border-[#047857]";
|
||||
if (c.includes("ptc")) return "bg-[#C2410C] text-white border-[#C2410C]";
|
||||
|
||||
Reference in New Issue
Block a user