1
0
forked from baron/baron-sso

feat(org): 조직도 렌더링 및 동기화 로직 대폭 개선, 역할(Role) 강제화, 탭 정리

- 조직도 렌더링 시 너비 동적 계산 및 스크롤 문제 해결
- 하위 조직(Leaf)을 부모 박스 내부에 임베딩하여 2열로 깔끔하게 표시되도록 조직도 UI 전면 개편
- 사용자 생성/수정 및 CSV 업로드 시 직급(Position)과 직무(JobTitle)가 정상적으로 Kratos 및 로컬 DB에 동기화되도록 백엔드 API 수정
- CSV 조직도 업로드 시 계층 구분을 '/' 대신 ' > '로 변경하여 이름에 '/'가 포함된 부서(예: 평면/셀)가 분리되지 않도록 보호
- 잘못 입력된 과거 직책 데이터(팀장, 그룹장 등)를 'user' 권한으로 일괄 초기화하고, 이후 'role' 필드에 시스템 권한(user, tenant_admin, super_admin) 외의 값이 들어오지 않도록 백엔드 정규화 로직 강화
- 사용자 목록 페이지의 페이지네이션 제한을 50명에서 1000명으로 상향 조정
- 테넌트 목록 페이지에 이름/슬러그 기반 검색 기능 추가
- 관리자 UI 전반에서 불필요한 배지(Admin only, System 등) 제거 및 테넌트 상세 페이지의 미사용 '외부 연동' 탭 삭제
This commit is contained in:
2026-04-10 13:48:12 +09:00
parent 5211842d47
commit 349cdf5fcd
10 changed files with 137 additions and 82 deletions

View File

@@ -42,10 +42,14 @@ export function TenantOrgChartPage() {
return query.data.items
.filter((u) => u.status === "active")
.map((u) => {
const parts = (u.department || "").split("/").filter(Boolean);
const deptStr = u.department || "";
const parts = deptStr.includes(" > ")
? deptStr.split(" > ")
: deptStr.split("/");
return {
...u,
_path: parts.map((name, i) => ({ level: i, name })),
_path: parts.map((name, i) => ({ level: i, name: name.trim() })).filter(p => p.name),
};
});
}, [query.data]);
@@ -124,7 +128,10 @@ 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
@@ -136,7 +143,10 @@ export function TenantOrgChartPage() {
newLines.push({
key: `${parentId}->${box.id}`,
x1: px, y1: py, x2: cx, y2: cy,
x1: px,
y1: py,
x2: cx,
y2: cy,
path: `M ${px} ${py} L ${px} ${midY} L ${cx} ${midY} L ${cx} ${cy}`,
});
} else {
@@ -145,10 +155,13 @@ export function TenantOrgChartPage() {
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,
x1: spineX,
y1: py,
x2: cx,
y2: cy,
path: `M ${spineX} ${py} L ${spineX} ${cy} L ${cx} ${cy}`,
});
}
@@ -228,9 +241,9 @@ export function TenantOrgChartPage() {
<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%"
style={{
width: svgSize.width ? `${svgSize.width}px` : "100%",
height: svgSize.height ? `${svgSize.height}px` : "100%",
}}
>
{lines.map((l) => (
@@ -322,8 +335,13 @@ function OrgNodeView({
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);
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-3`}
>
<div
id={myId}
data-parent={parentId || undefined}
@@ -357,9 +375,34 @@ function OrgNodeView({
))}
</div>
)}
{!collapsed && embedChildren && (
<div className="flex flex-col gap-2 p-2 pt-0 w-full">
{node.children.map((child) => {
const childMembers = [...child.members].sort(
(a, b) => getRankWeight(a) - getRankWeight(b),
);
return (
<div 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})</span>
</div>
{childMembers.length > 0 && (
<div className="grid grid-cols-2 gap-1 w-full">
{childMembers.map((m) => (
<MemberCard key={m.id} member={m} />
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
{!collapsed && node.children.length > 0 && (
{!collapsed && !embedChildren && node.children.length > 0 && (
<div
className={`flex ${
isVerticalChildren
@@ -391,9 +434,12 @@ function MemberCard({ member }: { member: UserWithPath }) {
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);
const roleBadge =
member.jobTitle && member.jobTitle !== member.position
? member.jobTitle
: member.position?.endsWith("장")
? member.position
: null;
return (
<div
@@ -401,9 +447,13 @@ function MemberCard({ member }: { member: UserWithPath }) {
>
<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>
<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>
<span className="text-[10px] opacity-90 whitespace-nowrap font-medium">
{member.position}
</span>
)}
</div>
{roleBadge && (