forked from baron/baron-sso
feat(orgchart): Introduce standalone orgchart RP and shared link public API
This commit includes: - Added SharedLink data model and Keto-bypassed public API for orgchart view - Configured 'orgfront' as a new OAuth2 client in hydra - Applied MH Dashboard premium beige theme to OrgChart - Implemented user lookup fallback to company code
This commit is contained in:
@@ -53,31 +53,21 @@ export function TenantOrgChartPage() {
|
||||
|
||||
const uMap = new Map<string, UserSummary[]>();
|
||||
|
||||
// Process users to map them to multiple tenants if applicable
|
||||
for (const u of usersQuery.data.items) {
|
||||
if (u.status !== "active") continue;
|
||||
|
||||
// Extract all associated tenant slugs
|
||||
const slugs = new Set<string>();
|
||||
|
||||
const primarySlug =
|
||||
u.tenantSlug?.toLowerCase() || u.companyCode?.toLowerCase() || "";
|
||||
if (primarySlug) {
|
||||
slugs.add(primarySlug);
|
||||
}
|
||||
const primarySlug = u.tenantSlug?.toLowerCase() || u.companyCode?.toLowerCase() || "";
|
||||
if (primarySlug) slugs.add(primarySlug);
|
||||
|
||||
if (u.joinedTenants && Array.isArray(u.joinedTenants)) {
|
||||
for (const jt of u.joinedTenants) {
|
||||
if (jt.slug) {
|
||||
slugs.add(jt.slug.toLowerCase());
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -106,10 +96,8 @@ export function TenantOrgChartPage() {
|
||||
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;
|
||||
@@ -154,13 +142,9 @@ export function TenantOrgChartPage() {
|
||||
|
||||
if (pRect.width === 0 || cRect.width === 0) continue;
|
||||
|
||||
const parentLevel = Number.parseInt(
|
||||
parent.getAttribute("data-level") || "0",
|
||||
10,
|
||||
);
|
||||
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;
|
||||
@@ -176,11 +160,10 @@ export function TenantOrgChartPage() {
|
||||
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 spineX = pRect.left + 24 - rect.left + scrollLeft;
|
||||
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
|
||||
const cx = cRect.left - rect.left + scrollLeft;
|
||||
const cy = cRect.top + 20 - rect.top + scrollTop;
|
||||
|
||||
newLines.push({
|
||||
key: `${parentId}->${box.id}`,
|
||||
@@ -201,7 +184,6 @@ export function TenantOrgChartPage() {
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const _forceTrigger = rootNodes.length + usersMap.size;
|
||||
const timeout = setTimeout(drawLines, 150);
|
||||
window.addEventListener("resize", drawLines);
|
||||
return () => {
|
||||
@@ -216,52 +198,45 @@ export function TenantOrgChartPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Count unique users across the fetched payload
|
||||
const totalUniqueUsers =
|
||||
usersQuery.data?.items?.filter((u) => u.status === "active").length || 0;
|
||||
|
||||
const targetNodes =
|
||||
selectedDept === "전체"
|
||||
? rootNodes
|
||||
: rootNodes.filter((n) => n.name === selectedDept);
|
||||
const totalUniqueUsers = usersQuery.data?.items?.filter((u) => u.status === "active").length || 0;
|
||||
const targetNodes = selectedDept === "전체" ? rootNodes : rootNodes.filter((n) => n.name === selectedDept);
|
||||
|
||||
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">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-800">조직도</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
조직(테넌트) 계층 구조를 기반으로 사용자들의 소속을 시각화합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col h-[calc(100vh-theme(spacing.32))] bg-[#f6efe6] rounded-xl overflow-hidden shadow-sm border border-[#e0d5c1]">
|
||||
<header className="flex flex-col sm:flex-row items-start sm:items-center justify-between px-6 py-4 bg-[linear-gradient(145deg,rgba(10,42,34,0.98)_0%,rgba(15,58,47,0.98)_52%,rgba(26,86,69,0.98)_100%)] border-b border-[#f2c484]/30 z-10 shrink-0">
|
||||
<div className="flex flex-col gap-1 mb-4 sm:mb-0">
|
||||
<p className="text-[#f2c484] text-xs font-bold uppercase tracking-wider">MH Dashboard</p>
|
||||
<h2 className="text-xl font-black text-[#f7f0e4]">조직 현황</h2>
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto max-w-2xl custom-scrollbar pb-1">
|
||||
<div className="flex items-center gap-2 overflow-x-auto max-w-full custom-scrollbar">
|
||||
{["전체", ...depts].map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedDept(d);
|
||||
setLines([]); // Reset lines during switch
|
||||
setLines([]);
|
||||
}}
|
||||
className={`whitespace-nowrap px-4 py-1.5 text-sm font-semibold rounded-full border transition-colors ${
|
||||
className={`whitespace-nowrap px-4 py-2 text-xs font-bold rounded-full transition-all border ${
|
||||
selectedDept === d
|
||||
? "bg-slate-800 text-white border-slate-800"
|
||||
: "bg-white text-slate-600 border-slate-200 hover:bg-slate-100"
|
||||
? "bg-[linear-gradient(180deg,rgba(255,253,248,0.98),rgba(245,235,221,0.94))] text-[#0a2a22] border-[#f2c484]/40 shadow-sm"
|
||||
: "bg-white/10 text-[#f7f0e4]/70 border-[#f2c484]/30 hover:text-[#f7f0e4] hover:border-[#f2c484]/50"
|
||||
}`}
|
||||
>
|
||||
{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-2 bg-[#f2c484]/10 text-[#f2c484] font-black rounded-full border border-[#f2c484]/30 text-xs shadow-sm">
|
||||
총 {totalUniqueUsers}명
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-auto relative p-8 bg-[#f8fafc]"
|
||||
className="flex-1 overflow-auto relative p-8 md:p-12"
|
||||
style={{
|
||||
background: "radial-gradient(circle at top left, rgba(214, 138, 58, 0.08), transparent 24%), radial-gradient(circle at top right, rgba(47, 153, 115, 0.05), transparent 20%), linear-gradient(180deg, rgba(246, 239, 230, 0.98), rgba(241, 234, 223, 0.96))"
|
||||
}}
|
||||
ref={containerRef}
|
||||
>
|
||||
<svg
|
||||
@@ -276,27 +251,20 @@ export function TenantOrgChartPage() {
|
||||
<path
|
||||
key={l.key}
|
||||
d={l.path}
|
||||
stroke="#cbd5e1"
|
||||
strokeWidth="2"
|
||||
stroke="#bca58a"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
))}
|
||||
</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-32">
|
||||
{targetNodes.map((tNode) => {
|
||||
const orgNode = buildHierarchy(tNode, 0);
|
||||
return (
|
||||
<div
|
||||
key={orgNode.id}
|
||||
className="flex flex-col items-center w-full"
|
||||
>
|
||||
<OrgNodeView
|
||||
node={orgNode}
|
||||
parentId={null}
|
||||
onToggle={drawLines}
|
||||
/>
|
||||
<div key={orgNode.id} className="flex flex-col items-center w-full">
|
||||
<OrgNodeView node={orgNode} parentId={null} onToggle={drawLines} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -346,89 +314,63 @@ function OrgNodeView({
|
||||
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 membersToShow = [...node.members].sort((a, b) => getRankWeight(a) - getRankWeight(b));
|
||||
const isVerticalChildren = node.level >= 1;
|
||||
const isVerticallyStacked = node.level >= 1;
|
||||
|
||||
const embedChildren =
|
||||
node.children.length > 0 &&
|
||||
node.children.every((c) => c.children.length === 0);
|
||||
node.children.length > 0 && node.children.every((c) => c.children.length === 0);
|
||||
|
||||
// Determine header color based on level
|
||||
const headerBgClass = node.level === 0 ? "bg-[#0a2a22]" : "bg-[#2f5547]";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col ${isVerticallyStacked ? "items-start w-full" : "items-center"} mb-3`}
|
||||
>
|
||||
<div className={`flex flex-col ${isVerticallyStacked ? "items-start w-full" : "items-center"} mb-4`}>
|
||||
<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" }}
|
||||
className={`bg-white border border-[#e0d5c1] rounded-[10px] shadow-sm flex flex-col transition-all shrink-0 ${collapsed ? "opacity-80 scale-[0.98]" : ""}`}
|
||||
style={{ width: "fit-content", minWidth: "320px", maxWidth: "400px" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
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 w-full text-left ${
|
||||
node.level === 0
|
||||
? "text-slate-800 text-lg"
|
||||
: "text-slate-700 text-sm"
|
||||
}`}
|
||||
className={`${headerBgClass} text-white px-4 py-3 font-black flex justify-center items-center gap-2 cursor-pointer select-none outline-none w-full text-center ${
|
||||
node.level === 0 ? "text-[17px]" : "text-[15px]"
|
||||
} ${membersToShow.length > 0 || embedChildren ? "rounded-t-[9px]" : "rounded-[9px]"}`}
|
||||
onClick={toggle}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<span>{node.name}</span>
|
||||
<span className="text-slate-400 font-normal text-xs ml-4">
|
||||
({node.totalCount || node.members.length})
|
||||
<span className="text-white/60 font-semibold text-xs bg-black/20 px-2 py-0.5 rounded-full">
|
||||
{node.totalCount || node.members.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{!collapsed && membersToShow.length > 0 && (
|
||||
<div className="p-1.5 pt-0 grid grid-cols-2 gap-1 w-full">
|
||||
<div className="p-3 grid grid-cols-2 gap-2 w-full">
|
||||
{membersToShow.map((m) => (
|
||||
<MemberCard
|
||||
key={m.id}
|
||||
member={m}
|
||||
companyCode={node.companyCode}
|
||||
/>
|
||||
<MemberCard key={m.id} member={m} companyCode={node.companyCode} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!collapsed && embedChildren && (
|
||||
<div className="flex flex-col gap-2 p-2 pt-0 w-full">
|
||||
<div className="flex flex-col gap-3 p-3 pt-0 w-full">
|
||||
{node.children.map((child) => {
|
||||
const childMembers = [...child.members].sort(
|
||||
(a, b) => getRankWeight(a) - getRankWeight(b),
|
||||
);
|
||||
const childMembers = [...child.members].sort((a, b) => getRankWeight(a) - getRankWeight(b));
|
||||
return (
|
||||
<div
|
||||
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-[#f8f9fa] border border-[#e5e7eb] rounded-[8px] flex flex-col w-full overflow-hidden"
|
||||
>
|
||||
<div className="text-[11px] font-bold text-slate-600 flex justify-between px-1">
|
||||
<div className="bg-[#7b93ab] text-white text-[13px] font-bold px-3 py-1.5 flex justify-between items-center">
|
||||
<span>{child.name}</span>
|
||||
<span className="text-slate-400 font-normal">
|
||||
({child.totalCount || child.members.length})
|
||||
</span>
|
||||
<span className="bg-black/10 px-1.5 py-0.5 rounded text-[11px] font-medium">{child.totalCount || child.members.length}</span>
|
||||
</div>
|
||||
{childMembers.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-1 w-full">
|
||||
<div className="grid grid-cols-2 gap-2 p-2 w-full">
|
||||
{childMembers.map((m) => (
|
||||
<MemberCard
|
||||
key={m.id}
|
||||
member={m}
|
||||
companyCode={child.companyCode}
|
||||
/>
|
||||
<MemberCard key={m.id} member={m} companyCode={child.companyCode} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -440,20 +382,9 @@ function OrgNodeView({
|
||||
</div>
|
||||
|
||||
{!collapsed && !embedChildren && 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`}
|
||||
>
|
||||
<div className={`flex ${isVerticalChildren ? "flex-col items-start pl-8 gap-8 w-full" : "flex-row gap-12 justify-center items-start"} relative mt-4`}>
|
||||
{node.children.map((c) => (
|
||||
<OrgNodeView
|
||||
key={c.id}
|
||||
node={c}
|
||||
parentId={myId}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
<OrgNodeView key={c.id} node={c} parentId={myId} onToggle={onToggle} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -467,41 +398,39 @@ function MemberCard({
|
||||
}: { member: UserSummary; companyCode?: string }) {
|
||||
const coColor = (() => {
|
||||
const c = (companyCode || 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";
|
||||
if (c.includes("hanmac")) return "border-l-[#ef4444]";
|
||||
if (c.includes("saman")) return "border-l-[#ffb366]";
|
||||
if (c.includes("ptc")) return "border-l-[#a855f7]";
|
||||
if (c.includes("baron")) return "border-l-[#3b82f6]";
|
||||
return "border-l-slate-400";
|
||||
})();
|
||||
|
||||
const roleBadge =
|
||||
member.jobTitle && member.jobTitle !== member.position
|
||||
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 flex-col px-2.5 py-2 rounded-[4px] border border-[#e5e7eb] bg-white border-l-[4px] w-full transition-transform hover:-translate-y-[2px] hover:shadow-md cursor-pointer ${coColor}`}>
|
||||
<div className="flex items-center justify-between min-w-0 w-full mb-1">
|
||||
<div className="flex items-baseline gap-1 truncate shrink-0">
|
||||
<span className="font-bold text-[11px] whitespace-nowrap">
|
||||
<span className="font-black text-[12px] text-[#334155] whitespace-nowrap">
|
||||
{member.name}
|
||||
</span>
|
||||
{member.position && member.position !== roleBadge && (
|
||||
<span className="text-[10px] opacity-90 whitespace-nowrap font-medium">
|
||||
{member.position}
|
||||
{roleBadge && (
|
||||
<span className="text-[#2f5547] text-[9px] font-extrabold ml-1 truncate">
|
||||
{roleBadge}
|
||||
</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 className="flex items-center min-w-0 w-full">
|
||||
<span className="text-[#94a3b8] text-[9px] font-medium truncate">
|
||||
{member.position || "사원"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user