1
0
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:
2026-04-13 11:01:44 +09:00
parent ea44785ef0
commit 3bd8724d45

View File

@@ -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]";