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;
}
}
+53 -44
View File
@@ -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
</DialogHeader>
<div className="space-y-6 py-4">
{/* 회사 선택 — 모든 후속 입력의 컨텍스트라 가장 위 */}
<div className="space-y-2">
<Label htmlFor="companyCode" className="text-sm font-medium">
<span className="text-destructive">*</span>
</Label>
{isSuperAdmin ? (
<>
<Select
value={formData.company_code}
onValueChange={(value) => handleInputChange("company_code", value)}
>
<SelectTrigger>
<SelectValue placeholder="회사 선택" />
</SelectTrigger>
<SelectContent>
{companies.map((company) => (
<SelectItem key={company.company_code} value={company.company_code}>
{company.company_name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
ID .
</p>
</>
) : (
<>
<Input
id="companyCode"
value={
companies.find((c) => c.company_code === formData.company_code)?.company_name ||
formData.company_code
}
disabled
className="bg-muted cursor-not-allowed"
/>
<p className="text-muted-foreground text-xs"> .</p>
</>
)}
</div>
{/* 기본 정보 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
@@ -460,17 +510,18 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
<div className="flex gap-2">
<Input
id="userId"
placeholder="사용자 ID 입력"
placeholder={isSuperAdmin && !formData.company_code ? "회사 먼저 선택" : "사용자 ID 입력"}
value={formData.user_id}
onChange={(e) => handleInputChange("user_id", e.target.value)}
onKeyDown={handleKeyDown}
disabled={isSuperAdmin && !formData.company_code}
className="flex-1"
/>
<Button
type="button"
variant={isUserIdChecked && lastCheckedUserId === formData.user_id ? "default" : "outline"}
onClick={checkUserIdDuplicate}
disabled={!formData.user_id.trim() || isLoading}
disabled={!formData.user_id.trim() || isLoading || (isSuperAdmin && !formData.company_code)}
className="whitespace-nowrap"
>
{isUserIdChecked && lastCheckedUserId === formData.user_id ? "확인완료" : "중복확인"}
@@ -534,48 +585,6 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
</div>
)}
{/* 회사 선택 */}
<div className="space-y-2">
<Label htmlFor="companyCode" className="text-sm font-medium">
<span className="text-destructive">*</span>
</Label>
{isSuperAdmin ? (
<>
<Select
value={formData.company_code}
onValueChange={(value) => handleInputChange("company_code", value)}
>
<SelectTrigger>
<SelectValue placeholder="회사 선택" />
</SelectTrigger>
<SelectContent>
{companies.map((company) => (
<SelectItem key={company.company_code} value={company.company_code}>
{company.company_name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
.
</p>
</>
) : (
<>
<Input
id="companyCode"
value={
companies.find((c) => c.company_code === formData.company_code)?.company_name ||
formData.company_code
}
disabled
className="bg-muted cursor-not-allowed"
/>
<p className="text-muted-foreground text-xs"> .</p>
</>
)}
</div>
{/* 부서 정보 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
+15
View File
@@ -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}`);