diff --git a/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantDeptController.java b/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantDeptController.java new file mode 100644 index 00000000..d7245db4 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantDeptController.java @@ -0,0 +1,83 @@ +package com.erp.crosstenant; + +import com.erp.service.AdminService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * SUPER_ADMIN 의 cross-tenant 부서 조회 — 사용자 등록/수정 폼의 "부서" dropdown 을 + * 선택된 회사 DB 기준으로 채우기 위한 보조 endpoint. + * + * 단일 회사 모드의 {@code GET /api/admin/departments} 와 응답 형태 동일. + * 차이점: company_code 가 query param 으로 명시되고, 그 회사 DB 컨텍스트로 임시 전환. + * + * @see CrossTenantUserController + * @see com.erp.controller.AdminController#getDepartmentList // 단일 모드 원본 + */ +@RestController +@RequestMapping("/api/admin/cross-tenant/departments") +@RequiredArgsConstructor +@Slf4j +public class CrossTenantDeptController { + + private final CrossTenantExecutor executor; + private final AdminService adminService; + + /** + * GET /api/admin/cross-tenant/departments?company_code=TEST02 + * 응답 구조는 단일 모드와 동일: { success, data: { departments, flat_list }, total, total_count } + */ + @GetMapping + public ResponseEntity> listDepartments( + HttpServletRequest request, + @RequestParam("company_code") String companyCode) { + if (!CrossTenantContext.isSuperAdmin(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(errorBody("super_admin_required", request.getRequestURI())); + } + if (!CrossTenantContext.isMetaContext()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(errorBody("cross_tenant_requires_meta_context", request.getRequestURI())); + } + + try { + Map serviceResult = executor.runInCompany(companyCode, () -> { + Map params = new HashMap<>(); + params.put("company_code", companyCode); + return adminService.getDepartmentList(params); + }); + + int total = ((Number) serviceResult.get("total")).intValue(); + + Map data = new LinkedHashMap<>(); + data.put("departments", serviceResult.get("departments")); + data.put("flat_list", serviceResult.get("flat_list")); + + Map response = new LinkedHashMap<>(); + response.put("success", true); + response.put("data", data); + response.put("message", "부서 목록 조회 성공"); + response.put("total", total); + response.put("total_count", total); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(errorBody(e.getMessage(), request.getRequestURI())); + } + } + + private Map errorBody(String message, String path) { + Map body = new LinkedHashMap<>(); + body.put("success", false); + body.put("message", message); + body.put("path", path); + return body; + } +} diff --git a/frontend/components/admin/UserFormModal.tsx b/frontend/components/admin/UserFormModal.tsx index e58d10fb..00fe6861 100644 --- a/frontend/components/admin/UserFormModal.tsx +++ b/frontend/components/admin/UserFormModal.tsx @@ -271,6 +271,14 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF return; } + // SUPER_ADMIN(메타) 은 어느 회사에서 체크할지 먼저 골라야 함. + // 회사별 USER_INFO 라 회사 미선택 상태에선 체크 의미가 없음. + if (isSuperAdmin && !formData.company_code) { + setDuplicateCheckMessage("회사를 먼저 선택해주세요."); + setDuplicateCheckType("error"); + return; + } + try { // cross-tenant 모드: 회사별 USER_INFO 라 그 회사 코드와 함께 중복 체크. // 단일 모드: 두번째 인자 무시 (백엔드가 JWT.company_code 사용). @@ -447,6 +455,48 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
+ {/* 회사 선택 — 모든 후속 입력의 컨텍스트라 가장 위 */} +
+ + {isSuperAdmin ? ( + <> + +

+ 회사를 먼저 선택해야 사용자 ID 중복확인과 부서 선택이 가능합니다. +

+ + ) : ( + <> + c.company_code === formData.company_code)?.company_name || + formData.company_code + } + disabled + className="bg-muted cursor-not-allowed" + /> +

회사는 최고 관리자만 변경할 수 있습니다.

+ + )} +
+ {/* 기본 정보 */}
@@ -460,17 +510,18 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
handleInputChange("user_id", e.target.value)} onKeyDown={handleKeyDown} + disabled={isSuperAdmin && !formData.company_code} className="flex-1" />
)} - {/* 회사 선택 */} -
- - {isSuperAdmin ? ( - <> - -

- 권한 관리는 별도의 권한 관리 페이지에서 설정할 수 있습니다. -

- - ) : ( - <> - c.company_code === formData.company_code)?.company_name || - formData.company_code - } - disabled - className="bg-muted cursor-not-allowed" - /> -

회사는 최고 관리자만 변경할 수 있습니다.

- - )} -
- {/* 부서 정보 */}
diff --git a/frontend/lib/api/user.ts b/frontend/lib/api/user.ts index 52d8e458..db327caa 100644 --- a/frontend/lib/api/user.ts +++ b/frontend/lib/api/user.ts @@ -219,8 +219,23 @@ export async function getCompanyList() { /** * 부서 목록 조회 + * + * cross-tenant 모드: companyCode 가 가리키는 회사 DB 의 부서. 미선택이면 빈 배열. + * (회사를 안 골랐는데 메타 DB 부서를 보여주면 다른 회사 부서가 섞여 보이는 버그 방지) + * 단일 모드: 기존 /admin/departments — 백엔드가 JWT.company_code 사용. */ export async function getDepartmentList(companyCode?: string) { + if (isCrossTenantMode()) { + if (!companyCode) return []; + const response = await apiClient.get( + `/admin/cross-tenant/departments?company_code=${encodeURIComponent(companyCode)}` + ); + if (response.data.success && response.data.data) { + return response.data.data.departments || []; + } + throw new Error(response.data.message || "부서 목록 조회에 실패했습니다."); + } + const params = companyCode ? `?companyCode=${encodeURIComponent(companyCode)}` : ""; const response = await apiClient.get(`/admin/departments${params}`);