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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user