1
0
forked from baron/baron-sso

조직도 기능 추가

This commit is contained in:
2026-04-10 11:38:47 +09:00
parent 6971b69b79
commit 5211842d47
28 changed files with 1845 additions and 447 deletions

View File

@@ -0,0 +1,417 @@
import { useQuery } from "@tanstack/react-query";
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 { t } from "../../../lib/i18n";
type UserWithPath = UserSummary & { _path: { level: number; name: string }[] };
interface OrgNode {
name: string;
level: number;
members: UserWithPath[];
subData: UserWithPath[];
children: OrgNode[];
totalCount?: number;
}
export function TenantOrgChartPage() {
const [selectedDept, setSelectedDept] = React.useState<string>("전체");
const containerRef = React.useRef<HTMLDivElement>(null);
const [lines, setLines] = React.useState<
{
x1: number;
y1: number;
x2: number;
y2: number;
key: string;
path: string;
}[]
>([]);
const [svgSize, setSvgSize] = React.useState({ width: 0, height: 0 });
const query = 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 parts = (u.department || "").split("/").filter(Boolean);
return {
...u,
_path: parts.map((name, i) => ({ level: i, 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) => ({
...g,
children: buildHierarchy(g.subData, depth + 1),
}));
};
const calculateTotalCount = (node: OrgNode): number => {
let count = node.members.length;
for (const c of node.children) {
count += calculateTotalCount(c);
}
node.totalCount = count;
return count;
};
const drawLines = React.useCallback(() => {
if (!containerRef.current) return;
const container = containerRef.current;
const rect = container.getBoundingClientRect();
const scrollTop = container.scrollTop;
const scrollLeft = container.scrollLeft;
const childBoxes = container.querySelectorAll("[data-parent]");
const newLines: any[] = [];
for (const box of Array.from(childBoxes)) {
const parentId = box.getAttribute("data-parent");
if (!parentId) continue;
const parent = document.getElementById(parentId);
if (!parent) continue;
const pRect = parent.getBoundingClientRect();
const cRect = box.getBoundingClientRect();
if (pRect.width === 0 || cRect.width === 0) continue;
const parentLevel = Number.parseInt(parent.getAttribute("data-level") || "0", 10);
if (parentLevel === 0) {
// Horizontal fork for Level 0 -> Level 1
const px = pRect.left + pRect.width / 2 - rect.left + scrollLeft;
const py = pRect.bottom - rect.top + scrollTop;
const cx = cRect.left + cRect.width / 2 - rect.left + scrollLeft;
const cy = cRect.top - rect.top + scrollTop;
const midY = py + (cy - py) / 2;
newLines.push({
key: `${parentId}->${box.id}`,
x1: px, y1: py, x2: cx, y2: cy,
path: `M ${px} ${py} L ${px} ${midY} L ${cx} ${midY} L ${cx} ${cy}`,
});
} else {
// Vertical spine for Level >= 1 -> Level >= 2
const spineX = pRect.left + 32 - rect.left + scrollLeft; // 32px indent from parent's left edge
const py = pRect.bottom - rect.top + scrollTop;
const cx = cRect.left - rect.left + scrollLeft; // Child's left edge
const cy = cRect.top + 24 - rect.top + scrollTop; // Middle of child's header
newLines.push({
key: `${parentId}->${box.id}`,
x1: spineX, y1: py, x2: cx, y2: cy,
path: `M ${spineX} ${py} L ${spineX} ${cy} L ${cx} ${cy}`,
});
}
}
setLines(newLines);
setSvgSize({
width: Math.max(container.scrollWidth, rect.width),
height: Math.max(container.scrollHeight, rect.height),
});
}, []);
React.useLayoutEffect(() => {
const timeout = setTimeout(drawLines, 150);
window.addEventListener("resize", drawLines);
return () => {
clearTimeout(timeout);
window.removeEventListener("resize", drawLines);
};
}, [drawLines, selectedDept, users]);
if (query.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);
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">
<header className="flex items-center justify-between px-6 py-4 bg-white border-b border-slate-200 shadow-sm z-10 shrink-0">
<div className="flex items-center gap-4">
<Button variant="outline" size="icon" asChild className="h-8 w-8">
<Link to="/tenants">
<ChevronLeft size={16} />
</Link>
</Button>
<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>
</div>
</header>
<div
className="flex-1 overflow-auto relative p-8 bg-[#f8fafc]"
ref={containerRef}
>
<svg
aria-hidden="true"
className="absolute top-0 left-0 pointer-events-none z-0"
style={{
width: svgSize.width ? `${svgSize.width}px` : "100%",
height: svgSize.height ? `${svgSize.height}px` : "100%"
}}
>
{lines.map((l) => (
<path
key={l.key}
d={l.path}
stroke="#cbd5e1"
strokeWidth="2"
fill="none"
strokeLinejoin="round"
/>
))}
</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);
return (
<div key={dName} className="flex flex-col items-center w-full">
<OrgNodeView
node={dNode}
parentId={null}
onToggle={drawLines}
/>
</div>
);
})}
</div>
</div>
</div>
);
}
// --------------------- Node Rendering --------------------- //
const ROLE_ORDER = [
"사장",
"부사장",
"전무",
"상무",
"이사",
"수석",
"책임",
"선임",
"주임",
"사원",
];
function getRankWeight(u: UserWithPath) {
const role = u.position || "";
let idx = ROLE_ORDER.indexOf(role);
if (idx === -1) idx = 99;
const isLeader = u.position?.endsWith("장") || u.jobTitle?.endsWith("장");
return (isLeader ? -100 : 0) + idx;
}
function OrgNodeView({
node,
parentId,
onToggle,
}: {
node: OrgNode;
parentId: string | null;
onToggle: () => void;
}) {
const [collapsed, setCollapsed] = React.useState(false);
const myId = `node-${node.level}-${node.name.replace(/\s/g, "")}`;
const toggle = () => {
setCollapsed(!collapsed);
setTimeout(onToggle, 100);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
toggle();
}
};
const membersToShow = [...node.members].sort(
(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
return (
<div className={`flex flex-col ${isVerticallyStacked ? "items-start w-full" : "items-center"} mb-3`}>
<div
id={myId}
data-parent={parentId || undefined}
data-level={node.level}
className={`bg-white border rounded-xl shadow-sm mb-4 flex flex-col transition-all shrink-0 ${
node.level === 0 ? "border-slate-800 border-t-4" : "border-slate-300"
} ${collapsed ? "opacity-80" : ""}`}
style={{ width: "fit-content", minWidth: "260px", maxWidth: "400px" }}
>
<div
role="button"
tabIndex={0}
className={`px-4 py-2 font-bold flex justify-between items-center cursor-pointer select-none hover:bg-slate-50 transition-colors rounded-t-xl outline-none focus-visible:ring-2 focus-visible:ring-primary ${
node.level === 0
? "text-slate-800 text-lg"
: "text-slate-700 text-sm"
}`}
onClick={toggle}
onKeyDown={handleKeyDown}
>
<span>{node.name}</span>
<span className="text-slate-400 font-normal text-xs ml-4">
({node.totalCount})
</span>
</div>
{!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} />
))}
</div>
)}
</div>
{!collapsed && node.children.length > 0 && (
<div
className={`flex ${
isVerticalChildren
? "flex-col items-start pl-12 gap-4 w-full"
: "flex-row gap-10 justify-center items-start"
} relative`}
>
{node.children.map((c) => (
<OrgNodeView
key={c.name}
node={c}
parentId={myId}
onToggle={onToggle}
/>
))}
</div>
)}
</div>
);
}
function MemberCard({ member }: { member: UserWithPath }) {
const coColor = (() => {
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]";
if (c.includes("baron")) return "bg-[#4338CA] text-white border-[#4338CA]";
return "bg-slate-600 text-white border-slate-700";
})();
const roleBadge = member.jobTitle && member.jobTitle !== member.position
? member.jobTitle
: (member.position?.endsWith("장") ? member.position : null);
return (
<div
className={`relative flex items-center px-1.5 h-[30px] rounded border shadow-sm overflow-hidden w-full leading-none ${coColor}`}
>
<div className="flex items-center gap-1 min-w-0 w-full">
<div className="flex items-baseline gap-1 truncate shrink-0">
<span className="font-bold text-[11px] whitespace-nowrap">{member.name}</span>
{member.position && member.position !== roleBadge && (
<span className="text-[10px] opacity-90 whitespace-nowrap font-medium">{member.position}</span>
)}
</div>
{roleBadge && (
<span className="bg-white/20 text-[9px] px-1 py-[1.5px] rounded-[3px] font-bold tracking-tight shrink-0 whitespace-nowrap ml-auto">
{roleBadge}
</span>
)}
</div>
</div>
);
}