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 * 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]";
|
||||||
|
|||||||
Reference in New Issue
Block a user