1
0
forked from baron/baron-sso

feat(adminfront): map multi-tenant users onto Tenant Full Tree

Reimplements the Org Chart using the Tenant Full Tree API and maps users to all their respective tenants (including joinedTenants). Multi-tenant users are duplicated correctly across nodes they belong to.
This commit is contained in:
2026-04-13 11:20:41 +09:00
parent b08516f557
commit 9ff49230fc

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,83 @@ 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(() => { const uMap = new Map<string, UserSummary[]>();
if (selectedDept !== "전체" && !depts.includes(selectedDept)) {
setSelectedDept("전체");
}
}, [selectedDept, depts]);
const buildHierarchy = (data: UserWithPath[], depth: number): OrgNode[] => { // Process users to map them to multiple tenants if applicable
if (!data.length) return []; for (const u of usersQuery.data.items) {
const map: Record<string, OrgNode> = {}; if (u.status !== "active") continue;
const groups: OrgNode[] = [];
for (const m of data) { // Extract all associated tenant slugs
const step = m._path[depth]; const slugs = new Set<string>();
if (!step) continue;
if (!map[step.name]) { const primarySlug =
map[step.name] = { u.tenantSlug?.toLowerCase() || u.companyCode?.toLowerCase() || "";
name: step.name, if (primarySlug) {
level: step.level, slugs.add(primarySlug);
members: [],
subData: [],
children: [],
};
groups.push(map[step.name]);
} }
if (m._path.length === depth + 1) {
map[step.name].members.push(m); if (u.joinedTenants && Array.isArray(u.joinedTenants)) {
} else { for (const jt of u.joinedTenants) {
map[step.name].subData.push(m); if (jt.slug) {
slugs.add(jt.slug.toLowerCase());
}
}
}
// Add user to all matching slugs in the map
for (const slug of slugs) {
const list = uMap.get(slug) || [];
// Prevent duplicate user references in the same list
if (!list.some((existing) => existing.id === u.id)) {
list.push(u);
}
uMap.set(slug, list);
} }
} }
return groups.map((g) => ({ const allTenants = tenantsQuery.data.items;
...g, const { subTree: roots } = buildTenantFullTree(allTenants);
children: buildHierarchy(g.subData, depth + 1),
}));
};
const calculateTotalCount = (node: OrgNode): number => { return { rootNodes: roots, usersMap: uMap };
let count = node.members.length; }, [tenantsQuery.data, usersQuery.data]);
for (const c of node.children) {
count += calculateTotalCount(c); 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));
// Calculate recursive total users instead of simple tenant count to account for actual mapped members
let recursiveTotal = members.length;
for (const child of children) {
recursiveTotal += child.totalCount || 0;
} }
node.totalCount = count;
return count; return {
id: tNode.id,
name: tNode.name,
level: depth,
members,
children,
totalCount: recursiveTotal,
companyCode: slug,
type: tNode.type,
};
}; };
const drawLines = React.useCallback(() => { const drawLines = React.useCallback(() => {
@@ -184,28 +189,24 @@ 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]; // Count unique users across the fetched payload
const totalUsers = targetDepts.reduce((acc, d) => { const totalUniqueUsers =
return acc + users.filter((u) => u._path[0]?.name === d).length; usersQuery.data?.items?.filter((u) => u.status === "active").length || 0;
}, 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,30 +215,13 @@ 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} {totalUniqueUsers}
</div> </div>
</div> </div>
</header> </header>
@@ -267,17 +251,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 +287,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 +305,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 +322,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 +354,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 +378,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 +415,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 +427,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]";