feat(cross-tenant): 부서 endpoint + UserFormModal 회사-우선 reorder

직전 Phase 1 의 후속 폴리시.

신규 백엔드
- crosstenant/CrossTenantDeptController.java
  GET /api/admin/cross-tenant/departments?company_code=TEST02
  단일 모드 GET /admin/departments 와 응답 형태 동일. company_code query param
  으로 명시된 회사 DB 컨텍스트로 임시 전환해서 부서 트리 반환.
  버그 수정: 메타 DB DEPT_INFO 시드 (qnc/COMPANY_7 등 다른 회사 부서) 가
  TEST02 선택 시에도 dropdown 에 섞여 보이던 문제 해결.

프론트
- lib/api/user.ts — getDepartmentList(companyCode) 가 isCrossTenantMode() 면
  /admin/cross-tenant/departments?company_code= 호출.
  cross-tenant 모드 + companyCode 미지정 → 빈 배열 반환 (회사 안 골랐는데
  메타 부서 보여주는 것 방지).

UserFormModal
- 회사 dropdown 을 폼 가장 위로 이동 — 사용자 ID 중복확인·부서 선택이
  모두 회사에 의존하므로 자연스러운 입력 순서
- SUPER_ADMIN 인데 회사 미선택 상태에선 사용자 ID input + 중복확인 버튼
  disable + placeholder "회사 먼저 선택"
- checkUserIdDuplicate 가드: 회사 미선택이면 "회사를 먼저 선택해주세요"
  (백엔드의 400 "company_code 가 비어있음" 보다 친절)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-04-29 18:38:30 +09:00
parent a41f99c579
commit 4d19c31440
3 changed files with 151 additions and 44 deletions
@@ -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<Map<String, Object>> 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<String, Object> serviceResult = executor.runInCompany(companyCode, () -> {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
return adminService.getDepartmentList(params);
});
int total = ((Number) serviceResult.get("total")).intValue();
Map<String, Object> data = new LinkedHashMap<>();
data.put("departments", serviceResult.get("departments"));
data.put("flat_list", serviceResult.get("flat_list"));
Map<String, Object> 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<String, Object> errorBody(String message, String path) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("success", false);
body.put("message", message);
body.put("path", path);
return body;
}
}