From 349cdf5fcd9020b2adf2366437862f94a0a6a86a Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 10 Apr 2026 13:48:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(org):=20=EC=A1=B0=EC=A7=81=EB=8F=84=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=20=EB=B0=8F=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EB=8C=80=ED=8F=AD=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0,=20=EC=97=AD=ED=95=A0(Role)=20=EA=B0=95=EC=A0=9C?= =?UTF-8?q?=ED=99=94,=20=ED=83=AD=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 조직도 렌더링 시 너비 동적 계산 및 스크롤 문제 해결 - 하위 조직(Leaf)을 부모 박스 내부에 임베딩하여 2열로 깔끔하게 표시되도록 조직도 UI 전면 개편 - 사용자 생성/수정 및 CSV 업로드 시 직급(Position)과 직무(JobTitle)가 정상적으로 Kratos 및 로컬 DB에 동기화되도록 백엔드 API 수정 - CSV 조직도 업로드 시 계층 구분을 '/' 대신 ' > '로 변경하여 이름에 '/'가 포함된 부서(예: 평면/셀)가 분리되지 않도록 보호 - 잘못 입력된 과거 직책 데이터(팀장, 그룹장 등)를 'user' 권한으로 일괄 초기화하고, 이후 'role' 필드에 시스템 권한(user, tenant_admin, super_admin) 외의 값이 들어오지 않도록 백엔드 정규화 로직 강화 - 사용자 목록 페이지의 페이지네이션 제한을 50명에서 1000명으로 상향 조정 - 테넌트 목록 페이지에 이름/슬러그 기반 검색 기능 추가 - 관리자 UI 전반에서 불필요한 배지(Admin only, System 등) 제거 및 테넌트 상세 페이지의 미사용 '외부 연동' 탭 삭제 --- .../src/features/api-keys/ApiKeyListPage.tsx | 9 +- .../src/features/audit/AuditLogsPage.tsx | 9 +- .../tenants/routes/TenantCreatePage.tsx | 9 +- .../tenants/routes/TenantDetailPage.tsx | 15 ---- .../tenants/routes/TenantListPage.tsx | 42 ++++++++-- .../tenants/routes/TenantOrgChartPage.tsx | 82 +++++++++++++++---- .../routes/UserGroupDetailPage.tsx | 5 +- .../users/components/UserBulkUploadModal.tsx | 12 ++- adminfront/src/lib/adminApi.ts | 27 +++--- backend/internal/service/org_chart_service.go | 9 +- 10 files changed, 137 insertions(+), 82 deletions(-) diff --git a/adminfront/src/features/api-keys/ApiKeyListPage.tsx b/adminfront/src/features/api-keys/ApiKeyListPage.tsx index bcc2e35b..fa76ed8b 100644 --- a/adminfront/src/features/api-keys/ApiKeyListPage.tsx +++ b/adminfront/src/features/api-keys/ApiKeyListPage.tsx @@ -98,17 +98,16 @@ function ApiKeyListPage() {
- {t("ui.admin.api_keys.list.registry.title", "API Key Registry")} + {t("ui.admin.apikeys.registry.title", "API Key Registry")} {t( - "msg.admin.api_keys.list.registry.count", - "총 {{count}}개 API 키", - { count: query.data?.total ?? 0 }, + "msg.admin.apikeys.registry.count", + "총 {{count}}개의 활성 키가 등록되어 있습니다.", + { count: query.data?.items?.length ?? 0 }, )}
- {t("ui.common.badge.system", "System")}
{(errorMsg || fallbackError) && ( diff --git a/adminfront/src/features/audit/AuditLogsPage.tsx b/adminfront/src/features/audit/AuditLogsPage.tsx index 47c4c828..f958c357 100644 --- a/adminfront/src/features/audit/AuditLogsPage.tsx +++ b/adminfront/src/features/audit/AuditLogsPage.tsx @@ -191,17 +191,14 @@ function AuditLogsPage() {
- {t("ui.admin.audit.registry.title", "Audit registry")} + {t("ui.admin.audit.registry.title", "Log Registry")} - {t("msg.admin.audit.registry.count", "로드된 로그 {{count}}건", { - count: logs.length, + {t("msg.admin.audit.registry.count", "총 {{count}}개 로그", { + count: data?.pages[0]?.total ?? 0, })}
- - {t("ui.common.badge.command_only", "Command only")} -
diff --git a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx index 85efa81d..7850849d 100644 --- a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx @@ -59,20 +59,17 @@ function TenantCreatePage() {
-
+

- {t("ui.admin.tenants.create.title", "테넌트 추가")} + {t("ui.admin.tenants.create.title", "테넌트 생성")}

{t( "msg.admin.tenants.create.subtitle", - "글로벌 운영 기준의 신규 테넌트를 등록합니다.", + "새로운 테넌트를 시스템에 등록합니다.", )}

- - {t("ui.common.badge.admin_only", "Admin only")} -
diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx index e111ae2e..97714c8c 100644 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx @@ -24,7 +24,6 @@ function TenantDetailPage() { const canAccessSchema = profile?.role === "super_admin" || profile?.role === "tenant_admin"; - const isFederationTab = location.pathname.includes("/federation"); const isPermissionsTab = location.pathname.includes("/permissions"); const isOrganizationTab = location.pathname.includes("/organization"); @@ -43,9 +42,6 @@ function TenantDetailPage() { )}

- - {t("ui.common.admin_only", "관리자 전용")} - {/* Tabs */} @@ -53,7 +49,6 @@ function TenantDetailPage() { {t("ui.admin.tenants.detail.tab_profile", "프로필")} - - {t("ui.admin.tenants.detail.tab_federation", "외부 연동")} - ([]); + const [search, setSearch] = React.useState(""); const { data: profile } = useQuery({ queryKey: ["me"], @@ -109,7 +118,16 @@ function TenantListPage() { ? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.") : null; - const tenants = query.data?.items ?? []; + const allTenants = query.data?.items ?? []; + const tenants = React.useMemo(() => { + if (!search.trim()) return allTenants; + const term = search.toLowerCase(); + return allTenants.filter( + (t) => + t.name.toLowerCase().includes(term) || + t.slug.toLowerCase().includes(term), + ); + }, [allTenants, search]); const handleSelectAll = (checked: boolean) => { if (checked) { @@ -235,11 +253,23 @@ function TenantListPage() { })}
- - {t("ui.common.badge.admin_only", "Admin only")} - - + {" "} +
+
+ + setSearch(e.target.value)} + /> +
+
+ {(errorMsg || fallbackError) && (
{errorMsg ?? fallbackError} diff --git a/adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx b/adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx index 0e01a851..86fdc14a 100644 --- a/adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx @@ -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() {
+
)} + + {!collapsed && embedChildren && ( +
+ {node.children.map((child) => { + const childMembers = [...child.members].sort( + (a, b) => getRankWeight(a) - getRankWeight(b), + ); + return ( +
+
+ {child.name} + ({child.totalCount}) +
+ {childMembers.length > 0 && ( +
+ {childMembers.map((m) => ( + + ))} +
+ )} +
+ ); + })} +
+ )}
- {!collapsed && node.children.length > 0 && ( + {!collapsed && !embedChildren && node.children.length > 0 && (
- {member.name} + + {member.name} + {member.position && member.position !== roleBadge && ( - {member.position} + + {member.position} + )}
{roleBadge && ( diff --git a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx index f2104737..ceea0c72 100644 --- a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx +++ b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx @@ -251,12 +251,9 @@ export function UserGroupDetailPage() {

- + {t("ui.admin.groups.detail.breadcrumb_unit", "조직 단위")} - - ID: {id?.split("-")[0]}... -
diff --git a/adminfront/src/features/users/components/UserBulkUploadModal.tsx b/adminfront/src/features/users/components/UserBulkUploadModal.tsx index 4d46bea1..27a54e3d 100644 --- a/adminfront/src/features/users/components/UserBulkUploadModal.tsx +++ b/adminfront/src/features/users/components/UserBulkUploadModal.tsx @@ -74,15 +74,13 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) { }; const downloadTemplate = () => { - const headers = "email,name,phone,role,tenant,department,position,jobTitle,employee_id"; + const headers = + "email,name,phone,role,tenant,department,position,jobTitle,employee_id"; const example = "user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,프론트엔드,EMP001"; - const blob = new Blob( - [ - `${headers}\n${example}`, - ], - { type: "text/csv;charset=utf-8;" }, - ); + const blob = new Blob([`${headers}\n${example}`], { + type: "text/csv;charset=utf-8;", + }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 01524702..4c2bca0a 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -275,29 +275,32 @@ export interface ImportResult { errors: string[]; } -export async function fetchImportProgress(tenantId: string, progressId: string) { +export async function fetchImportProgress( + tenantId: string, + progressId: string, +) { const { data } = await apiClient.get<{ current: number; total: number }>( - `/v1/admin/tenants/${tenantId}/organization/import/progress/${progressId}` + `/v1/admin/tenants/${tenantId}/organization/import/progress/${progressId}`, ); return data; } -export async function importOrgChart(tenantId: string, file: File, progressId?: string) { +export async function importOrgChart( + tenantId: string, + file: File, + progressId?: string, +) { const formData = new FormData(); formData.append("file", file); - const url = progressId + const url = progressId ? `/v1/admin/tenants/${tenantId}/organization/import?progressId=${progressId}` : `/v1/admin/tenants/${tenantId}/organization/import`; - const { data } = await apiClient.post<{ data: ImportResult }>( - url, - formData, - { - headers: { - "Content-Type": "multipart/form-data", - }, + const { data } = await apiClient.post<{ data: ImportResult }>(url, formData, { + headers: { + "Content-Type": "multipart/form-data", }, - ); + }); return data.data; } diff --git a/backend/internal/service/org_chart_service.go b/backend/internal/service/org_chart_service.go index 93ccfd71..68a11ca4 100644 --- a/backend/internal/service/org_chart_service.go +++ b/backend/internal/service/org_chart_service.go @@ -200,13 +200,13 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r orgParts = append(orgParts, val) } } - orgPath := strings.Join(orgParts, "/") + orgPath := strings.Join(orgParts, " > ") leafID := companyTenantID - if orgPath != "" && orgPath != "-" { + if len(orgParts) > 0 { // [Matrix Fix] Build hierarchy under the Root Tenant (tenantID), NOT the individual company. // This allows departments like '총괄기획실' to be shared across multiple companies without duplication. - leafID, err = s.ensureOrgPath(ctx, tenantID, orgPath, pathCache, result) + leafID, err = s.ensureOrgPath(ctx, tenantID, orgParts, pathCache, result) if err != nil { result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Hierarchy fail: %v", rowIdx+2, err)) continue @@ -435,8 +435,7 @@ func (s *orgChartService) ensureCompanyTenant(ctx context.Context, rootID, name, return tenant.ID, nil } -func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID, path string, cache map[string]string, res *ImportResult) (string, error) { - parts := strings.Split(path, "/") +func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string, parts []string, cache map[string]string, res *ImportResult) (string, error) { currentParentID := rootTenantID currentPath := "" for i, part := range parts {