forked from baron/baron-sso
Revert "feat(adminfront): rebuild Org Chart strictly based on Tenant Full Tree"
This reverts commit 3bd8724d45.
This commit is contained in:
@@ -3,26 +3,22 @@ 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,
|
||||
fetchTenants,
|
||||
fetchUsers,
|
||||
} from "../../../lib/adminApi";
|
||||
import { type UserSummary, fetchUsers } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||
|
||||
type UserWithPath = UserSummary & { _path: { level: number; name: string }[] };
|
||||
|
||||
interface OrgNode {
|
||||
id: string;
|
||||
name: string;
|
||||
level: number;
|
||||
members: UserSummary[];
|
||||
members: UserWithPath[];
|
||||
subData: UserWithPath[];
|
||||
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<
|
||||
{
|
||||
@@ -36,55 +32,82 @@ export function TenantOrgChartPage() {
|
||||
>([]);
|
||||
const [svgSize, setSvgSize] = React.useState({ width: 0, height: 0 });
|
||||
|
||||
const tenantsQuery = useQuery({
|
||||
queryKey: ["tenants-full-tree-v2"],
|
||||
queryFn: () => fetchTenants(10000, 0),
|
||||
});
|
||||
|
||||
const usersQuery = useQuery({
|
||||
const query = useQuery({
|
||||
queryKey: ["users", { limit: 5000, offset: 0 }],
|
||||
queryFn: () => fetchUsers(5000, 0),
|
||||
});
|
||||
|
||||
const { rootNodes, usersMap } = React.useMemo(() => {
|
||||
if (!tenantsQuery.data?.items || !usersQuery.data?.items) {
|
||||
return { rootNodes: [], usersMap: new Map<string, UserSummary[]>() };
|
||||
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 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;
|
||||
return groups.map((g) => ({
|
||||
...g,
|
||||
children: buildHierarchy(g.subData, depth + 1),
|
||||
}));
|
||||
};
|
||||
|
||||
const list = uMap.get(slug) || [];
|
||||
list.push(u);
|
||||
uMap.set(slug, list);
|
||||
const calculateTotalCount = (node: OrgNode): number => {
|
||||
let count = node.members.length;
|
||||
for (const c of node.children) {
|
||||
count += calculateTotalCount(c);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
node.totalCount = count;
|
||||
return count;
|
||||
};
|
||||
|
||||
const drawLines = React.useCallback(() => {
|
||||
@@ -161,22 +184,28 @@ export function TenantOrgChartPage() {
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const _forceTrigger = rootNodes.length + usersMap.size;
|
||||
// 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 timeout = setTimeout(drawLines, 150);
|
||||
window.addEventListener("resize", drawLines);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
window.removeEventListener("resize", drawLines);
|
||||
};
|
||||
}, [drawLines, rootNodes.length, usersMap.size]);
|
||||
}, [drawLines, selectedDept, users]);
|
||||
|
||||
if (tenantsQuery.isLoading || usersQuery.isLoading) {
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center text-muted-foreground">로딩 중...</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalUsers = usersQuery.data?.items?.length || 0;
|
||||
const targetDepts = selectedDept === "전체" ? depts : [selectedDept];
|
||||
const totalUsers = targetDepts.reduce((acc, d) => {
|
||||
return acc + users.filter((u) => u._path[0]?.name === d).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">
|
||||
@@ -185,11 +214,28 @@ 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>
|
||||
@@ -221,15 +267,17 @@ export function TenantOrgChartPage() {
|
||||
</svg>
|
||||
|
||||
<div className="flex flex-col gap-16 relative z-10 w-max mx-auto items-center pb-24">
|
||||
{rootNodes.map((tNode) => {
|
||||
const orgNode = buildHierarchy(tNode, 0);
|
||||
{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);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={orgNode.id}
|
||||
className="flex flex-col items-center w-full"
|
||||
>
|
||||
<div key={dName} className="flex flex-col items-center w-full">
|
||||
<OrgNodeView
|
||||
node={orgNode}
|
||||
node={dNode}
|
||||
parentId={null}
|
||||
onToggle={drawLines}
|
||||
/>
|
||||
@@ -257,7 +305,7 @@ const ROLE_ORDER = [
|
||||
"사원",
|
||||
];
|
||||
|
||||
function getRankWeight(u: UserSummary) {
|
||||
function getRankWeight(u: UserWithPath) {
|
||||
const role = u.position || "";
|
||||
let idx = ROLE_ORDER.indexOf(role);
|
||||
if (idx === -1) idx = 99;
|
||||
@@ -275,7 +323,7 @@ function OrgNodeView({
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = React.useState(false);
|
||||
const myId = `node-${node.level}-${node.id}`;
|
||||
const myId = `node-${node.level}-${node.name.replace(/\s/g, "")}`;
|
||||
|
||||
const toggle = () => {
|
||||
setCollapsed(!collapsed);
|
||||
@@ -292,9 +340,10 @@ function OrgNodeView({
|
||||
(a, b) => getRankWeight(a) - getRankWeight(b),
|
||||
);
|
||||
|
||||
const isVerticalChildren = node.level >= 1;
|
||||
const isVerticallyStacked = node.level >= 1;
|
||||
const isVerticalChildren = node.level >= 1; // Children of Level 1+ are vertical
|
||||
const isVerticallyStacked = node.level >= 1; // Level 1+ are vertically stacked inside parent
|
||||
|
||||
// 하위 조직이 모두 말단(Leaf) 조직일 경우, 부모 박스 내부에 회색 그룹으로 묶어서(임베딩) 표시합니다.
|
||||
const embedChildren =
|
||||
node.children.length > 0 &&
|
||||
node.children.every((c) => c.children.length === 0);
|
||||
@@ -324,18 +373,14 @@ function OrgNodeView({
|
||||
>
|
||||
<span>{node.name}</span>
|
||||
<span className="text-slate-400 font-normal text-xs ml-4">
|
||||
({node.totalCount || node.members.length})
|
||||
({node.totalCount})
|
||||
</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}
|
||||
companyCode={node.companyCode}
|
||||
/>
|
||||
<MemberCard key={m.id} member={m} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -348,23 +393,19 @@ function OrgNodeView({
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={child.id}
|
||||
key={child.name}
|
||||
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.members.length})
|
||||
({child.totalCount})
|
||||
</span>
|
||||
</div>
|
||||
{childMembers.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-1 w-full">
|
||||
{childMembers.map((m) => (
|
||||
<MemberCard
|
||||
key={m.id}
|
||||
member={m}
|
||||
companyCode={child.companyCode}
|
||||
/>
|
||||
<MemberCard key={m.id} member={m} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -385,7 +426,7 @@ function OrgNodeView({
|
||||
>
|
||||
{node.children.map((c) => (
|
||||
<OrgNodeView
|
||||
key={c.id}
|
||||
key={c.name}
|
||||
node={c}
|
||||
parentId={myId}
|
||||
onToggle={onToggle}
|
||||
@@ -397,12 +438,9 @@ function OrgNodeView({
|
||||
);
|
||||
}
|
||||
|
||||
function MemberCard({
|
||||
member,
|
||||
companyCode,
|
||||
}: { member: UserSummary; companyCode?: string }) {
|
||||
function MemberCard({ member }: { member: UserWithPath }) {
|
||||
const coColor = (() => {
|
||||
const c = (companyCode || member.companyCode || "").toLowerCase();
|
||||
const c = (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