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 * as React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Button } from "../../../components/ui/button"; 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"; import { t } from "../../../lib/i18n";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
type UserWithPath = UserSummary & { _path: { level: number; name: string }[] };
interface OrgNode { interface OrgNode {
id: string;
name: string; name: string;
level: number; level: number;
members: UserWithPath[]; members: UserSummary[];
subData: UserWithPath[];
children: OrgNode[]; children: OrgNode[];
totalCount?: number; totalCount?: number;
companyCode?: string;
type?: string;
} }
export function TenantOrgChartPage() { export function TenantOrgChartPage() {
const [selectedDept, setSelectedDept] = React.useState<string>("전체");
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
const [lines, setLines] = React.useState< const [lines, setLines] = React.useState<
{ {
@@ -32,82 +36,55 @@ export function TenantOrgChartPage() {
>([]); >([]);
const [svgSize, setSvgSize] = React.useState({ width: 0, height: 0 }); 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 }], queryKey: ["users", { limit: 5000, offset: 0 }],
queryFn: () => fetchUsers(5000, 0), queryFn: () => fetchUsers(5000, 0),
}); });
const users = React.useMemo(() => { const { rootNodes, usersMap } = React.useMemo(() => {
if (!query.data?.items) return []; if (!tenantsQuery.data?.items || !usersQuery.data?.items) {
return query.data.items return { rootNodes: [], usersMap: new Map<string, UserSummary[]>() };
.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);
}
} }
return groups.map((g) => ({ const uMap = new Map<string, UserSummary[]>();
...g, for (const u of usersQuery.data.items) {
children: buildHierarchy(g.subData, depth + 1), if (u.status !== "active") continue;
})); const slug =
}; u.tenantSlug?.toLowerCase() || u.companyCode?.toLowerCase() || "";
if (!slug) continue;
const calculateTotalCount = (node: OrgNode): number => { const list = uMap.get(slug) || [];
let count = node.members.length; list.push(u);
for (const c of node.children) { uMap.set(slug, list);
count += calculateTotalCount(c);
} }
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(() => { const drawLines = React.useCallback(() => {
@@ -184,28 +161,22 @@ export function TenantOrgChartPage() {
}, []); }, []);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
// Biome requires used variables. We use users and selectedDept length just to satisfy the linter const _forceTrigger = rootNodes.length + usersMap.size;
// so it knows to re-run this effect when they change.
const _forceTrigger = selectedDept + users.length;
const timeout = setTimeout(drawLines, 150); const timeout = setTimeout(drawLines, 150);
window.addEventListener("resize", drawLines); window.addEventListener("resize", drawLines);
return () => { return () => {
clearTimeout(timeout); clearTimeout(timeout);
window.removeEventListener("resize", drawLines); window.removeEventListener("resize", drawLines);
}; };
}, [drawLines, selectedDept, users]); }, [drawLines, rootNodes.length, usersMap.size]);
if (query.isLoading) { if (tenantsQuery.isLoading || usersQuery.isLoading) {
return ( return (
<div className="p-8 text-center text-muted-foreground"> ...</div> <div className="p-8 text-center text-muted-foreground"> ...</div>
); );
} }
const targetDepts = selectedDept === "전체" ? depts : [selectedDept]; const totalUsers = usersQuery.data?.items?.length || 0;
const totalUsers = targetDepts.reduce((acc, d) => {
return acc + users.filter((u) => u._path[0]?.name === d).length;
}, 0);
return ( 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"> <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> <div>
<h2 className="text-xl font-bold text-slate-800"></h2> <h2 className="text-xl font-bold text-slate-800"></h2>
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
. .
</p> </p>
</div> </div>
</div> </div>
<div className="flex gap-2 overflow-x-auto max-w-2xl custom-scrollbar pb-1"> <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"> <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} {totalUsers}
</div> </div>
@@ -267,17 +221,15 @@ export function TenantOrgChartPage() {
</svg> </svg>
<div className="flex flex-col gap-16 relative z-10 w-max mx-auto items-center pb-24"> <div className="flex flex-col gap-16 relative z-10 w-max mx-auto items-center pb-24">
{targetDepts.map((dName) => { {rootNodes.map((tNode) => {
const dData = users.filter((u) => u._path[0]?.name === dName); const orgNode = buildHierarchy(tNode, 0);
const hierarchy = buildHierarchy(dData, 0);
const dNode = hierarchy[0];
if (!dNode) return null;
calculateTotalCount(dNode);
return ( 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 <OrgNodeView
node={dNode} node={orgNode}
parentId={null} parentId={null}
onToggle={drawLines} onToggle={drawLines}
/> />
@@ -305,7 +257,7 @@ const ROLE_ORDER = [
"사원", "사원",
]; ];
function getRankWeight(u: UserWithPath) { function getRankWeight(u: UserSummary) {
const role = u.position || ""; const role = u.position || "";
let idx = ROLE_ORDER.indexOf(role); let idx = ROLE_ORDER.indexOf(role);
if (idx === -1) idx = 99; if (idx === -1) idx = 99;
@@ -323,7 +275,7 @@ function OrgNodeView({
onToggle: () => void; onToggle: () => void;
}) { }) {
const [collapsed, setCollapsed] = React.useState(false); 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 = () => { const toggle = () => {
setCollapsed(!collapsed); setCollapsed(!collapsed);
@@ -340,10 +292,9 @@ function OrgNodeView({
(a, b) => getRankWeight(a) - getRankWeight(b), (a, b) => getRankWeight(a) - getRankWeight(b),
); );
const isVerticalChildren = node.level >= 1; // Children of Level 1+ are vertical const isVerticalChildren = node.level >= 1;
const isVerticallyStacked = node.level >= 1; // Level 1+ are vertically stacked inside parent const isVerticallyStacked = node.level >= 1;
// 하위 조직이 모두 말단(Leaf) 조직일 경우, 부모 박스 내부에 회색 그룹으로 묶어서(임베딩) 표시합니다.
const embedChildren = const embedChildren =
node.children.length > 0 && node.children.length > 0 &&
node.children.every((c) => c.children.length === 0); node.children.every((c) => c.children.length === 0);
@@ -373,14 +324,18 @@ function OrgNodeView({
> >
<span>{node.name}</span> <span>{node.name}</span>
<span className="text-slate-400 font-normal text-xs ml-4"> <span className="text-slate-400 font-normal text-xs ml-4">
({node.totalCount}) ({node.totalCount || node.members.length})
</span> </span>
</button> </button>
{!collapsed && membersToShow.length > 0 && ( {!collapsed && membersToShow.length > 0 && (
<div className="p-1.5 pt-0 grid grid-cols-2 gap-1 w-full"> <div className="p-1.5 pt-0 grid grid-cols-2 gap-1 w-full">
{membersToShow.map((m) => ( {membersToShow.map((m) => (
<MemberCard key={m.id} member={m} /> <MemberCard
key={m.id}
member={m}
companyCode={node.companyCode}
/>
))} ))}
</div> </div>
)} )}
@@ -393,19 +348,23 @@ function OrgNodeView({
); );
return ( return (
<div <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" 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"> <div className="text-[11px] font-bold text-slate-600 flex justify-between px-1">
<span>{child.name}</span> <span>{child.name}</span>
<span className="text-slate-400 font-normal"> <span className="text-slate-400 font-normal">
({child.totalCount}) ({child.totalCount || child.members.length})
</span> </span>
</div> </div>
{childMembers.length > 0 && ( {childMembers.length > 0 && (
<div className="grid grid-cols-2 gap-1 w-full"> <div className="grid grid-cols-2 gap-1 w-full">
{childMembers.map((m) => ( {childMembers.map((m) => (
<MemberCard key={m.id} member={m} /> <MemberCard
key={m.id}
member={m}
companyCode={child.companyCode}
/>
))} ))}
</div> </div>
)} )}
@@ -426,7 +385,7 @@ function OrgNodeView({
> >
{node.children.map((c) => ( {node.children.map((c) => (
<OrgNodeView <OrgNodeView
key={c.name} key={c.id}
node={c} node={c}
parentId={myId} parentId={myId}
onToggle={onToggle} onToggle={onToggle}
@@ -438,9 +397,12 @@ function OrgNodeView({
); );
} }
function MemberCard({ member }: { member: UserWithPath }) { function MemberCard({
member,
companyCode,
}: { member: UserSummary; companyCode?: string }) {
const coColor = (() => { 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("hanmac")) return "bg-[#1E3A8A] text-white border-[#1E3A8A]";
if (c.includes("saman")) return "bg-[#047857] text-white border-[#047857]"; if (c.includes("saman")) return "bg-[#047857] text-white border-[#047857]";
if (c.includes("ptc")) return "bg-[#C2410C] text-white border-[#C2410C]"; if (c.includes("ptc")) return "bg-[#C2410C] text-white border-[#C2410C]";