Merge remote-tracking branch 'origin/main' into hjjeong
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
package com.erp.constants;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
public final class InputTypeConstants {
|
||||
private InputTypeConstants() {}
|
||||
|
||||
/**
|
||||
* INSERT/UPDATE-type 검증용 허용 INPUT_TYPE.
|
||||
* 신규 표준 8종 + 운영 DB 에 잔존하는 legacy 7종(category/select/textarea/checkbox/radio/datetime/boolean).
|
||||
* 5/15 common-code 재설계가 화이트리스트를 8종으로 좁히면서도 옛 데이터/프론트 정리를 빠뜨려
|
||||
* 컬럼 설정 저장 batch 가 일괄 거부됐던 회귀 회복. legacy 정리는 별도 PR 로.
|
||||
*/
|
||||
public static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
|
||||
"text", "number", "date", "code", "entity",
|
||||
"numbering", "file", "image",
|
||||
"category", "select", "textarea", "checkbox", "radio", "datetime", "boolean"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.erp.constants;
|
||||
|
||||
public enum InputTypeContext {
|
||||
USER_INSERT,
|
||||
USER_UPDATE_TYPE,
|
||||
USER_UPDATE_OTHER,
|
||||
SYSTEM_NORMALIZE
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.provisioning.SuperAdminGuard;
|
||||
import com.erp.service.AdminService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -30,13 +32,17 @@ public class AdminController {
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
@RequestParam Map<String, Object> params,
|
||||
HttpServletRequest request) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("user_type", role);
|
||||
params.put("user_id", userId);
|
||||
params.putIfAbsent("user_lang", "ko");
|
||||
params.put("is_management_screen",
|
||||
params.get("menu_type") == null || "true".equals(params.get("include_inactive")));
|
||||
// 관리 호스트(solution.invyone.com 등) 여부 — 테넌트 호스트이면 IS_SOLUTION_ONLY 메뉴를 SQL 단계에서 제외
|
||||
String host = request.getHeader("Host");
|
||||
params.put("is_management_host", !SuperAdminGuard.isTenantHost(host));
|
||||
return ResponseEntity.ok(ApiResponse.success(adminService.getAdminMenuList(params), "관리자 메뉴 목록 조회 성공"));
|
||||
}
|
||||
|
||||
@@ -49,11 +55,15 @@ public class AdminController {
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
@RequestParam Map<String, Object> params,
|
||||
HttpServletRequest request) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("user_type", role);
|
||||
params.put("user_id", userId);
|
||||
params.putIfAbsent("user_lang", "ko");
|
||||
// 관리 호스트(solution.invyone.com 등) 여부 — 테넌트 호스트이면 IS_SOLUTION_ONLY 메뉴를 SQL 단계에서 제외
|
||||
String host = request.getHeader("Host");
|
||||
params.put("is_management_host", !SuperAdminGuard.isTenantHost(host));
|
||||
return ResponseEntity.ok(ApiResponse.success(adminService.getUserMenuList(params), "사용자 메뉴 목록 조회 성공"));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CascadingAutoFillService;
|
||||
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.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/cascading-auto-fill")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CascadingAutoFillController {
|
||||
private final CascadingAutoFillService cascadingAutoFillService;
|
||||
|
||||
// Pipeline api_test compatibility alias
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupListAlias(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingAutoFillService.getCascadingAutoFillGroupList(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/groups")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingAutoFillService.getCascadingAutoFillGroupList(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/groups/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupDetail(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
Map<String, Object> result = cascadingAutoFillService.getCascadingAutoFillGroupDetail(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@PostMapping("/groups")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> createGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.success(cascadingAutoFillService.insertCascadingAutoFillGroup(body)));
|
||||
}
|
||||
|
||||
@PutMapping("/groups/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("group_code", groupCode);
|
||||
Map<String, Object> result = cascadingAutoFillService.updateCascadingAutoFillGroup(body);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@DeleteMapping("/groups/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
boolean deleted = cascadingAutoFillService.deleteCascadingAutoFillGroup(params);
|
||||
if (!deleted) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
@GetMapping("/options/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getMasterOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
List<Map<String, Object>> result = cascadingAutoFillService.getAutoFillMasterOptions(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@GetMapping("/data/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getAutoFillData(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode,
|
||||
@RequestParam(required = false) String masterValue) {
|
||||
if (masterValue == null || masterValue.isBlank()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error("masterValue 파라미터가 필요합니다."));
|
||||
}
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
params.put("master_value", masterValue);
|
||||
Map<String, Object> result = cascadingAutoFillService.getAutoFillData(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CascadingConditionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/cascading-condition")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CascadingConditionController {
|
||||
private final CascadingConditionService cascadingConditionService;
|
||||
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingConditionListAlias(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.getCascadingConditionList(params)));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingConditionList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.getCascadingConditionList(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/filtered-options/{relationCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getFilteredOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String relationCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("relation_code", relationCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.getFilteredOptions(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/{conditionId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingConditionInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long conditionId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("condition_id", conditionId);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.getCascadingConditionInfo(params)));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCascadingCondition(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.insertCascadingCondition(body)));
|
||||
}
|
||||
|
||||
@PutMapping("/{conditionId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCascadingCondition(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long conditionId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("condition_id", conditionId);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.updateCascadingCondition(body)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{conditionId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCascadingCondition(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long conditionId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("condition_id", conditionId);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.deleteCascadingCondition(params)));
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CascadingHierarchyService;
|
||||
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.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/cascading-hierarchy")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CascadingHierarchyController {
|
||||
private final CascadingHierarchyService cascadingHierarchyService;
|
||||
|
||||
// Pipeline api_test compatibility alias
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupListAlias(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingHierarchyService.getCascadingHierarchyGroupList(params)));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingHierarchyService.getCascadingHierarchyGroupList(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupDetail(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
Map<String, Object> result = cascadingHierarchyService.getCascadingHierarchyGroupDetail(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("계층 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> createGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "user_id", required = false) String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
if (userId != null) body.put("user_id", userId);
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.success(cascadingHierarchyService.insertCascadingHierarchyGroup(body)));
|
||||
}
|
||||
|
||||
@PutMapping("/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "user_id", required = false) String userId,
|
||||
@PathVariable String groupCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("group_code", groupCode);
|
||||
if (userId != null) body.put("user_id", userId);
|
||||
Map<String, Object> result = cascadingHierarchyService.updateCascadingHierarchyGroup(body);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("계층 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
boolean deleted = cascadingHierarchyService.deleteCascadingHierarchyGroup(params);
|
||||
if (!deleted) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("계층 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
@PostMapping("/{groupCode}/levels")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> addLevel(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("group_code", groupCode);
|
||||
Map<String, Object> result = cascadingHierarchyService.addCascadingHierarchyLevel(body);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("계층 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@PutMapping("/levels/{levelId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateLevel(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long levelId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("level_id", levelId);
|
||||
Map<String, Object> result = cascadingHierarchyService.updateCascadingHierarchyLevel(body);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("레벨을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@DeleteMapping("/levels/{levelId}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteLevel(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long levelId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("level_id", levelId);
|
||||
boolean deleted = cascadingHierarchyService.deleteCascadingHierarchyLevel(params);
|
||||
if (!deleted) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("레벨을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
@GetMapping("/{groupCode}/options/{levelOrder}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getLevelOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode,
|
||||
@PathVariable Integer levelOrder,
|
||||
@RequestParam(required = false) String parentValue) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
params.put("level_order", levelOrder);
|
||||
if (parentValue != null) params.put("parent_value", parentValue);
|
||||
Map<String, Object> result = cascadingHierarchyService.getLevelOptions(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("레벨을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
}
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CascadingMutualExclusionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 상호 배제 API
|
||||
* Node.js: app.use("/api/cascading-mutual-exclusion", cascadingMutualExclusionRoutes)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/cascading-mutual-exclusion")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CascadingMutualExclusionController {
|
||||
private final CascadingMutualExclusionService cascadingMutualExclusionService;
|
||||
|
||||
/** GET /list — 목록 조회 (alias) */
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingMutualExclusionListAlias(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.getCascadingMutualExclusionList(params)));
|
||||
}
|
||||
|
||||
/** GET / — 목록 조회 */
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingMutualExclusionList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.getCascadingMutualExclusionList(params)));
|
||||
}
|
||||
|
||||
/** GET /{exclusionId} — 상세 조회 */
|
||||
@GetMapping("/{exclusionId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingMutualExclusionInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long exclusionId) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("id", exclusionId);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.getCascadingMutualExclusionInfo(params)));
|
||||
}
|
||||
|
||||
/** POST / — 생성 */
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCascadingMutualExclusion(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.insertCascadingMutualExclusion(body)));
|
||||
}
|
||||
|
||||
/** PUT /{exclusionId} — 수정 */
|
||||
@PutMapping("/{exclusionId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCascadingMutualExclusion(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long exclusionId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("id", exclusionId);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.updateCascadingMutualExclusion(body)));
|
||||
}
|
||||
|
||||
/** DELETE /{exclusionId} — 하드 삭제 */
|
||||
@DeleteMapping("/{exclusionId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCascadingMutualExclusion(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long exclusionId) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("id", exclusionId);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.deleteCascadingMutualExclusion(params)));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /validate/{exclusionCode} — 상호 배제 검증
|
||||
* body: { "field_values": { "field_a": "val1", "field_b": "val1" } }
|
||||
*/
|
||||
@PostMapping("/validate/{exclusionCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> validateCascadingMutualExclusion(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String exclusionCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("code", exclusionCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.validateCascadingMutualExclusion(body)));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /options/{exclusionCode} — 배제 옵션 조회
|
||||
* query: selectedValues (콤마 구분된 이미 선택된 값들)
|
||||
*/
|
||||
@GetMapping("/options/{exclusionCode}")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getExcludedOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String exclusionCode,
|
||||
@RequestParam(required = false) String currentField,
|
||||
@RequestParam(required = false) String selectedValues) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", exclusionCode);
|
||||
params.put("current_field", currentField);
|
||||
params.put("selected_values", selectedValues);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.getExcludedOptions(params)));
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CascadingRelationService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 연쇄 관계 API
|
||||
* Node.js: app.use("/api/cascading-relation", cascadingRelationRoutes)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/cascading-relation")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CascadingRelationController {
|
||||
private final CascadingRelationService cascadingRelationService;
|
||||
|
||||
/** GET /api/cascading-relation/list — 목록 조회 (alias) */
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingRelationListAlias(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.getCascadingRelationList(params)));
|
||||
}
|
||||
|
||||
/** GET /api/cascading-relation — 목록 조회 */
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingRelationList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.getCascadingRelationList(params)));
|
||||
}
|
||||
|
||||
/** GET /api/cascading-relation/{id} — 상세 조회 */
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingRelationInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long id) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("id", id);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.getCascadingRelationInfo(params)));
|
||||
}
|
||||
|
||||
/** GET /api/cascading-relation/code/{code} — 코드로 단건 조회 */
|
||||
@GetMapping("/code/{code}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingRelationByCode(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.getCascadingRelationByCode(params)));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cascading-relation/parent-options/{code}
|
||||
* 부모 옵션 조회 (parent_table 동적 쿼리)
|
||||
*/
|
||||
@GetMapping("/parent-options/{code}")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getParentOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.getParentOptions(params)));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cascading-relation/options/{code}?parentValue=&parentValues=
|
||||
* 연쇄 자식 옵션 조회 (child_table 동적 쿼리)
|
||||
*/
|
||||
@GetMapping("/options/{code}")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCascadingOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code,
|
||||
@RequestParam(required = false) String parentValue,
|
||||
@RequestParam(required = false) String parentValues) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
params.put("parent_value", parentValue);
|
||||
params.put("parent_values", parentValues);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.getCascadingOptions(params)));
|
||||
}
|
||||
|
||||
/** POST /api/cascading-relation — 생성 */
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCascadingRelation(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "user_id", required = false) String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("user_id", userId != null ? userId : "system");
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.insertCascadingRelation(body)));
|
||||
}
|
||||
|
||||
/** PUT /api/cascading-relation/{id} — 수정 */
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCascadingRelation(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "user_id", required = false) String userId,
|
||||
@PathVariable Long id,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("user_id", userId != null ? userId : "system");
|
||||
body.put("id", id);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.updateCascadingRelation(body)));
|
||||
}
|
||||
|
||||
/** DELETE /api/cascading-relation/{id} — 소프트 삭제 (is_active = 'N') */
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCascadingRelation(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "user_id", required = false) String userId,
|
||||
@PathVariable Long id) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("user_id", userId != null ? userId : "system");
|
||||
params.put("id", id);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.deleteCascadingRelation(params)));
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CategoryTreeService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/category-tree")
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryTreeController {
|
||||
|
||||
private final CategoryTreeService categoryTreeService;
|
||||
|
||||
/**
|
||||
* GET /api/category-tree/test/all-category-keys
|
||||
* 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합)
|
||||
* 주의: /test/{tableName}/{columnName} 보다 먼저 매핑되어야 함
|
||||
*/
|
||||
@GetMapping("/test/all-category-keys")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryTreeKeyList(
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
|
||||
List<Map<String, Object>> keys = categoryTreeService.getCategoryTreeKeyList(companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(keys));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/category-tree/test/{tableName}/{columnName}
|
||||
* 카테고리 트리 조회
|
||||
*/
|
||||
@GetMapping("/test/{tableName}/{columnName}")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryTreeList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName) {
|
||||
|
||||
List<Map<String, Object>> tree = categoryTreeService.getCategoryTreeList(companyCode, tableName, columnName);
|
||||
return ResponseEntity.ok(ApiResponse.success(tree));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/category-tree/test/{tableName}/{columnName}/flat
|
||||
* 카테고리 플랫 리스트 조회
|
||||
*/
|
||||
@GetMapping("/test/{tableName}/{columnName}/flat")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryTreeFlatList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName) {
|
||||
|
||||
List<Map<String, Object>> list = categoryTreeService.getCategoryTreeFlatList(companyCode, tableName, columnName);
|
||||
return ResponseEntity.ok(ApiResponse.success(list));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/category-tree/test/value/{valueId}
|
||||
* 카테고리 값 단건 조회
|
||||
*/
|
||||
@GetMapping("/test/value/{valueId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryTreeInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable int valueId) {
|
||||
|
||||
Map<String, Object> value = categoryTreeService.getCategoryTreeInfo(companyCode, valueId);
|
||||
if (value == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값을 찾을 수 없습니다"));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/category-tree/test/value
|
||||
* 카테고리 값 생성
|
||||
*/
|
||||
@PostMapping("/test/value")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCategoryTree(
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (body.get("table_name") == null || body.get("column_name") == null
|
||||
|| body.get("value_code") == null || body.get("value_label") == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("tableName, columnName, valueCode, valueLabel은 필수입니다"));
|
||||
}
|
||||
|
||||
// 최고 관리자(*) 는 targetCompanyCode 로 회사 코드 오버라이드 가능
|
||||
String companyCode = userCompanyCode;
|
||||
String targetCompanyCode = (String) body.get("target_company_code");
|
||||
if (targetCompanyCode != null && "*".equals(userCompanyCode)) {
|
||||
companyCode = targetCompanyCode;
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, Object> value = categoryTreeService.insertCategoryTree(body, companyCode, userId);
|
||||
return ResponseEntity.ok(ApiResponse.success(value));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 생성 오류", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/category-tree/test/value/{valueId}
|
||||
* 카테고리 값 수정
|
||||
*/
|
||||
@PutMapping("/test/value/{valueId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCategoryTree(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@PathVariable int valueId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
try {
|
||||
Map<String, Object> value = categoryTreeService.updateCategoryTree(companyCode, valueId, body, userId);
|
||||
if (value == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값을 찾을 수 없습니다"));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(value));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 수정 오류", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/category-tree/test/value/{valueId}/can-delete
|
||||
* 카테고리 값 삭제 가능 여부 사전 확인
|
||||
*/
|
||||
@GetMapping("/test/value/{valueId}/can-delete")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCanDelete(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable int valueId) {
|
||||
|
||||
Map<String, Object> result = categoryTreeService.checkCanDelete(companyCode, valueId);
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/category-tree/test/value/{valueId}
|
||||
* 카테고리 값 삭제
|
||||
*/
|
||||
@DeleteMapping("/test/value/{valueId}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteCategoryTree(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable int valueId) {
|
||||
|
||||
try {
|
||||
boolean deleted = categoryTreeService.deleteCategoryTree(companyCode, valueId);
|
||||
if (!deleted) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값을 찾을 수 없습니다"));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "삭제되었습니다"));
|
||||
} catch (IllegalStateException e) {
|
||||
String msg = e.getMessage();
|
||||
if (msg != null && msg.startsWith("VALIDATION:")) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error(msg.substring("VALIDATION:".length())));
|
||||
}
|
||||
log.error("카테고리 값 삭제 오류", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error(msg));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 삭제 오류", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/category-tree/test/columns/{tableName}
|
||||
* 테이블의 카테고리 컬럼 목록 조회
|
||||
*/
|
||||
@GetMapping("/test/columns/{tableName}")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryTreeColumnList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String tableName) {
|
||||
|
||||
List<Map<String, Object>> columns = categoryTreeService.getCategoryTreeColumnList(companyCode, tableName);
|
||||
return ResponseEntity.ok(ApiResponse.success(columns));
|
||||
}
|
||||
}
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CategoryValueCascadingService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/category-value-cascading")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CategoryValueCascadingController {
|
||||
private final CategoryValueCascadingService categoryValueCascadingService;
|
||||
|
||||
/** GET /groups → 그룹 목록 조회 */
|
||||
@GetMapping("/groups")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingGroupList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingGroupList(params)));
|
||||
}
|
||||
|
||||
/** GET /groups/{groupId} → 그룹 상세 조회 (매핑 포함) */
|
||||
@GetMapping("/groups/{groupId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingGroupInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long groupId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_id", groupId);
|
||||
Map<String, Object> result = categoryValueCascadingService.getCategoryValueCascadingGroupInfo(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값 연쇄관계 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
/** GET /code/{code} → 관계 코드로 조회 */
|
||||
@GetMapping("/code/{code}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingGroupByCode(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
Map<String, Object> result = categoryValueCascadingService.getCategoryValueCascadingGroupByCode(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값 연쇄관계를 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
/** POST /groups → 그룹 생성 */
|
||||
@PostMapping("/groups")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCategoryValueCascadingGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.insertCategoryValueCascadingGroup(body)));
|
||||
}
|
||||
|
||||
/** PUT /groups/{groupId} → 그룹 수정 */
|
||||
@PutMapping("/groups/{groupId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCategoryValueCascadingGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long groupId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("group_id", groupId);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.updateCategoryValueCascadingGroup(body)));
|
||||
}
|
||||
|
||||
/** DELETE /groups/{groupId} → 그룹 소프트 삭제 */
|
||||
@DeleteMapping("/groups/{groupId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCategoryValueCascadingGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long groupId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_id", groupId);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.deleteCategoryValueCascadingGroup(params)));
|
||||
}
|
||||
|
||||
/** POST /groups/{groupId}/mappings → 매핑 일괄 저장 */
|
||||
@PostMapping("/groups/{groupId}/mappings")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> saveCategoryValueCascadingMappings(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long groupId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("group_id", groupId);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.saveCategoryValueCascadingMappings(body)));
|
||||
}
|
||||
|
||||
/** GET /parent-options/{code} → 부모 카테고리 값 목록 */
|
||||
@GetMapping("/parent-options/{code}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingParentOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingParentOptions(params)));
|
||||
}
|
||||
|
||||
/** GET /child-options/{code} → 자식 카테고리 값 목록 */
|
||||
@GetMapping("/child-options/{code}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingChildOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingChildOptions(params)));
|
||||
}
|
||||
|
||||
/** GET /options/{code} → 연쇄 옵션 조회 (parentValue/parentValues 파라미터) */
|
||||
@GetMapping("/options/{code}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingOptions(params)));
|
||||
}
|
||||
|
||||
/** GET /table/{tableName}/mappings → 테이블별 매핑 조회 */
|
||||
@GetMapping("/table/{tableName}/mappings")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingMappingsByTable(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String tableName) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("table_name", tableName);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingMappingsByTable(params)));
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CodeMergeService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/code-merge")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CodeMergeController {
|
||||
|
||||
private final CodeMergeService codeMergeService;
|
||||
|
||||
/** POST /api/code-merge/merge-all-tables — columnName 기준 전체 테이블 코드 병합 */
|
||||
@PostMapping("/merge-all-tables")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> mergeAllTables(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
codeMergeService.mergeAllTables(body),
|
||||
"코드 병합이 완료되었습니다."));
|
||||
}
|
||||
|
||||
/** GET /api/code-merge/tables-with-column/:columnName — 해당 컬럼을 가진 테이블 목록 */
|
||||
@GetMapping("/tables-with-column/{columnName}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getTablesWithColumn(
|
||||
@PathVariable String columnName) {
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
codeMergeService.getTablesWithColumn(columnName)));
|
||||
}
|
||||
|
||||
/** POST /api/code-merge/preview — columnName + oldValue 기준 영향 미리보기 */
|
||||
@PostMapping("/preview")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> previewCodeMerge(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
codeMergeService.previewCodeMerge(body)));
|
||||
}
|
||||
|
||||
/** POST /api/code-merge/merge-by-value — 값 기반 전체 테이블/컬럼 코드 병합 */
|
||||
@PostMapping("/merge-by-value")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> mergeByValue(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
codeMergeService.mergeByValue(body),
|
||||
"코드 병합이 완료되었습니다."));
|
||||
}
|
||||
|
||||
/** POST /api/code-merge/preview-by-value — 값 기반 영향 미리보기 */
|
||||
@PostMapping("/preview-by-value")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> previewByValue(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
codeMergeService.previewByValue(body)));
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,18 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Common Code Controller
|
||||
* Common Code Controller — 마스터-디테일 패턴.
|
||||
*
|
||||
* commonCodeRoutes.ts 포팅 — /api/common-codes 기준 15개 엔드포인트.
|
||||
* /info : code_info (1레벨 그룹 마스터)
|
||||
* /detail : code_detail (2레벨~ 트리)
|
||||
*
|
||||
* NOTE: Spring MVC 는 리터럴 세그먼트를 경로변수보다 우선하므로
|
||||
* /check-duplicate, /reorder 는 별도 선언 순서 없이도 정상 동작하나,
|
||||
* 가독성을 위해 구체적인 경로를 먼저 선언한다.
|
||||
* /check-duplicate 는 /{codeInfo} / /{id} 보다 먼저 매칭된다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/common-codes")
|
||||
@@ -27,151 +28,200 @@ public class CommonCodeController {
|
||||
|
||||
private final CommonCodeService service;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories
|
||||
// Node.js: { success, data: [...], total, message } (flat, not nested)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// CODE_INFO — 그룹 마스터
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
|
||||
@GetMapping("/categories")
|
||||
public ResponseEntity<Map<String, Object>> getCommonCodeCategoryList(
|
||||
/** 그룹 목록 (페이징/검색) */
|
||||
@GetMapping("/info")
|
||||
public ResponseEntity<Map<String, Object>> getCodeInfoList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
|
||||
params.put("company_code", companyCode);
|
||||
Map<String, Object> svcResult = service.getCommonCodeCategoryList(params);
|
||||
Map<String, Object> svc = service.getCodeInfoList(params);
|
||||
|
||||
Map<String, Object> response = new java.util.LinkedHashMap<>();
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", svcResult.get("data"));
|
||||
response.put("total", svcResult.get("total"));
|
||||
response.put("message", "카테고리 목록 조회 성공");
|
||||
response.put("data", svc.get("data"));
|
||||
response.put("total", svc.get("total"));
|
||||
response.put("message", "코드 그룹 목록 조회 성공");
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/check-duplicate ← /{categoryCode} 보다 먼저
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/categories/check-duplicate")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCategoryDuplicate(
|
||||
/** 그룹 중복 체크 — /{codeInfo} 보다 먼저 선언 */
|
||||
@GetMapping("/info/check-duplicate")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCodeInfoDuplicate(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam(defaultValue = "category_code") String field,
|
||||
@RequestParam(defaultValue = "code_info") String field,
|
||||
@RequestParam String value,
|
||||
@RequestParam(required = false) String excludeCode) {
|
||||
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.checkCategoryDuplicate(field, value, excludeCode, companyCode)));
|
||||
ApiResponse.success(service.checkCodeInfoDuplicate(field, value, excludeCode, companyCode)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// POST /categories
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
/** 그룹 단건 */
|
||||
@GetMapping("/info/{codeInfo}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCodeInfoInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String codeInfo) {
|
||||
|
||||
@PostMapping("/categories")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCommonCodeCategory(
|
||||
Map<String, Object> info = service.getCodeInfoInfo(codeInfo, companyCode);
|
||||
if (info == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("코드 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(info));
|
||||
}
|
||||
|
||||
/** 그룹 생성 */
|
||||
@PostMapping("/info")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCodeInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (body.get("category_code") == null || body.get("category_name") == null) {
|
||||
if (body.get("code_info") == null || body.get("code_name") == null) {
|
||||
return ResponseEntity.status(400)
|
||||
.body(ApiResponse.error("필수 필드가 누락되었습니다. (categoryCode, categoryName)"));
|
||||
.body(ApiResponse.error("필수 필드가 누락되었습니다. (code_info, code_name)"));
|
||||
}
|
||||
try {
|
||||
Map<String, Object> created = service.insertCommonCodeCategory(body, companyCode, userId);
|
||||
Map<String, Object> created = service.insertCodeInfo(body, companyCode, userId);
|
||||
return ResponseEntity.status(201)
|
||||
.body(ApiResponse.success(created, "카테고리가 성공적으로 생성되었습니다."));
|
||||
.body(ApiResponse.success(created, "코드 그룹이 성공적으로 생성되었습니다."));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 생성 실패", e);
|
||||
log.error("코드 그룹 생성 실패", e);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("카테고리 생성에 실패했습니다."));
|
||||
.body(ApiResponse.error("코드 그룹 생성에 실패했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// PUT /categories/:categoryCode
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@PutMapping("/categories/{categoryCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCommonCodeCategory(
|
||||
/** 그룹 수정 */
|
||||
@PutMapping("/info/{codeInfo}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCodeInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@PathVariable String categoryCode,
|
||||
@PathVariable String codeInfo,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
try {
|
||||
Map<String, Object> updated = service.updateCommonCodeCategory(categoryCode, body, companyCode, userId);
|
||||
Map<String, Object> updated = service.updateCodeInfo(codeInfo, body, companyCode, userId);
|
||||
if (updated == null) {
|
||||
return ResponseEntity.status(404)
|
||||
.body(ApiResponse.error("카테고리를 찾을 수 없습니다."));
|
||||
.body(ApiResponse.error("코드 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(updated, "카테고리가 성공적으로 수정되었습니다."));
|
||||
return ResponseEntity.ok(ApiResponse.success(updated, "코드 그룹이 성공적으로 수정되었습니다."));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 수정 실패", e);
|
||||
log.error("코드 그룹 수정 실패", e);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("카테고리 수정에 실패했습니다."));
|
||||
.body(ApiResponse.error("코드 그룹 수정에 실패했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DELETE /categories/:categoryCode
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@DeleteMapping("/categories/{categoryCode}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteCommonCodeCategory(
|
||||
/** 그룹 삭제 (CASCADE 로 code_detail 자식 자동 삭제) */
|
||||
@DeleteMapping("/info/{codeInfo}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteCodeInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode) {
|
||||
@PathVariable String codeInfo) {
|
||||
|
||||
try {
|
||||
service.deleteCommonCodeCategory(categoryCode, companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "카테고리가 성공적으로 삭제되었습니다."));
|
||||
service.deleteCodeInfo(codeInfo, companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "코드 그룹이 성공적으로 삭제되었습니다."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 삭제 실패", e);
|
||||
log.error("코드 그룹 삭제 실패", e);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("카테고리 삭제에 실패했습니다."));
|
||||
.body(ApiResponse.error("코드 그룹 삭제에 실패했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/:categoryCode/codes
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// CODE_DETAIL — 디테일 트리
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
|
||||
@GetMapping("/categories/{categoryCode}/codes")
|
||||
public ResponseEntity<Map<String, Object>> getCommonCodeList(
|
||||
/**
|
||||
* 디테일 트리.
|
||||
* - code_info 필수 (어느 그룹)
|
||||
* - parent_detail_id (optional): 지정 시 해당 부모의 자식만, 미지정 시 그룹 전체 트리 (재귀 CTE)
|
||||
* - flat=true 인 경우 동일 (트리는 평탄화된 depth+sort_order 순)
|
||||
*/
|
||||
@GetMapping("/detail")
|
||||
public ResponseEntity<Map<String, Object>> getCodeDetail(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@RequestParam("code_info") String codeInfo,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
|
||||
params.put("company_code", companyCode);
|
||||
Map<String, Object> svcResult = service.getCommonCodeList(categoryCode, params);
|
||||
|
||||
Map<String, Object> response = new java.util.LinkedHashMap<>();
|
||||
Object parentRaw = params.get("parent_detail_id");
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", svcResult.get("data"));
|
||||
response.put("total", svcResult.get("total"));
|
||||
response.put("message", "코드 목록 조회 성공");
|
||||
|
||||
if (parentRaw != null && !parentRaw.toString().isEmpty()) {
|
||||
// 특정 부모 직속 자식만
|
||||
Map<String, Object> svc = service.getCodeDetailList(codeInfo, params);
|
||||
response.put("data", svc.get("data"));
|
||||
response.put("total", svc.get("total"));
|
||||
} else {
|
||||
// 그룹 전체 트리 (재귀 CTE 로 평탄화)
|
||||
List<Map<String, Object>> tree = service.getCodeDetailTree(codeInfo, companyCode);
|
||||
response.put("data", tree);
|
||||
response.put("total", tree.size());
|
||||
}
|
||||
response.put("message", "코드 디테일 조회 성공");
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// POST /categories/:categoryCode/codes
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
/** 디테일 중복 체크 — /{id} 보다 먼저 선언 */
|
||||
@GetMapping("/detail/check-duplicate")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCodeDetailDuplicate(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam("code_info") String codeInfo,
|
||||
@RequestParam("code_value") String codeValue,
|
||||
@RequestParam(value = "exclude_id", required = false) Long excludeId) {
|
||||
|
||||
@PostMapping("/categories/{categoryCode}/codes")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCommonCode(
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.checkCodeDetailDuplicate(codeInfo, codeValue, excludeId, companyCode)));
|
||||
}
|
||||
|
||||
/** 디테일 단건 */
|
||||
@GetMapping("/detail/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCodeDetailInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable("id") Long codeDetailId) {
|
||||
|
||||
Map<String, Object> info = service.getCodeDetailInfo(codeDetailId, companyCode);
|
||||
if (info == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("코드를 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(info));
|
||||
}
|
||||
|
||||
/** 디테일 자식 존재 여부 */
|
||||
@GetMapping("/detail/{id}/has-children")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> hasCodeDetailChildren(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable("id") Long codeDetailId) {
|
||||
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.hasCodeDetailChildren(codeDetailId, companyCode)));
|
||||
}
|
||||
|
||||
/** 디테일 생성 */
|
||||
@PostMapping("/detail")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCodeDetail(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@PathVariable String categoryCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (body.get("code_value") == null || body.get("code_name") == null) {
|
||||
Object codeInfoRaw = body.get("code_info");
|
||||
if (codeInfoRaw == null || body.get("code_value") == null || body.get("code_name") == null) {
|
||||
return ResponseEntity.status(400)
|
||||
.body(ApiResponse.error("필수 필드가 누락되었습니다. (codeValue, codeName)"));
|
||||
.body(ApiResponse.error("필수 필드가 누락되었습니다. (code_info, code_value, code_name)"));
|
||||
}
|
||||
try {
|
||||
Map<String, Object> created = service.insertCommonCode(categoryCode, body, companyCode, userId);
|
||||
Map<String, Object> created = service.insertCodeDetail(codeInfoRaw.toString(), body, companyCode, userId);
|
||||
return ResponseEntity.status(201)
|
||||
.body(ApiResponse.success(created, "코드가 성공적으로 생성되었습니다."));
|
||||
} catch (Exception e) {
|
||||
@@ -181,122 +231,18 @@ public class CommonCodeController {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/:categoryCode/codes/check-duplicate ← /{codeValue} 보다 먼저
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/categories/{categoryCode}/codes/check-duplicate")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCodeDuplicate(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@RequestParam(defaultValue = "code_value") String field,
|
||||
@RequestParam String value,
|
||||
@RequestParam(required = false) String excludeCode) {
|
||||
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.checkCodeDuplicate(categoryCode, field, value, excludeCode, companyCode)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// PUT /categories/:categoryCode/codes/reorder ← /{codeValue} 보다 먼저
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@PutMapping("/categories/{categoryCode}/codes/reorder")
|
||||
public ResponseEntity<ApiResponse<Void>> updateCommonCodeOrder(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
Object codesRaw = body.get("codes");
|
||||
if (!(codesRaw instanceof List)) {
|
||||
return ResponseEntity.status(400)
|
||||
.body(ApiResponse.error("codes 배열이 필요합니다."));
|
||||
}
|
||||
try {
|
||||
service.updateCommonCodeOrder(categoryCode, (List<Map<String, Object>>) codesRaw, companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "정렬 순서가 변경되었습니다."));
|
||||
} catch (Exception e) {
|
||||
log.error("코드 정렬 변경 실패", e);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("정렬 순서 변경에 실패했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/:categoryCode/hierarchy
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/categories/{categoryCode}/hierarchy")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCommonCodeHierarchicalList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.getCommonCodeHierarchicalList(categoryCode, params)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/:categoryCode/tree
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/categories/{categoryCode}/tree")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCommonCodeTree(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode) {
|
||||
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.getCommonCodeTree(categoryCode, companyCode)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/:categoryCode/options
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/categories/{categoryCode}/options")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCommonCodeOptionList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.getCommonCodeOptionList(categoryCode, params)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/:categoryCode/codes/:codeValue/has-children
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/categories/{categoryCode}/codes/{codeValue}/has-children")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> hasChildren(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@PathVariable String codeValue) {
|
||||
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.hasChildren(categoryCode, codeValue, companyCode)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// PUT /categories/:categoryCode/codes/:codeValue
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@PutMapping("/categories/{categoryCode}/codes/{codeValue}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCommonCode(
|
||||
/** 디테일 수정 */
|
||||
@PutMapping("/detail/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCodeDetail(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@PathVariable String categoryCode,
|
||||
@PathVariable String codeValue,
|
||||
@PathVariable("id") Long codeDetailId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
try {
|
||||
Map<String, Object> updated = service.updateCommonCode(categoryCode, codeValue, body, companyCode, userId);
|
||||
Map<String, Object> updated = service.updateCodeDetail(codeDetailId, body, companyCode, userId);
|
||||
if (updated == null) {
|
||||
return ResponseEntity.status(404)
|
||||
.body(ApiResponse.error("코드를 찾을 수 없습니다."));
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("코드를 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(updated, "코드가 성공적으로 수정되었습니다."));
|
||||
} catch (Exception e) {
|
||||
@@ -306,18 +252,14 @@ public class CommonCodeController {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DELETE /categories/:categoryCode/codes/:codeValue
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@DeleteMapping("/categories/{categoryCode}/codes/{codeValue}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteCommonCode(
|
||||
/** 디테일 삭제 (CASCADE 로 자식 자동 삭제) */
|
||||
@DeleteMapping("/detail/{id}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteCodeDetail(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@PathVariable String codeValue) {
|
||||
@PathVariable("id") Long codeDetailId) {
|
||||
|
||||
try {
|
||||
service.deleteCommonCode(categoryCode, codeValue, companyCode);
|
||||
service.deleteCodeDetail(codeDetailId, companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "코드가 성공적으로 삭제되었습니다."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error(e.getMessage()));
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.provisioning.SuperAdminGuard;
|
||||
import com.erp.service.CompanyManagementService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -16,6 +18,7 @@ import java.util.Map;
|
||||
@Slf4j
|
||||
public class CompanyManagementController {
|
||||
|
||||
private final SuperAdminGuard guard;
|
||||
private final CompanyManagementService companyManagementService;
|
||||
|
||||
/**
|
||||
@@ -24,9 +27,12 @@ public class CompanyManagementController {
|
||||
*/
|
||||
@DeleteMapping("/{companyCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCompany(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String companyCode,
|
||||
@RequestBody(required = false) Map<String, Object> body) {
|
||||
|
||||
guard.enforce(request);
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
if (body != null) {
|
||||
@@ -52,7 +58,11 @@ public class CompanyManagementController {
|
||||
* ※ /{companyCode}/disk-usage 보다 먼저 정의 (경로 특이성으로 충돌 없음)
|
||||
*/
|
||||
@GetMapping("/disk-usage/all")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage() {
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage(
|
||||
HttpServletRequest request) {
|
||||
|
||||
guard.enforce(request);
|
||||
|
||||
try {
|
||||
Map<String, Object> data = companyManagementService.getAllCompaniesDiskUsage();
|
||||
return ResponseEntity.ok(ApiResponse.success(data));
|
||||
@@ -68,7 +78,11 @@ public class CompanyManagementController {
|
||||
*/
|
||||
@GetMapping("/{companyCode}/disk-usage")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyDiskUsage(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String companyCode) {
|
||||
|
||||
guard.enforce(request);
|
||||
|
||||
try {
|
||||
Map<String, Object> data = companyManagementService.getCompanyDiskUsage(companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(data));
|
||||
|
||||
@@ -18,23 +18,32 @@ public class DepartmentController {
|
||||
|
||||
private final DepartmentService departmentService;
|
||||
|
||||
private static final java.util.regex.Pattern ISO_DATE_PATTERN =
|
||||
java.util.regex.Pattern.compile("\\d{4}-\\d{2}-\\d{2}");
|
||||
|
||||
/**
|
||||
* 부서 목록 조회 (회사별).
|
||||
* 기본은 active 부서만. ?include_deleted=true 시 soft-delete 된 부서도 포함.
|
||||
* GET /api/departments/companies/{companyCode}/departments[?include_deleted=true]
|
||||
* ?base_date=YYYY-MM-DD 시 해당 시점에 active 했던 부서만 반환.
|
||||
* GET /api/departments/companies/{companyCode}/departments[?include_deleted=true][&base_date=YYYY-MM-DD]
|
||||
*/
|
||||
@GetMapping("/companies/{companyCode}/departments")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getDepartments(
|
||||
@PathVariable String companyCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted) {
|
||||
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted,
|
||||
@RequestParam(value = "base_date", required = false) String baseDate) {
|
||||
|
||||
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||
return ResponseEntity.status(403)
|
||||
.body(ApiResponse.error("해당 회사의 부서를 조회할 권한이 없습니다."));
|
||||
}
|
||||
if (baseDate != null && !baseDate.isBlank() && !ISO_DATE_PATTERN.matcher(baseDate).matches()) {
|
||||
return ResponseEntity.status(400)
|
||||
.body(ApiResponse.error("base_date 는 YYYY-MM-DD 형식이어야 합니다."));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted);
|
||||
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted, baseDate);
|
||||
return ResponseEntity.ok(ApiResponse.success(departments, "부서 목록 조회 성공"));
|
||||
}
|
||||
|
||||
@@ -66,6 +75,7 @@ public class DepartmentController {
|
||||
/**
|
||||
* 부서 생성
|
||||
* POST /api/departments/companies/{companyCode}/departments
|
||||
* body 에 approval_managers/dept_managers/org_leaders 배열 (각 element {user_id: 'xxx'}) 포함 가능. 최대 10명.
|
||||
*/
|
||||
@PostMapping("/companies/{companyCode}/departments")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> createDepartment(
|
||||
@@ -94,6 +104,7 @@ public class DepartmentController {
|
||||
/**
|
||||
* 부서 수정
|
||||
* PUT /api/departments/{deptCode}
|
||||
* body 에 approval_managers/dept_managers/org_leaders 배열 (각 element {user_id: 'xxx'}) 포함 가능. 최대 10명.
|
||||
*/
|
||||
@PutMapping("/{deptCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateDepartment(
|
||||
@@ -131,6 +142,135 @@ public class DepartmentController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 미리보기 (read-only validation).
|
||||
* POST /api/departments/companies/{companyCode}/departments/bulk/preview
|
||||
* body: { action: "create"|"update_department"|"update_manager", rows: List<Map> }
|
||||
* response: { rows: [...with row_index/result/error_detail], ok_count, error_count }
|
||||
*/
|
||||
@PostMapping("/companies/{companyCode}/departments/bulk/preview")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkPreview(
|
||||
@PathVariable String companyCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 처리할 권한이 없습니다."));
|
||||
}
|
||||
String action = body.get("action") != null ? body.get("action").toString() : "";
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> rows = body.get("rows") instanceof List
|
||||
? (List<Map<String, Object>>) body.get("rows") : null;
|
||||
if (rows == null) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("rows 가 없습니다."));
|
||||
}
|
||||
try {
|
||||
List<Map<String, Object>> result;
|
||||
switch (action) {
|
||||
case "create":
|
||||
result = departmentService.bulkPreviewCreate(companyCode, rows);
|
||||
break;
|
||||
case "update_department":
|
||||
result = departmentService.bulkPreviewUpdate(companyCode, "department", rows);
|
||||
break;
|
||||
case "update_manager":
|
||||
result = departmentService.bulkPreviewUpdate(companyCode, "manager", rows);
|
||||
break;
|
||||
default:
|
||||
return ResponseEntity.status(400)
|
||||
.body(ApiResponse.error("action 은 create|update_department|update_manager 중 하나."));
|
||||
}
|
||||
int ok = 0, err = 0;
|
||||
for (Map<String, Object> r : result) {
|
||||
if ("ok".equals(r.get("result"))) ok++; else err++;
|
||||
}
|
||||
Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("rows", result);
|
||||
data.put("ok_count", ok);
|
||||
data.put("error_count", err);
|
||||
return ResponseEntity.ok(ApiResponse.success(data, "미리보기 완료"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄등록 적용 (@Transactional, all-or-nothing).
|
||||
* POST /api/departments/companies/{companyCode}/departments/bulk/create
|
||||
* body: { rows: List<Map> } — 클라이언트가 미리보기 결과 중 ok 인 row 만 보내야 함.
|
||||
*/
|
||||
@PostMapping("/companies/{companyCode}/departments/bulk/create")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkCreate(
|
||||
@PathVariable String companyCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 등록할 권한이 없습니다."));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> rows = body.get("rows") instanceof List
|
||||
? (List<Map<String, Object>>) body.get("rows") : null;
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("등록할 데이터가 없습니다."));
|
||||
}
|
||||
try {
|
||||
int inserted = departmentService.bulkSaveCreate(companyCode, rows);
|
||||
Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("inserted", inserted);
|
||||
return ResponseEntity.status(201).body(ApiResponse.success(data, inserted + "건이 등록되었습니다."));
|
||||
} catch (DepartmentService.DuplicateDeptNameException e) {
|
||||
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄업데이트 적용 (@Transactional). mode = department | manager.
|
||||
* POST /api/departments/companies/{companyCode}/departments/bulk/update
|
||||
* body: { mode: "department"|"manager", rows: List<Map> } — 각 row 에 dept_code 필수.
|
||||
*/
|
||||
@PostMapping("/companies/{companyCode}/departments/bulk/update")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkUpdate(
|
||||
@PathVariable String companyCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 수정할 권한이 없습니다."));
|
||||
}
|
||||
String mode = body.get("mode") != null ? body.get("mode").toString() : "";
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> rows = body.get("rows") instanceof List
|
||||
? (List<Map<String, Object>>) body.get("rows") : null;
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("수정할 데이터가 없습니다."));
|
||||
}
|
||||
try {
|
||||
int updated = departmentService.bulkUpdate(companyCode, mode, rows);
|
||||
Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("updated", updated);
|
||||
return ResponseEntity.ok(ApiResponse.success(data, updated + "건이 수정되었습니다."));
|
||||
} catch (DepartmentService.DuplicateDeptNameException e) {
|
||||
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 삭제 (soft-delete, V1 slim scope).
|
||||
* - 기존 hard-delete → DELETED_AT = NOW() 마킹으로 변경
|
||||
|
||||
@@ -19,21 +19,21 @@ public class EntityReferenceController {
|
||||
private final EntityReferenceService entityReferenceService;
|
||||
|
||||
/**
|
||||
* GET /api/entity-reference/code/:codeCategory
|
||||
* GET /api/entity-reference/code/:codeInfo
|
||||
* 공통 코드 데이터 조회
|
||||
*
|
||||
* NOTE: Spring MVC는 리터럴 경로 세그먼트("code")를 변수 경로({tableName})보다 우선하므로
|
||||
* /code/{codeCategory} 가 /{tableName}/{columnName} 보다 먼저 매핑됨.
|
||||
* /code/{codeInfo} 가 /{tableName}/{columnName} 보다 먼저 매핑됨.
|
||||
*/
|
||||
@GetMapping("/code/{codeCategory}")
|
||||
@GetMapping("/code/{codeInfo}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCodeData(
|
||||
@PathVariable String codeCategory,
|
||||
@PathVariable String codeInfo,
|
||||
@RequestParam(required = false, defaultValue = "100") Integer limit,
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_category", codeCategory);
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("company_code", companyCode);
|
||||
params.put("limit", limit);
|
||||
if (search != null) params.put("search", search);
|
||||
@@ -41,7 +41,7 @@ public class EntityReferenceController {
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResponse.success(entityReferenceService.getCodeData(params)));
|
||||
} catch (Exception e) {
|
||||
log.error("공통 코드 데이터 조회 실패: codeCategory={}", codeCategory, e);
|
||||
log.error("공통 코드 데이터 조회 실패: codeInfo={}", codeInfo, e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("공통 코드 데이터 조회 중 오류가 발생했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/numbering-rule")
|
||||
@RequestMapping("/api/numbering-rules")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class NumberingRuleController {
|
||||
@@ -136,7 +136,7 @@ public class NumberingRuleController {
|
||||
Map<String, Object> formData = body != null ? (Map<String, Object>) body.get("form_data") : null;
|
||||
String manualInputValue = body != null ? (String) body.get("manual_input_value") : null;
|
||||
String code = numberingRuleService.previewCode(ruleId, companyCode, formData, manualInputValue);
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "미리보기 생성이 완료되었습니다."));
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "미리보기 생성이 완료되었습니다."));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
@@ -202,7 +202,7 @@ public class NumberingRuleController {
|
||||
Map<String, Object> formData = body != null ? (Map<String, Object>) body.get("form_data") : null;
|
||||
String manualInputValue = body != null ? (String) body.get("manual_input_value") : null;
|
||||
String code = numberingRuleService.previewCode(ruleId, companyCode, formData, manualInputValue);
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "미리보기 생성이 완료되었습니다."));
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "미리보기 생성이 완료되었습니다."));
|
||||
}
|
||||
|
||||
/** POST /{ruleId}/allocate → 코드 할당 (순번 증가) */
|
||||
@@ -215,7 +215,7 @@ public class NumberingRuleController {
|
||||
Map<String, Object> formData = body != null ? (Map<String, Object>) body.get("form_data") : null;
|
||||
String userInputCode = body != null ? (String) body.get("user_input_code") : null;
|
||||
String code = numberingRuleService.allocateCode(ruleId, companyCode, formData, userInputCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "코드 할당이 완료되었습니다."));
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "코드 할당이 완료되었습니다."));
|
||||
}
|
||||
|
||||
/** POST /{ruleId}/generate (deprecated) → allocateCode 위임 */
|
||||
@@ -224,18 +224,63 @@ public class NumberingRuleController {
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String ruleId) {
|
||||
String code = numberingRuleService.generateCode(ruleId, companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "코드 생성이 완료되었습니다."));
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "코드 생성이 완료되었습니다."));
|
||||
}
|
||||
|
||||
/** POST /{ruleId}/reset → 순번 초기화 */
|
||||
/** admin 권한 (SUPER_ADMIN / ADMIN / COMPANY_ADMIN) 만 시퀀스 직접 조작 가능 */
|
||||
private boolean isAdminRole(String role) {
|
||||
return "SUPER_ADMIN".equals(role)
|
||||
|| "ADMIN".equals(role)
|
||||
|| "COMPANY_ADMIN".equals(role);
|
||||
}
|
||||
|
||||
/** POST /{ruleId}/reset → 순번 초기화 (admin 전용) */
|
||||
@PostMapping("/{ruleId}/reset")
|
||||
public ResponseEntity<ApiResponse<Void>> resetSequence(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@PathVariable String ruleId) {
|
||||
if (!isAdminRole(role)) {
|
||||
return ResponseEntity.status(403)
|
||||
.body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
numberingRuleService.resetSequence(ruleId, companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "시퀀스가 초기화되었습니다."));
|
||||
}
|
||||
|
||||
/** PUT /{ruleId}/sequence → 현재 시퀀스 임의 값으로 수정 (admin 전용) */
|
||||
@PutMapping("/{ruleId}/sequence")
|
||||
public ResponseEntity<ApiResponse<Void>> updateRuleSequence(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@PathVariable String ruleId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
if (!isAdminRole(role)) {
|
||||
return ResponseEntity.status(403)
|
||||
.body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
Object seqObj = body.get("sequence");
|
||||
if (seqObj == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("sequence 값이 필요합니다."));
|
||||
}
|
||||
Integer newSequence;
|
||||
try {
|
||||
newSequence = (seqObj instanceof Number)
|
||||
? ((Number) seqObj).intValue()
|
||||
: Integer.parseInt(seqObj.toString());
|
||||
} catch (NumberFormatException e) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("sequence 는 정수여야 합니다."));
|
||||
}
|
||||
if (newSequence < 0) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("sequence 는 0 이상이어야 합니다."));
|
||||
}
|
||||
numberingRuleService.updateRuleSequence(ruleId, newSequence, companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "시퀀스가 수정되었습니다."));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// ■ Admin
|
||||
// ================================================================
|
||||
|
||||
@@ -593,10 +593,10 @@ public class ScreenManagementController {
|
||||
}
|
||||
|
||||
@PostMapping("/copy-code-category")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> copyCodeCategoryAndCodes(
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> copyCodeInfoAndCodes(
|
||||
@RequestBody Map<String, Object> body) {
|
||||
try {
|
||||
int count = service.copyCodeCategoryAndCodes(body);
|
||||
int count = service.copyCodeInfoAndCodes(body);
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of("count", count)));
|
||||
} catch (Exception e) {
|
||||
log.error("코드 카테고리 복제 실패", e);
|
||||
|
||||
@@ -1,373 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.TableCategoryValueService;
|
||||
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.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/table-categories")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TableCategoryValueController {
|
||||
|
||||
private final TableCategoryValueService service;
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Category Columns
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/** GET /api/table-categories/all-columns */
|
||||
@GetMapping("/all-columns")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getAllCategoryColumns(
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
try {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(service.getAllCategoryColumns(params)));
|
||||
} catch (Exception e) {
|
||||
log.error("전체 카테고리 컬럼 조회 실패", e);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("전체 카테고리 컬럼 조회 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/table-categories/{tableName}/columns */
|
||||
@GetMapping("/{tableName}/columns")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryColumns(
|
||||
@PathVariable String tableName,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
try {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(service.getCategoryColumns(params)));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 컬럼 조회 실패: tableName={}", tableName, e);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("카테고리 컬럼 조회 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Category Values — Read
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/** GET /api/table-categories/{tableName}/{columnName}/values */
|
||||
@GetMapping("/{tableName}/{columnName}/values")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryValues(
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestParam(required = false) String menuObjid,
|
||||
@RequestParam(required = false, defaultValue = "false") boolean includeInactive,
|
||||
@RequestParam(required = false) String filterCompanyCode,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
try {
|
||||
// SUPER_ADMIN 이 특정 회사 기준 필터링 요청 시 해당 companyCode 사용
|
||||
String effectiveCompanyCode = ("*".equals(companyCode) && filterCompanyCode != null)
|
||||
? filterCompanyCode : companyCode;
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
params.put("company_code", effectiveCompanyCode);
|
||||
params.put("include_inactive", includeInactive);
|
||||
if (menuObjid != null) params.put("menu_objid", Long.parseLong(menuObjid));
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(service.getCategoryValues(params)));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 조회 실패: tableName={}, columnName={}", tableName, columnName, e);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("카테고리 값 조회 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Category Values — Write
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/** POST /api/table-categories/values */
|
||||
@PostMapping("/values")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> addCategoryValue(
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
if (body.get("menu_objid") == null) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("menuObjid는 필수입니다"));
|
||||
}
|
||||
body.put("company_code", companyCode);
|
||||
body.put("user_id", userId);
|
||||
try {
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.success(service.addCategoryValue(body)));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(500).body(ApiResponse.error(
|
||||
e.getMessage() != null ? e.getMessage() : "카테고리 값 추가 중 오류가 발생했습니다"));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 추가 실패", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 추가 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
/** PUT /api/table-categories/values/{valueId} */
|
||||
@PutMapping("/values/{valueId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCategoryValue(
|
||||
@PathVariable Long valueId,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
body.put("value_id", valueId);
|
||||
body.put("company_code", companyCode);
|
||||
body.put("user_id", userId);
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResponse.success(service.updateCategoryValue(body)));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 수정 중 오류가 발생했습니다"));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 수정 실패: valueId={}", valueId, e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 수정 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE /api/table-categories/values/{valueId} */
|
||||
@DeleteMapping("/values/{valueId}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteCategoryValue(
|
||||
@PathVariable Long valueId,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("value_id", valueId);
|
||||
params.put("company_code", companyCode);
|
||||
params.put("user_id", userId);
|
||||
try {
|
||||
service.deleteCategoryValue(params);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "카테고리 값이 삭제되었습니다"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
// 사용 중인 경우 400
|
||||
if (e.getMessage() != null && e.getMessage().contains("삭제할 수 없습니다")) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
return ResponseEntity.status(500).body(ApiResponse.error(
|
||||
e.getMessage() != null ? e.getMessage() : "카테고리 값 삭제 중 오류가 발생했습니다"));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 삭제 실패: valueId={}", valueId, e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 삭제 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/table-categories/values/bulk-delete */
|
||||
@PostMapping("/values/bulk-delete")
|
||||
public ResponseEntity<ApiResponse<Void>> bulkDeleteCategoryValues(
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
Object rawIds = body.get("value_ids");
|
||||
if (!(rawIds instanceof List) || ((List<?>) rawIds).isEmpty()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("삭제할 값 ID 목록이 필요합니다"));
|
||||
}
|
||||
body.put("company_code", companyCode);
|
||||
body.put("user_id", userId);
|
||||
try {
|
||||
service.bulkDeleteCategoryValues(body);
|
||||
int count = ((List<?>) rawIds).size();
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(null, count + "개의 카테고리 값이 삭제되었습니다"));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 일괄 삭제 실패", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 일괄 삭제 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/table-categories/values/reorder */
|
||||
@PostMapping("/values/reorder")
|
||||
public ResponseEntity<ApiResponse<Void>> reorderCategoryValues(
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
Object rawIds = body.get("ordered_value_ids");
|
||||
if (!(rawIds instanceof List) || ((List<?>) rawIds).isEmpty()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("순서 정보가 필요합니다"));
|
||||
}
|
||||
body.put("company_code", companyCode);
|
||||
try {
|
||||
service.reorderCategoryValues(body);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "카테고리 값 순서가 변경되었습니다"));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 순서 변경 실패", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 순서 변경 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Labels by Codes
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/** POST /api/table-categories/labels-by-codes */
|
||||
@PostMapping("/labels-by-codes")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryLabelsByCodes(
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
Object rawCodes = body.get("value_codes");
|
||||
if (!(rawCodes instanceof List) || ((List<?>) rawCodes).isEmpty()) {
|
||||
return ResponseEntity.ok(ApiResponse.success(new java.util.LinkedHashMap<>()));
|
||||
}
|
||||
body.put("company_code", companyCode);
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResponse.success(service.getCategoryLabelsByCodes(body)));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 라벨 조회 실패", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 라벨 조회 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Second-Level Menus (NOTE: 리터럴 경로이므로 variable 경로보다 우선)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/** GET /api/table-categories/second-level-menus */
|
||||
@GetMapping("/second-level-menus")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getSecondLevelMenus(
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
try {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(service.getSecondLevelMenus(params)));
|
||||
} catch (Exception e) {
|
||||
log.error("2레벨 메뉴 목록 조회 실패", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("2레벨 메뉴 목록 조회 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Column Mapping
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/** GET /api/table-categories/column-mapping/{tableName}/{menuObjid} */
|
||||
@GetMapping("/column-mapping/{tableName}/{menuObjid}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getColumnMapping(
|
||||
@PathVariable String tableName,
|
||||
@PathVariable Long menuObjid,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
try {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("menu_objid", menuObjid);
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(service.getColumnMapping(params)));
|
||||
} catch (Exception e) {
|
||||
log.error("컬럼 매핑 조회 실패: tableName={}, menuObjid={}", tableName, menuObjid, e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("컬럼 매핑 조회 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/table-categories/logical-columns/{tableName}/{menuObjid} */
|
||||
@GetMapping("/logical-columns/{tableName}/{menuObjid}")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getLogicalColumns(
|
||||
@PathVariable String tableName,
|
||||
@PathVariable Long menuObjid,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
try {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("menu_objid", menuObjid);
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(service.getLogicalColumns(params)));
|
||||
} catch (Exception e) {
|
||||
log.error("논리적 컬럼 목록 조회 실패: tableName={}, menuObjid={}", tableName, menuObjid, e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("논리적 컬럼 목록 조회 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/table-categories/column-mapping */
|
||||
@PostMapping("/column-mapping")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> createColumnMapping(
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
String tableName = (String) body.get("table_name");
|
||||
String logicalColumnName = (String) body.get("logical_column_name");
|
||||
String physicalColumnName = (String) body.get("physical_column_name");
|
||||
Object menuObjid = body.get("menu_objid");
|
||||
|
||||
if (tableName == null || logicalColumnName == null
|
||||
|| physicalColumnName == null || menuObjid == null) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(
|
||||
"tableName, logicalColumnName, physicalColumnName, menuObjid는 필수입니다"));
|
||||
}
|
||||
|
||||
body.put("company_code", companyCode);
|
||||
body.put("user_id", userId);
|
||||
// menuObjid를 Long으로 보장
|
||||
body.put("menu_objid", toLong(menuObjid));
|
||||
|
||||
try {
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.success(service.createColumnMapping(body), "컬럼 매핑이 생성되었습니다"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(500).body(ApiResponse.error(
|
||||
e.getMessage() != null ? e.getMessage() : "컬럼 매핑 생성 중 오류가 발생했습니다"));
|
||||
} catch (Exception e) {
|
||||
log.error("컬럼 매핑 생성 실패", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("컬럼 매핑 생성 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/table-categories/column-mapping/{tableName}/{columnName}/all
|
||||
* NOTE: 3-segment 경로이므로 /{mappingId} 1-segment 경로보다 Spring이 우선 매핑.
|
||||
*/
|
||||
@DeleteMapping("/column-mapping/{tableName}/{columnName}/all")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteColumnMappingsByColumn(
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
try {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
params.put("company_code", companyCode);
|
||||
int deleted = service.deleteColumnMappingsByColumn(params);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("deleted_count", deleted);
|
||||
return ResponseEntity.ok(ApiResponse.success(data,
|
||||
deleted + "개의 컬럼 매핑이 삭제되었습니다"));
|
||||
} catch (Exception e) {
|
||||
log.error("테이블+컬럼 기준 매핑 삭제 실패: tableName={}, columnName={}", tableName, columnName, e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("컬럼 매핑 삭제 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE /api/table-categories/column-mapping/{mappingId} */
|
||||
@DeleteMapping("/column-mapping/{mappingId}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteColumnMapping(
|
||||
@PathVariable Long mappingId,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("mapping_id", mappingId);
|
||||
params.put("company_code", companyCode);
|
||||
try {
|
||||
service.deleteColumnMapping(params);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "컬럼 매핑이 삭제되었습니다"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(500).body(ApiResponse.error(
|
||||
e.getMessage() != null ? e.getMessage() : "컬럼 매핑 삭제 중 오류가 발생했습니다"));
|
||||
} catch (Exception e) {
|
||||
log.error("컬럼 매핑 삭제 실패: mappingId={}", mappingId, e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("컬럼 매핑 삭제 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── private util ───────────────────────────────────────────────
|
||||
|
||||
private long toLong(Object val) {
|
||||
if (val == null) return 0L;
|
||||
if (val instanceof Number) return ((Number) val).longValue();
|
||||
try { return Long.parseLong(val.toString()); } catch (NumberFormatException e) { return 0L; }
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,11 @@ public class TableManagementController {
|
||||
@PutMapping("/tables/{tableName}/label")
|
||||
public ResponseEntity<ApiResponse<Void>> updateTableLabel(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
String displayName = (String) body.get("display_name");
|
||||
String description = (String) body.get("description");
|
||||
if (displayName == null || displayName.isBlank()) {
|
||||
@@ -105,7 +109,11 @@ public class TableManagementController {
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestBody Map<String, Object> settings,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
return doUpdateColumnSettings(tableName, columnName, settings, companyCode);
|
||||
}
|
||||
|
||||
@@ -115,7 +123,11 @@ public class TableManagementController {
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestBody Map<String, Object> settings,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
return doUpdateColumnSettings(tableName, columnName, settings, companyCode);
|
||||
}
|
||||
|
||||
@@ -136,7 +148,11 @@ public class TableManagementController {
|
||||
public ResponseEntity<ApiResponse<Void>> updateAllColumnSettingsPost(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody List<Map<String, Object>> columnSettings,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
return doUpdateAllColumnSettings(tableName, columnSettings, companyCode);
|
||||
}
|
||||
|
||||
@@ -145,7 +161,11 @@ public class TableManagementController {
|
||||
public ResponseEntity<ApiResponse<Void>> updateAllColumnSettingsBatch(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody List<Map<String, Object>> columnSettings,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
return doUpdateAllColumnSettings(tableName, columnSettings, companyCode);
|
||||
}
|
||||
|
||||
@@ -166,7 +186,11 @@ public class TableManagementController {
|
||||
public ResponseEntity<ApiResponse<Void>> updateColumnWebType(
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
String webType = (String) body.get("web_type");
|
||||
if (webType == null || webType.isBlank()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("웹 타입이 필요합니다."));
|
||||
@@ -183,7 +207,11 @@ public class TableManagementController {
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
String inputType = (String) body.get("input_type");
|
||||
if (tableName == null || columnName == null || inputType == null || inputType.isBlank()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("테이블명, 컬럼명, 입력 타입이 모두 필요합니다."));
|
||||
@@ -241,7 +269,11 @@ public class TableManagementController {
|
||||
@PutMapping("/tables/{tableName}/primary-key")
|
||||
public ResponseEntity<ApiResponse<Void>> setTablePrimaryKey(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> columns = (List<String>) body.get("columns");
|
||||
if (tableName == null || columns == null || columns.isEmpty()) {
|
||||
@@ -256,7 +288,11 @@ public class TableManagementController {
|
||||
@PostMapping("/tables/{tableName}/indexes")
|
||||
public ResponseEntity<ApiResponse<Void>> toggleTableIndex(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
}
|
||||
String columnName = (String) body.get("column_name");
|
||||
String indexType = (String) body.get("index_type");
|
||||
String action = (String) body.get("action");
|
||||
@@ -281,7 +317,11 @@ public class TableManagementController {
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
}
|
||||
Object nullableObj = body.get("nullable");
|
||||
if (tableName == null || columnName == null || !(nullableObj instanceof Boolean)) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("tableName, columnName, nullable(boolean)이 필요합니다."));
|
||||
@@ -299,7 +339,11 @@ public class TableManagementController {
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
}
|
||||
Object uniqueObj = body.get("unique");
|
||||
if (tableName == null || columnName == null || !(uniqueObj instanceof Boolean)) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("tableName, columnName, unique(boolean)이 필요합니다."));
|
||||
@@ -325,6 +369,57 @@ public class TableManagementController {
|
||||
"테이블 데이터를 성공적으로 조회했습니다."));
|
||||
}
|
||||
|
||||
/** POST /api/table-management/tables/:tableName/aggregate
|
||||
* body: { aggregation: "count"|"sum"|..., columnName?: string, filters?: [...] }
|
||||
* → { value: number }
|
||||
*/
|
||||
@PostMapping("/tables/{tableName}/aggregate")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> aggregateTableData(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> options) {
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
tableManagementService.aggregateTableData(tableName, options == null ? Map.of() : options),
|
||||
"테이블 집계를 성공적으로 조회했습니다."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/table-management/tables/:tableName/aggregate-group
|
||||
* body: { aggregation, groupBy, valueColumn?, filters?, limit?, orderDir? }
|
||||
* → { rows: [{ group, value }, ...] }
|
||||
*/
|
||||
@PostMapping("/tables/{tableName}/aggregate-group")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> aggregateTableGroup(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> options) {
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
tableManagementService.aggregateTableGroup(tableName, options == null ? Map.of() : options),
|
||||
"테이블 그룹 집계를 성공적으로 조회했습니다."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/table-management/tables/:tableName/select-rows
|
||||
* body: { columns?, filters?, orderBy?, limit?, offset? }
|
||||
* → { rows: [{...}, ...] }
|
||||
*/
|
||||
@PostMapping("/tables/{tableName}/select-rows")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> selectTableRows(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> options) {
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
tableManagementService.selectTableRows(tableName, options == null ? Map.of() : options),
|
||||
"테이블 row 를 성공적으로 조회했습니다."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/table-management/tables/:tableName/record (단일 레코드) */
|
||||
@PostMapping("/tables/{tableName}/record")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getTableRecord(
|
||||
@@ -366,7 +461,11 @@ public class TableManagementController {
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> addTableData(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> data,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
if (data == null || data.isEmpty()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("추가할 데이터가 필요합니다."));
|
||||
}
|
||||
@@ -399,7 +498,11 @@ public class TableManagementController {
|
||||
public ResponseEntity<ApiResponse<Void>> editTableData(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> originalData = (Map<String, Object>) body.get("original_data");
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -433,7 +536,11 @@ public class TableManagementController {
|
||||
@DeleteMapping("/tables/{tableName}/delete")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteTableData(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Object body) {
|
||||
@RequestBody Object body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
List<Map<String, Object>> dataList;
|
||||
if (body instanceof List) {
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -457,7 +564,11 @@ public class TableManagementController {
|
||||
@PostMapping("/tables/{tableName}/log")
|
||||
public ResponseEntity<ApiResponse<Void>> createLogTable(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> logColumns = (List<String>) body.get("log_columns");
|
||||
boolean isActive = Boolean.TRUE.equals(body.get("is_active"));
|
||||
@@ -487,7 +598,11 @@ public class TableManagementController {
|
||||
@PostMapping("/tables/{tableName}/log/toggle")
|
||||
public ResponseEntity<ApiResponse<Void>> toggleLogTable(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
boolean isActive = Boolean.TRUE.equals(body.get("is_active"));
|
||||
tableManagementService.toggleLogTable(tableName, isActive);
|
||||
return ResponseEntity.ok(ApiResponse.success(null,
|
||||
@@ -544,7 +659,11 @@ public class TableManagementController {
|
||||
@PostMapping("/multi-table-save")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> multiTableSave(
|
||||
@RequestBody Map<String, Object> payload,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
tableManagementService.multiTableSave(payload, companyCode),
|
||||
"다중 테이블 저장이 완료되었습니다."));
|
||||
@@ -575,4 +694,16 @@ public class TableManagementController {
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
tableManagementService.checkDatabaseConnection(), "데이터베이스 연결 상태를 확인했습니다."));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 권한 헬퍼
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
private boolean isAdmin(String role) {
|
||||
return isSuperAdmin(role) || "COMPANY_ADMIN".equals(role);
|
||||
}
|
||||
|
||||
private boolean isSuperAdmin(String roleOrCode) {
|
||||
return "*".equals(roleOrCode) || "SUPER_ADMIN".equals(roleOrCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.erp.crosstenant;
|
||||
|
||||
import com.erp.tenant.DbContextHolder;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* Cross-tenant 어드민 엔드포인트 진입 가드.
|
||||
@@ -42,4 +44,16 @@ public final class CrossTenantContext {
|
||||
public static boolean isMetaContext() {
|
||||
return DbContextHolder.isMeta();
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리 호스트(solution.invyone.com / admin.invyone.com / localhost / 베이스 도메인) 외엔 거절.
|
||||
* cross-tenant 작업은 plane 격리상 관리 호스트에서만 허용. SuperAdminGuard.isTenantHost 와 동일 규칙.
|
||||
*/
|
||||
public static void requireManagementHost(HttpServletRequest request) {
|
||||
String host = request.getHeader("Host");
|
||||
if (com.erp.provisioning.SuperAdminGuard.isTenantHost(host)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"Cross-tenant operations are only available on the management site");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,12 @@ public class CrossTenantController {
|
||||
*/
|
||||
@GetMapping("/_active-companies")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> activeCompaniesSmoke(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -92,6 +98,12 @@ public class CrossTenantController {
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> listUsers(
|
||||
HttpServletRequest request,
|
||||
@RequestParam Map<String, Object> queryParams) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -173,6 +185,12 @@ public class CrossTenantController {
|
||||
Map<String, Object> queryParams,
|
||||
String mapperId,
|
||||
boolean wrapSearchWithPercent) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
|
||||
@@ -39,6 +39,12 @@ public class CrossTenantDeptController {
|
||||
public ResponseEntity<Map<String, Object>> listDepartments(
|
||||
HttpServletRequest request,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(errorBody(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(errorBody("super_admin_required", request.getRequestURI()));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.erp.crosstenant;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.provisioning.CompanyAuditLogService;
|
||||
import com.erp.service.RoleService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -33,6 +34,7 @@ public class CrossTenantRoleController {
|
||||
|
||||
private final CrossTenantExecutor executor;
|
||||
private final RoleService roleService;
|
||||
private final CompanyAuditLogService auditLogService;
|
||||
|
||||
// ── 권한 그룹 CRUD ──────────────────────────────────────────────
|
||||
|
||||
@@ -49,6 +51,7 @@ public class CrossTenantRoleController {
|
||||
if (g != null) return g;
|
||||
|
||||
String targetCompany = (String) body.get("company_code");
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
|
||||
Map<String, Object> params = new HashMap<>(body);
|
||||
@@ -62,6 +65,10 @@ public class CrossTenantRoleController {
|
||||
}
|
||||
return roleService.createRoleGroup(params);
|
||||
});
|
||||
auditLogService.log(targetCompany, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
|
||||
(String) body.get("auth_code"),
|
||||
auditDetails(request, null));
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(result, "권한 그룹 생성 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
@@ -81,6 +88,7 @@ public class CrossTenantRoleController {
|
||||
if (g != null) return g;
|
||||
|
||||
String targetCompany = (String) body.get("company_code");
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
|
||||
Map<String, Object> params = new HashMap<>(body);
|
||||
@@ -94,6 +102,10 @@ public class CrossTenantRoleController {
|
||||
}
|
||||
return roleService.updateRoleGroup(params);
|
||||
});
|
||||
auditLogService.log(targetCompany, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
|
||||
id,
|
||||
auditDetails(request, id));
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "권한 그룹 수정 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
@@ -111,12 +123,17 @@ public class CrossTenantRoleController {
|
||||
ResponseEntity<ApiResponse<Void>> g = guardVoid(request);
|
||||
if (g != null) return g;
|
||||
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
executor.runInCompany(companyCode, () -> {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("objid", id);
|
||||
roleService.deleteRoleGroup(p);
|
||||
});
|
||||
auditLogService.log(companyCode, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
|
||||
id,
|
||||
auditDetails(request, id));
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "권한 그룹 삭제 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
@@ -266,6 +283,12 @@ public class CrossTenantRoleController {
|
||||
// ── 가드 헬퍼 (응답 타입별로 3가지 — Map/Void/List) ────────
|
||||
|
||||
private ResponseEntity<ApiResponse<Map<String, Object>>> guardMap(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -278,6 +301,12 @@ public class CrossTenantRoleController {
|
||||
}
|
||||
|
||||
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -290,6 +319,12 @@ public class CrossTenantRoleController {
|
||||
}
|
||||
|
||||
private ResponseEntity<ApiResponse<List<Map<String, Object>>>> guardList(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -301,6 +336,14 @@ public class CrossTenantRoleController {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** audit log details 기본 맵 생성 헬퍼. */
|
||||
private Map<String, Object> auditDetails(HttpServletRequest request, String roleId) {
|
||||
Map<String, Object> d = new HashMap<>();
|
||||
d.put("host", request.getHeader("Host"));
|
||||
if (roleId != null) d.put("role_id", roleId);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** "Y"/"N"/null 정규화 — RoleController 의 동일 헬퍼 미러. */
|
||||
private String asYn(Object raw) {
|
||||
if (raw == null) return null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.erp.crosstenant;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.provisioning.CompanyAuditLogService;
|
||||
import com.erp.service.AdminService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -36,6 +37,7 @@ public class CrossTenantUserController {
|
||||
|
||||
private final CrossTenantExecutor executor;
|
||||
private final AdminService adminService;
|
||||
private final CompanyAuditLogService auditLogService;
|
||||
|
||||
// ── 등록 / 수정 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -51,9 +53,14 @@ public class CrossTenantUserController {
|
||||
if (guard != null) return guard;
|
||||
|
||||
String targetCompanyCode = (String) body.get("company_code");
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
|
||||
() -> adminService.saveUser(body));
|
||||
auditLogService.log(targetCompanyCode, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_USER_CREATE,
|
||||
(String) body.get("user_id"),
|
||||
auditDetails(request, (String) body.get("user_id")));
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "사용자 저장 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
@@ -116,6 +123,7 @@ public class CrossTenantUserController {
|
||||
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
|
||||
if (guard != null) return guard;
|
||||
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
executor.runInCompany(companyCode, () -> {
|
||||
Map<String, Object> existing = adminService.getUserInfo(userId);
|
||||
@@ -124,6 +132,10 @@ public class CrossTenantUserController {
|
||||
}
|
||||
adminService.changeUserStatus(userId, "inactive");
|
||||
});
|
||||
auditLogService.log(companyCode, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_USER_DELETE,
|
||||
userId,
|
||||
auditDetails(request, userId));
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "사용자 삭제 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
|
||||
@@ -166,9 +178,14 @@ public class CrossTenantUserController {
|
||||
|
||||
String targetCompanyCode = (String) body.get("company_code");
|
||||
String userId = (String) body.get("user_id");
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
executor.runInCompany(targetCompanyCode, () ->
|
||||
adminService.resetUserPassword(userId));
|
||||
auditLogService.log(targetCompanyCode, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_PW_RESET,
|
||||
userId,
|
||||
auditDetails(request, userId));
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
|
||||
@@ -260,6 +277,12 @@ public class CrossTenantUserController {
|
||||
|
||||
/** Map<String,Object> 응답용 가드 — null 이면 통과, 아니면 그대로 반환. */
|
||||
private ResponseEntity<ApiResponse<Map<String, Object>>> guard(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -273,6 +296,12 @@ public class CrossTenantUserController {
|
||||
|
||||
/** Void 응답용 가드 (제네릭만 다름). */
|
||||
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -283,4 +312,12 @@ public class CrossTenantUserController {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** audit log details 기본 맵 생성 헬퍼. */
|
||||
private Map<String, Object> auditDetails(HttpServletRequest request, String targetUserId) {
|
||||
Map<String, Object> d = new HashMap<>();
|
||||
d.put("host", request.getHeader("Host"));
|
||||
if (targetUserId != null) d.put("target_user_id", targetUserId);
|
||||
return d;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +183,130 @@ public class StartupSchemaMigrator {
|
||||
// conditional 매핑(when/then/default) 규칙 저장용.
|
||||
// direct/fixed 매핑은 NULL. 메타 DB 는 Flyway V021 로도 적용되지만
|
||||
// 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
|
||||
"ALTER TABLE BATCH_MAPPINGS ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB"
|
||||
"ALTER TABLE BATCH_MAPPINGS ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB",
|
||||
|
||||
// V022 / RUN_088: 부서별 다중 관리자(결재/부서/조직장) 매핑 테이블.
|
||||
// 기존 DEPT_INFO.APPROVAL_MANAGER/DEPT_MANAGER 단일 컬럼 → 매핑 테이블로 다중화.
|
||||
// role: 'approval' | 'dept' | 'org_leader'. 부서 hard-delete 시 CASCADE 로 정리.
|
||||
// 메타 DB 는 Flyway V022 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS DEPT_MANAGERS (
|
||||
DEPT_CODE VARCHAR(1024) NOT NULL,
|
||||
USER_ID VARCHAR(50) NOT NULL,
|
||||
ROLE VARCHAR(20) NOT NULL,
|
||||
SORT_ORDER INTEGER NOT NULL DEFAULT 1,
|
||||
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (DEPT_CODE, USER_ID, ROLE),
|
||||
CONSTRAINT chk_dept_managers_role
|
||||
CHECK (ROLE IN ('approval', 'dept', 'org_leader')),
|
||||
CONSTRAINT fk_dept_managers_dept
|
||||
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_dept_managers_role ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER)",
|
||||
|
||||
// V023 / RUN_089: MENU_INFO 에 IS_SOLUTION_ONLY 컬럼 추가.
|
||||
// 솔루션 관리 호스트(solution.invyone.com 등) 에서만 노출되는 메뉴 플래그.
|
||||
// 테넌트 사이트에선 mapper SQL 단계에서 제외. 메타 DB 는 Flyway V023 으로도 적용되지만
|
||||
// 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
|
||||
"ALTER TABLE MENU_INFO ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL",
|
||||
|
||||
// V023 데이터 동기화: 솔루션 전용 메뉴 마킹.
|
||||
// 회사관리 / 회사 프로비저닝 / 감사로그는 관리 호스트에서만 노출돼야 함.
|
||||
// 이미 TRUE 인 행은 그대로 두기 위해 false 인 행만 갱신.
|
||||
"""
|
||||
UPDATE MENU_INFO
|
||||
SET IS_SOLUTION_ONLY = TRUE
|
||||
WHERE IS_SOLUTION_ONLY = FALSE
|
||||
AND MENU_URL IN (
|
||||
'/admin/sysMng/subdomainList',
|
||||
'/admin/userMng/companyList',
|
||||
'/admin/audit-log'
|
||||
)
|
||||
""",
|
||||
|
||||
// V024 / RUN_089: TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO rename.
|
||||
// 5/15 common-code 재설계(commit 2348800e) 가 mapper SQL 의 컬럼 참조명만
|
||||
// 바꾸고 DB rename 을 빠뜨려, 테이블타입관리 컬럼 조회 API 가 500 반환.
|
||||
// PostgreSQL 은 RENAME COLUMN 에 IF EXISTS 가 없어서 DO 블록으로 멱등 처리:
|
||||
// - CODE_CATEGORY 만 있는 기존 테넌트: rename 수행
|
||||
// - 이미 CODE_INFO 인 신규 테넌트: no-op
|
||||
// - 둘 다 있거나 둘 다 없는 비정상 상태: no-op (방어적)
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'table_type_columns'
|
||||
AND column_name = 'code_category'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'table_type_columns'
|
||||
AND column_name = 'code_info'
|
||||
) THEN
|
||||
ALTER TABLE TABLE_TYPE_COLUMNS
|
||||
RENAME COLUMN CODE_CATEGORY TO CODE_INFO;
|
||||
END IF;
|
||||
END $$
|
||||
""",
|
||||
|
||||
// V025 / RUN_090 (1) TABLE_TYPE_COLUMNS 중복 행 정리.
|
||||
// PK 가 id 단일 (varchar) 인데 (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) 에는
|
||||
// UNIQUE 가 없어서 같은 키로 row 가 여러 개 INSERT 된 이력이 있음.
|
||||
// 메타 DB 실측: 35K rows 중 2 그룹 4 row 가 중복. 그 그룹들은 동일 데이터를
|
||||
// updated_date NULL 짜리 옛 row 와 2026-03-16 마지막 갱신 row 가 공존하는 형태.
|
||||
// 가장 최근 (updated_date DESC NULLS LAST, id::bigint DESC) 행만 남기고 제거.
|
||||
// 테넌트 DB 들은 실측상 중복 없음 → DELETE 0건. 멱등 (재실행해도 변화 없음).
|
||||
"""
|
||||
DELETE FROM TABLE_TYPE_COLUMNS
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE
|
||||
ORDER BY UPDATED_DATE DESC NULLS LAST,
|
||||
id::bigint DESC
|
||||
) AS rn
|
||||
FROM TABLE_TYPE_COLUMNS
|
||||
) r
|
||||
WHERE r.rn > 1
|
||||
)
|
||||
""",
|
||||
|
||||
// V025 / RUN_090 (2) ON CONFLICT 매칭용 UNIQUE INDEX 추가.
|
||||
// mapper 의 upsertColumnSettings / upsertNullable / upsertUnique /
|
||||
// upsertColumnInputType 모두 ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE)
|
||||
// 를 쓰는데 DB 엔 매칭 unique 제약이 없어서 모든 쓰기 API 가 500.
|
||||
// 인덱스 형태로 등록하면 ON CONFLICT 가 인식하고 ADD CONSTRAINT 식의
|
||||
// IF NOT EXISTS 누락 문제도 회피.
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE)",
|
||||
|
||||
// V026 / RUN_091: TABLE_TYPE_COLUMNS.INPUT_TYPE legacy → 표준 8종 정리.
|
||||
// 5/15 common-code 재설계가 화이트리스트를 8종으로 좁혔지만 운영 DB 의
|
||||
// 옛 값(category 886, select 149, textarea 102, checkbox 55, radio 12,
|
||||
// datetime 2, boolean 1) 을 정리하는 마이그레이션을 빠뜨림.
|
||||
// 매핑:
|
||||
// category / select / radio / checkbox / boolean → code (commonCode 통합 의도)
|
||||
// textarea → text (single/multi line 구분 손실 — UI 동작 가벼움)
|
||||
// datetime → date
|
||||
// 메타 DB 1,207 row 갱신. 테넌트 DB 들은 비어있어 영향 0.
|
||||
// WHERE 절로 멱등 (재실행 시 0 row).
|
||||
"""
|
||||
UPDATE TABLE_TYPE_COLUMNS
|
||||
SET INPUT_TYPE = CASE INPUT_TYPE
|
||||
WHEN 'category' THEN 'code'
|
||||
WHEN 'select' THEN 'code'
|
||||
WHEN 'radio' THEN 'code'
|
||||
WHEN 'checkbox' THEN 'code'
|
||||
WHEN 'boolean' THEN 'code'
|
||||
WHEN 'textarea' THEN 'text'
|
||||
WHEN 'datetime' THEN 'date'
|
||||
END,
|
||||
UPDATED_DATE = NOW()
|
||||
WHERE INPUT_TYPE IN ('category','select','radio','checkbox','boolean','textarea','datetime')
|
||||
"""
|
||||
);
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
@@ -206,9 +329,18 @@ public class StartupSchemaMigrator {
|
||||
}
|
||||
|
||||
int ok = 0, fail = 0;
|
||||
List<String> failedDbs = new java.util.ArrayList<>();
|
||||
for (String db : tenantDbs) {
|
||||
if (db == null || db.isBlank() || db.equalsIgnoreCase(metaDb)) continue;
|
||||
if (applyTo(db, "tenant")) ok++; else fail++;
|
||||
if (applyTo(db, "tenant")) {
|
||||
ok++;
|
||||
} else {
|
||||
fail++;
|
||||
failedDbs.add(db);
|
||||
}
|
||||
}
|
||||
if (!failedDbs.isEmpty()) {
|
||||
log.error("[SchemaMigrator] 마이그레이션 실패 테넌트 DB ({}건): {}", failedDbs.size(), failedDbs);
|
||||
}
|
||||
log.info("[SchemaMigrator] done — meta=done, tenants ok={}, fail={}", ok, fail);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,12 @@ public class CompanyAuditLogService {
|
||||
public static final String ACTION_PW_RESET = "ADMIN_PASSWORD_RESET";
|
||||
public static final String ACTION_RECOPY = "TEMPLATES_RECOPY";
|
||||
|
||||
// cross-tenant write 감사 액션
|
||||
public static final String ACTION_CT_USER_CREATE = "CROSS_TENANT_USER_CREATE";
|
||||
public static final String ACTION_CT_USER_DELETE = "CROSS_TENANT_USER_DELETE";
|
||||
public static final String ACTION_CT_PW_RESET = "CROSS_TENANT_PASSWORD_RESET";
|
||||
public static final String ACTION_CT_ROLE_UPDATE = "CROSS_TENANT_ROLE_UPDATE";
|
||||
|
||||
private final SqlSession sqlSession;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
|
||||
@@ -5,12 +5,9 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
@@ -40,13 +37,7 @@ public class ProvisioningController {
|
||||
private final ProvisioningRegistry registry;
|
||||
private final SqlSession sqlSession;
|
||||
private final CompanyStatsService statsService;
|
||||
|
||||
/**
|
||||
* 프로덕션 배포 시엔 반드시 true 로. 개발 중엔 JWT 없는 curl 테스트를 허용하기 위해 false 기본.
|
||||
* 환경변수: TENANT_PROVISIONING_REQUIRE_SUPER_ADMIN=true
|
||||
*/
|
||||
@Value("${tenant.provisioning.require-super-admin:false}")
|
||||
private boolean requireSuperAdmin;
|
||||
private final SuperAdminGuard guard;
|
||||
|
||||
@GetMapping("/table-groups")
|
||||
public ResponseEntity<List<Map<String, Object>>> tableGroups(HttpServletRequest request) {
|
||||
@@ -208,23 +199,11 @@ public class ProvisioningController {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 권한 체크
|
||||
//
|
||||
// 현재 `/api/**` 가 permitAll 이라 Controller 레벨에서 수동 검증.
|
||||
// JWT 가 있으면 JwtAuthenticationFilter 가 request.getAttribute("user_type") 세팅.
|
||||
// 개발 모드(requireSuperAdmin=false): JWT 없이도 통과 (curl 테스트용). 단 다른 role 은 차단.
|
||||
// 프로덕션 모드(requireSuperAdmin=true): SUPER_ADMIN 아니면 모두 403.
|
||||
// 권한 체크 — SuperAdminGuard 로 위임 (호스트 격리 + role 검증).
|
||||
// CompanyMgmtController 와 동일한 가드를 공유.
|
||||
// ------------------------------------------------------------------
|
||||
private void enforceSuperAdmin(HttpServletRequest request) {
|
||||
String userType = (String) request.getAttribute("user_type");
|
||||
if ("SUPER_ADMIN".equals(userType)) return;
|
||||
|
||||
if (!requireSuperAdmin && userType == null) {
|
||||
log.warn("[Provisioning] anonymous access allowed in dev mode (set " +
|
||||
"tenant.provisioning.require-super-admin=true in production)");
|
||||
return;
|
||||
}
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "SUPER_ADMIN only");
|
||||
guard.enforce(request);
|
||||
}
|
||||
|
||||
// --- Validation helpers ---
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.erp.provisioning;
|
||||
|
||||
import com.erp.tenant.ReservedSubdomains;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -7,9 +8,14 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* `/api/admin/provisioning/*` 계열 엔드포인트 공통 권한 가드.
|
||||
*
|
||||
* - 호스트 격리: 테넌트 서브도메인(qnc.invyone.com 등)에서 호출하면 무조건 403.
|
||||
* 프로비저닝 plane 은 solution/admin/localhost/베이스 도메인 같은 "관리 호스트" 에서만 동작.
|
||||
* (한 번 SUPER_ADMIN 토큰이 새도 임의의 테넌트 사이트에서는 회사를 만들 수 없게 막음)
|
||||
* - 프로덕션 (tenant.provisioning.require-super-admin=true): SUPER_ADMIN 만 통과
|
||||
* - 개발 (기본값 false): JWT 없어도 통과 (curl 테스트). 다른 role 은 여전히 차단
|
||||
*
|
||||
@@ -19,10 +25,22 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
@Slf4j
|
||||
public class SuperAdminGuard {
|
||||
|
||||
private static final Pattern IPV4 = Pattern.compile("^\\d{1,3}(\\.\\d{1,3}){3}$");
|
||||
|
||||
@Value("${tenant.provisioning.require-super-admin:false}")
|
||||
private boolean requireSuperAdmin;
|
||||
|
||||
public void enforce(HttpServletRequest request) {
|
||||
// 1) 호스트 격리 — role 보다 먼저 체크. 어떤 role 도 테넌트 사이트에서는 통과 못 함.
|
||||
String host = request.getHeader("Host");
|
||||
if (isTenantHost(host)) {
|
||||
log.warn("[ProvisioningGuard] blocked tenant-host call: host={} path={} userType={}",
|
||||
host, request.getRequestURI(), request.getAttribute("user_type"));
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"Provisioning is only available on the management site");
|
||||
}
|
||||
|
||||
// 2) role 체크
|
||||
String userType = (String) request.getAttribute("user_type");
|
||||
if ("SUPER_ADMIN".equals(userType)) return;
|
||||
if (!requireSuperAdmin && userType == null) {
|
||||
@@ -37,4 +55,40 @@ public class SuperAdminGuard {
|
||||
String userId = (String) request.getAttribute("user_id");
|
||||
return userId == null ? "dev-anonymous" : userId;
|
||||
}
|
||||
|
||||
/** 감사 로그에 기록할 호스트 (Host 헤더 그대로, 포트 포함). null safe. */
|
||||
public String requestHost(HttpServletRequest request) {
|
||||
String host = request.getHeader("Host");
|
||||
return host == null ? "" : host;
|
||||
}
|
||||
|
||||
/**
|
||||
* "테넌트 사이트에서 온 요청인지" 판단. SubdomainResolverFilter.extractSubdomain 와 같은 규칙.
|
||||
* - localhost / IP / 베이스 도메인 → false (관리 호스트)
|
||||
* - solution.invyone.com 등 예약어 prefix → false (관리 호스트)
|
||||
* - qnc.invyone.com / test02.invyone.com 같은 실제 테넌트 → true
|
||||
*/
|
||||
public static boolean isTenantHost(String host) {
|
||||
if (host == null || host.isBlank()) return false;
|
||||
|
||||
int colon = host.indexOf(':');
|
||||
if (colon != -1) host = host.substring(0, colon);
|
||||
host = host.toLowerCase();
|
||||
|
||||
if ("localhost".equals(host)) return false;
|
||||
if (IPV4.matcher(host).matches()) return false;
|
||||
|
||||
String[] parts = host.split("\\.");
|
||||
if (parts.length == 2) {
|
||||
// {sub}.localhost (dev)
|
||||
if (!"localhost".equals(parts[1])) return false;
|
||||
String first = parts[0];
|
||||
if (first.isEmpty()) return false;
|
||||
return !ReservedSubdomains.VALUES.contains(first);
|
||||
}
|
||||
if (parts.length < 3) return false;
|
||||
|
||||
String first = parts[0];
|
||||
return !ReservedSubdomains.VALUES.contains(first);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class CascadingAutoFillService extends BaseService {
|
||||
|
||||
private static final String NS = "cascadingAutoFill.";
|
||||
|
||||
@Autowired
|
||||
private CommonService commonService;
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
public Map<String, Object> getCascadingAutoFillGroupList(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
commonService.applyPagination(params);
|
||||
int totalCount = sqlSession.selectOne(NS + "getCascadingAutoFillGroupListCnt", params);
|
||||
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCascadingAutoFillGroupList", params);
|
||||
return commonService.buildListResponse(list, totalCount, params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getCascadingAutoFillGroupDetail(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
Map<String, Object> group = sqlSession.selectOne(NS + "getCascadingAutoFillGroupByCode", params);
|
||||
if (group == null) return null;
|
||||
|
||||
Map<String, Object> mappingParams = new HashMap<>();
|
||||
mappingParams.put("group_code", params.get("group_code"));
|
||||
mappingParams.put("company_code", group.get("company_code"));
|
||||
List<Map<String, Object>> mappings = sqlSession.selectList(NS + "getCascadingAutoFillMappingList", mappingParams);
|
||||
|
||||
Map<String, Object> result = new HashMap<>(group);
|
||||
result.put("mappings", mappings);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCascadingAutoFillGroup(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
// Generate group code: AF_{timestamp_base36}_{count:03d}
|
||||
Map<String, Object> countParams = new HashMap<>();
|
||||
countParams.put("company_code", companyCode);
|
||||
Number cntNum = sqlSession.selectOne(NS + "getCascadingAutoFillGroupCount", countParams);
|
||||
int count = (cntNum != null ? cntNum.intValue() : 0) + 1;
|
||||
String timestamp = Long.toString(System.currentTimeMillis(), 36).toUpperCase();
|
||||
String suffix = timestamp.substring(Math.max(0, timestamp.length() - 4));
|
||||
String groupCode = "AF_" + suffix + "_" + String.format("%03d", count);
|
||||
params.put("group_code", groupCode);
|
||||
|
||||
if (params.get("is_active") == null) {
|
||||
params.put("is_active", "Y");
|
||||
}
|
||||
|
||||
sqlSession.insert(NS + "insertCascadingAutoFillGroup", params);
|
||||
|
||||
// Insert mappings
|
||||
Object mappingsObj = params.get("mappings");
|
||||
if (mappingsObj instanceof List) {
|
||||
List<?> mappings = (List<?>) mappingsObj;
|
||||
for (int i = 0; i < mappings.size(); i++) {
|
||||
Object m = mappings.get(i);
|
||||
if (m instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> mapping = (Map<String, Object>) m;
|
||||
Map<String, Object> mp = new HashMap<>(mapping);
|
||||
mp.put("group_code", groupCode);
|
||||
mp.put("company_code", companyCode);
|
||||
if (mp.get("is_editable") == null) mp.put("is_editable", "Y");
|
||||
if (mp.get("is_required") == null) mp.put("is_required", "N");
|
||||
if (mp.get("sort_order") == null) mp.put("sort_order", i + 1);
|
||||
sqlSession.insert(NS + "insertCascadingAutoFillMapping", mp);
|
||||
}
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCascadingAutoFillGroup(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String groupCode = (String) params.get("group_code");
|
||||
|
||||
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingAutoFillGroupByCode", params);
|
||||
if (existing == null) return null;
|
||||
|
||||
String actualCompanyCode = (String) existing.get("company_code");
|
||||
params.put("company_code", actualCompanyCode);
|
||||
|
||||
sqlSession.update(NS + "updateCascadingAutoFillGroup", params);
|
||||
|
||||
// Replace mappings if provided
|
||||
if (params.containsKey("mappings")) {
|
||||
Map<String, Object> delParams = new HashMap<>();
|
||||
delParams.put("group_code", groupCode);
|
||||
delParams.put("company_code", actualCompanyCode);
|
||||
sqlSession.delete(NS + "deleteCascadingAutoFillMappings", delParams);
|
||||
|
||||
Object mappingsObj = params.get("mappings");
|
||||
if (mappingsObj instanceof List) {
|
||||
List<?> mappings = (List<?>) mappingsObj;
|
||||
for (int i = 0; i < mappings.size(); i++) {
|
||||
Object m = mappings.get(i);
|
||||
if (m instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> mapping = (Map<String, Object>) m;
|
||||
Map<String, Object> mp = new HashMap<>(mapping);
|
||||
mp.put("group_code", groupCode);
|
||||
mp.put("company_code", actualCompanyCode);
|
||||
if (mp.get("is_editable") == null) mp.put("is_editable", "Y");
|
||||
if (mp.get("is_required") == null) mp.put("is_required", "N");
|
||||
if (mp.get("sort_order") == null) mp.put("sort_order", i + 1);
|
||||
sqlSession.insert(NS + "insertCascadingAutoFillMapping", mp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean deleteCascadingAutoFillGroup(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingAutoFillGroupByCode", params);
|
||||
if (existing == null) return false;
|
||||
|
||||
String groupCode = (String) params.get("group_code");
|
||||
String companyCode = (String) existing.get("company_code");
|
||||
|
||||
Map<String, Object> delParams = new HashMap<>();
|
||||
delParams.put("group_code", groupCode);
|
||||
delParams.put("company_code", companyCode);
|
||||
sqlSession.delete(NS + "deleteCascadingAutoFillMappings", delParams);
|
||||
sqlSession.delete(NS + "deleteCascadingAutoFillGroup", delParams);
|
||||
return true;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getAutoFillMasterOptions(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
Map<String, Object> groupParams = new HashMap<>(params);
|
||||
groupParams.put("is_active", "Y");
|
||||
Map<String, Object> group = sqlSession.selectOne(NS + "getCascadingAutoFillGroupByCode", groupParams);
|
||||
if (group == null) return null;
|
||||
|
||||
String masterTable = sanitizeIdentifier((String) group.get("master_table"));
|
||||
String masterValueColumn = sanitizeIdentifier((String) group.get("master_value_column"));
|
||||
Object labelColObj = group.get("master_label_column");
|
||||
String labelColumn = (labelColObj != null && !labelColObj.toString().isEmpty())
|
||||
? sanitizeIdentifier(labelColObj.toString()) : masterValueColumn;
|
||||
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT ").append(masterValueColumn).append(" AS value, ")
|
||||
.append(labelColumn).append(" AS label")
|
||||
.append(" FROM ").append(masterTable)
|
||||
.append(" WHERE 1=1");
|
||||
|
||||
List<Object> sqlParams = new ArrayList<>();
|
||||
|
||||
if (!"*".equals(companyCode) && hasColumn(masterTable, "company_code")) {
|
||||
sql.append(" AND company_code = ?");
|
||||
sqlParams.add(companyCode);
|
||||
}
|
||||
sql.append(" ORDER BY ").append(labelColumn);
|
||||
|
||||
return jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
|
||||
}
|
||||
|
||||
public Map<String, Object> getAutoFillData(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String groupCode = (String) params.get("group_code");
|
||||
String masterValue = (String) params.get("master_value");
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
Map<String, Object> groupParams = new HashMap<>(params);
|
||||
groupParams.put("is_active", "Y");
|
||||
Map<String, Object> group = sqlSession.selectOne(NS + "getCascadingAutoFillGroupByCode", groupParams);
|
||||
if (group == null) return null;
|
||||
|
||||
String actualCompanyCode = (String) group.get("company_code");
|
||||
Map<String, Object> mappingParams = new HashMap<>();
|
||||
mappingParams.put("group_code", groupCode);
|
||||
mappingParams.put("company_code", actualCompanyCode);
|
||||
List<Map<String, Object>> mappings = sqlSession.selectList(NS + "getCascadingAutoFillMappingList", mappingParams);
|
||||
|
||||
if (mappings.isEmpty()) {
|
||||
Map<String, Object> empty = new HashMap<>();
|
||||
empty.put("data", new HashMap<>());
|
||||
empty.put("mappings", new ArrayList<>());
|
||||
return empty;
|
||||
}
|
||||
|
||||
String masterTable = sanitizeIdentifier((String) group.get("master_table"));
|
||||
String masterValueColumn = sanitizeIdentifier((String) group.get("master_value_column"));
|
||||
String sourceColumns = mappings.stream()
|
||||
.map(m -> sanitizeIdentifier((String) m.get("source_column")))
|
||||
.collect(Collectors.joining(", "));
|
||||
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT ").append(sourceColumns)
|
||||
.append(" FROM ").append(masterTable)
|
||||
.append(" WHERE ").append(masterValueColumn).append(" = ?");
|
||||
|
||||
List<Object> sqlParams = new ArrayList<>();
|
||||
sqlParams.add(masterValue);
|
||||
|
||||
if (!"*".equals(companyCode) && hasColumn(masterTable, "company_code")) {
|
||||
sql.append(" AND company_code = ?");
|
||||
sqlParams.add(companyCode);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
|
||||
Map<String, Object> dataRow = rows.isEmpty() ? null : rows.get(0);
|
||||
|
||||
Map<String, Object> autoFillData = new LinkedHashMap<>();
|
||||
List<Map<String, Object>> mappingInfo = new ArrayList<>();
|
||||
|
||||
for (Map<String, Object> mapping : mappings) {
|
||||
String sourceColumn = (String) mapping.get("source_column");
|
||||
String targetField = (String) mapping.get("target_field");
|
||||
Object sourceValue = (dataRow != null) ? dataRow.get(sourceColumn) : null;
|
||||
Object defaultVal = mapping.get("default_value");
|
||||
Object finalValue = (sourceValue != null) ? sourceValue : defaultVal;
|
||||
|
||||
autoFillData.put(targetField, finalValue);
|
||||
|
||||
Map<String, Object> info = new LinkedHashMap<>();
|
||||
info.put("target_field", targetField);
|
||||
info.put("target_label", mapping.get("target_label"));
|
||||
info.put("value", finalValue);
|
||||
info.put("is_editable", "Y".equals(mapping.get("is_editable")));
|
||||
info.put("is_required", "Y".equals(mapping.get("is_required")));
|
||||
mappingInfo.add(info);
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("data", autoFillData);
|
||||
result.put("mappings", mappingInfo);
|
||||
return result;
|
||||
}
|
||||
|
||||
private String sanitizeIdentifier(String identifier) {
|
||||
if (identifier == null || !identifier.matches("[a-zA-Z0-9_.]+")) {
|
||||
throw new IllegalArgumentException("Invalid SQL identifier: " + identifier);
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
private boolean hasColumn(String tableName, String columnName) {
|
||||
try {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?",
|
||||
Integer.class, tableName, columnName);
|
||||
return count != null && count > 0;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class CascadingConditionService extends BaseService {
|
||||
|
||||
private static final String NS = "cascadingCondition.";
|
||||
private static final String NS_RELATION = "cascadingRelation.";
|
||||
|
||||
@Autowired
|
||||
private CommonService commonService;
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
public Map<String, Object> getCascadingConditionList(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
commonService.applyPagination(params);
|
||||
int totalCount = sqlSession.selectOne(NS + "getCascadingConditionListCnt", params);
|
||||
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCascadingConditionList", params);
|
||||
return commonService.buildListResponse(list, totalCount, params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getCascadingConditionInfo(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
return sqlSession.selectOne(NS + "getCascadingConditionInfo", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCascadingCondition(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
sqlSession.insert(NS + "insertCascadingCondition", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCascadingCondition(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
sqlSession.update(NS + "updateCascadingCondition", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> deleteCascadingCondition(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
sqlSession.delete(NS + "deleteCascadingCondition", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
public Map<String, Object> getFilteredOptions(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String companyCode = (String) params.get("company_code");
|
||||
String conditionFieldValue = params.get("condition_field_value") != null
|
||||
? String.valueOf(params.get("condition_field_value")) : null;
|
||||
String parentValue = params.get("parent_value") != null
|
||||
? String.valueOf(params.get("parent_value")) : null;
|
||||
|
||||
// 1. 연쇄 관계 조회
|
||||
Map<String, Object> relation = sqlSession.selectOne(NS_RELATION + "get_cascading_relation_by_code", params);
|
||||
if (relation == null) {
|
||||
Map<String, Object> empty = new LinkedHashMap<>();
|
||||
empty.put("data", Collections.emptyList());
|
||||
empty.put("applied_condition", null);
|
||||
return empty;
|
||||
}
|
||||
|
||||
// 2. 조건 규칙 조회 (우선순위 내림차순)
|
||||
List<Map<String, Object>> conditions = sqlSession.selectList(NS + "getCascadingConditionsByRelationCode", params);
|
||||
|
||||
// 3. 매칭 조건 탐색
|
||||
Map<String, Object> matchedCondition = null;
|
||||
if (conditionFieldValue != null) {
|
||||
for (Map<String, Object> cond : conditions) {
|
||||
String operator = (String) cond.get("condition_operator");
|
||||
String expectedValue = (String) cond.get("condition_value");
|
||||
if (evaluateCondition(conditionFieldValue, operator, expectedValue)) {
|
||||
matchedCondition = cond;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 동적 옵션 쿼리 생성
|
||||
String childTable = String.valueOf(relation.get("child_table"));
|
||||
String valueCol = String.valueOf(relation.get("child_value_column"));
|
||||
String labelCol = String.valueOf(relation.get("child_label_column"));
|
||||
Object filterColObj = relation.get("child_filter_column");
|
||||
Object orderColObj = relation.get("child_order_column");
|
||||
String filterCol = filterColObj != null ? String.valueOf(filterColObj) : null;
|
||||
String orderCol = orderColObj != null ? String.valueOf(orderColObj) : null;
|
||||
String orderDir = relation.get("child_order_direction") != null
|
||||
? String.valueOf(relation.get("child_order_direction")) : "ASC";
|
||||
|
||||
StringBuilder sql = new StringBuilder("SELECT ")
|
||||
.append(valueCol).append(" as value, ")
|
||||
.append(labelCol).append(" as label FROM ")
|
||||
.append(childTable).append(" WHERE 1=1");
|
||||
List<Object> sqlParams = new ArrayList<>();
|
||||
|
||||
if (parentValue != null && filterCol != null && !filterCol.isEmpty()) {
|
||||
sql.append(" AND ").append(filterCol).append(" = ?");
|
||||
sqlParams.add(parentValue);
|
||||
}
|
||||
|
||||
if (matchedCondition != null) {
|
||||
String condFilterColumn = (String) matchedCondition.get("filter_column");
|
||||
String condFilterValues = (String) matchedCondition.get("filter_values");
|
||||
if (condFilterColumn != null && condFilterValues != null) {
|
||||
String[] values = condFilterValues.split(",");
|
||||
String placeholders = Arrays.stream(values).map(v -> "?").collect(Collectors.joining(","));
|
||||
sql.append(" AND ").append(condFilterColumn).append(" IN (").append(placeholders).append(")");
|
||||
for (String v : values) sqlParams.add(v.trim());
|
||||
}
|
||||
}
|
||||
|
||||
// 멀티테넌시 필터 (child_table에 company_code 컬럼이 있는 경우만)
|
||||
if (companyCode != null && !"*".equals(companyCode)) {
|
||||
try {
|
||||
Integer cnt = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = 'company_code'",
|
||||
Integer.class, childTable);
|
||||
if (cnt != null && cnt > 0) {
|
||||
sql.append(" AND company_code = ?");
|
||||
sqlParams.add(companyCode);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("company_code 컬럼 확인 실패: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
sql.append(" ORDER BY ");
|
||||
if (orderCol != null && !orderCol.isEmpty()) {
|
||||
sql.append(orderCol).append(" ").append(orderDir);
|
||||
} else {
|
||||
sql.append(labelCol);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> options = jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", options);
|
||||
if (matchedCondition != null) {
|
||||
Map<String, Object> applied = new HashMap<>();
|
||||
applied.put("condition_id", matchedCondition.get("condition_id"));
|
||||
applied.put("condition_name", matchedCondition.get("condition_name"));
|
||||
result.put("applied_condition", applied);
|
||||
} else {
|
||||
result.put("applied_condition", null);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean evaluateCondition(String actualValue, String operator, String expectedValue) {
|
||||
if (operator == null || actualValue == null || expectedValue == null) return false;
|
||||
String actual = actualValue.toLowerCase().trim();
|
||||
String expected = expectedValue.toLowerCase().trim();
|
||||
return switch (operator.toUpperCase()) {
|
||||
case "EQ", "=", "EQUALS" -> actual.equals(expected);
|
||||
case "NEQ", "!=", "<>", "NOT_EQUALS" -> !actual.equals(expected);
|
||||
case "CONTAINS", "LIKE" -> actual.contains(expected);
|
||||
case "NOT_CONTAINS", "NOT_LIKE" -> !actual.contains(expected);
|
||||
case "STARTS_WITH" -> actual.startsWith(expected);
|
||||
case "ENDS_WITH" -> actual.endsWith(expected);
|
||||
case "IN" -> Arrays.stream(expected.split(",")).map(String::trim).anyMatch(v -> v.equals(actual));
|
||||
case "NOT_IN" -> Arrays.stream(expected.split(",")).map(String::trim).noneMatch(v -> v.equals(actual));
|
||||
case "GT", ">" -> {
|
||||
try { yield Double.parseDouble(actual) > Double.parseDouble(expected); }
|
||||
catch (NumberFormatException e) { yield false; }
|
||||
}
|
||||
case "GTE", ">=" -> {
|
||||
try { yield Double.parseDouble(actual) >= Double.parseDouble(expected); }
|
||||
catch (NumberFormatException e) { yield false; }
|
||||
}
|
||||
case "LT", "<" -> {
|
||||
try { yield Double.parseDouble(actual) < Double.parseDouble(expected); }
|
||||
catch (NumberFormatException e) { yield false; }
|
||||
}
|
||||
case "LTE", "<=" -> {
|
||||
try { yield Double.parseDouble(actual) <= Double.parseDouble(expected); }
|
||||
catch (NumberFormatException e) { yield false; }
|
||||
}
|
||||
case "IS_NULL", "NULL" -> actual.isEmpty() || "null".equals(actual) || "undefined".equals(actual);
|
||||
case "IS_NOT_NULL", "NOT_NULL" -> !actual.isEmpty() && !"null".equals(actual) && !"undefined".equals(actual);
|
||||
default -> {
|
||||
log.warn("알 수 없는 연산자: {}", operator);
|
||||
yield false;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CascadingHierarchyService extends BaseService {
|
||||
|
||||
private static final String NS = "cascadingHierarchy.";
|
||||
|
||||
private final CommonService commonService;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public Map<String, Object> getCascadingHierarchyGroupList(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
commonService.applyPagination(params);
|
||||
int totalCount = sqlSession.selectOne(NS + "getCascadingHierarchyGroupListCnt", params);
|
||||
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCascadingHierarchyGroupList", params);
|
||||
return commonService.buildListResponse(list, totalCount, params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getCascadingHierarchyGroupDetail(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
Map<String, Object> group = sqlSession.selectOne(NS + "getCascadingHierarchyGroupByCode", params);
|
||||
if (group == null) return null;
|
||||
|
||||
Map<String, Object> levelParams = new HashMap<>();
|
||||
levelParams.put("group_code", params.get("group_code"));
|
||||
levelParams.put("company_code", group.get("company_code"));
|
||||
List<Map<String, Object>> levels = sqlSession.selectList(NS + "getCascadingHierarchyLevelList", levelParams);
|
||||
|
||||
Map<String, Object> result = new HashMap<>(group);
|
||||
result.put("levels", levels);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCascadingHierarchyGroup(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String companyCode = (String) params.get("company_code");
|
||||
String userId = (String) params.getOrDefault("user_id", "system");
|
||||
|
||||
// Generate group code: HG_{timestamp_base36}_{count:03d}
|
||||
Map<String, Object> countParams = new HashMap<>();
|
||||
countParams.put("company_code", companyCode);
|
||||
Number cntNum = sqlSession.selectOne(NS + "getCascadingHierarchyGroupCount", countParams);
|
||||
int count = (cntNum != null ? cntNum.intValue() : 0) + 1;
|
||||
String timestamp = Long.toString(System.currentTimeMillis(), 36).toUpperCase();
|
||||
String suffix = timestamp.substring(Math.max(0, timestamp.length() - 4));
|
||||
String groupCode = "HG_" + suffix + "_" + String.format("%03d", count);
|
||||
|
||||
params.put("group_code", groupCode);
|
||||
params.put("created_by", userId);
|
||||
|
||||
if (params.get("hierarchy_type") == null) params.put("hierarchy_type", "MULTI_TABLE");
|
||||
if (params.get("is_fixed_levels") == null) params.put("is_fixed_levels", "Y");
|
||||
if (params.get("empty_message") == null) params.put("empty_message", "선택해주세요");
|
||||
if (params.get("no_options_message") == null) params.put("no_options_message", "옵션이 없습니다");
|
||||
if (params.get("loading_message") == null) params.put("loading_message", "로딩 중...");
|
||||
|
||||
sqlSession.insert(NS + "insertCascadingHierarchyGroup", params);
|
||||
|
||||
// Insert levels for MULTI_TABLE type
|
||||
Object levelsObj = params.get("levels");
|
||||
if ("MULTI_TABLE".equals(params.get("hierarchy_type")) && levelsObj instanceof List) {
|
||||
List<?> levels = (List<?>) levelsObj;
|
||||
for (Object l : levels) {
|
||||
if (l instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> level = (Map<String, Object>) l;
|
||||
Map<String, Object> lp = new HashMap<>(level);
|
||||
lp.put("group_code", groupCode);
|
||||
lp.put("company_code", companyCode);
|
||||
if (lp.get("order_direction") == null) lp.put("order_direction", "ASC");
|
||||
if (lp.get("is_required") == null) lp.put("is_required", "Y");
|
||||
if (lp.get("is_searchable") == null) lp.put("is_searchable", "N");
|
||||
if (lp.get("placeholder") == null && lp.get("level_name") != null) {
|
||||
lp.put("placeholder", lp.get("level_name") + " 선택");
|
||||
}
|
||||
sqlSession.insert(NS + "insertCascadingHierarchyLevel", lp);
|
||||
}
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCascadingHierarchyGroup(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
params.put("updated_by", params.getOrDefault("user_id", "system"));
|
||||
|
||||
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingHierarchyGroupByCode", params);
|
||||
if (existing == null) return null;
|
||||
|
||||
params.put("company_code", existing.get("company_code"));
|
||||
sqlSession.update(NS + "updateCascadingHierarchyGroup", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean deleteCascadingHierarchyGroup(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingHierarchyGroupByCode", params);
|
||||
if (existing == null) return false;
|
||||
|
||||
String groupCode = (String) params.get("group_code");
|
||||
String companyCode = (String) existing.get("company_code");
|
||||
|
||||
Map<String, Object> delParams = new HashMap<>();
|
||||
delParams.put("group_code", groupCode);
|
||||
delParams.put("company_code", companyCode);
|
||||
sqlSession.delete(NS + "deleteCascadingHierarchyLevels", delParams);
|
||||
sqlSession.delete(NS + "deleteCascadingHierarchyGroup", delParams);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> addCascadingHierarchyLevel(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String groupCode = (String) params.get("group_code");
|
||||
|
||||
Map<String, Object> groupParams = new HashMap<>();
|
||||
groupParams.put("group_code", groupCode);
|
||||
groupParams.put("company_code", params.get("company_code"));
|
||||
Map<String, Object> group = sqlSession.selectOne(NS + "getCascadingHierarchyGroupByCode", groupParams);
|
||||
if (group == null) return null;
|
||||
|
||||
params.put("company_code", group.get("company_code"));
|
||||
if (params.get("order_direction") == null) params.put("order_direction", "ASC");
|
||||
if (params.get("is_required") == null) params.put("is_required", "Y");
|
||||
if (params.get("is_searchable") == null) params.put("is_searchable", "N");
|
||||
if (params.get("placeholder") == null && params.get("level_name") != null) {
|
||||
params.put("placeholder", params.get("level_name") + " 선택");
|
||||
}
|
||||
|
||||
sqlSession.insert(NS + "insertCascadingHierarchyLevel", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCascadingHierarchyLevel(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingHierarchyLevelInfo", params);
|
||||
if (existing == null) return null;
|
||||
|
||||
sqlSession.update(NS + "updateCascadingHierarchyLevel", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean deleteCascadingHierarchyLevel(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingHierarchyLevelInfo", params);
|
||||
if (existing == null) return false;
|
||||
|
||||
sqlSession.delete(NS + "deleteCascadingHierarchyLevel", params);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Map<String, Object> getLevelOptions(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
Map<String, Object> level = sqlSession.selectOne(NS + "getCascadingHierarchyLevelForOptions", params);
|
||||
if (level == null) return null;
|
||||
|
||||
String tableName = sanitizeIdentifier((String) level.get("table_name"));
|
||||
String valueColumn = sanitizeIdentifier((String) level.get("value_column"));
|
||||
String labelColumn = sanitizeIdentifier((String) level.get("label_column"));
|
||||
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT ").append(valueColumn).append(" AS value, ")
|
||||
.append(labelColumn).append(" AS label")
|
||||
.append(" FROM ").append(tableName)
|
||||
.append(" WHERE 1=1");
|
||||
|
||||
List<Object> sqlParams = new ArrayList<>();
|
||||
|
||||
// Parent value filter (level 2+)
|
||||
Object parentValue = params.get("parent_value");
|
||||
Object parentKeyColumn = level.get("parent_key_column");
|
||||
if (parentKeyColumn != null && !parentKeyColumn.toString().isEmpty() && parentValue != null) {
|
||||
sql.append(" AND ").append(sanitizeIdentifier(parentKeyColumn.toString())).append(" = ?");
|
||||
sqlParams.add(parentValue);
|
||||
}
|
||||
|
||||
// Fixed filter
|
||||
Object filterColumn = level.get("filter_column");
|
||||
Object filterValue = level.get("filter_value");
|
||||
if (filterColumn != null && !filterColumn.toString().isEmpty()
|
||||
&& filterValue != null && !filterValue.toString().isEmpty()) {
|
||||
sql.append(" AND ").append(sanitizeIdentifier(filterColumn.toString())).append(" = ?");
|
||||
sqlParams.add(filterValue.toString());
|
||||
}
|
||||
|
||||
// Multi-tenancy
|
||||
if (!"*".equals(companyCode) && hasColumn(tableName, "company_code")) {
|
||||
sql.append(" AND company_code = ?");
|
||||
sqlParams.add(companyCode);
|
||||
}
|
||||
|
||||
// Order
|
||||
Object orderColumn = level.get("order_column");
|
||||
if (orderColumn != null && !orderColumn.toString().isEmpty()) {
|
||||
Object orderDir = level.get("order_direction");
|
||||
String dir = (orderDir != null && "DESC".equalsIgnoreCase(orderDir.toString())) ? "DESC" : "ASC";
|
||||
sql.append(" ORDER BY ").append(sanitizeIdentifier(orderColumn.toString())).append(" ").append(dir);
|
||||
} else {
|
||||
sql.append(" ORDER BY ").append(labelColumn);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> options = jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
|
||||
|
||||
Map<String, Object> levelInfo = new LinkedHashMap<>();
|
||||
levelInfo.put("level_id", level.get("level_id"));
|
||||
levelInfo.put("level_name", level.get("level_name"));
|
||||
levelInfo.put("placeholder", level.get("placeholder"));
|
||||
levelInfo.put("is_required", level.get("is_required"));
|
||||
levelInfo.put("is_searchable", level.get("is_searchable"));
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("data", options);
|
||||
result.put("level_info", levelInfo);
|
||||
return result;
|
||||
}
|
||||
|
||||
private String sanitizeIdentifier(String identifier) {
|
||||
if (identifier == null || !identifier.matches("[a-zA-Z0-9_.]+")) {
|
||||
throw new IllegalArgumentException("Invalid SQL identifier: " + identifier);
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
private boolean hasColumn(String tableName, String columnName) {
|
||||
try {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?",
|
||||
Integer.class, tableName, columnName);
|
||||
return count != null && count > 0;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CascadingMutualExclusionService extends BaseService {
|
||||
|
||||
private static final String NS = "cascadingMutualExclusion.";
|
||||
|
||||
private final CommonService commonService;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public Map<String, Object> getCascadingMutualExclusionList(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
commonService.applyPagination(params);
|
||||
int totalCount = sqlSession.selectOne(NS + "getCascadingMutualExclusionListCnt", params);
|
||||
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCascadingMutualExclusionList", params);
|
||||
return commonService.buildListResponse(list, totalCount, params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getCascadingMutualExclusionInfo(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
return sqlSession.selectOne(NS + "getCascadingMutualExclusionInfo", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCascadingMutualExclusion(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
if (params.get("exclusion_type") == null) params.put("exclusion_type", "SAME_VALUE");
|
||||
if (params.get("error_message") == null) params.put("error_message", "동일한 값을 선택할 수 없습니다");
|
||||
|
||||
// 배제 코드 자동 생성: EX_XXXX_NNN
|
||||
String companyCode = (String) params.get("company_code");
|
||||
Map<String, Object> countParams = new LinkedHashMap<>();
|
||||
countParams.put("company_code", companyCode);
|
||||
int count = sqlSession.selectOne(NS + "getCascadingMutualExclusionCount", countParams);
|
||||
String ts = Long.toString(System.currentTimeMillis(), 36).toUpperCase();
|
||||
ts = ts.substring(Math.max(0, ts.length() - 4));
|
||||
params.put("exclusion_code", String.format("EX_%s_%03d", ts, count + 1));
|
||||
|
||||
sqlSession.insert(NS + "insertCascadingMutualExclusion", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCascadingMutualExclusion(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
sqlSession.update(NS + "updateCascadingMutualExclusion", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> deleteCascadingMutualExclusion(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
sqlSession.delete(NS + "deleteCascadingMutualExclusion", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상호 배제 검증: 선택한 값들 간 충돌 여부 확인 (SAME_VALUE 타입)
|
||||
*/
|
||||
public Map<String, Object> validateCascadingMutualExclusion(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
Map<String, Object> exclusion = sqlSession.selectOne(NS + "getCascadingMutualExclusionByCode", params);
|
||||
if (exclusion == null) throw new NoSuchElementException("상호 배제 규칙을 찾을 수 없습니다.");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> fieldValues = (Map<String, Object>) params.getOrDefault("field_values", Collections.emptyMap());
|
||||
|
||||
String fieldNamesStr = (String) exclusion.get("field_names");
|
||||
String[] fields = fieldNamesStr != null ? fieldNamesStr.split(",") : new String[0];
|
||||
|
||||
List<String> values = new ArrayList<>();
|
||||
for (String field : fields) {
|
||||
Object v = fieldValues.get(field.trim());
|
||||
if (v != null) values.add(v.toString());
|
||||
}
|
||||
|
||||
boolean isValid = true;
|
||||
String errorMessage = null;
|
||||
List<String> conflictingFields = new ArrayList<>();
|
||||
|
||||
String exclusionType = (String) exclusion.getOrDefault("exclusion_type", "SAME_VALUE");
|
||||
if ("SAME_VALUE".equals(exclusionType)) {
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
boolean hasDuplicate = false;
|
||||
for (String v : values) {
|
||||
if (!seen.add(v)) { hasDuplicate = true; break; }
|
||||
}
|
||||
if (hasDuplicate) {
|
||||
isValid = false;
|
||||
errorMessage = (String) exclusion.get("error_message");
|
||||
Map<String, List<String>> valueCounts = new LinkedHashMap<>();
|
||||
for (String field : fields) {
|
||||
Object v = fieldValues.get(field.trim());
|
||||
if (v != null) {
|
||||
valueCounts.computeIfAbsent(v.toString(), k -> new ArrayList<>()).add(field.trim());
|
||||
}
|
||||
}
|
||||
for (List<String> fl : valueCounts.values()) {
|
||||
if (fl.size() > 1) { conflictingFields = fl; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("is_valid", isValid);
|
||||
result.put("error_message", isValid ? null : errorMessage);
|
||||
result.put("conflicting_fields", conflictingFields);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배제 옵션 조회: source_table에서 이미 선택된 값을 제외한 목록 반환
|
||||
*/
|
||||
public List<Map<String, Object>> getExcludedOptions(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
Map<String, Object> exclusion = sqlSession.selectOne(NS + "getCascadingMutualExclusionByCode", params);
|
||||
if (exclusion == null) throw new NoSuchElementException("상호 배제 규칙을 찾을 수 없습니다.");
|
||||
|
||||
String sourceTable = (String) exclusion.get("source_table");
|
||||
String valueColumn = (String) exclusion.get("value_column");
|
||||
String labelColumn = (String) exclusion.get("label_column");
|
||||
if (labelColumn == null || labelColumn.isEmpty()) labelColumn = valueColumn;
|
||||
|
||||
boolean hasCompanyCode = hasColumn(sourceTable, "company_code");
|
||||
|
||||
List<Object> queryParams = new ArrayList<>();
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT ")
|
||||
.append(valueColumn).append(" AS value, ")
|
||||
.append(labelColumn).append(" AS label")
|
||||
.append(" FROM ").append(sourceTable)
|
||||
.append(" WHERE 1=1");
|
||||
|
||||
if (hasCompanyCode && !"*".equals(companyCode)) {
|
||||
sql.append(" AND company_code = ?");
|
||||
queryParams.add(companyCode);
|
||||
}
|
||||
|
||||
Object selectedValuesParam = params.get("selected_values");
|
||||
if (selectedValuesParam != null) {
|
||||
List<String> excludeValues = new ArrayList<>();
|
||||
for (String v : selectedValuesParam.toString().split(",")) {
|
||||
String trimmed = v.trim();
|
||||
if (!trimmed.isEmpty()) excludeValues.add(trimmed);
|
||||
}
|
||||
if (!excludeValues.isEmpty()) {
|
||||
String placeholders = String.join(", ", Collections.nCopies(excludeValues.size(), "?"));
|
||||
sql.append(" AND ").append(valueColumn).append(" NOT IN (").append(placeholders).append(")");
|
||||
queryParams.addAll(excludeValues);
|
||||
}
|
||||
}
|
||||
|
||||
sql.append(" ORDER BY ").append(labelColumn);
|
||||
|
||||
return jdbcTemplate.queryForList(sql.toString(), queryParams.toArray());
|
||||
}
|
||||
|
||||
private boolean hasColumn(String tableName, String columnName) {
|
||||
String sql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?";
|
||||
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, tableName, columnName);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class CascadingRelationService extends BaseService {
|
||||
|
||||
private static final String NS = "cascadingRelation.";
|
||||
|
||||
@Autowired
|
||||
private CommonService commonService;
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
public Map<String, Object> getCascadingRelationList(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
commonService.applyPagination(params);
|
||||
int totalCount = sqlSession.selectOne(NS + "getCascadingRelationListCnt", params);
|
||||
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCascadingRelationList", params);
|
||||
return commonService.buildListResponse(list, totalCount, params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getCascadingRelationInfo(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
return sqlSession.selectOne(NS + "getCascadingRelationInfo", params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getCascadingRelationByCode(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
return sqlSession.selectOne(NS + "getCascadingRelationByCode", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCascadingRelation(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
if (params.get("empty_parent_message") == null)
|
||||
params.put("empty_parent_message", "상위 항목을 먼저 선택하세요");
|
||||
if (params.get("no_options_message") == null)
|
||||
params.put("no_options_message", "선택 가능한 항목이 없습니다");
|
||||
if (params.get("loading_message") == null)
|
||||
params.put("loading_message", "로딩 중...");
|
||||
if (params.get("child_order_direction") == null)
|
||||
params.put("child_order_direction", "ASC");
|
||||
Object clearOnParentChange = params.get("clear_on_parent_change");
|
||||
if (clearOnParentChange == null) {
|
||||
params.put("clear_on_parent_change", "Y");
|
||||
} else if (clearOnParentChange instanceof Boolean) {
|
||||
params.put("clear_on_parent_change", Boolean.TRUE.equals(clearOnParentChange) ? "Y" : "N");
|
||||
}
|
||||
params.put("is_active", "Y");
|
||||
sqlSession.insert(NS + "insertCascadingRelation", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCascadingRelation(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
Object isActive = params.get("is_active");
|
||||
if (isActive instanceof Boolean) {
|
||||
params.put("is_active", Boolean.TRUE.equals(isActive) ? "Y" : "N");
|
||||
}
|
||||
Object clearOnParentChange = params.get("clear_on_parent_change");
|
||||
if (clearOnParentChange instanceof Boolean) {
|
||||
params.put("clear_on_parent_change", Boolean.TRUE.equals(clearOnParentChange) ? "Y" : "N");
|
||||
}
|
||||
sqlSession.update(NS + "updateCascadingRelation", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> deleteCascadingRelation(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
sqlSession.update(NS + "deleteCascadingRelation", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 부모 옵션 조회: relation_code로 관계 조회 후 parent_table에서 동적 쿼리
|
||||
*/
|
||||
public List<Map<String, Object>> getParentOptions(Map<String, Object> params) {
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
Map<String, Object> relation = sqlSession.selectOne(NS + "getCascadingRelationByCode", params);
|
||||
if (relation == null) {
|
||||
throw new NoSuchElementException("연쇄 관계를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
String parentTable = (String) relation.get("parent_table");
|
||||
String parentValueColumn = (String) relation.get("parent_value_column");
|
||||
String parentLabelColumn = (String) relation.get("parent_label_column");
|
||||
if (parentLabelColumn == null || parentLabelColumn.isEmpty()) {
|
||||
parentLabelColumn = parentValueColumn;
|
||||
}
|
||||
|
||||
boolean hasCompanyCode = hasColumn(parentTable, "company_code");
|
||||
boolean hasStatus = hasColumn(parentTable, "status");
|
||||
|
||||
List<Object> queryParams = new ArrayList<>();
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT ")
|
||||
.append(parentValueColumn).append(" AS value, ")
|
||||
.append(parentLabelColumn).append(" AS label")
|
||||
.append(" FROM ").append(parentTable)
|
||||
.append(" WHERE 1=1");
|
||||
|
||||
if (hasCompanyCode && !"*".equals(companyCode)) {
|
||||
sql.append(" AND company_code = ?");
|
||||
queryParams.add(companyCode);
|
||||
}
|
||||
if (hasStatus) {
|
||||
sql.append(" AND (status IS NULL OR status != 'N')");
|
||||
}
|
||||
sql.append(" ORDER BY ").append(parentLabelColumn).append(" ASC");
|
||||
|
||||
return jdbcTemplate.queryForList(sql.toString(), queryParams.toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* 연쇄 옵션 조회: relation_code로 관계 조회 후 child_table에서 동적 쿼리
|
||||
* parentValue(단일) 또는 parentValues(콤마 구분 다중) 지원
|
||||
*/
|
||||
public List<Map<String, Object>> getCascadingOptions(Map<String, Object> params) {
|
||||
String companyCode = (String) params.get("company_code");
|
||||
Object parentValueParam = params.get("parent_value");
|
||||
Object parentValuesParam = params.get("parent_values");
|
||||
|
||||
List<String> parentValueArray = new ArrayList<>();
|
||||
if (parentValuesParam != null) {
|
||||
for (String v : parentValuesParam.toString().split(",")) {
|
||||
String trimmed = v.trim();
|
||||
if (!trimmed.isEmpty()) parentValueArray.add(trimmed);
|
||||
}
|
||||
} else if (parentValueParam != null) {
|
||||
parentValueArray.add(parentValueParam.toString());
|
||||
}
|
||||
|
||||
if (parentValueArray.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
Map<String, Object> relation = sqlSession.selectOne(NS + "getCascadingRelationByCode", params);
|
||||
if (relation == null) {
|
||||
throw new NoSuchElementException("연쇄 관계를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
String childTable = (String) relation.get("child_table");
|
||||
String childFilterColumn = (String) relation.get("child_filter_column");
|
||||
String childValueColumn = (String) relation.get("child_value_column");
|
||||
String childLabelColumn = (String) relation.get("child_label_column");
|
||||
String childOrderColumn = (String) relation.get("child_order_column");
|
||||
String childOrderDir = (String) relation.get("child_order_direction");
|
||||
if (childOrderDir == null || childOrderDir.isEmpty()) childOrderDir = "ASC";
|
||||
|
||||
boolean hasCompanyCode = hasColumn(childTable, "company_code");
|
||||
|
||||
List<Object> queryParams = new ArrayList<>(parentValueArray);
|
||||
String placeholders = String.join(", ", Collections.nCopies(parentValueArray.size(), "?"));
|
||||
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT DISTINCT ")
|
||||
.append(childValueColumn).append(" AS value, ")
|
||||
.append(childLabelColumn).append(" AS label, ")
|
||||
.append(childFilterColumn).append(" AS parent_value")
|
||||
.append(" FROM ").append(childTable)
|
||||
.append(" WHERE ").append(childFilterColumn).append(" IN (").append(placeholders).append(")");
|
||||
|
||||
if (hasCompanyCode && !"*".equals(companyCode)) {
|
||||
sql.append(" AND company_code = ?");
|
||||
queryParams.add(companyCode);
|
||||
}
|
||||
|
||||
if (childOrderColumn != null && !childOrderColumn.isEmpty()) {
|
||||
sql.append(" ORDER BY ").append(childOrderColumn).append(" ").append(childOrderDir);
|
||||
} else {
|
||||
sql.append(" ORDER BY ").append(childLabelColumn).append(" ASC");
|
||||
}
|
||||
|
||||
return jdbcTemplate.queryForList(sql.toString(), queryParams.toArray());
|
||||
}
|
||||
|
||||
private boolean hasColumn(String tableName, String columnName) {
|
||||
String sql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?";
|
||||
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, tableName, columnName);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,415 +0,0 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class CategoryTreeService extends BaseService {
|
||||
|
||||
private static final String NS = "categoryTree.";
|
||||
|
||||
private Long toLong(Object val) {
|
||||
if (val == null) return null;
|
||||
if (val instanceof Number n) return n.longValue();
|
||||
try { return Long.parseLong(val.toString()); } catch (Exception e) { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 트리 조회 (플랫 리스트 → 트리 변환)
|
||||
*/
|
||||
public List<Map<String, Object>> getCategoryTreeList(String companyCode, String tableName, String columnName) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
List<Map<String, Object>> flatList = sqlSession.selectList(NS + "getCategoryTreeList", params);
|
||||
return buildTree(flatList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 플랫 리스트 조회
|
||||
*/
|
||||
public List<Map<String, Object>> getCategoryTreeFlatList(String companyCode, String tableName, String columnName) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
return sqlSession.selectList(NS + "getCategoryTreeList", params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 단건 조회
|
||||
*/
|
||||
public Map<String, Object> getCategoryTreeInfo(String companyCode, int valueId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("value_id", valueId);
|
||||
return sqlSession.selectOne(NS + "getCategoryTreeInfo", params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 생성
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> insertCategoryTree(Map<String, Object> body, String companyCode, String createdBy) {
|
||||
String tableName = (String) body.get("table_name");
|
||||
String columnName = (String) body.get("column_name");
|
||||
String valueCode = (String) body.get("value_code");
|
||||
String valueLabel = (String) body.get("value_label");
|
||||
|
||||
Object valueOrderRaw = body.get("value_order");
|
||||
int valueOrder = valueOrderRaw != null ? ((Number) valueOrderRaw).intValue() : 0;
|
||||
|
||||
Object parentValueIdRaw = body.get("parent_value_id");
|
||||
|
||||
// depth / path 계산
|
||||
int depth = 1;
|
||||
String path = valueLabel;
|
||||
|
||||
if (parentValueIdRaw != null) {
|
||||
Map<String, Object> parentParams = new HashMap<>();
|
||||
parentParams.put("company_code", companyCode);
|
||||
parentParams.put("value_id", ((Number) parentValueIdRaw).intValue());
|
||||
Map<String, Object> parent = sqlSession.selectOne(NS + "getCategoryTreeInfo", parentParams);
|
||||
if (parent != null) {
|
||||
depth = ((Number) parent.get("depth")).intValue() + 1;
|
||||
if (depth > 3) {
|
||||
throw new IllegalArgumentException("카테고리는 최대 3단계까지만 가능합니다");
|
||||
}
|
||||
String parentPath = (String) parent.get("path");
|
||||
path = parentPath != null ? parentPath + "/" + valueLabel : valueLabel;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
params.put("value_code", valueCode);
|
||||
params.put("value_label", valueLabel);
|
||||
params.put("value_order", valueOrder);
|
||||
params.put("parent_value_id", parentValueIdRaw);
|
||||
params.put("depth", depth);
|
||||
params.put("path", path);
|
||||
params.put("description", body.get("description"));
|
||||
params.put("color", body.get("color"));
|
||||
params.put("icon", body.get("icon"));
|
||||
|
||||
Object isActiveRaw = body.get("is_active");
|
||||
Object isDefaultRaw = body.get("is_default");
|
||||
params.put("is_active", isActiveRaw != null ? isActiveRaw : true);
|
||||
params.put("is_default", isDefaultRaw != null ? isDefaultRaw : false);
|
||||
|
||||
params.put("company_code", companyCode);
|
||||
params.put("created_by", createdBy);
|
||||
|
||||
sqlSession.insert(NS + "insertCategoryTree", params);
|
||||
|
||||
// useGeneratedKeys → params.get("value_id") 에 생성된 ID 저장
|
||||
Map<String, Object> fetchParams = new HashMap<>();
|
||||
fetchParams.put("company_code", companyCode);
|
||||
fetchParams.put("value_id", params.get("value_id"));
|
||||
return sqlSession.selectOne(NS + "getCategoryTreeInfo", fetchParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 수정
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> updateCategoryTree(String companyCode, int valueId,
|
||||
Map<String, Object> body, String updatedBy) {
|
||||
Map<String, Object> currentParams = new HashMap<>();
|
||||
currentParams.put("company_code", companyCode);
|
||||
currentParams.put("value_id", valueId);
|
||||
Map<String, Object> current = sqlSession.selectOne(NS + "getCategoryTreeInfo", currentParams);
|
||||
if (current == null) return null;
|
||||
|
||||
String currentLabel = (String) current.get("value_label");
|
||||
int currentDepth = ((Number) current.get("depth")).intValue();
|
||||
String currentPath = (String) current.get("path");
|
||||
Object currentParentId = current.get("parent_value_id");
|
||||
|
||||
String newLabel = body.containsKey("value_label") ? (String) body.get("value_label") : currentLabel;
|
||||
int newDepth = currentDepth;
|
||||
String newPath = currentPath;
|
||||
Object newParentId = body.containsKey("parent_value_id") ? body.get("parent_value_id") : currentParentId;
|
||||
|
||||
boolean labelChanged = body.containsKey("value_label")
|
||||
&& !Objects.equals(newLabel, currentLabel);
|
||||
boolean parentChanged = body.containsKey("parent_value_id")
|
||||
&& !Objects.equals(toLong(body.get("parent_value_id")), toLong(currentParentId));
|
||||
|
||||
if (parentChanged) {
|
||||
if (body.get("parent_value_id") != null) {
|
||||
Map<String, Object> newParentParams = new HashMap<>();
|
||||
newParentParams.put("company_code", companyCode);
|
||||
newParentParams.put("value_id", ((Number) body.get("parent_value_id")).intValue());
|
||||
Map<String, Object> newParent = sqlSession.selectOne(NS + "getCategoryTreeInfo", newParentParams);
|
||||
if (newParent != null) {
|
||||
newDepth = ((Number) newParent.get("depth")).intValue() + 1;
|
||||
if (newDepth > 3) {
|
||||
throw new IllegalArgumentException("카테고리는 최대 3단계까지만 가능합니다");
|
||||
}
|
||||
String parentPath = (String) newParent.get("path");
|
||||
newPath = parentPath != null ? parentPath + "/" + newLabel : newLabel;
|
||||
}
|
||||
} else {
|
||||
newDepth = 1;
|
||||
newPath = newLabel;
|
||||
}
|
||||
} else if (labelChanged) {
|
||||
if (currentParentId != null) {
|
||||
Map<String, Object> parentParams = new HashMap<>();
|
||||
parentParams.put("company_code", companyCode);
|
||||
parentParams.put("value_id", ((Number) currentParentId).intValue());
|
||||
Map<String, Object> parent = sqlSession.selectOne(NS + "getCategoryTreeInfo", parentParams);
|
||||
String parentPath = parent != null ? (String) parent.get("path") : null;
|
||||
newPath = parentPath != null ? parentPath + "/" + newLabel : newLabel;
|
||||
} else {
|
||||
newPath = newLabel;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> updateParams = new HashMap<>();
|
||||
updateParams.put("company_code", companyCode);
|
||||
updateParams.put("value_id", valueId);
|
||||
updateParams.put("value_code", body.get("value_code"));
|
||||
updateParams.put("value_label", body.containsKey("value_label") ? newLabel : null);
|
||||
updateParams.put("value_order", body.get("value_order"));
|
||||
updateParams.put("parent_value_id", newParentId);
|
||||
updateParams.put("depth", newDepth);
|
||||
updateParams.put("path", newPath);
|
||||
updateParams.put("description", body.get("description"));
|
||||
updateParams.put("color", body.get("color"));
|
||||
updateParams.put("icon", body.get("icon"));
|
||||
updateParams.put("is_active", body.get("is_active"));
|
||||
updateParams.put("is_default", body.get("is_default"));
|
||||
updateParams.put("updated_by", updatedBy);
|
||||
|
||||
int affected = sqlSession.update(NS + "updateCategoryTree", updateParams);
|
||||
if (affected == 0) return null;
|
||||
|
||||
if (labelChanged || parentChanged) {
|
||||
updateChildrenPaths(companyCode, valueId, newPath != null ? newPath : "");
|
||||
}
|
||||
|
||||
Map<String, Object> fetchParams = new HashMap<>();
|
||||
fetchParams.put("company_code", companyCode);
|
||||
fetchParams.put("value_id", valueId);
|
||||
return sqlSession.selectOne(NS + "getCategoryTreeInfo", fetchParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제 가능 여부 사전 확인
|
||||
*/
|
||||
public Map<String, Object> checkCanDelete(String companyCode, int valueId) {
|
||||
Map<String, Object> value = getCategoryTreeInfo(companyCode, valueId);
|
||||
if (value == null) {
|
||||
Map<String, Object> res = new LinkedHashMap<>();
|
||||
res.put("can_delete", false);
|
||||
res.put("reason", "카테고리 값을 찾을 수 없습니다");
|
||||
return res;
|
||||
}
|
||||
|
||||
Map<String, Object> childParams = new HashMap<>();
|
||||
childParams.put("value_id", valueId);
|
||||
childParams.put("company_code", companyCode);
|
||||
Integer childCountObj = sqlSession.selectOne(NS + "getCategoryTreeChildrenCnt", childParams);
|
||||
int childCount = childCountObj != null ? childCountObj : 0;
|
||||
if (childCount > 0) {
|
||||
Map<String, Object> res = new LinkedHashMap<>();
|
||||
res.put("can_delete", false);
|
||||
res.put("reason", "하위 카테고리가 " + childCount + "개 존재합니다. 하위 카테고리를 먼저 삭제해주세요.");
|
||||
return res;
|
||||
}
|
||||
|
||||
Map<String, Object> usage = checkCategoryValueInUse(companyCode, value);
|
||||
boolean inUse = Boolean.TRUE.equals(usage.get("in_use"));
|
||||
if (inUse) {
|
||||
int count = ((Number) usage.get("count")).intValue();
|
||||
Map<String, Object> res = new LinkedHashMap<>();
|
||||
res.put("can_delete", false);
|
||||
res.put("reason", "이 카테고리 값(" + value.get("value_label") + ")은 " + count + "건의 데이터에서 사용 중이므로 삭제할 수 없습니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
Map<String, Object> res = new LinkedHashMap<>();
|
||||
res.put("can_delete", true);
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제 (자식·사용 여부 검증 후 삭제)
|
||||
*/
|
||||
@Transactional
|
||||
public boolean deleteCategoryTree(String companyCode, int valueId) {
|
||||
Map<String, Object> value = getCategoryTreeInfo(companyCode, valueId);
|
||||
if (value == null) return false;
|
||||
|
||||
// 1. 자식 존재 여부
|
||||
Map<String, Object> childParams = new HashMap<>();
|
||||
childParams.put("value_id", valueId);
|
||||
childParams.put("company_code", companyCode);
|
||||
Integer childCountObj = sqlSession.selectOne(NS + "getCategoryTreeChildrenCnt", childParams);
|
||||
int childCount = childCountObj != null ? childCountObj : 0;
|
||||
if (childCount > 0) {
|
||||
throw new IllegalStateException(
|
||||
"VALIDATION:하위 카테고리가 " + childCount + "개 존재합니다. 하위 카테고리를 먼저 삭제해주세요.");
|
||||
}
|
||||
|
||||
// 2. 실제 데이터 사용 여부
|
||||
Map<String, Object> usage = checkCategoryValueInUse(companyCode, value);
|
||||
boolean inUse = Boolean.TRUE.equals(usage.get("in_use"));
|
||||
if (inUse) {
|
||||
int count = ((Number) usage.get("count")).intValue();
|
||||
throw new IllegalStateException(
|
||||
"VALIDATION:이 카테고리 값(" + value.get("value_label") + ")은 "
|
||||
+ value.get("table_name") + " 테이블에서 " + count + "건의 데이터가 사용 중이므로 삭제할 수 없습니다.");
|
||||
}
|
||||
|
||||
// 3. 삭제
|
||||
Map<String, Object> deleteParams = new HashMap<>();
|
||||
deleteParams.put("company_code", companyCode);
|
||||
deleteParams.put("value_id", valueId);
|
||||
return sqlSession.delete(NS + "deleteCategoryTree", deleteParams) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 카테고리 컬럼 목록 조회
|
||||
*/
|
||||
public List<Map<String, Object>> getCategoryTreeColumnList(String companyCode, String tableName) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("company_code", companyCode);
|
||||
return sqlSession.selectList(NS + "getCategoryTreeColumnList", params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합)
|
||||
*/
|
||||
public List<Map<String, Object>> getCategoryTreeKeyList(String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
return sqlSession.selectList(NS + "getCategoryTreeKeyList", params);
|
||||
}
|
||||
|
||||
// ─── private helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 플랫 리스트 → 트리 구조 변환
|
||||
*/
|
||||
private List<Map<String, Object>> buildTree(List<Map<String, Object>> flatList) {
|
||||
Map<Object, Map<String, Object>> map = new LinkedHashMap<>();
|
||||
List<Map<String, Object>> roots = new ArrayList<>();
|
||||
|
||||
for (Map<String, Object> item : flatList) {
|
||||
Map<String, Object> node = new LinkedHashMap<>(item);
|
||||
node.put("children", new ArrayList<>());
|
||||
map.put(item.get("value_id"), node);
|
||||
}
|
||||
|
||||
for (Map<String, Object> item : flatList) {
|
||||
Object parentId = item.get("parent_value_id");
|
||||
Map<String, Object> node = map.get(item.get("value_id"));
|
||||
if (parentId != null && map.containsKey(parentId)) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> children =
|
||||
(List<Map<String, Object>>) map.get(parentId).get("children");
|
||||
children.add(node);
|
||||
} else {
|
||||
roots.add(node);
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 항목들의 path 재귀 업데이트
|
||||
*/
|
||||
private void updateChildrenPaths(String companyCode, int parentValueId, String parentPath) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("parent_value_id", parentValueId);
|
||||
|
||||
List<Map<String, Object>> children = sqlSession.selectList(NS + "getCategoryTreeChildrenList", params);
|
||||
for (Map<String, Object> child : children) {
|
||||
String valueLabel = (String) child.get("value_label");
|
||||
String newPath = parentPath + "/" + valueLabel;
|
||||
|
||||
Map<String, Object> updateParams = new HashMap<>();
|
||||
updateParams.put("value_id", child.get("value_id"));
|
||||
updateParams.put("path", newPath);
|
||||
sqlSession.update(NS + "updateCategoryTreeChildPath", updateParams);
|
||||
|
||||
int childId = ((Number) child.get("value_id")).intValue();
|
||||
updateChildrenPaths(companyCode, childId, newPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값이 실제 데이터 테이블에서 사용 중인지 확인
|
||||
* 오류 발생 시 무시하고 삭제 허용 (Node.js 동일 동작)
|
||||
*/
|
||||
private Map<String, Object> checkCategoryValueInUse(String companyCode, Map<String, Object> value) {
|
||||
String tableName = (String) value.get("table_name");
|
||||
String columnName = (String) value.get("column_name");
|
||||
String valueCode = (String) value.get("value_code");
|
||||
|
||||
Map<String, Object> notInUse = Map.of("in_use", false, "count", 0);
|
||||
|
||||
try {
|
||||
// 1. 테이블 존재 확인
|
||||
Map<String, Object> tableParams = new HashMap<>();
|
||||
tableParams.put("table_name", tableName);
|
||||
Integer teObj = sqlSession.selectOne(NS + "checkTableExists", tableParams);
|
||||
if (teObj == null || teObj == 0) return notInUse;
|
||||
|
||||
// 2. 컬럼 존재 확인
|
||||
Map<String, Object> colParams = new HashMap<>();
|
||||
colParams.put("table_name", tableName);
|
||||
colParams.put("column_name", columnName);
|
||||
Integer ceObj = sqlSession.selectOne(NS + "checkColumnExists", colParams);
|
||||
if (ceObj == null || ceObj == 0) return notInUse;
|
||||
|
||||
// 3. company_code 컬럼 존재 확인
|
||||
Map<String, Object> companyColParams = new HashMap<>();
|
||||
companyColParams.put("table_name", tableName);
|
||||
companyColParams.put("column_name", "company_code");
|
||||
Integer ccObj = sqlSession.selectOne(NS + "checkColumnExists", companyColParams);
|
||||
boolean hasCompanyCode = ccObj != null && ccObj > 0;
|
||||
|
||||
// 4. 사용 건수 조회
|
||||
int count;
|
||||
if (hasCompanyCode && !"*".equals(companyCode)) {
|
||||
Map<String, Object> countParams = new HashMap<>();
|
||||
countParams.put("table_name", tableName);
|
||||
countParams.put("column_name", columnName);
|
||||
countParams.put("company_code", companyCode);
|
||||
countParams.put("value_code", valueCode);
|
||||
Integer cntObj = sqlSession.selectOne(NS + "countCategoryUsageWithCompany", countParams);
|
||||
count = cntObj != null ? cntObj : 0;
|
||||
} else {
|
||||
Map<String, Object> countParams = new HashMap<>();
|
||||
countParams.put("table_name", tableName);
|
||||
countParams.put("column_name", columnName);
|
||||
countParams.put("value_code", valueCode);
|
||||
Integer cntObj = sqlSession.selectOne(NS + "countCategoryUsage", countParams);
|
||||
count = cntObj != null ? cntObj : 0;
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("in_use", count > 0);
|
||||
result.put("count", count);
|
||||
return result;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("카테고리 사용 여부 확인 중 오류 (무시하고 삭제 허용): {}", e.getMessage());
|
||||
return notInUse;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class CategoryValueCascadingService extends BaseService {
|
||||
|
||||
private static final String NS = "categoryValueCascading.";
|
||||
|
||||
@Autowired
|
||||
private CommonService commonService;
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
public Map<String, Object> getCategoryValueCascadingGroupList(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
commonService.applyPagination(params);
|
||||
int totalCount = sqlSession.selectOne(NS + "getCategoryValueCascadingGroupListCnt", params);
|
||||
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCategoryValueCascadingGroupList", params);
|
||||
return commonService.buildListResponse(list, totalCount, params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getCategoryValueCascadingGroupInfo(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
Map<String, Object> group = sqlSession.selectOne(NS + "getCategoryValueCascadingGroupInfo", params);
|
||||
if (group == null) return null;
|
||||
|
||||
List<Map<String, Object>> mappings = sqlSession.selectList(NS + "getCategoryValueCascadingMappingsByGroupId", params);
|
||||
|
||||
Map<String, List<Map<String, Object>>> mappingsByParent = new LinkedHashMap<>();
|
||||
for (Map<String, Object> m : mappings) {
|
||||
String parentKey = String.valueOf(m.get("parent_value_code"));
|
||||
mappingsByParent.computeIfAbsent(parentKey, k -> new ArrayList<>()).add(Map.of(
|
||||
"child_value_code", m.getOrDefault("child_value_code", ""),
|
||||
"child_value_label", m.getOrDefault("child_value_label", ""),
|
||||
"display_order", m.getOrDefault("display_order", 0)
|
||||
));
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>(group);
|
||||
result.put("mappings", mappings);
|
||||
result.put("mappings_by_parent", mappingsByParent);
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Object> getCategoryValueCascadingGroupByCode(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
return sqlSession.selectOne(NS + "getCategoryValueCascadingGroupByCode", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCategoryValueCascadingGroup(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
sqlSession.insert(NS + "insertCategoryValueCascadingGroup", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCategoryValueCascadingGroup(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
sqlSession.update(NS + "updateCategoryValueCascadingGroup", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> deleteCategoryValueCascadingGroup(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
sqlSession.update(NS + "deleteCategoryValueCascadingGroup", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> saveCategoryValueCascadingMappings(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String companyCode = (String) params.get("company_code");
|
||||
Object groupId = params.get("group_id");
|
||||
|
||||
sqlSession.delete(NS + "deleteCategoryValueCascadingMappingsByGroupId", params);
|
||||
|
||||
int savedCount = 0;
|
||||
Object mappingsObj = params.get("mappings");
|
||||
if (mappingsObj instanceof List<?>) {
|
||||
List<Map<String, Object>> mappings = (List<Map<String, Object>>) mappingsObj;
|
||||
for (Map<String, Object> mapping : mappings) {
|
||||
Map<String, Object> mappingParams = new HashMap<>(mapping);
|
||||
mappingParams.put("group_id", groupId);
|
||||
mappingParams.put("company_code", companyCode);
|
||||
sqlSession.insert(NS + "insertCategoryValueCascadingMapping", mappingParams);
|
||||
savedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("saved_count", savedCount);
|
||||
result.put("group_id", groupId);
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Object> getCategoryValueCascadingParentOptions(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
Map<String, Object> group = sqlSession.selectOne(NS + "getCategoryValueCascadingGroupByCode", params);
|
||||
if (group == null) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", Collections.emptyList());
|
||||
return result;
|
||||
}
|
||||
|
||||
String tableName = String.valueOf(group.get("parent_table_name"));
|
||||
String columnName = String.valueOf(group.get("parent_column_name"));
|
||||
Object menuObjid = group.get("parent_menu_objid");
|
||||
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT value_code as value, value_label as label, value_order as display_order" +
|
||||
" FROM category_values WHERE table_name = ? AND column_name = ? AND is_active = true");
|
||||
List<Object> sqlParams = new ArrayList<>(Arrays.asList(tableName, columnName));
|
||||
|
||||
if (menuObjid != null) {
|
||||
sql.append(" AND menu_objid = ?");
|
||||
sqlParams.add(menuObjid);
|
||||
}
|
||||
if (companyCode != null && !"*".equals(companyCode)) {
|
||||
sql.append(" AND (company_code = ? OR company_code = '*')");
|
||||
sqlParams.add(companyCode);
|
||||
}
|
||||
sql.append(" ORDER BY value_order, value_label");
|
||||
|
||||
List<Map<String, Object>> options = jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", options);
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Object> getCategoryValueCascadingChildOptions(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
Map<String, Object> group = sqlSession.selectOne(NS + "getCategoryValueCascadingGroupByCode", params);
|
||||
if (group == null) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", Collections.emptyList());
|
||||
return result;
|
||||
}
|
||||
|
||||
String tableName = String.valueOf(group.get("child_table_name"));
|
||||
String columnName = String.valueOf(group.get("child_column_name"));
|
||||
Object menuObjid = group.get("child_menu_objid");
|
||||
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT value_code as value, value_label as label, value_order as display_order" +
|
||||
" FROM category_values WHERE table_name = ? AND column_name = ? AND is_active = true");
|
||||
List<Object> sqlParams = new ArrayList<>(Arrays.asList(tableName, columnName));
|
||||
|
||||
if (menuObjid != null) {
|
||||
sql.append(" AND menu_objid = ?");
|
||||
sqlParams.add(menuObjid);
|
||||
}
|
||||
if (companyCode != null && !"*".equals(companyCode)) {
|
||||
sql.append(" AND (company_code = ? OR company_code = '*')");
|
||||
sqlParams.add(companyCode);
|
||||
}
|
||||
sql.append(" ORDER BY value_order, value_label");
|
||||
|
||||
List<Map<String, Object>> options = jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", options);
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Object> getCategoryValueCascadingOptions(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
|
||||
String parentValuesStr = params.get("parent_values") != null ? String.valueOf(params.get("parent_values")) : null;
|
||||
String parentValue = params.get("parent_value") != null ? String.valueOf(params.get("parent_value")) : null;
|
||||
|
||||
List<String> parentValueArray = new ArrayList<>();
|
||||
if (parentValuesStr != null && !parentValuesStr.isEmpty()) {
|
||||
for (String v : parentValuesStr.split(",")) {
|
||||
String trimmed = v.trim();
|
||||
if (!trimmed.isEmpty()) parentValueArray.add(trimmed);
|
||||
}
|
||||
} else if (parentValue != null && !parentValue.isEmpty()) {
|
||||
parentValueArray.add(parentValue);
|
||||
}
|
||||
|
||||
if (parentValueArray.isEmpty()) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", Collections.emptyList());
|
||||
return result;
|
||||
}
|
||||
|
||||
Map<String, Object> group = sqlSession.selectOne(NS + "getCategoryValueCascadingGroupByCode", params);
|
||||
if (group == null) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", Collections.emptyList());
|
||||
return result;
|
||||
}
|
||||
|
||||
Object groupId = group.get("group_id");
|
||||
String showGroupLabel = group.get("show_group_label") != null ? String.valueOf(group.get("show_group_label")) : "N";
|
||||
|
||||
String placeholders = parentValueArray.stream().map(v -> "?").collect(Collectors.joining(", "));
|
||||
String sql = "SELECT DISTINCT child_value_code as value, child_value_label as label," +
|
||||
" parent_value_code as parent_value, parent_value_label as parent_label, display_order" +
|
||||
" FROM category_value_cascading_mapping" +
|
||||
" WHERE group_id = ? AND parent_value_code IN (" + placeholders + ") AND is_active = 'Y'" +
|
||||
" ORDER BY parent_value_code, display_order, child_value_label";
|
||||
|
||||
List<Object> sqlParams = new ArrayList<>();
|
||||
sqlParams.add(groupId);
|
||||
sqlParams.addAll(parentValueArray);
|
||||
|
||||
List<Map<String, Object>> options = jdbcTemplate.queryForList(sql, sqlParams.toArray());
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", options);
|
||||
result.put("show_group_label", "Y".equals(showGroupLabel));
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Object> getCategoryValueCascadingMappingsByTable(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String companyCode = (String) params.get("company_code");
|
||||
String tableName = (String) params.get("table_name");
|
||||
|
||||
StringBuilder groupSql = new StringBuilder(
|
||||
"SELECT group_id, relation_code, child_column_name" +
|
||||
" FROM category_value_cascading_group" +
|
||||
" WHERE child_table_name = ? AND is_active = 'Y'");
|
||||
List<Object> groupSqlParams = new ArrayList<>();
|
||||
groupSqlParams.add(tableName);
|
||||
|
||||
if (companyCode != null && !"*".equals(companyCode)) {
|
||||
groupSql.append(" AND (company_code = ? OR company_code = '*')");
|
||||
groupSqlParams.add(companyCode);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> groups = jdbcTemplate.queryForList(groupSql.toString(), groupSqlParams.toArray());
|
||||
|
||||
Map<String, Object> mappings = new LinkedHashMap<>();
|
||||
for (Map<String, Object> group : groups) {
|
||||
Object groupId = group.get("group_id");
|
||||
String childColumnName = String.valueOf(group.get("child_column_name"));
|
||||
|
||||
List<Map<String, Object>> groupMappings = jdbcTemplate.queryForList(
|
||||
"SELECT DISTINCT child_value_code as code, child_value_label as label" +
|
||||
" FROM category_value_cascading_mapping" +
|
||||
" WHERE group_id = ? AND is_active = 'Y'" +
|
||||
" ORDER BY child_value_label",
|
||||
groupId);
|
||||
|
||||
if (!groupMappings.isEmpty()) {
|
||||
mappings.put(childColumnName, groupMappings);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", mappings);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CodeMergeService extends BaseService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
private static final String NS = "codeMerge.";
|
||||
|
||||
// ── Tables With Column ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /tables-with-column/:columnName
|
||||
* 해당 컬럼과 company_code 컬럼을 함께 가진 public 테이블 목록 반환
|
||||
*/
|
||||
public Map<String, Object> getTablesWithColumn(String columnName) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("column_name", columnName);
|
||||
List<Map<String, Object>> rows = sqlSession.selectList(NS + "getTablesWithColumn", params);
|
||||
|
||||
List<String> tables = rows.stream()
|
||||
.map(r -> {
|
||||
Object val = r.get("table_name");
|
||||
return val != null ? val.toString() : null;
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("column_name", columnName);
|
||||
result.put("tables", tables);
|
||||
result.put("count", tables.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Preview (column-based) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /preview
|
||||
* columnName + oldValue 기준으로 영향받을 테이블/행 수 미리보기 (DB 변경 없음)
|
||||
*/
|
||||
public Map<String, Object> previewCodeMerge(Map<String, Object> body) {
|
||||
String columnName = str(body.get("column_name"));
|
||||
String oldValue = str(body.get("old_value"));
|
||||
String companyCode = str(body.get("company_code"));
|
||||
|
||||
if (isBlank(columnName) || isBlank(oldValue)) {
|
||||
throw new IllegalArgumentException("필수 필드가 누락되었습니다. (columnName, oldValue)");
|
||||
}
|
||||
|
||||
log.info("코드 병합 미리보기: column={}, oldValue={}, company={}", columnName, oldValue, companyCode);
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("column_name", columnName);
|
||||
List<Map<String, Object>> tableRows = sqlSession.selectList(NS + "getTablesWithColumn", params);
|
||||
|
||||
List<Map<String, Object>> preview = new ArrayList<>();
|
||||
int totalRows = 0;
|
||||
|
||||
for (Map<String, Object> tableRow : tableRows) {
|
||||
Object nameVal = tableRow.get("table_name");
|
||||
if (nameVal == null) continue;
|
||||
String tableName = nameVal.toString();
|
||||
|
||||
// 테이블명·컬럼명은 information_schema에서 검증된 값 — SQL 인젝션 위험 없음
|
||||
String countSql = String.format(
|
||||
"SELECT COUNT(*) FROM \"%s\" WHERE \"%s\" = ? AND company_code = ?",
|
||||
tableName, columnName);
|
||||
try {
|
||||
Integer count = jdbcTemplate.queryForObject(countSql, Integer.class, oldValue, companyCode);
|
||||
if (count != null && count > 0) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("table_name", tableName);
|
||||
item.put("affected_rows", count);
|
||||
preview.add(item);
|
||||
totalRows += count;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("테이블 {} 조회 실패 (건너뜀): {}", tableName, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("column_name", columnName);
|
||||
result.put("old_value", oldValue);
|
||||
result.put("preview", preview);
|
||||
result.put("total_affected_rows", totalRows);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Merge All Tables (column-based) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /merge-all-tables
|
||||
* PostgreSQL 함수 merge_code_all_tables(columnName, oldValue, newValue, companyCode) 호출
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> mergeAllTables(Map<String, Object> body) {
|
||||
String columnName = str(body.get("column_name"));
|
||||
String oldValue = str(body.get("old_value"));
|
||||
String newValue = str(body.get("new_value"));
|
||||
String companyCode = str(body.get("company_code"));
|
||||
|
||||
if (isBlank(columnName) || isBlank(oldValue) || isBlank(newValue)) {
|
||||
throw new IllegalArgumentException("필수 필드가 누락되었습니다. (columnName, oldValue, newValue)");
|
||||
}
|
||||
if (oldValue.equals(newValue)) {
|
||||
throw new IllegalArgumentException("기존 값과 새 값이 동일합니다.");
|
||||
}
|
||||
|
||||
log.info("코드 병합 시작: column={}, {} → {}, company={}", columnName, oldValue, newValue, companyCode);
|
||||
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT * FROM merge_code_all_tables(?, ?, ?, ?)",
|
||||
columnName, oldValue, newValue, companyCode);
|
||||
|
||||
int totalRows = rows.stream()
|
||||
.mapToInt(r -> r.get("rows_updated") != null ? ((Number) r.get("rows_updated")).intValue() : 0)
|
||||
.sum();
|
||||
|
||||
List<Map<String, Object>> affectedTables = rows.stream().map(r -> {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("table_name", r.get("table_name"));
|
||||
item.put("rows_updated", r.get("rows_updated") != null ? ((Number) r.get("rows_updated")).intValue() : 0);
|
||||
return item;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
log.info("코드 병합 완료: 영향 테이블 {}개, 총 {}행", affectedTables.size(), totalRows);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("column_name", columnName);
|
||||
result.put("old_value", oldValue);
|
||||
result.put("new_value", newValue);
|
||||
result.put("affected_tables", affectedTables);
|
||||
result.put("total_rows_updated", totalRows);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Merge By Value ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /merge-by-value
|
||||
* PostgreSQL 함수 merge_code_by_value(oldValue, newValue, companyCode) 호출
|
||||
* 컬럼명에 관계없이 해당 값을 가진 모든 위치를 변경
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> mergeByValue(Map<String, Object> body) {
|
||||
String oldValue = str(body.get("old_value"));
|
||||
String newValue = str(body.get("new_value"));
|
||||
String companyCode = str(body.get("company_code"));
|
||||
|
||||
if (isBlank(oldValue) || isBlank(newValue)) {
|
||||
throw new IllegalArgumentException("필수 필드가 누락되었습니다. (oldValue, newValue)");
|
||||
}
|
||||
if (oldValue.equals(newValue)) {
|
||||
throw new IllegalArgumentException("기존 값과 새 값이 동일합니다.");
|
||||
}
|
||||
|
||||
log.info("값 기반 코드 병합 시작: {} → {}, company={}", oldValue, newValue, companyCode);
|
||||
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT * FROM merge_code_by_value(?, ?, ?)",
|
||||
oldValue, newValue, companyCode);
|
||||
|
||||
int totalRows = rows.stream()
|
||||
.mapToInt(r -> r.get("out_rows_updated") != null ? ((Number) r.get("out_rows_updated")).intValue() : 0)
|
||||
.sum();
|
||||
|
||||
List<Map<String, Object>> affectedData = rows.stream().map(r -> {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("table_name", r.get("out_table_name"));
|
||||
item.put("column_name", r.get("out_column_name"));
|
||||
item.put("rows_updated", r.get("out_rows_updated") != null ? ((Number) r.get("out_rows_updated")).intValue() : 0);
|
||||
return item;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
log.info("값 기반 코드 병합 완료: {} → {}, 총 {}행", oldValue, newValue, totalRows);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("old_value", oldValue);
|
||||
result.put("new_value", newValue);
|
||||
result.put("affected_data", affectedData);
|
||||
result.put("total_rows_updated", totalRows);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Preview By Value ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /preview-by-value
|
||||
* PostgreSQL 함수 preview_merge_code_by_value(oldValue, companyCode) 호출
|
||||
*/
|
||||
public Map<String, Object> previewByValue(Map<String, Object> body) {
|
||||
String oldValue = str(body.get("old_value"));
|
||||
String companyCode = str(body.get("company_code"));
|
||||
|
||||
if (isBlank(oldValue)) {
|
||||
throw new IllegalArgumentException("필수 필드가 누락되었습니다. (oldValue)");
|
||||
}
|
||||
|
||||
log.info("값 기반 코드 병합 미리보기: oldValue={}, company={}", oldValue, companyCode);
|
||||
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT * FROM preview_merge_code_by_value(?, ?)",
|
||||
oldValue, companyCode);
|
||||
|
||||
int totalRows = rows.stream()
|
||||
.mapToInt(r -> r.get("out_affected_rows") != null ? ((Number) r.get("out_affected_rows")).intValue() : 0)
|
||||
.sum();
|
||||
|
||||
List<Map<String, Object>> preview = rows.stream().map(r -> {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("table_name", r.get("out_table_name"));
|
||||
item.put("column_name", r.get("out_column_name"));
|
||||
item.put("affected_rows", r.get("out_affected_rows") != null ? ((Number) r.get("out_affected_rows")).intValue() : 0);
|
||||
return item;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("old_value", oldValue);
|
||||
result.put("preview", preview);
|
||||
result.put("total_affected_rows", totalRows);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private String str(Object val) {
|
||||
return val != null ? val.toString() : null;
|
||||
}
|
||||
|
||||
private boolean isBlank(String s) {
|
||||
return s == null || s.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,12 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Common Code Service
|
||||
* Common Code Service — 마스터-디테일 패턴.
|
||||
*
|
||||
* commonCodeRoutes.ts 포팅.
|
||||
* 테이블: code_category, code_info
|
||||
* • code_info : 1레벨 그룹 마스터 (PK = code_info + company_code)
|
||||
* • code_detail : 2레벨 ~ 무한대 트리 (PK = code_detail_id, parent_detail_id 로 self-ref)
|
||||
*
|
||||
* 옛 캐스케이딩/카테고리 구조 폐기. 단일 그룹 안에서 재귀 트리.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@@ -19,13 +21,11 @@ public class CommonCodeService extends BaseService {
|
||||
|
||||
private static final String NS = "commonCode.";
|
||||
|
||||
private static final long DEFAULT_MENU_OBJID = 1757401858940L;
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 카테고리 목록
|
||||
// CODE_INFO — 그룹 마스터 CRUD
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public Map<String, Object> getCommonCodeCategoryList(Map<String, Object> params) {
|
||||
public Map<String, Object> getCodeInfoList(Map<String, Object> params) {
|
||||
int page = toInt(params.get("page"), 1);
|
||||
int size = toInt(params.get("size"), 20);
|
||||
params.put("limit", size);
|
||||
@@ -34,296 +34,54 @@ public class CommonCodeService extends BaseService {
|
||||
Object isActiveRaw = params.get("is_active");
|
||||
if (isActiveRaw != null) params.put("is_active", toActiveStr(isActiveRaw));
|
||||
|
||||
List<Map<String, Object>> categories = sqlSession.selectList(NS + "getCommonCodeCategoryList", params);
|
||||
Integer totalObj = sqlSession.selectOne(NS + "getCommonCodeCategoryListCnt", params);
|
||||
List<Map<String, Object>> data = sqlSession.selectList(NS + "getCodeInfoList", params);
|
||||
Integer totalObj = sqlSession.selectOne(NS + "getCodeInfoListCnt", params);
|
||||
int total = totalObj != null ? totalObj : 0;
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", categories);
|
||||
result.put("data", data);
|
||||
result.put("total", total);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 카테고리 중복 확인
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public Map<String, Object> checkCategoryDuplicate(String field, String value,
|
||||
String excludeCode, String companyCode) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("is_duplicate", false);
|
||||
result.put("message", "값을 입력해주세요.");
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Object> getCodeInfoInfo(String codeInfo, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("field", field != null ? field : "category_code");
|
||||
params.put("value", value.trim());
|
||||
params.put("exclude_code", excludeCode);
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("company_code", companyCode);
|
||||
Integer countObj = sqlSession.selectOne(NS + "getCommonCodeCategoryDuplicateByField", params);
|
||||
boolean isDuplicate = countObj != null && countObj > 0;
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("is_duplicate", isDuplicate);
|
||||
result.put("message", isDuplicate ? "이미 사용 중인 값입니다." : "사용 가능한 값입니다.");
|
||||
return result;
|
||||
return sqlSession.selectOne(NS + "getCodeInfoInfo", params);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 카테고리 생성
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCommonCodeCategory(Map<String, Object> body, String companyCode, String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", body.get("category_code"));
|
||||
params.put("category_name", body.get("category_name"));
|
||||
params.put("category_name_eng", body.getOrDefault("category_name_eng", null));
|
||||
params.put("description", body.getOrDefault("description", null));
|
||||
params.put("sort_order", body.getOrDefault("sort_order", 0));
|
||||
params.put("is_active", toActiveStr(body.getOrDefault("is_active", true)));
|
||||
params.put("menu_objid", body.getOrDefault("menu_objid", DEFAULT_MENU_OBJID));
|
||||
params.put("company_code", companyCode);
|
||||
params.put("created_by", userId);
|
||||
params.put("updated_by", userId);
|
||||
|
||||
sqlSession.insert(NS + "insertCommonCodeCategory", params);
|
||||
|
||||
Map<String, Object> q = new HashMap<>();
|
||||
q.put("category_code", params.get("category_code"));
|
||||
q.put("company_code", companyCode);
|
||||
return sqlSession.selectOne(NS + "getCommonCodeCategoryInfo", q);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 카테고리 수정
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCommonCodeCategory(String categoryCode, Map<String, Object> body,
|
||||
String companyCode, String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("company_code", companyCode);
|
||||
params.put("updated_by", userId);
|
||||
|
||||
if (body.containsKey("category_name")) params.put("category_name", body.get("category_name"));
|
||||
if (body.containsKey("category_name_eng")) params.put("category_name_eng", body.get("category_name_eng"));
|
||||
if (body.containsKey("description")) params.put("description", body.get("description"));
|
||||
if (body.containsKey("sort_order")) params.put("sort_order", body.get("sort_order"));
|
||||
if (body.containsKey("is_active")) params.put("is_active", toActiveStr(body.get("is_active")));
|
||||
|
||||
int updated = sqlSession.update(NS + "updateCommonCodeCategory", params);
|
||||
if (updated == 0) return null;
|
||||
|
||||
Map<String, Object> q = new HashMap<>();
|
||||
q.put("category_code", categoryCode);
|
||||
q.put("company_code", companyCode);
|
||||
return sqlSession.selectOne(NS + "getCommonCodeCategoryInfo", q);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 카테고리 삭제
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public void deleteCommonCodeCategory(String categoryCode, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("company_code", companyCode);
|
||||
int deleted = sqlSession.delete(NS + "deleteCommonCodeCategory", params);
|
||||
if (deleted == 0) throw new IllegalArgumentException("카테고리를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 목록 (snake_case + camelCase 이중 필드)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public Map<String, Object> getCommonCodeList(String categoryCode, Map<String, Object> params) {
|
||||
int page = toInt(params.get("page"), 1);
|
||||
int size = toInt(params.get("size"), 20);
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("limit", size);
|
||||
params.put("offset", (page - 1) * size);
|
||||
|
||||
Object isActiveRaw = params.get("is_active");
|
||||
if (isActiveRaw != null) params.put("is_active", toActiveStr(isActiveRaw));
|
||||
|
||||
List<Map<String, Object>> rawList = sqlSession.selectList(NS + "getCommonCodeList", params);
|
||||
Integer totalObj = sqlSession.selectOne(NS + "getCommonCodeListCnt", params);
|
||||
int total = totalObj != null ? totalObj : 0;
|
||||
|
||||
List<Map<String, Object>> codes = new ArrayList<>();
|
||||
for (Map<String, Object> raw : rawList) {
|
||||
codes.add(transformCode(raw));
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", codes);
|
||||
result.put("total", total);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 중복 확인
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public Map<String, Object> checkCodeDuplicate(String categoryCode, String field, String value,
|
||||
String excludeCode, String companyCode) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("is_duplicate", false);
|
||||
result.put("message", "값을 입력해주세요.");
|
||||
return result;
|
||||
public Map<String, Object> insertCodeInfo(Map<String, Object> body, String companyCode, String userId) {
|
||||
Object rawCodeInfo = body.get("code_info");
|
||||
String codeInfo = rawCodeInfo == null ? null : rawCodeInfo.toString().trim();
|
||||
if (codeInfo != null && !codeInfo.isEmpty()
|
||||
&& getCodeInfoInfo(codeInfo, companyCode) != null) {
|
||||
throw new IllegalArgumentException("이미 존재하는 그룹 코드입니다: " + codeInfo);
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("field", field != null ? field : "code_value");
|
||||
params.put("value", value.trim());
|
||||
params.put("exclude_code", excludeCode);
|
||||
params.put("company_code", companyCode);
|
||||
Integer countObj = sqlSession.selectOne(NS + "getCommonCodeDuplicateByField", params);
|
||||
boolean isDuplicate = countObj != null && countObj > 0;
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("is_duplicate", isDuplicate);
|
||||
result.put("message", isDuplicate ? "이미 사용 중인 값입니다." : "사용 가능한 값입니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 생성
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCommonCode(String categoryCode, Map<String, Object> body,
|
||||
String companyCode, String userId) {
|
||||
// parentCodeValue 기반 depth 자동 계산
|
||||
Object parentCodeValueRaw = body.getOrDefault("parent_code_value", null);
|
||||
int depth = 1;
|
||||
if (parentCodeValueRaw != null && !parentCodeValueRaw.toString().isEmpty()) {
|
||||
Map<String, Object> parentParams = new HashMap<>();
|
||||
parentParams.put("category_code", categoryCode);
|
||||
parentParams.put("code_value", parentCodeValueRaw.toString());
|
||||
parentParams.put("company_code", companyCode);
|
||||
Integer parentDepth = sqlSession.selectOne(NS + "getCommonCodeParentDepth", parentParams);
|
||||
depth = (parentDepth != null ? parentDepth : 0) + 1;
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("code_value", body.get("code_value"));
|
||||
params.put("code_info", body.get("code_info"));
|
||||
params.put("code_name", body.get("code_name"));
|
||||
params.put("code_name_eng", body.getOrDefault("code_name_eng", null));
|
||||
params.put("description", body.getOrDefault("description", null));
|
||||
params.put("sort_order", body.getOrDefault("sort_order", 0));
|
||||
params.put("is_active", toActiveStr(body.getOrDefault("is_active", true)));
|
||||
params.put("menu_objid", body.getOrDefault("menu_objid", DEFAULT_MENU_OBJID));
|
||||
params.put("menu_objid", body.getOrDefault("menu_objid", null));
|
||||
params.put("company_code", companyCode);
|
||||
params.put("parent_code_value", parentCodeValueRaw);
|
||||
params.put("depth", depth);
|
||||
params.put("created_by", userId);
|
||||
params.put("updated_by", userId);
|
||||
|
||||
sqlSession.insert(NS + "insertCommonCode", params);
|
||||
sqlSession.insert(NS + "insertCodeInfo", params);
|
||||
|
||||
Map<String, Object> q = new HashMap<>();
|
||||
q.put("category_code", categoryCode);
|
||||
q.put("code_value", params.get("code_value"));
|
||||
q.put("company_code", companyCode);
|
||||
Map<String, Object> raw = sqlSession.selectOne(NS + "getCommonCodeInfo", q);
|
||||
return raw != null ? transformCode(raw) : null;
|
||||
return getCodeInfoInfo(String.valueOf(params.get("code_info")), companyCode);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 정렬 순서 변경
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public void updateCommonCodeOrder(String categoryCode, List<Map<String, Object>> codes, String companyCode) {
|
||||
for (Map<String, Object> code : codes) {
|
||||
public Map<String, Object> updateCodeInfo(String codeInfo, Map<String, Object> body,
|
||||
String companyCode, String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("code_value", code.get("code_value"));
|
||||
params.put("sort_order", code.get("sort_order"));
|
||||
params.put("company_code", companyCode);
|
||||
sqlSession.update(NS + "updateCommonCodeSortOrder", params);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 계층형 코드 목록
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public List<Map<String, Object>> getCommonCodeHierarchicalList(String categoryCode, Map<String, Object> params) {
|
||||
params.put("category_code", categoryCode);
|
||||
|
||||
Object isActiveRaw = params.get("is_active");
|
||||
if (isActiveRaw != null) params.put("is_active", toActiveStr(isActiveRaw));
|
||||
else params.remove("is_active");
|
||||
|
||||
// parentCodeValue, depth 필터는 params에 그대로 전달 (XML에서 처리)
|
||||
|
||||
List<Map<String, Object>> rawList = sqlSession.selectList(NS + "getCommonCodeHierarchicalList", params);
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Map<String, Object> raw : rawList) {
|
||||
result.add(transformCode(raw));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 트리 구조 — { flat: [...], tree: [...] }
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public Map<String, Object> getCommonCodeTree(String categoryCode, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("company_code", companyCode);
|
||||
|
||||
List<Map<String, Object>> flatList = sqlSession.selectList(NS + "getCommonCodeTreeList", params);
|
||||
|
||||
List<Map<String, Object>> flatTransformed = new ArrayList<>();
|
||||
for (Map<String, Object> raw : flatList) {
|
||||
flatTransformed.add(transformCode(raw));
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("flat", flatTransformed);
|
||||
result.put("tree", buildTree(flatList));
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 자식 존재 여부
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public Map<String, Object> hasChildren(String categoryCode, String codeValue, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("code_value", codeValue);
|
||||
params.put("company_code", companyCode);
|
||||
Integer countObj = sqlSession.selectOne(NS + "getCommonCodeChildrenCnt", params);
|
||||
int count = countObj != null ? countObj : 0;
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("has_children", count > 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 수정
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCommonCode(String categoryCode, String codeValue,
|
||||
Map<String, Object> body, String companyCode, String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("code_value", codeValue);
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("company_code", companyCode);
|
||||
params.put("updated_by", userId);
|
||||
|
||||
@@ -332,127 +90,224 @@ public class CommonCodeService extends BaseService {
|
||||
if (body.containsKey("description")) params.put("description", body.get("description"));
|
||||
if (body.containsKey("sort_order")) params.put("sort_order", body.get("sort_order"));
|
||||
if (body.containsKey("is_active")) params.put("is_active", toActiveStr(body.get("is_active")));
|
||||
if (body.containsKey("menu_objid")) params.put("menu_objid", body.get("menu_objid"));
|
||||
|
||||
if (body.containsKey("parent_code_value")) {
|
||||
Object newParent = body.get("parent_code_value");
|
||||
params.put("parent_code_value", newParent);
|
||||
// parentCodeValue 변경 시 depth 재계산
|
||||
if (newParent != null && !newParent.toString().isEmpty()) {
|
||||
Map<String, Object> parentParams = new HashMap<>();
|
||||
parentParams.put("category_code", categoryCode);
|
||||
parentParams.put("code_value", newParent.toString());
|
||||
parentParams.put("company_code", companyCode);
|
||||
Integer parentDepth = sqlSession.selectOne(NS + "getCommonCodeParentDepth", parentParams);
|
||||
params.put("depth", (parentDepth != null ? parentDepth : 0) + 1);
|
||||
} else {
|
||||
params.put("depth", 1);
|
||||
}
|
||||
} else if (body.containsKey("depth")) {
|
||||
params.put("depth", body.get("depth"));
|
||||
}
|
||||
|
||||
int updated = sqlSession.update(NS + "updateCommonCode", params);
|
||||
int updated = sqlSession.update(NS + "updateCodeInfo", params);
|
||||
if (updated == 0) return null;
|
||||
|
||||
Map<String, Object> q = new HashMap<>();
|
||||
q.put("category_code", categoryCode);
|
||||
q.put("code_value", codeValue);
|
||||
q.put("company_code", companyCode);
|
||||
Map<String, Object> raw = sqlSession.selectOne(NS + "getCommonCodeInfo", q);
|
||||
return raw != null ? transformCode(raw) : null;
|
||||
return getCodeInfoInfo(codeInfo, companyCode);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteCodeInfo(String codeInfo, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("company_code", companyCode);
|
||||
|
||||
int deleted = sqlSession.delete(NS + "deleteCodeInfo", params);
|
||||
if (deleted == 0) throw new IllegalArgumentException("코드 그룹을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
public Map<String, Object> checkCodeInfoDuplicate(String field, String value,
|
||||
String excludeCode, String companyCode) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
result.put("is_duplicate", false);
|
||||
result.put("message", "값을 입력해주세요.");
|
||||
return result;
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("field", field != null ? field : "code_info");
|
||||
params.put("value", value.trim());
|
||||
params.put("exclude_code", excludeCode);
|
||||
params.put("company_code", companyCode);
|
||||
|
||||
Integer countObj = sqlSession.selectOne(NS + "getCodeInfoDuplicateByField", params);
|
||||
boolean isDuplicate = countObj != null && countObj > 0;
|
||||
|
||||
result.put("is_duplicate", isDuplicate);
|
||||
result.put("message", isDuplicate ? "이미 사용 중인 값입니다." : "사용 가능한 값입니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 삭제
|
||||
// CODE_DETAIL — 디테일 트리 CRUD
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public void deleteCommonCode(String categoryCode, String codeValue, String companyCode) {
|
||||
public Map<String, Object> getCodeDetailList(String codeInfo, Map<String, Object> params) {
|
||||
int page = toInt(params.get("page"), 1);
|
||||
int size = toInt(params.get("size"), 20);
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("limit", size);
|
||||
params.put("offset", (page - 1) * size);
|
||||
|
||||
Object isActiveRaw = params.get("is_active");
|
||||
if (isActiveRaw != null) params.put("is_active", toActiveStr(isActiveRaw));
|
||||
|
||||
Object parentRaw = params.get("parent_detail_id");
|
||||
if (parentRaw != null && !parentRaw.toString().isEmpty()) {
|
||||
params.put("parent_detail_id", toLong(parentRaw));
|
||||
} else {
|
||||
params.remove("parent_detail_id");
|
||||
}
|
||||
|
||||
List<Map<String, Object>> data = sqlSession.selectList(NS + "getCodeDetailList", params);
|
||||
Integer totalObj = sqlSession.selectOne(NS + "getCodeDetailListCnt", params);
|
||||
int total = totalObj != null ? totalObj : 0;
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", data);
|
||||
result.put("total", total);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹 전체 트리 — 평탄화된 리스트로 반환 (depth + sort_order 순).
|
||||
* 프론트가 parent_detail_id 로 nest 처리하기 좋게.
|
||||
*/
|
||||
public List<Map<String, Object>> getCodeDetailTree(String codeInfo, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("code_value", codeValue);
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("company_code", companyCode);
|
||||
int deleted = sqlSession.delete(NS + "deleteCommonCode", params);
|
||||
return sqlSession.selectList(NS + "getCodeDetailTree", params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getCodeDetailInfo(Long codeDetailId, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_detail_id", codeDetailId);
|
||||
params.put("company_code", companyCode);
|
||||
return sqlSession.selectOne(NS + "getCodeDetailInfo", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCodeDetail(String codeInfo, Map<String, Object> body,
|
||||
String companyCode, String userId) {
|
||||
// parent_detail_id 기반 depth 자동 계산. NULL = 그룹 직속 (depth=2).
|
||||
Long parentDetailId = toLong(body.getOrDefault("parent_detail_id", null));
|
||||
int depth = 2;
|
||||
if (parentDetailId != null) {
|
||||
Map<String, Object> parentParams = new HashMap<>();
|
||||
parentParams.put("code_detail_id", parentDetailId);
|
||||
parentParams.put("company_code", companyCode);
|
||||
Integer parentDepth = sqlSession.selectOne(NS + "getCodeDetailParentDepth", parentParams);
|
||||
depth = (parentDepth != null ? parentDepth : 1) + 1;
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("parent_detail_id", parentDetailId);
|
||||
params.put("code_value", body.get("code_value"));
|
||||
params.put("code_name", body.get("code_name"));
|
||||
params.put("code_name_eng", body.getOrDefault("code_name_eng", null));
|
||||
params.put("description", body.getOrDefault("description", null));
|
||||
params.put("depth", depth);
|
||||
params.put("sort_order", body.getOrDefault("sort_order", 0));
|
||||
params.put("is_active", toActiveStr(body.getOrDefault("is_active", true)));
|
||||
params.put("company_code", companyCode);
|
||||
params.put("created_by", userId);
|
||||
params.put("updated_by", userId);
|
||||
|
||||
sqlSession.insert(NS + "insertCodeDetail", params);
|
||||
|
||||
Object newIdRaw = params.get("code_detail_id");
|
||||
Long newId = toLong(newIdRaw);
|
||||
return newId != null ? getCodeDetailInfo(newId, companyCode) : null;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCodeDetail(Long codeDetailId, Map<String, Object> body,
|
||||
String companyCode, String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_detail_id", codeDetailId);
|
||||
params.put("company_code", companyCode);
|
||||
params.put("updated_by", userId);
|
||||
|
||||
if (body.containsKey("code_value")) params.put("code_value", body.get("code_value"));
|
||||
if (body.containsKey("code_name")) params.put("code_name", body.get("code_name"));
|
||||
if (body.containsKey("code_name_eng")) params.put("code_name_eng", body.get("code_name_eng"));
|
||||
if (body.containsKey("description")) params.put("description", body.get("description"));
|
||||
if (body.containsKey("sort_order")) params.put("sort_order", body.get("sort_order"));
|
||||
if (body.containsKey("is_active")) params.put("is_active", toActiveStr(body.get("is_active")));
|
||||
|
||||
// parent_detail_id 변경 시 depth 재계산.
|
||||
if (body.containsKey("parent_detail_id")) {
|
||||
Long newParent = toLong(body.get("parent_detail_id"));
|
||||
int newDepth = 2;
|
||||
if (newParent != null) {
|
||||
Map<String, Object> parentParams = new HashMap<>();
|
||||
parentParams.put("code_detail_id", newParent);
|
||||
parentParams.put("company_code", companyCode);
|
||||
Integer parentDepth = sqlSession.selectOne(NS + "getCodeDetailParentDepth", parentParams);
|
||||
newDepth = (parentDepth != null ? parentDepth : 1) + 1;
|
||||
}
|
||||
params.put("reparent", true);
|
||||
params.put("parent_detail_id", newParent);
|
||||
params.put("depth", newDepth);
|
||||
}
|
||||
|
||||
int updated = sqlSession.update(NS + "updateCodeDetail", params);
|
||||
if (updated == 0) return null;
|
||||
|
||||
return getCodeDetailInfo(codeDetailId, companyCode);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteCodeDetail(Long codeDetailId, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_detail_id", codeDetailId);
|
||||
params.put("company_code", companyCode);
|
||||
|
||||
int deleted = sqlSession.delete(NS + "deleteCodeDetail", params);
|
||||
if (deleted == 0) throw new IllegalArgumentException("코드를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 옵션 목록
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public List<Map<String, Object>> getCommonCodeOptionList(String categoryCode, Map<String, Object> params) {
|
||||
params.put("category_code", categoryCode);
|
||||
|
||||
Object isActiveRaw = params.get("is_active");
|
||||
// 미지정 시 활성 코드만 반환 (드롭다운 기본 동작)
|
||||
params.put("is_active", isActiveRaw != null ? toActiveStr(isActiveRaw) : "Y");
|
||||
|
||||
List<Map<String, Object>> rawList = sqlSession.selectList(NS + "getCommonCodeOptionList", params);
|
||||
List<Map<String, Object>> options = new ArrayList<>();
|
||||
for (Map<String, Object> raw : rawList) {
|
||||
Map<String, Object> opt = new LinkedHashMap<>();
|
||||
opt.put("value", raw.get("code_value"));
|
||||
opt.put("label", raw.get("code_name"));
|
||||
opt.put("label_eng", raw.get("code_name_eng"));
|
||||
options.add(opt);
|
||||
public Map<String, Object> checkCodeDetailDuplicate(String codeInfo, String codeValue,
|
||||
Long excludeId, String companyCode) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
if (codeValue == null || codeValue.trim().isEmpty()) {
|
||||
result.put("is_duplicate", false);
|
||||
result.put("message", "값을 입력해주세요.");
|
||||
return result;
|
||||
}
|
||||
return options;
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("code_value", codeValue.trim());
|
||||
params.put("exclude_id", excludeId);
|
||||
params.put("company_code", companyCode);
|
||||
|
||||
Integer countObj = sqlSession.selectOne(NS + "getCodeDetailDuplicateCnt", params);
|
||||
boolean isDuplicate = countObj != null && countObj > 0;
|
||||
|
||||
result.put("is_duplicate", isDuplicate);
|
||||
result.put("message", isDuplicate ? "이미 사용 중인 값입니다." : "사용 가능한 값입니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Object> hasCodeDetailChildren(Long codeDetailId, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_detail_id", codeDetailId);
|
||||
params.put("company_code", companyCode);
|
||||
|
||||
Integer countObj = sqlSession.selectOne(NS + "getCodeDetailChildrenCnt", params);
|
||||
int count = countObj != null ? countObj : 0;
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("has_children", count > 0);
|
||||
result.put("count", count);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Private helpers
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/** snake_case 원본 + camelCase 별칭을 모두 포함하는 Map 반환 */
|
||||
private Map<String, Object> transformCode(Map<String, Object> raw) {
|
||||
Map<String, Object> item = new LinkedHashMap<>(raw);
|
||||
item.put("code_value", raw.get("code_value"));
|
||||
item.put("code_name", raw.get("code_name"));
|
||||
item.put("code_name_eng", raw.get("code_name_eng"));
|
||||
item.put("code_category", raw.get("code_category"));
|
||||
item.put("sort_order", raw.get("sort_order"));
|
||||
item.put("is_active", raw.get("is_active"));
|
||||
item.put("menu_objid", raw.get("menu_objid"));
|
||||
item.put("company_code", raw.get("company_code"));
|
||||
item.put("parent_code_value", raw.get("parent_code_value"));
|
||||
item.put("created_by", raw.get("created_by"));
|
||||
item.put("updated_by", raw.get("updated_by"));
|
||||
item.put("created_date", raw.get("created_date"));
|
||||
item.put("updated_date", raw.get("updated_date"));
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 평탄 목록을 부모-자식 트리로 변환 */
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Map<String, Object>> buildTree(List<Map<String, Object>> codes) {
|
||||
Map<String, Map<String, Object>> byValue = new LinkedHashMap<>();
|
||||
for (Map<String, Object> code : codes) {
|
||||
String val = objToStr(code.get("code_value"));
|
||||
Map<String, Object> node = new LinkedHashMap<>(transformCode(code));
|
||||
node.put("children", new ArrayList<Map<String, Object>>());
|
||||
byValue.put(val, node);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> roots = new ArrayList<>();
|
||||
for (Map<String, Object> code : codes) {
|
||||
String val = objToStr(code.get("code_value"));
|
||||
Object parentRaw = code.get("parent_code_value");
|
||||
String parentVal = (parentRaw != null) ? parentRaw.toString() : null;
|
||||
|
||||
Map<String, Object> node = byValue.get(val);
|
||||
if (parentVal == null || parentVal.isEmpty() || !byValue.containsKey(parentVal)) {
|
||||
roots.add(node);
|
||||
} else {
|
||||
((List<Map<String, Object>>) byValue.get(parentVal).get("children")).add(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/** boolean/String → VARCHAR 'Y'/'N' 변환 */
|
||||
/** boolean / String / Number → VARCHAR(1) 'Y'/'N'. */
|
||||
private String toActiveStr(Object val) {
|
||||
if (val == null) return "Y";
|
||||
if (val instanceof Boolean b) return b ? "Y" : "N";
|
||||
if (val instanceof Number n) return n.intValue() != 0 ? "Y" : "N";
|
||||
String s = val.toString().toLowerCase();
|
||||
return ("true".equals(s) || "y".equals(s) || "1".equals(s)) ? "Y" : "N";
|
||||
}
|
||||
@@ -463,7 +318,12 @@ public class CommonCodeService extends BaseService {
|
||||
catch (NumberFormatException e) { return defaultVal; }
|
||||
}
|
||||
|
||||
private String objToStr(Object val) {
|
||||
return val != null ? val.toString() : "";
|
||||
private Long toLong(Object val) {
|
||||
if (val == null) return null;
|
||||
if (val instanceof Number n) return n.longValue();
|
||||
String s = val.toString().trim();
|
||||
if (s.isEmpty() || "null".equalsIgnoreCase(s)) return null;
|
||||
try { return Long.parseLong(s); }
|
||||
catch (NumberFormatException e) { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import com.erp.constants.InputTypeConstants;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -39,12 +40,6 @@ public class DdlService extends BaseService {
|
||||
"id", "created_date", "updated_date", "company_code"
|
||||
);
|
||||
|
||||
/** 사용자가 신규 추가하는 컬럼에 허용되는 INPUT_TYPE 8종 (백엔드 백스톱) */
|
||||
private static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
|
||||
"text", "number", "date", "code", "entity",
|
||||
"numbering", "file", "image"
|
||||
);
|
||||
|
||||
public DdlService(JdbcTemplate jdbcTemplate, PlatformTransactionManager transactionManager) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.transactionTemplate = new TransactionTemplate(transactionManager);
|
||||
@@ -146,9 +141,9 @@ public class DdlService extends BaseService {
|
||||
transactionTemplate.execute(status -> {
|
||||
jdbcTemplate.execute(ddlQuery);
|
||||
String inputType = convertToInputType(column);
|
||||
if (!USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
|
||||
if (!InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
|
||||
throw new IllegalArgumentException(
|
||||
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + USER_SELECTABLE_INPUT_TYPES
|
||||
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
|
||||
+ " (받은 값: " + inputType + ")"
|
||||
);
|
||||
}
|
||||
@@ -421,9 +416,9 @@ public class DdlService extends BaseService {
|
||||
for (int i = 0; i < columns.size(); i++) {
|
||||
Map<String, Object> col = columns.get(i);
|
||||
String inputType = convertToInputType(col);
|
||||
if (!USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
|
||||
if (!InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
|
||||
throw new IllegalArgumentException(
|
||||
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + USER_SELECTABLE_INPUT_TYPES
|
||||
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
|
||||
+ " (받은 값: " + inputType + ")"
|
||||
);
|
||||
}
|
||||
@@ -532,6 +527,9 @@ public class DdlService extends BaseService {
|
||||
case "radio" -> "radio";
|
||||
case "code" -> "code";
|
||||
case "entity" -> "entity";
|
||||
case "file" -> "file";
|
||||
case "image" -> "image";
|
||||
case "numbering" -> "numbering";
|
||||
default -> "text";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
@@ -20,17 +21,22 @@ public class DepartmentService extends BaseService {
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
public List<Map<String, Object>> getDepartments(String companyCode) {
|
||||
return getDepartments(companyCode, false);
|
||||
return getDepartments(companyCode, false, null);
|
||||
}
|
||||
|
||||
/** soft-delete 대응 — includeDeleted=true 면 DELETED_AT 부서도 포함 */
|
||||
public List<Map<String, Object>> getDepartments(String companyCode, boolean includeDeleted) {
|
||||
return getDepartments(companyCode, includeDeleted, null);
|
||||
}
|
||||
|
||||
/** 기준일 필터 — baseDate 가 있으면 해당 시점에 active 한 부서만 반환 (start_date ≤ baseDate ≤ end_date OR end_date IS NULL) */
|
||||
public List<Map<String, Object>> getDepartments(String companyCode, boolean includeDeleted, String baseDate) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("include_deleted", includeDeleted);
|
||||
params.put("base_date", baseDate); // null/빈문자면 XML if 가 skip
|
||||
List<Map<String, Object>> departments = sqlSession.selectList("department.selectDepartments", params);
|
||||
|
||||
// member_count를 int로 변환
|
||||
for (Map<String, Object> dept : departments) {
|
||||
Object cnt = dept.get("member_count");
|
||||
if (cnt != null) {
|
||||
@@ -38,6 +44,10 @@ public class DepartmentService extends BaseService {
|
||||
} else {
|
||||
dept.put("member_count", 0);
|
||||
}
|
||||
// dept_managers JSON 컬럼들 (String) → List<Map> 으로 파싱
|
||||
parseManagersJson(dept, "approval_managers");
|
||||
parseManagersJson(dept, "dept_managers");
|
||||
parseManagersJson(dept, "org_leaders");
|
||||
}
|
||||
return departments;
|
||||
}
|
||||
@@ -46,14 +56,26 @@ public class DepartmentService extends BaseService {
|
||||
public Map<String, Object> getDepartment(String deptCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dept_code", deptCode);
|
||||
return sqlSession.selectOne("department.selectDepartmentByCode", params);
|
||||
Map<String, Object> dept = sqlSession.selectOne("department.selectDepartmentByCode", params);
|
||||
if (dept != null) {
|
||||
parseManagersJson(dept, "approval_managers");
|
||||
parseManagersJson(dept, "dept_managers");
|
||||
parseManagersJson(dept, "org_leaders");
|
||||
}
|
||||
return dept;
|
||||
}
|
||||
|
||||
/** deleted 부서까지 포함 — 복구 검증 / 부모 deleted 체크 등 internal 흐름용 */
|
||||
public Map<String, Object> getDepartmentIncludingDeleted(String deptCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dept_code", deptCode);
|
||||
return sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", params);
|
||||
Map<String, Object> dept = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", params);
|
||||
if (dept != null) {
|
||||
parseManagersJson(dept, "approval_managers");
|
||||
parseManagersJson(dept, "dept_managers");
|
||||
parseManagersJson(dept, "org_leaders");
|
||||
}
|
||||
return dept;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -129,11 +151,15 @@ public class DepartmentService extends BaseService {
|
||||
insertParams.put("location", nullIfBlank(bodyParam(body, "location", "location")));
|
||||
sqlSession.insert("department.insertDepartment", insertParams);
|
||||
|
||||
syncManagers(deptCode, companyCode, body, "approval");
|
||||
syncManagers(deptCode, companyCode, body, "dept");
|
||||
syncManagers(deptCode, companyCode, body, "org_leader");
|
||||
|
||||
log.info("부서 생성 성공: deptCode={}, deptName={}", deptCode, deptName);
|
||||
|
||||
Map<String, Object> findParams = new HashMap<>();
|
||||
findParams.put("dept_code", deptCode);
|
||||
return sqlSession.selectOne("department.selectDepartmentByCode", findParams);
|
||||
return getDepartment(deptCode);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -196,10 +222,12 @@ public class DepartmentService extends BaseService {
|
||||
return null;
|
||||
}
|
||||
|
||||
syncManagers(deptCode, deptCompanyCode, body, "approval");
|
||||
syncManagers(deptCode, deptCompanyCode, body, "dept");
|
||||
syncManagers(deptCode, deptCompanyCode, body, "org_leader");
|
||||
|
||||
log.info("부서 수정 성공: deptCode={}", deptCode);
|
||||
Map<String, Object> findParams = new HashMap<>();
|
||||
findParams.put("dept_code", deptCode);
|
||||
return sqlSession.selectOne("department.selectDepartmentByCode", findParams);
|
||||
return getDepartment(deptCode);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -338,6 +366,330 @@ public class DepartmentService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 일괄등록 / 일괄업데이트 (Bulk)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
private static final int BULK_MAX_ROWS = 1000;
|
||||
|
||||
/**
|
||||
* 일괄등록 — preview (read-only validation). DB 쓰기 없음.
|
||||
* batch 내 dept_name 중복 + DB active 중복 + parent/날짜/매니저 검증.
|
||||
* 각 row 에 row_index / result(ok|error) / error_detail 채워서 반환.
|
||||
*/
|
||||
public List<Map<String, Object>> bulkPreviewCreate(String companyCode, List<Map<String, Object>> rows) {
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
if (rows == null || rows.isEmpty()) return results;
|
||||
if (rows.size() > BULK_MAX_ROWS) {
|
||||
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 처리 가능합니다.");
|
||||
}
|
||||
Set<String> existingNames = collectActiveDeptNames(companyCode);
|
||||
Set<String> batchNames = new HashSet<>();
|
||||
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Map<String, Object> input = rows.get(i);
|
||||
Map<String, Object> out = new HashMap<>(input);
|
||||
out.put("row_index", i);
|
||||
String error = validateBulkCreateRow(input, companyCode, existingNames, batchNames);
|
||||
if (error == null) {
|
||||
out.put("result", "ok");
|
||||
out.put("error_detail", null);
|
||||
String dn = trimString(input.get("dept_name"));
|
||||
if (dn != null) batchNames.add(dn.toLowerCase());
|
||||
} else {
|
||||
out.put("result", "error");
|
||||
out.put("error_detail", error);
|
||||
}
|
||||
results.add(out);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄업데이트 — preview (read-only). mode = department | manager.
|
||||
*/
|
||||
public List<Map<String, Object>> bulkPreviewUpdate(String companyCode, String mode, List<Map<String, Object>> rows) {
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
if (rows == null || rows.isEmpty()) return results;
|
||||
if (rows.size() > BULK_MAX_ROWS) {
|
||||
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 처리 가능합니다.");
|
||||
}
|
||||
if (!"department".equals(mode) && !"manager".equals(mode)) {
|
||||
throw new IllegalArgumentException("mode 는 department | manager 여야 합니다.");
|
||||
}
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Map<String, Object> input = rows.get(i);
|
||||
Map<String, Object> out = new HashMap<>(input);
|
||||
out.put("row_index", i);
|
||||
String error = validateBulkUpdateRow(input, companyCode, mode);
|
||||
if (error == null) {
|
||||
out.put("result", "ok");
|
||||
out.put("error_detail", null);
|
||||
} else {
|
||||
out.put("result", "error");
|
||||
out.put("error_detail", error);
|
||||
}
|
||||
results.add(out);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄등록 — 실제 저장 (@Transactional, all-or-nothing).
|
||||
* 각 row 를 createDepartment 로 위임 — 검증 + manager sync 까지 동일 흐름.
|
||||
* 중간 실패 시 IllegalArgumentException 으로 행번호+사유 합쳐서 던짐 → 전체 롤백.
|
||||
*/
|
||||
@Transactional
|
||||
public int bulkSaveCreate(String companyCode, List<Map<String, Object>> rows) {
|
||||
if (rows == null || rows.isEmpty()) return 0;
|
||||
if (rows.size() > BULK_MAX_ROWS) {
|
||||
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 등록 가능합니다.");
|
||||
}
|
||||
int inserted = 0;
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Map<String, Object> row = rows.get(i);
|
||||
String label = trimString(row.get("dept_name"));
|
||||
try {
|
||||
createDepartment(companyCode, row);
|
||||
inserted++;
|
||||
} catch (DuplicateDeptNameException | IllegalArgumentException | IllegalStateException e) {
|
||||
throw new IllegalArgumentException("행 " + (i + 1) + " (" + (label != null ? label : "?") + "): " + e.getMessage());
|
||||
}
|
||||
}
|
||||
log.info("부서 일괄등록 성공: company={}, inserted={}", companyCode, inserted);
|
||||
return inserted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄업데이트 — 실제 적용 (@Transactional). mode = department | manager.
|
||||
* department: 부서 정보 부분 업데이트 (row 의 null/미지정 필드는 기존값 보존).
|
||||
* manager: row 에 명시된 매니저 role 만 sync (delete-all + insert-all).
|
||||
*/
|
||||
@Transactional
|
||||
public int bulkUpdate(String companyCode, String mode, List<Map<String, Object>> rows) {
|
||||
if (rows == null || rows.isEmpty()) return 0;
|
||||
if (rows.size() > BULK_MAX_ROWS) {
|
||||
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 수정 가능합니다.");
|
||||
}
|
||||
if (!"department".equals(mode) && !"manager".equals(mode)) {
|
||||
throw new IllegalArgumentException("mode 는 department | manager 여야 합니다.");
|
||||
}
|
||||
int updated = 0;
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Map<String, Object> row = rows.get(i);
|
||||
String deptCode = trimString(row.get("dept_code"));
|
||||
if (deptCode == null) {
|
||||
throw new IllegalArgumentException("행 " + (i + 1) + ": 부서코드(dept_code) 필수.");
|
||||
}
|
||||
Map<String, Object> existing = getDepartment(deptCode);
|
||||
if (existing == null) {
|
||||
throw new IllegalArgumentException("행 " + (i + 1) + ": 부서를 찾을 수 없습니다: " + deptCode);
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null
|
||||
? existing.get("company_code").toString() : null;
|
||||
if (!companyCode.equals(deptCompanyCode) && !"*".equals(deptCompanyCode)) {
|
||||
throw new IllegalArgumentException("행 " + (i + 1) + ": 다른 회사의 부서입니다: " + deptCode);
|
||||
}
|
||||
try {
|
||||
if ("department".equals(mode)) {
|
||||
Map<String, Object> merged = buildMergedDeptBody(existing, row);
|
||||
Map<String, Object> result = updateDepartment(deptCode, merged);
|
||||
if (result == null) {
|
||||
throw new IllegalStateException("수정 실패: " + deptCode);
|
||||
}
|
||||
} else {
|
||||
// manager mode — row 에 명시된 role 만 sync
|
||||
if (row.containsKey("approval_managers")) {
|
||||
syncManagers(deptCode, companyCode, row, "approval");
|
||||
}
|
||||
if (row.containsKey("dept_managers")) {
|
||||
syncManagers(deptCode, companyCode, row, "dept");
|
||||
}
|
||||
if (row.containsKey("org_leaders")) {
|
||||
syncManagers(deptCode, companyCode, row, "org_leader");
|
||||
}
|
||||
}
|
||||
updated++;
|
||||
} catch (DuplicateDeptNameException | IllegalArgumentException | IllegalStateException e) {
|
||||
throw new IllegalArgumentException("행 " + (i + 1) + " (" + deptCode + "): " + e.getMessage());
|
||||
}
|
||||
}
|
||||
log.info("부서 일괄수정 성공: company={}, mode={}, updated={}", companyCode, mode, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** company 의 active 부서명 lowercase set — 일괄등록 중복검증용 */
|
||||
private Set<String> collectActiveDeptNames(String companyCode) {
|
||||
Set<String> names = new HashSet<>();
|
||||
for (Map<String, Object> d : getDepartments(companyCode, false, null)) {
|
||||
Object name = d.get("dept_name");
|
||||
if (name != null) names.add(name.toString().trim().toLowerCase());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄등록 row 검증. null = ok. 에러 메시지 반환 시 해당 row 는 error.
|
||||
*/
|
||||
private String validateBulkCreateRow(Map<String, Object> row, String companyCode,
|
||||
Set<String> existingNames, Set<String> batchNames) {
|
||||
String deptName = trimString(row.get("dept_name"));
|
||||
if (deptName == null || deptName.isEmpty()) return "부서명은 필수입니다.";
|
||||
String lower = deptName.toLowerCase();
|
||||
if (batchNames.contains(lower)) return "동일 일괄 내 부서명 중복: " + deptName;
|
||||
if (existingNames.contains(lower)) return "이미 존재하는 부서명: " + deptName;
|
||||
|
||||
String dt = trimString(row.get("dept_type"));
|
||||
if (dt != null && !"dept".equals(dt) && !"team".equals(dt) && !"temp".equals(dt)) {
|
||||
return "부서유형은 dept|team|temp 중 하나: " + dt;
|
||||
}
|
||||
String parent = trimString(row.get("parent_dept_code"));
|
||||
String parentErr = validateParentForBulk(parent, companyCode);
|
||||
if (parentErr != null) return parentErr;
|
||||
|
||||
String dateErr = validateDateRange(row);
|
||||
if (dateErr != null) return dateErr;
|
||||
|
||||
String mgrErr = validateManagerIds(row, companyCode);
|
||||
if (mgrErr != null) return mgrErr;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄업데이트 row 검증. dept_code 필수 + 회사 격리 + (department mode 한정) 부서명/유형/날짜/부모 검증.
|
||||
*/
|
||||
private String validateBulkUpdateRow(Map<String, Object> row, String companyCode, String mode) {
|
||||
String deptCode = trimString(row.get("dept_code"));
|
||||
if (deptCode == null) return "부서코드(dept_code) 필수.";
|
||||
Map<String, Object> existing = getDepartment(deptCode);
|
||||
if (existing == null) return "부서를 찾을 수 없습니다: " + deptCode;
|
||||
String deptCompanyCode = existing.get("company_code") != null
|
||||
? existing.get("company_code").toString() : null;
|
||||
if (!companyCode.equals(deptCompanyCode) && !"*".equals(deptCompanyCode)) {
|
||||
return "다른 회사의 부서: " + deptCode;
|
||||
}
|
||||
if ("department".equals(mode)) {
|
||||
String newName = trimString(row.get("dept_name"));
|
||||
if (newName != null && !newName.isEmpty()) {
|
||||
String existingName = existing.get("dept_name") != null
|
||||
? existing.get("dept_name").toString().trim() : "";
|
||||
if (!newName.equalsIgnoreCase(existingName)) {
|
||||
Map<String, Object> dupParams = new HashMap<>();
|
||||
dupParams.put("company_code", companyCode);
|
||||
dupParams.put("dept_name", newName);
|
||||
Map<String, Object> dup = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
|
||||
if (dup != null && !deptCode.equals(dup.get("dept_code"))) {
|
||||
return "이미 존재하는 부서명: " + newName;
|
||||
}
|
||||
}
|
||||
}
|
||||
String dt = trimString(row.get("dept_type"));
|
||||
if (dt != null && !"dept".equals(dt) && !"team".equals(dt) && !"temp".equals(dt)) {
|
||||
return "부서유형은 dept|team|temp 중 하나: " + dt;
|
||||
}
|
||||
String dateErr = validateDateRange(row);
|
||||
if (dateErr != null) return dateErr;
|
||||
String parent = trimString(row.get("parent_dept_code"));
|
||||
String parentErr = validateParentForBulk(parent, companyCode);
|
||||
if (parentErr != null) return parentErr;
|
||||
}
|
||||
return validateManagerIds(row, companyCode);
|
||||
}
|
||||
|
||||
private String validateParentForBulk(String parent, String companyCode) {
|
||||
if (parent == null) return null;
|
||||
Map<String, Object> p = sqlSession.selectOne(
|
||||
"department.selectDepartmentByCodeIncludingDeleted", Map.of("dept_code", parent));
|
||||
if (p == null) return "상위 부서를 찾을 수 없습니다: " + parent;
|
||||
if (p.get("deleted_at") != null) return "삭제된 부서를 상위로 지정할 수 없음: " + parent;
|
||||
Object pc = p.get("company_code");
|
||||
if (pc == null || (!companyCode.equals(pc.toString()) && !"*".equals(pc.toString()))) {
|
||||
return "다른 회사의 부서를 상위로 지정 불가: " + parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String validateDateRange(Map<String, Object> row) {
|
||||
String sd = trimString(row.get("start_date"));
|
||||
String ed = trimString(row.get("end_date"));
|
||||
if (sd != null && !sd.matches("\\d{4}-\\d{2}-\\d{2}")) return "시작일 형식 오류 (YYYY-MM-DD): " + sd;
|
||||
if (ed != null && !ed.matches("\\d{4}-\\d{2}-\\d{2}")) return "종료일 형식 오류 (YYYY-MM-DD): " + ed;
|
||||
if (sd != null && ed != null && sd.compareTo(ed) > 0) return "시작일이 종료일보다 늦을 수 없음.";
|
||||
return null;
|
||||
}
|
||||
|
||||
private String validateManagerIds(Map<String, Object> row, String companyCode) {
|
||||
for (String key : new String[]{"approval_managers", "dept_managers", "org_leaders"}) {
|
||||
Object raw = row.get(key);
|
||||
if (raw instanceof List<?> list && list.size() > 10) {
|
||||
return key + " 는 최대 10명까지 등록 가능합니다.";
|
||||
}
|
||||
}
|
||||
List<String> ids = collectManagerUserIds(row);
|
||||
if (ids.isEmpty()) return null;
|
||||
Map<String, Object> vParams = new HashMap<>();
|
||||
vParams.put("user_ids", ids);
|
||||
vParams.put("company_code", companyCode);
|
||||
List<String> valid = sqlSession.selectList("department.selectValidUserIds", vParams);
|
||||
if (valid == null || valid.size() != ids.size()) {
|
||||
Set<String> invalid = new HashSet<>(ids);
|
||||
if (valid != null) invalid.removeAll(valid);
|
||||
return "유효하지 않은 사용자 ID: " + invalid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<String> collectManagerUserIds(Map<String, Object> row) {
|
||||
List<String> ids = new ArrayList<>();
|
||||
for (String key : new String[]{"approval_managers", "dept_managers", "org_leaders"}) {
|
||||
Object raw = row.get(key);
|
||||
if (raw instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
String uid = null;
|
||||
if (item instanceof Map<?, ?> m) {
|
||||
Object v = m.get("user_id");
|
||||
if (v != null) uid = v.toString().trim();
|
||||
} else if (item != null) {
|
||||
uid = item.toString().trim();
|
||||
}
|
||||
if (uid != null && !uid.isEmpty() && !ids.contains(uid)) ids.add(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄업데이트 department mode — 기존값 + row override 머지.
|
||||
* row 값이 null/미지정이면 기존값 보존 (PATCH semantic).
|
||||
* 매니저 매핑 키는 항상 제거 (department mode 에서는 안 다룸).
|
||||
*/
|
||||
private Map<String, Object> buildMergedDeptBody(Map<String, Object> existing, Map<String, Object> row) {
|
||||
Map<String, Object> merged = new HashMap<>();
|
||||
String[] textKeys = {
|
||||
"dept_name", "parent_dept_code", "short_name", "dept_type", "org_system",
|
||||
"approval_manager", "dept_manager", "zipcode", "address1", "address2",
|
||||
"sort_order", "status", "location"
|
||||
};
|
||||
for (String k : textKeys) merged.put(k, existing.get(k));
|
||||
merged.put("start_date", stringifyDate(existing.get("start_date")));
|
||||
merged.put("end_date", stringifyDate(existing.get("end_date")));
|
||||
for (Map.Entry<String, Object> e : row.entrySet()) {
|
||||
String k = e.getKey();
|
||||
if ("dept_code".equals(k)) continue;
|
||||
if (e.getValue() == null) continue;
|
||||
if ("approval_managers".equals(k) || "dept_managers".equals(k) || "org_leaders".equals(k)) continue;
|
||||
merged.put(k, e.getValue());
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
private String stringifyDate(Object date) {
|
||||
if (date == null) return null;
|
||||
String s = date.toString();
|
||||
return s.length() >= 10 ? s.substring(0, 10) : null;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 부서원 관리
|
||||
// ──────────────────────────────────────────────────
|
||||
@@ -472,6 +824,108 @@ public class DepartmentService extends BaseService {
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── 관리자 매핑 sync ────────────────────────────────
|
||||
|
||||
private static final com.fasterxml.jackson.databind.ObjectMapper JSON_MAPPER =
|
||||
new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
|
||||
private static final int MAX_MANAGERS_JSON_BYTES = 64 * 1024;
|
||||
|
||||
private void parseManagersJson(Map<String, Object> dept, String key) {
|
||||
Object raw = dept.get(key);
|
||||
if (raw == null) {
|
||||
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
|
||||
return;
|
||||
}
|
||||
String s = raw.toString();
|
||||
if (s.length() > MAX_MANAGERS_JSON_BYTES) {
|
||||
log.warn("parseManagersJson 크기 초과 dept_code={} key={} len={}",
|
||||
dept.get("dept_code"), key, s.length());
|
||||
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.List<Map<String, Object>> parsed = JSON_MAPPER.readValue(s,
|
||||
new com.fasterxml.jackson.core.type.TypeReference<java.util.List<Map<String, Object>>>() {});
|
||||
dept.put(key, parsed);
|
||||
} catch (Exception e) {
|
||||
log.warn("parseManagersJson 실패 dept_code={} key={} err={}",
|
||||
dept.get("dept_code"), key, e.getMessage());
|
||||
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 관리자 role 단위 sync — 항상 delete-all + insert-all 패턴.
|
||||
* body 의 키는 (role 별): "approval_managers" / "dept_managers" / "org_leaders".
|
||||
* 각 값은 List<Map> 형태이며 각 element 에서 "user_id" 만 추출.
|
||||
* 최대 10명 검증 + 빈 user_id 무시.
|
||||
*/
|
||||
private void syncManagers(String deptCode, String companyCode, Map<String, Object> body, String role) {
|
||||
String bodyKey = switch (role) {
|
||||
case "approval" -> "approval_managers";
|
||||
case "dept" -> "dept_managers";
|
||||
case "org_leader" -> "org_leaders";
|
||||
default -> throw new IllegalArgumentException("Unknown role: " + role);
|
||||
};
|
||||
// PUT partial update: 키가 명시적으로 존재할 때만 sync.
|
||||
// body 에 키 자체가 없으면 기존 매핑 보존 (partial update 의도).
|
||||
if (!body.containsKey(bodyKey)) {
|
||||
return;
|
||||
}
|
||||
Object raw = body.get(bodyKey);
|
||||
java.util.List<String> userIds = new java.util.ArrayList<>();
|
||||
if (raw instanceof java.util.List<?> list) {
|
||||
for (Object item : list) {
|
||||
String uid = null;
|
||||
if (item instanceof Map<?, ?> m) {
|
||||
Object v = m.get("user_id");
|
||||
if (v != null) uid = v.toString().trim();
|
||||
} else if (item != null) {
|
||||
uid = item.toString().trim();
|
||||
}
|
||||
if (uid != null && !uid.isEmpty() && !userIds.contains(uid)) {
|
||||
userIds.add(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (userIds.size() > 10) {
|
||||
String roleLabel = switch (role) {
|
||||
case "approval" -> "결재 관리자";
|
||||
case "dept" -> "부서 관리자";
|
||||
case "org_leader" -> "조직장";
|
||||
default -> role;
|
||||
};
|
||||
throw new IllegalArgumentException(roleLabel + " 는 최대 10명까지 등록 가능합니다.");
|
||||
}
|
||||
// user_id 가 같은 회사 (or '*') 에 실존하는지 검증 — cross-tenant 차단
|
||||
if (!userIds.isEmpty()) {
|
||||
Map<String, Object> vParams = new HashMap<>();
|
||||
vParams.put("user_ids", userIds);
|
||||
vParams.put("company_code", companyCode);
|
||||
List<String> validUserIds = sqlSession.selectList("department.selectValidUserIds", vParams);
|
||||
if (validUserIds == null || validUserIds.size() != userIds.size()) {
|
||||
Set<String> invalid = new HashSet<>(userIds);
|
||||
if (validUserIds != null) invalid.removeAll(validUserIds);
|
||||
throw new IllegalArgumentException("유효하지 않은 사용자 ID: " + invalid);
|
||||
}
|
||||
}
|
||||
// delete-all
|
||||
Map<String, Object> delParams = new HashMap<>();
|
||||
delParams.put("dept_code", deptCode);
|
||||
delParams.put("role", role);
|
||||
sqlSession.delete("department.deleteDeptManagersByDeptAndRole", delParams);
|
||||
// insert-all
|
||||
if (!userIds.isEmpty()) {
|
||||
Map<String, Object> insParams = new HashMap<>();
|
||||
insParams.put("dept_code", deptCode);
|
||||
insParams.put("role", role);
|
||||
insParams.put("user_ids", userIds);
|
||||
sqlSession.insert("department.insertDeptManagers", insParams);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 중복 예외 클래스 ────────────────────────────────
|
||||
|
||||
public static class DuplicateDeptNameException extends RuntimeException {
|
||||
|
||||
@@ -101,20 +101,20 @@ public class EntityReferenceService extends BaseService {
|
||||
}
|
||||
|
||||
public Map<String, Object> getCodeData(Map<String, Object> params) {
|
||||
String codeCategory = (String) params.get("code_category");
|
||||
String codeInfo = (String) params.get("code_info");
|
||||
String companyCode = (String) params.get("company_code");
|
||||
int limit = toInt(params.getOrDefault("limit", 100));
|
||||
Object search = params.get("search");
|
||||
|
||||
Map<String, Object> queryParams = new HashMap<>();
|
||||
queryParams.put("code_category", codeCategory);
|
||||
queryParams.put("code_info", codeInfo);
|
||||
queryParams.put("company_code", companyCode);
|
||||
queryParams.put("limit", limit);
|
||||
if (search != null && !search.toString().isBlank()) {
|
||||
queryParams.put("search_like", "%" + search + "%");
|
||||
}
|
||||
|
||||
log.info("공통 코드 데이터 조회: category={}, company={}", codeCategory, companyCode);
|
||||
log.info("공통 코드 데이터 조회: category={}, company={}", codeInfo, companyCode);
|
||||
|
||||
List<Map<String, Object>> rows = sqlSession.selectList(NS + "selectCodeData", queryParams);
|
||||
|
||||
@@ -128,7 +128,7 @@ public class EntityReferenceService extends BaseService {
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("options", options);
|
||||
result.put("code_category", codeCategory);
|
||||
result.put("code_info", codeInfo);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -394,12 +394,12 @@ public class EntitySearchService extends BaseService {
|
||||
Map<String, Object> ttcp = new HashMap<>();
|
||||
ttcp.put("table_name", tableName);
|
||||
ttcp.put("column_name", columnName);
|
||||
Map<String, Object> ttcRow = sqlSession.selectOne(NS + "getCodeCategoryInfo", ttcp);
|
||||
String codeCategory = ttcRow != null ? (String) ttcRow.get("code_category") : null;
|
||||
Map<String, Object> ttcRow = sqlSession.selectOne(NS + "getCodeInfoInfo", ttcp);
|
||||
String codeInfo = ttcRow != null ? (String) ttcRow.get("code_info") : null;
|
||||
|
||||
if (codeCategory != null) {
|
||||
if (codeInfo != null) {
|
||||
Map<String, Object> cip = new HashMap<>();
|
||||
cip.put("code_category", codeCategory);
|
||||
cip.put("code_info", codeInfo);
|
||||
cip.put("raw_values", rawValues);
|
||||
cip.put("company_code", companyCode);
|
||||
List<Map<String, Object>> ciRows = sqlSession.selectList(NS + "getCodeInfoList", cip);
|
||||
|
||||
@@ -189,7 +189,14 @@ public class NumberingRuleService extends BaseService {
|
||||
return allocateCode(ruleId, companyCode, null, null);
|
||||
}
|
||||
|
||||
/** POST /:ruleId/reset → 순번 초기화 */
|
||||
/**
|
||||
* POST /:ruleId/reset → 순번 초기화 (admin)
|
||||
*
|
||||
* 두 테이블 다 처리:
|
||||
* 1. numbering_rule_sequences (prefix 별 발번 카운터, 실제 ground truth) 전체 DELETE → 다음 발번 1 부터
|
||||
* 2. numbering_rules.current_sequence (표시용) 직접 0 으로 set
|
||||
* - admin 전용 SQL `setCurrentSequenceInRule` 사용 (GREATEST 없음)
|
||||
*/
|
||||
@Transactional
|
||||
public void resetSequence(String ruleId, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
@@ -197,10 +204,32 @@ public class NumberingRuleService extends BaseService {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("current_sequence", 0);
|
||||
sqlSession.delete(NS + "deleteSequencesByRuleId", params);
|
||||
sqlSession.update(NS + "updateCurrentSequenceInRule", params);
|
||||
sqlSession.update(NS + "setCurrentSequenceInRule", params);
|
||||
log.info("시퀀스 초기화 완료: ruleId={}, companyCode={}", ruleId, companyCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /:ruleId/sequence → 현재 시퀀스 임의 값으로 수정 (admin)
|
||||
*
|
||||
* admin 이 "지금 카운터를 N 으로 set" 의도. 다음 발번은 N+1 부터.
|
||||
* 두 테이블 다 처리:
|
||||
* 1. numbering_rule_sequences (prefix 별 실제 카운터) 전체 DELETE
|
||||
* → 다음 allocate 시 새 row 가 INSERT (current_sequence=1) 되거나
|
||||
* 또는 admin set 값을 기반으로 시작하도록 별도 처리 필요할 수 있음
|
||||
* - 운영 전 단계라 historical sequence 폐기 안전
|
||||
* 2. numbering_rules.current_sequence 를 newSequence 로 set
|
||||
*/
|
||||
@Transactional
|
||||
public void updateRuleSequence(String ruleId, Integer newSequence, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("rule_id", ruleId);
|
||||
params.put("company_code", companyCode);
|
||||
params.put("current_sequence", newSequence);
|
||||
sqlSession.delete(NS + "deleteSequencesByRuleId", params);
|
||||
sqlSession.update(NS + "setCurrentSequenceInRule", params);
|
||||
log.info("시퀀스 수정 완료: ruleId={}, newSequence={}, companyCode={}", ruleId, newSequence, companyCode);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// ■ Available Rules
|
||||
// ================================================================
|
||||
@@ -426,12 +455,31 @@ public class NumberingRuleService extends BaseService {
|
||||
return seq == null ? 0L : ((Number) seq).longValue();
|
||||
}
|
||||
|
||||
/** 순번 증가 UPSERT – ON CONFLICT DO UPDATE RETURNING */
|
||||
/**
|
||||
* 순번 증가 UPSERT – ON CONFLICT DO UPDATE RETURNING.
|
||||
*
|
||||
* INSERT 분기의 base 값:
|
||||
* - 동일 prefix 의 row 가 없을 때 (첫 발번 / admin reset 후 / 새 카테고리 등)
|
||||
* `numbering_rules.current_sequence + 1` 부터 시작.
|
||||
* - 의미: admin 이 sequence 를 N 으로 set 하고 historical sequences 를 비웠을 때,
|
||||
* 다음 발번이 N+1 부터 정확히 시작되도록.
|
||||
* - numbering_rules row 가 없는 비정상 케이스는 0+1=1.
|
||||
*/
|
||||
private long incrementSequenceForPrefix(String ruleId, String companyCode, String prefixKey) {
|
||||
String sql = """
|
||||
INSERT INTO numbering_rule_sequences
|
||||
(rule_id, company_code, prefix_key, current_sequence, last_allocated_at)
|
||||
VALUES (?, ?, ?, 1, NOW())
|
||||
VALUES (
|
||||
?, ?, ?,
|
||||
COALESCE((
|
||||
SELECT current_sequence
|
||||
FROM numbering_rules
|
||||
WHERE rule_id = ?
|
||||
AND (company_code = ? OR company_code = '*')
|
||||
LIMIT 1
|
||||
), 0) + 1,
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (rule_id, company_code, prefix_key)
|
||||
DO UPDATE SET
|
||||
current_sequence = numbering_rule_sequences.current_sequence + 1,
|
||||
@@ -439,7 +487,7 @@ public class NumberingRuleService extends BaseService {
|
||||
RETURNING current_sequence
|
||||
""";
|
||||
Long newSeq = jdbcTemplate.queryForObject(sql, Long.class,
|
||||
ruleId, companyCode, prefixKey);
|
||||
ruleId, companyCode, prefixKey, ruleId, companyCode);
|
||||
return newSeq != null ? newSeq : 1L;
|
||||
}
|
||||
|
||||
|
||||
@@ -994,7 +994,7 @@ public class ScreenManagementService extends BaseService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int copyCodeCategoryAndCodes(Map<String, Object> body) {
|
||||
public int copyCodeInfoAndCodes(Map<String, Object> body) {
|
||||
String sourceCompanyCode = (String) body.get("source_company_code");
|
||||
String targetCompanyCode = (String) body.get("target_company_code");
|
||||
String userId = (String) body.get("user_id");
|
||||
@@ -1002,16 +1002,16 @@ public class ScreenManagementService extends BaseService {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("source_company_code", sourceCompanyCode);
|
||||
|
||||
List<Map<String, Object>> categories = sqlSession.selectList(NS + "selectCodeCategoryForCopy", params);
|
||||
List<Map<String, Object>> categories = sqlSession.selectList(NS + "selectCodeInfoForCopy", params);
|
||||
int count = 0;
|
||||
for (Map<String, Object> cat : categories) {
|
||||
Map<String, Object> cp = new HashMap<>(cat);
|
||||
cp.put("target_company_code", targetCompanyCode);
|
||||
sqlSession.insert(NS + "upsertCodeCategory", cp);
|
||||
sqlSession.insert(NS + "upsertCodeInfo", cp);
|
||||
|
||||
Map<String, Object> codeParams = new HashMap<>();
|
||||
codeParams.put("source_company_code", sourceCompanyCode);
|
||||
codeParams.put("code_category", cat.get("category_code"));
|
||||
codeParams.put("code_info", cat.get("category_code"));
|
||||
List<Map<String, Object>> codes = sqlSession.selectList(NS + "selectCodeInfoForCopy", codeParams);
|
||||
for (Map<String, Object> code : codes) {
|
||||
Map<String, Object> cop = new HashMap<>(code);
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class TableCategoryValueService extends BaseService {
|
||||
|
||||
private static final String NS = "tableCategoryValue.";
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Category Columns
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public List<Map<String, Object>> getCategoryColumns(Map<String, Object> params) {
|
||||
log.info("카테고리 컬럼 목록 조회: tableName={}, companyCode={}",
|
||||
params.get("table_name"), params.get("company_code"));
|
||||
return sqlSession.selectList(NS + "getCategoryColumnList", params);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getAllCategoryColumns(Map<String, Object> params) {
|
||||
log.info("전체 카테고리 컬럼 목록 조회: companyCode={}", params.get("company_code"));
|
||||
return sqlSession.selectList(NS + "getAllCategoryColumnList", params);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Category Values — Read
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public List<Map<String, Object>> getCategoryValues(Map<String, Object> params) {
|
||||
log.info("카테고리 값 목록 조회: tableName={}, columnName={}, companyCode={}",
|
||||
params.get("table_name"), params.get("column_name"), params.get("company_code"));
|
||||
|
||||
List<Map<String, Object>> flatList = sqlSession.selectList(NS + "getCategoryValueList", params);
|
||||
List<Map<String, Object>> hierarchy = buildHierarchy(flatList, null);
|
||||
|
||||
log.info("카테고리 값 {}개 조회 완료 (평면)", flatList.size());
|
||||
return hierarchy;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Category Values — Write
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> addCategoryValue(Map<String, Object> params) {
|
||||
String tableName = (String) params.get("table_name");
|
||||
String columnName = (String) params.get("column_name");
|
||||
String valueCode = (String) params.get("value_code");
|
||||
String valueLabel = (String) params.get("value_label");
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
log.info("카테고리 값 추가: tableName={}, columnName={}, valueCode={}, companyCode={}",
|
||||
tableName, columnName, valueCode, companyCode);
|
||||
|
||||
Integer codeDup = sqlSession.selectOne(NS + "countDuplicateCode", params);
|
||||
if (codeDup != null && codeDup > 0) {
|
||||
throw new IllegalArgumentException("이미 존재하는 코드입니다");
|
||||
}
|
||||
|
||||
Integer labelDup = sqlSession.selectOne(NS + "countDuplicateLabel", params);
|
||||
if (labelDup != null && labelDup > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"이미 동일한 이름의 카테고리 값이 존재합니다: \"" + valueLabel + "\"");
|
||||
}
|
||||
|
||||
if (params.get("value_order") == null) params.put("value_order", 0);
|
||||
if (params.get("depth") == null) params.put("depth", 1);
|
||||
if (params.get("is_active") == null) params.put("is_active", true);
|
||||
if (params.get("is_default") == null) params.put("is_default", false);
|
||||
|
||||
sqlSession.insert(NS + "insertCategoryValue", params);
|
||||
long valueId = toLong(params.get("value_id"));
|
||||
|
||||
log.info("카테고리 값 추가 완료: valueId={}", valueId);
|
||||
|
||||
Map<String, Object> fetchP = new HashMap<>();
|
||||
fetchP.put("value_id", valueId);
|
||||
return sqlSession.selectOne(NS + "getCategoryValueInfo", fetchP);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCategoryValue(Map<String, Object> params) {
|
||||
long valueId = toLong(params.get("value_id"));
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
log.info("카테고리 값 수정: valueId={}, companyCode={}", valueId, companyCode);
|
||||
|
||||
if (params.get("value_label") != null) {
|
||||
Map<String, Object> current = sqlSession.selectOne(NS + "getCategoryValueLabelInfo",
|
||||
Map.of("value_id", valueId));
|
||||
if (current != null) {
|
||||
Map<String, Object> labelP = new HashMap<>();
|
||||
labelP.put("table_name", current.get("table_name"));
|
||||
labelP.put("column_name", current.get("column_name"));
|
||||
labelP.put("company_code", current.get("company_code"));
|
||||
labelP.put("value_label", params.get("value_label"));
|
||||
labelP.put("value_id", valueId);
|
||||
Integer dup = sqlSession.selectOne(NS + "countDuplicateLabelExcludeSelf", labelP);
|
||||
if (dup != null && dup > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"이미 동일한 이름의 카테고리 값이 존재합니다: \""
|
||||
+ params.get("value_label") + "\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
params.put("value_id", valueId);
|
||||
Integer rows = sqlSession.selectOne(NS + "updateCategoryValue", params);
|
||||
if (rows == null || rows == 0) {
|
||||
// update returns affected rows via selectOne workaround; use update method instead
|
||||
sqlSession.update(NS + "updateCategoryValue", params);
|
||||
}
|
||||
|
||||
Map<String, Object> fetchP = new HashMap<>();
|
||||
fetchP.put("value_id", valueId);
|
||||
return sqlSession.selectOne(NS + "getCategoryValueInfo", fetchP);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Category Values — Delete
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public void deleteCategoryValue(Map<String, Object> params) {
|
||||
long valueId = toLong(params.get("value_id"));
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
log.info("카테고리 값 삭제: valueId={}, companyCode={}", valueId, companyCode);
|
||||
|
||||
List<Map<String, Object>> childRows = sqlSession.selectList(NS + "getChildValueIdList", params);
|
||||
List<Long> allIds = new ArrayList<>();
|
||||
allIds.add(valueId);
|
||||
childRows.forEach(r -> allIds.add(toLong(r.get("value_id"))));
|
||||
|
||||
log.info("삭제 대상 카테고리 값 수집 완료: 자신={}, 하위={}", valueId, childRows.size());
|
||||
|
||||
for (Long id : allIds) {
|
||||
checkNotInUse(id, companyCode);
|
||||
}
|
||||
|
||||
List<Long> reversed = new ArrayList<>(allIds);
|
||||
Collections.reverse(reversed);
|
||||
for (Long id : reversed) {
|
||||
Map<String, Object> delP = new HashMap<>();
|
||||
delP.put("value_id", id);
|
||||
delP.put("company_code", companyCode);
|
||||
sqlSession.delete(NS + "deleteValueById", delP);
|
||||
}
|
||||
|
||||
log.info("카테고리 값 삭제 완료: totalDeleted={}", allIds.size());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void bulkDeleteCategoryValues(Map<String, Object> params) {
|
||||
log.info("카테고리 값 일괄 삭제: count={}, companyCode={}",
|
||||
((List<?>) params.get("value_ids")).size(), params.get("company_code"));
|
||||
sqlSession.update(NS + "bulkSoftDeleteValues", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void reorderCategoryValues(Map<String, Object> params) {
|
||||
List<?> rawIds = (List<?>) params.get("ordered_value_ids");
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
log.info("카테고리 값 순서 변경: count={}, companyCode={}", rawIds.size(), companyCode);
|
||||
|
||||
for (int i = 0; i < rawIds.size(); i++) {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("value_id", toLong(rawIds.get(i)));
|
||||
p.put("value_order", i + 1);
|
||||
p.put("company_code", companyCode);
|
||||
sqlSession.update(NS + "updateValueOrder", p);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Column Mapping
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public Map<String, Object> getColumnMapping(Map<String, Object> params) {
|
||||
log.info("컬럼 매핑 조회: tableName={}, menuObjid={}, companyCode={}",
|
||||
params.get("table_name"), params.get("menu_objid"), params.get("company_code"));
|
||||
|
||||
List<Map<String, Object>> rows = sqlSession.selectList(NS + "getColumnMappingList", params);
|
||||
|
||||
Map<String, Object> mapping = new LinkedHashMap<>();
|
||||
for (Map<String, Object> row : rows) {
|
||||
mapping.put(String.valueOf(row.get("logical_column_name")),
|
||||
String.valueOf(row.get("physical_column_name")));
|
||||
}
|
||||
log.info("컬럼 매핑 {}개 조회 완료", mapping.size());
|
||||
return mapping;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> createColumnMapping(Map<String, Object> params) {
|
||||
String tableName = (String) params.get("table_name");
|
||||
String logicalColumnName = (String) params.get("logical_column_name");
|
||||
String physicalColumnName = (String) params.get("physical_column_name");
|
||||
|
||||
log.info("컬럼 매핑 생성: tableName={}, logical={}, physical={}, companyCode={}",
|
||||
tableName, logicalColumnName, physicalColumnName, params.get("company_code"));
|
||||
|
||||
Integer colExists = sqlSession.selectOne(NS + "checkPhysicalColumnExists", params);
|
||||
if (colExists == null || colExists == 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"테이블 " + tableName + "에 컬럼 " + physicalColumnName + "이(가) 존재하지 않습니다");
|
||||
}
|
||||
|
||||
sqlSession.insert(NS + "upsertColumnMapping", params);
|
||||
Map<String, Object> result = sqlSession.selectOne(NS + "getColumnMappingInfo", params);
|
||||
|
||||
log.info("컬럼 매핑 생성 완료: mappingId={}", result != null ? result.get("mapping_id") : "?");
|
||||
return result;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getLogicalColumns(Map<String, Object> params) {
|
||||
log.info("논리적 컬럼 목록 조회: tableName={}, menuObjid={}, companyCode={}",
|
||||
params.get("table_name"), params.get("menu_objid"), params.get("company_code"));
|
||||
return sqlSession.selectList(NS + "getLogicalColumnList", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteColumnMapping(Map<String, Object> params) {
|
||||
int deleted = sqlSession.delete(NS + "deleteColumnMappingById", params);
|
||||
if (deleted == 0) {
|
||||
throw new IllegalArgumentException("컬럼 매핑을 찾을 수 없거나 권한이 없습니다");
|
||||
}
|
||||
log.info("컬럼 매핑 삭제 완료: mappingId={}", params.get("mapping_id"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int deleteColumnMappingsByColumn(Map<String, Object> params) {
|
||||
int deleted = sqlSession.delete(NS + "deleteColumnMappingsByColumn", params);
|
||||
log.info("테이블+컬럼 기준 매핑 삭제 완료: tableName={}, columnName={}, deletedCount={}",
|
||||
params.get("table_name"), params.get("column_name"), deleted);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Labels by Codes
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public Map<String, Object> getCategoryLabelsByCodes(Map<String, Object> params) {
|
||||
Object rawCodes = params.get("value_codes");
|
||||
if (!(rawCodes instanceof List) || ((List<?>) rawCodes).isEmpty()) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
log.info("카테고리 코드로 라벨 조회: count={}, companyCode={}",
|
||||
((List<?>) rawCodes).size(), params.get("company_code"));
|
||||
|
||||
List<Map<String, Object>> rows = sqlSession.selectList(NS + "getLabelListByCodes", params);
|
||||
|
||||
Map<String, Object> labels = new LinkedHashMap<>();
|
||||
for (Map<String, Object> row : rows) {
|
||||
String code = String.valueOf(row.get("value_code"));
|
||||
if (!labels.containsKey(code)) {
|
||||
labels.put(code, row.get("value_label"));
|
||||
}
|
||||
}
|
||||
log.info("카테고리 라벨 {}개 조회 완료", labels.size());
|
||||
return labels;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Second-Level Menus
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public List<Map<String, Object>> getSecondLevelMenus(Map<String, Object> params) {
|
||||
log.info("2레벨 메뉴 목록 조회: companyCode={}", params.get("company_code"));
|
||||
|
||||
Integer hasCC = sqlSession.selectOne(NS + "checkMenuInfoHasCompanyCode", null);
|
||||
params.put("has_company_code", hasCC != null && hasCC > 0);
|
||||
log.info("menu_info.company_code 컬럼 존재 여부: {}", hasCC != null && hasCC > 0);
|
||||
|
||||
List<Map<String, Object>> menus = sqlSession.selectList(NS + "getSecondLevelMenuList", params);
|
||||
log.info("2레벨 메뉴 {}개 조회 완료", menus.size());
|
||||
return menus;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// private helpers
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
private void checkNotInUse(long valueId, String companyCode) {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("value_id", valueId);
|
||||
p.put("company_code", companyCode);
|
||||
|
||||
Map<String, Object> valueInfo = sqlSession.selectOne(NS + "getCategoryValueUsageInfo", p);
|
||||
if (valueInfo == null) {
|
||||
throw new IllegalArgumentException("카테고리 값을 찾을 수 없습니다");
|
||||
}
|
||||
|
||||
String tableName = String.valueOf(valueInfo.get("table_name"));
|
||||
String columnName = String.valueOf(valueInfo.get("column_name"));
|
||||
String valueCode = String.valueOf(valueInfo.get("value_code"));
|
||||
String valueLabel = String.valueOf(valueInfo.get("value_label"));
|
||||
|
||||
String safeTable = sanitize(tableName);
|
||||
String safeColumn = sanitize(columnName);
|
||||
|
||||
if (safeTable.isEmpty() || safeColumn.isEmpty()) return;
|
||||
|
||||
Integer tableExists = sqlSession.selectOne(NS + "checkTableExistsForUsage",
|
||||
Map.of("table_name", safeTable));
|
||||
if (tableExists == null || tableExists == 0) return;
|
||||
|
||||
Map<String, Object> countP = new HashMap<>();
|
||||
countP.put("safe_table_name", safeTable);
|
||||
countP.put("safe_column_name", safeColumn);
|
||||
countP.put("value_code", valueCode);
|
||||
countP.put("company_code", companyCode);
|
||||
Integer count = sqlSession.selectOne(NS + "countValueUsageInTable", countP);
|
||||
|
||||
if (count != null && count > 0) {
|
||||
List<Map<String, Object>> menus = sqlSession.selectList(NS + "getMenuListUsingTable",
|
||||
Map.of("table_name", tableName, "company_code", companyCode));
|
||||
|
||||
StringBuilder msg = new StringBuilder();
|
||||
msg.append("카테고리 \"").append(valueLabel).append("\"을(를) 삭제할 수 없습니다.\n");
|
||||
msg.append("\n현재 ").append(count).append("개의 데이터에서 사용 중입니다.");
|
||||
|
||||
if (!menus.isEmpty()) {
|
||||
String menuNames = menus.stream()
|
||||
.map(m -> String.valueOf(m.get("menu_name")))
|
||||
.collect(Collectors.joining(", "));
|
||||
msg.append("\n\n다음 메뉴에서 사용 중입니다:\n").append(menuNames);
|
||||
}
|
||||
msg.append("\n\n메뉴에서 사용하는 카테고리 항목을 수정한 후 다시 삭제해주세요.");
|
||||
|
||||
throw new IllegalArgumentException(msg.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildHierarchy(
|
||||
List<Map<String, Object>> values, Object parentId) {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Map<String, Object> v : values) {
|
||||
Object pid = v.get("parent_value_id");
|
||||
if (Objects.equals(pid, parentId)) {
|
||||
List<Map<String, Object>> children = buildHierarchy(values, v.get("value_id"));
|
||||
v.put("children", children);
|
||||
result.add(v);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String sanitize(String name) {
|
||||
if (name == null) return "";
|
||||
return name.replaceAll("[^a-zA-Z0-9_]", "");
|
||||
}
|
||||
|
||||
private long toLong(Object val) {
|
||||
if (val == null) return 0L;
|
||||
if (val instanceof Number) return ((Number) val).longValue();
|
||||
try { return Long.parseLong(val.toString()); } catch (NumberFormatException e) { return 0L; }
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import com.erp.constants.InputTypeConstants;
|
||||
import com.erp.constants.InputTypeContext;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -26,10 +28,14 @@ public class TableManagementService extends BaseService {
|
||||
|
||||
private static final String NS = "tableManagement.";
|
||||
|
||||
/** 사용자가 직접 선택 가능한 INPUT_TYPE 8종 (INSERT/UPDATE-type 검증용) */
|
||||
private static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
|
||||
"text", "number", "date", "code", "entity",
|
||||
"numbering", "file", "image"
|
||||
/** 로그 테이블 컬럼 정의에 허용하는 PostgreSQL data_type 화이트리스트.
|
||||
* information_schema.columns.data_type 값과 정확히 일치해야 한다. */
|
||||
private static final Set<String> ALLOWED_LOG_COLUMN_TYPES = Set.of(
|
||||
"varchar", "text", "char", "character", "character varying",
|
||||
"integer", "bigint", "smallint", "numeric", "decimal", "real", "double precision",
|
||||
"boolean", "date", "timestamp", "timestamp without time zone", "timestamp with time zone",
|
||||
"time", "time without time zone", "time with time zone",
|
||||
"uuid", "json", "jsonb", "bytea"
|
||||
);
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
@@ -151,16 +157,19 @@ public class TableManagementService extends BaseService {
|
||||
Map<String, Object> settings, String companyCode) {
|
||||
ensureTableInLabels(tableName);
|
||||
|
||||
boolean inputTypeChanged = settings.containsKey("input_type");
|
||||
String ctx = inputTypeChanged ? "user-update-type" : "user-update-other";
|
||||
String inputType = normalizeInputType((String) settings.get("input_type"), ctx);
|
||||
Object rawInputType = settings.get("input_type");
|
||||
boolean inputTypeChanged = settings.containsKey("input_type") && rawInputType != null;
|
||||
InputTypeContext ctx = inputTypeChanged
|
||||
? InputTypeContext.USER_UPDATE_TYPE
|
||||
: InputTypeContext.USER_UPDATE_OTHER;
|
||||
String inputType = normalizeInputType((String) rawInputType, ctx);
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
params.put("column_label", settings.get("column_label"));
|
||||
params.put("input_type", inputType);
|
||||
params.put("detail_settings", toJsonString(settings.get("detail_settings")));
|
||||
params.put("code_category", "code".equals(inputType) ? settings.get("code_category") : null);
|
||||
params.put("code_info", "code".equals(inputType) ? settings.get("code_info") : null);
|
||||
params.put("code_value", "code".equals(inputType) ? settings.get("code_value") : null);
|
||||
params.put("reference_table", "entity".equals(inputType) ? settings.get("reference_table") : null);
|
||||
params.put("reference_column", "entity".equals(inputType) ? settings.get("reference_column") : null);
|
||||
@@ -210,7 +219,7 @@ public class TableManagementService extends BaseService {
|
||||
public void updateColumnInputType(String tableName, String columnName,
|
||||
String inputType, String companyCode,
|
||||
Map<String, Object> detailSettings) {
|
||||
String finalType = normalizeInputType(inputType, "user-update-type");
|
||||
String finalType = normalizeInputType(inputType, InputTypeContext.USER_UPDATE_TYPE);
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
@@ -463,6 +472,369 @@ public class TableManagementService extends BaseService {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 동적 테이블 집계 (count / sum / avg / min / max / distinctCount)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
private static final Set<String> AGG_TYPES = Set.of(
|
||||
"count", "sum", "avg", "min", "max", "distinctCount"
|
||||
);
|
||||
|
||||
private static final Set<String> FILTER_OPS = Set.of(
|
||||
"=", "!=", ">", "<", ">=", "<=",
|
||||
"like", "in", "notIn", "isNull", "isNotNull"
|
||||
);
|
||||
|
||||
/**
|
||||
* 단일 집계 값 계산.
|
||||
*
|
||||
* count — column 없이도 동작 (COUNT(*))
|
||||
* sum/avg/min/max — column 필수
|
||||
* distinctCount — column 필수 (COUNT(DISTINCT col))
|
||||
*/
|
||||
public Map<String, Object> aggregateTableData(String tableName, Map<String, Object> options) {
|
||||
String safeTable = sanitize(tableName);
|
||||
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
|
||||
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
|
||||
}
|
||||
|
||||
String aggregation = options.get("aggregation") instanceof String s ? s : "count";
|
||||
if (!AGG_TYPES.contains(aggregation)) {
|
||||
throw new IllegalArgumentException("지원하지 않는 집계 타입: " + aggregation);
|
||||
}
|
||||
|
||||
String columnName = options.get("columnName") instanceof String s ? s : null;
|
||||
String safeColumn = columnName != null ? sanitize(columnName) : "";
|
||||
|
||||
boolean columnRequired = !"count".equals(aggregation);
|
||||
if (columnRequired) {
|
||||
if (safeColumn.isBlank()) {
|
||||
throw new IllegalArgumentException(aggregation + " 은 columnName 이 필요합니다.");
|
||||
}
|
||||
if (!hasColumn(safeTable, safeColumn)) {
|
||||
throw new IllegalArgumentException("컬럼이 존재하지 않습니다: " + tableName + "." + columnName);
|
||||
}
|
||||
} else if (!safeColumn.isBlank() && !hasColumn(safeTable, safeColumn)) {
|
||||
// count + columnName 가 들어왔지만 실제 없는 컬럼이면 명확히 거절
|
||||
throw new IllegalArgumentException("컬럼이 존재하지 않습니다: " + tableName + "." + columnName);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
|
||||
|
||||
List<Object> values = new ArrayList<>();
|
||||
String where = buildAggregateWhere(safeTable, filters, values);
|
||||
|
||||
String selectExpr;
|
||||
if ("count".equals(aggregation)) {
|
||||
selectExpr = !safeColumn.isBlank()
|
||||
? String.format("COUNT(\"%s\")", safeColumn)
|
||||
: "COUNT(*)";
|
||||
} else if ("distinctCount".equals(aggregation)) {
|
||||
selectExpr = String.format("COUNT(DISTINCT \"%s\")", safeColumn);
|
||||
} else {
|
||||
// sum / avg / min / max — 숫자 캐스팅 (avg 만 numeric, 나머지는 컬럼 타입 그대로)
|
||||
String upper = aggregation.toUpperCase();
|
||||
if ("AVG".equals(upper) || "SUM".equals(upper)) {
|
||||
selectExpr = String.format("%s(CAST(\"%s\" AS NUMERIC))", upper, safeColumn);
|
||||
} else {
|
||||
selectExpr = String.format("%s(\"%s\")", upper, safeColumn);
|
||||
}
|
||||
}
|
||||
|
||||
String sql = String.format("SELECT %s AS agg_value FROM \"%s\" main %s",
|
||||
selectExpr, safeTable, where);
|
||||
|
||||
Number raw = jdbcTemplate.queryForObject(sql, Number.class, values.toArray());
|
||||
double value = raw != null ? raw.doubleValue() : 0d;
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("value", value);
|
||||
return result;
|
||||
}
|
||||
|
||||
private String buildAggregateWhere(String safeTable, List<Map<String, Object>> filters, List<Object> values) {
|
||||
if (filters == null || filters.isEmpty()) return "";
|
||||
List<String> clauses = new ArrayList<>();
|
||||
for (Map<String, Object> f : filters) {
|
||||
if (f == null) continue;
|
||||
String col = f.get("column") instanceof String s ? s : null;
|
||||
String op = f.get("operator") instanceof String s ? s : "=";
|
||||
if (col == null || col.isBlank()) continue;
|
||||
String safeCol = sanitize(col);
|
||||
if (safeCol.isBlank() || !hasColumn(safeTable, safeCol)) continue;
|
||||
if (!FILTER_OPS.contains(op)) continue;
|
||||
|
||||
Object val = f.get("value");
|
||||
|
||||
switch (op) {
|
||||
case "isNull":
|
||||
clauses.add(String.format("\"%s\" IS NULL", safeCol));
|
||||
break;
|
||||
case "isNotNull":
|
||||
clauses.add(String.format("\"%s\" IS NOT NULL", safeCol));
|
||||
break;
|
||||
case "in":
|
||||
case "notIn": {
|
||||
List<Object> list = toList(val);
|
||||
if (list.isEmpty()) continue;
|
||||
String marks = list.stream().map(v -> "?").collect(Collectors.joining(", "));
|
||||
String kw = "in".equals(op) ? "IN" : "NOT IN";
|
||||
clauses.add(String.format("\"%s\" %s (%s)", safeCol, kw, marks));
|
||||
values.addAll(list);
|
||||
break;
|
||||
}
|
||||
case "like":
|
||||
if (isEmptyAggregateFilterValue(val)) continue;
|
||||
clauses.add(String.format("\"%s\"::text ILIKE ?", safeCol));
|
||||
values.add("%" + val + "%");
|
||||
break;
|
||||
default:
|
||||
if (isEmptyAggregateFilterValue(val)) continue;
|
||||
clauses.add(String.format("\"%s\" %s ?", safeCol, op));
|
||||
values.add(val);
|
||||
}
|
||||
}
|
||||
return clauses.isEmpty() ? "" : "WHERE " + String.join(" AND ", clauses);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> normalizeAggregateFilters(Object rawFilters) {
|
||||
if (!(rawFilters instanceof List<?> rawList) || rawList.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (Object item : rawList) {
|
||||
if (item instanceof Map<?, ?> rawMap) {
|
||||
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||
for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
|
||||
if (entry.getKey() instanceof String key) {
|
||||
normalized.put(key, entry.getValue());
|
||||
}
|
||||
}
|
||||
if (!normalized.isEmpty()) out.add(normalized);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private boolean isEmptyAggregateFilterValue(Object val) {
|
||||
if (val == null) return true;
|
||||
if (val instanceof String s) return s.isBlank();
|
||||
if (val instanceof Collection<?> c) return c.isEmpty();
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<Object> toList(Object val) {
|
||||
if (val == null) return List.of();
|
||||
if (val instanceof List<?> l) {
|
||||
List<Object> out = new ArrayList<>();
|
||||
for (Object o : l) {
|
||||
if (o == null) continue;
|
||||
if (o instanceof String s && s.isBlank()) continue;
|
||||
out.add(o);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (val instanceof String s) {
|
||||
if (s.isBlank()) return List.of();
|
||||
return Arrays.stream(s.split(","))
|
||||
.map(String::trim)
|
||||
.filter(p -> !p.isEmpty())
|
||||
.map(p -> (Object) p)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
return List.of(val);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 그룹별 집계 (Phase G.3 — canonical chart 용)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* groupBy 컬럼별로 집계 결과 반환. canonical chart 컴포넌트가 bar / line / donut /
|
||||
* horizontalBar 모두에서 같은 endpoint 를 사용.
|
||||
*
|
||||
* body 예:
|
||||
* { "groupBy": "status", "aggregation": "count", "filters": [...] }
|
||||
* { "groupBy": "dept_code", "aggregation": "sum", "valueColumn": "amount", "limit": 12 }
|
||||
*
|
||||
* response:
|
||||
* { "rows": [{ "group": "재직", "value": 35 }, { "group": "휴직", "value": 4 }] }
|
||||
*/
|
||||
public Map<String, Object> aggregateTableGroup(String tableName, Map<String, Object> options) {
|
||||
String safeTable = sanitize(tableName);
|
||||
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
|
||||
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
|
||||
}
|
||||
|
||||
String groupBy = options.get("groupBy") instanceof String s ? s : null;
|
||||
String safeGroupBy = groupBy != null ? sanitize(groupBy) : "";
|
||||
if (safeGroupBy.isBlank() || !hasColumn(safeTable, safeGroupBy)) {
|
||||
throw new IllegalArgumentException("groupBy 컬럼이 존재하지 않습니다: " + tableName + "." + groupBy);
|
||||
}
|
||||
|
||||
String aggregation = options.get("aggregation") instanceof String s ? s : "count";
|
||||
if (!AGG_TYPES.contains(aggregation)) {
|
||||
throw new IllegalArgumentException("지원하지 않는 집계 타입: " + aggregation);
|
||||
}
|
||||
|
||||
String valueColumn = options.get("valueColumn") instanceof String s ? s : null;
|
||||
if (valueColumn == null && options.get("columnName") instanceof String s) valueColumn = s;
|
||||
String safeValueCol = valueColumn != null ? sanitize(valueColumn) : "";
|
||||
|
||||
boolean columnRequired = !"count".equals(aggregation);
|
||||
if (columnRequired) {
|
||||
if (safeValueCol.isBlank()) {
|
||||
throw new IllegalArgumentException(aggregation + " 은 valueColumn 이 필요합니다.");
|
||||
}
|
||||
if (!hasColumn(safeTable, safeValueCol)) {
|
||||
throw new IllegalArgumentException("valueColumn 이 존재하지 않습니다: " + tableName + "." + valueColumn);
|
||||
}
|
||||
} else if (!safeValueCol.isBlank() && !hasColumn(safeTable, safeValueCol)) {
|
||||
throw new IllegalArgumentException("valueColumn 이 존재하지 않습니다: " + tableName + "." + valueColumn);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
|
||||
|
||||
int limit = toInt(options.get("limit"), 50);
|
||||
if (limit < 1) limit = 50;
|
||||
if (limit > 500) limit = 500;
|
||||
|
||||
String orderDir = options.get("orderDir") instanceof String s
|
||||
&& ("asc".equalsIgnoreCase(s) || "desc".equalsIgnoreCase(s))
|
||||
? s.toUpperCase()
|
||||
: "DESC";
|
||||
|
||||
List<Object> values = new ArrayList<>();
|
||||
String where = buildAggregateWhere(safeTable, filters, values);
|
||||
|
||||
String selectExpr;
|
||||
if ("count".equals(aggregation)) {
|
||||
selectExpr = !safeValueCol.isBlank()
|
||||
? String.format("COUNT(\"%s\")", safeValueCol)
|
||||
: "COUNT(*)";
|
||||
} else if ("distinctCount".equals(aggregation)) {
|
||||
selectExpr = String.format("COUNT(DISTINCT \"%s\")", safeValueCol);
|
||||
} else {
|
||||
String upper = aggregation.toUpperCase();
|
||||
if ("AVG".equals(upper) || "SUM".equals(upper)) {
|
||||
selectExpr = String.format("%s(CAST(\"%s\" AS NUMERIC))", upper, safeValueCol);
|
||||
} else {
|
||||
selectExpr = String.format("%s(\"%s\")", upper, safeValueCol);
|
||||
}
|
||||
}
|
||||
|
||||
String sql = String.format(
|
||||
"SELECT \"%s\" AS group_value, %s AS agg_value " +
|
||||
"FROM \"%s\" main %s " +
|
||||
"GROUP BY \"%s\" " +
|
||||
"ORDER BY agg_value %s NULLS LAST " +
|
||||
"LIMIT %d",
|
||||
safeGroupBy, selectExpr, safeTable, where, safeGroupBy, orderDir, limit);
|
||||
|
||||
List<Map<String, Object>> rawRows = jdbcTemplate.queryForList(sql, values.toArray());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (Map<String, Object> r : rawRows) {
|
||||
Object groupVal = r.get("group_value");
|
||||
Object aggVal = r.get("agg_value");
|
||||
double value = aggVal instanceof Number ? ((Number) aggVal).doubleValue() : 0d;
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("group", groupVal);
|
||||
out.put("value", value);
|
||||
rows.add(out);
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("rows", rows);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 가벼운 select-rows (Phase G.3.1 — card-list / grouped-table 용)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* OptionFilter 호환 필터 + orderBy + limit/offset 로 임의 컬럼들의 row 들을 반환.
|
||||
* `getTableData` 는 페이지네이션 + ILIKE search 가 묶여 있어 view 컴포넌트가
|
||||
* 사용하기 무겁다. 본 메서드는 raw rows 만 깔끔하게 반환.
|
||||
*
|
||||
* body 예:
|
||||
* { "columns": ["user_name", "dept_code"], "filters": [...], "limit": 50 }
|
||||
* { "groupBy 없이 단순 다중 컬럼", "orderBy": [{ "column": "created_date", "direction": "desc" }] }
|
||||
*
|
||||
* response:
|
||||
* { "rows": [{...}, {...}] }
|
||||
*/
|
||||
public Map<String, Object> selectTableRows(String tableName, Map<String, Object> options) {
|
||||
String safeTable = sanitize(tableName);
|
||||
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
|
||||
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Object> rawColumns = options.get("columns") instanceof List<?> raw
|
||||
? (List<Object>) raw : Collections.emptyList();
|
||||
|
||||
List<String> safeColumns = new ArrayList<>();
|
||||
for (Object c : rawColumns) {
|
||||
if (!(c instanceof String s)) continue;
|
||||
String safe = sanitize(s);
|
||||
if (safe.isBlank()) continue;
|
||||
if (!hasColumn(safeTable, safe)) continue;
|
||||
safeColumns.add(safe);
|
||||
}
|
||||
|
||||
String selectExpr;
|
||||
if (safeColumns.isEmpty()) {
|
||||
selectExpr = "main.*";
|
||||
} else {
|
||||
selectExpr = safeColumns.stream()
|
||||
.map(c -> "\"" + c + "\"")
|
||||
.collect(Collectors.joining(", "));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
|
||||
|
||||
List<Object> values = new ArrayList<>();
|
||||
String where = buildAggregateWhere(safeTable, filters, values);
|
||||
|
||||
// orderBy: [{ column, direction }]
|
||||
List<Map<String, Object>> orderBy = normalizeAggregateFilters(options.get("orderBy"));
|
||||
|
||||
List<String> orderClauses = new ArrayList<>();
|
||||
for (Map<String, Object> ob : orderBy) {
|
||||
if (ob == null) continue;
|
||||
String col = ob.get("column") instanceof String s ? s : null;
|
||||
if (col == null) continue;
|
||||
String safeCol = sanitize(col);
|
||||
if (safeCol.isBlank() || !hasColumn(safeTable, safeCol)) continue;
|
||||
String dir = ob.get("direction") instanceof String s
|
||||
&& "desc".equalsIgnoreCase(s) ? "DESC" : "ASC";
|
||||
orderClauses.add(String.format("\"%s\" %s", safeCol, dir));
|
||||
}
|
||||
String order = "";
|
||||
if (!orderClauses.isEmpty()) {
|
||||
order = "ORDER BY " + String.join(", ", orderClauses);
|
||||
} else if (hasColumn(safeTable, "created_date")) {
|
||||
order = "ORDER BY main.created_date DESC";
|
||||
}
|
||||
|
||||
int limit = toInt(options.get("limit"), 50);
|
||||
if (limit < 1) limit = 50;
|
||||
if (limit > 500) limit = 500;
|
||||
int offset = toInt(options.get("offset"), 0);
|
||||
if (offset < 0) offset = 0;
|
||||
|
||||
String sql = String.format(
|
||||
"SELECT %s FROM \"%s\" main %s %s LIMIT %d OFFSET %d",
|
||||
selectExpr, safeTable, where, order, limit, offset);
|
||||
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, values.toArray());
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("rows", rows);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> addTableData(String tableName, Map<String, Object> data) {
|
||||
String safeTable = sanitize(tableName);
|
||||
@@ -611,9 +983,14 @@ public class TableManagementService extends BaseService {
|
||||
|
||||
@Transactional
|
||||
public void createLogTable(String tableName, List<String> logColumns, boolean isActive) {
|
||||
String logTableName = tableName + "_log";
|
||||
String safeLog = sanitize(logTableName);
|
||||
String safeOrig = sanitize(tableName);
|
||||
if (safeOrig.isBlank()) {
|
||||
throw new IllegalArgumentException("유효하지 않은 테이블명입니다.");
|
||||
}
|
||||
String safeLog = sanitize(safeOrig + "_log");
|
||||
if (safeLog.isBlank()) {
|
||||
throw new IllegalArgumentException("유효하지 않은 로그 테이블명입니다.");
|
||||
}
|
||||
|
||||
// 원본 테이블 컬럼 정보 조회
|
||||
Map<String, String> colTypes = getColumnTypes(safeOrig);
|
||||
@@ -625,13 +1002,32 @@ public class TableManagementService extends BaseService {
|
||||
colDefs.add("log_date TIMESTAMP DEFAULT NOW()");
|
||||
colDefs.add("log_user VARCHAR(100)");
|
||||
|
||||
List<String> targetCols = (logColumns != null && !logColumns.isEmpty())
|
||||
? logColumns.stream().map(this::sanitize).filter(c -> !c.isBlank()).collect(Collectors.toList())
|
||||
List<String> requestedCols = (logColumns != null && !logColumns.isEmpty())
|
||||
? logColumns
|
||||
: new ArrayList<>(colTypes.keySet());
|
||||
|
||||
for (String col : targetCols) {
|
||||
String type = colTypes.getOrDefault(col, "TEXT");
|
||||
colDefs.add(String.format("\"%s\" %s", col, type));
|
||||
// 실제 SQL 에 들어간 컬럼만 메타에 저장 (skip 된 것은 log_columns 설정에서도 빠짐)
|
||||
List<String> persistedCols = new ArrayList<>();
|
||||
for (String col : requestedCols) {
|
||||
if (col == null) continue;
|
||||
String safeCol = sanitize(col);
|
||||
if (safeCol.isBlank()) continue; // sanitize 결과 빈 식별자 차단
|
||||
if (!colTypes.containsKey(col)) continue; // 원본 테이블에 없는 컬럼 skip
|
||||
|
||||
String rawType = colTypes.get(col);
|
||||
String normalized = (rawType == null ? "" : rawType.toLowerCase(Locale.ROOT).trim());
|
||||
if (!ALLOWED_LOG_COLUMN_TYPES.contains(normalized)) {
|
||||
// 알 수 없는 type 은 text 로 fallback (안전 default)
|
||||
log.warn("로그 테이블 컬럼 타입 화이트리스트 미일치 → text 로 대체: table={}, col={}, type={}",
|
||||
safeOrig, safeCol, rawType);
|
||||
normalized = "text";
|
||||
}
|
||||
colDefs.add(String.format("\"%s\" %s", safeCol, normalized));
|
||||
persistedCols.add(safeCol);
|
||||
}
|
||||
|
||||
if (persistedCols.isEmpty()) {
|
||||
throw new IllegalArgumentException("log 생성할 컬럼이 없습니다.");
|
||||
}
|
||||
|
||||
String createSql = String.format(
|
||||
@@ -642,7 +1038,7 @@ public class TableManagementService extends BaseService {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("is_active", isActive);
|
||||
params.put("log_columns", String.join(",", targetCols));
|
||||
params.put("log_columns", String.join(",", persistedCols));
|
||||
sqlSession.update(NS + "upsertLogConfig", params);
|
||||
|
||||
log.info("로그 테이블 생성: {}", safeLog);
|
||||
@@ -872,19 +1268,18 @@ public class TableManagementService extends BaseService {
|
||||
|
||||
/**
|
||||
* context 에 따라 INPUT_TYPE 정규화 및 검증.
|
||||
* @param context "user-insert" | "user-update-type" | "user-update-other" | "system-normalize"
|
||||
*/
|
||||
private String normalizeInputType(String value, String context) {
|
||||
if ("user-insert".equals(context) || "user-update-type".equals(context)) {
|
||||
if (value == null || !USER_SELECTABLE_INPUT_TYPES.contains(value)) {
|
||||
private String normalizeInputType(String value, InputTypeContext context) {
|
||||
if (context == InputTypeContext.USER_INSERT || context == InputTypeContext.USER_UPDATE_TYPE) {
|
||||
if (value == null || !InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(value)) {
|
||||
throw new IllegalArgumentException(
|
||||
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + USER_SELECTABLE_INPUT_TYPES
|
||||
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
|
||||
+ " (받은 값: " + value + ")"
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
// user-update-other / system-normalize: 기존 동작 그대로
|
||||
// USER_UPDATE_OTHER / SYSTEM_NORMALIZE: 기존 동작 그대로
|
||||
return normalizeInputType(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- =================================================================
|
||||
-- V022: DEPT_MANAGERS 테이블 (다중 결재/부서/조직장 매핑)
|
||||
-- =================================================================
|
||||
-- 기존 DEPT_INFO.APPROVAL_MANAGER / DEPT_MANAGER 단일 컬럼을 매핑 테이블로 다중화.
|
||||
-- role: 'approval' | 'dept' | 'org_leader'. 부서 삭제(hard) 시 CASCADE 로 정리.
|
||||
-- 멱등: IF NOT EXISTS 로 재실행 안전.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS DEPT_MANAGERS (
|
||||
DEPT_CODE VARCHAR(1024) NOT NULL,
|
||||
USER_ID VARCHAR(50) NOT NULL,
|
||||
ROLE VARCHAR(20) NOT NULL,
|
||||
SORT_ORDER INTEGER NOT NULL DEFAULT 1,
|
||||
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (DEPT_CODE, USER_ID, ROLE),
|
||||
CONSTRAINT chk_dept_managers_role
|
||||
CHECK (ROLE IN ('approval', 'dept', 'org_leader')),
|
||||
CONSTRAINT fk_dept_managers_dept
|
||||
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dept_managers_role
|
||||
ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER);
|
||||
@@ -0,0 +1,12 @@
|
||||
ALTER TABLE MENU_INFO
|
||||
ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN MENU_INFO.IS_SOLUTION_ONLY IS '솔루션 사이트(solution.invyone.com 등 관리 호스트) 에서만 노출되는 메뉴. 테넌트 사이트에선 SQL 단계에서 제외.';
|
||||
|
||||
-- 솔루션 전용 메뉴 마킹
|
||||
UPDATE MENU_INFO SET IS_SOLUTION_ONLY = TRUE
|
||||
WHERE MENU_URL IN (
|
||||
'/admin/sysMng/subdomainList',
|
||||
'/admin/userMng/companyList',
|
||||
'/admin/audit-log'
|
||||
);
|
||||
@@ -58,6 +58,9 @@
|
||||
AND RMA.READ_YN = 'Y'
|
||||
)
|
||||
</if>
|
||||
<if test='is_management_host == false'>
|
||||
AND MENU.IS_SOLUTION_ONLY = FALSE
|
||||
</if>
|
||||
|
||||
UNION ALL
|
||||
|
||||
@@ -105,6 +108,9 @@
|
||||
AND RMA.READ_YN = 'Y'
|
||||
)
|
||||
</if>
|
||||
<if test='is_management_host == false'>
|
||||
AND S.IS_SOLUTION_ONLY = FALSE
|
||||
</if>
|
||||
)
|
||||
SELECT
|
||||
V.LEV
|
||||
@@ -187,6 +193,9 @@
|
||||
AND MENU.COMPANY_CODE = #{company_code}
|
||||
</otherwise>
|
||||
</choose>
|
||||
<if test='is_management_host == false'>
|
||||
AND MENU.IS_SOLUTION_ONLY = FALSE
|
||||
</if>
|
||||
|
||||
UNION ALL
|
||||
|
||||
@@ -212,6 +221,9 @@
|
||||
ON S.PARENT_OBJ_ID = V.OBJID
|
||||
WHERE S.OBJID != ALL(V.PATH)
|
||||
AND S.STATUS = 'active'
|
||||
<if test='is_management_host == false'>
|
||||
AND S.IS_SOLUTION_ONLY = FALSE
|
||||
</if>
|
||||
)
|
||||
SELECT
|
||||
V.LEV
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="cascadingAutoFill">
|
||||
|
||||
<sql id="cascadingAutoFillGroupSearchCondition">
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (G.GROUP_NAME ILIKE '%' || #{keyword} || '%')
|
||||
</if>
|
||||
<if test="is_active != null and is_active != ''">
|
||||
AND G.IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<select id="getCascadingAutoFillGroupList" parameterType="map" resultType="map">
|
||||
SELECT G.*, COUNT(M.MAPPING_ID) AS MAPPING_COUNT
|
||||
FROM CASCADING_AUTO_FILL_GROUP G
|
||||
LEFT JOIN CASCADING_AUTO_FILL_MAPPING M
|
||||
ON G.GROUP_CODE = M.GROUP_CODE AND G.COMPANY_CODE = M.COMPANY_CODE
|
||||
WHERE 1=1
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (G.COMPANY_CODE = #{company_code} OR G.COMPANY_CODE = '*')
|
||||
</if>
|
||||
<include refid="cascadingAutoFillGroupSearchCondition"/>
|
||||
GROUP BY G.GROUP_ID
|
||||
ORDER BY G.GROUP_NAME
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingAutoFillGroupListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(DISTINCT G.GROUP_ID)
|
||||
FROM CASCADING_AUTO_FILL_GROUP G
|
||||
WHERE 1=1
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (G.COMPANY_CODE = #{company_code} OR G.COMPANY_CODE = '*')
|
||||
</if>
|
||||
<include refid="cascadingAutoFillGroupSearchCondition"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingAutoFillGroupByCode" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_AUTO_FILL_GROUP
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
<if test="is_active != null">
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingAutoFillMappingList" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_AUTO_FILL_MAPPING
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
ORDER BY SORT_ORDER, MAPPING_ID
|
||||
</select>
|
||||
|
||||
<select id="getCascadingAutoFillGroupCount" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM CASCADING_AUTO_FILL_GROUP
|
||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</select>
|
||||
|
||||
<insert id="insertCascadingAutoFillGroup" parameterType="map" useGeneratedKeys="true" keyProperty="groupId">
|
||||
INSERT INTO CASCADING_AUTO_FILL_GROUP (
|
||||
GROUP_CODE, GROUP_NAME, DESCRIPTION,
|
||||
MASTER_TABLE, MASTER_VALUE_COLUMN, MASTER_LABEL_COLUMN,
|
||||
COMPANY_CODE, IS_ACTIVE, CREATED_DATE
|
||||
) VALUES (
|
||||
#{group_code},
|
||||
#{group_name},
|
||||
#{description, jdbcType=VARCHAR},
|
||||
#{master_table},
|
||||
#{master_value_column},
|
||||
#{master_label_column, jdbcType=VARCHAR},
|
||||
#{company_code},
|
||||
#{is_active, jdbcType=VARCHAR},
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<insert id="insertCascadingAutoFillMapping" parameterType="map" useGeneratedKeys="true" keyProperty="mappingId">
|
||||
INSERT INTO CASCADING_AUTO_FILL_MAPPING (
|
||||
GROUP_CODE, COMPANY_CODE, SOURCE_COLUMN, TARGET_FIELD, TARGET_LABEL,
|
||||
IS_EDITABLE, IS_REQUIRED, DEFAULT_VALUE, SORT_ORDER
|
||||
) VALUES (
|
||||
#{group_code},
|
||||
#{company_code},
|
||||
#{source_column},
|
||||
#{target_field},
|
||||
#{target_label, jdbcType=VARCHAR},
|
||||
#{is_editable, jdbcType=VARCHAR},
|
||||
#{is_required, jdbcType=VARCHAR},
|
||||
#{default_value, jdbcType=VARCHAR},
|
||||
#{sort_order}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCascadingAutoFillGroup" parameterType="map">
|
||||
UPDATE CASCADING_AUTO_FILL_GROUP SET
|
||||
GROUP_NAME = COALESCE(#{group_name, jdbcType=VARCHAR}, GROUP_NAME),
|
||||
DESCRIPTION = COALESCE(#{description, jdbcType=VARCHAR}, DESCRIPTION),
|
||||
MASTER_TABLE = COALESCE(#{master_table, jdbcType=VARCHAR}, MASTER_TABLE),
|
||||
MASTER_VALUE_COLUMN = COALESCE(#{master_value_column, jdbcType=VARCHAR}, MASTER_VALUE_COLUMN),
|
||||
MASTER_LABEL_COLUMN = COALESCE(#{master_label_column, jdbcType=VARCHAR}, MASTER_LABEL_COLUMN),
|
||||
IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE),
|
||||
UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</update>
|
||||
|
||||
<delete id="deleteCascadingAutoFillMappings" parameterType="map">
|
||||
DELETE FROM CASCADING_AUTO_FILL_MAPPING
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</delete>
|
||||
|
||||
<delete id="deleteCascadingAutoFillGroup" parameterType="map">
|
||||
DELETE FROM CASCADING_AUTO_FILL_GROUP
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</delete>
|
||||
|
||||
</mapper>
|
||||
@@ -1,100 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="cascadingCondition">
|
||||
|
||||
<sql id="cascadingConditionSearchCondition">
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND CONDITION_NAME ILIKE '%' || #{keyword} || '%'
|
||||
</if>
|
||||
<if test="is_active != null and is_active != ''">
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
<if test="relation_code != null and relation_code != ''">
|
||||
AND RELATION_CODE = #{relation_code}
|
||||
</if>
|
||||
<if test="relation_type != null and relation_type != ''">
|
||||
AND RELATION_TYPE = #{relation_type}
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<select id="getCascadingConditionList" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_CONDITION
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="cascadingConditionSearchCondition"/>
|
||||
<choose>
|
||||
<when test="sort_column != null and sort_column != ''">
|
||||
ORDER BY ${sortColumn}
|
||||
<if test="sort_direction != null and sort_direction != ''">
|
||||
${sortDirection}
|
||||
</if>
|
||||
</when>
|
||||
<otherwise>
|
||||
ORDER BY RELATION_CODE, PRIORITY, CONDITION_NAME
|
||||
</otherwise>
|
||||
</choose>
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingConditionListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM CASCADING_CONDITION
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="cascadingConditionSearchCondition"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingConditionInfo" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_CONDITION
|
||||
WHERE CONDITION_ID = #{condition_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<insert id="insertCascadingCondition" parameterType="map" useGeneratedKeys="true" keyProperty="conditionId">
|
||||
INSERT INTO CASCADING_CONDITION (
|
||||
RELATION_TYPE, RELATION_CODE, CONDITION_NAME,
|
||||
CONDITION_FIELD, CONDITION_OPERATOR, CONDITION_VALUE,
|
||||
FILTER_COLUMN, FILTER_VALUES, PRIORITY,
|
||||
COMPANY_CODE, IS_ACTIVE, CREATED_DATE
|
||||
) VALUES (
|
||||
COALESCE(#{relation_type}, 'RELATION'), #{relation_code}, #{condition_name},
|
||||
#{condition_field}, COALESCE(#{condition_operator}, 'EQ'), #{condition_value},
|
||||
#{filter_column}, #{filter_values}, COALESCE(#{priority}, 0),
|
||||
#{company_code}, COALESCE(#{is_active}, 'Y'), CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCascadingCondition" parameterType="map">
|
||||
UPDATE CASCADING_CONDITION SET
|
||||
CONDITION_NAME = COALESCE(#{condition_name}, CONDITION_NAME),
|
||||
CONDITION_FIELD = COALESCE(#{condition_field}, CONDITION_FIELD),
|
||||
CONDITION_OPERATOR = COALESCE(#{condition_operator}, CONDITION_OPERATOR),
|
||||
CONDITION_VALUE = COALESCE(#{condition_value}, CONDITION_VALUE),
|
||||
FILTER_COLUMN = COALESCE(#{filter_column}, FILTER_COLUMN),
|
||||
FILTER_VALUES = COALESCE(#{filter_values}, FILTER_VALUES),
|
||||
PRIORITY = COALESCE(#{priority}, PRIORITY),
|
||||
IS_ACTIVE = COALESCE(#{is_active}, IS_ACTIVE),
|
||||
UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE CONDITION_ID = #{condition_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<delete id="deleteCascadingCondition" parameterType="map">
|
||||
DELETE FROM CASCADING_CONDITION
|
||||
WHERE CONDITION_ID = #{condition_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</delete>
|
||||
|
||||
<select id="getCascadingConditionsByRelationCode" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_CONDITION
|
||||
WHERE RELATION_CODE = #{relation_code}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
ORDER BY PRIORITY DESC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -1,219 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="cascadingHierarchy">
|
||||
|
||||
<sql id="cascadingHierarchyGroupSearchCondition">
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (G.GROUP_NAME ILIKE '%' || #{keyword} || '%')
|
||||
</if>
|
||||
<if test="is_active != null and is_active != ''">
|
||||
AND G.IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
<if test="hierarchy_type != null and hierarchy_type != ''">
|
||||
AND G.HIERARCHY_TYPE = #{hierarchy_type}
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<select id="getCascadingHierarchyGroupList" parameterType="map" resultType="map">
|
||||
SELECT G.*,
|
||||
(SELECT COUNT(*)
|
||||
FROM CASCADING_HIERARCHY_LEVEL L
|
||||
WHERE L.GROUP_CODE = G.GROUP_CODE AND L.COMPANY_CODE = G.COMPANY_CODE) AS LEVEL_COUNT
|
||||
FROM CASCADING_HIERARCHY_GROUP G
|
||||
WHERE 1=1
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (G.COMPANY_CODE = #{company_code} OR G.COMPANY_CODE = '*')
|
||||
</if>
|
||||
<include refid="cascadingHierarchyGroupSearchCondition"/>
|
||||
ORDER BY G.GROUP_NAME
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingHierarchyGroupListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(DISTINCT G.GROUP_ID)
|
||||
FROM CASCADING_HIERARCHY_GROUP G
|
||||
WHERE 1=1
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (G.COMPANY_CODE = #{company_code} OR G.COMPANY_CODE = '*')
|
||||
</if>
|
||||
<include refid="cascadingHierarchyGroupSearchCondition"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingHierarchyGroupByCode" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_HIERARCHY_GROUP
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingHierarchyGroupCount" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM CASCADING_HIERARCHY_GROUP
|
||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</select>
|
||||
|
||||
<select id="getCascadingHierarchyLevelList" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_HIERARCHY_LEVEL
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
ORDER BY LEVEL_ORDER
|
||||
</select>
|
||||
|
||||
<select id="getCascadingHierarchyLevelInfo" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_HIERARCHY_LEVEL
|
||||
WHERE LEVEL_ID = #{level_id}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingHierarchyLevelForOptions" parameterType="map" resultType="map">
|
||||
SELECT L.*, G.HIERARCHY_TYPE
|
||||
FROM CASCADING_HIERARCHY_LEVEL L
|
||||
JOIN CASCADING_HIERARCHY_GROUP G
|
||||
ON L.GROUP_CODE = G.GROUP_CODE AND L.COMPANY_CODE = G.COMPANY_CODE
|
||||
WHERE L.GROUP_CODE = #{group_code}
|
||||
AND L.LEVEL_ORDER = #{level_order}
|
||||
AND L.IS_ACTIVE = 'Y'
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (L.COMPANY_CODE = #{company_code} OR L.COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<insert id="insertCascadingHierarchyGroup" parameterType="map" useGeneratedKeys="true" keyProperty="groupId">
|
||||
INSERT INTO CASCADING_HIERARCHY_GROUP (
|
||||
GROUP_CODE, GROUP_NAME, DESCRIPTION, HIERARCHY_TYPE,
|
||||
MAX_LEVELS, IS_FIXED_LEVELS,
|
||||
SELF_REF_TABLE, SELF_REF_ID_COLUMN, SELF_REF_PARENT_COLUMN,
|
||||
SELF_REF_VALUE_COLUMN, SELF_REF_LABEL_COLUMN, SELF_REF_LEVEL_COLUMN, SELF_REF_ORDER_COLUMN,
|
||||
BOM_TABLE, BOM_PARENT_COLUMN, BOM_CHILD_COLUMN,
|
||||
BOM_ITEM_TABLE, BOM_ITEM_ID_COLUMN, BOM_ITEM_LABEL_COLUMN, BOM_QTY_COLUMN, BOM_LEVEL_COLUMN,
|
||||
EMPTY_MESSAGE, NO_OPTIONS_MESSAGE, LOADING_MESSAGE,
|
||||
COMPANY_CODE, IS_ACTIVE, CREATED_BY, CREATED_DATE
|
||||
) VALUES (
|
||||
#{group_code},
|
||||
#{group_name},
|
||||
#{description, jdbcType=VARCHAR},
|
||||
#{hierarchy_type},
|
||||
#{max_levels, jdbcType=INTEGER},
|
||||
#{is_fixed_levels, jdbcType=VARCHAR},
|
||||
#{self_ref_table, jdbcType=VARCHAR},
|
||||
#{self_ref_id_column, jdbcType=VARCHAR},
|
||||
#{self_ref_parent_column, jdbcType=VARCHAR},
|
||||
#{self_ref_value_column, jdbcType=VARCHAR},
|
||||
#{self_ref_label_column, jdbcType=VARCHAR},
|
||||
#{self_ref_level_column, jdbcType=VARCHAR},
|
||||
#{self_ref_order_column, jdbcType=VARCHAR},
|
||||
#{bom_table, jdbcType=VARCHAR},
|
||||
#{bom_parent_column, jdbcType=VARCHAR},
|
||||
#{bom_child_column, jdbcType=VARCHAR},
|
||||
#{bom_item_table, jdbcType=VARCHAR},
|
||||
#{bom_item_id_column, jdbcType=VARCHAR},
|
||||
#{bom_item_label_column, jdbcType=VARCHAR},
|
||||
#{bom_qty_column, jdbcType=VARCHAR},
|
||||
#{bom_level_column, jdbcType=VARCHAR},
|
||||
#{empty_message, jdbcType=VARCHAR},
|
||||
#{no_options_message, jdbcType=VARCHAR},
|
||||
#{loading_message, jdbcType=VARCHAR},
|
||||
#{company_code},
|
||||
'Y',
|
||||
#{created_by, jdbcType=VARCHAR},
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<insert id="insertCascadingHierarchyLevel" parameterType="map" useGeneratedKeys="true" keyProperty="levelId">
|
||||
INSERT INTO CASCADING_HIERARCHY_LEVEL (
|
||||
GROUP_CODE, COMPANY_CODE, LEVEL_ORDER, LEVEL_NAME, LEVEL_CODE,
|
||||
TABLE_NAME, VALUE_COLUMN, LABEL_COLUMN, PARENT_KEY_COLUMN,
|
||||
FILTER_COLUMN, FILTER_VALUE, ORDER_COLUMN, ORDER_DIRECTION,
|
||||
PLACEHOLDER, IS_REQUIRED, IS_SEARCHABLE, IS_ACTIVE, CREATED_DATE
|
||||
) VALUES (
|
||||
#{group_code},
|
||||
#{company_code},
|
||||
#{level_order},
|
||||
#{level_name},
|
||||
#{level_code, jdbcType=VARCHAR},
|
||||
#{table_name},
|
||||
#{value_column},
|
||||
#{label_column},
|
||||
#{parent_key_column, jdbcType=VARCHAR},
|
||||
#{filter_column, jdbcType=VARCHAR},
|
||||
#{filter_value, jdbcType=VARCHAR},
|
||||
#{order_column, jdbcType=VARCHAR},
|
||||
#{order_direction, jdbcType=VARCHAR},
|
||||
#{placeholder, jdbcType=VARCHAR},
|
||||
#{is_required, jdbcType=VARCHAR},
|
||||
#{is_searchable, jdbcType=VARCHAR},
|
||||
'Y',
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCascadingHierarchyGroup" parameterType="map">
|
||||
UPDATE CASCADING_HIERARCHY_GROUP SET
|
||||
GROUP_NAME = COALESCE(#{group_name, jdbcType=VARCHAR}, GROUP_NAME),
|
||||
DESCRIPTION = COALESCE(#{description, jdbcType=VARCHAR}, DESCRIPTION),
|
||||
MAX_LEVELS = COALESCE(#{max_levels, jdbcType=INTEGER}, MAX_LEVELS),
|
||||
IS_FIXED_LEVELS = COALESCE(#{is_fixed_levels, jdbcType=VARCHAR}, IS_FIXED_LEVELS),
|
||||
EMPTY_MESSAGE = COALESCE(#{empty_message, jdbcType=VARCHAR}, EMPTY_MESSAGE),
|
||||
NO_OPTIONS_MESSAGE = COALESCE(#{no_options_message, jdbcType=VARCHAR}, NO_OPTIONS_MESSAGE),
|
||||
LOADING_MESSAGE = COALESCE(#{loading_message, jdbcType=VARCHAR}, LOADING_MESSAGE),
|
||||
IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE),
|
||||
UPDATED_BY = #{updated_by, jdbcType=VARCHAR},
|
||||
UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</update>
|
||||
|
||||
<update id="updateCascadingHierarchyLevel" parameterType="map">
|
||||
UPDATE CASCADING_HIERARCHY_LEVEL SET
|
||||
LEVEL_NAME = COALESCE(#{level_name, jdbcType=VARCHAR}, LEVEL_NAME),
|
||||
TABLE_NAME = COALESCE(#{table_name, jdbcType=VARCHAR}, TABLE_NAME),
|
||||
VALUE_COLUMN = COALESCE(#{value_column, jdbcType=VARCHAR}, VALUE_COLUMN),
|
||||
LABEL_COLUMN = COALESCE(#{label_column, jdbcType=VARCHAR}, LABEL_COLUMN),
|
||||
PARENT_KEY_COLUMN = COALESCE(#{parent_key_column, jdbcType=VARCHAR}, PARENT_KEY_COLUMN),
|
||||
FILTER_COLUMN = COALESCE(#{filter_column, jdbcType=VARCHAR}, FILTER_COLUMN),
|
||||
FILTER_VALUE = COALESCE(#{filter_value, jdbcType=VARCHAR}, FILTER_VALUE),
|
||||
ORDER_COLUMN = COALESCE(#{order_column, jdbcType=VARCHAR}, ORDER_COLUMN),
|
||||
ORDER_DIRECTION = COALESCE(#{order_direction, jdbcType=VARCHAR}, ORDER_DIRECTION),
|
||||
PLACEHOLDER = COALESCE(#{placeholder, jdbcType=VARCHAR}, PLACEHOLDER),
|
||||
IS_REQUIRED = COALESCE(#{is_required, jdbcType=VARCHAR}, IS_REQUIRED),
|
||||
IS_SEARCHABLE = COALESCE(#{is_searchable, jdbcType=VARCHAR}, IS_SEARCHABLE),
|
||||
IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE),
|
||||
UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE LEVEL_ID = #{level_id}
|
||||
</update>
|
||||
|
||||
<delete id="deleteCascadingHierarchyLevels" parameterType="map">
|
||||
DELETE FROM CASCADING_HIERARCHY_LEVEL
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</delete>
|
||||
|
||||
<delete id="deleteCascadingHierarchyLevel" parameterType="map">
|
||||
DELETE FROM CASCADING_HIERARCHY_LEVEL
|
||||
WHERE LEVEL_ID = #{level_id}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</delete>
|
||||
|
||||
<delete id="deleteCascadingHierarchyGroup" parameterType="map">
|
||||
DELETE FROM CASCADING_HIERARCHY_GROUP
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</delete>
|
||||
|
||||
</mapper>
|
||||
@@ -1,145 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="cascadingMutualExclusion">
|
||||
|
||||
<sql id="cascadingMutualExclusionSearchCondition">
|
||||
<if test="is_active != null and is_active != ''">
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND EXCLUSION_NAME ILIKE '%' || #{keyword} || '%'
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<select id="getCascadingMutualExclusionList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
EXCLUSION_ID
|
||||
, EXCLUSION_CODE
|
||||
, EXCLUSION_NAME
|
||||
, FIELD_NAMES
|
||||
, SOURCE_TABLE
|
||||
, VALUE_COLUMN
|
||||
, LABEL_COLUMN
|
||||
, EXCLUSION_TYPE
|
||||
, ERROR_MESSAGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_DATE
|
||||
FROM CASCADING_MUTUAL_EXCLUSION
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="cascadingMutualExclusionSearchCondition"/>
|
||||
ORDER BY CREATED_DATE DESC
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingMutualExclusionListCnt" parameterType="map" resultType="int">
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM CASCADING_MUTUAL_EXCLUSION
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="cascadingMutualExclusionSearchCondition"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingMutualExclusionInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
EXCLUSION_ID
|
||||
, EXCLUSION_CODE
|
||||
, EXCLUSION_NAME
|
||||
, FIELD_NAMES
|
||||
, SOURCE_TABLE
|
||||
, VALUE_COLUMN
|
||||
, LABEL_COLUMN
|
||||
, EXCLUSION_TYPE
|
||||
, ERROR_MESSAGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_DATE
|
||||
FROM CASCADING_MUTUAL_EXCLUSION
|
||||
WHERE EXCLUSION_ID = #{id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<!-- 코드로 단건 조회 (is_active = 'Y') -->
|
||||
<select id="getCascadingMutualExclusionByCode" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
EXCLUSION_ID
|
||||
, EXCLUSION_CODE
|
||||
, EXCLUSION_NAME
|
||||
, FIELD_NAMES
|
||||
, SOURCE_TABLE
|
||||
, VALUE_COLUMN
|
||||
, LABEL_COLUMN
|
||||
, EXCLUSION_TYPE
|
||||
, ERROR_MESSAGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
FROM CASCADING_MUTUAL_EXCLUSION
|
||||
WHERE EXCLUSION_CODE = #{code}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 코드 자동 생성용 카운트 -->
|
||||
<select id="getCascadingMutualExclusionCount" parameterType="map" resultType="int">
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM CASCADING_MUTUAL_EXCLUSION
|
||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</select>
|
||||
|
||||
<insert id="insertCascadingMutualExclusion" parameterType="map"
|
||||
useGeneratedKeys="true" keyProperty="exclusionId" keyColumn="exclusion_id">
|
||||
INSERT INTO CASCADING_MUTUAL_EXCLUSION (
|
||||
EXCLUSION_CODE
|
||||
, EXCLUSION_NAME
|
||||
, FIELD_NAMES
|
||||
, SOURCE_TABLE
|
||||
, VALUE_COLUMN
|
||||
, LABEL_COLUMN
|
||||
, EXCLUSION_TYPE
|
||||
, ERROR_MESSAGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_DATE
|
||||
) VALUES (
|
||||
#{exclusion_code, jdbcType=VARCHAR}
|
||||
, #{exclusion_name, jdbcType=VARCHAR}
|
||||
, #{field_names, jdbcType=VARCHAR}
|
||||
, #{source_table, jdbcType=VARCHAR}
|
||||
, #{value_column, jdbcType=VARCHAR}
|
||||
, #{label_column, jdbcType=VARCHAR}
|
||||
, #{exclusion_type, jdbcType=VARCHAR}
|
||||
, #{error_message, jdbcType=VARCHAR}
|
||||
, #{company_code}
|
||||
, 'Y'
|
||||
, CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCascadingMutualExclusion" parameterType="map">
|
||||
UPDATE CASCADING_MUTUAL_EXCLUSION
|
||||
SET
|
||||
EXCLUSION_NAME = COALESCE(#{exclusion_name, jdbcType=VARCHAR}, EXCLUSION_NAME)
|
||||
, FIELD_NAMES = COALESCE(#{field_names, jdbcType=VARCHAR}, FIELD_NAMES)
|
||||
, SOURCE_TABLE = COALESCE(#{source_table, jdbcType=VARCHAR}, SOURCE_TABLE)
|
||||
, VALUE_COLUMN = COALESCE(#{value_column, jdbcType=VARCHAR}, VALUE_COLUMN)
|
||||
, LABEL_COLUMN = COALESCE(#{label_column, jdbcType=VARCHAR}, LABEL_COLUMN)
|
||||
, EXCLUSION_TYPE = COALESCE(#{exclusion_type, jdbcType=VARCHAR}, EXCLUSION_TYPE)
|
||||
, ERROR_MESSAGE = COALESCE(#{error_message, jdbcType=VARCHAR}, ERROR_MESSAGE)
|
||||
, IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE)
|
||||
WHERE EXCLUSION_ID = #{id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<!-- 하드 삭제 (Node.js와 동일) -->
|
||||
<delete id="deleteCascadingMutualExclusion" parameterType="map">
|
||||
DELETE FROM CASCADING_MUTUAL_EXCLUSION
|
||||
WHERE EXCLUSION_ID = #{id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</delete>
|
||||
|
||||
</mapper>
|
||||
@@ -1,160 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="cascadingRelation">
|
||||
|
||||
<sql id="cascadingRelationColumns">
|
||||
RELATION_ID
|
||||
, RELATION_CODE
|
||||
, RELATION_NAME
|
||||
, DESCRIPTION
|
||||
, PARENT_TABLE
|
||||
, PARENT_VALUE_COLUMN
|
||||
, PARENT_LABEL_COLUMN
|
||||
, CHILD_TABLE
|
||||
, CHILD_FILTER_COLUMN
|
||||
, CHILD_VALUE_COLUMN
|
||||
, CHILD_LABEL_COLUMN
|
||||
, CHILD_ORDER_COLUMN
|
||||
, CHILD_ORDER_DIRECTION
|
||||
, EMPTY_PARENT_MESSAGE
|
||||
, NO_OPTIONS_MESSAGE
|
||||
, LOADING_MESSAGE
|
||||
, CLEAR_ON_PARENT_CHANGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_BY
|
||||
, UPDATED_DATE
|
||||
</sql>
|
||||
|
||||
<sql id="cascadingRelationSearchCondition">
|
||||
<if test="is_active != null and is_active != ''">
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (RELATION_NAME ILIKE '%' || #{keyword} || '%'
|
||||
OR RELATION_CODE ILIKE '%' || #{keyword} || '%')
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<select id="getCascadingRelationList" parameterType="map" resultType="map">
|
||||
SELECT <include refid="cascadingRelationColumns"/>
|
||||
FROM CASCADING_RELATION
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="cascadingRelationSearchCondition"/>
|
||||
ORDER BY CREATED_DATE DESC
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingRelationListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM CASCADING_RELATION
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="cascadingRelationSearchCondition"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingRelationInfo" parameterType="map" resultType="map">
|
||||
SELECT <include refid="cascadingRelationColumns"/>
|
||||
FROM CASCADING_RELATION
|
||||
WHERE RELATION_ID = #{id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<!-- 코드로 단건 조회 (is_active = 'Y' 조건 포함) -->
|
||||
<select id="getCascadingRelationByCode" parameterType="map" resultType="map">
|
||||
SELECT <include refid="cascadingRelationColumns"/>
|
||||
FROM CASCADING_RELATION
|
||||
WHERE RELATION_CODE = #{code}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<insert id="insertCascadingRelation" parameterType="map"
|
||||
useGeneratedKeys="true" keyProperty="relationId" keyColumn="relation_id">
|
||||
INSERT INTO CASCADING_RELATION (
|
||||
RELATION_CODE
|
||||
, RELATION_NAME
|
||||
, DESCRIPTION
|
||||
, PARENT_TABLE
|
||||
, PARENT_VALUE_COLUMN
|
||||
, PARENT_LABEL_COLUMN
|
||||
, CHILD_TABLE
|
||||
, CHILD_FILTER_COLUMN
|
||||
, CHILD_VALUE_COLUMN
|
||||
, CHILD_LABEL_COLUMN
|
||||
, CHILD_ORDER_COLUMN
|
||||
, CHILD_ORDER_DIRECTION
|
||||
, EMPTY_PARENT_MESSAGE
|
||||
, NO_OPTIONS_MESSAGE
|
||||
, LOADING_MESSAGE
|
||||
, CLEAR_ON_PARENT_CHANGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
) VALUES (
|
||||
#{relation_code, jdbcType=VARCHAR}
|
||||
, #{relation_name, jdbcType=VARCHAR}
|
||||
, #{description, jdbcType=VARCHAR}
|
||||
, #{parent_table, jdbcType=VARCHAR}
|
||||
, #{parent_value_column, jdbcType=VARCHAR}
|
||||
, #{parent_label_column, jdbcType=VARCHAR}
|
||||
, #{child_table, jdbcType=VARCHAR}
|
||||
, #{child_filter_column, jdbcType=VARCHAR}
|
||||
, #{child_value_column, jdbcType=VARCHAR}
|
||||
, #{child_label_column, jdbcType=VARCHAR}
|
||||
, #{child_order_column, jdbcType=VARCHAR}
|
||||
, #{child_order_direction, jdbcType=VARCHAR}
|
||||
, #{empty_parent_message, jdbcType=VARCHAR}
|
||||
, #{no_options_message, jdbcType=VARCHAR}
|
||||
, #{loading_message, jdbcType=VARCHAR}
|
||||
, #{clear_on_parent_change, jdbcType=VARCHAR}
|
||||
, #{company_code}
|
||||
, #{is_active, jdbcType=VARCHAR}
|
||||
, #{user_id, jdbcType=VARCHAR}
|
||||
, CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCascadingRelation" parameterType="map">
|
||||
UPDATE CASCADING_RELATION
|
||||
SET
|
||||
RELATION_NAME = COALESCE(#{relation_name, jdbcType=VARCHAR}, RELATION_NAME)
|
||||
, DESCRIPTION = COALESCE(#{description, jdbcType=VARCHAR}, DESCRIPTION)
|
||||
, PARENT_TABLE = COALESCE(#{parent_table, jdbcType=VARCHAR}, PARENT_TABLE)
|
||||
, PARENT_VALUE_COLUMN = COALESCE(#{parent_value_column, jdbcType=VARCHAR}, PARENT_VALUE_COLUMN)
|
||||
, PARENT_LABEL_COLUMN = COALESCE(#{parent_label_column, jdbcType=VARCHAR}, PARENT_LABEL_COLUMN)
|
||||
, CHILD_TABLE = COALESCE(#{child_table, jdbcType=VARCHAR}, CHILD_TABLE)
|
||||
, CHILD_FILTER_COLUMN = COALESCE(#{child_filter_column, jdbcType=VARCHAR}, CHILD_FILTER_COLUMN)
|
||||
, CHILD_VALUE_COLUMN = COALESCE(#{child_value_column, jdbcType=VARCHAR}, CHILD_VALUE_COLUMN)
|
||||
, CHILD_LABEL_COLUMN = COALESCE(#{child_label_column, jdbcType=VARCHAR}, CHILD_LABEL_COLUMN)
|
||||
, CHILD_ORDER_COLUMN = COALESCE(#{child_order_column, jdbcType=VARCHAR}, CHILD_ORDER_COLUMN)
|
||||
, CHILD_ORDER_DIRECTION = COALESCE(#{child_order_direction, jdbcType=VARCHAR}, CHILD_ORDER_DIRECTION)
|
||||
, EMPTY_PARENT_MESSAGE = COALESCE(#{empty_parent_message, jdbcType=VARCHAR}, EMPTY_PARENT_MESSAGE)
|
||||
, NO_OPTIONS_MESSAGE = COALESCE(#{no_options_message, jdbcType=VARCHAR}, NO_OPTIONS_MESSAGE)
|
||||
, LOADING_MESSAGE = COALESCE(#{loading_message, jdbcType=VARCHAR}, LOADING_MESSAGE)
|
||||
, CLEAR_ON_PARENT_CHANGE = COALESCE(#{clear_on_parent_change, jdbcType=VARCHAR}, CLEAR_ON_PARENT_CHANGE)
|
||||
, IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE)
|
||||
, UPDATED_BY = #{user_id, jdbcType=VARCHAR}
|
||||
, UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE RELATION_ID = #{id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<!-- 소프트 삭제: is_active = 'N' -->
|
||||
<update id="deleteCascadingRelation" parameterType="map">
|
||||
UPDATE CASCADING_RELATION
|
||||
SET
|
||||
IS_ACTIVE = 'N'
|
||||
, UPDATED_BY = #{user_id, jdbcType=VARCHAR}
|
||||
, UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE RELATION_ID = #{id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
@@ -1,182 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="categoryTree">
|
||||
|
||||
<!-- 공통 컬럼 -->
|
||||
<sql id="categoryValueColumns">
|
||||
value_id, table_name, column_name, value_code, value_label, value_order,
|
||||
parent_value_id, depth, path, description, color, icon,
|
||||
is_active, is_default, company_code,
|
||||
CREATED_DATE, UPDATED_DATE, created_by, updated_by
|
||||
</sql>
|
||||
|
||||
<!-- 카테고리 플랫 리스트 조회 (트리/플랫 모두 사용) -->
|
||||
<select id="getCategoryTreeList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
<include refid="categoryValueColumns"/>
|
||||
|
||||
FROM category_values
|
||||
|
||||
WHERE (company_code = #{company_code} OR company_code = '*')
|
||||
AND table_name = #{table_name}
|
||||
AND column_name = #{column_name}
|
||||
|
||||
ORDER BY depth ASC, value_order ASC, value_label ASC
|
||||
</select>
|
||||
|
||||
<!-- 카테고리 값 단건 조회 -->
|
||||
<select id="getCategoryTreeInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
<include refid="categoryValueColumns"/>
|
||||
|
||||
FROM category_values
|
||||
|
||||
WHERE (company_code = #{company_code} OR company_code = '*')
|
||||
AND value_id = #{value_id}
|
||||
</select>
|
||||
|
||||
<!-- 카테고리 값 생성 -->
|
||||
<insert id="insertCategoryTree" parameterType="map"
|
||||
useGeneratedKeys="true" keyProperty="valueId" keyColumn="value_id">
|
||||
INSERT INTO category_values (
|
||||
table_name, column_name, value_code, value_label, value_order,
|
||||
parent_value_id, depth, path, description, color, icon,
|
||||
is_active, is_default, company_code, created_by, updated_by
|
||||
) VALUES (
|
||||
#{table_name}, #{column_name}, #{value_code}, #{value_label}, #{value_order},
|
||||
#{parent_value_id}, #{depth}, #{path}, #{description}, #{color}, #{icon},
|
||||
#{is_active}, #{is_default}, #{company_code}, #{created_by}, #{created_by}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<!-- 카테고리 값 수정 (COALESCE로 부분 업데이트) -->
|
||||
<update id="updateCategoryTree" parameterType="map">
|
||||
UPDATE category_values
|
||||
SET
|
||||
value_code = COALESCE(#{value_code}, value_code),
|
||||
value_label = COALESCE(#{value_label}, value_label),
|
||||
value_order = COALESCE(#{value_order}, value_order),
|
||||
parent_value_id = #{parent_value_id},
|
||||
depth = #{depth},
|
||||
path = #{path},
|
||||
description = COALESCE(#{description}, description),
|
||||
color = COALESCE(#{color}, color),
|
||||
icon = COALESCE(#{icon}, icon),
|
||||
is_active = COALESCE(#{is_active}, is_active),
|
||||
is_default = COALESCE(#{is_default}, is_default),
|
||||
UPDATED_DATE = NOW(),
|
||||
updated_by = #{updated_by}
|
||||
|
||||
WHERE (company_code = #{company_code} OR company_code = '*')
|
||||
AND value_id = #{value_id}
|
||||
</update>
|
||||
|
||||
<!-- 카테고리 값 삭제 -->
|
||||
<delete id="deleteCategoryTree" parameterType="map">
|
||||
DELETE FROM category_values
|
||||
|
||||
WHERE (company_code = #{company_code} OR company_code = '*')
|
||||
AND value_id = #{value_id}
|
||||
</delete>
|
||||
|
||||
<!-- 자식 카테고리 수 조회 -->
|
||||
<select id="getCategoryTreeChildrenCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*) FROM category_values
|
||||
|
||||
WHERE parent_value_id = #{value_id}
|
||||
AND (company_code = #{company_code} OR company_code = '*')
|
||||
</select>
|
||||
|
||||
<!-- 테이블 존재 여부 확인 (0 또는 1 반환) -->
|
||||
<select id="checkTableExists" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = #{table_name}
|
||||
</select>
|
||||
|
||||
<!-- 컬럼 존재 여부 확인 (0 또는 1 반환) -->
|
||||
<select id="checkColumnExists" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = #{table_name}
|
||||
AND column_name = #{column_name}
|
||||
</select>
|
||||
|
||||
<!-- 카테고리 값 사용 건수 조회 (company_code 필터 포함) -->
|
||||
<select id="countCategoryUsageWithCompany" parameterType="map" resultType="int">
|
||||
<![CDATA[
|
||||
SELECT COUNT(*) FROM "${tableName}"
|
||||
|
||||
WHERE (company_code = #{company_code} OR company_code = '*')
|
||||
AND (#{value_code} = ANY(string_to_array("${columnName}"::text, ','))
|
||||
OR "${columnName}"::text = #{value_code})
|
||||
]]>
|
||||
</select>
|
||||
|
||||
<!-- 카테고리 값 사용 건수 조회 (company_code 필터 없음) -->
|
||||
<select id="countCategoryUsage" parameterType="map" resultType="int">
|
||||
<![CDATA[
|
||||
SELECT COUNT(*) FROM "${tableName}"
|
||||
|
||||
WHERE #{value_code} = ANY(string_to_array("${columnName}"::text, ','))
|
||||
OR "${columnName}"::text = #{value_code}
|
||||
]]>
|
||||
</select>
|
||||
|
||||
<!-- 직계 자식 목록 조회 (path 업데이트용) -->
|
||||
<select id="getCategoryTreeChildrenList" parameterType="map" resultType="map">
|
||||
SELECT value_id, value_label
|
||||
|
||||
FROM category_values
|
||||
|
||||
WHERE (company_code = #{company_code} OR company_code = '*')
|
||||
AND parent_value_id = #{parent_value_id}
|
||||
</select>
|
||||
|
||||
<!-- 자식 path 단건 업데이트 -->
|
||||
<update id="updateCategoryTreeChildPath" parameterType="map">
|
||||
UPDATE category_values
|
||||
SET path = #{path}, UPDATED_DATE = NOW()
|
||||
|
||||
WHERE value_id = #{value_id}
|
||||
</update>
|
||||
|
||||
<!-- 테이블의 카테고리 컬럼 목록 조회 -->
|
||||
<select id="getCategoryTreeColumnList" parameterType="map" resultType="map">
|
||||
SELECT DISTINCT column_name, column_label
|
||||
|
||||
FROM table_type_columns
|
||||
|
||||
WHERE table_name = #{table_name}
|
||||
AND input_type = 'category'
|
||||
AND (company_code = #{company_code} OR company_code = '*')
|
||||
|
||||
ORDER BY column_name
|
||||
</select>
|
||||
|
||||
<!-- 전체 카테고리 키 목록 조회 (table_name + column_name 조합) -->
|
||||
<select id="getCategoryTreeKeyList" parameterType="map" resultType="map">
|
||||
SELECT DISTINCT
|
||||
cv.table_name,
|
||||
cv.column_name,
|
||||
COALESCE(tl.table_label, cv.table_name) AS table_label,
|
||||
COALESCE(ttc.column_label, cv.column_name) AS column_label
|
||||
|
||||
FROM category_values cv
|
||||
|
||||
LEFT JOIN table_labels tl ON tl.table_name = cv.table_name
|
||||
|
||||
LEFT JOIN table_type_columns ttc ON ttc.table_name = cv.table_name
|
||||
AND ttc.column_name = cv.column_name
|
||||
AND ttc.company_code = '*'
|
||||
|
||||
WHERE (cv.company_code = #{company_code} OR cv.company_code = '*')
|
||||
OR cv.company_code = '*'
|
||||
|
||||
ORDER BY cv.table_name, cv.column_name
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -1,179 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="categoryValueCascading">
|
||||
|
||||
<sql id="groupSearchCondition">
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (RELATION_NAME ILIKE '%' || #{keyword} || '%' OR RELATION_CODE ILIKE '%' || #{keyword} || '%')
|
||||
</if>
|
||||
<if test="is_active != null and is_active != ''">
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<select id="getCategoryValueCascadingGroupList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
*
|
||||
FROM CATEGORY_VALUE_CASCADING_GROUP
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="groupSearchCondition"/>
|
||||
<choose>
|
||||
<when test="sort_column != null and sort_column != ''">
|
||||
ORDER BY ${sortColumn}
|
||||
<if test="sort_direction != null and sort_direction != ''">
|
||||
${sortDirection}
|
||||
</if>
|
||||
</when>
|
||||
<otherwise>
|
||||
ORDER BY RELATION_NAME ASC
|
||||
</otherwise>
|
||||
</choose>
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCategoryValueCascadingGroupListCnt" parameterType="map" resultType="int">
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM CATEGORY_VALUE_CASCADING_GROUP
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="groupSearchCondition"/>
|
||||
</select>
|
||||
|
||||
<select id="getCategoryValueCascadingGroupInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
*
|
||||
FROM CATEGORY_VALUE_CASCADING_GROUP
|
||||
WHERE GROUP_ID = #{group_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<select id="getCategoryValueCascadingGroupByCode" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
*
|
||||
FROM CATEGORY_VALUE_CASCADING_GROUP
|
||||
WHERE RELATION_CODE = #{code}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<insert id="insertCategoryValueCascadingGroup" parameterType="map" useGeneratedKeys="true" keyProperty="groupId">
|
||||
INSERT INTO CATEGORY_VALUE_CASCADING_GROUP (
|
||||
RELATION_CODE
|
||||
, RELATION_NAME
|
||||
, DESCRIPTION
|
||||
, PARENT_TABLE_NAME
|
||||
, PARENT_COLUMN_NAME
|
||||
, PARENT_MENU_OBJID
|
||||
, CHILD_TABLE_NAME
|
||||
, CHILD_COLUMN_NAME
|
||||
, CHILD_MENU_OBJID
|
||||
, CLEAR_ON_PARENT_CHANGE
|
||||
, SHOW_GROUP_LABEL
|
||||
, EMPTY_PARENT_MESSAGE
|
||||
, NO_OPTIONS_MESSAGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
) VALUES (
|
||||
#{relation_code}
|
||||
, #{relation_name}
|
||||
, #{description}
|
||||
, #{parent_table_name}
|
||||
, #{parent_column_name}
|
||||
, #{parent_menu_objid}
|
||||
, #{child_table_name}
|
||||
, #{child_column_name}
|
||||
, #{child_menu_objid}
|
||||
, COALESCE(#{clear_on_parent_change}, 'Y')
|
||||
, COALESCE(#{show_group_label}, 'Y')
|
||||
, COALESCE(#{empty_parent_message}, '상위 항목을 먼저 선택하세요')
|
||||
, COALESCE(#{no_options_message}, '선택 가능한 항목이 없습니다')
|
||||
, #{company_code}
|
||||
, 'Y'
|
||||
, #{created_by}
|
||||
, NOW()
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCategoryValueCascadingGroup" parameterType="map">
|
||||
UPDATE CATEGORY_VALUE_CASCADING_GROUP
|
||||
SET
|
||||
RELATION_NAME = COALESCE(#{relation_name}, RELATION_NAME)
|
||||
, DESCRIPTION = COALESCE(#{description}, DESCRIPTION)
|
||||
, PARENT_TABLE_NAME = COALESCE(#{parent_table_name}, PARENT_TABLE_NAME)
|
||||
, PARENT_COLUMN_NAME = COALESCE(#{parent_column_name}, PARENT_COLUMN_NAME)
|
||||
, PARENT_MENU_OBJID = COALESCE(#{parent_menu_objid}, PARENT_MENU_OBJID)
|
||||
, CHILD_TABLE_NAME = COALESCE(#{child_table_name}, CHILD_TABLE_NAME)
|
||||
, CHILD_COLUMN_NAME = COALESCE(#{child_column_name}, CHILD_COLUMN_NAME)
|
||||
, CHILD_MENU_OBJID = COALESCE(#{child_menu_objid}, CHILD_MENU_OBJID)
|
||||
, CLEAR_ON_PARENT_CHANGE = COALESCE(#{clear_on_parent_change}, CLEAR_ON_PARENT_CHANGE)
|
||||
, SHOW_GROUP_LABEL = COALESCE(#{show_group_label}, SHOW_GROUP_LABEL)
|
||||
, EMPTY_PARENT_MESSAGE = COALESCE(#{empty_parent_message}, EMPTY_PARENT_MESSAGE)
|
||||
, NO_OPTIONS_MESSAGE = COALESCE(#{no_options_message}, NO_OPTIONS_MESSAGE)
|
||||
, IS_ACTIVE = COALESCE(#{is_active}, IS_ACTIVE)
|
||||
, UPDATED_BY = #{updated_by}
|
||||
, UPDATED_DATE = NOW()
|
||||
WHERE GROUP_ID = #{group_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<update id="deleteCategoryValueCascadingGroup" parameterType="map">
|
||||
UPDATE CATEGORY_VALUE_CASCADING_GROUP
|
||||
SET
|
||||
IS_ACTIVE = 'N'
|
||||
, UPDATED_BY = #{updated_by}
|
||||
, UPDATED_DATE = NOW()
|
||||
WHERE GROUP_ID = #{group_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<select id="getCategoryValueCascadingMappingsByGroupId" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
MAPPING_ID
|
||||
, PARENT_VALUE_CODE
|
||||
, PARENT_VALUE_LABEL
|
||||
, CHILD_VALUE_CODE
|
||||
, CHILD_VALUE_LABEL
|
||||
, DISPLAY_ORDER
|
||||
, IS_ACTIVE
|
||||
FROM CATEGORY_VALUE_CASCADING_MAPPING
|
||||
WHERE GROUP_ID = #{group_id}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
ORDER BY PARENT_VALUE_CODE, DISPLAY_ORDER, CHILD_VALUE_LABEL
|
||||
</select>
|
||||
|
||||
<delete id="deleteCategoryValueCascadingMappingsByGroupId" parameterType="map">
|
||||
DELETE FROM CATEGORY_VALUE_CASCADING_MAPPING
|
||||
WHERE GROUP_ID = #{group_id}
|
||||
</delete>
|
||||
|
||||
<insert id="insertCategoryValueCascadingMapping" parameterType="map" useGeneratedKeys="true" keyProperty="mappingId">
|
||||
INSERT INTO CATEGORY_VALUE_CASCADING_MAPPING (
|
||||
GROUP_ID
|
||||
, PARENT_VALUE_CODE
|
||||
, PARENT_VALUE_LABEL
|
||||
, CHILD_VALUE_CODE
|
||||
, CHILD_VALUE_LABEL
|
||||
, DISPLAY_ORDER
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_DATE
|
||||
) VALUES (
|
||||
#{group_id}
|
||||
, #{parent_value_code}
|
||||
, #{parent_value_label}
|
||||
, #{child_value_code}
|
||||
, #{child_value_label}
|
||||
, COALESCE(#{display_order}, 0)
|
||||
, #{company_code}
|
||||
, 'Y'
|
||||
, NOW()
|
||||
)
|
||||
</insert>
|
||||
|
||||
</mapper>
|
||||
@@ -1,30 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="codeMerge">
|
||||
|
||||
<!--
|
||||
columnName 컬럼과 company_code 컬럼을 함께 가진 public BASE TABLE 목록 조회
|
||||
테이블명은 information_schema 검증값이므로 동적 SQL 사용 시 안전
|
||||
-->
|
||||
<select id="getTablesWithColumn" parameterType="map" resultType="map">
|
||||
SELECT DISTINCT t.table_name
|
||||
|
||||
FROM information_schema.columns c
|
||||
|
||||
JOIN information_schema.tables t
|
||||
ON c.table_name = t.table_name
|
||||
|
||||
WHERE c.column_name = #{column_name}
|
||||
AND t.table_schema = 'public'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM information_schema.columns c2
|
||||
WHERE c2.table_name = t.table_name
|
||||
AND c2.column_name = 'company_code'
|
||||
)
|
||||
|
||||
ORDER BY t.table_name
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -4,455 +4,436 @@
|
||||
<mapper namespace="commonCode">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- code_category -->
|
||||
<!-- CODE_INFO — 1레벨 그룹 마스터 -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="getCommonCodeCategoryList" parameterType="map" resultType="map">
|
||||
<select id="getCodeInfoList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
category_code,
|
||||
category_name,
|
||||
category_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
CODE_INFO
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, MENU_OBJID
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
|
||||
FROM code_category
|
||||
FROM CODE_INFO
|
||||
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<if test="search != null and search != ''">
|
||||
AND (
|
||||
LOWER(category_code) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(category_name) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(category_name_eng, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
LOWER(CODE_INFO) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(CODE_NAME) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(CODE_NAME_ENG, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
)
|
||||
</if>
|
||||
<if test="is_active != null">
|
||||
AND is_active = #{is_active}
|
||||
</if>
|
||||
<if test="menu_objid != null">
|
||||
AND menu_objid = #{menu_objid}
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
|
||||
ORDER BY sort_order ASC, category_code ASC
|
||||
ORDER BY SORT_ORDER ASC, CODE_INFO ASC
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeCategoryListCnt" parameterType="map" resultType="int">
|
||||
<select id="getCodeInfoListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM code_category
|
||||
FROM CODE_INFO
|
||||
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<if test="search != null and search != ''">
|
||||
AND (
|
||||
LOWER(category_code) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(category_name) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(category_name_eng, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
LOWER(CODE_INFO) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(CODE_NAME) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(CODE_NAME_ENG, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
)
|
||||
</if>
|
||||
<if test="is_active != null">
|
||||
AND is_active = #{is_active}
|
||||
</if>
|
||||
<if test="menu_objid != null">
|
||||
AND menu_objid = #{menu_objid}
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeCategoryInfo" parameterType="map" resultType="map">
|
||||
<select id="getCodeInfoInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
category_code,
|
||||
category_name,
|
||||
category_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
CODE_INFO
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, MENU_OBJID
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
|
||||
FROM code_category
|
||||
FROM CODE_INFO
|
||||
|
||||
WHERE category_code = #{category_code}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<insert id="insertCommonCodeCategory" parameterType="map">
|
||||
INSERT INTO code_category (
|
||||
category_code,
|
||||
category_name,
|
||||
category_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
<insert id="insertCodeInfo" parameterType="map">
|
||||
INSERT INTO CODE_INFO (
|
||||
CODE_INFO
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, MENU_OBJID
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
) VALUES (
|
||||
#{category_code},
|
||||
#{category_name},
|
||||
#{category_name_eng},
|
||||
#{description},
|
||||
#{sort_order},
|
||||
#{is_active},
|
||||
#{menu_objid},
|
||||
#{company_code},
|
||||
#{created_by},
|
||||
#{updated_by},
|
||||
NOW(),
|
||||
NOW()
|
||||
#{code_info}
|
||||
, #{code_name}
|
||||
, #{code_name_eng}
|
||||
, #{description}
|
||||
, #{sort_order}
|
||||
, #{is_active}
|
||||
, #{menu_objid}
|
||||
, #{company_code}
|
||||
, #{created_by}
|
||||
, #{updated_by}
|
||||
, NOW()
|
||||
, NOW()
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCommonCodeCategory" parameterType="map">
|
||||
UPDATE code_category
|
||||
<update id="updateCodeInfo" parameterType="map">
|
||||
UPDATE CODE_INFO
|
||||
<set>
|
||||
<if test="category_name != null">category_name = #{category_name},</if>
|
||||
<if test="category_name_eng != null">category_name_eng = #{category_name_eng},</if>
|
||||
<if test="description != null">description = #{description},</if>
|
||||
<if test="sort_order != null">sort_order = #{sort_order},</if>
|
||||
<if test="is_active != null">is_active = #{is_active},</if>
|
||||
updated_by = #{updated_by},
|
||||
updated_date = NOW()
|
||||
<if test="code_name != null">CODE_NAME = #{code_name},</if>
|
||||
<if test="code_name_eng != null">CODE_NAME_ENG = #{code_name_eng},</if>
|
||||
<if test="description != null">DESCRIPTION = #{description},</if>
|
||||
<if test="sort_order != null">SORT_ORDER = #{sort_order},</if>
|
||||
<if test="is_active != null">IS_ACTIVE = #{is_active},</if>
|
||||
<if test="menu_objid != null">MENU_OBJID = #{menu_objid},</if>
|
||||
UPDATED_BY = #{updated_by},
|
||||
UPDATED_DATE = NOW()
|
||||
</set>
|
||||
|
||||
WHERE category_code = #{category_code}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<delete id="deleteCommonCodeCategory" parameterType="map">
|
||||
DELETE FROM code_category
|
||||
<delete id="deleteCodeInfo" parameterType="map">
|
||||
DELETE FROM CODE_INFO
|
||||
|
||||
WHERE category_code = #{category_code}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</delete>
|
||||
|
||||
<select id="getCommonCodeCategoryDuplicateCnt" parameterType="map" resultType="int">
|
||||
<select id="getCodeInfoDuplicateCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM code_category
|
||||
FROM CODE_INFO
|
||||
|
||||
WHERE category_code = #{category_code}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeCategoryDuplicateByField" parameterType="map" resultType="int">
|
||||
<select id="getCodeInfoDuplicateByField" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM code_category
|
||||
FROM CODE_INFO
|
||||
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<choose>
|
||||
<when test="field == 'categoryCode'">AND category_code = #{value}</when>
|
||||
<when test="field == 'categoryName'">AND category_name = #{value}</when>
|
||||
<when test="field == 'categoryNameEng'">AND category_name_eng = #{value}</when>
|
||||
<otherwise>AND category_code = #{value}</otherwise>
|
||||
<when test='field == "code_info"'>AND CODE_INFO = #{value}</when>
|
||||
<when test='field == "code_name"'>AND CODE_NAME = #{value}</when>
|
||||
<when test='field == "code_name_eng"'>AND CODE_NAME_ENG = #{value}</when>
|
||||
<otherwise>AND CODE_INFO = #{value}</otherwise>
|
||||
</choose>
|
||||
<if test="exclude_code != null and exclude_code != ''">
|
||||
AND category_code != #{exclude_code}
|
||||
AND CODE_INFO != #{exclude_code}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- code_info -->
|
||||
<!-- CODE_DETAIL — 2레벨 ~ 무한대 트리 -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="getCommonCodeList" parameterType="map" resultType="map">
|
||||
<select id="getCodeDetailList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
code_category,
|
||||
code_value,
|
||||
code_name,
|
||||
code_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
parent_code_value,
|
||||
depth,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
CODE_DETAIL_ID
|
||||
, CODE_INFO
|
||||
, PARENT_DETAIL_ID
|
||||
, CODE_VALUE
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, DEPTH
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
|
||||
FROM code_info
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<choose>
|
||||
<when test="parent_detail_id != null">
|
||||
AND PARENT_DETAIL_ID = #{parent_detail_id}
|
||||
</when>
|
||||
<when test="only_roots != null and only_roots == true">
|
||||
AND PARENT_DETAIL_ID IS NULL
|
||||
</when>
|
||||
</choose>
|
||||
<if test="search != null and search != ''">
|
||||
AND (
|
||||
LOWER(code_value) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(code_name) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(code_name_eng, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
LOWER(CODE_VALUE) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(CODE_NAME) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(CODE_NAME_ENG, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
)
|
||||
</if>
|
||||
<if test="is_active != null">
|
||||
AND is_active = #{is_active}
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
|
||||
ORDER BY sort_order ASC, code_value ASC
|
||||
ORDER BY DEPTH ASC, SORT_ORDER ASC, CODE_VALUE ASC
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeListCnt" parameterType="map" resultType="int">
|
||||
<select id="getCodeDetailListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM code_info
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<choose>
|
||||
<when test="parent_detail_id != null">
|
||||
AND PARENT_DETAIL_ID = #{parent_detail_id}
|
||||
</when>
|
||||
<when test="only_roots != null and only_roots == true">
|
||||
AND PARENT_DETAIL_ID IS NULL
|
||||
</when>
|
||||
</choose>
|
||||
<if test="search != null and search != ''">
|
||||
AND (
|
||||
LOWER(code_value) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(code_name) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(code_name_eng, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
LOWER(CODE_VALUE) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(CODE_NAME) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(CODE_NAME_ENG, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
)
|
||||
</if>
|
||||
<if test="is_active != null">
|
||||
AND is_active = #{is_active}
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeInfo" parameterType="map" resultType="map">
|
||||
<select id="getCodeDetailInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
code_category,
|
||||
code_value,
|
||||
code_name,
|
||||
code_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
parent_code_value,
|
||||
depth,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
CODE_DETAIL_ID
|
||||
, CODE_INFO
|
||||
, PARENT_DETAIL_ID
|
||||
, CODE_VALUE
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, DEPTH
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
|
||||
FROM code_info
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND code_value = #{code_value}
|
||||
WHERE CODE_DETAIL_ID = #{code_detail_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<insert id="insertCommonCode" parameterType="map">
|
||||
INSERT INTO code_info (
|
||||
code_category,
|
||||
code_value,
|
||||
code_name,
|
||||
code_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
parent_code_value,
|
||||
depth,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
<!--
|
||||
그룹 전체 트리 — 재귀 CTE 로 평탄화.
|
||||
depth 오름차순 → sort_order 오름차순 → code_value 오름차순.
|
||||
-->
|
||||
<select id="getCodeDetailTree" parameterType="map" resultType="map">
|
||||
WITH RECURSIVE TREE AS (
|
||||
SELECT
|
||||
CODE_DETAIL_ID
|
||||
, CODE_INFO
|
||||
, PARENT_DETAIL_ID
|
||||
, CODE_VALUE
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, DEPTH
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
, ARRAY[SORT_ORDER, CODE_DETAIL_ID] AS PATH
|
||||
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
AND PARENT_DETAIL_ID IS NULL
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
C.CODE_DETAIL_ID
|
||||
, C.CODE_INFO
|
||||
, C.PARENT_DETAIL_ID
|
||||
, C.CODE_VALUE
|
||||
, C.CODE_NAME
|
||||
, C.CODE_NAME_ENG
|
||||
, C.DESCRIPTION
|
||||
, C.DEPTH
|
||||
, C.SORT_ORDER
|
||||
, C.IS_ACTIVE
|
||||
, C.COMPANY_CODE
|
||||
, C.CREATED_BY
|
||||
, C.UPDATED_BY
|
||||
, C.CREATED_DATE
|
||||
, C.UPDATED_DATE
|
||||
, TREE.PATH || ARRAY[C.SORT_ORDER, C.CODE_DETAIL_ID]
|
||||
|
||||
FROM CODE_DETAIL C
|
||||
INNER JOIN TREE ON C.PARENT_DETAIL_ID = TREE.CODE_DETAIL_ID
|
||||
WHERE C.CODE_INFO = #{code_info}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (C.COMPANY_CODE = #{company_code} OR C.COMPANY_CODE = '*')
|
||||
</if>
|
||||
)
|
||||
SELECT
|
||||
CODE_DETAIL_ID
|
||||
, CODE_INFO
|
||||
, PARENT_DETAIL_ID
|
||||
, CODE_VALUE
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, DEPTH
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
|
||||
FROM TREE
|
||||
|
||||
ORDER BY PATH
|
||||
</select>
|
||||
|
||||
<insert id="insertCodeDetail" parameterType="map" useGeneratedKeys="true" keyProperty="code_detail_id" keyColumn="code_detail_id">
|
||||
INSERT INTO CODE_DETAIL (
|
||||
CODE_INFO
|
||||
, PARENT_DETAIL_ID
|
||||
, CODE_VALUE
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, DEPTH
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
) VALUES (
|
||||
#{category_code},
|
||||
#{code_value},
|
||||
#{code_name},
|
||||
#{code_name_eng},
|
||||
#{description},
|
||||
#{sort_order},
|
||||
#{is_active},
|
||||
#{menu_objid},
|
||||
#{company_code},
|
||||
#{parent_code_value},
|
||||
#{depth},
|
||||
#{created_by},
|
||||
#{updated_by},
|
||||
NOW(),
|
||||
NOW()
|
||||
#{code_info}
|
||||
, #{parent_detail_id}
|
||||
, #{code_value}
|
||||
, #{code_name}
|
||||
, #{code_name_eng}
|
||||
, #{description}
|
||||
, #{depth}
|
||||
, #{sort_order}
|
||||
, #{is_active}
|
||||
, #{company_code}
|
||||
, #{created_by}
|
||||
, #{updated_by}
|
||||
, NOW()
|
||||
, NOW()
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCommonCode" parameterType="map">
|
||||
UPDATE code_info
|
||||
<update id="updateCodeDetail" parameterType="map">
|
||||
UPDATE CODE_DETAIL
|
||||
<set>
|
||||
<if test="code_name != null">code_name = #{code_name},</if>
|
||||
<if test="code_name_eng != null">code_name_eng = #{code_name_eng},</if>
|
||||
<if test="description != null">description = #{description},</if>
|
||||
<if test="sort_order != null">sort_order = #{sort_order},</if>
|
||||
<if test="is_active != null">is_active = #{is_active},</if>
|
||||
<if test="parent_code_value != null">parent_code_value = #{parent_code_value},</if>
|
||||
<if test="depth != null">depth = #{depth},</if>
|
||||
updated_by = #{updated_by},
|
||||
updated_date = NOW()
|
||||
<if test="code_value != null">CODE_VALUE = #{code_value},</if>
|
||||
<if test="code_name != null">CODE_NAME = #{code_name},</if>
|
||||
<if test="code_name_eng != null">CODE_NAME_ENG = #{code_name_eng},</if>
|
||||
<if test="description != null">DESCRIPTION = #{description},</if>
|
||||
<if test="sort_order != null">SORT_ORDER = #{sort_order},</if>
|
||||
<if test="is_active != null">IS_ACTIVE = #{is_active},</if>
|
||||
<if test="reparent != null and reparent == true">
|
||||
PARENT_DETAIL_ID = #{parent_detail_id},
|
||||
DEPTH = #{depth},
|
||||
</if>
|
||||
UPDATED_BY = #{updated_by},
|
||||
UPDATED_DATE = NOW()
|
||||
</set>
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND code_value = #{code_value}
|
||||
WHERE CODE_DETAIL_ID = #{code_detail_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<delete id="deleteCommonCode" parameterType="map">
|
||||
DELETE FROM code_info
|
||||
<delete id="deleteCodeDetail" parameterType="map">
|
||||
DELETE FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND code_value = #{code_value}
|
||||
WHERE CODE_DETAIL_ID = #{code_detail_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</delete>
|
||||
|
||||
<update id="updateCommonCodeSortOrder" parameterType="map">
|
||||
UPDATE code_info
|
||||
SET sort_order = #{sort_order},
|
||||
updated_date = NOW()
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND code_value = #{code_value}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<select id="getCommonCodeDuplicateCnt" parameterType="map" resultType="int">
|
||||
<select id="getCodeDetailDuplicateCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM code_info
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND code_value = #{code_value}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
AND CODE_VALUE = #{code_value}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<if test="exclude_id != null">
|
||||
AND CODE_DETAIL_ID != #{exclude_id}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeDuplicateByField" parameterType="map" resultType="int">
|
||||
<select id="getCodeDetailChildrenCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM code_info
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<choose>
|
||||
<when test="field == 'codeValue'">AND code_value = #{value}</when>
|
||||
<when test="field == 'codeName'">AND code_name = #{value}</when>
|
||||
<when test="field == 'codeNameEng'">AND code_name_eng = #{value}</when>
|
||||
<otherwise>AND code_value = #{value}</otherwise>
|
||||
</choose>
|
||||
<if test="exclude_code != null and exclude_code != ''">
|
||||
AND code_value != #{exclude_code}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeParentDepth" parameterType="map" resultType="int">
|
||||
SELECT COALESCE(depth, 0)
|
||||
|
||||
FROM code_info
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND code_value = #{code_value}
|
||||
WHERE PARENT_DETAIL_ID = #{code_detail_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeChildrenCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
<select id="getCodeDetailParentDepth" parameterType="map" resultType="int">
|
||||
SELECT COALESCE(DEPTH, 1)
|
||||
|
||||
FROM code_info
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND parent_code_value = #{code_value}
|
||||
WHERE CODE_DETAIL_ID = #{code_detail_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- 계층 / 트리 / 옵션 -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="getCommonCodeHierarchicalList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
code_category,
|
||||
code_value,
|
||||
code_name,
|
||||
code_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
parent_code_value,
|
||||
depth,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
|
||||
FROM code_info
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<if test="is_active != null">
|
||||
AND is_active = #{is_active}
|
||||
</if>
|
||||
<if test="parent_code_value != null">
|
||||
AND parent_code_value = #{parent_code_value}
|
||||
</if>
|
||||
<if test="depth != null">
|
||||
AND depth = #{depth}
|
||||
</if>
|
||||
|
||||
ORDER BY depth ASC, sort_order ASC, code_value ASC
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeTreeList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
code_category,
|
||||
code_value,
|
||||
code_name,
|
||||
code_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
parent_code_value,
|
||||
depth,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
|
||||
FROM code_info
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
|
||||
ORDER BY depth ASC, sort_order ASC, code_value ASC
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeOptionList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
code_value,
|
||||
code_name,
|
||||
code_name_eng
|
||||
|
||||
FROM code_info
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<if test="is_active != null">
|
||||
AND is_active = #{is_active}
|
||||
</if>
|
||||
|
||||
ORDER BY sort_order ASC, code_value ASC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -23,13 +23,23 @@
|
||||
D.SORT_ORDER,
|
||||
D.STATUS,
|
||||
D.DELETED_AT,
|
||||
COUNT(DISTINCT UD.USER_ID) AS MEMBER_COUNT
|
||||
COUNT(DISTINCT UD.USER_ID) AS MEMBER_COUNT,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
|
||||
FROM DEPT_INFO D
|
||||
LEFT JOIN USER_DEPT UD ON D.DEPT_CODE = UD.DEPT_CODE
|
||||
WHERE (D.COMPANY_CODE = #{company_code} OR D.COMPANY_CODE = '*')
|
||||
<if test="include_deleted == null or include_deleted == false">
|
||||
AND D.DELETED_AT IS NULL
|
||||
</if>
|
||||
<if test="base_date != null and base_date != ''">
|
||||
AND (D.START_DATE IS NULL OR D.START_DATE <= #{base_date}::date)
|
||||
AND (D.END_DATE IS NULL OR D.END_DATE >= #{base_date}::date)
|
||||
</if>
|
||||
GROUP BY
|
||||
D.DEPT_CODE, D.DEPT_NAME, D.COMPANY_CODE, D.PARENT_DEPT_CODE,
|
||||
D.SHORT_NAME, D.DEPT_TYPE, D.ORG_SYSTEM, D.APPROVAL_MANAGER, D.DEPT_MANAGER,
|
||||
@@ -57,7 +67,13 @@
|
||||
END_DATE,
|
||||
SORT_ORDER,
|
||||
STATUS,
|
||||
DELETED_AT
|
||||
DELETED_AT,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
|
||||
FROM DEPT_INFO
|
||||
WHERE DEPT_CODE = #{dept_code}
|
||||
AND DELETED_AT IS NULL
|
||||
@@ -82,7 +98,13 @@
|
||||
END_DATE,
|
||||
SORT_ORDER,
|
||||
STATUS,
|
||||
DELETED_AT
|
||||
DELETED_AT,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
|
||||
FROM DEPT_INFO
|
||||
WHERE DEPT_CODE = #{dept_code}
|
||||
</select>
|
||||
@@ -302,4 +324,27 @@
|
||||
AND DEPT_CODE = #{dept_code}
|
||||
</update>
|
||||
|
||||
<!-- 부서별 관리자 매핑 (role 단위 sync 용) — 전체 삭제 -->
|
||||
<delete id="deleteDeptManagersByDeptAndRole" parameterType="map">
|
||||
DELETE FROM DEPT_MANAGERS
|
||||
WHERE DEPT_CODE = #{dept_code}
|
||||
AND ROLE = #{role}
|
||||
</delete>
|
||||
|
||||
<!-- 부서별 관리자 매핑 — bulk insert. parameterType=map, list 와 role 전달. -->
|
||||
<insert id="insertDeptManagers" parameterType="map">
|
||||
INSERT INTO DEPT_MANAGERS (DEPT_CODE, USER_ID, ROLE, SORT_ORDER) VALUES
|
||||
<foreach collection="user_ids" item="uid" index="idx" separator=",">
|
||||
(#{dept_code}, #{uid}, #{role}, #{idx} + 1)
|
||||
</foreach>
|
||||
</insert>
|
||||
|
||||
<!-- 사용자 ID 들이 같은 회사(또는 글로벌 *) 에 실존하는지 검증 — cross-tenant injection 방지 -->
|
||||
<select id="selectValidUserIds" parameterType="map" resultType="string">
|
||||
SELECT USER_ID FROM USER_INFO
|
||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
AND USER_ID IN
|
||||
<foreach collection="user_ids" item="u" open="(" separator="," close=")">#{u}</foreach>
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
|
||||
FROM code_info
|
||||
|
||||
WHERE code_category = #{code_category}
|
||||
WHERE code_info = #{code_info}
|
||||
AND is_active = 'Y'
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (company_code = #{company_code} OR company_code = '*')
|
||||
|
||||
@@ -38,17 +38,17 @@
|
||||
</select>
|
||||
|
||||
<!-- ================================================================
|
||||
정적 쿼리: table_type_columns의 code_category 조회
|
||||
정적 쿼리: table_type_columns의 code_info 조회
|
||||
================================================================ -->
|
||||
|
||||
<select id="getCodeCategoryInfo" parameterType="map" resultType="map">
|
||||
SELECT code_category
|
||||
SELECT code_info
|
||||
|
||||
FROM table_type_columns
|
||||
|
||||
WHERE table_name = #{table_name}
|
||||
AND column_name = #{column_name}
|
||||
AND code_category IS NOT NULL
|
||||
AND code_info IS NOT NULL
|
||||
|
||||
LIMIT 1
|
||||
</select>
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||
FROM code_info
|
||||
|
||||
WHERE code_category = #{code_category}
|
||||
WHERE code_info = #{code_info}
|
||||
AND code_value IN
|
||||
<foreach collection="rawValues" item="v" open="(" separator="," close=")">
|
||||
#{v}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
, REFERENCE_TABLE
|
||||
, REFERENCE_COLUMN
|
||||
, DISPLAY_COLUMN
|
||||
, CODE_CATEGORY
|
||||
, CODE_INFO
|
||||
, CODE_VALUE
|
||||
, COMPANY_CODE
|
||||
FROM TABLE_TYPE_COLUMNS
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
category_column AS category_column,
|
||||
category_value_id AS category_value_id,
|
||||
created_by AS created_by,
|
||||
CREATED_DATE AS CREATED_DATE,
|
||||
UPDATED_DATE AS UPDATED_DATE
|
||||
created_at AS created_at,
|
||||
updated_at AS updated_at
|
||||
</sql>
|
||||
|
||||
<sql id="partColumns">
|
||||
@@ -42,7 +42,7 @@
|
||||
<otherwise>AND (company_code = #{company_code} OR company_code = '*')</otherwise>
|
||||
</choose>
|
||||
|
||||
ORDER BY CREATED_DATE DESC
|
||||
ORDER BY created_at DESC
|
||||
</select>
|
||||
|
||||
<select id="getRuleById" parameterType="map" resultType="map">
|
||||
@@ -61,19 +61,19 @@
|
||||
INSERT INTO numbering_rules (
|
||||
rule_id, rule_name, description, separator, reset_period,
|
||||
current_sequence, table_name, column_name, company_code,
|
||||
category_column, category_value_id, created_by, CREATED_DATE, UPDATED_DATE
|
||||
category_column, category_value_id, created_by, created_at, updated_at
|
||||
) VALUES (
|
||||
#{rule_id},
|
||||
#{rule_name},
|
||||
#{description, jdbcType=VARCHAR},
|
||||
#{separator, jdbcType=VARCHAR},
|
||||
#{reset_period, jdbcType=VARCHAR},
|
||||
#{current_sequence, jdbcType=INTEGER},
|
||||
#{current_sequence, jdbcType=VARCHAR},
|
||||
#{table_name, jdbcType=VARCHAR},
|
||||
#{column_name, jdbcType=VARCHAR},
|
||||
#{company_code},
|
||||
#{category_column, jdbcType=VARCHAR},
|
||||
#{category_value_id, jdbcType=INTEGER},
|
||||
#{category_value_id, jdbcType=VARCHAR},
|
||||
#{created_by, jdbcType=VARCHAR},
|
||||
NOW(), NOW()
|
||||
)
|
||||
@@ -89,8 +89,8 @@
|
||||
table_name = COALESCE(#{table_name, jdbcType=VARCHAR}, table_name),
|
||||
column_name = COALESCE(#{column_name, jdbcType=VARCHAR}, column_name),
|
||||
category_column = COALESCE(#{category_column, jdbcType=VARCHAR}, category_column),
|
||||
category_value_id = COALESCE(#{category_value_id, jdbcType=INTEGER}, category_value_id),
|
||||
UPDATED_DATE = NOW()
|
||||
category_value_id = COALESCE(#{category_value_id, jdbcType=VARCHAR}, category_value_id),
|
||||
updated_at = NOW()
|
||||
|
||||
WHERE rule_id = #{rule_id}
|
||||
AND (company_code = #{company_code} OR company_code = '*')
|
||||
@@ -122,7 +122,7 @@
|
||||
<insert id="insertRulePart" parameterType="map">
|
||||
INSERT INTO numbering_rule_parts (
|
||||
rule_id, part_order, part_type, generation_method,
|
||||
auto_config, manual_config, company_code, CREATED_DATE
|
||||
auto_config, manual_config, company_code, created_at
|
||||
) VALUES (
|
||||
#{rule_id},
|
||||
#{order},
|
||||
@@ -164,7 +164,17 @@
|
||||
<update id="updateCurrentSequenceInRule" parameterType="map">
|
||||
UPDATE numbering_rules
|
||||
SET current_sequence = GREATEST(COALESCE(current_sequence, '0'), #{current_sequence}),
|
||||
UPDATED_DATE = NOW()
|
||||
updated_at = NOW()
|
||||
|
||||
WHERE rule_id = #{rule_id}
|
||||
AND (company_code = #{company_code} OR company_code = '*')
|
||||
</update>
|
||||
|
||||
<!-- admin 전용: GREATEST 없이 직접 SET. 임의 값 (0 포함) 으로 내릴 수 있음 -->
|
||||
<update id="setCurrentSequenceInRule" parameterType="map">
|
||||
UPDATE numbering_rules
|
||||
SET current_sequence = #{current_sequence},
|
||||
updated_at = NOW()
|
||||
|
||||
WHERE rule_id = #{rule_id}
|
||||
AND (company_code = #{company_code} OR company_code = '*')
|
||||
@@ -183,7 +193,7 @@
|
||||
<otherwise>AND (company_code = #{company_code} OR company_code = '*')</otherwise>
|
||||
</choose>
|
||||
|
||||
ORDER BY CREATED_DATE DESC
|
||||
ORDER BY created_at DESC
|
||||
</select>
|
||||
|
||||
<select id="getAvailableRulesForScreen" parameterType="map" resultType="map">
|
||||
@@ -200,7 +210,7 @@
|
||||
AND table_name = #{table_name}
|
||||
</if>
|
||||
|
||||
ORDER BY CREATED_DATE DESC
|
||||
ORDER BY created_at DESC
|
||||
</select>
|
||||
|
||||
<select id="getRuleByColumn" parameterType="map" resultType="map">
|
||||
@@ -218,8 +228,8 @@
|
||||
r.category_value_id AS category_value_id,
|
||||
cv.value_label AS category_value_label,
|
||||
r.created_by AS created_by,
|
||||
r.CREATED_DATE AS CREATED_DATE,
|
||||
r.UPDATED_DATE AS UPDATED_DATE
|
||||
r.created_at AS created_at,
|
||||
r.updated_at AS updated_at
|
||||
|
||||
FROM numbering_rules r
|
||||
|
||||
@@ -247,8 +257,8 @@
|
||||
r.category_value_id AS category_value_id,
|
||||
cv.value_label AS category_value_label,
|
||||
r.created_by AS created_by,
|
||||
r.CREATED_DATE AS CREATED_DATE,
|
||||
r.UPDATED_DATE AS UPDATED_DATE
|
||||
r.created_at AS created_at,
|
||||
r.updated_at AS updated_at
|
||||
|
||||
FROM numbering_rules r
|
||||
|
||||
@@ -259,7 +269,7 @@
|
||||
AND (r.column_name IS NULL OR r.column_name = '')
|
||||
AND r.category_value_id IS NULL
|
||||
|
||||
ORDER BY r.UPDATED_DATE DESC
|
||||
ORDER BY r.updated_at DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
@@ -280,7 +290,7 @@
|
||||
|
||||
WHERE (company_code = #{company_code} OR company_code = '*')
|
||||
|
||||
ORDER BY CREATED_DATE
|
||||
ORDER BY created_at
|
||||
</select>
|
||||
|
||||
<select id="getRulePartsForCopy" parameterType="map" resultType="map">
|
||||
|
||||
@@ -1091,12 +1091,12 @@
|
||||
<select id="selectCodeCategoryForCopy" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
*
|
||||
FROM CODE_CATEGORY
|
||||
FROM CODE_INFO
|
||||
WHERE (COMPANY_CODE = #{source_company_code} OR COMPANY_CODE = '*')
|
||||
</select>
|
||||
|
||||
<insert id="upsertCodeCategory" parameterType="map">
|
||||
INSERT INTO CODE_CATEGORY (
|
||||
INSERT INTO CODE_INFO (
|
||||
CATEGORY_CODE
|
||||
, CATEGORY_NAME
|
||||
, COMPANY_CODE
|
||||
@@ -1117,26 +1117,26 @@
|
||||
*
|
||||
FROM CODE_INFO
|
||||
WHERE (COMPANY_CODE = #{source_company_code} OR COMPANY_CODE = '*')
|
||||
AND CODE_CATEGORY = #{code_category}
|
||||
AND CODE_INFO = #{code_info}
|
||||
</select>
|
||||
|
||||
<insert id="upsertCodeInfo" parameterType="map">
|
||||
INSERT INTO CODE_INFO (
|
||||
CODE_CATEGORY
|
||||
CODE_INFO
|
||||
, CODE_VALUE
|
||||
, CODE_NAME
|
||||
, COMPANY_CODE
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
) VALUES (
|
||||
#{code_category}
|
||||
#{code_info}
|
||||
, #{code_value}
|
||||
, #{code_name}
|
||||
, #{target_company_code}
|
||||
, #{sort_order}
|
||||
, #{is_active}
|
||||
)
|
||||
ON CONFLICT (CODE_CATEGORY, CODE_VALUE, COMPANY_CODE) DO UPDATE SET
|
||||
ON CONFLICT (CODE_INFO, CODE_VALUE, COMPANY_CODE) DO UPDATE SET
|
||||
CODE_NAME = EXCLUDED.CODE_NAME
|
||||
, SORT_ORDER = EXCLUDED.SORT_ORDER
|
||||
, IS_ACTIVE = EXCLUDED.IS_ACTIVE
|
||||
@@ -1359,7 +1359,7 @@
|
||||
COLUMN_NAME
|
||||
, INPUT_TYPE
|
||||
, COLUMN_LABEL
|
||||
, CODE_CATEGORY
|
||||
, CODE_INFO
|
||||
, REFERENCE_TABLE
|
||||
, REFERENCE_COLUMN
|
||||
, DISPLAY_COLUMN
|
||||
|
||||
@@ -1,470 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="tableCategoryValue">
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
Category Columns
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="getCategoryColumnList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
TC.TABLE_NAME
|
||||
, TC.COLUMN_NAME
|
||||
, TC.COLUMN_NAME AS column_label
|
||||
, COUNT(CV.VALUE_ID) AS value_count
|
||||
|
||||
FROM TABLE_TYPE_COLUMNS TC
|
||||
|
||||
LEFT JOIN CATEGORY_VALUES CV
|
||||
ON TC.TABLE_NAME = CV.TABLE_NAME
|
||||
AND TC.COLUMN_NAME = CV.COLUMN_NAME
|
||||
AND CV.IS_ACTIVE = TRUE
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (CV.COMPANY_CODE = #{company_code} OR CV.COMPANY_CODE = '*')
|
||||
</if>
|
||||
|
||||
WHERE TC.TABLE_NAME = #{table_name}
|
||||
AND TC.INPUT_TYPE = 'category'
|
||||
|
||||
GROUP BY TC.TABLE_NAME, TC.COLUMN_NAME, TC.DISPLAY_ORDER
|
||||
|
||||
ORDER BY TC.DISPLAY_ORDER, TC.COLUMN_NAME
|
||||
</select>
|
||||
|
||||
<select id="getAllCategoryColumnList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
TC.TABLE_NAME
|
||||
, TC.COLUMN_NAME
|
||||
, TC.COLUMN_NAME AS column_label
|
||||
, COALESCE(CV_COUNT.cnt, 0) AS value_count
|
||||
|
||||
FROM (
|
||||
SELECT DISTINCT TABLE_NAME, COLUMN_NAME, MIN(DISPLAY_ORDER) AS display_order
|
||||
FROM TABLE_TYPE_COLUMNS
|
||||
WHERE INPUT_TYPE = 'category'
|
||||
GROUP BY TABLE_NAME, COLUMN_NAME
|
||||
) TC
|
||||
|
||||
LEFT JOIN (
|
||||
SELECT TABLE_NAME, COLUMN_NAME, COUNT(*) AS cnt
|
||||
FROM CATEGORY_VALUES
|
||||
WHERE IS_ACTIVE = TRUE
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
GROUP BY TABLE_NAME, COLUMN_NAME
|
||||
) CV_COUNT
|
||||
ON TC.TABLE_NAME = CV_COUNT.TABLE_NAME
|
||||
AND TC.COLUMN_NAME = CV_COUNT.COLUMN_NAME
|
||||
|
||||
ORDER BY TC.TABLE_NAME, TC.DISPLAY_ORDER, TC.COLUMN_NAME
|
||||
</select>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
Category Values — Read
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="getCategoryValueList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
VALUE_ID
|
||||
, TABLE_NAME
|
||||
, COLUMN_NAME
|
||||
, VALUE_CODE
|
||||
, VALUE_LABEL
|
||||
, VALUE_ORDER
|
||||
, PARENT_VALUE_ID
|
||||
, DEPTH
|
||||
, DESCRIPTION
|
||||
, COLOR
|
||||
, ICON
|
||||
, IS_ACTIVE
|
||||
, IS_DEFAULT
|
||||
, COMPANY_CODE
|
||||
, MENU_OBJID
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
|
||||
FROM CATEGORY_VALUES
|
||||
|
||||
WHERE TABLE_NAME = #{table_name}
|
||||
AND COLUMN_NAME = #{column_name}
|
||||
<choose>
|
||||
<when test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</when>
|
||||
<otherwise>
|
||||
AND COMPANY_CODE = '*'
|
||||
</otherwise>
|
||||
</choose>
|
||||
<if test="include_inactive == null or include_inactive == false">
|
||||
AND IS_ACTIVE = TRUE
|
||||
</if>
|
||||
|
||||
ORDER BY VALUE_ORDER, VALUE_LABEL
|
||||
</select>
|
||||
|
||||
<select id="getCategoryValueInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
VALUE_ID
|
||||
, TABLE_NAME
|
||||
, COLUMN_NAME
|
||||
, VALUE_CODE
|
||||
, VALUE_LABEL
|
||||
, VALUE_ORDER
|
||||
, PARENT_VALUE_ID
|
||||
, DEPTH
|
||||
, DESCRIPTION
|
||||
, COLOR
|
||||
, ICON
|
||||
, IS_ACTIVE
|
||||
, IS_DEFAULT
|
||||
, COMPANY_CODE
|
||||
, MENU_OBJID
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
|
||||
FROM CATEGORY_VALUES
|
||||
|
||||
WHERE VALUE_ID = #{value_id}
|
||||
</select>
|
||||
|
||||
<!-- 사용 여부 확인용: table_name, column_name, value_code, value_label 반환 -->
|
||||
<select id="getCategoryValueUsageInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
TABLE_NAME
|
||||
, COLUMN_NAME
|
||||
, VALUE_CODE
|
||||
, VALUE_LABEL
|
||||
|
||||
FROM CATEGORY_VALUES
|
||||
|
||||
WHERE VALUE_ID = #{value_id}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!-- 수정 시 라벨 중복 체크용: table_name, column_name, company_code 반환 -->
|
||||
<select id="getCategoryValueLabelInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
TABLE_NAME
|
||||
, COLUMN_NAME
|
||||
, COMPANY_CODE
|
||||
|
||||
FROM CATEGORY_VALUES
|
||||
|
||||
WHERE VALUE_ID = #{value_id}
|
||||
</select>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
Category Values — Write
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="countDuplicateCode" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*) FROM CATEGORY_VALUES
|
||||
WHERE TABLE_NAME = #{table_name}
|
||||
AND COLUMN_NAME = #{column_name}
|
||||
AND VALUE_CODE = #{value_code}
|
||||
AND MENU_OBJID = #{menu_objid}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="countDuplicateLabel" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*) FROM CATEGORY_VALUES
|
||||
WHERE TABLE_NAME = #{table_name}
|
||||
AND COLUMN_NAME = #{column_name}
|
||||
AND VALUE_LABEL = #{value_label}
|
||||
AND IS_ACTIVE = TRUE
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!-- 수정 시 자기 자신 제외 라벨 중복 체크 (항상 company_code 필터) -->
|
||||
<select id="countDuplicateLabelExcludeSelf" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*) FROM CATEGORY_VALUES
|
||||
WHERE TABLE_NAME = #{table_name}
|
||||
AND COLUMN_NAME = #{column_name}
|
||||
AND VALUE_LABEL = #{value_label}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
AND IS_ACTIVE = TRUE
|
||||
AND VALUE_ID != #{value_id}
|
||||
</select>
|
||||
|
||||
<insert id="insertCategoryValue" parameterType="map"
|
||||
useGeneratedKeys="true" keyProperty="valueId" keyColumn="value_id">
|
||||
INSERT INTO CATEGORY_VALUES (
|
||||
TABLE_NAME, COLUMN_NAME, VALUE_CODE, VALUE_LABEL, VALUE_ORDER,
|
||||
PARENT_VALUE_ID, DEPTH, DESCRIPTION, COLOR, ICON,
|
||||
IS_ACTIVE, IS_DEFAULT, COMPANY_CODE, MENU_OBJID, CREATED_BY
|
||||
) VALUES (
|
||||
#{table_name}, #{column_name}, #{value_code}, #{value_label}, #{value_order},
|
||||
#{parent_value_id}, #{depth}, #{description}, #{color}, #{icon},
|
||||
#{is_active}, #{is_default}, #{company_code}, #{menu_objid}, #{user_id}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCategoryValue" parameterType="map">
|
||||
UPDATE CATEGORY_VALUES
|
||||
<set>
|
||||
<if test="value_label != null">VALUE_LABEL = #{value_label},</if>
|
||||
<if test="value_order != null">VALUE_ORDER = #{value_order},</if>
|
||||
<if test="description != null">DESCRIPTION = #{description},</if>
|
||||
<if test="color != null">COLOR = #{color},</if>
|
||||
<if test="icon != null">ICON = #{icon},</if>
|
||||
<if test="is_active != null">IS_ACTIVE = #{is_active},</if>
|
||||
<if test="is_default != null">IS_DEFAULT = #{is_default},</if>
|
||||
UPDATED_DATE = NOW(),
|
||||
UPDATED_BY = #{user_id}
|
||||
</set>
|
||||
WHERE VALUE_ID = #{value_id}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</update>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
Category Values — Delete
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="checkTableExistsForUsage" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = #{table_name}
|
||||
</select>
|
||||
|
||||
<!--
|
||||
동적 테이블 쿼리: safeTableName, safeColumnName 은 서비스에서
|
||||
[a-zA-Z0-9_] 로 sanitize 후 전달. ${} 는 리터럴 치환.
|
||||
-->
|
||||
<select id="countValueUsageInTable" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM ${safeTableName}
|
||||
|
||||
WHERE ${safeColumnName} = #{value_code}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getMenuListUsingTable" parameterType="map" resultType="map">
|
||||
SELECT DISTINCT
|
||||
MI.OBJID AS menu_objid
|
||||
, MI.MENU_NAME_KOR AS menu_name
|
||||
, MI.MENU_URL
|
||||
|
||||
FROM MENU_INFO MI
|
||||
|
||||
INNER JOIN SCREEN_MENU_ASSIGNMENTS SMA ON SMA.MENU_OBJID = MI.OBJID
|
||||
INNER JOIN SCREEN_DEFINITIONS SD ON SD.SCREEN_ID = SMA.SCREEN_ID
|
||||
|
||||
WHERE SD.TABLE_NAME = #{table_name}
|
||||
AND (MI.COMPANY_CODE = #{company_code} OR MI.COMPANY_CODE = '*')
|
||||
|
||||
ORDER BY MI.MENU_NAME_KOR
|
||||
</select>
|
||||
|
||||
<!-- 재귀 CTE 로 모든 하위 value_id 수집 -->
|
||||
<select id="getChildValueIdList" parameterType="map" resultType="map">
|
||||
WITH RECURSIVE category_tree AS (
|
||||
SELECT VALUE_ID
|
||||
FROM CATEGORY_VALUES
|
||||
WHERE PARENT_VALUE_ID = #{value_id}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
UNION ALL
|
||||
SELECT CV.VALUE_ID
|
||||
FROM CATEGORY_VALUES CV
|
||||
INNER JOIN category_tree CT ON CV.PARENT_VALUE_ID = CT.VALUE_ID
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
WHERE (CV.COMPANY_CODE = #{company_code} OR CV.COMPANY_CODE = '*')
|
||||
</if>
|
||||
)
|
||||
SELECT VALUE_ID FROM category_tree
|
||||
</select>
|
||||
|
||||
<delete id="deleteValueById" parameterType="map">
|
||||
DELETE FROM CATEGORY_VALUES
|
||||
WHERE VALUE_ID = #{value_id}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</delete>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
Bulk / Reorder
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<update id="bulkSoftDeleteValues" parameterType="map">
|
||||
UPDATE CATEGORY_VALUES
|
||||
SET IS_ACTIVE = FALSE,
|
||||
UPDATED_DATE = NOW(),
|
||||
UPDATED_BY = #{user_id}
|
||||
WHERE VALUE_ID IN
|
||||
<foreach collection="valueIds" item="id" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</update>
|
||||
|
||||
<update id="updateValueOrder" parameterType="map">
|
||||
UPDATE CATEGORY_VALUES
|
||||
SET VALUE_ORDER = #{value_order},
|
||||
UPDATED_DATE = NOW()
|
||||
WHERE VALUE_ID = #{value_id}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</update>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
Column Mapping
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="getColumnMappingList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
LOGICAL_COLUMN_NAME
|
||||
, PHYSICAL_COLUMN_NAME
|
||||
|
||||
FROM CATEGORY_COLUMN_MAPPING
|
||||
|
||||
WHERE TABLE_NAME = #{table_name}
|
||||
AND MENU_OBJID = #{menu_objid}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getLogicalColumnList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
MAPPING_ID
|
||||
, LOGICAL_COLUMN_NAME
|
||||
, PHYSICAL_COLUMN_NAME
|
||||
, DESCRIPTION
|
||||
|
||||
FROM CATEGORY_COLUMN_MAPPING
|
||||
|
||||
WHERE TABLE_NAME = #{table_name}
|
||||
AND MENU_OBJID = #{menu_objid}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
|
||||
ORDER BY LOGICAL_COLUMN_NAME
|
||||
</select>
|
||||
|
||||
<select id="checkPhysicalColumnExists" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = #{table_name}
|
||||
AND column_name = #{physical_column_name}
|
||||
</select>
|
||||
|
||||
<!-- UPSERT: ON CONFLICT (table_name, logical_column_name, menu_objid, company_code) -->
|
||||
<insert id="upsertColumnMapping" parameterType="map">
|
||||
INSERT INTO CATEGORY_COLUMN_MAPPING (
|
||||
TABLE_NAME, LOGICAL_COLUMN_NAME, PHYSICAL_COLUMN_NAME,
|
||||
MENU_OBJID, COMPANY_CODE, DESCRIPTION, CREATED_BY, UPDATED_BY
|
||||
) VALUES (
|
||||
#{table_name}, #{logical_column_name}, #{physical_column_name},
|
||||
#{menu_objid}, #{company_code}, #{description}, #{user_id}, #{user_id}
|
||||
)
|
||||
ON CONFLICT (table_name, logical_column_name, menu_objid, company_code)
|
||||
DO UPDATE SET
|
||||
PHYSICAL_COLUMN_NAME = EXCLUDED.PHYSICAL_COLUMN_NAME,
|
||||
DESCRIPTION = EXCLUDED.DESCRIPTION,
|
||||
UPDATED_DATE = NOW(),
|
||||
UPDATED_BY = EXCLUDED.UPDATED_BY
|
||||
</insert>
|
||||
|
||||
<select id="getColumnMappingInfo" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
|
||||
FROM CATEGORY_COLUMN_MAPPING
|
||||
|
||||
WHERE TABLE_NAME = #{table_name}
|
||||
AND LOGICAL_COLUMN_NAME = #{logical_column_name}
|
||||
AND MENU_OBJID = #{menu_objid}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</select>
|
||||
|
||||
<delete id="deleteColumnMappingById" parameterType="map">
|
||||
DELETE FROM CATEGORY_COLUMN_MAPPING
|
||||
WHERE MAPPING_ID = #{mapping_id}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</delete>
|
||||
|
||||
<delete id="deleteColumnMappingsByColumn" parameterType="map">
|
||||
DELETE FROM CATEGORY_COLUMN_MAPPING
|
||||
WHERE TABLE_NAME = #{table_name}
|
||||
AND LOGICAL_COLUMN_NAME = #{column_name}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</delete>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
Labels by Codes
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="getLabelListByCodes" parameterType="map" resultType="map">
|
||||
SELECT DISTINCT
|
||||
VALUE_CODE
|
||||
, VALUE_LABEL
|
||||
|
||||
FROM CATEGORY_VALUES
|
||||
|
||||
WHERE VALUE_CODE IN
|
||||
<foreach collection="valueCodes" item="code" open="(" separator="," close=")">
|
||||
#{code}
|
||||
</foreach>
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
Second-Level Menus
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="checkMenuInfoHasCompanyCode" resultType="int">
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_name = 'menu_info'
|
||||
AND column_name = 'company_code'
|
||||
</select>
|
||||
|
||||
<select id="getSecondLevelMenuList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
M1.OBJID AS menu_objid
|
||||
, M1.MENU_NAME_KOR AS menu_name
|
||||
, M0.MENU_NAME_KOR AS parent_menu_name
|
||||
, M1.SCREEN_CODE AS screen_code
|
||||
|
||||
FROM MENU_INFO M1
|
||||
|
||||
INNER JOIN MENU_INFO M0 ON M1.PARENT_OBJ_ID = M0.OBJID
|
||||
|
||||
WHERE M1.MENU_TYPE = '1'
|
||||
AND M1.STATUS = 'active'
|
||||
AND M0.PARENT_OBJ_ID = '0'
|
||||
<if test='has_company_code and company_code != null and company_code != "*"'>
|
||||
AND (M1.COMPANY_CODE = #{company_code} OR M1.COMPANY_CODE = '*')
|
||||
</if>
|
||||
|
||||
ORDER BY M0.SEQ, M1.SEQ
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -57,7 +57,7 @@
|
||||
, C.CHARACTER_MAXIMUM_LENGTH AS MAX_LENGTH
|
||||
, C.NUMERIC_PRECISION
|
||||
, C.NUMERIC_SCALE
|
||||
, CL.CODE_CATEGORY
|
||||
, CL.CODE_INFO
|
||||
, CL.CODE_VALUE
|
||||
, CL.REFERENCE_TABLE
|
||||
, CL.REFERENCE_COLUMN
|
||||
@@ -110,7 +110,7 @@
|
||||
, C.CHARACTER_MAXIMUM_LENGTH AS MAX_LENGTH
|
||||
, C.NUMERIC_PRECISION
|
||||
, C.NUMERIC_SCALE
|
||||
, COALESCE(TTC.CODE_CATEGORY, CL.CODE_CATEGORY) AS CODE_CATEGORY
|
||||
, COALESCE(TTC.CODE_INFO, CL.CODE_INFO) AS CODE_INFO
|
||||
, COALESCE(TTC.CODE_VALUE, CL.CODE_VALUE) AS CODE_VALUE
|
||||
, COALESCE(TTC.REFERENCE_TABLE, CL.REFERENCE_TABLE) AS REFERENCE_TABLE
|
||||
, COALESCE(TTC.REFERENCE_COLUMN, CL.REFERENCE_COLUMN) AS REFERENCE_COLUMN
|
||||
@@ -253,7 +253,7 @@
|
||||
, DESCRIPTION
|
||||
, DISPLAY_ORDER
|
||||
, IS_VISIBLE
|
||||
, CODE_CATEGORY
|
||||
, CODE_INFO
|
||||
, CODE_VALUE
|
||||
, REFERENCE_TABLE
|
||||
, REFERENCE_COLUMN
|
||||
@@ -275,7 +275,7 @@
|
||||
, COLUMN_LABEL
|
||||
, INPUT_TYPE
|
||||
, DETAIL_SETTINGS
|
||||
, CODE_CATEGORY
|
||||
, CODE_INFO
|
||||
, CODE_VALUE
|
||||
, REFERENCE_TABLE
|
||||
, REFERENCE_COLUMN
|
||||
@@ -293,7 +293,7 @@
|
||||
, #{column_label}
|
||||
, #{input_type}
|
||||
, #{detail_settings}::JSONB
|
||||
, #{code_category}
|
||||
, #{code_info}
|
||||
, #{code_value}
|
||||
, #{reference_table}
|
||||
, #{reference_column}
|
||||
@@ -311,7 +311,7 @@
|
||||
COLUMN_LABEL = COALESCE(EXCLUDED.COLUMN_LABEL, TABLE_TYPE_COLUMNS.COLUMN_LABEL)
|
||||
, INPUT_TYPE = COALESCE(EXCLUDED.INPUT_TYPE, TABLE_TYPE_COLUMNS.INPUT_TYPE)
|
||||
, DETAIL_SETTINGS = COALESCE(EXCLUDED.DETAIL_SETTINGS, TABLE_TYPE_COLUMNS.DETAIL_SETTINGS)
|
||||
, CODE_CATEGORY = EXCLUDED.CODE_CATEGORY
|
||||
, CODE_INFO = EXCLUDED.CODE_INFO
|
||||
, CODE_VALUE = EXCLUDED.CODE_VALUE
|
||||
, REFERENCE_TABLE = EXCLUDED.REFERENCE_TABLE
|
||||
, REFERENCE_COLUMN = EXCLUDED.REFERENCE_COLUMN
|
||||
@@ -354,7 +354,7 @@
|
||||
, REFERENCE_TABLE = CASE WHEN #{clear_entity} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.REFERENCE_TABLE END
|
||||
, REFERENCE_COLUMN= CASE WHEN #{clear_entity} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.REFERENCE_COLUMN END
|
||||
, DISPLAY_COLUMN = CASE WHEN #{clear_entity} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.DISPLAY_COLUMN END
|
||||
, CODE_CATEGORY = CASE WHEN #{clear_code} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.CODE_CATEGORY END
|
||||
, CODE_INFO = CASE WHEN #{clear_code} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.CODE_INFO END
|
||||
, CODE_VALUE = CASE WHEN #{clear_code} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.CODE_VALUE END
|
||||
, CATEGORY_REF = CASE WHEN #{clear_category} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.CATEGORY_REF END
|
||||
, UPDATED_DATE = NOW()
|
||||
@@ -389,7 +389,7 @@
|
||||
<select id="getTablePrimaryKeyList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
TC.CONNAME AS constraint_name
|
||||
, ARRAY_AGG(A.ATTNAME ORDER BY X.N) AS columns
|
||||
, ARRAY_AGG(A.ATTNAME ORDER BY X.N)::text AS columns
|
||||
FROM PG_CONSTRAINT TC
|
||||
JOIN PG_CLASS C
|
||||
ON TC.CONRELID = C.OID
|
||||
@@ -411,7 +411,7 @@
|
||||
SELECT
|
||||
I.RELNAME AS index_name
|
||||
, IX.INDISUNIQUE AS is_unique
|
||||
, ARRAY_AGG(A.ATTNAME ORDER BY X.N) AS columns
|
||||
, ARRAY_AGG(A.ATTNAME ORDER BY X.N)::text AS columns
|
||||
FROM PG_INDEX IX
|
||||
JOIN PG_CLASS T
|
||||
ON IX.INDRELID = T.OID
|
||||
@@ -667,15 +667,15 @@
|
||||
SET
|
||||
PROPERTIES = JSONB_SET(
|
||||
JSONB_SET(
|
||||
SL.PROPERTIES,
|
||||
SL.PROPERTIES::JSONB,
|
||||
'{widgetType}', TO_JSONB(#{component_id}::TEXT)
|
||||
),
|
||||
'{componentType}', TO_JSONB(#{component_id}::TEXT)
|
||||
)
|
||||
)::TEXT
|
||||
FROM SCREEN_DEFINITIONS SD
|
||||
WHERE SL.SCREEN_ID = SD.SCREEN_ID
|
||||
AND SL.PROPERTIES->>'tableName' = #{table_name}
|
||||
AND SL.PROPERTIES->>'columnName' = #{column_name}
|
||||
AND SL.PROPERTIES::JSONB->>'tableName' = #{table_name}
|
||||
AND SL.PROPERTIES::JSONB->>'columnName' = #{column_name}
|
||||
AND ((SD.COMPANY_CODE = #{company_code} OR SD.COMPANY_CODE = '*') OR #{company_code} = '*')
|
||||
</update>
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# 088 마이그레이션 — DEPT_MANAGERS 테이블 추가 (다중 관리자 + 조직장)
|
||||
|
||||
작성일: 2026-05-14
|
||||
작성자: johngreen
|
||||
관련: RPS 더존 ERP UJA1040 레퍼런스 대비 누락 기능 (A 단계 — 다중 관리자 + 조직장)
|
||||
|
||||
## 목적
|
||||
|
||||
부서별로 결재 관리자 / 부서 관리자 / 조직장을 각각 **다중 등록 (최대 10명)** 할 수 있도록 매핑 테이블 신설.
|
||||
|
||||
- 기존 `DEPT_INFO.APPROVAL_MANAGER` / `DEPT_INFO.DEPT_MANAGER` 컬럼은 단일 `user_id` 만 저장 가능
|
||||
- 신규 `DEPT_MANAGERS` 매핑 테이블이 SoT(source of truth). `ROLE` 컬럼으로 3 종류 구분
|
||||
- `approval` = 결재 관리자 (자동 결재라인 등록 시 호출)
|
||||
- `dept` = 부서 관리자 (행정 책임자)
|
||||
- `org_leader` = 조직장 (본인 부서 + 하위 부서의 경비/근태 조회·승인 권한)
|
||||
- 기존 단일 컬럼은 **호환 위해 일단 유지**. 향후 cleanup PR 에서 제거 예정
|
||||
|
||||
## 스키마
|
||||
|
||||
### DEPT_MANAGERS (신규)
|
||||
|
||||
| 컬럼 | 타입 | 제약 | 설명 |
|
||||
|---|---|---|---|
|
||||
| `DEPT_CODE` | VARCHAR(1024) | NOT NULL, FK → DEPT_INFO ON DELETE CASCADE | 부서 코드 |
|
||||
| `USER_ID` | VARCHAR(50) | NOT NULL | 사용자 ID |
|
||||
| `ROLE` | VARCHAR(20) | NOT NULL, CHECK | `approval` \| `dept` \| `org_leader` |
|
||||
| `SORT_ORDER` | INTEGER | NOT NULL DEFAULT 1 | 표시 순서 |
|
||||
| `CREATED_AT` | TIMESTAMP | NOT NULL DEFAULT NOW() | 등록 시각 |
|
||||
|
||||
PK: `(DEPT_CODE, USER_ID, ROLE)` — 같은 사용자가 같은 부서에 같은 role 로 중복 등록 차단.
|
||||
인덱스: `(DEPT_CODE, ROLE, SORT_ORDER)` — 부서별 role 조회 + 정렬 가속.
|
||||
|
||||
## SQL
|
||||
|
||||
```sql
|
||||
-- =================================================================
|
||||
-- 088: DEPT_MANAGERS 테이블 (idempotent)
|
||||
-- =================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS DEPT_MANAGERS (
|
||||
DEPT_CODE VARCHAR(1024) NOT NULL,
|
||||
USER_ID VARCHAR(50) NOT NULL,
|
||||
ROLE VARCHAR(20) NOT NULL,
|
||||
SORT_ORDER INTEGER NOT NULL DEFAULT 1,
|
||||
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (DEPT_CODE, USER_ID, ROLE),
|
||||
CONSTRAINT chk_dept_managers_role
|
||||
CHECK (ROLE IN ('approval', 'dept', 'org_leader')),
|
||||
CONSTRAINT fk_dept_managers_dept
|
||||
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dept_managers_role
|
||||
ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER);
|
||||
```
|
||||
|
||||
부팅 시 `StartupSchemaMigrator` 가 메타 DB + 모든 활성 테넌트 DB 에 동일 DDL 을 `IF NOT EXISTS` 로 적용하므로 일반적으로는 별도 수동 실행이 필요 없음.
|
||||
|
||||
## 사전 점검
|
||||
|
||||
```sql
|
||||
-- A. 테이블 사전 상태
|
||||
SELECT table_name FROM information_schema.tables WHERE table_name = 'dept_managers';
|
||||
-- 빈 결과여야 정상. 이미 있으면 CREATE 의 IF NOT EXISTS 가 안전.
|
||||
|
||||
-- B. DEPT_INFO 행수 (FK 영향 범위)
|
||||
SELECT COUNT(*) FROM DEPT_INFO;
|
||||
```
|
||||
|
||||
## 사후 검증
|
||||
|
||||
```sql
|
||||
-- C. 테이블 추가 확인
|
||||
SELECT column_name, data_type, character_maximum_length
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'dept_managers'
|
||||
ORDER BY ordinal_position;
|
||||
-- 기대: 5 행 (DEPT_CODE/USER_ID/ROLE/SORT_ORDER/CREATED_AT)
|
||||
|
||||
-- D. CHECK 제약 확인
|
||||
SELECT constraint_name, check_clause FROM information_schema.check_constraints
|
||||
WHERE constraint_name = 'chk_dept_managers_role';
|
||||
-- 기대: ROLE IN ('approval', 'dept', 'org_leader')
|
||||
|
||||
-- E. FK 동작 확인 (테스트)
|
||||
BEGIN;
|
||||
INSERT INTO DEPT_MANAGERS (DEPT_CODE, USER_ID, ROLE)
|
||||
VALUES ('NON_EXISTENT_DEPT', 'tester', 'approval');
|
||||
-- 기대: FK 위반 에러 (foreign key constraint "fk_dept_managers_dept")
|
||||
ROLLBACK;
|
||||
```
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
# 1) 메타 DB
|
||||
psql -h <host> -U postgres -d invyone -f RUN_088.sql
|
||||
|
||||
# 2) 각 테넌트 DB (StartupSchemaMigrator 가 부팅 시 자동 적용하므로 통상 생략 가능)
|
||||
for db in $(psql -tA -d invyone -c "SELECT db_name FROM company_mng WHERE db_status='active'"); do
|
||||
echo "=== $db ==="
|
||||
psql -h <host> -U postgres -d "$db" -f RUN_088.sql
|
||||
done
|
||||
```
|
||||
|
||||
## 롤백
|
||||
|
||||
```sql
|
||||
-- DEPT_MANAGERS 테이블 제거 (저장된 다중 관리자 매핑 함께 삭제됨)
|
||||
DROP INDEX IF EXISTS idx_dept_managers_role;
|
||||
DROP TABLE IF EXISTS DEPT_MANAGERS;
|
||||
```
|
||||
|
||||
롤백 후엔 백엔드/프론트가 단일 `APPROVAL_MANAGER` / `DEPT_MANAGER` 컬럼만 사용하는 이전 동작으로 자연스럽게 복귀 (호환 컬럼 유지하기 때문).
|
||||
|
||||
## 적용 환경 체크리스트
|
||||
|
||||
- [ ] 로컬 docker `naengangi-pg` (관련 없음 — invyone DB 는 wace/운영에만 존재)
|
||||
- [ ] wace 개발서버 PostgreSQL
|
||||
- [ ] 운영 메타 DB (`invyone`)
|
||||
- [ ] 운영 각 테넌트 DB (loop or 부팅 시 자동)
|
||||
|
||||
## 관련 코드
|
||||
|
||||
- Flyway: `backend-spring/src/main/resources/db/migration/V022__create_dept_managers.sql`
|
||||
- StartupSchemaMigrator: `backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java` (마지막 항목으로 추가)
|
||||
- Mapper: `backend-spring/src/main/resources/mapper/department.xml`
|
||||
- `selectDepartments` / `selectDepartmentByCode` 의 SELECT 절에 `APPROVAL_MANAGERS`/`DEPT_MANAGERS`/`ORG_LEADERS` json_agg 컬럼 추가
|
||||
- 신규 query: `insertDeptManagers`, `deleteDeptManagersByDept`
|
||||
- Service: `DepartmentService.java`
|
||||
- `createDepartment` / `updateDepartment` 가 body 의 `approval_managers[]`/`dept_managers[]`/`org_leaders[]` 배열을 `DEPT_MANAGERS` 에 sync (트랜잭션, 최대 10명 검증)
|
||||
- Frontend: `frontend/app/(main)/admin/userMng/deptMngList/page.tsx`
|
||||
- BasicInfoForm 에 다중 chip UI + ManagerPicker 모달
|
||||
@@ -0,0 +1,143 @@
|
||||
# 089 마이그레이션 — IS_SOLUTION_ONLY 메뉴 플래그 + TABLE_TYPE_COLUMNS.CODE_CATEGORY rename
|
||||
|
||||
작성일: 2026-05-15
|
||||
작성자: johngreen
|
||||
관련:
|
||||
- (V023) 멀티테넌시 메뉴 격리 — 5/15 fix (commit c530a67c)
|
||||
- (V024) common-code 마스터-디테일 재설계 — 5/15 refactor (commit 2348800e)
|
||||
|
||||
## 목적
|
||||
|
||||
V023 과 V024 두 건의 누락된 운영 문서를 합본 처리.
|
||||
앱 부팅 시 `StartupSchemaMigrator` 가 idempotent 로 메타 DB + 활성 테넌트 DB 전부에 자동 적용한다.
|
||||
|
||||
### V023 — MENU_INFO.IS_SOLUTION_ONLY 컬럼 (회상)
|
||||
|
||||
테넌트 사이트(`*.invyone.com`)에서 솔루션 전용 관리자 메뉴(회사관리/회사 프로비저닝/감사로그)를 숨기기 위한 플래그.
|
||||
- 메뉴 mapper SQL(`selectAdminMenuList`, `selectUserMenuList`)이 `is_management_host` 파라미터를 보고 `IS_SOLUTION_ONLY=TRUE` 행을 제외.
|
||||
- 이미 부팅 마이그레이션으로는 적용 중이지만 RUN_*.md 운영 문서가 빠져있어 이번 089 에 합본.
|
||||
|
||||
### V024 — TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO (★ 신규, 본 PR 의 핵심)
|
||||
|
||||
5/15 의 commonCode 마스터-디테일 재설계(commit `2348800e`)가 mapper SQL 6 군데에서
|
||||
`CL.CODE_CATEGORY` → `CL.CODE_INFO` 로 컬럼 참조명을 바꿨지만, **DB 컬럼 rename SQL 을 빠뜨린 채 머지**됨.
|
||||
그 결과 모든 테넌트 DB 의 `테이블 타입관리 > 테이블 클릭 > 컬럼 목록` API
|
||||
(`GET /api/table-management/tables/{name}/columns`) 가 **500** 반환:
|
||||
|
||||
```
|
||||
ERROR: column cl.code_info does not exist
|
||||
```
|
||||
|
||||
본 089 마이그레이션이 `CODE_CATEGORY` → `CODE_INFO` 로 컬럼명을 안전하게 변경한다.
|
||||
|
||||
## 스키마
|
||||
|
||||
### MENU_INFO (V023)
|
||||
|
||||
| 컬럼 | 타입 | 제약 | 설명 |
|
||||
|---|---|---|---|
|
||||
| `IS_SOLUTION_ONLY` | BOOLEAN | NOT NULL DEFAULT FALSE | TRUE 인 메뉴는 솔루션 관리 호스트에서만 노출 |
|
||||
|
||||
### TABLE_TYPE_COLUMNS (V024)
|
||||
|
||||
| 변경 | 설명 |
|
||||
|---|---|
|
||||
| `CODE_CATEGORY` → `CODE_INFO` | 컬럼 RENAME (값/타입/제약 그대로) |
|
||||
|
||||
## SQL
|
||||
|
||||
```sql
|
||||
-- =================================================================
|
||||
-- 089-V023: MENU_INFO.IS_SOLUTION_ONLY (idempotent)
|
||||
-- =================================================================
|
||||
|
||||
ALTER TABLE MENU_INFO
|
||||
ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL;
|
||||
|
||||
UPDATE MENU_INFO
|
||||
SET IS_SOLUTION_ONLY = TRUE
|
||||
WHERE IS_SOLUTION_ONLY = FALSE
|
||||
AND MENU_URL IN (
|
||||
'/admin/sysMng/subdomainList',
|
||||
'/admin/userMng/companyList',
|
||||
'/admin/audit-log'
|
||||
);
|
||||
|
||||
-- =================================================================
|
||||
-- 089-V024: TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO (idempotent)
|
||||
-- =================================================================
|
||||
-- PostgreSQL 은 RENAME COLUMN 에 IF EXISTS 가 없으므로 DO 블록으로
|
||||
-- 멱등성 보장 (이미 CODE_INFO 면 no-op, CODE_CATEGORY 만 존재할 때만 rename).
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'table_type_columns'
|
||||
AND column_name = 'code_category'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'table_type_columns'
|
||||
AND column_name = 'code_info'
|
||||
) THEN
|
||||
ALTER TABLE TABLE_TYPE_COLUMNS
|
||||
RENAME COLUMN CODE_CATEGORY TO CODE_INFO;
|
||||
END IF;
|
||||
END $$;
|
||||
```
|
||||
|
||||
## 멱등성
|
||||
|
||||
- V023: `ADD COLUMN IF NOT EXISTS` + UPDATE `WHERE IS_SOLUTION_ONLY = FALSE` 로 중복 실행 안전.
|
||||
- V024: DO 블록 안에서 information_schema 로 현재 상태 확인 후 분기.
|
||||
- 신규 테넌트 DB (이미 CODE_INFO 면): no-op
|
||||
- 기존 테넌트 DB (CODE_CATEGORY 만 있으면): rename 수행
|
||||
- 둘 다 있거나 둘 다 없으면: no-op (방어적)
|
||||
|
||||
## 적용 방법
|
||||
|
||||
부팅 시 자동 적용 — 별도 작업 불필요.
|
||||
`backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java` 의 MIGRATIONS 리스트에
|
||||
위 SQL 이 등록되어 있어서 앱이 시작할 때 모든 활성 테넌트 DB 에 idempotent 로 실행된다.
|
||||
|
||||
수동 적용이 필요한 경우 (예: 새 환경 부트스트랩 전):
|
||||
```bash
|
||||
psql -h <host> -U <user> -d <tenant_db> -f - <<'SQL'
|
||||
-- 위 SQL 본문 붙여넣기
|
||||
SQL
|
||||
```
|
||||
|
||||
## 검증
|
||||
|
||||
```sql
|
||||
-- V023
|
||||
SELECT COLUMN_NAME FROM information_schema.columns
|
||||
WHERE TABLE_NAME = 'menu_info' AND COLUMN_NAME = 'is_solution_only';
|
||||
-- → 1 row
|
||||
|
||||
SELECT MENU_URL, IS_SOLUTION_ONLY FROM MENU_INFO
|
||||
WHERE MENU_URL IN ('/admin/sysMng/subdomainList', '/admin/userMng/companyList', '/admin/audit-log');
|
||||
-- → 모두 IS_SOLUTION_ONLY = TRUE
|
||||
|
||||
-- V024
|
||||
SELECT COLUMN_NAME FROM information_schema.columns
|
||||
WHERE TABLE_NAME = 'table_type_columns' AND COLUMN_NAME IN ('code_category', 'code_info');
|
||||
-- → 1 row: code_info (code_category 는 존재하면 안 됨)
|
||||
```
|
||||
|
||||
## 영향 범위
|
||||
|
||||
- 테이블 타입관리 페이지 컬럼 조회 500 에러 해소.
|
||||
- common-code 재설계 후속 (mapper/Service/Frontend 는 이미 5/15 에 머지됨).
|
||||
- 부팅 시점 1회 실행 — 런타임 트래픽에는 영향 없음.
|
||||
|
||||
## 롤백
|
||||
|
||||
V024 rename 을 되돌리려면 mapper SQL 도 같이 되돌려야 하므로 일반적으로 권장하지 않음.
|
||||
만약 필요하면:
|
||||
```sql
|
||||
ALTER TABLE TABLE_TYPE_COLUMNS RENAME COLUMN CODE_INFO TO CODE_CATEGORY;
|
||||
```
|
||||
+ `mapper/tableManagement.xml`, `commonCode.xml`, FE `commonCode.ts` 등 5/15 변경분 revert.
|
||||
@@ -0,0 +1,109 @@
|
||||
# 090 마이그레이션 — TABLE_TYPE_COLUMNS 중복 정리 + ON CONFLICT 용 UNIQUE INDEX
|
||||
|
||||
작성일: 2026-05-15
|
||||
작성자: johngreen
|
||||
관련 버그: 테이블 타입관리에서 모든 쓰기 API (UNIQUE 토글 / NOT NULL 토글 / 컬럼 설정 저장) 가 500 반환.
|
||||
|
||||
## 증상
|
||||
|
||||
```
|
||||
PSQLException: ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification
|
||||
mapper: tableManagement.upsertColumnSettings / upsertNullable / upsertUnique / upsertColumnInputType
|
||||
```
|
||||
|
||||
## 원인
|
||||
|
||||
`TABLE_TYPE_COLUMNS` 의 PK 는 `id` 단일(varchar). 운영 DB 어디에도
|
||||
`(TABLE_NAME, COLUMN_NAME, COMPANY_CODE)` UNIQUE 제약/인덱스가 없음.
|
||||
mapper 의 `INSERT … ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) DO UPDATE …`
|
||||
구문이 매칭할 unique constraint 를 찾지 못해 즉시 BadSqlGrammar 로 500.
|
||||
|
||||
RUN_044 가 company_code 컬럼을 추가했지만 함께 도입했어야 할 unique index 가
|
||||
빠진 채로 운영에 들어간 것으로 보이며, 그 후 mapper 가 ON CONFLICT 패턴으로 작성되면서
|
||||
실제로는 한 번도 정상 동작하지 못한 채로 잠복했던 정황 (운영 메타 DB 의 35,316 행 중
|
||||
중복 키 그룹 2개 = 추가 4 row 가 그 흔적).
|
||||
|
||||
## 조치
|
||||
|
||||
### (1) 중복 행 정리
|
||||
|
||||
각 `(TABLE_NAME, COLUMN_NAME, COMPANY_CODE)` 그룹에서
|
||||
`updated_date DESC NULLS LAST, id::bigint DESC` 로 정렬해 첫 행만 유지, 나머지 DELETE.
|
||||
|
||||
```sql
|
||||
DELETE FROM TABLE_TYPE_COLUMNS
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE
|
||||
ORDER BY UPDATED_DATE DESC NULLS LAST,
|
||||
id::bigint DESC
|
||||
) AS rn
|
||||
FROM TABLE_TYPE_COLUMNS
|
||||
) r
|
||||
WHERE r.rn > 1
|
||||
);
|
||||
```
|
||||
|
||||
실측(2026-05-15) 중복:
|
||||
|
||||
| DB | 중복 그룹 | 삭제될 row |
|
||||
|---|---|---|
|
||||
| meta `invyone` | 2 (`sales_order_mng.incoterms@COMPANY_16`, `sales_order_mng.payment_term@COMPANY_16`) | 2 |
|
||||
| `siflex_invyone` | 0 | 0 |
|
||||
| `test01_invyone` | 0 | 0 |
|
||||
| `test02_invyone` | 0 | 0 |
|
||||
|
||||
남는 행은 가장 최근에 갱신된 동일 키 row (column_label/input_type 모두 동일 — 옛 NULL updated_date row 가 제거 대상).
|
||||
|
||||
### (2) UNIQUE INDEX 추가
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC
|
||||
ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE);
|
||||
```
|
||||
|
||||
PostgreSQL 은 ON CONFLICT 가 인덱스도 인식하므로 mapper 의 모든 upsert SQL 이
|
||||
즉시 정상 동작. `IF NOT EXISTS` 로 멱등.
|
||||
|
||||
## 적용 방법
|
||||
|
||||
부팅 시 자동 적용 — 별도 작업 불필요. `StartupSchemaMigrator.MIGRATIONS` 리스트에
|
||||
V025 / RUN_090 (1) (2) 항목으로 등록되어 있어서 앱이 시작할 때 메타 DB + 모든 활성
|
||||
테넌트 DB 에 차례로 실행된다.
|
||||
|
||||
## 검증
|
||||
|
||||
```sql
|
||||
-- 중복 없음
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT 1 FROM TABLE_TYPE_COLUMNS
|
||||
GROUP BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE HAVING COUNT(*) > 1
|
||||
) d;
|
||||
-- → 0
|
||||
|
||||
-- 인덱스 존재
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'table_type_columns' AND indexname = 'ux_table_type_columns_tcc';
|
||||
-- → 1 row
|
||||
```
|
||||
|
||||
브라우저 검증:
|
||||
1. 솔루션 또는 테넌트 사이트 > 시스템 관리 > 테이블 타입관리 > 거래처 클릭
|
||||
2. 어느 컬럼이든 `UQ` / `NN` 토글 클릭 → 200, 토스트 "UNIQUE/NOT NULL 제약이 설정되었습니다"
|
||||
3. "컬럼 설정 저장" 버튼 클릭 → 200, 토스트 "모든 컬럼 설정을 성공적으로 저장했습니다"
|
||||
|
||||
## 영향 범위
|
||||
|
||||
- 테이블 타입관리 페이지 쓰기 API 4종 (`unique`, `nullable`, `columns/settings`, `columns/{c}/input-type`) 정상화.
|
||||
- 멱등 — 재실행 시 DELETE 0건, CREATE INDEX 도 IF NOT EXISTS 라 skip.
|
||||
- 부팅 시점 1회 실행, 런타임 트래픽에는 영향 없음.
|
||||
|
||||
## 롤백
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS UX_TABLE_TYPE_COLUMNS_TCC;
|
||||
```
|
||||
DELETE 된 중복 row 는 정보 손실 없음 (남은 row 와 column_label/input_type 동일) 이라
|
||||
복구가 의미 없음. 그래도 굳이 되돌리려면 사전 백업 필요.
|
||||
@@ -0,0 +1,81 @@
|
||||
# 091 마이그레이션 — TABLE_TYPE_COLUMNS.INPUT_TYPE legacy → 표준 8종 정리
|
||||
|
||||
작성일: 2026-05-16
|
||||
작성자: johngreen
|
||||
관련: 5/15 common-code 재설계 (commit `2348800e`) 후속 데이터 마이그레이션.
|
||||
|
||||
## 배경
|
||||
|
||||
5/15 PR 이 `InputTypeConstants.USER_SELECTABLE_INPUT_TYPES` 화이트리스트를
|
||||
표준 8종(`text/number/date/code/entity/numbering/file/image`) 으로 좁혔지만,
|
||||
운영 DB 에 잔존하는 옛 input_type 값들을 정리하는 데이터 마이그레이션이 빠지고
|
||||
프론트엔드도 옛 값을 그대로 echo 했기 때문에 컬럼 설정 저장 batch 가 400 으로 거부됐다.
|
||||
|
||||
긴급 회복은 `90787d83` 에서 화이트리스트에 legacy 7종을 다시 인정하는 방식으로
|
||||
끝냈고, 본 091 마이그레이션은 그 뒤로 **데이터를 표준으로 통합**하는 후속 정리.
|
||||
|
||||
## 매핑
|
||||
|
||||
| Legacy | → | Standard | 사유 |
|
||||
|---|---|---|---|
|
||||
| `category` | → | `code` | commonCode 통합 의도와 일치 |
|
||||
| `select` | → | `code` | 미리 정의된 코드 선택 = code 와 동등 |
|
||||
| `radio` | → | `code` | enum 선택 |
|
||||
| `checkbox` | → | `code` | enum/boolean → code 매핑 (표준에 boolean 없음) |
|
||||
| `boolean` | → | `code` | 표준에 boolean 없음 — code 가 가장 근접 |
|
||||
| `textarea` | → | `text` | single/multi line 구분 UI 손실 (가벼움) |
|
||||
| `datetime` | → | `date` | 표준에 datetime 분리 없음 |
|
||||
|
||||
## 영향 범위 (실측 2026-05-16)
|
||||
|
||||
| DB | 갱신 row |
|
||||
|---|---|
|
||||
| meta `invyone` | 1,207 (category 886 + select 149 + textarea 102 + checkbox 55 + radio 12 + datetime 2 + boolean 1) |
|
||||
| `siflex_invyone` | 0 (테이블 비어있음) |
|
||||
| `test01_invyone` | 0 |
|
||||
| `test02_invyone` | 0 |
|
||||
|
||||
## SQL
|
||||
|
||||
```sql
|
||||
UPDATE TABLE_TYPE_COLUMNS
|
||||
SET INPUT_TYPE = CASE INPUT_TYPE
|
||||
WHEN 'category' THEN 'code'
|
||||
WHEN 'select' THEN 'code'
|
||||
WHEN 'radio' THEN 'code'
|
||||
WHEN 'checkbox' THEN 'code'
|
||||
WHEN 'boolean' THEN 'code'
|
||||
WHEN 'textarea' THEN 'text'
|
||||
WHEN 'datetime' THEN 'date'
|
||||
END,
|
||||
UPDATED_DATE = NOW()
|
||||
WHERE INPUT_TYPE IN ('category','select','radio','checkbox','boolean','textarea','datetime');
|
||||
```
|
||||
|
||||
## 멱등성
|
||||
|
||||
`WHERE INPUT_TYPE IN (...)` 으로 두 번째 실행 시 매칭 row 0 → no-op.
|
||||
|
||||
## 적용 방법
|
||||
|
||||
부팅 시 자동 적용. `StartupSchemaMigrator.MIGRATIONS` 리스트에 V026 / RUN_091 항목으로
|
||||
등록되어 있어서 backend 시작 시 메타 DB + 활성 테넌트 DB 전부에 idempotent 로 실행된다.
|
||||
|
||||
## 검증
|
||||
|
||||
```sql
|
||||
-- 화이트리스트 밖 row 0 이어야 함
|
||||
SELECT input_type, COUNT(*) FROM table_type_columns
|
||||
WHERE input_type NOT IN ('text','number','date','code','entity','numbering','file','image')
|
||||
GROUP BY 1;
|
||||
-- → 0 rows
|
||||
```
|
||||
|
||||
## 후속 cleanup (별도 PR 거리)
|
||||
|
||||
본 마이그레이션이 모든 환경에 한 번 적용된 다음에는:
|
||||
1. `InputTypeConstants.USER_SELECTABLE_INPUT_TYPES` 에서 legacy 7종 다시 제거.
|
||||
2. 프론트엔드 input type 선택 UI 에서 legacy 옵션 제거 (이미 있을 수도).
|
||||
3. mapper/Service 에서 legacy 값 참조 흔적 grep + 정리.
|
||||
|
||||
이번 PR 은 데이터 정리만. 화이트리스트 축소는 운영 안정 확인 후.
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -64,6 +65,7 @@ import {
|
||||
import { getCompanyList } from "@/lib/api/company";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Company } from "@/types/company";
|
||||
import { isManagementHost } from "@/lib/tenant/subdomain";
|
||||
|
||||
const RESOURCE_TYPE_CONFIG: Record<
|
||||
string,
|
||||
@@ -78,7 +80,7 @@ const RESOURCE_TYPE_CONFIG: Record<
|
||||
USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" },
|
||||
ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
||||
COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" },
|
||||
CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||
CODE_INFO: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||
CODE: { label: "코드", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||
DATA: { label: "데이터", icon: Database, color: "bg-muted text-foreground" },
|
||||
TABLE: { label: "테이블", icon: Database, color: "bg-muted text-foreground" },
|
||||
@@ -290,6 +292,16 @@ function groupByDate(entries: AuditLogEntry[]): Map<string, AuditLogEntry[]> {
|
||||
}
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const router = useRouter();
|
||||
const [hostBlocked, setHostBlocked] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!isManagementHost(window.location.hostname)) {
|
||||
setHostBlocked(true);
|
||||
router.replace("/main");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const { user } = useAuth();
|
||||
const isSuperAdmin = user?.company_code === "*";
|
||||
|
||||
@@ -393,6 +405,8 @@ export default function AuditLogPage() {
|
||||
setDetailOpen(true);
|
||||
};
|
||||
|
||||
if (hostBlocked) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4 p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
/**
|
||||
* 기존 자동입력 페이지 → 통합 관리 페이지로 리다이렉트
|
||||
*/
|
||||
export default function AutoFillRedirect() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace("/admin/cascading-management?tab=autofill");
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground text-sm">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,10 @@
|
||||
// INVYONE 스튜디오 진입 페이지 (templates 테이블 기반)
|
||||
// - 템플릿 목록 + 새 템플릿 생성 → templates 테이블 CRUD
|
||||
// - URL ?id=<template_id> 로 바로 진입
|
||||
// - ScreenDesigner 는 template_id 를 통해 templates API 로 저장/로드
|
||||
// - InvyoneStudio 는 template_id 를 통해 templates API 로 저장/로드
|
||||
import { Suspense, useState, useEffect, useCallback } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import ScreenDesigner from "@/components/screen/ScreenDesigner";
|
||||
import InvyoneStudio from "@/components/screen/InvyoneStudio";
|
||||
import type { ScreenDefinition } from "@/types/screen";
|
||||
import { getTemplateList, deleteTemplate } from "@/lib/api/template";
|
||||
import { createTemplate } from "@/lib/utils/templateAdapter";
|
||||
@@ -442,7 +442,7 @@ function BuilderInner() {
|
||||
|
||||
return (
|
||||
<div className="ide-builder h-[calc(100vh-4rem)] w-full overflow-hidden bg-background">
|
||||
<ScreenDesigner
|
||||
<InvyoneStudio
|
||||
selectedScreen={selectedScreen}
|
||||
onBackToList={handleBackToList}
|
||||
onScreenUpdate={(updatedFields) => {
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Link2, Layers, Filter, FormInput, Ban, Tags, Columns } from "lucide-react";
|
||||
|
||||
// 탭별 컴포넌트
|
||||
import CascadingRelationsTab from "./tabs/CascadingRelationsTab";
|
||||
import AutoFillTab from "./tabs/AutoFillTab";
|
||||
import HierarchyTab from "./tabs/HierarchyTab";
|
||||
import ConditionTab from "./tabs/ConditionTab";
|
||||
import MutualExclusionTab from "./tabs/MutualExclusionTab";
|
||||
import CategoryValueCascadingTab from "./tabs/CategoryValueCascadingTab";
|
||||
import HierarchyColumnTab from "./tabs/HierarchyColumnTab";
|
||||
|
||||
export default function CascadingManagementPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState("relations");
|
||||
|
||||
// URL 쿼리 파라미터에서 탭 설정
|
||||
useEffect(() => {
|
||||
const tab = searchParams.get("tab");
|
||||
if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion", "category-value", "hierarchy-column"].includes(tab)) {
|
||||
setActiveTab(tab);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// 탭 변경 시 URL 업데이트
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", value);
|
||||
router.replace(url.pathname + url.search);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">연쇄 드롭다운 통합 관리</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
연쇄 드롭다운, 자동 입력, 다단계 계층, 조건부 필터 등을 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 탭 네비게이션 */}
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-6">
|
||||
<TabsTrigger value="relations" className="gap-2">
|
||||
<Link2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">2단계 연쇄관계</span>
|
||||
<span className="sm:hidden">연쇄</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="hierarchy" className="gap-2">
|
||||
<Layers className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">다단계 계층</span>
|
||||
<span className="sm:hidden">계층</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="condition" className="gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">조건부 필터</span>
|
||||
<span className="sm:hidden">조건</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="autofill" className="gap-2">
|
||||
<FormInput className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">자동 입력</span>
|
||||
<span className="sm:hidden">자동</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="exclusion" className="gap-2">
|
||||
<Ban className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">상호 배제</span>
|
||||
<span className="sm:hidden">배제</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="category-value" className="gap-2">
|
||||
<Tags className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">카테고리값</span>
|
||||
<span className="sm:hidden">카테고리</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 탭 컨텐츠 */}
|
||||
<div className="mt-6">
|
||||
<TabsContent value="relations">
|
||||
<CascadingRelationsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="hierarchy">
|
||||
<HierarchyTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="condition">
|
||||
<ConditionTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="autofill">
|
||||
<AutoFillTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="exclusion">
|
||||
<MutualExclusionTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="category-value">
|
||||
<CategoryValueCascadingTab />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,687 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Search,
|
||||
RefreshCw,
|
||||
ArrowRight,
|
||||
X,
|
||||
GripVertical,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cascadingAutoFillApi, AutoFillGroup, AutoFillMapping } from "@/lib/api/cascadingAutoFill";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
|
||||
interface TableColumn {
|
||||
columnName: string;
|
||||
columnLabel?: string;
|
||||
dataType?: string;
|
||||
}
|
||||
|
||||
export default function AutoFillTab() {
|
||||
// 목록 상태
|
||||
const [groups, setGroups] = useState<AutoFillGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<AutoFillGroup | null>(null);
|
||||
const [deletingGroupCode, setDeletingGroupCode] = useState<string | null>(null);
|
||||
|
||||
// 테이블/컬럼 목록
|
||||
const [tableList, setTableList] = useState<Array<{ tableName: string; displayName?: string }>>([]);
|
||||
const [masterColumns, setMasterColumns] = useState<TableColumn[]>([]);
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState({
|
||||
groupName: "",
|
||||
description: "",
|
||||
masterTable: "",
|
||||
masterValueColumn: "",
|
||||
masterLabelColumn: "",
|
||||
isActive: "Y",
|
||||
});
|
||||
|
||||
// 매핑 데이터
|
||||
const [mappings, setMappings] = useState<AutoFillMapping[]>([]);
|
||||
|
||||
// 테이블 Combobox 상태
|
||||
const [tableComboOpen, setTableComboOpen] = useState(false);
|
||||
|
||||
// 목록 로드
|
||||
const loadGroups = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await cascadingAutoFillApi.getGroups();
|
||||
if (response.success && response.data) {
|
||||
setGroups(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("그룹 목록 로드 실패:", error);
|
||||
showErrorToast("그룹 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 테이블 목록 로드
|
||||
const loadTableList = useCallback(async () => {
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setTableList(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 테이블 컬럼 로드
|
||||
const loadColumns = useCallback(async (tableName: string) => {
|
||||
if (!tableName) {
|
||||
setMasterColumns([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(tableName);
|
||||
if (response.success && response.data?.columns) {
|
||||
setMasterColumns(
|
||||
response.data.columns.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name,
|
||||
columnLabel: col.columnLabel || col.column_label || col.columnName,
|
||||
dataType: col.dataType || col.data_type,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
setMasterColumns([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadGroups();
|
||||
loadTableList();
|
||||
}, [loadGroups, loadTableList]);
|
||||
|
||||
// 테이블 변경 시 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (formData.masterTable) {
|
||||
loadColumns(formData.masterTable);
|
||||
}
|
||||
}, [formData.masterTable, loadColumns]);
|
||||
|
||||
// 필터된 목록
|
||||
const filteredGroups = groups.filter(
|
||||
(g) =>
|
||||
g.groupCode.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
g.groupName.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
g.masterTable?.toLowerCase().includes(searchText.toLowerCase()),
|
||||
);
|
||||
|
||||
// 모달 열기 (생성)
|
||||
const handleOpenCreate = () => {
|
||||
setEditingGroup(null);
|
||||
setFormData({
|
||||
groupName: "",
|
||||
description: "",
|
||||
masterTable: "",
|
||||
masterValueColumn: "",
|
||||
masterLabelColumn: "",
|
||||
isActive: "Y",
|
||||
});
|
||||
setMappings([]);
|
||||
setMasterColumns([]);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 모달 열기 (수정)
|
||||
const handleOpenEdit = async (group: AutoFillGroup) => {
|
||||
setEditingGroup(group);
|
||||
|
||||
// 상세 정보 로드
|
||||
const detailResponse = await cascadingAutoFillApi.getGroupDetail(group.groupCode);
|
||||
if (detailResponse.success && detailResponse.data) {
|
||||
const detail = detailResponse.data;
|
||||
|
||||
// 컬럼 먼저 로드
|
||||
if (detail.masterTable) {
|
||||
await loadColumns(detail.masterTable);
|
||||
}
|
||||
|
||||
setFormData({
|
||||
groupCode: detail.groupCode,
|
||||
groupName: detail.groupName,
|
||||
description: detail.description || "",
|
||||
masterTable: detail.masterTable,
|
||||
masterValueColumn: detail.masterValueColumn,
|
||||
masterLabelColumn: detail.masterLabelColumn || "",
|
||||
isActive: detail.isActive || "Y",
|
||||
});
|
||||
|
||||
// 매핑 데이터 변환 (snake_case → camelCase)
|
||||
const convertedMappings = (detail.mappings || []).map((m: any) => ({
|
||||
sourceColumn: m.source_column || m.sourceColumn,
|
||||
targetField: m.target_field || m.targetField,
|
||||
targetLabel: m.target_label || m.targetLabel || "",
|
||||
isEditable: m.is_editable || m.isEditable || "Y",
|
||||
isRequired: m.is_required || m.isRequired || "N",
|
||||
defaultValue: m.default_value || m.defaultValue || "",
|
||||
sortOrder: m.sort_order || m.sortOrder || 0,
|
||||
}));
|
||||
setMappings(convertedMappings);
|
||||
}
|
||||
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleDeleteConfirm = (groupCode: string) => {
|
||||
setDeletingGroupCode(groupCode);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 실행
|
||||
const handleDelete = async () => {
|
||||
if (!deletingGroupCode) return;
|
||||
|
||||
try {
|
||||
const response = await cascadingAutoFillApi.deleteGroup(deletingGroupCode);
|
||||
if (response.success) {
|
||||
toast.success("자동 입력 그룹이 삭제되었습니다.");
|
||||
loadGroups();
|
||||
} else {
|
||||
toast.error(response.error || "삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("삭제 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeletingGroupCode(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
// 유효성 검사
|
||||
if (!formData.groupName || !formData.masterTable || !formData.masterValueColumn) {
|
||||
toast.error("필수 항목을 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const saveData = {
|
||||
...formData,
|
||||
mappings,
|
||||
};
|
||||
|
||||
let response;
|
||||
if (editingGroup) {
|
||||
response = await cascadingAutoFillApi.updateGroup(editingGroup.groupCode!, saveData);
|
||||
} else {
|
||||
response = await cascadingAutoFillApi.createGroup(saveData);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(editingGroup ? "수정되었습니다." : "생성되었습니다.");
|
||||
setIsModalOpen(false);
|
||||
loadGroups();
|
||||
} else {
|
||||
toast.error(response.error || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast("자동입력 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
||||
}
|
||||
};
|
||||
|
||||
// 매핑 추가
|
||||
const handleAddMapping = () => {
|
||||
setMappings([
|
||||
...mappings,
|
||||
{
|
||||
sourceColumn: "",
|
||||
targetField: "",
|
||||
targetLabel: "",
|
||||
isEditable: "Y",
|
||||
isRequired: "N",
|
||||
defaultValue: "",
|
||||
sortOrder: mappings.length + 1,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// 매핑 삭제
|
||||
const handleRemoveMapping = (index: number) => {
|
||||
setMappings(mappings.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 매핑 수정
|
||||
const handleMappingChange = (index: number, field: keyof AutoFillMapping, value: any) => {
|
||||
const updated = [...mappings];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
setMappings(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 검색 및 액션 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="그룹 코드, 이름, 테이블명으로 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={loadGroups}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 목록 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>자동 입력 그룹</CardTitle>
|
||||
<CardDescription>
|
||||
마스터 선택 시 여러 필드를 자동으로 입력합니다. (총 {filteredGroups.length}개)
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={handleOpenCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 그룹 추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||
<span className="ml-2">로딩 중...</span>
|
||||
</div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
{searchText ? "검색 결과가 없습니다." : "등록된 자동 입력 그룹이 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>그룹 코드</TableHead>
|
||||
<TableHead>그룹명</TableHead>
|
||||
<TableHead>마스터 테이블</TableHead>
|
||||
<TableHead>매핑 수</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredGroups.map((group) => (
|
||||
<TableRow key={group.groupCode}>
|
||||
<TableCell className="font-mono text-sm">{group.groupCode}</TableCell>
|
||||
<TableCell className="font-medium">{group.groupName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{group.masterTable}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{group.mappingCount || 0}개</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={group.isActive === "Y" ? "default" : "outline"}>
|
||||
{group.isActive === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(group)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(group.groupCode)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 생성/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingGroup ? "자동 입력 그룹 수정" : "자동 입력 그룹 생성"}</DialogTitle>
|
||||
<DialogDescription>마스터 데이터 선택 시 자동으로 입력할 필드들을 설정합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">기본 정보</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>그룹명 *</Label>
|
||||
<Input
|
||||
value={formData.groupName}
|
||||
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
|
||||
placeholder="예: 고객사 정보 자동입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>설명</Label>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="이 자동 입력 그룹에 대한 설명"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={formData.isActive === "Y"}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked ? "Y" : "N" })}
|
||||
/>
|
||||
<Label>활성화</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 마스터 테이블 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">마스터 테이블 설정</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
사용자가 선택할 마스터 데이터의 테이블과 컬럼을 지정합니다.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>마스터 테이블 *</Label>
|
||||
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboOpen}
|
||||
className="h-10 w-full justify-between text-sm"
|
||||
>
|
||||
{formData.masterTable
|
||||
? tableList.find((t) => t.tableName === formData.masterTable)?.displayName ||
|
||||
formData.masterTable
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
|
||||
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tableList.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.tableName} ${table.displayName || ""}`}
|
||||
onSelect={() => {
|
||||
setFormData({
|
||||
...formData,
|
||||
masterTable: table.tableName,
|
||||
masterValueColumn: "",
|
||||
masterLabelColumn: "",
|
||||
});
|
||||
setTableComboOpen(false);
|
||||
}}
|
||||
className="text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.masterTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||
{table.displayName && table.displayName !== table.tableName && (
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>값 컬럼 *</Label>
|
||||
<Select
|
||||
value={formData.masterValueColumn}
|
||||
onValueChange={(value) => setFormData({ ...formData, masterValueColumn: value })}
|
||||
disabled={!formData.masterTable}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="값 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{masterColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>라벨 컬럼</Label>
|
||||
<Select
|
||||
value={formData.masterLabelColumn}
|
||||
onValueChange={(value) => setFormData({ ...formData, masterLabelColumn: value })}
|
||||
disabled={!formData.masterTable}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="라벨 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{masterColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">필드 매핑</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
마스터 테이블의 컬럼을 폼의 어떤 필드에 자동 입력할지 설정합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleAddMapping}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{mappings.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed py-8 text-center text-sm">
|
||||
매핑이 없습니다. "매핑 추가" 버튼을 클릭하여 추가하세요.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{mappings.map((mapping, index) => (
|
||||
<div key={index} className="bg-muted/30 flex items-center gap-3 rounded-lg border p-3">
|
||||
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
|
||||
|
||||
{/* 소스 컬럼 */}
|
||||
<div className="w-40">
|
||||
<Select
|
||||
value={mapping.sourceColumn}
|
||||
onValueChange={(value) => handleMappingChange(index, "sourceColumn", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="소스 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{masterColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<ArrowRight className="text-muted-foreground h-4 w-4" />
|
||||
|
||||
{/* 타겟 필드 */}
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={mapping.targetField}
|
||||
onChange={(e) => handleMappingChange(index, "targetField", e.target.value)}
|
||||
placeholder="타겟 필드명 (예: contact_name)"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 타겟 라벨 */}
|
||||
<div className="w-28">
|
||||
<Input
|
||||
value={mapping.targetLabel || ""}
|
||||
onChange={(e) => handleMappingChange(index, "targetLabel", e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 옵션 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Checkbox
|
||||
id={`editable-${index}`}
|
||||
checked={mapping.isEditable === "Y"}
|
||||
onCheckedChange={(checked) => handleMappingChange(index, "isEditable", checked ? "Y" : "N")}
|
||||
/>
|
||||
<Label htmlFor={`editable-${index}`} className="text-xs">
|
||||
수정
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Checkbox
|
||||
id={`required-${index}`}
|
||||
checked={mapping.isRequired === "Y"}
|
||||
onCheckedChange={(checked) => handleMappingChange(index, "isRequired", checked ? "Y" : "N")}
|
||||
/>
|
||||
<Label htmlFor={`required-${index}`} className="text-xs">
|
||||
필수
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleRemoveMapping(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{editingGroup ? "수정" : "생성"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>자동 입력 그룹 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 자동 입력 그룹을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,899 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Link2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cascadingRelationApi, CascadingRelation, CascadingRelationCreateInput } from "@/lib/api/cascadingRelation";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
|
||||
interface TableInfo {
|
||||
tableName: string;
|
||||
tableLabel?: string;
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
columnName: string;
|
||||
columnLabel?: string;
|
||||
}
|
||||
|
||||
export default function CascadingRelationsTab() {
|
||||
// 목록 상태
|
||||
const [relations, setRelations] = useState<CascadingRelation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingRelation, setEditingRelation] = useState<CascadingRelation | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 테이블/컬럼 목록
|
||||
const [tableList, setTableList] = useState<TableInfo[]>([]);
|
||||
const [parentColumns, setParentColumns] = useState<ColumnInfo[]>([]);
|
||||
const [childColumns, setChildColumns] = useState<ColumnInfo[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [loadingParentColumns, setLoadingParentColumns] = useState(false);
|
||||
const [loadingChildColumns, setLoadingChildColumns] = useState(false);
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState<CascadingRelationCreateInput>({
|
||||
relationCode: "",
|
||||
relationName: "",
|
||||
description: "",
|
||||
parentTable: "",
|
||||
parentValueColumn: "",
|
||||
parentLabelColumn: "",
|
||||
childTable: "",
|
||||
childFilterColumn: "",
|
||||
childValueColumn: "",
|
||||
childLabelColumn: "",
|
||||
childOrderColumn: "",
|
||||
childOrderDirection: "ASC",
|
||||
emptyParentMessage: "상위 항목을 먼저 선택하세요",
|
||||
noOptionsMessage: "선택 가능한 항목이 없습니다",
|
||||
loadingMessage: "로딩 중...",
|
||||
clearOnParentChange: true,
|
||||
});
|
||||
|
||||
// 고급 설정 토글
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// 테이블 Combobox 상태
|
||||
const [parentTableComboOpen, setParentTableComboOpen] = useState(false);
|
||||
const [childTableComboOpen, setChildTableComboOpen] = useState(false);
|
||||
|
||||
// 목록 조회
|
||||
const loadRelations = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await cascadingRelationApi.getList("Y");
|
||||
if (response.success && response.data) {
|
||||
setRelations(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast("연쇄 관계 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 테이블 목록 조회
|
||||
const loadTableList = useCallback(async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setTableList(
|
||||
response.data.map((t: any) => ({
|
||||
tableName: t.tableName || t.name,
|
||||
tableLabel: t.tableLabel || t.displayName || t.tableName || t.name,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 실패:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 컬럼 목록 조회 (수정됨)
|
||||
const loadColumns = useCallback(async (tableName: string, type: "parent" | "child") => {
|
||||
if (!tableName) return;
|
||||
|
||||
if (type === "parent") {
|
||||
setLoadingParentColumns(true);
|
||||
setParentColumns([]);
|
||||
} else {
|
||||
setLoadingChildColumns(true);
|
||||
setChildColumns([]);
|
||||
}
|
||||
|
||||
try {
|
||||
// getColumnList 사용 (getTableColumns가 아님)
|
||||
const response = await tableManagementApi.getColumnList(tableName);
|
||||
console.log(`컬럼 목록 조회 (${tableName}):`, response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 응답 구조: { data: { columns: [...] } }
|
||||
const columnList = response.data.columns || response.data;
|
||||
const columns = (Array.isArray(columnList) ? columnList : []).map((c: any) => ({
|
||||
columnName: c.columnName || c.name,
|
||||
columnLabel: c.columnLabel || c.label || c.columnName || c.name,
|
||||
}));
|
||||
|
||||
if (type === "parent") {
|
||||
setParentColumns(columns);
|
||||
// 자동 추천: id, code, _id, _code로 끝나는 컬럼
|
||||
autoSelectColumn(columns, "parentValueColumn", ["id", "code", "_id", "_code"]);
|
||||
} else {
|
||||
setChildColumns(columns);
|
||||
// 자동 추천
|
||||
autoSelectColumn(columns, "childValueColumn", ["id", "code", "_id", "_code"]);
|
||||
autoSelectColumn(columns, "childLabelColumn", ["name", "label", "_name", "description"]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 조회 실패:", error);
|
||||
toast.error(`${tableName} 테이블의 컬럼을 불러오지 못했습니다.`);
|
||||
} finally {
|
||||
if (type === "parent") {
|
||||
setLoadingParentColumns(false);
|
||||
} else {
|
||||
setLoadingChildColumns(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 수정 모드용 컬럼 로드 (자동 선택 없음)
|
||||
const loadColumnsForEdit = async (tableName: string, type: "parent" | "child") => {
|
||||
if (!tableName) return;
|
||||
|
||||
if (type === "parent") {
|
||||
setLoadingParentColumns(true);
|
||||
} else {
|
||||
setLoadingChildColumns(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(tableName);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const columnList = response.data.columns || response.data;
|
||||
|
||||
const columns = (Array.isArray(columnList) ? columnList : []).map((c: any) => ({
|
||||
columnName: c.columnName || c.name,
|
||||
columnLabel: c.columnLabel || c.label || c.columnName || c.name,
|
||||
}));
|
||||
|
||||
if (type === "parent") {
|
||||
setParentColumns(columns);
|
||||
} else {
|
||||
setChildColumns(columns);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 조회 실패:", error);
|
||||
} finally {
|
||||
if (type === "parent") {
|
||||
setLoadingParentColumns(false);
|
||||
} else {
|
||||
setLoadingChildColumns(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 자동 컬럼 선택 (패턴 매칭)
|
||||
const autoSelectColumn = (columns: ColumnInfo[], field: keyof CascadingRelationCreateInput, patterns: string[]) => {
|
||||
// 이미 값이 있으면 스킵
|
||||
if (formData[field]) return;
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const found = columns.find((c) => c.columnName.toLowerCase().endsWith(pattern.toLowerCase()));
|
||||
if (found) {
|
||||
setFormData((prev) => ({ ...prev, [field]: found.columnName }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRelations();
|
||||
loadTableList();
|
||||
}, [loadRelations, loadTableList]);
|
||||
|
||||
// 부모 테이블 변경 시 컬럼 로드 (수정 모드가 아닐 때만)
|
||||
useEffect(() => {
|
||||
// 수정 모드에서는 handleOpenEdit에서 직접 로드하므로 스킵
|
||||
if (editingRelation) return;
|
||||
|
||||
if (formData.parentTable) {
|
||||
loadColumns(formData.parentTable, "parent");
|
||||
} else {
|
||||
setParentColumns([]);
|
||||
}
|
||||
}, [formData.parentTable, editingRelation]);
|
||||
|
||||
// 자식 테이블 변경 시 컬럼 로드 (수정 모드가 아닐 때만)
|
||||
useEffect(() => {
|
||||
// 수정 모드에서는 handleOpenEdit에서 직접 로드하므로 스킵
|
||||
if (editingRelation) return;
|
||||
|
||||
if (formData.childTable) {
|
||||
loadColumns(formData.childTable, "child");
|
||||
} else {
|
||||
setChildColumns([]);
|
||||
}
|
||||
}, [formData.childTable, editingRelation]);
|
||||
|
||||
// 관계 코드 자동 생성
|
||||
const generateRelationCode = (parentTable: string, childTable: string) => {
|
||||
if (!parentTable || !childTable) return "";
|
||||
const parent = parentTable.replace(/_mng$|_info$|_master$/i, "").toUpperCase();
|
||||
const child = childTable.replace(/_mng$|_info$|_master$/i, "").toUpperCase();
|
||||
return `${parent}_${child}`;
|
||||
};
|
||||
|
||||
// 관계명 자동 생성
|
||||
const generateRelationName = (parentTable: string, childTable: string) => {
|
||||
if (!parentTable || !childTable) return "";
|
||||
const parentInfo = tableList.find((t) => t.tableName === parentTable);
|
||||
const childInfo = tableList.find((t) => t.tableName === childTable);
|
||||
const parentName = parentInfo?.tableLabel || parentTable;
|
||||
const childName = childInfo?.tableLabel || childTable;
|
||||
return `${parentName}-${childName}`;
|
||||
};
|
||||
|
||||
// 모달 열기 (신규)
|
||||
const handleOpenCreate = () => {
|
||||
setEditingRelation(null);
|
||||
setFormData({
|
||||
relationCode: "",
|
||||
relationName: "",
|
||||
description: "",
|
||||
parentTable: "",
|
||||
parentValueColumn: "",
|
||||
parentLabelColumn: "",
|
||||
childTable: "",
|
||||
childFilterColumn: "",
|
||||
childValueColumn: "",
|
||||
childLabelColumn: "",
|
||||
childOrderColumn: "",
|
||||
childOrderDirection: "ASC",
|
||||
emptyParentMessage: "상위 항목을 먼저 선택하세요",
|
||||
noOptionsMessage: "선택 가능한 항목이 없습니다",
|
||||
loadingMessage: "로딩 중...",
|
||||
clearOnParentChange: true,
|
||||
});
|
||||
setParentColumns([]);
|
||||
setChildColumns([]);
|
||||
setShowAdvanced(false);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 모달 열기 (수정)
|
||||
const handleOpenEdit = async (relation: CascadingRelation) => {
|
||||
setEditingRelation(relation);
|
||||
setShowAdvanced(false);
|
||||
|
||||
// 먼저 컬럼 목록을 로드 (모달 열기 전)
|
||||
const loadPromises: Promise<void>[] = [];
|
||||
if (relation.parent_table) {
|
||||
loadPromises.push(loadColumnsForEdit(relation.parent_table, "parent"));
|
||||
}
|
||||
if (relation.child_table) {
|
||||
loadPromises.push(loadColumnsForEdit(relation.child_table, "child"));
|
||||
}
|
||||
|
||||
// 컬럼 로드 완료 대기
|
||||
await Promise.all(loadPromises);
|
||||
|
||||
// 컬럼 로드 후 formData 설정 (이렇게 해야 Select에서 값이 제대로 표시됨)
|
||||
setFormData({
|
||||
relationCode: relation.relation_code,
|
||||
relationName: relation.relation_name,
|
||||
description: relation.description || "",
|
||||
parentTable: relation.parent_table,
|
||||
parentValueColumn: relation.parent_value_column,
|
||||
parentLabelColumn: relation.parent_label_column || "",
|
||||
childTable: relation.child_table,
|
||||
childFilterColumn: relation.child_filter_column,
|
||||
childValueColumn: relation.child_value_column,
|
||||
childLabelColumn: relation.child_label_column,
|
||||
childOrderColumn: relation.child_order_column || "",
|
||||
childOrderDirection: relation.child_order_direction || "ASC",
|
||||
emptyParentMessage: relation.empty_parent_message || "상위 항목을 먼저 선택하세요",
|
||||
noOptionsMessage: relation.no_options_message || "선택 가능한 항목이 없습니다",
|
||||
loadingMessage: relation.loading_message || "로딩 중...",
|
||||
clearOnParentChange: relation.clear_on_parent_change === "Y",
|
||||
});
|
||||
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 부모 테이블 선택 시 자동 설정
|
||||
const handleParentTableChange = async (value: string) => {
|
||||
// 테이블이 변경되면 컬럼 초기화 (같은 테이블이면 유지)
|
||||
const shouldClearColumns = value !== formData.parentTable;
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
parentTable: value,
|
||||
parentValueColumn: shouldClearColumns ? "" : prev.parentValueColumn,
|
||||
parentLabelColumn: shouldClearColumns ? "" : prev.parentLabelColumn,
|
||||
}));
|
||||
|
||||
// 수정 모드에서 테이블 변경 시 컬럼 로드
|
||||
if (editingRelation && value) {
|
||||
await loadColumnsForEdit(value, "parent");
|
||||
}
|
||||
};
|
||||
|
||||
// 자식 테이블 선택 시 자동 설정
|
||||
const handleChildTableChange = async (value: string) => {
|
||||
// 테이블이 변경되면 컬럼 초기화 (같은 테이블이면 유지)
|
||||
const shouldClearColumns = value !== formData.childTable;
|
||||
|
||||
const newFormData = {
|
||||
...formData,
|
||||
childTable: value,
|
||||
childFilterColumn: shouldClearColumns ? "" : formData.childFilterColumn,
|
||||
childValueColumn: shouldClearColumns ? "" : formData.childValueColumn,
|
||||
childLabelColumn: shouldClearColumns ? "" : formData.childLabelColumn,
|
||||
childOrderColumn: shouldClearColumns ? "" : formData.childOrderColumn,
|
||||
};
|
||||
|
||||
// 관계 코드/이름 자동 생성 (신규 모드에서만)
|
||||
if (!editingRelation) {
|
||||
newFormData.relationCode = generateRelationCode(formData.parentTable, value);
|
||||
newFormData.relationName = generateRelationName(formData.parentTable, value);
|
||||
}
|
||||
|
||||
setFormData(newFormData);
|
||||
|
||||
// 수정 모드에서 테이블 변경 시 컬럼 로드
|
||||
if (editingRelation && value) {
|
||||
await loadColumnsForEdit(value, "child");
|
||||
}
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
// 필수 필드 검증
|
||||
if (!formData.parentTable || !formData.parentValueColumn) {
|
||||
toast.error("부모 테이블과 값 컬럼을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!formData.childTable ||
|
||||
!formData.childFilterColumn ||
|
||||
!formData.childValueColumn ||
|
||||
!formData.childLabelColumn
|
||||
) {
|
||||
toast.error("자식 테이블 설정을 완료해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 관계 코드/이름 자동 생성 (비어있으면)
|
||||
const finalData = { ...formData };
|
||||
if (!finalData.relationCode) {
|
||||
finalData.relationCode = generateRelationCode(formData.parentTable, formData.childTable);
|
||||
}
|
||||
if (!finalData.relationName) {
|
||||
finalData.relationName = generateRelationName(formData.parentTable, formData.childTable);
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
let response;
|
||||
if (editingRelation) {
|
||||
response = await cascadingRelationApi.update(editingRelation.relation_id, finalData);
|
||||
} else {
|
||||
response = await cascadingRelationApi.create(finalData);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(editingRelation ? "연쇄 관계가 수정되었습니다." : "연쇄 관계가 생성되었습니다.");
|
||||
setIsModalOpen(false);
|
||||
loadRelations();
|
||||
} else {
|
||||
toast.error(response.message || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast("연쇄 관계 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const handleDelete = async (relation: CascadingRelation) => {
|
||||
if (!confirm(`"${relation.relation_name}" 관계를 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await cascadingRelationApi.delete(relation.relation_id);
|
||||
if (response.success) {
|
||||
toast.success("연쇄 관계가 삭제되었습니다.");
|
||||
loadRelations();
|
||||
} else {
|
||||
toast.error(response.message || "삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast("연쇄 관계 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
||||
}
|
||||
};
|
||||
|
||||
// 필터링된 목록
|
||||
const filteredRelations = relations.filter(
|
||||
(r) =>
|
||||
r.relation_code.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
r.relation_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
r.parent_table.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
r.child_table.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
// 컬럼 셀렉트 렌더링 헬퍼
|
||||
const renderColumnSelect = (
|
||||
value: string,
|
||||
onChange: (v: string) => void,
|
||||
columns: ColumnInfo[],
|
||||
loading: boolean,
|
||||
placeholder: string,
|
||||
disabled?: boolean,
|
||||
) => (
|
||||
<Select value={value} onValueChange={onChange} disabled={disabled || loading}>
|
||||
<SelectTrigger className="h-9">
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span className="text-xs">로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={placeholder} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.length === 0 ? (
|
||||
<div className="text-muted-foreground p-2 text-center text-xs">테이블을 먼저 선택하세요</div>
|
||||
) : (
|
||||
columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{col.columnLabel}</span>
|
||||
{col.columnLabel !== col.columnName && (
|
||||
<span className="text-muted-foreground text-xs">({col.columnName})</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Link2 className="h-5 w-5" />
|
||||
2단계 연쇄 관계
|
||||
</CardTitle>
|
||||
<CardDescription>부모-자식 관계로 연결된 드롭다운을 정의합니다. (예: 창고 → 위치)</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={loadRelations} disabled={loading}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button onClick={handleOpenCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 관계 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* 검색 */}
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="관계 코드, 관계명, 테이블명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>관계명</TableHead>
|
||||
<TableHead>연결</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead className="w-[100px]">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-muted-foreground py-8 text-center">
|
||||
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredRelations.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-muted-foreground py-8 text-center">
|
||||
{searchTerm ? "검색 결과가 없습니다." : "등록된 연쇄 관계가 없습니다."}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredRelations.map((relation) => (
|
||||
<TableRow key={relation.relation_id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{relation.relation_name}</div>
|
||||
<div className="text-muted-foreground font-mono text-xs">{relation.relation_code}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="rounded bg-primary/10 px-2 py-0.5 text-primary">{relation.parent_table}</span>
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
||||
<span className="rounded bg-emerald-100 px-2 py-0.5 text-emerald-700">
|
||||
{relation.child_table}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={relation.is_active === "Y" ? "default" : "secondary"}>
|
||||
{relation.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(relation)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDelete(relation)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 생성/수정 모달 - 간소화된 UI */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingRelation ? "연쇄 관계 수정" : "새 연쇄 관계"}</DialogTitle>
|
||||
<DialogDescription>부모 테이블 선택 시 자식 테이블의 옵션이 필터링됩니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Step 1: 부모 테이블 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="mb-3 text-sm font-semibold text-primary">1. 부모 (상위 선택)</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">테이블</Label>
|
||||
<Popover open={parentTableComboOpen} onOpenChange={setParentTableComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={parentTableComboOpen}
|
||||
className="h-9 w-full justify-between text-sm"
|
||||
>
|
||||
{loadingTables
|
||||
? "로딩 중..."
|
||||
: formData.parentTable
|
||||
? tableList.find((t) => t.tableName === formData.parentTable)?.tableLabel ||
|
||||
formData.parentTable
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
|
||||
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tableList.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.tableName} ${table.tableLabel || ""}`}
|
||||
onSelect={() => {
|
||||
handleParentTableChange(table.tableName);
|
||||
setParentTableComboOpen(false);
|
||||
}}
|
||||
className="text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.parentTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.tableLabel || table.tableName}</span>
|
||||
{table.tableLabel && table.tableLabel !== table.tableName && (
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">값 컬럼 (필터링 기준)</Label>
|
||||
{renderColumnSelect(
|
||||
formData.parentValueColumn,
|
||||
(v) => setFormData({ ...formData, parentValueColumn: v }),
|
||||
parentColumns,
|
||||
loadingParentColumns,
|
||||
"컬럼 선택",
|
||||
!formData.parentTable,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2: 자식 테이블 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="mb-3 text-sm font-semibold text-emerald-600">2. 자식 (하위 옵션)</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">테이블</Label>
|
||||
<Popover open={childTableComboOpen} onOpenChange={setChildTableComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={childTableComboOpen}
|
||||
className="h-9 w-full justify-between text-sm"
|
||||
disabled={!formData.parentTable}
|
||||
>
|
||||
{formData.childTable
|
||||
? tableList.find((t) => t.tableName === formData.childTable)?.tableLabel ||
|
||||
formData.childTable
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
|
||||
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tableList.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.tableName} ${table.tableLabel || ""}`}
|
||||
onSelect={() => {
|
||||
handleChildTableChange(table.tableName);
|
||||
setChildTableComboOpen(false);
|
||||
}}
|
||||
className="text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.childTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.tableLabel || table.tableName}</span>
|
||||
{table.tableLabel && table.tableLabel !== table.tableName && (
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">필터 컬럼 (부모 값과 매칭)</Label>
|
||||
{renderColumnSelect(
|
||||
formData.childFilterColumn,
|
||||
(v) => setFormData({ ...formData, childFilterColumn: v }),
|
||||
childColumns,
|
||||
loadingChildColumns,
|
||||
"컬럼 선택",
|
||||
!formData.childTable,
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">값 컬럼 (저장될 값)</Label>
|
||||
{renderColumnSelect(
|
||||
formData.childValueColumn,
|
||||
(v) => setFormData({ ...formData, childValueColumn: v }),
|
||||
childColumns,
|
||||
loadingChildColumns,
|
||||
"컬럼 선택",
|
||||
!formData.childTable,
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">라벨 컬럼 (표시될 텍스트)</Label>
|
||||
{renderColumnSelect(
|
||||
formData.childLabelColumn,
|
||||
(v) => setFormData({ ...formData, childLabelColumn: v }),
|
||||
childColumns,
|
||||
loadingChildColumns,
|
||||
"컬럼 선택",
|
||||
!formData.childTable,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 관계 정보 (자동 생성) */}
|
||||
{formData.parentTable && formData.childTable && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">관계 코드</Label>
|
||||
<Input
|
||||
value={formData.relationCode || generateRelationCode(formData.parentTable, formData.childTable)}
|
||||
onChange={(e) => setFormData({ ...formData, relationCode: e.target.value.toUpperCase() })}
|
||||
placeholder="자동 생성"
|
||||
className="h-8 text-xs"
|
||||
disabled={!!editingRelation}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">관계명</Label>
|
||||
<Input
|
||||
value={formData.relationName || generateRelationName(formData.parentTable, formData.childTable)}
|
||||
onChange={(e) => setFormData({ ...formData, relationName: e.target.value })}
|
||||
placeholder="자동 생성"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 고급 설정 토글 */}
|
||||
<div className="border-t pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="text-muted-foreground hover:text-foreground flex w-full items-center justify-between text-xs"
|
||||
>
|
||||
<span>고급 설정</span>
|
||||
<ChevronRight className={`h-4 w-4 transition-transform ${showAdvanced ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">설명</Label>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="이 관계에 대한 설명..."
|
||||
rows={2}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">상위 미선택 메시지</Label>
|
||||
<Input
|
||||
value={formData.emptyParentMessage}
|
||||
onChange={(e) => setFormData({ ...formData, emptyParentMessage: e.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">옵션 없음 메시지</Label>
|
||||
<Input
|
||||
value={formData.noOptionsMessage}
|
||||
onChange={(e) => setFormData({ ...formData, noOptionsMessage: e.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs">부모 변경 시 초기화</Label>
|
||||
<p className="text-muted-foreground text-xs">부모 값 변경 시 자식 선택 초기화</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.clearOnParentChange}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, clearOnParentChange: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : editingRelation ? (
|
||||
"수정"
|
||||
) : (
|
||||
"생성"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,502 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Filter, Plus, RefreshCw, Search, Pencil, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import {
|
||||
cascadingConditionApi,
|
||||
CascadingCondition,
|
||||
CONDITION_OPERATORS,
|
||||
} from "@/lib/api/cascadingCondition";
|
||||
import { cascadingRelationApi } from "@/lib/api/cascadingRelation";
|
||||
|
||||
export default function ConditionTab() {
|
||||
// 목록 상태
|
||||
const [conditions, setConditions] = useState<CascadingCondition[]>([]);
|
||||
const [relations, setRelations] = useState<Array<{ relation_code: string; relation_name: string }>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [editingCondition, setEditingCondition] = useState<CascadingCondition | null>(null);
|
||||
const [deletingConditionId, setDeletingConditionId] = useState<number | null>(null);
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<Omit<CascadingCondition, "conditionId">>({
|
||||
relationType: "RELATION",
|
||||
relationCode: "",
|
||||
conditionName: "",
|
||||
conditionField: "",
|
||||
conditionOperator: "EQ",
|
||||
conditionValue: "",
|
||||
filterColumn: "",
|
||||
filterValues: "",
|
||||
priority: 0,
|
||||
});
|
||||
|
||||
// 목록 로드
|
||||
const loadConditions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await cascadingConditionApi.getList();
|
||||
if (response.success && response.data) {
|
||||
setConditions(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("조건 목록 로드 실패:", error);
|
||||
toast.error("조건 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 연쇄 관계 목록 로드
|
||||
const loadRelations = useCallback(async () => {
|
||||
try {
|
||||
const response = await cascadingRelationApi.getList("Y");
|
||||
if (response.success && response.data) {
|
||||
setRelations(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("연쇄 관계 목록 로드 실패:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadConditions();
|
||||
loadRelations();
|
||||
}, [loadConditions, loadRelations]);
|
||||
|
||||
// 필터된 목록
|
||||
const filteredConditions = conditions.filter(
|
||||
(c) =>
|
||||
c.conditionName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
c.relationCode?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
c.conditionField?.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
|
||||
// 모달 열기 (생성)
|
||||
const handleOpenCreate = () => {
|
||||
setEditingCondition(null);
|
||||
setFormData({
|
||||
relationType: "RELATION",
|
||||
relationCode: "",
|
||||
conditionName: "",
|
||||
conditionField: "",
|
||||
conditionOperator: "EQ",
|
||||
conditionValue: "",
|
||||
filterColumn: "",
|
||||
filterValues: "",
|
||||
priority: 0,
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 모달 열기 (수정)
|
||||
const handleOpenEdit = (condition: CascadingCondition) => {
|
||||
setEditingCondition(condition);
|
||||
setFormData({
|
||||
relationType: condition.relationType || "RELATION",
|
||||
relationCode: condition.relationCode,
|
||||
conditionName: condition.conditionName,
|
||||
conditionField: condition.conditionField,
|
||||
conditionOperator: condition.conditionOperator,
|
||||
conditionValue: condition.conditionValue,
|
||||
filterColumn: condition.filterColumn,
|
||||
filterValues: condition.filterValues,
|
||||
priority: condition.priority || 0,
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleDeleteConfirm = (conditionId: number) => {
|
||||
setDeletingConditionId(conditionId);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 실행
|
||||
const handleDelete = async () => {
|
||||
if (!deletingConditionId) return;
|
||||
|
||||
try {
|
||||
const response = await cascadingConditionApi.delete(deletingConditionId);
|
||||
if (response.success) {
|
||||
toast.success("조건부 규칙이 삭제되었습니다.");
|
||||
loadConditions();
|
||||
} else {
|
||||
toast.error(response.error || "삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast("조건 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
||||
} finally {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeletingConditionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
// 유효성 검사
|
||||
if (!formData.relationCode || !formData.conditionName || !formData.conditionField) {
|
||||
toast.error("필수 항목을 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.conditionValue || !formData.filterColumn || !formData.filterValues) {
|
||||
toast.error("조건 값, 필터 컬럼, 필터 값을 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (editingCondition) {
|
||||
response = await cascadingConditionApi.update(editingCondition.conditionId!, formData);
|
||||
} else {
|
||||
response = await cascadingConditionApi.create(formData);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(editingCondition ? "수정되었습니다." : "생성되었습니다.");
|
||||
setIsModalOpen(false);
|
||||
loadConditions();
|
||||
} else {
|
||||
toast.error(response.error || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast("조건 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
||||
}
|
||||
};
|
||||
|
||||
// 연산자 라벨 찾기
|
||||
const getOperatorLabel = (operator: string) => {
|
||||
return CONDITION_OPERATORS.find((op) => op.value === operator)?.label || operator;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 검색 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="조건명, 관계 코드, 조건 필드로 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={loadConditions}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 목록 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Filter className="h-5 w-5" />
|
||||
조건부 필터 규칙
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
특정 필드 값에 따라 드롭다운 옵션을 필터링합니다. (총 {filteredConditions.length}개)
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={handleOpenCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
새 규칙 추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||
<span className="ml-2">로딩 중...</span>
|
||||
</div>
|
||||
) : filteredConditions.length === 0 ? (
|
||||
<div className="text-muted-foreground space-y-4 py-8 text-center">
|
||||
<div className="text-sm">
|
||||
{searchText ? "검색 결과가 없습니다." : "등록된 조건부 필터 규칙이 없습니다."}
|
||||
</div>
|
||||
<div className="mx-auto max-w-md space-y-3 text-left">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="text-foreground mb-2 text-sm font-medium">예시: 상태별 품목 필터</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
"상태" 필드가 "활성"일 때만 "품목" 드롭다운에 활성 품목만 표시
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="text-foreground mb-2 text-sm font-medium">예시: 유형별 옵션 필터</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
"유형" 필드가 "입고"일 때 "창고" 드롭다운에 입고 가능 창고만 표시
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>연쇄 관계</TableHead>
|
||||
<TableHead>조건명</TableHead>
|
||||
<TableHead>조건</TableHead>
|
||||
<TableHead>필터</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredConditions.map((condition) => (
|
||||
<TableRow key={condition.conditionId}>
|
||||
<TableCell className="font-mono text-sm">{condition.relationCode}</TableCell>
|
||||
<TableCell className="font-medium">{condition.conditionName}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{condition.conditionField}</span>
|
||||
<span className="mx-1 text-primary">{getOperatorLabel(condition.conditionOperator)}</span>
|
||||
<span className="font-medium">{condition.conditionValue}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{condition.filterColumn}</span>
|
||||
<span className="mx-1">=</span>
|
||||
<span className="font-mono text-xs">{condition.filterValues}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={condition.isActive === "Y" ? "default" : "secondary"}>
|
||||
{condition.isActive === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(condition)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteConfirm(condition.conditionId!)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 생성/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingCondition ? "조건부 규칙 수정" : "조건부 규칙 생성"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
특정 필드 값에 따라 드롭다운 옵션을 필터링하는 규칙을 설정합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 연쇄 관계 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label>연쇄 관계 *</Label>
|
||||
<Select
|
||||
value={formData.relationCode}
|
||||
onValueChange={(value) => setFormData({ ...formData, relationCode: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="연쇄 관계 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{relations.map((rel) => (
|
||||
<SelectItem key={rel.relation_code} value={rel.relation_code}>
|
||||
{rel.relation_name} ({rel.relation_code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 조건명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>조건명 *</Label>
|
||||
<Input
|
||||
value={formData.conditionName}
|
||||
onChange={(e) => setFormData({ ...formData, conditionName: e.target.value })}
|
||||
placeholder="예: 활성 품목만 표시"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 조건 설정 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="mb-3 text-sm font-semibold">조건 설정</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">조건 필드 *</Label>
|
||||
<Input
|
||||
value={formData.conditionField}
|
||||
onChange={(e) => setFormData({ ...formData, conditionField: e.target.value })}
|
||||
placeholder="예: status"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">연산자 *</Label>
|
||||
<Select
|
||||
value={formData.conditionOperator}
|
||||
onValueChange={(value) => setFormData({ ...formData, conditionOperator: value })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONDITION_OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">조건 값 *</Label>
|
||||
<Input
|
||||
value={formData.conditionValue}
|
||||
onChange={(e) => setFormData({ ...formData, conditionValue: e.target.value })}
|
||||
placeholder="예: active"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
폼의 "{formData.conditionField || "필드"}" 값이 "{formData.conditionValue || "값"}"일 때 필터 적용
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 필터 설정 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="mb-3 text-sm font-semibold">필터 설정</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">필터 컬럼 *</Label>
|
||||
<Input
|
||||
value={formData.filterColumn}
|
||||
onChange={(e) => setFormData({ ...formData, filterColumn: e.target.value })}
|
||||
placeholder="예: status"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">필터 값 *</Label>
|
||||
<Input
|
||||
value={formData.filterValues}
|
||||
onChange={(e) => setFormData({ ...formData, filterValues: e.target.value })}
|
||||
placeholder="예: active,pending"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
드롭다운 옵션 중 "{formData.filterColumn || "컬럼"}"이 "{formData.filterValues || "값"}"인 항목만 표시
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 우선순위 */}
|
||||
<div className="space-y-2">
|
||||
<Label>우선순위</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: Number(e.target.value) })}
|
||||
placeholder="높을수록 먼저 적용"
|
||||
className="w-32"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
여러 조건이 일치할 경우 우선순위가 높은 규칙이 적용됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{editingCondition ? "수정" : "생성"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>조건부 규칙 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 조건부 규칙을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,627 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Plus, Pencil, Trash2, Database, RefreshCw, Layers } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import {
|
||||
hierarchyColumnApi,
|
||||
HierarchyColumnGroup,
|
||||
CreateHierarchyGroupRequest,
|
||||
} from "@/lib/api/hierarchyColumn";
|
||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
import apiClient from "@/lib/api/client";
|
||||
|
||||
interface TableInfo {
|
||||
tableName: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
columnName: string;
|
||||
displayName?: string;
|
||||
dataType?: string;
|
||||
}
|
||||
|
||||
interface CategoryInfo {
|
||||
categoryCode: string;
|
||||
categoryName: string;
|
||||
}
|
||||
|
||||
export default function HierarchyColumnTab() {
|
||||
// 상태
|
||||
const [groups, setGroups] = useState<HierarchyColumnGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [selectedGroup, setSelectedGroup] = useState<HierarchyColumnGroup | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState({
|
||||
groupCode: "",
|
||||
groupName: "",
|
||||
description: "",
|
||||
codeCategory: "",
|
||||
tableName: "",
|
||||
maxDepth: 3,
|
||||
mappings: [
|
||||
{ depth: 1, levelLabel: "대분류", columnName: "", placeholder: "대분류 선택", isRequired: true },
|
||||
{ depth: 2, levelLabel: "중분류", columnName: "", placeholder: "중분류 선택", isRequired: false },
|
||||
{ depth: 3, levelLabel: "소분류", columnName: "", placeholder: "소분류 선택", isRequired: false },
|
||||
],
|
||||
});
|
||||
|
||||
// 참조 데이터
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||
const [categories, setCategories] = useState<CategoryInfo[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [loadingCategories, setLoadingCategories] = useState(false);
|
||||
|
||||
// 그룹 목록 로드
|
||||
const loadGroups = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await hierarchyColumnApi.getAll();
|
||||
if (response.success && response.data) {
|
||||
setGroups(response.data);
|
||||
} else {
|
||||
toast.error(response.error || "계층구조 그룹 로드 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("계층구조 그룹 로드 에러:", error);
|
||||
toast.error("계층구조 그룹을 로드하는 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 테이블 목록 로드
|
||||
const loadTables = useCallback(async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const response = await apiClient.get("/table-management/tables");
|
||||
if (response.data?.success && response.data?.data) {
|
||||
setTables(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 로드 에러:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 카테고리 목록 로드
|
||||
const loadCategories = useCallback(async () => {
|
||||
setLoadingCategories(true);
|
||||
try {
|
||||
const response = await commonCodeApi.categories.getList();
|
||||
if (response.success && response.data) {
|
||||
setCategories(
|
||||
response.data.map((cat: any) => ({
|
||||
categoryCode: cat.categoryCode || cat.category_code,
|
||||
categoryName: cat.categoryName || cat.category_name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 로드 에러:", error);
|
||||
} finally {
|
||||
setLoadingCategories(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 테이블 선택 시 컬럼 로드
|
||||
const loadColumns = useCallback(async (tableName: string) => {
|
||||
if (!tableName) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
if (response.data?.success && response.data?.data) {
|
||||
setColumns(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 로드 에러:", error);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
loadGroups();
|
||||
loadTables();
|
||||
loadCategories();
|
||||
}, [loadGroups, loadTables, loadCategories]);
|
||||
|
||||
// 테이블 선택 변경 시 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (formData.tableName) {
|
||||
loadColumns(formData.tableName);
|
||||
}
|
||||
}, [formData.tableName, loadColumns]);
|
||||
|
||||
// 폼 초기화
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
groupCode: "",
|
||||
groupName: "",
|
||||
description: "",
|
||||
codeCategory: "",
|
||||
tableName: "",
|
||||
maxDepth: 3,
|
||||
mappings: [
|
||||
{ depth: 1, levelLabel: "대분류", columnName: "", placeholder: "대분류 선택", isRequired: true },
|
||||
{ depth: 2, levelLabel: "중분류", columnName: "", placeholder: "중분류 선택", isRequired: false },
|
||||
{ depth: 3, levelLabel: "소분류", columnName: "", placeholder: "소분류 선택", isRequired: false },
|
||||
],
|
||||
});
|
||||
setSelectedGroup(null);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
// 모달 열기 (신규)
|
||||
const openCreateModal = () => {
|
||||
resetForm();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// 모달 열기 (수정)
|
||||
const openEditModal = (group: HierarchyColumnGroup) => {
|
||||
setSelectedGroup(group);
|
||||
setIsEditing(true);
|
||||
|
||||
// 매핑 데이터 변환
|
||||
const mappings = [1, 2, 3].map((depth) => {
|
||||
const existing = group.mappings?.find((m) => m.depth === depth);
|
||||
return {
|
||||
depth,
|
||||
levelLabel: existing?.level_label || (depth === 1 ? "대분류" : depth === 2 ? "중분류" : "소분류"),
|
||||
columnName: existing?.column_name || "",
|
||||
placeholder: existing?.placeholder || `${depth === 1 ? "대분류" : depth === 2 ? "중분류" : "소분류"} 선택`,
|
||||
isRequired: existing?.is_required === "Y",
|
||||
};
|
||||
});
|
||||
|
||||
setFormData({
|
||||
groupCode: group.group_code,
|
||||
groupName: group.group_name,
|
||||
description: group.description || "",
|
||||
codeCategory: group.code_category,
|
||||
tableName: group.table_name,
|
||||
maxDepth: group.max_depth,
|
||||
mappings,
|
||||
});
|
||||
|
||||
// 컬럼 로드
|
||||
loadColumns(group.table_name);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 확인 열기
|
||||
const openDeleteDialog = (group: HierarchyColumnGroup) => {
|
||||
setSelectedGroup(group);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
// 필수 필드 검증
|
||||
if (!formData.groupCode || !formData.groupName || !formData.codeCategory || !formData.tableName) {
|
||||
toast.error("필수 필드를 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 최소 1개 컬럼 매핑 검증
|
||||
const validMappings = formData.mappings
|
||||
.filter((m) => m.depth <= formData.maxDepth && m.columnName)
|
||||
.map((m) => ({
|
||||
depth: m.depth,
|
||||
levelLabel: m.levelLabel,
|
||||
columnName: m.columnName,
|
||||
placeholder: m.placeholder,
|
||||
isRequired: m.isRequired,
|
||||
}));
|
||||
|
||||
if (validMappings.length === 0) {
|
||||
toast.error("최소 하나의 컬럼 매핑이 필요합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEditing && selectedGroup) {
|
||||
// 수정
|
||||
const response = await hierarchyColumnApi.update(selectedGroup.group_id, {
|
||||
groupName: formData.groupName,
|
||||
description: formData.description,
|
||||
maxDepth: formData.maxDepth,
|
||||
mappings: validMappings,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
toast.success("계층구조 그룹이 수정되었습니다.");
|
||||
setModalOpen(false);
|
||||
loadGroups();
|
||||
} else {
|
||||
toast.error(response.error || "수정 실패");
|
||||
}
|
||||
} else {
|
||||
// 생성
|
||||
const request: CreateHierarchyGroupRequest = {
|
||||
groupCode: formData.groupCode,
|
||||
groupName: formData.groupName,
|
||||
description: formData.description,
|
||||
codeCategory: formData.codeCategory,
|
||||
tableName: formData.tableName,
|
||||
maxDepth: formData.maxDepth,
|
||||
mappings: validMappings,
|
||||
};
|
||||
|
||||
const response = await hierarchyColumnApi.create(request);
|
||||
|
||||
if (response.success) {
|
||||
toast.success("계층구조 그룹이 생성되었습니다.");
|
||||
setModalOpen(false);
|
||||
loadGroups();
|
||||
} else {
|
||||
toast.error(response.error || "생성 실패");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("저장 에러:", error);
|
||||
showErrorToast("계층구조 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const handleDelete = async () => {
|
||||
if (!selectedGroup) return;
|
||||
|
||||
try {
|
||||
const response = await hierarchyColumnApi.delete(selectedGroup.group_id);
|
||||
if (response.success) {
|
||||
toast.success("계층구조 그룹이 삭제되었습니다.");
|
||||
setDeleteDialogOpen(false);
|
||||
loadGroups();
|
||||
} else {
|
||||
toast.error(response.error || "삭제 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("삭제 에러:", error);
|
||||
showErrorToast("계층구조 설정 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
||||
}
|
||||
};
|
||||
|
||||
// 매핑 컬럼 변경
|
||||
const handleMappingChange = (depth: number, field: string, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
mappings: prev.mappings.map((m) =>
|
||||
m.depth === depth ? { ...m, [field]: value } : m
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">계층구조 컬럼 그룹</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
공통코드 계층구조를 테이블 컬럼에 매핑하여 대분류/중분류/소분류를 각각 별도 컬럼에 저장합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={loadGroups} disabled={loading}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreateModal}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
새 그룹 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 그룹 목록 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<LoadingSpinner />
|
||||
<span className="ml-2 text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Layers className="h-12 w-12 text-muted-foreground" />
|
||||
<p className="mt-4 text-muted-foreground">계층구조 컬럼 그룹이 없습니다.</p>
|
||||
<Button className="mt-4" onClick={openCreateModal}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
첫 번째 그룹 만들기
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{groups.map((group) => (
|
||||
<Card key={group.group_id} className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">{group.group_name}</CardTitle>
|
||||
<CardDescription className="text-xs">{group.group_code}</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEditModal(group)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => openDeleteDialog(group)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{group.table_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{group.code_category}</Badge>
|
||||
<Badge variant="secondary">{group.max_depth}단계</Badge>
|
||||
</div>
|
||||
{group.mappings && group.mappings.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{group.mappings.map((mapping) => (
|
||||
<div key={mapping.depth} className="flex items-center gap-2 text-xs">
|
||||
<Badge variant="outline" className="w-14 justify-center">
|
||||
{mapping.level_label}
|
||||
</Badge>
|
||||
<span className="font-mono text-muted-foreground">{mapping.column_name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 생성/수정 모달 */}
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className="max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? "계층구조 그룹 수정" : "계층구조 그룹 생성"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
공통코드 계층구조를 테이블 컬럼에 매핑합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 기본 정보 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>그룹 코드 *</Label>
|
||||
<Input
|
||||
value={formData.groupCode}
|
||||
onChange={(e) => setFormData({ ...formData, groupCode: e.target.value.toUpperCase() })}
|
||||
placeholder="예: ITEM_CAT_HIERARCHY"
|
||||
disabled={isEditing}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>그룹명 *</Label>
|
||||
<Input
|
||||
value={formData.groupName}
|
||||
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
|
||||
placeholder="예: 품목분류 계층"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>설명</Label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="계층구조에 대한 설명"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>공통코드 카테고리 *</Label>
|
||||
<Select
|
||||
value={formData.codeCategory}
|
||||
onValueChange={(value) => setFormData({ ...formData, codeCategory: value })}
|
||||
disabled={isEditing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loadingCategories ? (
|
||||
<SelectItem value="_loading" disabled>로딩 중...</SelectItem>
|
||||
) : (
|
||||
categories.map((cat) => (
|
||||
<SelectItem key={cat.categoryCode} value={cat.categoryCode}>
|
||||
{cat.categoryName} ({cat.categoryCode})
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>적용 테이블 *</Label>
|
||||
<Select
|
||||
value={formData.tableName}
|
||||
onValueChange={(value) => setFormData({ ...formData, tableName: value })}
|
||||
disabled={isEditing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loadingTables ? (
|
||||
<SelectItem value="_loading" disabled>로딩 중...</SelectItem>
|
||||
) : (
|
||||
tables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{table.displayName || table.tableName}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>최대 깊이</Label>
|
||||
<Select
|
||||
value={String(formData.maxDepth)}
|
||||
onValueChange={(value) => setFormData({ ...formData, maxDepth: Number(value) })}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1단계 (대분류만)</SelectItem>
|
||||
<SelectItem value="2">2단계 (대/중분류)</SelectItem>
|
||||
<SelectItem value="3">3단계 (대/중/소분류)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 매핑 */}
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<Label className="text-base font-medium">컬럼 매핑</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
각 계층 레벨에 저장할 컬럼을 선택합니다.
|
||||
</p>
|
||||
|
||||
{formData.mappings
|
||||
.filter((m) => m.depth <= formData.maxDepth)
|
||||
.map((mapping) => (
|
||||
<div key={mapping.depth} className="grid grid-cols-4 gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={mapping.depth === 1 ? "default" : "outline"}>
|
||||
{mapping.depth}단계
|
||||
</Badge>
|
||||
<Input
|
||||
value={mapping.levelLabel}
|
||||
onChange={(e) => handleMappingChange(mapping.depth, "levelLabel", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
placeholder="라벨"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={mapping.columnName || "_none"}
|
||||
onValueChange={(value) => handleMappingChange(mapping.depth, "columnName", value === "_none" ? "" : value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none">컬럼 선택</SelectItem>
|
||||
{loadingColumns ? (
|
||||
<SelectItem value="_loading" disabled>로딩 중...</SelectItem>
|
||||
) : (
|
||||
columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={mapping.placeholder}
|
||||
onChange={(e) => handleMappingChange(mapping.depth, "placeholder", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
placeholder="플레이스홀더"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mapping.isRequired}
|
||||
onChange={(e) => handleMappingChange(mapping.depth, "isRequired", e.target.checked)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">필수</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{isEditing ? "수정" : "생성"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>계층구조 그룹 삭제</DialogTitle>
|
||||
<DialogDescription>
|
||||
"{selectedGroup?.group_name}" 그룹을 삭제하시겠습니까?
|
||||
<br />
|
||||
이 작업은 되돌릴 수 없습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
삭제
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,847 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Layers,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Pencil,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { hierarchyApi, HierarchyGroup, HierarchyLevel, HIERARCHY_TYPES } from "@/lib/api/cascadingHierarchy";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
|
||||
export default function HierarchyTab() {
|
||||
// 목록 상태
|
||||
const [groups, setGroups] = useState<HierarchyGroup[]>([]);
|
||||
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
// 확장된 그룹 (레벨 표시)
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const [groupLevels, setGroupLevels] = useState<Record<string, HierarchyLevel[]>>({});
|
||||
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<HierarchyGroup | null>(null);
|
||||
const [deletingGroupCode, setDeletingGroupCode] = useState<string | null>(null);
|
||||
|
||||
// 레벨 모달
|
||||
const [isLevelModalOpen, setIsLevelModalOpen] = useState(false);
|
||||
const [editingLevel, setEditingLevel] = useState<HierarchyLevel | null>(null);
|
||||
const [currentGroupCode, setCurrentGroupCode] = useState<string>("");
|
||||
const [levelColumns, setLevelColumns] = useState<Array<{ columnName: string; displayName: string }>>([]);
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<Partial<HierarchyGroup>>({
|
||||
groupName: "",
|
||||
description: "",
|
||||
hierarchyType: "MULTI_TABLE",
|
||||
maxLevels: undefined,
|
||||
isFixedLevels: "Y",
|
||||
emptyMessage: "선택해주세요",
|
||||
noOptionsMessage: "옵션이 없습니다",
|
||||
loadingMessage: "로딩 중...",
|
||||
});
|
||||
|
||||
// 레벨 폼 데이터
|
||||
const [levelFormData, setLevelFormData] = useState<Partial<HierarchyLevel>>({
|
||||
levelOrder: 1,
|
||||
levelName: "",
|
||||
levelCode: "",
|
||||
tableName: "",
|
||||
valueColumn: "",
|
||||
labelColumn: "",
|
||||
parentKeyColumn: "",
|
||||
orderColumn: "",
|
||||
orderDirection: "ASC",
|
||||
placeholder: "",
|
||||
isRequired: "Y",
|
||||
isSearchable: "N",
|
||||
});
|
||||
|
||||
// 테이블 Combobox 상태
|
||||
const [tableComboOpen, setTableComboOpen] = useState(false);
|
||||
|
||||
// snake_case를 camelCase로 변환하는 함수
|
||||
const transformGroup = (g: any): HierarchyGroup => ({
|
||||
groupId: g.group_id,
|
||||
groupCode: g.group_code,
|
||||
groupName: g.group_name,
|
||||
description: g.description,
|
||||
hierarchyType: g.hierarchy_type,
|
||||
maxLevels: g.max_levels,
|
||||
isFixedLevels: g.is_fixed_levels,
|
||||
selfRefTable: g.self_ref_table,
|
||||
selfRefIdColumn: g.self_ref_id_column,
|
||||
selfRefParentColumn: g.self_ref_parent_column,
|
||||
selfRefValueColumn: g.self_ref_value_column,
|
||||
selfRefLabelColumn: g.self_ref_label_column,
|
||||
selfRefLevelColumn: g.self_ref_level_column,
|
||||
selfRefOrderColumn: g.self_ref_order_column,
|
||||
bomTable: g.bom_table,
|
||||
bomParentColumn: g.bom_parent_column,
|
||||
bomChildColumn: g.bom_child_column,
|
||||
bomItemTable: g.bom_item_table,
|
||||
bomItemIdColumn: g.bom_item_id_column,
|
||||
bomItemLabelColumn: g.bom_item_label_column,
|
||||
bomQtyColumn: g.bom_qty_column,
|
||||
bomLevelColumn: g.bom_level_column,
|
||||
emptyMessage: g.empty_message,
|
||||
noOptionsMessage: g.no_options_message,
|
||||
loadingMessage: g.loading_message,
|
||||
companyCode: g.company_code,
|
||||
isActive: g.is_active,
|
||||
createdBy: g.created_by,
|
||||
createdDate: g.created_date,
|
||||
updatedBy: g.updated_by,
|
||||
updatedDate: g.updated_date,
|
||||
levelCount: g.level_count || 0,
|
||||
levels: g.levels,
|
||||
});
|
||||
|
||||
// 목록 로드
|
||||
const loadGroups = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await hierarchyApi.getGroups();
|
||||
if (response.success && response.data) {
|
||||
// snake_case를 camelCase로 변환
|
||||
const transformedData = response.data.map(transformGroup);
|
||||
setGroups(transformedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("계층 그룹 목록 로드 실패:", error);
|
||||
toast.error("목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 테이블 목록 로드
|
||||
const loadTables = useCallback(async () => {
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setTables(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadGroups();
|
||||
loadTables();
|
||||
}, [loadGroups, loadTables]);
|
||||
|
||||
// 그룹 레벨 로드
|
||||
const loadGroupLevels = async (groupCode: string) => {
|
||||
try {
|
||||
const response = await hierarchyApi.getDetail(groupCode);
|
||||
if (response.success && response.data?.levels) {
|
||||
setGroupLevels((prev) => ({
|
||||
...prev,
|
||||
[groupCode]: response.data!.levels || [],
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("레벨 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 그룹 확장 토글
|
||||
const toggleGroupExpand = async (groupCode: string) => {
|
||||
const newExpanded = new Set(expandedGroups);
|
||||
if (newExpanded.has(groupCode)) {
|
||||
newExpanded.delete(groupCode);
|
||||
} else {
|
||||
newExpanded.add(groupCode);
|
||||
if (!groupLevels[groupCode]) {
|
||||
await loadGroupLevels(groupCode);
|
||||
}
|
||||
}
|
||||
setExpandedGroups(newExpanded);
|
||||
};
|
||||
|
||||
// 컬럼 로드 (레벨 폼용)
|
||||
const loadLevelColumns = async (tableName: string) => {
|
||||
if (!tableName) {
|
||||
setLevelColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(tableName);
|
||||
if (response.success && response.data?.columns) {
|
||||
setLevelColumns(response.data.columns);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 필터된 목록
|
||||
const filteredGroups = groups.filter(
|
||||
(g) =>
|
||||
g.groupName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
g.groupCode?.toLowerCase().includes(searchText.toLowerCase()),
|
||||
);
|
||||
|
||||
// 모달 열기 (생성)
|
||||
const handleOpenCreate = () => {
|
||||
setEditingGroup(null);
|
||||
setFormData({
|
||||
groupName: "",
|
||||
description: "",
|
||||
hierarchyType: "MULTI_TABLE",
|
||||
maxLevels: undefined,
|
||||
isFixedLevels: "Y",
|
||||
emptyMessage: "선택해주세요",
|
||||
noOptionsMessage: "옵션이 없습니다",
|
||||
loadingMessage: "로딩 중...",
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 모달 열기 (수정)
|
||||
const handleOpenEdit = (group: HierarchyGroup) => {
|
||||
setEditingGroup(group);
|
||||
setFormData({
|
||||
groupCode: group.groupCode,
|
||||
groupName: group.groupName,
|
||||
description: group.description || "",
|
||||
hierarchyType: group.hierarchyType,
|
||||
maxLevels: group.maxLevels,
|
||||
isFixedLevels: group.isFixedLevels || "Y",
|
||||
emptyMessage: group.emptyMessage || "선택해주세요",
|
||||
noOptionsMessage: group.noOptionsMessage || "옵션이 없습니다",
|
||||
loadingMessage: group.loadingMessage || "로딩 중...",
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleDeleteConfirm = (groupCode: string) => {
|
||||
setDeletingGroupCode(groupCode);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 실행
|
||||
const handleDelete = async () => {
|
||||
if (!deletingGroupCode) return;
|
||||
|
||||
try {
|
||||
const response = await hierarchyApi.deleteGroup(deletingGroupCode);
|
||||
if (response.success) {
|
||||
toast.success("계층 그룹이 삭제되었습니다.");
|
||||
loadGroups();
|
||||
} else {
|
||||
toast.error(response.error || "삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("삭제 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeletingGroupCode(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!formData.groupName || !formData.hierarchyType) {
|
||||
toast.error("필수 항목을 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (editingGroup) {
|
||||
response = await hierarchyApi.updateGroup(editingGroup.groupCode!, formData);
|
||||
} else {
|
||||
response = await hierarchyApi.createGroup(formData as HierarchyGroup);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(editingGroup ? "수정되었습니다." : "생성되었습니다.");
|
||||
setIsModalOpen(false);
|
||||
loadGroups();
|
||||
} else {
|
||||
toast.error(response.error || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 레벨 모달 열기 (생성)
|
||||
const handleOpenCreateLevel = (groupCode: string) => {
|
||||
setCurrentGroupCode(groupCode);
|
||||
setEditingLevel(null);
|
||||
const existingLevels = groupLevels[groupCode] || [];
|
||||
setLevelFormData({
|
||||
levelOrder: existingLevels.length + 1,
|
||||
levelName: "",
|
||||
levelCode: "",
|
||||
tableName: "",
|
||||
valueColumn: "",
|
||||
labelColumn: "",
|
||||
parentKeyColumn: "",
|
||||
orderColumn: "",
|
||||
orderDirection: "ASC",
|
||||
placeholder: "",
|
||||
isRequired: "Y",
|
||||
isSearchable: "N",
|
||||
});
|
||||
setLevelColumns([]);
|
||||
setIsLevelModalOpen(true);
|
||||
};
|
||||
|
||||
// 레벨 모달 열기 (수정)
|
||||
const handleOpenEditLevel = async (level: HierarchyLevel) => {
|
||||
setCurrentGroupCode(level.groupCode);
|
||||
setEditingLevel(level);
|
||||
setLevelFormData({
|
||||
levelOrder: level.levelOrder,
|
||||
levelName: level.levelName,
|
||||
levelCode: level.levelCode || "",
|
||||
tableName: level.tableName,
|
||||
valueColumn: level.valueColumn,
|
||||
labelColumn: level.labelColumn,
|
||||
parentKeyColumn: level.parentKeyColumn || "",
|
||||
orderColumn: level.orderColumn || "",
|
||||
orderDirection: level.orderDirection || "ASC",
|
||||
placeholder: level.placeholder || "",
|
||||
isRequired: level.isRequired || "Y",
|
||||
isSearchable: level.isSearchable || "N",
|
||||
});
|
||||
await loadLevelColumns(level.tableName);
|
||||
setIsLevelModalOpen(true);
|
||||
};
|
||||
|
||||
// 레벨 저장
|
||||
const handleSaveLevel = async () => {
|
||||
if (
|
||||
!levelFormData.levelName ||
|
||||
!levelFormData.tableName ||
|
||||
!levelFormData.valueColumn ||
|
||||
!levelFormData.labelColumn
|
||||
) {
|
||||
toast.error("필수 항목을 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (editingLevel) {
|
||||
response = await hierarchyApi.updateLevel(editingLevel.levelId!, levelFormData);
|
||||
} else {
|
||||
response = await hierarchyApi.addLevel(currentGroupCode, levelFormData);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(editingLevel ? "레벨이 수정되었습니다." : "레벨이 추가되었습니다.");
|
||||
setIsLevelModalOpen(false);
|
||||
await loadGroupLevels(currentGroupCode);
|
||||
} else {
|
||||
toast.error(response.error || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 레벨 삭제
|
||||
const handleDeleteLevel = async (levelId: number, groupCode: string) => {
|
||||
if (!confirm("이 레벨을 삭제하시겠습니까?")) return;
|
||||
|
||||
try {
|
||||
const response = await hierarchyApi.deleteLevel(levelId);
|
||||
if (response.success) {
|
||||
toast.success("레벨이 삭제되었습니다.");
|
||||
await loadGroupLevels(groupCode);
|
||||
} else {
|
||||
toast.error(response.error || "삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("삭제 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 계층 타입 라벨
|
||||
const getHierarchyTypeLabel = (type: string) => {
|
||||
return HIERARCHY_TYPES.find((t) => t.value === type)?.label || type;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 검색 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="그룹 코드, 이름으로 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={loadGroups}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 목록 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Layers className="h-5 w-5" />
|
||||
다단계 계층
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
국가 > 도시 > 구 같은 다단계 연쇄 드롭다운을 관리합니다. (총 {filteredGroups.length}개)
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={handleOpenCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 계층 추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||
<span className="ml-2">로딩 중...</span>
|
||||
</div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="text-muted-foreground space-y-4 py-8 text-center">
|
||||
<div className="text-sm">{searchText ? "검색 결과가 없습니다." : "등록된 계층 그룹이 없습니다."}</div>
|
||||
<div className="mx-auto max-w-md space-y-3 text-left">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="text-foreground mb-2 text-sm font-medium">예시: 지역 계층</div>
|
||||
<div className="text-muted-foreground text-xs">국가 > 시/도 > 시/군/구 > 읍/면/동</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="text-foreground mb-2 text-sm font-medium">예시: 조직 계층</div>
|
||||
<div className="text-muted-foreground text-xs">본부 > 팀 > 파트 (자기 참조 구조)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredGroups.map((group) => (
|
||||
<div key={group.groupCode} className="rounded-lg border">
|
||||
<div
|
||||
className="hover:bg-muted/50 flex cursor-pointer items-center justify-between p-4"
|
||||
onClick={() => toggleGroupExpand(group.groupCode)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{expandedGroups.has(group.groupCode) ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium">{group.groupName}</div>
|
||||
<div className="text-muted-foreground text-xs">{group.groupCode}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="outline">{getHierarchyTypeLabel(group.hierarchyType)}</Badge>
|
||||
<Badge variant="secondary">{group.levelCount || 0}개 레벨</Badge>
|
||||
<Badge variant={group.isActive === "Y" ? "default" : "secondary"}>
|
||||
{group.isActive === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(group)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(group.groupCode)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 레벨 목록 */}
|
||||
{expandedGroups.has(group.groupCode) && (
|
||||
<div className="bg-muted/20 border-t p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">레벨 목록</span>
|
||||
<Button size="sm" variant="outline" onClick={() => handleOpenCreateLevel(group.groupCode)}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
레벨 추가
|
||||
</Button>
|
||||
</div>
|
||||
{(groupLevels[group.groupCode] || []).length === 0 ? (
|
||||
<div className="text-muted-foreground py-4 text-center text-sm">등록된 레벨이 없습니다.</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">순서</TableHead>
|
||||
<TableHead>레벨명</TableHead>
|
||||
<TableHead>테이블</TableHead>
|
||||
<TableHead>값 컬럼</TableHead>
|
||||
<TableHead>부모 키</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(groupLevels[group.groupCode] || []).map((level) => (
|
||||
<TableRow key={level.levelId}>
|
||||
<TableCell>{level.levelOrder}</TableCell>
|
||||
<TableCell className="font-medium">{level.levelName}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{level.tableName}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{level.valueColumn}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{level.parentKeyColumn || "-"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleOpenEditLevel(level)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteLevel(level.levelId!, group.groupCode)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 그룹 생성/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingGroup ? "계층 그룹 수정" : "계층 그룹 생성"}</DialogTitle>
|
||||
<DialogDescription>다단계 연쇄 드롭다운의 기본 정보를 설정합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>그룹명 *</Label>
|
||||
<Input
|
||||
value={formData.groupName}
|
||||
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
|
||||
placeholder="예: 지역 계층"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>계층 유형 *</Label>
|
||||
<Select
|
||||
value={formData.hierarchyType}
|
||||
onValueChange={(v: any) => setFormData({ ...formData, hierarchyType: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HIERARCHY_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>설명</Label>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="계층 구조에 대한 설명"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>최대 레벨 수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.maxLevels || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, maxLevels: e.target.value ? Number(e.target.value) : undefined })
|
||||
}
|
||||
placeholder="예: 4"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>고정 레벨 여부</Label>
|
||||
<Select
|
||||
value={formData.isFixedLevels}
|
||||
onValueChange={(v) => setFormData({ ...formData, isFixedLevels: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y">고정</SelectItem>
|
||||
<SelectItem value="N">가변</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{editingGroup ? "수정" : "생성"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 레벨 생성/수정 모달 */}
|
||||
<Dialog open={isLevelModalOpen} onOpenChange={setIsLevelModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingLevel ? "레벨 수정" : "레벨 추가"}</DialogTitle>
|
||||
<DialogDescription>계층의 개별 레벨 정보를 설정합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>레벨 순서 *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={levelFormData.levelOrder}
|
||||
onChange={(e) => setLevelFormData({ ...levelFormData, levelOrder: Number(e.target.value) })}
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>레벨명 *</Label>
|
||||
<Input
|
||||
value={levelFormData.levelName}
|
||||
onChange={(e) => setLevelFormData({ ...levelFormData, levelName: e.target.value })}
|
||||
placeholder="예: 시/도"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>테이블 *</Label>
|
||||
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboOpen}
|
||||
className="h-10 w-full justify-between text-sm"
|
||||
>
|
||||
{levelFormData.tableName
|
||||
? tables.find((t) => t.tableName === levelFormData.tableName)?.displayName ||
|
||||
levelFormData.tableName
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
|
||||
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables.map((t) => (
|
||||
<CommandItem
|
||||
key={t.tableName}
|
||||
value={`${t.tableName} ${t.displayName || ""}`}
|
||||
onSelect={async () => {
|
||||
setLevelFormData({
|
||||
...levelFormData,
|
||||
tableName: t.tableName,
|
||||
valueColumn: "",
|
||||
labelColumn: "",
|
||||
parentKeyColumn: "",
|
||||
});
|
||||
await loadLevelColumns(t.tableName);
|
||||
setTableComboOpen(false);
|
||||
}}
|
||||
className="text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
levelFormData.tableName === t.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{t.displayName || t.tableName}</span>
|
||||
{t.displayName && t.displayName !== t.tableName && (
|
||||
<span className="text-muted-foreground text-xs">{t.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>값 컬럼 *</Label>
|
||||
<Select
|
||||
value={levelFormData.valueColumn}
|
||||
onValueChange={(v) => setLevelFormData({ ...levelFormData, valueColumn: v })}
|
||||
disabled={!levelFormData.tableName}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{levelColumns.map((c) => (
|
||||
<SelectItem key={c.columnName} value={c.columnName}>
|
||||
{c.displayName || c.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>라벨 컬럼 *</Label>
|
||||
<Select
|
||||
value={levelFormData.labelColumn}
|
||||
onValueChange={(v) => setLevelFormData({ ...levelFormData, labelColumn: v })}
|
||||
disabled={!levelFormData.tableName}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{levelColumns.map((c) => (
|
||||
<SelectItem key={c.columnName} value={c.columnName}>
|
||||
{c.displayName || c.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>부모 키 컬럼 (레벨 2 이상)</Label>
|
||||
<Select
|
||||
value={levelFormData.parentKeyColumn || "__none__"}
|
||||
onValueChange={(v) =>
|
||||
setLevelFormData({ ...levelFormData, parentKeyColumn: v === "__none__" ? "" : v })
|
||||
}
|
||||
disabled={!levelFormData.tableName}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{levelColumns.map((c) => (
|
||||
<SelectItem key={c.columnName} value={c.columnName}>
|
||||
{c.displayName || c.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">상위 레벨의 선택 값을 참조하는 컬럼입니다.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>플레이스홀더</Label>
|
||||
<Input
|
||||
value={levelFormData.placeholder}
|
||||
onChange={(e) => setLevelFormData({ ...levelFormData, placeholder: e.target.value })}
|
||||
placeholder={`${levelFormData.levelName || "레벨"} 선택`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsLevelModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSaveLevel}>{editingLevel ? "수정" : "추가"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>계층 그룹 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 계층 그룹과 모든 레벨을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,582 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Ban, Check, ChevronsUpDown, Plus, RefreshCw, Search, Pencil, Trash2 } from "lucide-react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { mutualExclusionApi, MutualExclusion, EXCLUSION_TYPES } from "@/lib/api/cascadingMutualExclusion";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
|
||||
export default function MutualExclusionTab() {
|
||||
// 목록 상태
|
||||
const [exclusions, setExclusions] = useState<MutualExclusion[]>([]);
|
||||
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||
const [columns, setColumns] = useState<Array<{ columnName: string; displayName: string }>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
// 테이블 Combobox 상태
|
||||
const [tableComboOpen, setTableComboOpen] = useState(false);
|
||||
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [editingExclusion, setEditingExclusion] = useState<MutualExclusion | null>(null);
|
||||
const [deletingExclusionId, setDeletingExclusionId] = useState<number | null>(null);
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<Omit<MutualExclusion, "exclusionId" | "exclusionCode">>({
|
||||
exclusionName: "",
|
||||
fieldNames: "",
|
||||
sourceTable: "",
|
||||
valueColumn: "",
|
||||
labelColumn: "",
|
||||
exclusionType: "SAME_VALUE",
|
||||
errorMessage: "동일한 값을 선택할 수 없습니다",
|
||||
});
|
||||
|
||||
// 필드 목록 (동적 추가)
|
||||
const [fieldList, setFieldList] = useState<string[]>(["", ""]);
|
||||
|
||||
// 목록 로드
|
||||
const loadExclusions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await mutualExclusionApi.getList();
|
||||
if (response.success && response.data) {
|
||||
setExclusions(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("상호 배제 목록 로드 실패:", error);
|
||||
toast.error("목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 테이블 목록 로드
|
||||
const loadTables = useCallback(async () => {
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setTables(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadExclusions();
|
||||
loadTables();
|
||||
}, [loadExclusions, loadTables]);
|
||||
|
||||
// 테이블 선택 시 컬럼 로드
|
||||
const loadColumns = async (tableName: string) => {
|
||||
if (!tableName) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(tableName);
|
||||
if (response.success && response.data?.columns) {
|
||||
setColumns(response.data.columns);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 필터된 목록
|
||||
const filteredExclusions = exclusions.filter(
|
||||
(e) =>
|
||||
e.exclusionName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
e.exclusionCode?.toLowerCase().includes(searchText.toLowerCase()),
|
||||
);
|
||||
|
||||
// 모달 열기 (생성)
|
||||
const handleOpenCreate = () => {
|
||||
setEditingExclusion(null);
|
||||
setFormData({
|
||||
exclusionName: "",
|
||||
fieldNames: "",
|
||||
sourceTable: "",
|
||||
valueColumn: "",
|
||||
labelColumn: "",
|
||||
exclusionType: "SAME_VALUE",
|
||||
errorMessage: "동일한 값을 선택할 수 없습니다",
|
||||
});
|
||||
setFieldList(["", ""]);
|
||||
setColumns([]);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 모달 열기 (수정)
|
||||
const handleOpenEdit = async (exclusion: MutualExclusion) => {
|
||||
setEditingExclusion(exclusion);
|
||||
setFormData({
|
||||
exclusionCode: exclusion.exclusionCode,
|
||||
exclusionName: exclusion.exclusionName,
|
||||
fieldNames: exclusion.fieldNames,
|
||||
sourceTable: exclusion.sourceTable,
|
||||
valueColumn: exclusion.valueColumn,
|
||||
labelColumn: exclusion.labelColumn || "",
|
||||
exclusionType: exclusion.exclusionType || "SAME_VALUE",
|
||||
errorMessage: exclusion.errorMessage || "동일한 값을 선택할 수 없습니다",
|
||||
});
|
||||
setFieldList(exclusion.fieldNames.split(",").map((f) => f.trim()));
|
||||
await loadColumns(exclusion.sourceTable);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleDeleteConfirm = (exclusionId: number) => {
|
||||
setDeletingExclusionId(exclusionId);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 실행
|
||||
const handleDelete = async () => {
|
||||
if (!deletingExclusionId) return;
|
||||
|
||||
try {
|
||||
const response = await mutualExclusionApi.delete(deletingExclusionId);
|
||||
if (response.success) {
|
||||
toast.success("상호 배제 규칙이 삭제되었습니다.");
|
||||
loadExclusions();
|
||||
} else {
|
||||
toast.error(response.error || "삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("삭제 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeletingExclusionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 추가
|
||||
const addField = () => {
|
||||
setFieldList([...fieldList, ""]);
|
||||
};
|
||||
|
||||
// 필드 제거
|
||||
const removeField = (index: number) => {
|
||||
if (fieldList.length <= 2) {
|
||||
toast.error("최소 2개의 필드가 필요합니다.");
|
||||
return;
|
||||
}
|
||||
setFieldList(fieldList.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 필드 값 변경
|
||||
const updateField = (index: number, value: string) => {
|
||||
const newFields = [...fieldList];
|
||||
newFields[index] = value;
|
||||
setFieldList(newFields);
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
// 필드 목록 합치기
|
||||
const cleanedFields = fieldList.filter((f) => f.trim());
|
||||
if (cleanedFields.length < 2) {
|
||||
toast.error("최소 2개의 필드를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 유효성 검사
|
||||
if (!formData.exclusionName || !formData.sourceTable || !formData.valueColumn) {
|
||||
toast.error("필수 항목을 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const dataToSave = {
|
||||
...formData,
|
||||
fieldNames: cleanedFields.join(","),
|
||||
};
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (editingExclusion) {
|
||||
response = await mutualExclusionApi.update(editingExclusion.exclusionId!, dataToSave);
|
||||
} else {
|
||||
response = await mutualExclusionApi.create(dataToSave);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(editingExclusion ? "수정되었습니다." : "생성되었습니다.");
|
||||
setIsModalOpen(false);
|
||||
loadExclusions();
|
||||
} else {
|
||||
toast.error(response.error || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블 선택 핸들러
|
||||
const handleTableChange = async (tableName: string) => {
|
||||
setFormData({ ...formData, sourceTable: tableName, valueColumn: "", labelColumn: "" });
|
||||
await loadColumns(tableName);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 검색 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="배제 코드, 이름으로 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={loadExclusions}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 목록 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Ban className="h-5 w-5" />
|
||||
상호 배제 규칙
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
두 필드가 같은 값을 선택할 수 없도록 제한합니다. (총 {filteredExclusions.length}개)
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={handleOpenCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 규칙 추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||
<span className="ml-2">로딩 중...</span>
|
||||
</div>
|
||||
) : filteredExclusions.length === 0 ? (
|
||||
<div className="text-muted-foreground space-y-4 py-8 text-center">
|
||||
<div className="text-sm">
|
||||
{searchText ? "검색 결과가 없습니다." : "등록된 상호 배제 규칙이 없습니다."}
|
||||
</div>
|
||||
<div className="mx-auto max-w-md space-y-3 text-left">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="text-foreground mb-2 text-sm font-medium">예시: 창고 이동</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
"출발 창고"와 "도착 창고"가 같은 창고를 선택할 수 없도록 제한
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="text-foreground mb-2 text-sm font-medium">예시: 부서 이동</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
"현재 부서"와 "이동 부서"가 같은 부서를 선택할 수 없도록 제한
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>배제 코드</TableHead>
|
||||
<TableHead>배제명</TableHead>
|
||||
<TableHead>대상 필드</TableHead>
|
||||
<TableHead>소스 테이블</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredExclusions.map((exclusion) => (
|
||||
<TableRow key={exclusion.exclusionId}>
|
||||
<TableCell className="font-mono text-sm">{exclusion.exclusionCode}</TableCell>
|
||||
<TableCell className="font-medium">{exclusion.exclusionName}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{exclusion.fieldNames.split(",").map((field, idx) => (
|
||||
<Badge key={idx} variant="outline" className="text-xs">
|
||||
{field.trim()}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{exclusion.sourceTable}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={exclusion.isActive === "Y" ? "default" : "secondary"}>
|
||||
{exclusion.isActive === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(exclusion)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(exclusion.exclusionId!)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 생성/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingExclusion ? "상호 배제 규칙 수정" : "상호 배제 규칙 생성"}</DialogTitle>
|
||||
<DialogDescription>두 필드가 같은 값을 선택할 수 없도록 제한합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 배제명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>배제명 *</Label>
|
||||
<Input
|
||||
value={formData.exclusionName}
|
||||
onChange={(e) => setFormData({ ...formData, exclusionName: e.target.value })}
|
||||
placeholder="예: 창고 이동 제한"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 대상 필드 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold">대상 필드 (최소 2개)</h4>
|
||||
<Button variant="outline" size="sm" onClick={addField}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{fieldList.map((field, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={field}
|
||||
onChange={(e) => updateField(index, e.target.value)}
|
||||
placeholder={`필드 ${index + 1} (예: source_warehouse)`}
|
||||
className="flex-1"
|
||||
/>
|
||||
{fieldList.length > 2 && (
|
||||
<Button variant="ghost" size="icon" onClick={() => removeField(index)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2 text-xs">이 필드들은 서로 같은 값을 선택할 수 없습니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 소스 테이블 및 컬럼 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>소스 테이블 *</Label>
|
||||
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboOpen}
|
||||
className="h-10 w-full justify-between text-sm"
|
||||
>
|
||||
{formData.sourceTable
|
||||
? tables.find((t) => t.tableName === formData.sourceTable)?.displayName || formData.sourceTable
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
|
||||
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables
|
||||
.filter((t) => t.tableName)
|
||||
.map((t) => (
|
||||
<CommandItem
|
||||
key={t.tableName}
|
||||
value={`${t.tableName} ${t.displayName || ""}`}
|
||||
onSelect={async () => {
|
||||
setFormData({
|
||||
...formData,
|
||||
sourceTable: t.tableName,
|
||||
valueColumn: "",
|
||||
labelColumn: "",
|
||||
});
|
||||
await loadColumns(t.tableName);
|
||||
setTableComboOpen(false);
|
||||
}}
|
||||
className="text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.sourceTable === t.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{t.displayName || t.tableName}</span>
|
||||
{t.displayName && t.displayName !== t.tableName && (
|
||||
<span className="text-muted-foreground text-xs">{t.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>값 컬럼 *</Label>
|
||||
<Select
|
||||
value={formData.valueColumn}
|
||||
onValueChange={(v) => setFormData({ ...formData, valueColumn: v })}
|
||||
disabled={!formData.sourceTable}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="값 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns
|
||||
.filter((c) => c.columnName)
|
||||
.map((c) => (
|
||||
<SelectItem key={c.columnName} value={c.columnName}>
|
||||
{c.displayName || c.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>라벨 컬럼</Label>
|
||||
<Select
|
||||
value={formData.labelColumn}
|
||||
onValueChange={(v) => setFormData({ ...formData, labelColumn: v })}
|
||||
disabled={!formData.sourceTable}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="라벨 컬럼 선택 (선택)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns
|
||||
.filter((c) => c.columnName)
|
||||
.map((c) => (
|
||||
<SelectItem key={c.columnName} value={c.columnName}>
|
||||
{c.displayName || c.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>배제 유형</Label>
|
||||
<Select
|
||||
value={formData.exclusionType}
|
||||
onValueChange={(v) => setFormData({ ...formData, exclusionType: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXCLUSION_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
<div className="space-y-2">
|
||||
<Label>에러 메시지</Label>
|
||||
<Input
|
||||
value={formData.errorMessage}
|
||||
onChange={(e) => setFormData({ ...formData, errorMessage: e.target.value })}
|
||||
placeholder="동일한 값을 선택할 수 없습니다"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{editingExclusion ? "수정" : "생성"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>상호 배제 규칙 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 상호 배제 규칙을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
/**
|
||||
* 기존 연쇄관계 페이지 → 통합 관리 페이지로 리다이렉트
|
||||
*/
|
||||
export default function CascadingRelationsRedirect() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace("/admin/cascading-management");
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground text-sm">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Plus, RefreshCw, Search, X, LayoutGrid, LayoutList, TestTube2, Database, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import ScreenDesigner from "@/components/screen/ScreenDesigner";
|
||||
import InvyoneStudio from "@/components/screen/InvyoneStudio";
|
||||
import TemplateManager from "@/components/screen/TemplateManager";
|
||||
import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView";
|
||||
import { ScreenRelationFlow } from "@/components/screen/ScreenRelationFlow";
|
||||
@@ -138,7 +138,7 @@ export default function ScreenManagementPage() {
|
||||
if (isDesignMode) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-background">
|
||||
<ScreenDesigner
|
||||
<InvyoneStudio
|
||||
selectedScreen={selectedScreen}
|
||||
onBackToList={() => goToStep("list")}
|
||||
onScreenUpdate={(updatedFields) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FileText, Download, Plus, Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { getCompaniesStats } from "@/lib/api/provisioning";
|
||||
@@ -9,6 +10,7 @@ import CompanyAccordionRow from "@/components/admin/provisioning/CompanyAccordio
|
||||
import Wizard from "@/components/admin/provisioning/wizard/Wizard";
|
||||
import AuditLogDrawer from "@/components/admin/provisioning/AuditLogDrawer";
|
||||
import { toCsvString, downloadCsv } from "@/lib/csvExport";
|
||||
import { isManagementHost } from "@/lib/tenant/subdomain";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
@@ -18,8 +20,22 @@ const PAGE_SIZE = 10;
|
||||
*
|
||||
* 기존 /admin/userMng/companyList (회사 기본 CRUD) 와는 스코프가 다름.
|
||||
* 이 페이지는 "테넌트 DB 생성 + 서브도메인 라우팅 + 회사 라이프사이클" 전용.
|
||||
*
|
||||
* 호스트 격리: 솔루션/관리 호스트(solution.invyone.com, localhost 등) 에서만 접근 가능.
|
||||
* 테넌트 사이트(qnc.invyone.com 등) 에서 URL 직접 진입 시 /main 으로 리다이렉트.
|
||||
* 백엔드 SuperAdminGuard 도 동일 정책으로 API 자체를 거절.
|
||||
*/
|
||||
export default function SubdomainListPage() {
|
||||
const router = useRouter();
|
||||
const [hostBlocked, setHostBlocked] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!isManagementHost(window.location.hostname)) {
|
||||
setHostBlocked(true);
|
||||
router.replace("/main");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const [openKey, setOpenKey] = useState<string | null>(null);
|
||||
const [q, setQ] = useState("");
|
||||
const [filter, setFilter] = useState<"all" | "active" | "provisioning" | "inactive" | "failed">("all");
|
||||
@@ -51,6 +67,7 @@ export default function SubdomainListPage() {
|
||||
const { data: rows = [], isLoading, refetch, dataUpdatedAt } = useQuery({
|
||||
queryKey: ["companies-stats"],
|
||||
queryFn: getCompaniesStats,
|
||||
enabled: !hostBlocked, // 테넌트 사이트에서는 API 도 안 부르고 곧장 redirect
|
||||
refetchInterval: (query) => {
|
||||
// provisioning 중인 회사 있으면 3초 폴링, 없으면 30초
|
||||
const hasProvisioning = Array.isArray(query.state.data)
|
||||
@@ -95,6 +112,12 @@ export default function SubdomainListPage() {
|
||||
const provisCount = rows.filter((r) => r.db_status === "provisioning").length;
|
||||
const inactCount = rows.filter((r) => r.db_status === "inactive" || r.status === "inactive").length;
|
||||
|
||||
// 호스트 격리 — 테넌트 사이트에서 진입한 경우 redirect 대기 중 빈 화면.
|
||||
// 데이터/UI 가 잠깐이라도 노출되지 않도록 본 render 보다 먼저 차단.
|
||||
if (hostBlocked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { CodeCategoryPanel } from "@/components/admin/CodeCategoryPanel";
|
||||
import { CodeInfoPanel } from "@/components/admin/CodeInfoPanel";
|
||||
import { CodeDetailPanel } from "@/components/admin/CodeDetailPanel";
|
||||
import { useSelectedCategory } from "@/hooks/useSelectedCategory";
|
||||
import { useSelectedCodeInfo } from "@/hooks/useSelectedCodeInfo";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
|
||||
export default function CommonCodeManagementPage() {
|
||||
const { selectedCategoryCode, selectCategory } = useSelectedCategory();
|
||||
const { selectedCodeInfo, selectCodeInfo } = useSelectedCodeInfo();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
@@ -14,35 +14,41 @@ export default function CommonCodeManagementPage() {
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">공통코드 관리</h1>
|
||||
<p className="text-sm text-muted-foreground">시스템에서 사용하는 공통코드를 관리합니다</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
그룹(code_info) 과 디테일 트리(code_detail) 를 관리합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 - 좌우 레이아웃 */}
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:gap-6">
|
||||
{/* 좌측: 카테고리 패널 */}
|
||||
{/* 좌측: 그룹 패널 */}
|
||||
<div className="w-full lg:w-80 lg:border-r lg:pr-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">코드 카테고리</h2>
|
||||
<CodeCategoryPanel selectedCategoryCode={selectedCategoryCode} onSelectCategory={selectCategory} />
|
||||
<h2 className="text-lg font-semibold">코드 그룹</h2>
|
||||
<CodeInfoPanel
|
||||
selectedCodeInfo={selectedCodeInfo}
|
||||
onSelectCodeInfo={selectCodeInfo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 코드 상세 패널 */}
|
||||
{/* 우측: 디테일 트리 패널 */}
|
||||
<div className="min-w-0 flex-1 lg:pl-0">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
코드 상세 정보
|
||||
{selectedCategoryCode && (
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">({selectedCategoryCode})</span>
|
||||
코드 디테일
|
||||
{selectedCodeInfo && (
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||
({selectedCodeInfo})
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<CodeDetailPanel categoryCode={selectedCategoryCode} />
|
||||
<CodeDetailPanel codeInfo={selectedCodeInfo} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll to Top 버튼 */}
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ import {
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Loader2,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
@@ -30,10 +31,13 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { TABLE_MANAGEMENT_KEYS } from "@/constants/tableManagement";
|
||||
import { INPUT_TYPE_OPTIONS, USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
import { getCodeInfoList } from "@/lib/api/commonCode";
|
||||
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||
import { ddlApi } from "@/lib/api/ddl";
|
||||
import { getSecondLevelMenus, createColumnMapping, deleteColumnMappingsByColumn } from "@/lib/api/tableCategoryValue";
|
||||
// getSecondLevelMenus / createColumnMapping / deleteColumnMappingsByColumn (카테고리 모듈 폐기)
|
||||
const getSecondLevelMenus = async (): Promise<{ success: boolean; data?: any[] }> => ({ success: true, data: [] });
|
||||
const createColumnMapping = async (_params: Record<string, any>): Promise<{ success: boolean }> => ({ success: true });
|
||||
const deleteColumnMappingsByColumn = async (_table: string, _column: string): Promise<{ success: boolean }> => ({ success: true });
|
||||
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
||||
import { CreateTableModal } from "@/components/admin/CreateTableModal";
|
||||
import { AddColumnModal } from "@/components/admin/AddColumnModal";
|
||||
@@ -73,6 +77,9 @@ export default function TableManagementPage() {
|
||||
// 테이블 라벨 상태
|
||||
const [tableLabel, setTableLabel] = useState("");
|
||||
const [tableDescription, setTableDescription] = useState("");
|
||||
// 헤더 인라인 편집 상태 (Google Docs / Notion 패턴)
|
||||
const [editingHeaderField, setEditingHeaderField] = useState<"label" | "description" | null>(null);
|
||||
const [editingHeaderValue, setEditingHeaderValue] = useState("");
|
||||
|
||||
// 🎯 Entity 조인 관련 상태
|
||||
const [referenceTableColumns, setReferenceTableColumns] = useState<Record<string, ReferenceTableColumn[]>>({});
|
||||
@@ -239,19 +246,13 @@ export default function TableManagementPage() {
|
||||
// 공통코드 카테고리 목록 로드
|
||||
const loadCommonCodeCategories = async () => {
|
||||
try {
|
||||
const response = await commonCodeApi.categories.getList({ isActive: true });
|
||||
// console.log("🔍 공통코드 카테고리 API 응답:", response);
|
||||
const response = await getCodeInfoList({ is_active: true });
|
||||
|
||||
if (response.success && response.data) {
|
||||
// console.log("📋 공통코드 카테고리 데이터:", response.data);
|
||||
|
||||
const categories = response.data.map((category) => {
|
||||
// console.log("🏷️ 카테고리 항목:", category);
|
||||
return {
|
||||
value: category.category_code,
|
||||
label: category.category_name || category.category_code,
|
||||
};
|
||||
});
|
||||
const categories = response.data.map((row: Record<string, any>) => ({
|
||||
value: row.code_info,
|
||||
label: row.code_name || row.code_info,
|
||||
}));
|
||||
|
||||
// console.log("✅ 매핑된 카테고리 옵션:", categories);
|
||||
setCommonCodeCategories(categories);
|
||||
@@ -381,7 +382,7 @@ export default function TableManagementPage() {
|
||||
if (response.data.success) {
|
||||
const data = response.data.data;
|
||||
setConstraints({
|
||||
primaryKey: data.primaryKey ?? { name: "", columns: [] },
|
||||
primaryKey: data.primary_key ?? { name: "", columns: [] },
|
||||
indexes: data.indexes ?? [],
|
||||
});
|
||||
}
|
||||
@@ -433,7 +434,7 @@ export default function TableManagementPage() {
|
||||
|
||||
// 코드가 아닌 타입으로 변경 시 코드 설정 초기화
|
||||
if (newInputType !== "code") {
|
||||
updated.code_category = undefined;
|
||||
updated.code_info = undefined;
|
||||
updated.code_value = undefined;
|
||||
updated.hierarchy_role = undefined;
|
||||
}
|
||||
@@ -459,7 +460,7 @@ export default function TableManagementPage() {
|
||||
prev.map((col) => {
|
||||
if (col.column_name === columnName) {
|
||||
let newDetailSettings = col.detail_settings;
|
||||
let codeCategory = col.code_category;
|
||||
let codeInfo = col.code_info;
|
||||
let codeValue = col.code_value;
|
||||
let referenceTable = col.reference_table;
|
||||
let referenceColumn = col.reference_column;
|
||||
@@ -469,17 +470,17 @@ export default function TableManagementPage() {
|
||||
if (settingType === "code") {
|
||||
if (value === "none") {
|
||||
newDetailSettings = "";
|
||||
codeCategory = undefined;
|
||||
codeInfo = undefined;
|
||||
codeValue = undefined;
|
||||
hierarchyRole = undefined; // 코드 선택 해제 시 계층 역할도 초기화
|
||||
} else {
|
||||
// 기존 hierarchyRole 유지하면서 JSON 형식으로 저장
|
||||
const existingHierarchyRole = hierarchyRole;
|
||||
newDetailSettings = JSON.stringify({
|
||||
code_category: value,
|
||||
code_info: value,
|
||||
hierarchy_role: existingHierarchyRole,
|
||||
});
|
||||
codeCategory = value;
|
||||
codeInfo = value;
|
||||
codeValue = value;
|
||||
}
|
||||
} else if (settingType === "hierarchy_role") {
|
||||
@@ -529,7 +530,7 @@ export default function TableManagementPage() {
|
||||
return {
|
||||
...col,
|
||||
detail_settings: newDetailSettings,
|
||||
code_category: codeCategory,
|
||||
code_info: codeInfo,
|
||||
code_value: codeValue,
|
||||
reference_table: referenceTable,
|
||||
reference_column: referenceColumn,
|
||||
@@ -632,7 +633,7 @@ export default function TableManagementPage() {
|
||||
column_label: column.display_name,
|
||||
input_type: column.input_type || "text",
|
||||
detail_settings: finalDetailSettings,
|
||||
code_category: column.code_category || "",
|
||||
code_info: column.code_info || "",
|
||||
code_value: column.code_value || "",
|
||||
reference_table: column.reference_table || "",
|
||||
reference_column: column.reference_column || "",
|
||||
@@ -745,26 +746,49 @@ export default function TableManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 전체 저장 (테이블 라벨 + 모든 컬럼 설정)
|
||||
// 헤더 표시명/설명 인라인 저장 (PUT /label) — Google Docs 식 blur/Enter 커밋
|
||||
const commitHeaderEdit = async () => {
|
||||
if (!editingHeaderField || !selectedTable) {
|
||||
setEditingHeaderField(null);
|
||||
return;
|
||||
}
|
||||
const next = editingHeaderValue.trim();
|
||||
const current = editingHeaderField === "label" ? tableLabel : tableDescription;
|
||||
if (next === current) {
|
||||
setEditingHeaderField(null);
|
||||
return;
|
||||
}
|
||||
const newLabel = editingHeaderField === "label" ? next : tableLabel;
|
||||
const newDescription = editingHeaderField === "description" ? next : tableDescription;
|
||||
if (editingHeaderField === "label" && !newLabel) {
|
||||
toast.error("표시명은 비울 수 없습니다.");
|
||||
setEditingHeaderField(null);
|
||||
return;
|
||||
}
|
||||
if (editingHeaderField === "label") setTableLabel(newLabel);
|
||||
else setTableDescription(newDescription);
|
||||
setEditingHeaderField(null);
|
||||
try {
|
||||
await apiClient.put(`/table-management/tables/${selectedTable}/label`, {
|
||||
display_name: newLabel,
|
||||
description: newDescription,
|
||||
});
|
||||
toast.success(editingHeaderField === "label" ? "표시명이 저장되었습니다." : "설명이 저장되었습니다.");
|
||||
} catch (error: any) {
|
||||
showErrorToast("저장에 실패했습니다", error, {
|
||||
guidance: "잠시 후 다시 시도해 주세요.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 컬럼 설정만 일괄 저장 (헤더 라벨/설명은 inline 편집으로 즉시 저장됨)
|
||||
const saveAllSettings = async () => {
|
||||
if (!selectedTable) return;
|
||||
if (isSaving) return; // 저장 중 중복 실행 방지
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// 1. 테이블 라벨 저장 (변경된 경우에만)
|
||||
if (tableLabel !== selectedTable || tableDescription) {
|
||||
try {
|
||||
await apiClient.put(`/table-management/tables/${selectedTable}/label`, {
|
||||
displayName: tableLabel,
|
||||
description: tableDescription,
|
||||
});
|
||||
} catch (error) {
|
||||
// console.warn("테이블 라벨 저장 실패 (API 미구현 가능):", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 모든 컬럼 설정 저장
|
||||
// 모든 컬럼 설정 저장
|
||||
if (columns.length > 0) {
|
||||
const columnSettings = columns.map((column) => {
|
||||
// detailSettings 계산
|
||||
@@ -812,7 +836,7 @@ export default function TableManagementPage() {
|
||||
input_type: column.input_type || "text",
|
||||
detail_settings: finalDetailSettings,
|
||||
description: column.description || "",
|
||||
code_category: column.code_category || "",
|
||||
code_info: column.code_info || "",
|
||||
code_value: column.code_value || "",
|
||||
reference_table: column.reference_table || "",
|
||||
reference_column: column.reference_column || "",
|
||||
@@ -1055,8 +1079,8 @@ export default function TableManagementPage() {
|
||||
const action = checked ? "create" : "drop";
|
||||
try {
|
||||
const response = await apiClient.post(`/table-management/tables/${selectedTable}/indexes`, {
|
||||
columnName,
|
||||
indexType,
|
||||
column_name: columnName,
|
||||
index_type: indexType,
|
||||
action,
|
||||
});
|
||||
if (response.data.success) {
|
||||
@@ -1362,8 +1386,8 @@ export default function TableManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3패널 메인 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 메인 (우측 패널은 overlay 라 2패널 layout) */}
|
||||
<div className="relative flex flex-1 overflow-hidden">
|
||||
{/* 좌측: 테이블 목록 (240px) */}
|
||||
<div className="bg-card flex w-[280px] min-w-[280px] flex-shrink-0 flex-col border-r">
|
||||
{/* 검색 */}
|
||||
@@ -1378,7 +1402,7 @@ export default function TableManagementPage() {
|
||||
/>
|
||||
</div>
|
||||
{isSuperAdmin && (
|
||||
<div className="mt-2 flex items-center justify-between border-b pb-2">
|
||||
<div className="mt-2 flex min-h-9 items-center justify-between border-b pb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={
|
||||
@@ -1435,7 +1459,7 @@ export default function TableManagementPage() {
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex items-center gap-2 rounded-md px-2.5 py-[7px] transition-colors",
|
||||
"group relative flex items-center gap-2 rounded-md px-2.5 py-1.5 transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-foreground"
|
||||
: "text-foreground/80 hover:bg-accent/50",
|
||||
@@ -1465,13 +1489,13 @@ export default function TableManagementPage() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={cn(
|
||||
"truncate text-[16px] leading-tight",
|
||||
"truncate text-[13px] leading-tight",
|
||||
isActive ? "font-bold" : "font-medium",
|
||||
)}>
|
||||
{table.display_name || table.table_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate font-mono text-[12px] leading-tight tracking-tight">
|
||||
<div className="text-muted-foreground truncate font-mono text-[10.5px] leading-tight tracking-tight">
|
||||
{table.table_name}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1507,42 +1531,103 @@ export default function TableManagementPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 중앙 헤더: 테이블명 + 라벨 입력 + 저장 */}
|
||||
<div className="bg-card flex flex-shrink-0 items-center gap-3 border-b px-5 py-3">
|
||||
<div className="min-w-0 flex-shrink-0">
|
||||
<div className="text-[15px] font-bold tracking-tight">
|
||||
{tableLabel || selectedTable}
|
||||
{/* 중앙 헤더: inline click-to-edit (Google Docs / Notion 패턴) */}
|
||||
<div className="bg-card flex flex-shrink-0 items-start gap-3 border-b px-5 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* 표시명 (display_name) — 클릭하면 그 자리에서 편집 */}
|
||||
{editingHeaderField === "label" ? (
|
||||
<Input
|
||||
autoFocus
|
||||
value={editingHeaderValue}
|
||||
onChange={(e) => setEditingHeaderValue(e.target.value)}
|
||||
onBlur={commitHeaderEdit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === "Escape") {
|
||||
setEditingHeaderField(null);
|
||||
}
|
||||
}}
|
||||
className="h-7 -mx-2 px-2 text-[15px] font-bold tracking-tight"
|
||||
/>
|
||||
) : (
|
||||
<div className="group flex items-center gap-1.5">
|
||||
<span className="text-[15px] font-bold tracking-tight">
|
||||
{tableLabel || (
|
||||
<span className="text-muted-foreground/60">{selectedTable}</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingHeaderValue(tableLabel);
|
||||
setEditingHeaderField("label");
|
||||
}}
|
||||
className="text-muted-foreground/50 hover:text-foreground transition-colors"
|
||||
title="표시명 편집"
|
||||
aria-label="표시명 편집"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-muted-foreground font-mono text-[11px] tracking-tight">
|
||||
)}
|
||||
{/* table_name (코드, 편집 불가) */}
|
||||
<div className="-mx-2 px-2 text-muted-foreground font-mono text-[11px] tracking-tight">
|
||||
{selectedTable}
|
||||
</div>
|
||||
{/* 설명 (description) — 클릭하면 그 자리에서 편집 */}
|
||||
{editingHeaderField === "description" ? (
|
||||
<Input
|
||||
autoFocus
|
||||
value={editingHeaderValue}
|
||||
onChange={(e) => setEditingHeaderValue(e.target.value)}
|
||||
onBlur={commitHeaderEdit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === "Escape") {
|
||||
setEditingHeaderField(null);
|
||||
}
|
||||
}}
|
||||
placeholder="이 테이블에 대한 짧은 설명"
|
||||
className="mt-1 h-7 -mx-2 px-2 text-xs"
|
||||
/>
|
||||
) : (
|
||||
<div className="group mt-0.5 flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tableDescription || (
|
||||
<span className="text-muted-foreground/50">+ 설명 추가</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingHeaderValue(tableDescription);
|
||||
setEditingHeaderField("description");
|
||||
}}
|
||||
className="text-muted-foreground/50 hover:text-foreground transition-colors"
|
||||
title="설명 편집"
|
||||
aria-label="설명 편집"
|
||||
>
|
||||
<Pencil className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Input
|
||||
value={tableLabel}
|
||||
onChange={(e) => setTableLabel(e.target.value)}
|
||||
placeholder="표시명"
|
||||
className="h-8 max-w-[160px] text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={tableDescription}
|
||||
onChange={(e) => setTableDescription(e.target.value)}
|
||||
placeholder="설명"
|
||||
className="h-8 max-w-[200px] text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={saveAllSettings}
|
||||
disabled={!selectedTable || columns.length === 0 || isSaving}
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
className="h-8 flex-shrink-0 gap-1.5 text-xs"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{isSaving ? "저장 중..." : "전체 설정 저장"}
|
||||
{isSaving ? "저장 중..." : "컬럼 설정 저장"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1567,7 +1652,7 @@ export default function TableManagementPage() {
|
||||
<ColumnGrid
|
||||
columns={columns}
|
||||
selectedColumn={selectedColumn}
|
||||
onSelectColumn={setSelectedColumn}
|
||||
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
|
||||
onColumnChange={(columnName, field, value) => {
|
||||
if (field === "is_unique") {
|
||||
const currentColumn = columns.find((c) => c.column_name === columnName);
|
||||
@@ -1602,9 +1687,13 @@ export default function TableManagementPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측: 상세 패널 (selectedColumn 있을 때만) */}
|
||||
{selectedColumn && (
|
||||
<div className="w-[380px] min-w-[380px] flex-shrink-0 overflow-hidden">
|
||||
{/* 우측: 상세 패널 (overlay slide-in/out — 가운데 본문 위에 부드럽게 등장) */}
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card absolute top-0 right-0 bottom-0 z-20 flex w-[380px] flex-col overflow-hidden border-l shadow-2xl transition-transform duration-300 ease-out",
|
||||
selectedColumn ? "translate-x-0" : "pointer-events-none translate-x-full",
|
||||
)}
|
||||
>
|
||||
<ColumnDetailPanel
|
||||
column={columns.find((c) => c.column_name === selectedColumn) ?? null}
|
||||
tables={tables}
|
||||
@@ -1628,11 +1717,10 @@ export default function TableManagementPage() {
|
||||
}}
|
||||
onClose={() => setSelectedColumn(null)}
|
||||
onLoadReferenceColumns={loadReferenceTableColumns}
|
||||
codeCategoryOptions={commonCodeOptions}
|
||||
codeInfoOptions={commonCodeOptions}
|
||||
referenceTableOptions={referenceTableOptions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* DDL 모달 컴포넌트들 */}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { isManagementHost } from "@/lib/tenant/subdomain";
|
||||
import { useCompanyManagement } from "@/hooks/useCompanyManagement";
|
||||
import { CompanyToolbar } from "@/components/admin/CompanyToolbar";
|
||||
import { CompanyTable } from "@/components/admin/CompanyTable";
|
||||
@@ -13,6 +16,16 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
* 모든 회사 관리 기능을 통합하여 제공
|
||||
*/
|
||||
export default function CompanyPage() {
|
||||
const router = useRouter();
|
||||
const [hostBlocked, setHostBlocked] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!isManagementHost(window.location.hostname)) {
|
||||
setHostBlocked(true);
|
||||
router.replace("/main");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const {
|
||||
// 데이터
|
||||
companies,
|
||||
@@ -51,6 +64,8 @@ export default function CompanyPage() {
|
||||
clearError,
|
||||
} = useCompanyManagement();
|
||||
|
||||
if (hostBlocked) return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="space-y-6 p-6">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import * as XLSX from "xlsx";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpToLine,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
ChevronsUpDown,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderTree,
|
||||
@@ -28,6 +31,7 @@ import {
|
||||
Upload,
|
||||
Users,
|
||||
X,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -42,7 +46,9 @@ import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -93,6 +99,10 @@ interface DeptDetailDraft {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
sort_order: number;
|
||||
// 다중 관리자 (chip UI 용)
|
||||
approval_managers: string[];
|
||||
dept_managers: string[];
|
||||
org_leaders: string[];
|
||||
}
|
||||
|
||||
const emptyDraft = (companyCode = ""): DeptDetailDraft => ({
|
||||
@@ -113,6 +123,9 @@ const emptyDraft = (companyCode = ""): DeptDetailDraft => ({
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
sort_order: 10,
|
||||
approval_managers: [],
|
||||
dept_managers: [],
|
||||
org_leaders: [],
|
||||
});
|
||||
|
||||
export default function DeptMngListPage() {
|
||||
@@ -145,11 +158,15 @@ export default function DeptMngListPage() {
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [pendingDeleteDept, setPendingDeleteDept] = useState<{ code: string; name: string } | null>(null);
|
||||
|
||||
// ── 일괄등록 / 변경이력 모달 ─────────────────────────
|
||||
// ── 일괄등록 / 일괄업데이트 모달 ─────────────────────
|
||||
const [bulkOpen, setBulkOpen] = useState(false);
|
||||
const [bulkText, setBulkText] = useState("");
|
||||
const [bulkUploading, setBulkUploading] = useState(false);
|
||||
const [bulkFailures, setBulkFailures] = useState<{ line: number; deptName: string; reason: string }[]>([]);
|
||||
const [bulkTab, setBulkTab] = useState<"create" | "update">("create");
|
||||
const [bulkUpdateMode, setBulkUpdateMode] = useState<"department" | "manager">("department");
|
||||
const [bulkRows, setBulkRows] = useState<Record<string, any>[]>([]);
|
||||
const [bulkPreviewRows, setBulkPreviewRows] = useState<departmentAPI.BulkPreviewRow[]>([]);
|
||||
const [bulkSelected, setBulkSelected] = useState<Set<number>>(new Set());
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [bulkFileName, setBulkFileName] = useState<string>("");
|
||||
|
||||
// ── 트리 ⋮ 메뉴: 이동/삭제 대상 ───────────────────────
|
||||
const [moveTargetDept, setMoveTargetDept] = useState<Department | null>(null);
|
||||
@@ -201,7 +218,10 @@ export default function DeptMngListPage() {
|
||||
if (!selectedCompanyCode) return;
|
||||
setIsTreeLoading(true);
|
||||
try {
|
||||
const res = await departmentAPI.getDepartments(selectedCompanyCode, { includeDeleted: showDeleted });
|
||||
const res = await departmentAPI.getDepartments(selectedCompanyCode, {
|
||||
includeDeleted: showDeleted,
|
||||
baseDate: periodMode === "date" ? baseDate : undefined,
|
||||
});
|
||||
if (res.success && (res as any).data) {
|
||||
setDepartments((res as any).data);
|
||||
} else {
|
||||
@@ -210,7 +230,7 @@ export default function DeptMngListPage() {
|
||||
} finally {
|
||||
setIsTreeLoading(false);
|
||||
}
|
||||
}, [selectedCompanyCode, showDeleted]);
|
||||
}, [selectedCompanyCode, showDeleted, periodMode, baseDate]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDepartments();
|
||||
@@ -303,6 +323,18 @@ export default function DeptMngListPage() {
|
||||
end_date: (dept.end_date ?? "").slice(0, 10),
|
||||
sort_order: dept.sort_order ?? 10,
|
||||
status: (dept.status as "active" | "inactive") ?? "active",
|
||||
approval_managers: (() => {
|
||||
const arr = ((dept as any).approval_managers || []).map((m: any) => m.user_id).filter(Boolean);
|
||||
// 신규 매핑이 비어있고 옛날 단일 컬럼에 값 있으면 seed (PR #19 이전 데이터 호환)
|
||||
if (arr.length === 0 && dept.approval_manager) return [dept.approval_manager];
|
||||
return arr;
|
||||
})(),
|
||||
dept_managers: (() => {
|
||||
const arr = ((dept as any).dept_managers || []).map((m: any) => m.user_id).filter(Boolean);
|
||||
if (arr.length === 0 && dept.dept_manager) return [dept.dept_manager];
|
||||
return arr;
|
||||
})(),
|
||||
org_leaders: ((dept as any).org_leaders || []).map((m: any) => m.user_id).filter(Boolean),
|
||||
};
|
||||
setDraft(loaded);
|
||||
setOriginalDraft(loaded);
|
||||
@@ -342,6 +374,10 @@ export default function DeptMngListPage() {
|
||||
sort_order: d.sort_order ?? 10,
|
||||
status: d.status ?? "active",
|
||||
location: d.location ?? "",
|
||||
// 다중 관리자 보존 (서버 응답 형식 그대로 다시 전달)
|
||||
approval_managers: (d.approval_managers || []).map((m: any) => ({ user_id: typeof m === 'string' ? m : m.user_id })),
|
||||
dept_managers: (d.dept_managers || []).map((m: any) => ({ user_id: typeof m === 'string' ? m : m.user_id })),
|
||||
org_leaders: (d.org_leaders || []).map((m: any) => ({ user_id: typeof m === 'string' ? m : m.user_id })),
|
||||
...overrides,
|
||||
}), []);
|
||||
|
||||
@@ -472,6 +508,14 @@ export default function DeptMngListPage() {
|
||||
toast({ title: "회사를 선택해주세요", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
// 시작일/종료일 정합성 검증
|
||||
if (draft.start_date && draft.end_date && draft.start_date > draft.end_date) {
|
||||
toast({
|
||||
title: "시작일은 종료일보다 빠르거나 같아야 합니다",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 기본정보 탭 전체 필드를 payload 로 전달 — dept_info 스키마와 1:1 (V019 정리 후)
|
||||
const payload = {
|
||||
@@ -491,6 +535,10 @@ export default function DeptMngListPage() {
|
||||
status: draft.status,
|
||||
// dept_info 추가 필드 (location 코드만 유지)
|
||||
location: draft.location,
|
||||
// 다중 관리자 — backend 가 {user_id} 객체 배열 받음
|
||||
approval_managers: draft.approval_managers.map((uid) => ({ user_id: uid })),
|
||||
dept_managers: draft.dept_managers.map((uid) => ({ user_id: uid })),
|
||||
org_leaders: draft.org_leaders.map((uid) => ({ user_id: uid })),
|
||||
} as any;
|
||||
|
||||
try {
|
||||
@@ -573,6 +621,251 @@ export default function DeptMngListPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// 일괄등록 / 일괄업데이트 helpers
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
const BULK_HEADERS_CREATE: Record<string, string> = {
|
||||
"부서명": "dept_name",
|
||||
"상위부서코드": "parent_dept_code",
|
||||
"부서유형": "dept_type",
|
||||
"약칭": "short_name",
|
||||
"조직체계": "org_system",
|
||||
"정렬순서": "sort_order",
|
||||
"사용여부": "status",
|
||||
"시작일": "start_date",
|
||||
"종료일": "end_date",
|
||||
"결재관리자": "approval_managers",
|
||||
"부서관리자": "dept_managers",
|
||||
"조직장": "org_leaders",
|
||||
};
|
||||
const BULK_HEADERS_UPDATE_DEPT: Record<string, string> = {
|
||||
"부서코드": "dept_code",
|
||||
"부서명": "dept_name",
|
||||
"상위부서코드": "parent_dept_code",
|
||||
"부서유형": "dept_type",
|
||||
"약칭": "short_name",
|
||||
"조직체계": "org_system",
|
||||
"정렬순서": "sort_order",
|
||||
"사용여부": "status",
|
||||
"시작일": "start_date",
|
||||
"종료일": "end_date",
|
||||
};
|
||||
const BULK_HEADERS_UPDATE_MGR: Record<string, string> = {
|
||||
"부서코드": "dept_code",
|
||||
"결재관리자": "approval_managers",
|
||||
"부서관리자": "dept_managers",
|
||||
"조직장": "org_leaders",
|
||||
};
|
||||
const MANAGER_KEYS = new Set(["approval_managers", "dept_managers", "org_leaders"]);
|
||||
|
||||
const currentHeaderMap = () =>
|
||||
bulkTab === "create"
|
||||
? BULK_HEADERS_CREATE
|
||||
: bulkUpdateMode === "department"
|
||||
? BULK_HEADERS_UPDATE_DEPT
|
||||
: BULK_HEADERS_UPDATE_MGR;
|
||||
|
||||
const currentBulkAction = (): departmentAPI.BulkAction =>
|
||||
bulkTab === "create"
|
||||
? "create"
|
||||
: bulkUpdateMode === "department"
|
||||
? "update_department"
|
||||
: "update_manager";
|
||||
|
||||
const resetBulkData = useCallback(() => {
|
||||
setBulkRows([]);
|
||||
setBulkPreviewRows([]);
|
||||
setBulkSelected(new Set());
|
||||
setBulkFileName("");
|
||||
}, []);
|
||||
|
||||
const openBulkModal = () => {
|
||||
if (!selectedCompanyCode) {
|
||||
toast({ title: "회사를 먼저 선택하세요", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setBulkTab("create");
|
||||
setBulkUpdateMode("department");
|
||||
resetBulkData();
|
||||
setBulkOpen(true);
|
||||
};
|
||||
|
||||
/** 엑셀 템플릿 다운로드 — action 별 컬럼 다름. 예시 row 1개 포함 */
|
||||
const downloadBulkTemplate = () => {
|
||||
const action = currentBulkAction();
|
||||
const headerMap =
|
||||
action === "create"
|
||||
? BULK_HEADERS_CREATE
|
||||
: action === "update_department"
|
||||
? BULK_HEADERS_UPDATE_DEPT
|
||||
: BULK_HEADERS_UPDATE_MGR;
|
||||
const columns = Object.keys(headerMap);
|
||||
const example: Record<string, any> = {};
|
||||
columns.forEach((c) => {
|
||||
const snake = headerMap[c];
|
||||
if (snake === "dept_name") example[c] = "경영지원본부";
|
||||
else if (snake === "dept_code") example[c] = "DEPT_1";
|
||||
else if (snake === "dept_type") example[c] = "dept";
|
||||
else if (snake === "status") example[c] = "active";
|
||||
else if (snake === "sort_order") example[c] = 10;
|
||||
else if (MANAGER_KEYS.has(snake)) example[c] = action === "update_manager" ? "user001,user002" : "";
|
||||
else example[c] = "";
|
||||
});
|
||||
const ws = XLSX.utils.json_to_sheet([example], { header: columns });
|
||||
ws["!cols"] = columns.map(() => ({ wch: 16 }));
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "부서");
|
||||
const fileName =
|
||||
action === "create"
|
||||
? "부서_일괄등록_템플릿.xlsx"
|
||||
: action === "update_department"
|
||||
? "부서정보_일괄업데이트_템플릿.xlsx"
|
||||
: "부서관리자_일괄업데이트_템플릿.xlsx";
|
||||
XLSX.writeFile(wb, fileName);
|
||||
};
|
||||
|
||||
/** 업로드된 xlsx → 한글 헤더를 snake_case 로 매핑 + 매니저 필드는 CSV 분해 */
|
||||
const handleBulkFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setBulkFileName(file.name);
|
||||
try {
|
||||
const buf = await file.arrayBuffer();
|
||||
const wb = XLSX.read(buf, { type: "array" });
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
const raw = XLSX.utils.sheet_to_json<Record<string, any>>(ws, { defval: "" });
|
||||
const headerMap = currentHeaderMap();
|
||||
const rows = raw
|
||||
.map((row) => {
|
||||
const out: Record<string, any> = {};
|
||||
for (const [korean, snake] of Object.entries(headerMap)) {
|
||||
const v = row[korean];
|
||||
if (v === undefined || v === null || v === "") continue;
|
||||
if (MANAGER_KEYS.has(snake)) {
|
||||
const ids = String(v).split(/[,;]/).map((s) => s.trim()).filter(Boolean);
|
||||
if (ids.length > 0) out[snake] = ids;
|
||||
} else if (snake === "sort_order") {
|
||||
const n = Number(v);
|
||||
if (!Number.isNaN(n)) out[snake] = n;
|
||||
} else {
|
||||
out[snake] = String(v).trim();
|
||||
}
|
||||
}
|
||||
return out;
|
||||
})
|
||||
.filter((r) => Object.keys(r).length > 0);
|
||||
setBulkRows(rows);
|
||||
setBulkPreviewRows([]);
|
||||
setBulkSelected(new Set());
|
||||
toast({ title: `${rows.length}건 로드됨`, description: "[미리보기] 를 눌러 검증하세요." });
|
||||
} catch (err: any) {
|
||||
toast({ title: "파일 읽기 실패", description: err.message || String(err), variant: "destructive" });
|
||||
} finally {
|
||||
// 동일 파일 재선택 가능하도록
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkPreview = async () => {
|
||||
if (bulkRows.length === 0) return;
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
const res = await departmentAPI.bulkPreviewDepartments(selectedCompanyCode, currentBulkAction(), bulkRows);
|
||||
if (res.success && (res as any).data) {
|
||||
const rows: departmentAPI.BulkPreviewRow[] = (res as any).data.rows;
|
||||
setBulkPreviewRows(rows);
|
||||
// 기본: ok 인 row 만 선택
|
||||
setBulkSelected(new Set(rows.filter((r) => r.result === "ok").map((r) => r.row_index)));
|
||||
} else {
|
||||
toast({
|
||||
title: "미리보기 실패",
|
||||
description: (res as any).error || (res as any).message || "오류",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkApply = async () => {
|
||||
const okSelected = bulkPreviewRows.filter(
|
||||
(r) => bulkSelected.has(r.row_index) && r.result === "ok",
|
||||
);
|
||||
if (okSelected.length === 0) {
|
||||
toast({ title: "반영할 정상 행이 없습니다", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
const payload = okSelected.map((r) => {
|
||||
const { row_index, result, error_detail, ...rest } = r as any;
|
||||
return rest as Record<string, any>;
|
||||
});
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
const res =
|
||||
bulkTab === "create"
|
||||
? await departmentAPI.bulkCreateDepartments(selectedCompanyCode, payload)
|
||||
: await departmentAPI.bulkUpdateDepartments(selectedCompanyCode, bulkUpdateMode, payload);
|
||||
if (res.success) {
|
||||
const count =
|
||||
(res as any).data?.inserted ?? (res as any).data?.updated ?? payload.length;
|
||||
toast({
|
||||
title: bulkTab === "create" ? "일괄등록 완료" : "일괄업데이트 완료",
|
||||
description: `${count}건 처리됨`,
|
||||
});
|
||||
setBulkOpen(false);
|
||||
resetBulkData();
|
||||
await loadDepartments();
|
||||
} else {
|
||||
toast({
|
||||
title: bulkTab === "create" ? "일괄등록 실패" : "일괄업데이트 실패",
|
||||
description: (res as any).error || (res as any).message || "오류",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const previewColumns = useMemo(() => {
|
||||
if (bulkTab === "create") {
|
||||
return [
|
||||
{ key: "dept_name", label: "부서명" },
|
||||
{ key: "parent_dept_code", label: "상위부서코드" },
|
||||
{ key: "dept_type", label: "유형" },
|
||||
{ key: "sort_order", label: "순서" },
|
||||
{ key: "approval_managers", label: "결재관리자", manager: true },
|
||||
{ key: "dept_managers", label: "부서관리자", manager: true },
|
||||
{ key: "org_leaders", label: "조직장", manager: true },
|
||||
];
|
||||
}
|
||||
if (bulkUpdateMode === "department") {
|
||||
return [
|
||||
{ key: "dept_code", label: "부서코드" },
|
||||
{ key: "dept_name", label: "부서명" },
|
||||
{ key: "parent_dept_code", label: "상위부서코드" },
|
||||
{ key: "dept_type", label: "유형" },
|
||||
{ key: "sort_order", label: "순서" },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ key: "dept_code", label: "부서코드" },
|
||||
{ key: "approval_managers", label: "결재관리자", manager: true },
|
||||
{ key: "dept_managers", label: "부서관리자", manager: true },
|
||||
{ key: "org_leaders", label: "조직장", manager: true },
|
||||
];
|
||||
}, [bulkTab, bulkUpdateMode]);
|
||||
|
||||
const bulkOkCount = bulkPreviewRows.filter((r) => r.result === "ok").length;
|
||||
const bulkErrCount = bulkPreviewRows.length - bulkOkCount;
|
||||
const allOkSelected =
|
||||
bulkOkCount > 0 &&
|
||||
bulkPreviewRows
|
||||
.filter((r) => r.result === "ok")
|
||||
.every((r) => bulkSelected.has(r.row_index));
|
||||
|
||||
const isDirty = originalDraft
|
||||
? JSON.stringify(originalDraft) !== JSON.stringify(draft)
|
||||
: isNewMode && (draft.dept_name.trim() !== "" || draft.parent_dept_code !== null);
|
||||
@@ -598,14 +891,7 @@ export default function DeptMngListPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
onClick={() => {
|
||||
if (!selectedCompanyCode) {
|
||||
toast({ title: "회사를 먼저 선택하세요", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setBulkText("");
|
||||
setBulkOpen(true);
|
||||
}}
|
||||
onClick={openBulkModal}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
일괄등록
|
||||
@@ -645,8 +931,6 @@ export default function DeptMngListPage() {
|
||||
<aside className="flex w-[340px] shrink-0 flex-col border-r">
|
||||
{/* 기준일 / 회사 / 검색 */}
|
||||
<div className="space-y-3 border-b p-3">
|
||||
{/* TODO V2: 사용기간 필터 — backend 미구현, V1 hidden */}
|
||||
{false && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className="w-[60px] shrink-0 text-xs font-semibold">사용기간</Label>
|
||||
<RadioGroup
|
||||
@@ -671,7 +955,6 @@ export default function DeptMngListPage() {
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Select
|
||||
value={selectedCompanyCode}
|
||||
@@ -978,106 +1261,229 @@ export default function DeptMngListPage() {
|
||||
title={moveTargetDept ? `"${moveTargetDept.dept_name}" — 새 상위 부서 선택` : "부서 선택"}
|
||||
/>
|
||||
|
||||
{/* 일괄등록 */}
|
||||
{/* 일괄등록 / 일괄업데이트 */}
|
||||
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
|
||||
<DialogContent className="max-w-[640px]">
|
||||
<DialogContent className="flex max-h-[88vh] max-w-[1040px] flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>부서 일괄등록</DialogTitle>
|
||||
<DialogTitle>부서 일괄등록 / 일괄업데이트</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
|
||||
<p className="mb-1.5 font-semibold">CSV 형식으로 한 줄에 하나씩 입력하세요</p>
|
||||
<p className="text-muted-foreground">
|
||||
형식: <code className="rounded bg-background px-1 py-0.5 font-mono">부서명,상위부서코드,부서유형(dept|team|temp)</code>
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">부서코드는 저장 시 자동 부여됩니다 (DEPT_n).</p>
|
||||
<p className="mt-1 text-muted-foreground">예시: <code className="rounded bg-background px-1 py-0.5 font-mono">경영지원본부,,dept</code></p>
|
||||
</div>
|
||||
<textarea
|
||||
value={bulkText}
|
||||
onChange={(e) => setBulkText(e.target.value)}
|
||||
placeholder={"경영지원본부,,dept\n인사팀,DEPT_1,team"}
|
||||
className="h-48 w-full resize-none rounded-md border bg-background p-2 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setBulkOpen(false)}>취소</Button>
|
||||
<Button
|
||||
disabled={bulkUploading || !bulkText.trim()}
|
||||
onClick={async () => {
|
||||
const lines = bulkText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||
if (lines.length === 0) return;
|
||||
setBulkUploading(true);
|
||||
const failures: { line: number; deptName: string; reason: string }[] = [];
|
||||
let success = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const cols = line.split(",").map((c) => c.trim());
|
||||
const [dept_name, parent, dept_type] = cols;
|
||||
if (!dept_name) {
|
||||
failures.push({ line: i + 1, deptName: "(빈 줄)", reason: "부서명 필수" });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const res = await departmentAPI.createDepartment(selectedCompanyCode, {
|
||||
dept_name,
|
||||
parent_dept_code: parent || null,
|
||||
dept_type: (dept_type || "dept") as any,
|
||||
} as any);
|
||||
if (res.success) success++;
|
||||
else failures.push({ line: i + 1, deptName: dept_name, reason: (res as any).error || "알 수 없는 오류" });
|
||||
} catch (e: any) {
|
||||
failures.push({ line: i + 1, deptName: dept_name, reason: e?.message || "예외 발생" });
|
||||
}
|
||||
}
|
||||
setBulkUploading(false);
|
||||
toast({
|
||||
title: `일괄등록 완료`,
|
||||
description: `성공 ${success}건 / 실패 ${failures.length}건`,
|
||||
variant: failures.length > 0 ? "destructive" : "default",
|
||||
});
|
||||
if (failures.length > 0) {
|
||||
setBulkFailures(failures);
|
||||
} else {
|
||||
setBulkOpen(false);
|
||||
}
|
||||
await loadDepartments();
|
||||
}}
|
||||
>
|
||||
{bulkUploading ? "등록 중..." : "등록"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 일괄등록 실패 결과 */}
|
||||
<Dialog open={bulkFailures.length > 0} onOpenChange={(o) => !o && setBulkFailures([])}>
|
||||
<DialogContent className="max-w-[640px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>일괄등록 실패 항목 ({bulkFailures.length}건)</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[480px] overflow-y-auto rounded-md border bg-muted/30">
|
||||
<Tabs
|
||||
value={bulkTab}
|
||||
onValueChange={(v) => {
|
||||
setBulkTab(v as "create" | "update");
|
||||
resetBulkData();
|
||||
}}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<TabsList className="mb-2">
|
||||
<TabsTrigger value="create">일괄등록</TabsTrigger>
|
||||
<TabsTrigger value="update">일괄업데이트</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="create" className="m-0 space-y-2">
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
|
||||
<p className="mb-1 font-semibold">신규 조직도를 일괄 등록합니다</p>
|
||||
<ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
|
||||
<li>[엑셀 템플릿] 을 다운로드해 작성 후 업로드하세요.</li>
|
||||
<li>업로드 → [미리보기] 로 검증 → 정상 행만 선택해 [반영].</li>
|
||||
<li>부서코드는 저장 시 자동 부여됩니다 (DEPT_n).</li>
|
||||
<li>관리자 컬럼은 user_id 를 쉼표 (,) 로 구분해 입력하세요. 최대 10명/role.</li>
|
||||
<li>한 번에 최대 1000건까지 처리 가능.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="update" className="m-0 space-y-2">
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
|
||||
<p className="mb-1 font-semibold">기존 부서 정보 또는 관리자 매핑을 일괄 수정합니다</p>
|
||||
<ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
|
||||
<li>각 행에 <code className="rounded bg-background px-1 font-mono">부서코드(dept_code)</code> 필수.</li>
|
||||
<li><b>부서 정보</b>: 부서명/유형/순서/날짜 등 변경. 빈 셀은 기존값 유지.</li>
|
||||
<li><b>관리자 정보</b>: 결재/부서/조직장 매핑만 변경. 명시된 role 만 동기화.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<Label className="text-xs font-semibold">업로드 항목 선택</Label>
|
||||
<RadioGroup
|
||||
value={bulkUpdateMode}
|
||||
onValueChange={(v) => {
|
||||
setBulkUpdateMode(v as "department" | "manager");
|
||||
resetBulkData();
|
||||
}}
|
||||
className="flex items-center gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="department" id="bulk-mode-dept" className="h-3.5 w-3.5" />
|
||||
<Label htmlFor="bulk-mode-dept" className="cursor-pointer text-xs">부서 정보</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="manager" id="bulk-mode-mgr" className="h-3.5 w-3.5" />
|
||||
<Label htmlFor="bulk-mode-mgr" className="cursor-pointer text-xs">관리자 정보</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 회사 + 파일 선택 (탭 공통) */}
|
||||
<div className="mt-2 space-y-2 rounded-md border p-3">
|
||||
<div className="grid grid-cols-[100px_1fr_auto] items-center gap-3">
|
||||
<Label className="text-xs font-semibold">회사 선택</Label>
|
||||
<div className="text-xs">
|
||||
<span className="font-mono">{selectedCompanyCode}</span>
|
||||
{selectedCompany?.company_name && (
|
||||
<span className="ml-2 text-muted-foreground">{selectedCompany.company_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 gap-1.5 text-xs" onClick={downloadBulkTemplate}>
|
||||
<FileDown className="h-3.5 w-3.5" />
|
||||
엑셀 템플릿
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] items-center gap-3">
|
||||
<Label className="text-xs font-semibold">파일 선택</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={handleBulkFile}
|
||||
className="h-8 cursor-pointer text-xs file:mr-2 file:rounded file:border-0 file:bg-muted file:px-2 file:py-1 file:text-xs"
|
||||
/>
|
||||
{bulkFileName && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{bulkRows.length}건 로드됨
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 테이블 */}
|
||||
<div className="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border">
|
||||
<div className="flex items-center justify-between bg-muted/40 px-3 py-1.5 text-xs">
|
||||
<span className="font-semibold">
|
||||
반영할 부서 목록 ({bulkSelected.size}/{bulkPreviewRows.length})
|
||||
</span>
|
||||
{bulkPreviewRows.length > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">정상 {bulkOkCount}건</span>
|
||||
{" / "}
|
||||
<span className="text-destructive">오류 {bulkErrCount}건</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-[200px] flex-1 overflow-auto">
|
||||
{bulkPreviewRows.length === 0 ? (
|
||||
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
<FileDown className="h-6 w-6 opacity-30" />
|
||||
<p>{bulkRows.length === 0 ? "엑셀 파일을 업로드하세요" : "[미리보기] 버튼을 눌러 검증하세요"}</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-muted/50 sticky top-0">
|
||||
<thead className="sticky top-0 z-10 bg-muted/60">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-semibold w-16">라인</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">부서명</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">사유</th>
|
||||
<th className="w-9 px-2 py-1.5">
|
||||
<Checkbox
|
||||
checked={allOkSelected}
|
||||
onCheckedChange={(c) => {
|
||||
if (c) {
|
||||
setBulkSelected(
|
||||
new Set(
|
||||
bulkPreviewRows.filter((r) => r.result === "ok").map((r) => r.row_index),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setBulkSelected(new Set());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
{previewColumns.map((c) => (
|
||||
<th key={c.key} className="px-2 py-1.5 text-left font-semibold">{c.label}</th>
|
||||
))}
|
||||
<th className="w-16 px-2 py-1.5 text-left font-semibold">결과</th>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">오류상세</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{bulkFailures.map((f, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="px-3 py-1.5 font-mono">{f.line}</td>
|
||||
<td className="px-3 py-1.5">{f.deptName}</td>
|
||||
<td className="px-3 py-1.5 text-destructive">{f.reason}</td>
|
||||
{bulkPreviewRows.map((r) => {
|
||||
const isErr = r.result === "error";
|
||||
return (
|
||||
<tr
|
||||
key={r.row_index}
|
||||
className={cn("hover:bg-muted/30", isErr && "bg-destructive/5")}
|
||||
>
|
||||
<td className="px-2 py-1.5">
|
||||
<Checkbox
|
||||
disabled={isErr}
|
||||
checked={bulkSelected.has(r.row_index)}
|
||||
onCheckedChange={(c) => {
|
||||
setBulkSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (c) next.add(r.row_index);
|
||||
else next.delete(r.row_index);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
{previewColumns.map((c) => {
|
||||
const v = (r as any)[c.key];
|
||||
const display = (c as any).manager
|
||||
? Array.isArray(v) && v.length > 0 ? v.join(", ") : "-"
|
||||
: v != null && v !== "" ? String(v) : "-";
|
||||
return (
|
||||
<td key={c.key} className="max-w-[180px] truncate px-2 py-1.5" title={display}>
|
||||
{display}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="px-2 py-1.5">
|
||||
{isErr ? (
|
||||
<Badge variant="destructive" className="gap-1 text-[10px]">
|
||||
<XCircle className="h-3 w-3" />
|
||||
오류
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="gap-1 border-emerald-500/30 bg-emerald-500/15 text-[10px] text-emerald-700 hover:bg-emerald-500/20 dark:text-emerald-300">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
정상
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className="max-w-[300px] truncate px-2 py-1.5 text-destructive"
|
||||
title={r.error_detail || ""}
|
||||
>
|
||||
{r.error_detail || ""}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setBulkFailures([]); setBulkOpen(false); }}>닫기</Button>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => setBulkOpen(false)} disabled={bulkBusy}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBulkPreview}
|
||||
disabled={bulkBusy || bulkRows.length === 0}
|
||||
>
|
||||
{bulkBusy ? "검증 중..." : "미리보기"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBulkApply}
|
||||
disabled={bulkBusy || bulkSelected.size === 0}
|
||||
>
|
||||
{bulkBusy ? "처리 중..." : `반영 (${bulkSelected.size}건)`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -1366,17 +1772,27 @@ function BasicInfoForm({
|
||||
</Row>
|
||||
|
||||
<Row label="결재 관리자" hint>
|
||||
<PickerField
|
||||
value={draft.approval_manager}
|
||||
onChange={(v) => update("approval_manager", v)}
|
||||
placeholder="사용자 이름을 입력해주세요."
|
||||
<ManagerChipsField
|
||||
userIds={draft.approval_managers}
|
||||
onChange={(ids) => update("approval_managers", ids)}
|
||||
companyCode={draft.company_code}
|
||||
max={10}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="부서 관리자">
|
||||
<PickerField
|
||||
value={draft.dept_manager}
|
||||
onChange={(v) => update("dept_manager", v)}
|
||||
placeholder="사용자 이름을 입력해주세요."
|
||||
<ManagerChipsField
|
||||
userIds={draft.dept_managers}
|
||||
onChange={(ids) => update("dept_managers", ids)}
|
||||
companyCode={draft.company_code}
|
||||
max={10}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="조직장" hint>
|
||||
<ManagerChipsField
|
||||
userIds={draft.org_leaders}
|
||||
onChange={(ids) => update("org_leaders", ids)}
|
||||
companyCode={draft.company_code}
|
||||
max={10}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
@@ -1423,8 +1839,6 @@ function BasicInfoForm({
|
||||
</RadioGroup>
|
||||
</Row>
|
||||
|
||||
{/* TODO V2: 사용기간 (시작일/종료일) — 필터 도입 시 사용. 컬럼은 DEPT_INFO.START_DATE/END_DATE 유지 */}
|
||||
{false && (
|
||||
<Row label="시작일">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
@@ -1444,7 +1858,6 @@ function BasicInfoForm({
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row label="정렬">
|
||||
<Input
|
||||
@@ -1518,6 +1931,99 @@ function PickerField({
|
||||
);
|
||||
}
|
||||
|
||||
function ManagerChipsField({
|
||||
userIds,
|
||||
onChange,
|
||||
companyCode,
|
||||
max,
|
||||
}: {
|
||||
userIds: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
companyCode: string;
|
||||
max: number;
|
||||
}) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [resolvedNames, setResolvedNames] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const unknown = userIds.filter((id) => !resolvedNames[id]);
|
||||
if (unknown.length === 0 || !companyCode) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const updates: Record<string, string> = {};
|
||||
for (const id of unknown) {
|
||||
try {
|
||||
const res = await departmentAPI.searchUsers(companyCode, id);
|
||||
if (res.success && Array.isArray((res as any).data)) {
|
||||
const found = (res as any).data.find((u: any) => u.user_id === id);
|
||||
if (found) updates[id] = found.user_name || id;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (!cancelled && Object.keys(updates).length > 0) {
|
||||
setResolvedNames((prev) => ({ ...prev, ...updates }));
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [userIds, companyCode]);
|
||||
|
||||
const handleRemove = (id: string) => onChange(userIds.filter((x) => x !== id));
|
||||
const handleAdd = (id: string) => {
|
||||
if (userIds.includes(id)) return;
|
||||
if (userIds.length >= max) return;
|
||||
onChange([...userIds, id]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{userIds.map((id) => (
|
||||
<div
|
||||
key={id}
|
||||
className="flex items-center gap-1 rounded-md border bg-muted/50 px-2 py-0.5 text-xs"
|
||||
>
|
||||
<span className="font-medium">{resolvedNames[id] || id}</span>
|
||||
<span className="text-muted-foreground text-[10px]">({id})</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(id)}
|
||||
className="text-muted-foreground hover:text-destructive ml-0.5"
|
||||
title="제거"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{userIds.length < max && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
disabled={!companyCode}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
{userIds.length >= max && (
|
||||
<span className="text-muted-foreground text-[10px]">최대 {max}명</span>
|
||||
)}
|
||||
</div>
|
||||
<UserSearchModal
|
||||
open={pickerOpen}
|
||||
companyCode={companyCode}
|
||||
existingMemberIds={new Set(userIds)}
|
||||
onAdd={async (userId) => {
|
||||
handleAdd(userId);
|
||||
}}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────
|
||||
// 사용자 검색 모달
|
||||
// ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -442,11 +442,8 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
|
||||
const isInputType =
|
||||
compType?.includes("input") ||
|
||||
compType?.includes("select") ||
|
||||
compType?.includes("textarea") ||
|
||||
compType?.includes("v2-input") ||
|
||||
compType?.includes("v2-select") ||
|
||||
compType?.includes("v2-media") ||
|
||||
compType?.includes("file-upload");
|
||||
compType?.includes("textarea");
|
||||
// (옛 v2-media / file-upload 매칭은 Phase D.5 에서 제거 — canonical input 의 includes("input") 으로 포함)
|
||||
const hasColumnName = !!(comp as any).columnName;
|
||||
return isInputType && hasColumnName;
|
||||
});
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { ValidationMessage } from "@/components/common/ValidationMessage";
|
||||
import { useCreateCategory, useUpdateCategory } from "@/hooks/queries/useCategories";
|
||||
import type { CodeCategory } from "@/types/commonCode";
|
||||
import { useCheckCategoryDuplicate } from "@/hooks/queries/useValidation";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
createCategorySchema,
|
||||
updateCategorySchema,
|
||||
type CreateCategoryData,
|
||||
type UpdateCategoryData,
|
||||
} from "@/lib/schemas/commonCode";
|
||||
|
||||
interface CodeCategoryFormModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
editingCategoryCode?: string;
|
||||
categories: CodeCategory[];
|
||||
}
|
||||
|
||||
export function CodeCategoryFormModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
editingCategoryCode,
|
||||
categories,
|
||||
}: CodeCategoryFormModalProps) {
|
||||
const createCategoryMutation = useCreateCategory();
|
||||
const updateCategoryMutation = useUpdateCategory();
|
||||
|
||||
const isEditing = !!editingCategoryCode;
|
||||
const editingCategory = categories.find((c) => c.category_code === editingCategoryCode);
|
||||
|
||||
// 생성과 수정을 위한 별도 폼 설정
|
||||
const createForm = useForm<CreateCategoryData>({
|
||||
resolver: zodResolver(createCategorySchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
categoryCode: "",
|
||||
categoryName: "",
|
||||
categoryNameEng: "",
|
||||
description: "",
|
||||
sortOrder: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const updateForm = useForm<UpdateCategoryData>({
|
||||
resolver: zodResolver(updateCategorySchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
categoryName: "",
|
||||
categoryNameEng: "",
|
||||
description: "",
|
||||
sortOrder: 1,
|
||||
isActive: "Y",
|
||||
},
|
||||
});
|
||||
|
||||
// 필드 검증 상태 관리
|
||||
const [validatedFields, setValidatedFields] = useState<Set<string>>(new Set());
|
||||
|
||||
// 필드 검증 처리 함수
|
||||
const handleFieldBlur = (fieldName: string) => {
|
||||
setValidatedFields((prev) => new Set(prev).add(fieldName));
|
||||
};
|
||||
|
||||
// 중복 검사 훅들
|
||||
const categoryCodeCheck = useCheckCategoryDuplicate(
|
||||
"categoryCode",
|
||||
isEditing ? "" : createForm.watch("categoryCode"),
|
||||
isEditing ? editingCategoryCode : undefined,
|
||||
validatedFields.has("categoryCode"),
|
||||
);
|
||||
|
||||
const categoryNameCheck = useCheckCategoryDuplicate(
|
||||
"categoryName",
|
||||
isEditing ? updateForm.watch("categoryName") : createForm.watch("categoryName"),
|
||||
isEditing ? editingCategoryCode : undefined,
|
||||
validatedFields.has("categoryName"),
|
||||
);
|
||||
|
||||
const categoryNameEngCheck = useCheckCategoryDuplicate(
|
||||
"categoryNameEng",
|
||||
isEditing ? updateForm.watch("categoryNameEng") : createForm.watch("categoryNameEng"),
|
||||
isEditing ? editingCategoryCode : undefined,
|
||||
validatedFields.has("categoryNameEng"),
|
||||
);
|
||||
|
||||
// 중복 검사 결과 확인 (수정 시에는 카테고리 코드 검사 제외)
|
||||
const hasDuplicateErrors =
|
||||
(!isEditing && categoryCodeCheck.data?.isDuplicate && validatedFields.has("categoryCode")) ||
|
||||
(categoryNameCheck.data?.isDuplicate && validatedFields.has("categoryName")) ||
|
||||
(categoryNameEngCheck.data?.isDuplicate && validatedFields.has("categoryNameEng"));
|
||||
|
||||
// 중복 검사 로딩 중인지 확인 (수정 시에는 카테고리 코드 검사 제외)
|
||||
const isDuplicateChecking =
|
||||
(!isEditing && categoryCodeCheck.isLoading) || categoryNameCheck.isLoading || categoryNameEngCheck.isLoading;
|
||||
|
||||
// 폼은 조건부로 직접 사용
|
||||
|
||||
// 편집 모드일 때 기존 데이터 로드
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// 검증 상태 초기화
|
||||
setValidatedFields(new Set());
|
||||
|
||||
if (isEditing && editingCategory) {
|
||||
// 수정 모드: 기존 데이터 로드
|
||||
updateForm.reset({
|
||||
categoryName: editingCategory.category_name,
|
||||
categoryNameEng: editingCategory.category_name_eng || "",
|
||||
description: editingCategory.description || "",
|
||||
sortOrder: editingCategory.sort_order,
|
||||
isActive: editingCategory.is_active as "Y" | "N", // 타입 안전한 캐스팅
|
||||
});
|
||||
} else {
|
||||
// 새 카테고리 모드: 자동 순서 계산
|
||||
const maxSortOrder = categories.length > 0 ? Math.max(...categories.map((c) => c.sort_order)) : 0;
|
||||
|
||||
createForm.reset({
|
||||
categoryCode: "",
|
||||
categoryName: "",
|
||||
categoryNameEng: "",
|
||||
description: "",
|
||||
sortOrder: maxSortOrder + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen, isEditing, editingCategory, categories, createForm, updateForm]);
|
||||
|
||||
const handleSubmit = isEditing
|
||||
? updateForm.handleSubmit(async (data) => {
|
||||
try {
|
||||
await updateCategoryMutation.mutateAsync({
|
||||
categoryCode: editingCategoryCode!,
|
||||
data: data as UpdateCategoryData,
|
||||
});
|
||||
onClose();
|
||||
updateForm.reset();
|
||||
} catch (error) {
|
||||
console.error("카테고리 수정 실패:", error);
|
||||
}
|
||||
})
|
||||
: createForm.handleSubmit(async (data) => {
|
||||
try {
|
||||
await createCategoryMutation.mutateAsync(data as CreateCategoryData);
|
||||
onClose();
|
||||
createForm.reset();
|
||||
} catch (error) {
|
||||
console.error("카테고리 생성 실패:", error);
|
||||
}
|
||||
});
|
||||
|
||||
const isLoading = createCategoryMutation.isPending || updateCategoryMutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">{isEditing ? "카테고리 수정" : "새 카테고리"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||
{/* 카테고리 코드 */}
|
||||
{!isEditing && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="categoryCode" className="text-xs sm:text-sm">카테고리 코드 *</Label>
|
||||
<Input
|
||||
id="categoryCode"
|
||||
{...createForm.register("categoryCode")}
|
||||
disabled={isLoading}
|
||||
placeholder="카테고리 코드를 입력하세요"
|
||||
className={createForm.formState.errors.categoryCode ? "h-8 text-xs sm:h-10 sm:text-sm border-destructive" : "h-8 text-xs sm:h-10 sm:text-sm"}
|
||||
onBlur={() => handleFieldBlur("categoryCode")}
|
||||
/>
|
||||
{createForm.formState.errors.categoryCode && (
|
||||
<p className="text-[10px] sm:text-xs text-destructive">{createForm.formState.errors.categoryCode.message}</p>
|
||||
)}
|
||||
{!createForm.formState.errors.categoryCode && (
|
||||
<ValidationMessage
|
||||
message={categoryCodeCheck.data?.message}
|
||||
isValid={!categoryCodeCheck.data?.isDuplicate}
|
||||
isLoading={categoryCodeCheck.isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리 코드 표시 (수정 시) */}
|
||||
{isEditing && editingCategory && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="categoryCodeDisplay" className="text-xs sm:text-sm">카테고리 코드</Label>
|
||||
<Input id="categoryCodeDisplay" value={editingCategory.category_code} disabled className="h-8 text-xs sm:h-10 sm:text-sm bg-muted cursor-not-allowed" />
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">카테고리 코드는 수정할 수 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="categoryName">카테고리명 *</Label>
|
||||
<Input
|
||||
id="categoryName"
|
||||
{...(isEditing ? updateForm.register("categoryName") : createForm.register("categoryName"))}
|
||||
disabled={isLoading}
|
||||
placeholder="카테고리명을 입력하세요"
|
||||
className={
|
||||
isEditing
|
||||
? updateForm.formState.errors.categoryName
|
||||
? "border-destructive"
|
||||
: ""
|
||||
: createForm.formState.errors.categoryName
|
||||
? "border-destructive"
|
||||
: ""
|
||||
}
|
||||
onBlur={() => handleFieldBlur("categoryName")}
|
||||
/>
|
||||
{isEditing
|
||||
? updateForm.formState.errors.categoryName && (
|
||||
<p className="text-sm text-destructive">{updateForm.formState.errors.categoryName.message}</p>
|
||||
)
|
||||
: createForm.formState.errors.categoryName && (
|
||||
<p className="text-sm text-destructive">{createForm.formState.errors.categoryName.message}</p>
|
||||
)}
|
||||
{!(isEditing ? updateForm.formState.errors.categoryName : createForm.formState.errors.categoryName) && (
|
||||
<ValidationMessage
|
||||
message={categoryNameCheck.data?.message}
|
||||
isValid={!categoryNameCheck.data?.isDuplicate}
|
||||
isLoading={categoryNameCheck.isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 영문명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="categoryNameEng">카테고리 영문명 *</Label>
|
||||
<Input
|
||||
id="categoryNameEng"
|
||||
{...(isEditing ? updateForm.register("categoryNameEng") : createForm.register("categoryNameEng"))}
|
||||
disabled={isLoading}
|
||||
placeholder="카테고리 영문명을 입력하세요"
|
||||
className={
|
||||
isEditing
|
||||
? updateForm.formState.errors.categoryNameEng
|
||||
? "border-destructive"
|
||||
: ""
|
||||
: createForm.formState.errors.categoryNameEng
|
||||
? "border-destructive"
|
||||
: ""
|
||||
}
|
||||
onBlur={() => handleFieldBlur("categoryNameEng")}
|
||||
/>
|
||||
{isEditing
|
||||
? updateForm.formState.errors.categoryNameEng && (
|
||||
<p className="text-sm text-destructive">{updateForm.formState.errors.categoryNameEng.message}</p>
|
||||
)
|
||||
: createForm.formState.errors.categoryNameEng && (
|
||||
<p className="text-sm text-destructive">{createForm.formState.errors.categoryNameEng.message}</p>
|
||||
)}
|
||||
{!(isEditing
|
||||
? updateForm.formState.errors.categoryNameEng
|
||||
: createForm.formState.errors.categoryNameEng) && (
|
||||
<ValidationMessage
|
||||
message={categoryNameEngCheck.data?.message}
|
||||
isValid={!categoryNameEngCheck.data?.isDuplicate}
|
||||
isLoading={categoryNameEngCheck.isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명 *</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
{...(isEditing ? updateForm.register("description") : createForm.register("description"))}
|
||||
disabled={isLoading}
|
||||
placeholder="설명을 입력하세요"
|
||||
rows={3}
|
||||
className={
|
||||
isEditing
|
||||
? updateForm.formState.errors.description
|
||||
? "border-destructive"
|
||||
: ""
|
||||
: createForm.formState.errors.description
|
||||
? "border-destructive"
|
||||
: ""
|
||||
}
|
||||
onBlur={() => handleFieldBlur("description")}
|
||||
/>
|
||||
{isEditing
|
||||
? updateForm.formState.errors.description && (
|
||||
<p className="text-sm text-destructive">{updateForm.formState.errors.description.message}</p>
|
||||
)
|
||||
: createForm.formState.errors.description && (
|
||||
<p className="text-sm text-destructive">{createForm.formState.errors.description.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 정렬 순서 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sortOrder">정렬 순서</Label>
|
||||
<Input
|
||||
id="sortOrder"
|
||||
type="number"
|
||||
{...(isEditing
|
||||
? updateForm.register("sortOrder", { valueAsNumber: true })
|
||||
: createForm.register("sortOrder", { valueAsNumber: true }))}
|
||||
disabled={isLoading}
|
||||
min={1}
|
||||
className={
|
||||
isEditing
|
||||
? updateForm.formState.errors.sortOrder
|
||||
? "border-destructive"
|
||||
: ""
|
||||
: createForm.formState.errors.sortOrder
|
||||
? "border-destructive"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{isEditing
|
||||
? updateForm.formState.errors.sortOrder && (
|
||||
<p className="text-sm text-destructive">{updateForm.formState.errors.sortOrder.message}</p>
|
||||
)
|
||||
: createForm.formState.errors.sortOrder && (
|
||||
<p className="text-sm text-destructive">{createForm.formState.errors.sortOrder.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 활성 상태 (수정 시에만) */}
|
||||
{isEditing && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="isActive"
|
||||
checked={updateForm.watch("isActive") === "Y"}
|
||||
onCheckedChange={(checked) => updateForm.setValue("isActive", checked ? "Y" : "N")}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Label htmlFor="isActive">{updateForm.watch("isActive") === "Y" ? "활성" : "비활성"}</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex gap-2 pt-4 sm:justify-end sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isLoading ||
|
||||
!(isEditing ? updateForm.formState.isValid : createForm.formState.isValid) ||
|
||||
hasDuplicateErrors ||
|
||||
isDuplicateChecking
|
||||
}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<LoadingSpinner size="sm" className="mr-2" />
|
||||
{isEditing ? "수정 중..." : "저장 중..."}
|
||||
</>
|
||||
) : isEditing ? (
|
||||
"카테고리 수정"
|
||||
) : (
|
||||
"카테고리 저장"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -9,226 +9,167 @@ import { CodeFormModal } from "./CodeFormModal";
|
||||
import { SortableCodeItem } from "./SortableCodeItem";
|
||||
import { AlertModal } from "@/components/common/AlertModal";
|
||||
import { Search, Plus } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDeleteCode, useReorderCodes } from "@/hooks/queries/useCodes";
|
||||
import { useCodesInfinite } from "@/hooks/queries/useCodesInfinite";
|
||||
import type { CodeInfo } from "@/types/commonCode";
|
||||
|
||||
// Drag and Drop
|
||||
import { useDeleteCodeDetail, useUpdateCodeDetail, useCodeDetailTree } from "@/hooks/queries/useCodeDetail";
|
||||
import type { CodeDetail } from "@/types/commonCode";
|
||||
import { DndContext, DragOverlay } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { SortableContext, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
||||
import { useDragAndDrop } from "@/hooks/useDragAndDrop";
|
||||
import { useSearchAndFilter } from "@/hooks/useSearchAndFilter";
|
||||
|
||||
interface CodeDetailPanelProps {
|
||||
categoryCode: string;
|
||||
codeInfo: string;
|
||||
}
|
||||
|
||||
export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||
// 검색 및 필터 상태 (먼저 선언)
|
||||
/**
|
||||
* 디테일 트리 패널.
|
||||
*
|
||||
* BE 가 `/api/common-codes/detail?code_info=X` 로 depth+sort_order 평탄화 리스트를 내려준다.
|
||||
* FE 는 parent_detail_id 로 children 맵을 만들어 indent + collapse 처리.
|
||||
* sort_order 변경은 형제 단위 reorder → 각 행 update PUT 으로 직렬 호출.
|
||||
*/
|
||||
export function CodeDetailPanel({ codeInfo }: CodeDetailPanelProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [showActiveOnly, setShowActiveOnly] = useState(false);
|
||||
|
||||
// React Query로 코드 데이터 관리 (무한 스크롤)
|
||||
const {
|
||||
data: codes = [],
|
||||
isLoading,
|
||||
error,
|
||||
handleScroll,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
} = useCodesInfinite(categoryCode, {
|
||||
const treeQuery = useCodeDetailTree(codeInfo || undefined, {
|
||||
search: searchTerm || undefined,
|
||||
active: showActiveOnly || undefined,
|
||||
});
|
||||
const deleteCodeMutation = useDeleteCode();
|
||||
const reorderCodesMutation = useReorderCodes();
|
||||
const rows: CodeDetail[] = useMemo(() => treeQuery.data || [], [treeQuery.data]);
|
||||
const isLoading = treeQuery.isLoading;
|
||||
const error = treeQuery.error;
|
||||
|
||||
// 드래그앤드롭을 위해 필터링된 코드 목록 사용
|
||||
const { filteredItems: filteredCodesRaw } = useSearchAndFilter(codes, {
|
||||
searchFields: ["code_name", "code_value"],
|
||||
});
|
||||
const deleteMutation = useDeleteCodeDetail();
|
||||
const updateMutation = useUpdateCodeDetail();
|
||||
|
||||
// 계층 구조로 정렬 (부모 → 자식 순서)
|
||||
const filteredCodes = useMemo(() => {
|
||||
if (!filteredCodesRaw || filteredCodesRaw.length === 0) return [];
|
||||
|
||||
// 코드를 계층 순서로 정렬하는 함수
|
||||
const sortHierarchically = (codes: CodeInfo[]): CodeInfo[] => {
|
||||
const result: CodeInfo[] = [];
|
||||
const codeMap = new Map<string, CodeInfo>();
|
||||
const childrenMap = new Map<string, CodeInfo[]>();
|
||||
|
||||
// 코드 맵 생성
|
||||
codes.forEach((code) => {
|
||||
const codeValue = code.code_value || "";
|
||||
const parentValue = code.parent_code_value;
|
||||
codeMap.set(codeValue, code);
|
||||
|
||||
if (parentValue) {
|
||||
if (!childrenMap.has(parentValue)) {
|
||||
childrenMap.set(parentValue, []);
|
||||
}
|
||||
childrenMap.get(parentValue)!.push(code);
|
||||
}
|
||||
});
|
||||
|
||||
// 재귀적으로 트리 구조 순회
|
||||
const traverse = (parentValue: string | null, depth: number) => {
|
||||
const children = parentValue
|
||||
? childrenMap.get(parentValue) || []
|
||||
: codes.filter((c) => !c.parent_code_value);
|
||||
|
||||
// 정렬 순서로 정렬
|
||||
children
|
||||
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0))
|
||||
.forEach((code) => {
|
||||
result.push(code);
|
||||
const codeValue = code.code_value || "";
|
||||
traverse(codeValue, depth + 1);
|
||||
});
|
||||
};
|
||||
|
||||
traverse(null, 1);
|
||||
|
||||
// 트리에 포함되지 않은 코드들도 추가 (orphan 코드)
|
||||
codes.forEach((code) => {
|
||||
if (!result.includes(code)) {
|
||||
result.push(code);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
return sortHierarchically(filteredCodesRaw);
|
||||
}, [filteredCodesRaw]);
|
||||
|
||||
// 모달 상태
|
||||
const [showFormModal, setShowFormModal] = useState(false);
|
||||
const [editingCode, setEditingCode] = useState<CodeInfo | null>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deletingCode, setDeletingCode] = useState<CodeInfo | null>(null);
|
||||
const [defaultParentCode, setDefaultParentCode] = useState<string | undefined>(undefined);
|
||||
|
||||
// 트리 접기/펼치기 상태 (코드값 Set)
|
||||
const [collapsedCodes, setCollapsedCodes] = useState<Set<string>>(new Set());
|
||||
|
||||
// 자식 정보 계산
|
||||
/** parent_detail_id → children */
|
||||
const childrenMap = useMemo(() => {
|
||||
const map = new Map<string, CodeInfo[]>();
|
||||
codes.forEach((code) => {
|
||||
const parentValue = code.parent_code_value;
|
||||
if (parentValue) {
|
||||
if (!map.has(parentValue)) {
|
||||
map.set(parentValue, []);
|
||||
const map = new Map<string, CodeDetail[]>();
|
||||
for (const row of rows) {
|
||||
const key = row.parent_detail_id == null ? "ROOT" : String(row.parent_detail_id);
|
||||
const list = map.get(key) || [];
|
||||
list.push(row);
|
||||
map.set(key, list);
|
||||
}
|
||||
map.get(parentValue)!.push(code);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [codes]);
|
||||
}, [rows]);
|
||||
|
||||
// 접기/펼치기 토글
|
||||
const toggleExpand = (codeValue: string) => {
|
||||
setCollapsedCodes((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(codeValue)) {
|
||||
newSet.delete(codeValue);
|
||||
} else {
|
||||
newSet.add(codeValue);
|
||||
}
|
||||
return newSet;
|
||||
// 접기/펼치기 (code_detail_id Set)
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
const toggleExpand = (id: string) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 특정 코드가 표시되어야 하는지 확인 (부모가 접혀있으면 숨김)
|
||||
const isCodeVisible = (code: CodeInfo): boolean => {
|
||||
const parentValue = code.parent_code_value;
|
||||
if (!parentValue) return true; // 최상위 코드는 항상 표시
|
||||
|
||||
// 부모가 접혀있으면 숨김
|
||||
if (collapsedCodes.has(parentValue)) return false;
|
||||
|
||||
// 부모의 부모도 확인 (재귀적으로)
|
||||
const parentCode = codes.find((c) => c.code_value === parentValue);
|
||||
if (parentCode) {
|
||||
return isCodeVisible(parentCode);
|
||||
// 부모 행 중 하나라도 접혀있으면 자식 숨김
|
||||
const isRowVisible = (row: CodeDetail): boolean => {
|
||||
let parentId = row.parent_detail_id;
|
||||
while (parentId != null) {
|
||||
if (collapsed.has(String(parentId))) return false;
|
||||
const parentRow = rows.find((r) => String(r.code_detail_id) === String(parentId));
|
||||
parentId = parentRow ? parentRow.parent_detail_id : null;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 표시할 코드 목록 (접힌 상태 반영)
|
||||
const visibleCodes = useMemo(() => {
|
||||
return filteredCodes.filter(isCodeVisible);
|
||||
}, [filteredCodes, collapsedCodes, codes]);
|
||||
const visibleRows = useMemo(() => rows.filter(isRowVisible), [rows, collapsed]);
|
||||
|
||||
// 드래그 앤 드롭 훅 사용
|
||||
const dragAndDrop = useDragAndDrop<CodeInfo>({
|
||||
items: filteredCodes,
|
||||
onReorder: async (reorderedItems) => {
|
||||
await reorderCodesMutation.mutateAsync({
|
||||
categoryCode,
|
||||
codes: reorderedItems.map((item) => ({
|
||||
codeValue: item.id,
|
||||
sortOrder: item.sortOrder,
|
||||
})),
|
||||
// 모달 상태
|
||||
const [showFormModal, setShowFormModal] = useState(false);
|
||||
const [editingRow, setEditingRow] = useState<CodeDetail | null>(null);
|
||||
const [defaultParentDetailId, setDefaultParentDetailId] = useState<number | null>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deletingRow, setDeletingRow] = useState<CodeDetail | null>(null);
|
||||
|
||||
// sort_order 재할당 (형제 단위) — 드래그 결과를 PUT 으로 직렬 호출
|
||||
const dragAndDrop = useDragAndDrop<CodeDetail>({
|
||||
items: visibleRows,
|
||||
getItemId: (row) => String(row.code_detail_id),
|
||||
onReorder: async (reordered) => {
|
||||
// visibleRows 기준으로 재정렬. 형제만 reorder 의미가 있으므로
|
||||
// parent_detail_id 가 같은 그룹별로 처리.
|
||||
const idMap = new Map<string, number>();
|
||||
reordered.forEach(({ id, sortOrder }) => idMap.set(id, sortOrder));
|
||||
|
||||
// 같은 부모 안의 형제만 묶어서 정렬
|
||||
const byParent = new Map<string, CodeDetail[]>();
|
||||
for (const row of visibleRows) {
|
||||
const parentKey =
|
||||
row.parent_detail_id == null ? "ROOT" : String(row.parent_detail_id);
|
||||
const list = byParent.get(parentKey) || [];
|
||||
list.push(row);
|
||||
byParent.set(parentKey, list);
|
||||
}
|
||||
|
||||
for (const list of byParent.values()) {
|
||||
const sorted = [...list].sort((a, b) => {
|
||||
const ao = idMap.get(String(a.code_detail_id)) ?? a.sort_order ?? 0;
|
||||
const bo = idMap.get(String(b.code_detail_id)) ?? b.sort_order ?? 0;
|
||||
return ao - bo;
|
||||
});
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const row = sorted[i];
|
||||
const nextOrder = (i + 1) * 10;
|
||||
if ((row.sort_order ?? 0) === nextOrder) continue;
|
||||
await updateMutation.mutateAsync({
|
||||
codeDetailId: row.code_detail_id,
|
||||
data: {
|
||||
code_info: row.code_info,
|
||||
parent_detail_id: row.parent_detail_id ?? null,
|
||||
code_value: row.code_value,
|
||||
code_name: row.code_name,
|
||||
code_name_eng: row.code_name_eng || "",
|
||||
description: row.description || "",
|
||||
sort_order: nextOrder,
|
||||
is_active: row.is_active || "Y",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
getItemId: (code: CodeInfo) => code.code_value || "",
|
||||
});
|
||||
|
||||
// 새 코드 생성
|
||||
const handleNewCode = () => {
|
||||
setEditingCode(null);
|
||||
setDefaultParentCode(undefined);
|
||||
const handleNew = () => {
|
||||
setEditingRow(null);
|
||||
setDefaultParentDetailId(null);
|
||||
setShowFormModal(true);
|
||||
};
|
||||
|
||||
// 코드 수정
|
||||
const handleEditCode = (code: CodeInfo) => {
|
||||
setEditingCode(code);
|
||||
setDefaultParentCode(undefined);
|
||||
const handleEdit = (row: CodeDetail) => {
|
||||
setEditingRow(row);
|
||||
setDefaultParentDetailId(null);
|
||||
setShowFormModal(true);
|
||||
};
|
||||
|
||||
// 하위 코드 추가
|
||||
const handleAddChild = (parentCode: CodeInfo) => {
|
||||
setEditingCode(null);
|
||||
setDefaultParentCode(parentCode.code_value || "");
|
||||
const handleAddChild = (parent: CodeDetail) => {
|
||||
setEditingRow(null);
|
||||
setDefaultParentDetailId(Number(parent.code_detail_id));
|
||||
setShowFormModal(true);
|
||||
};
|
||||
|
||||
// 코드 삭제 확인
|
||||
const handleDeleteCode = (code: CodeInfo) => {
|
||||
setDeletingCode(code);
|
||||
const handleDelete = (row: CodeDetail) => {
|
||||
setDeletingRow(row);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
// 코드 삭제 실행
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deletingCode) return;
|
||||
|
||||
if (!deletingRow) return;
|
||||
try {
|
||||
await deleteCodeMutation.mutateAsync({
|
||||
categoryCode,
|
||||
codeValue: deletingCode.code_value || "",
|
||||
});
|
||||
|
||||
await deleteMutation.mutateAsync(deletingRow.code_detail_id);
|
||||
setShowDeleteModal(false);
|
||||
setDeletingCode(null);
|
||||
setDeletingRow(null);
|
||||
} catch (error) {
|
||||
console.error("코드 삭제 실패:", error);
|
||||
console.error("디테일 삭제 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 드래그 앤 드롭 로직은 useDragAndDrop 훅에서 처리
|
||||
|
||||
if (!categoryCode) {
|
||||
if (!codeInfo) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">카테고리를 선택하세요</p>
|
||||
<p className="text-sm text-muted-foreground">그룹을 선택하세요</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -237,8 +178,14 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-destructive text-sm font-semibold">코드를 불러오는 중 오류가 발생했습니다.</p>
|
||||
<Button variant="outline" onClick={() => window.location.reload()} className="mt-4 h-10 text-sm font-medium">
|
||||
<p className="text-sm font-semibold text-destructive">
|
||||
디테일을 불러오는 중 오류가 발생했습니다.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 h-10 text-sm font-medium"
|
||||
>
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
@@ -248,77 +195,71 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4">
|
||||
{/* 검색 및 액션 */}
|
||||
<div className="space-y-3">
|
||||
{/* 검색 + 버튼 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="코드 검색..."
|
||||
placeholder="디테일 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleNewCode} className="h-10 gap-2 text-sm font-medium">
|
||||
<Button onClick={handleNew} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 활성 필터 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="activeOnlyCodes"
|
||||
id="activeOnlyDetail"
|
||||
checked={showActiveOnly}
|
||||
onChange={(e) => setShowActiveOnly(e.target.checked)}
|
||||
className="border-input h-4 w-4 rounded"
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<label htmlFor="activeOnlyCodes" className="text-muted-foreground text-sm">
|
||||
<label htmlFor="activeOnlyDetail" className="text-sm text-muted-foreground">
|
||||
활성만 표시
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 코드 목록 (무한 스크롤) */}
|
||||
<div className="space-y-3" onScroll={handleScroll}>
|
||||
<div className="space-y-3">
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : visibleCodes.length === 0 ? (
|
||||
) : visibleRows.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{codes.length === 0 ? "코드가 없습니다." : "검색 결과가 없습니다."}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{rows.length === 0 ? "디테일이 없습니다." : "검색 결과가 없습니다."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DndContext {...dragAndDrop.dndContextProps}>
|
||||
<SortableContext
|
||||
items={visibleCodes.map((code) => code.code_value || "")}
|
||||
items={visibleRows.map((r) => String(r.code_detail_id))}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{visibleCodes.map((code, index) => {
|
||||
const codeValue = code.code_value || "";
|
||||
const children = childrenMap.get(codeValue) || [];
|
||||
{visibleRows.map((row) => {
|
||||
const idStr = String(row.code_detail_id);
|
||||
const children = childrenMap.get(idStr) || [];
|
||||
const hasChildren = children.length > 0;
|
||||
const isExpanded = !collapsedCodes.has(codeValue);
|
||||
const isExpanded = !collapsed.has(idStr);
|
||||
|
||||
return (
|
||||
<SortableCodeItem
|
||||
key={`${codeValue}-${index}`}
|
||||
code={code}
|
||||
categoryCode={categoryCode}
|
||||
onEdit={() => handleEditCode(code)}
|
||||
onDelete={() => handleDeleteCode(code)}
|
||||
onAddChild={() => handleAddChild(code)}
|
||||
key={idStr}
|
||||
row={row}
|
||||
onEdit={() => handleEdit(row)}
|
||||
onDelete={() => handleDelete(row)}
|
||||
onAddChild={() => handleAddChild(row)}
|
||||
hasChildren={hasChildren}
|
||||
childCount={children.length}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={() => toggleExpand(codeValue)}
|
||||
onToggleExpand={() => toggleExpand(idStr)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -326,78 +267,53 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{dragAndDrop.activeItem ? (
|
||||
<div className="bg-card cursor-grabbing rounded-lg border p-4 shadow-lg">
|
||||
{(() => {
|
||||
const activeCode = dragAndDrop.activeItem;
|
||||
if (!activeCode) return null;
|
||||
return (
|
||||
<div className="cursor-grabbing rounded-lg border bg-card p-4 shadow-lg">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold">{activeCode.code_name}</h4>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{dragAndDrop.activeItem.code_name}
|
||||
</h4>
|
||||
<Badge
|
||||
variant={
|
||||
activeCode.is_active === "Y" ? "default" : "secondary"
|
||||
}
|
||||
variant={dragAndDrop.activeItem.is_active === "Y" ? "default" : "secondary"}
|
||||
>
|
||||
{activeCode.is_active === "Y" ? "활성" : "비활성"}
|
||||
{dragAndDrop.activeItem.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{activeCode.code_value}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{dragAndDrop.activeItem.code_value}
|
||||
</p>
|
||||
{activeCode.description && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">{activeCode.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
{/* 무한 스크롤 로딩 인디케이터 */}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<LoadingSpinner size="sm" />
|
||||
<span className="text-muted-foreground ml-2 text-sm">코드를 더 불러오는 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모든 코드 로드 완료 메시지 */}
|
||||
{!hasNextPage && codes.length > 0 && (
|
||||
<div className="text-muted-foreground py-4 text-center text-sm">모든 코드를 불러왔습니다.</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 코드 폼 모달 */}
|
||||
{showFormModal && (
|
||||
<CodeFormModal
|
||||
isOpen={showFormModal}
|
||||
onClose={() => {
|
||||
setShowFormModal(false);
|
||||
setEditingCode(null);
|
||||
setDefaultParentCode(undefined);
|
||||
setEditingRow(null);
|
||||
setDefaultParentDetailId(null);
|
||||
}}
|
||||
categoryCode={categoryCode}
|
||||
editingCode={editingCode}
|
||||
codes={codes}
|
||||
defaultParentCode={defaultParentCode}
|
||||
codeInfo={codeInfo}
|
||||
editingRow={editingRow}
|
||||
allRows={rows}
|
||||
defaultParentDetailId={defaultParentDetailId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
{showDeleteModal && (
|
||||
<AlertModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
type="error"
|
||||
title="코드 삭제"
|
||||
message="정말로 이 코드를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
||||
title="디테일 삭제"
|
||||
message="정말로 이 코드를 삭제하시겠습니까? 하위 코드도 모두 함께 삭제됩니다 (CASCADE)."
|
||||
confirmText="삭제"
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
@@ -405,3 +321,6 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// `arrayMove` import 유지 위해 keep no-op (트리 reorder 로직에서 향후 활용 가능)
|
||||
void arrayMove;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
@@ -9,32 +9,31 @@ import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { ValidationMessage } from "@/components/common/ValidationMessage";
|
||||
import { useCreateCode, useUpdateCode } from "@/hooks/queries/useCodes";
|
||||
import { useCheckCodeDuplicate } from "@/hooks/queries/useValidation";
|
||||
import { createCodeSchema, updateCodeSchema, type CreateCodeData, type UpdateCodeData } from "@/lib/schemas/commonCode";
|
||||
import type { CodeInfo } from "@/types/commonCode";
|
||||
import type { FieldError } from "react-hook-form";
|
||||
import { useCreateCodeDetail, useUpdateCodeDetail } from "@/hooks/queries/useCodeDetail";
|
||||
import { useCheckCodeDetailDuplicate } from "@/hooks/queries/useValidation";
|
||||
import {
|
||||
createCodeDetailSchema,
|
||||
updateCodeDetailSchema,
|
||||
type CreateCodeDetailData,
|
||||
type UpdateCodeDetailData,
|
||||
} from "@/lib/schemas/commonCode";
|
||||
import type { CodeDetail } from "@/types/commonCode";
|
||||
|
||||
interface CodeFormModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
categoryCode: string;
|
||||
editingCode?: CodeInfo | null;
|
||||
codes: CodeInfo[];
|
||||
defaultParentCode?: string; // 하위 코드 추가 시 기본 부모 코드
|
||||
codeInfo: string;
|
||||
editingRow?: CodeDetail | null;
|
||||
allRows: CodeDetail[];
|
||||
defaultParentDetailId?: number | null;
|
||||
}
|
||||
|
||||
// 에러 메시지를 안전하게 문자열로 변환하는 헬퍼 함수
|
||||
const getErrorMessage = (error: FieldError | undefined): string => {
|
||||
if (!error) return "";
|
||||
if (typeof error === "string") return error;
|
||||
return error.message || "";
|
||||
};
|
||||
const PARENT_NONE = "__NONE__";
|
||||
|
||||
// 코드값 자동 생성 함수 (UUID 기반 짧은 코드)
|
||||
/** code_value 자동 생성 (timestamp+random) */
|
||||
const generateCodeValue = (): string => {
|
||||
const timestamp = Date.now().toString(36).toUpperCase();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
@@ -44,194 +43,257 @@ const generateCodeValue = (): string => {
|
||||
export function CodeFormModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
categoryCode,
|
||||
editingCode,
|
||||
codes,
|
||||
defaultParentCode,
|
||||
codeInfo,
|
||||
editingRow,
|
||||
allRows,
|
||||
defaultParentDetailId,
|
||||
}: CodeFormModalProps) {
|
||||
const createCodeMutation = useCreateCode();
|
||||
const updateCodeMutation = useUpdateCode();
|
||||
const createMutation = useCreateCodeDetail();
|
||||
const updateMutation = useUpdateCodeDetail();
|
||||
|
||||
const isEditing = !!editingCode;
|
||||
|
||||
// 검증 상태 관리 (코드명만 중복 검사)
|
||||
const [validationStates, setValidationStates] = useState({
|
||||
codeName: { enabled: false, value: "" },
|
||||
});
|
||||
|
||||
// 코드명 중복 검사
|
||||
const codeNameCheck = useCheckCodeDuplicate(
|
||||
categoryCode,
|
||||
"codeName",
|
||||
validationStates.codeName.value,
|
||||
isEditing ? editingCode?.code_value : undefined,
|
||||
validationStates.codeName.enabled,
|
||||
);
|
||||
|
||||
// 중복 검사 결과 확인
|
||||
const hasDuplicateErrors = codeNameCheck.data?.isDuplicate && validationStates.codeName.enabled;
|
||||
|
||||
// 중복 검사 로딩 중인지 확인
|
||||
const isDuplicateChecking = codeNameCheck.isLoading;
|
||||
|
||||
// 폼 스키마 선택 (생성/수정에 따라)
|
||||
const schema = isEditing ? updateCodeSchema : createCodeSchema;
|
||||
const isEditing = !!editingRow;
|
||||
const schema = isEditing ? updateCodeDetailSchema : createCodeDetailSchema;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
mode: "onChange", // 실시간 검증 활성화
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
codeValue: "",
|
||||
codeName: "",
|
||||
codeNameEng: "",
|
||||
code_info: codeInfo,
|
||||
parent_detail_id: null as number | null,
|
||||
code_value: "",
|
||||
code_name: "",
|
||||
code_name_eng: "",
|
||||
description: "",
|
||||
sortOrder: 1,
|
||||
parentCodeValue: "" as string | undefined,
|
||||
...(isEditing && { isActive: "Y" as const }),
|
||||
sort_order: 10,
|
||||
...(isEditing && { is_active: "Y" as const }),
|
||||
},
|
||||
});
|
||||
|
||||
// 편집 모드일 때 기존 데이터 로드
|
||||
// 부모 후보: 자기 자신 + 자기 자손은 제외
|
||||
const parentOptions = useMemo(() => {
|
||||
if (!isEditing) return allRows;
|
||||
if (!editingRow) return allRows;
|
||||
const selfId = String(editingRow.code_detail_id);
|
||||
|
||||
const descendants = new Set<string>([selfId]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const row of allRows) {
|
||||
const parentKey =
|
||||
row.parent_detail_id == null ? "" : String(row.parent_detail_id);
|
||||
if (
|
||||
descendants.has(parentKey) &&
|
||||
!descendants.has(String(row.code_detail_id))
|
||||
) {
|
||||
descendants.add(String(row.code_detail_id));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return allRows.filter((r) => !descendants.has(String(r.code_detail_id)));
|
||||
}, [allRows, editingRow, isEditing]);
|
||||
|
||||
// 중복 검사 (code_name)
|
||||
const [duplicateState, setDuplicateState] = useState<{ enabled: boolean; value: string }>({
|
||||
enabled: false,
|
||||
value: "",
|
||||
});
|
||||
const nameCheck = useCheckCodeDetailDuplicate(
|
||||
codeInfo,
|
||||
"code_name",
|
||||
duplicateState.value,
|
||||
isEditing ? editingRow?.code_detail_id : undefined,
|
||||
duplicateState.enabled,
|
||||
);
|
||||
const hasDuplicateErrors = !!nameCheck.data?.isDuplicate && duplicateState.enabled;
|
||||
const isDuplicateChecking = nameCheck.isLoading;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (isEditing && editingCode) {
|
||||
// 수정 모드: 기존 데이터 로드 (codeValue는 표시용으로만 설정)
|
||||
const parentValue = editingCode.parent_code_value || "";
|
||||
|
||||
if (!isOpen) return;
|
||||
if (isEditing && editingRow) {
|
||||
form.reset({
|
||||
codeName: editingCode.code_name,
|
||||
codeNameEng: editingCode.code_name_eng || "",
|
||||
description: editingCode.description || "",
|
||||
sortOrder: editingCode.sort_order,
|
||||
isActive: editingCode.is_active as "Y" | "N",
|
||||
parentCodeValue: parentValue,
|
||||
});
|
||||
|
||||
// codeValue는 별도로 설정 (표시용)
|
||||
form.setValue("codeValue" as any, editingCode.code_value);
|
||||
code_info: editingRow.code_info || codeInfo,
|
||||
parent_detail_id: editingRow.parent_detail_id ?? null,
|
||||
code_value: editingRow.code_value || "",
|
||||
code_name: editingRow.code_name || "",
|
||||
code_name_eng: editingRow.code_name_eng || "",
|
||||
description: editingRow.description || "",
|
||||
sort_order: editingRow.sort_order ?? 10,
|
||||
is_active: ((editingRow.is_active as "Y" | "N") || "Y") as any,
|
||||
} as any);
|
||||
} else {
|
||||
// 새 코드 모드: 자동 순서 계산
|
||||
const maxSortOrder = codes.length > 0 ? Math.max(...codes.map((c) => c.sort_order || 0)) : 0;
|
||||
|
||||
// 기본 부모 코드가 있으면 설정 (하위 코드 추가 시)
|
||||
const parentValue = defaultParentCode || "";
|
||||
|
||||
// 코드값 자동 생성
|
||||
const autoCodeValue = generateCodeValue();
|
||||
|
||||
// 형제 중 최대 sort_order + 10
|
||||
const siblings = allRows.filter((r) => {
|
||||
const pid = r.parent_detail_id ?? null;
|
||||
return pid === (defaultParentDetailId ?? null);
|
||||
});
|
||||
const maxSort =
|
||||
siblings.length > 0
|
||||
? Math.max(...siblings.map((r) => r.sort_order ?? 0))
|
||||
: 0;
|
||||
form.reset({
|
||||
codeValue: autoCodeValue,
|
||||
codeName: "",
|
||||
codeNameEng: "",
|
||||
code_info: codeInfo,
|
||||
parent_detail_id: defaultParentDetailId ?? null,
|
||||
code_value: generateCodeValue(),
|
||||
code_name: "",
|
||||
code_name_eng: "",
|
||||
description: "",
|
||||
sortOrder: maxSortOrder + 1,
|
||||
parentCodeValue: parentValue,
|
||||
sort_order: maxSort + 10,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen, isEditing, editingCode, codes, defaultParentCode]);
|
||||
setDuplicateState({ enabled: false, value: "" });
|
||||
}, [isOpen, isEditing, editingRow, codeInfo, defaultParentDetailId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
if (isEditing && editingCode) {
|
||||
// 수정
|
||||
await updateCodeMutation.mutateAsync({
|
||||
categoryCode,
|
||||
codeValue: editingCode.code_value || "",
|
||||
data: data as UpdateCodeData,
|
||||
if (isEditing && editingRow) {
|
||||
await updateMutation.mutateAsync({
|
||||
codeDetailId: editingRow.code_detail_id,
|
||||
data: data as UpdateCodeDetailData,
|
||||
});
|
||||
} else {
|
||||
// 생성
|
||||
await createCodeMutation.mutateAsync({
|
||||
categoryCode,
|
||||
data: data as CreateCodeData,
|
||||
});
|
||||
await createMutation.mutateAsync(data as CreateCodeDetailData);
|
||||
}
|
||||
|
||||
onClose();
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
console.error("코드 저장 실패:", error);
|
||||
console.error("디테일 저장 실패:", error);
|
||||
}
|
||||
});
|
||||
|
||||
const isLoading = createCodeMutation.isPending || updateCodeMutation.isPending;
|
||||
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const watchedParent = form.watch("parent_detail_id");
|
||||
const parentSelectValue =
|
||||
watchedParent == null || watchedParent === undefined
|
||||
? PARENT_NONE
|
||||
: String(watchedParent);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{isEditing ? "코드 수정" : defaultParentCode ? "하위 코드 추가" : "새 코드"}
|
||||
{isEditing
|
||||
? "디테일 수정"
|
||||
: defaultParentDetailId != null
|
||||
? "하위 코드 추가"
|
||||
: "새 코드"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||
{/* 코드값 (자동 생성, 수정 시에만 표시) */}
|
||||
{isEditing && (
|
||||
{/* code_value */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">코드값</Label>
|
||||
<div className="bg-muted h-8 rounded-md border px-3 py-1.5 text-xs sm:h-10 sm:py-2 sm:text-sm">
|
||||
{form.watch("codeValue")}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[10px] sm:text-xs">코드값은 변경할 수 없습니다</p>
|
||||
</div>
|
||||
<Label htmlFor="code_value" className="text-xs sm:text-sm">
|
||||
코드값 *
|
||||
</Label>
|
||||
<Input
|
||||
id="code_value"
|
||||
{...form.register("code_value")}
|
||||
disabled={isLoading || isEditing}
|
||||
placeholder="코드값"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
{isEditing && (
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
코드값은 변경할 수 없습니다
|
||||
</p>
|
||||
)}
|
||||
{form.formState.errors.code_value && (
|
||||
<p className="text-[10px] sm:text-xs text-destructive">
|
||||
{form.formState.errors.code_value.message as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 부모 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parent_detail_id" className="text-xs sm:text-sm">
|
||||
상위 코드
|
||||
</Label>
|
||||
<Select
|
||||
value={parentSelectValue}
|
||||
onValueChange={(v) =>
|
||||
form.setValue("parent_detail_id", v === PARENT_NONE ? null : Number(v), {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="상위 코드를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={PARENT_NONE}>그룹 직속 (2 레벨)</SelectItem>
|
||||
{parentOptions.map((opt) => (
|
||||
<SelectItem
|
||||
key={String(opt.code_detail_id)}
|
||||
value={String(opt.code_detail_id)}
|
||||
>
|
||||
{" ".repeat(Math.max(0, (opt.depth ?? 2) - 2))}
|
||||
{opt.code_name || opt.code_value}{" "}
|
||||
<span className="ml-1 text-muted-foreground">({opt.code_value})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
비워두면 그룹 직속(2 레벨)으로 등록됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 코드명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="codeName" className="text-xs sm:text-sm">
|
||||
<Label htmlFor="code_name" className="text-xs sm:text-sm">
|
||||
코드명 *
|
||||
</Label>
|
||||
<Input
|
||||
id="codeName"
|
||||
{...form.register("codeName")}
|
||||
id="code_name"
|
||||
{...form.register("code_name")}
|
||||
disabled={isLoading}
|
||||
placeholder="코드명을 입력하세요"
|
||||
placeholder="코드명"
|
||||
className={
|
||||
form.formState.errors.codeName
|
||||
? "border-destructive h-8 text-xs sm:h-10 sm:text-sm"
|
||||
form.formState.errors.code_name
|
||||
? "h-8 border-destructive text-xs sm:h-10 sm:text-sm"
|
||||
: "h-8 text-xs sm:h-10 sm:text-sm"
|
||||
}
|
||||
onBlur={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
if (value) {
|
||||
setValidationStates((prev) => ({
|
||||
...prev,
|
||||
codeName: { enabled: true, value },
|
||||
}));
|
||||
}
|
||||
if (value) setDuplicateState({ enabled: true, value });
|
||||
}}
|
||||
/>
|
||||
{form.formState.errors.codeName && (
|
||||
<p className="text-destructive text-[10px] sm:text-xs">
|
||||
{getErrorMessage(form.formState.errors.codeName)}
|
||||
{form.formState.errors.code_name && (
|
||||
<p className="text-[10px] sm:text-xs text-destructive">
|
||||
{form.formState.errors.code_name.message as string}
|
||||
</p>
|
||||
)}
|
||||
{!form.formState.errors.codeName && (
|
||||
{!form.formState.errors.code_name && (
|
||||
<ValidationMessage
|
||||
message={codeNameCheck.data?.message}
|
||||
isValid={!codeNameCheck.data?.isDuplicate}
|
||||
isLoading={codeNameCheck.isLoading}
|
||||
message={nameCheck.data?.message}
|
||||
isValid={!nameCheck.data?.isDuplicate}
|
||||
isLoading={nameCheck.isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 영문명 (선택) */}
|
||||
{/* 영문명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="codeNameEng" className="text-xs sm:text-sm">
|
||||
<Label htmlFor="code_name_eng" className="text-xs sm:text-sm">
|
||||
코드 영문명
|
||||
</Label>
|
||||
<Input
|
||||
id="codeNameEng"
|
||||
{...form.register("codeNameEng")}
|
||||
id="code_name_eng"
|
||||
{...form.register("code_name_eng")}
|
||||
disabled={isLoading}
|
||||
placeholder="코드 영문명을 입력하세요 (선택사항)"
|
||||
placeholder="영문명 (선택)"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 설명 (선택) */}
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-xs sm:text-sm">
|
||||
설명
|
||||
@@ -240,64 +302,41 @@ export function CodeFormModal({
|
||||
id="description"
|
||||
{...form.register("description")}
|
||||
disabled={isLoading}
|
||||
placeholder="설명을 입력하세요 (선택사항)"
|
||||
placeholder="설명 (선택)"
|
||||
rows={2}
|
||||
className="text-xs sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 부모 코드 표시 (하위 코드 추가 시에만 표시, 읽기 전용) */}
|
||||
{defaultParentCode && (
|
||||
{/* 정렬 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">상위 코드</Label>
|
||||
<div className="bg-muted h-8 rounded-md border px-3 py-1.5 text-xs sm:h-10 sm:py-2 sm:text-sm">
|
||||
{(() => {
|
||||
const parentCode = codes.find((c) => c.code_value === defaultParentCode);
|
||||
return parentCode
|
||||
? `${parentCode.code_name} (${defaultParentCode})`
|
||||
: defaultParentCode;
|
||||
})()}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[10px] sm:text-xs">이 코드는 위 코드의 하위로 추가됩니다</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 정렬 순서 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sortOrder" className="text-xs sm:text-sm">
|
||||
<Label htmlFor="sort_order" className="text-xs sm:text-sm">
|
||||
정렬 순서
|
||||
</Label>
|
||||
<Input
|
||||
id="sortOrder"
|
||||
id="sort_order"
|
||||
type="number"
|
||||
{...form.register("sortOrder", { valueAsNumber: true })}
|
||||
{...form.register("sort_order", { valueAsNumber: true })}
|
||||
disabled={isLoading}
|
||||
min={1}
|
||||
className={
|
||||
form.formState.errors.sortOrder
|
||||
? "border-destructive h-8 text-xs sm:h-10 sm:text-sm"
|
||||
: "h-8 text-xs sm:h-10 sm:text-sm"
|
||||
}
|
||||
min={0}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
{form.formState.errors.sortOrder && (
|
||||
<p className="text-destructive text-[10px] sm:text-xs">
|
||||
{getErrorMessage(form.formState.errors.sortOrder)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 활성 상태 (수정 시에만) */}
|
||||
{/* 활성 (수정) */}
|
||||
{isEditing && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="isActive"
|
||||
checked={form.watch("isActive") === "Y"}
|
||||
onCheckedChange={(checked) => form.setValue("isActive", checked ? "Y" : "N")}
|
||||
id="is_active"
|
||||
checked={form.watch("is_active" as any) === "Y"}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue("is_active" as any, checked ? "Y" : "N")
|
||||
}
|
||||
disabled={isLoading}
|
||||
aria-label="활성 상태"
|
||||
/>
|
||||
<Label htmlFor="isActive" className="text-xs sm:text-sm">
|
||||
{form.watch("isActive") === "Y" ? "활성" : "비활성"}
|
||||
<Label htmlFor="is_active" className="text-xs sm:text-sm">
|
||||
{form.watch("is_active" as any) === "Y" ? "활성" : "비활성"}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
@@ -315,7 +354,12 @@ export function CodeFormModal({
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !form.formState.isValid || hasDuplicateErrors || isDuplicateChecking}
|
||||
disabled={
|
||||
isLoading ||
|
||||
!form.formState.isValid ||
|
||||
hasDuplicateErrors ||
|
||||
isDuplicateChecking
|
||||
}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isLoading ? (
|
||||
@@ -324,9 +368,9 @@ export function CodeFormModal({
|
||||
{isEditing ? "수정 중..." : "저장 중..."}
|
||||
</>
|
||||
) : isEditing ? (
|
||||
"코드 수정"
|
||||
"디테일 수정"
|
||||
) : (
|
||||
"코드 저장"
|
||||
"디테일 저장"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { ValidationMessage } from "@/components/common/ValidationMessage";
|
||||
import { useCreateCodeInfo, useUpdateCodeInfo } from "@/hooks/queries/useCodeInfo";
|
||||
import { useCheckCodeInfoDuplicate } from "@/hooks/queries/useValidation";
|
||||
import type { CodeInfo } from "@/types/commonCode";
|
||||
import {
|
||||
createCodeInfoSchema,
|
||||
updateCodeInfoSchema,
|
||||
type CreateCodeInfoData,
|
||||
type UpdateCodeInfoData,
|
||||
} from "@/lib/schemas/commonCode";
|
||||
|
||||
interface CodeInfoFormModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
editingCodeInfo?: string;
|
||||
existingRows: CodeInfo[];
|
||||
}
|
||||
|
||||
export function CodeInfoFormModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
editingCodeInfo,
|
||||
existingRows,
|
||||
}: CodeInfoFormModalProps) {
|
||||
const createMutation = useCreateCodeInfo();
|
||||
const updateMutation = useUpdateCodeInfo();
|
||||
|
||||
const isEditing = !!editingCodeInfo;
|
||||
const editingRow = existingRows.find((r) => r.code_info === editingCodeInfo);
|
||||
|
||||
const createForm = useForm<CreateCodeInfoData>({
|
||||
resolver: zodResolver(createCodeInfoSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
code_info: "",
|
||||
code_name: "",
|
||||
code_name_eng: "",
|
||||
description: "",
|
||||
sort_order: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const updateForm = useForm<UpdateCodeInfoData>({
|
||||
resolver: zodResolver(updateCodeInfoSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
code_name: "",
|
||||
code_name_eng: "",
|
||||
description: "",
|
||||
sort_order: 0,
|
||||
is_active: "Y",
|
||||
},
|
||||
});
|
||||
|
||||
const [validatedFields, setValidatedFields] = useState<Set<string>>(new Set());
|
||||
const handleFieldBlur = (name: string) => {
|
||||
setValidatedFields((prev) => new Set(prev).add(name));
|
||||
};
|
||||
|
||||
const codeCheck = useCheckCodeInfoDuplicate(
|
||||
"code_info",
|
||||
isEditing ? "" : createForm.watch("code_info"),
|
||||
isEditing ? editingCodeInfo : undefined,
|
||||
validatedFields.has("code_info"),
|
||||
);
|
||||
const nameCheck = useCheckCodeInfoDuplicate(
|
||||
"code_name",
|
||||
isEditing ? updateForm.watch("code_name") : createForm.watch("code_name"),
|
||||
isEditing ? editingCodeInfo : undefined,
|
||||
validatedFields.has("code_name"),
|
||||
);
|
||||
const nameEngCheck = useCheckCodeInfoDuplicate(
|
||||
"code_name_eng",
|
||||
isEditing
|
||||
? updateForm.watch("code_name_eng") || ""
|
||||
: createForm.watch("code_name_eng") || "",
|
||||
isEditing ? editingCodeInfo : undefined,
|
||||
validatedFields.has("code_name_eng"),
|
||||
);
|
||||
|
||||
const hasDuplicateErrors =
|
||||
(!isEditing && codeCheck.data?.isDuplicate && validatedFields.has("code_info")) ||
|
||||
(nameCheck.data?.isDuplicate && validatedFields.has("code_name")) ||
|
||||
(nameEngCheck.data?.isDuplicate && validatedFields.has("code_name_eng"));
|
||||
|
||||
const isDuplicateChecking =
|
||||
(!isEditing && codeCheck.isLoading) || nameCheck.isLoading || nameEngCheck.isLoading;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setValidatedFields(new Set());
|
||||
|
||||
if (isEditing && editingRow) {
|
||||
updateForm.reset({
|
||||
code_name: editingRow.code_name,
|
||||
code_name_eng: editingRow.code_name_eng || "",
|
||||
description: editingRow.description || "",
|
||||
sort_order: editingRow.sort_order ?? 0,
|
||||
is_active: (editingRow.is_active as "Y" | "N") || "Y",
|
||||
});
|
||||
} else {
|
||||
const maxSort =
|
||||
existingRows.length > 0
|
||||
? Math.max(...existingRows.map((r) => r.sort_order ?? 0))
|
||||
: 0;
|
||||
createForm.reset({
|
||||
code_info: "",
|
||||
code_name: "",
|
||||
code_name_eng: "",
|
||||
description: "",
|
||||
sort_order: maxSort + 1,
|
||||
});
|
||||
}
|
||||
}, [isOpen, isEditing, editingRow, existingRows]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSubmit = isEditing
|
||||
? updateForm.handleSubmit(async (data) => {
|
||||
try {
|
||||
await updateMutation.mutateAsync({ codeInfo: editingCodeInfo!, data });
|
||||
onClose();
|
||||
updateForm.reset();
|
||||
} catch (error) {
|
||||
console.error("그룹 수정 실패:", error);
|
||||
}
|
||||
})
|
||||
: createForm.handleSubmit(async (data) => {
|
||||
try {
|
||||
await createMutation.mutateAsync(data);
|
||||
onClose();
|
||||
createForm.reset();
|
||||
} catch (error) {
|
||||
console.error("그룹 생성 실패:", error);
|
||||
}
|
||||
});
|
||||
|
||||
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{isEditing ? "그룹 수정" : "새 그룹"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||
{/* 그룹 코드 (code_info) */}
|
||||
{!isEditing && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code_info" className="text-xs sm:text-sm">
|
||||
그룹 코드 *
|
||||
</Label>
|
||||
<Input
|
||||
id="code_info"
|
||||
{...createForm.register("code_info")}
|
||||
disabled={isLoading}
|
||||
placeholder="예: STATUS, USER_ROLE"
|
||||
className={
|
||||
createForm.formState.errors.code_info
|
||||
? "h-8 border-destructive text-xs sm:h-10 sm:text-sm"
|
||||
: "h-8 text-xs sm:h-10 sm:text-sm"
|
||||
}
|
||||
onBlur={() => handleFieldBlur("code_info")}
|
||||
/>
|
||||
{createForm.formState.errors.code_info && (
|
||||
<p className="text-[10px] sm:text-xs text-destructive">
|
||||
{createForm.formState.errors.code_info.message}
|
||||
</p>
|
||||
)}
|
||||
{!createForm.formState.errors.code_info && (
|
||||
<ValidationMessage
|
||||
message={codeCheck.data?.message}
|
||||
isValid={!codeCheck.data?.isDuplicate}
|
||||
isLoading={codeCheck.isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing && editingRow && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code_info_display" className="text-xs sm:text-sm">
|
||||
그룹 코드
|
||||
</Label>
|
||||
<Input
|
||||
id="code_info_display"
|
||||
value={editingRow.code_info}
|
||||
disabled
|
||||
className="h-8 cursor-not-allowed bg-muted text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
그룹 코드는 수정할 수 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 그룹명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code_name">그룹명 *</Label>
|
||||
<Input
|
||||
id="code_name"
|
||||
{...(isEditing
|
||||
? updateForm.register("code_name")
|
||||
: createForm.register("code_name"))}
|
||||
disabled={isLoading}
|
||||
placeholder="그룹명을 입력하세요"
|
||||
className={
|
||||
(isEditing
|
||||
? updateForm.formState.errors.code_name
|
||||
: createForm.formState.errors.code_name)
|
||||
? "border-destructive"
|
||||
: ""
|
||||
}
|
||||
onBlur={() => handleFieldBlur("code_name")}
|
||||
/>
|
||||
{(isEditing
|
||||
? updateForm.formState.errors.code_name
|
||||
: createForm.formState.errors.code_name) && (
|
||||
<p className="text-sm text-destructive">
|
||||
{
|
||||
(isEditing
|
||||
? updateForm.formState.errors.code_name
|
||||
: createForm.formState.errors.code_name
|
||||
)?.message
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
{!(isEditing
|
||||
? updateForm.formState.errors.code_name
|
||||
: createForm.formState.errors.code_name) && (
|
||||
<ValidationMessage
|
||||
message={nameCheck.data?.message}
|
||||
isValid={!nameCheck.data?.isDuplicate}
|
||||
isLoading={nameCheck.isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 영문명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code_name_eng">그룹 영문명</Label>
|
||||
<Input
|
||||
id="code_name_eng"
|
||||
{...(isEditing
|
||||
? updateForm.register("code_name_eng")
|
||||
: createForm.register("code_name_eng"))}
|
||||
disabled={isLoading}
|
||||
placeholder="영문명을 입력하세요 (선택)"
|
||||
onBlur={() => handleFieldBlur("code_name_eng")}
|
||||
/>
|
||||
{!(isEditing
|
||||
? updateForm.formState.errors.code_name_eng
|
||||
: createForm.formState.errors.code_name_eng) && (
|
||||
<ValidationMessage
|
||||
message={nameEngCheck.data?.message}
|
||||
isValid={!nameEngCheck.data?.isDuplicate}
|
||||
isLoading={nameEngCheck.isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
{...(isEditing
|
||||
? updateForm.register("description")
|
||||
: createForm.register("description"))}
|
||||
disabled={isLoading}
|
||||
placeholder="설명을 입력하세요 (선택)"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 순서 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sort_order">정렬 순서</Label>
|
||||
<Input
|
||||
id="sort_order"
|
||||
type="number"
|
||||
{...(isEditing
|
||||
? updateForm.register("sort_order", { valueAsNumber: true })
|
||||
: createForm.register("sort_order", { valueAsNumber: true }))}
|
||||
disabled={isLoading}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 활성 상태 (수정 시) */}
|
||||
{isEditing && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={updateForm.watch("is_active") === "Y"}
|
||||
onCheckedChange={(checked) =>
|
||||
updateForm.setValue("is_active", checked ? "Y" : "N")
|
||||
}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Label htmlFor="is_active">
|
||||
{updateForm.watch("is_active") === "Y" ? "활성" : "비활성"}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex gap-2 pt-4 sm:justify-end sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isLoading ||
|
||||
!(isEditing ? updateForm.formState.isValid : createForm.formState.isValid) ||
|
||||
hasDuplicateErrors ||
|
||||
isDuplicateChecking
|
||||
}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<LoadingSpinner size="sm" className="mr-2" />
|
||||
{isEditing ? "수정 중..." : "저장 중..."}
|
||||
</>
|
||||
) : isEditing ? (
|
||||
"그룹 수정"
|
||||
) : (
|
||||
"그룹 저장"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+26
-28
@@ -5,35 +5,34 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Edit, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useUpdateCategory } from "@/hooks/queries/useCategories";
|
||||
import type { CategoryInfo } from "@/types/commonCode";
|
||||
import { useUpdateCodeInfo } from "@/hooks/queries/useCodeInfo";
|
||||
import type { CodeInfo } from "@/types/commonCode";
|
||||
|
||||
interface CategoryItemProps {
|
||||
category: CategoryInfo;
|
||||
interface CodeInfoItemProps {
|
||||
row: CodeInfo;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete }: CategoryItemProps) {
|
||||
const updateCategoryMutation = useUpdateCategory();
|
||||
export function CodeInfoItem({ row, isSelected, onSelect, onEdit, onDelete }: CodeInfoItemProps) {
|
||||
const updateMutation = useUpdateCodeInfo();
|
||||
|
||||
// 활성/비활성 토글 핸들러
|
||||
const handleToggleActive = async (checked: boolean) => {
|
||||
const handleToggleActive = async (next: "Y" | "N") => {
|
||||
try {
|
||||
await updateCategoryMutation.mutateAsync({
|
||||
categoryCode: category.category_code,
|
||||
await updateMutation.mutateAsync({
|
||||
codeInfo: row.code_info,
|
||||
data: {
|
||||
categoryName: category.category_name,
|
||||
categoryNameEng: category.category_name_eng || "",
|
||||
description: category.description || "",
|
||||
sortOrder: category.sort_order,
|
||||
isActive: checked ? "Y" : "N",
|
||||
code_name: row.code_name,
|
||||
code_name_eng: row.code_name_eng || "",
|
||||
description: row.description || "",
|
||||
sort_order: row.sort_order ?? 0,
|
||||
is_active: next,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("카테고리 활성 상태 변경 실패:", error);
|
||||
console.error("그룹 활성 상태 변경 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -41,40 +40,39 @@ export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete
|
||||
<div
|
||||
className={cn(
|
||||
"cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all",
|
||||
isSelected
|
||||
? "shadow-md"
|
||||
: "hover:shadow-md",
|
||||
isSelected ? "border-primary shadow-md" : "hover:shadow-md",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold">{category.category_name}</h4>
|
||||
<h4 className="text-sm font-semibold">{row.code_name}</h4>
|
||||
<Badge
|
||||
variant={category.is_active === "Y" ? "default" : "secondary"}
|
||||
variant={row.is_active === "Y" ? "default" : "secondary"}
|
||||
className={cn(
|
||||
"cursor-pointer text-xs transition-colors",
|
||||
updateCategoryMutation.isPending && "cursor-not-allowed opacity-50",
|
||||
updateMutation.isPending && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!updateCategoryMutation.isPending) {
|
||||
handleToggleActive(category.is_active !== "Y");
|
||||
if (!updateMutation.isPending) {
|
||||
handleToggleActive(row.is_active === "Y" ? "N" : "Y");
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{category.is_active === "Y" ? "활성" : "비활성"}
|
||||
{row.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{category.category_code}</p>
|
||||
{category.description && <p className="mt-1 text-xs text-muted-foreground">{category.description}</p>}
|
||||
<p className="mt-1 text-xs text-muted-foreground">{row.code_info}</p>
|
||||
{row.description && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{row.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
{isSelected && (
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="sm" onClick={onEdit}>
|
||||
+52
-67
@@ -4,77 +4,66 @@ import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { CodeCategoryFormModal } from "./CodeCategoryFormModal";
|
||||
import { CategoryItem } from "./CategoryItem";
|
||||
import { CodeInfoFormModal } from "./CodeInfoFormModal";
|
||||
import { CodeInfoItem } from "./CodeInfoItem";
|
||||
import { AlertModal } from "@/components/common/AlertModal";
|
||||
import { Search, Plus } from "lucide-react";
|
||||
import { useDeleteCategory } from "@/hooks/queries/useCategories";
|
||||
import { useCategoriesInfinite } from "@/hooks/queries/useCategoriesInfinite";
|
||||
import { useDeleteCodeInfo } from "@/hooks/queries/useCodeInfo";
|
||||
import { useCodeInfoInfinite } from "@/hooks/queries/useCodeInfoInfinite";
|
||||
|
||||
interface CodeCategoryPanelProps {
|
||||
selectedCategoryCode: string;
|
||||
onSelectCategory: (categoryCode: string) => void;
|
||||
interface CodeInfoPanelProps {
|
||||
selectedCodeInfo: string;
|
||||
onSelectCodeInfo: (codeInfo: string) => void;
|
||||
}
|
||||
|
||||
export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: CodeCategoryPanelProps) {
|
||||
// 검색 및 필터 상태 (먼저 선언)
|
||||
export function CodeInfoPanel({ selectedCodeInfo, onSelectCodeInfo }: CodeInfoPanelProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [showActiveOnly, setShowActiveOnly] = useState(false);
|
||||
|
||||
// React Query로 카테고리 데이터 관리 (무한 스크롤)
|
||||
const {
|
||||
data: categories = [],
|
||||
data: rows = [],
|
||||
isLoading,
|
||||
error,
|
||||
handleScroll,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
} = useCategoriesInfinite({
|
||||
} = useCodeInfoInfinite({
|
||||
search: searchTerm || undefined,
|
||||
active: showActiveOnly || undefined, // isActive -> active로 수정
|
||||
active: showActiveOnly || undefined,
|
||||
});
|
||||
const deleteCategoryMutation = useDeleteCategory();
|
||||
const deleteMutation = useDeleteCodeInfo();
|
||||
|
||||
// 모달 상태
|
||||
const [showFormModal, setShowFormModal] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<string>("");
|
||||
const [editingCode, setEditingCode] = useState<string>("");
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deletingCategory, setDeletingCategory] = useState<string>("");
|
||||
const [deletingCode, setDeletingCode] = useState<string>("");
|
||||
|
||||
// 새 카테고리 생성
|
||||
const handleNewCategory = () => {
|
||||
setEditingCategory("");
|
||||
const handleNew = () => {
|
||||
setEditingCode("");
|
||||
setShowFormModal(true);
|
||||
};
|
||||
|
||||
// 카테고리 수정
|
||||
const handleEditCategory = (categoryCode: string) => {
|
||||
setEditingCategory(categoryCode);
|
||||
const handleEdit = (code: string) => {
|
||||
setEditingCode(code);
|
||||
setShowFormModal(true);
|
||||
};
|
||||
|
||||
// 카테고리 삭제 확인
|
||||
const handleDeleteCategory = (categoryCode: string) => {
|
||||
setDeletingCategory(categoryCode);
|
||||
const handleDelete = (code: string) => {
|
||||
setDeletingCode(code);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
// 카테고리 삭제 실행
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deletingCategory) return;
|
||||
|
||||
if (!deletingCode) return;
|
||||
try {
|
||||
await deleteCategoryMutation.mutateAsync(deletingCategory);
|
||||
|
||||
// 삭제된 카테고리가 선택된 상태라면 선택 해제
|
||||
if (selectedCategoryCode === deletingCategory) {
|
||||
onSelectCategory("");
|
||||
await deleteMutation.mutateAsync(deletingCode);
|
||||
if (selectedCodeInfo === deletingCode) {
|
||||
onSelectCodeInfo("");
|
||||
}
|
||||
|
||||
setShowDeleteModal(false);
|
||||
setDeletingCategory("");
|
||||
setDeletingCode("");
|
||||
} catch (error) {
|
||||
console.error("카테고리 삭제 실패:", error);
|
||||
console.error("그룹 삭제 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,7 +71,7 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-destructive">카테고리를 불러오는 중 오류가 발생했습니다.</p>
|
||||
<p className="text-destructive">그룹을 불러오는 중 오류가 발생했습니다.</p>
|
||||
<Button variant="outline" onClick={() => window.location.reload()} className="mt-2">
|
||||
다시 시도
|
||||
</Button>
|
||||
@@ -93,66 +82,63 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4">
|
||||
{/* 검색 및 액션 */}
|
||||
{/* 검색 + 등록 */}
|
||||
<div className="space-y-3">
|
||||
{/* 검색 + 버튼 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="카테고리 검색..."
|
||||
placeholder="그룹 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleNewCategory} className="h-10 gap-2 text-sm font-medium">
|
||||
<Button onClick={handleNew} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 활성 필터 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="activeOnly"
|
||||
id="activeOnlyInfo"
|
||||
checked={showActiveOnly}
|
||||
onChange={(e) => setShowActiveOnly(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<label htmlFor="activeOnly" className="text-sm text-muted-foreground">
|
||||
<label htmlFor="activeOnlyInfo" className="text-sm text-muted-foreground">
|
||||
활성만 표시
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 목록 (무한 스크롤) */}
|
||||
{/* 목록 */}
|
||||
<div className="space-y-3" onScroll={handleScroll}>
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
) : rows.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{searchTerm ? "검색 결과가 없습니다." : "카테고리가 없습니다."}
|
||||
{searchTerm ? "검색 결과가 없습니다." : "그룹이 없습니다."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{categories.map((category, index) => (
|
||||
<CategoryItem
|
||||
key={`${category.category_code}-${index}`}
|
||||
category={category}
|
||||
isSelected={selectedCategoryCode === category.category_code}
|
||||
onSelect={() => onSelectCategory(category.category_code)}
|
||||
onEdit={() => handleEditCategory(category.category_code)}
|
||||
onDelete={() => handleDeleteCategory(category.category_code)}
|
||||
{rows.map((row, index) => (
|
||||
<CodeInfoItem
|
||||
key={`${row.code_info}-${index}`}
|
||||
row={row}
|
||||
isSelected={selectedCodeInfo === row.code_info}
|
||||
onSelect={() => onSelectCodeInfo(row.code_info)}
|
||||
onEdit={() => handleEdit(row.code_info)}
|
||||
onDelete={() => handleDelete(row.code_info)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 추가 로딩 표시 */}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<LoadingSpinner size="sm" />
|
||||
@@ -160,32 +146,31 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 더 이상 데이터가 없을 때 */}
|
||||
{!hasNextPage && categories.length > 0 && (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">모든 카테고리를 불러왔습니다.</div>
|
||||
{!hasNextPage && rows.length > 0 && (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
모든 그룹을 불러왔습니다.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 카테고리 폼 모달 */}
|
||||
{showFormModal && (
|
||||
<CodeCategoryFormModal
|
||||
<CodeInfoFormModal
|
||||
isOpen={showFormModal}
|
||||
onClose={() => setShowFormModal(false)}
|
||||
editingCategoryCode={editingCategory}
|
||||
categories={categories}
|
||||
editingCodeInfo={editingCode}
|
||||
existingRows={rows}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
{showDeleteModal && (
|
||||
<AlertModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
type="error"
|
||||
title="카테고리 삭제"
|
||||
message="정말로 이 카테고리를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
||||
title="그룹 삭제"
|
||||
message="정말로 이 그룹을 삭제하시겠습니까? 그룹의 모든 디테일 코드도 함께 삭제됩니다 (CASCADE)."
|
||||
confirmText="삭제"
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
@@ -58,7 +58,7 @@ export function MenuCopyDialog({
|
||||
const [addPrefix, setAddPrefix] = useState("");
|
||||
|
||||
// 카테고리/코드 복사 옵션
|
||||
const [copyCodeCategory, setCopyCodeCategory] = useState(false);
|
||||
const [copyCodeInfo, setCopyCodeInfo] = useState(false);
|
||||
const [copyNumberingRules, setCopyNumberingRules] = useState(false);
|
||||
const [copyCategoryMapping, setCopyCategoryMapping] = useState(false);
|
||||
const [copyTableTypeColumns, setCopyTableTypeColumns] = useState(false);
|
||||
@@ -74,7 +74,7 @@ export function MenuCopyDialog({
|
||||
setUseBulkRename(false);
|
||||
setRemoveText("");
|
||||
setAddPrefix("");
|
||||
setCopyCodeCategory(false);
|
||||
setCopyCodeInfo(false);
|
||||
setCopyNumberingRules(false);
|
||||
setCopyCategoryMapping(false);
|
||||
setCopyTableTypeColumns(false);
|
||||
@@ -127,7 +127,7 @@ export function MenuCopyDialog({
|
||||
|
||||
// 추가 복사 옵션
|
||||
const additionalCopyOptions = {
|
||||
copyCodeCategory,
|
||||
copyCodeInfo,
|
||||
copyNumberingRules,
|
||||
copyCategoryMapping,
|
||||
copyTableTypeColumns,
|
||||
@@ -294,13 +294,13 @@ export function MenuCopyDialog({
|
||||
<div className="space-y-2 pl-2 border-l-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="copyCodeCategory"
|
||||
checked={copyCodeCategory}
|
||||
onCheckedChange={(checked) => setCopyCodeCategory(checked as boolean)}
|
||||
id="copyCodeInfo"
|
||||
checked={copyCodeInfo}
|
||||
onCheckedChange={(checked) => setCopyCodeInfo(checked as boolean)}
|
||||
disabled={copying}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="copyCodeCategory"
|
||||
htmlFor="copyCodeInfo"
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
코드 카테고리 + 코드 복사
|
||||
|
||||
@@ -7,85 +7,76 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Edit, Trash2, CornerDownRight, Plus, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useUpdateCode } from "@/hooks/queries/useCodes";
|
||||
import type { CodeInfo } from "@/types/commonCode";
|
||||
import { useUpdateCodeDetail } from "@/hooks/queries/useCodeDetail";
|
||||
import type { CodeDetail } from "@/types/commonCode";
|
||||
|
||||
interface SortableCodeItemProps {
|
||||
code: CodeInfo;
|
||||
categoryCode: string;
|
||||
row: CodeDetail;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onAddChild: () => void; // 하위 코드 추가
|
||||
onAddChild: () => void;
|
||||
isDragOverlay?: boolean;
|
||||
maxDepth?: number; // 최대 깊이 (기본값 3)
|
||||
hasChildren?: boolean; // 자식이 있는지 여부
|
||||
childCount?: number; // 자식 개수
|
||||
isExpanded?: boolean; // 펼쳐진 상태
|
||||
onToggleExpand?: () => void; // 접기/펼치기 토글
|
||||
hasChildren?: boolean;
|
||||
childCount?: number;
|
||||
isExpanded?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
}
|
||||
|
||||
export function SortableCodeItem({
|
||||
code,
|
||||
categoryCode,
|
||||
row,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddChild,
|
||||
isDragOverlay = false,
|
||||
maxDepth = 3,
|
||||
hasChildren = false,
|
||||
childCount = 0,
|
||||
isExpanded = true,
|
||||
onToggleExpand,
|
||||
}: SortableCodeItemProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: code.code_value || "",
|
||||
id: String(row.code_detail_id),
|
||||
disabled: isDragOverlay,
|
||||
});
|
||||
const updateCodeMutation = useUpdateCode();
|
||||
const updateMutation = useUpdateCodeDetail();
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
// 활성/비활성 토글 핸들러
|
||||
const handleToggleActive = async (checked: boolean) => {
|
||||
const handleToggleActive = async (next: "Y" | "N") => {
|
||||
try {
|
||||
const codeValue = code.code_value;
|
||||
if (!codeValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateCodeMutation.mutateAsync({
|
||||
categoryCode,
|
||||
codeValue: codeValue,
|
||||
await updateMutation.mutateAsync({
|
||||
codeDetailId: row.code_detail_id,
|
||||
data: {
|
||||
codeName: code.code_name,
|
||||
codeNameEng: code.code_name_eng || "",
|
||||
description: code.description || "",
|
||||
sortOrder: code.sort_order,
|
||||
isActive: checked ? "Y" : "N",
|
||||
code_info: row.code_info,
|
||||
parent_detail_id: row.parent_detail_id ?? null,
|
||||
code_value: row.code_value,
|
||||
code_name: row.code_name,
|
||||
code_name_eng: row.code_name_eng || "",
|
||||
description: row.description || "",
|
||||
sort_order: row.sort_order ?? 10,
|
||||
is_active: next,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("코드 활성 상태 변경 실패:", error);
|
||||
console.error("디테일 활성 상태 변경 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 계층구조 깊이에 따른 들여쓰기
|
||||
const depth = code.depth || 1;
|
||||
const indentLevel = (depth - 1) * 28; // 28px per level
|
||||
const hasParent = !!code.parent_code_value;
|
||||
// depth 는 2 부터. (그룹 직속 = 2)
|
||||
const depth: number = row.depth ?? 2;
|
||||
const indentLevel = Math.max(0, depth - 2) * 28;
|
||||
const hasParent = row.parent_detail_id != null;
|
||||
|
||||
return (
|
||||
<div className="flex items-stretch">
|
||||
{/* 계층구조 들여쓰기 영역 */}
|
||||
{depth > 1 && (
|
||||
{indentLevel > 0 && (
|
||||
<div
|
||||
className="flex items-center justify-end pr-2"
|
||||
style={{ width: `${indentLevel}px`, minWidth: `${indentLevel}px` }}
|
||||
>
|
||||
<CornerDownRight className="text-muted-foreground/50 h-4 w-4" />
|
||||
<CornerDownRight className="h-4 w-4 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -95,17 +86,17 @@ export function SortableCodeItem({
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={cn(
|
||||
"group bg-card flex-1 cursor-grab rounded-lg border p-4 shadow-sm transition-all hover:shadow-md",
|
||||
"group flex-1 cursor-grab rounded-lg border bg-card p-4 shadow-sm transition-all hover:shadow-md",
|
||||
isDragging && "cursor-grabbing opacity-50",
|
||||
depth === 1 && "border-l-primary border-l-4",
|
||||
depth === 2 && "border-l-4 border-l-blue-400",
|
||||
depth === 3 && "border-l-4 border-l-green-400",
|
||||
depth === 2 && "border-l-4 border-l-primary",
|
||||
depth === 3 && "border-l-4 border-l-blue-400",
|
||||
depth === 4 && "border-l-4 border-l-green-400",
|
||||
depth >= 5 && "border-l-4 border-l-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* 접기/펼치기 버튼 (자식이 있을 때만 표시) */}
|
||||
{hasChildren && onToggleExpand && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -116,77 +107,54 @@ export function SortableCodeItem({
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="text-muted-foreground hover:text-foreground -ml-1 flex h-5 w-5 items-center justify-center rounded transition-colors hover:bg-muted"
|
||||
className="-ml-1 flex h-5 w-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title={isExpanded ? "접기" : "펼치기"}
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
)}
|
||||
<h4 className="text-sm font-semibold">{code.code_name}</h4>
|
||||
{/* 접힌 상태에서 자식 개수 표시 */}
|
||||
{hasChildren && !isExpanded && <span className="text-muted-foreground text-[10px]">({childCount})</span>}
|
||||
{/* 깊이 표시 배지 */}
|
||||
{depth === 1 && (
|
||||
<h4 className="text-sm font-semibold">{row.code_name}</h4>
|
||||
{hasChildren && !isExpanded && (
|
||||
<span className="text-[10px] text-muted-foreground">({childCount})</span>
|
||||
)}
|
||||
<Badge variant="outline" className="bg-muted/40 px-1.5 py-0 text-[10px]">
|
||||
depth {depth}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-primary/30 bg-primary/10 text-primary px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
대분류
|
||||
</Badge>
|
||||
)}
|
||||
{depth === 2 && (
|
||||
<Badge variant="outline" className="bg-primary/10 px-1.5 py-0 text-[10px] text-primary">
|
||||
중분류
|
||||
</Badge>
|
||||
)}
|
||||
{depth === 3 && (
|
||||
<Badge variant="outline" className="bg-emerald-50 px-1.5 py-0 text-[10px] text-emerald-600">
|
||||
소분류
|
||||
</Badge>
|
||||
)}
|
||||
{depth > 3 && (
|
||||
<Badge variant="outline" className="bg-muted px-1.5 py-0 text-[10px]">
|
||||
{depth}단계
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant={code.is_active === "Y" ? "default" : "secondary"}
|
||||
variant={row.is_active === "Y" ? "default" : "secondary"}
|
||||
className={cn(
|
||||
"cursor-pointer text-xs transition-colors",
|
||||
updateCodeMutation.isPending && "cursor-not-allowed opacity-50",
|
||||
updateMutation.isPending && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!updateCodeMutation.isPending) {
|
||||
const isActive = code.is_active === "Y";
|
||||
handleToggleActive(!isActive);
|
||||
if (!updateMutation.isPending) {
|
||||
handleToggleActive(row.is_active === "Y" ? "N" : "Y");
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{code.is_active === "Y" ? "활성" : "비활성"}
|
||||
{row.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{code.code_value}</p>
|
||||
{/* 부모 코드 표시 */}
|
||||
<p className="mt-1 text-xs text-muted-foreground">{row.code_value}</p>
|
||||
{hasParent && (
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||
상위: {code.parent_code_value}
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
상위: detail#{row.parent_detail_id}
|
||||
</p>
|
||||
)}
|
||||
{code.description && <p className="text-muted-foreground mt-1 text-xs">{code.description}</p>}
|
||||
{row.description && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{row.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 하위 코드 추가 버튼 (최대 깊이 미만일 때만 표시) */}
|
||||
{depth < maxDepth && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -200,7 +168,6 @@ export function SortableCodeItem({
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface ColumnDetailPanelProps {
|
||||
onClose: () => void;
|
||||
onLoadReferenceColumns?: (tableName: string) => void;
|
||||
/** 코드 카테고리 옵션 (value, label) */
|
||||
codeCategoryOptions?: Array<{ value: string; label: string }>;
|
||||
codeInfoOptions?: Array<{ value: string; label: string }>;
|
||||
/** 참조 테이블 옵션 (value, label) */
|
||||
referenceTableOptions?: Array<{ value: string; label: string }>;
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export function ColumnDetailPanel({
|
||||
onColumnChange,
|
||||
onClose,
|
||||
onLoadReferenceColumns,
|
||||
codeCategoryOptions = [],
|
||||
codeInfoOptions = [],
|
||||
referenceTableOptions = [],
|
||||
}: ColumnDetailPanelProps) {
|
||||
const [advancedOpen, setAdvancedOpen] = React.useState(false);
|
||||
@@ -75,11 +75,9 @@ export function ColumnDetailPanel({
|
||||
return n;
|
||||
}, [column]);
|
||||
|
||||
if (!column) return null;
|
||||
|
||||
const refTableOpts = useMemo(() => {
|
||||
const hasKorean = (s: string) => /[가-힣]/.test(s);
|
||||
const raw = referenceTableOptions.length
|
||||
const rawSource = referenceTableOptions.length
|
||||
? [...referenceTableOptions]
|
||||
: [
|
||||
{ value: "none", label: "없음" },
|
||||
@@ -92,6 +90,14 @@ export function ColumnDetailPanel({
|
||||
})),
|
||||
];
|
||||
|
||||
// value 기준 dedupe — referenceTableOptions/tables 어디서든 중복 들어오면 React key 충돌
|
||||
const seen = new Set<string>();
|
||||
const raw = rawSource.filter((o) => {
|
||||
if (seen.has(o.value)) return false;
|
||||
seen.add(o.value);
|
||||
return true;
|
||||
});
|
||||
|
||||
const noneOpt = raw.find((o) => o.value === "none");
|
||||
const rest = raw.filter((o) => o.value !== "none");
|
||||
|
||||
@@ -106,6 +112,10 @@ export function ColumnDetailPanel({
|
||||
return noneOpt ? [noneOpt, ...rest] : rest;
|
||||
}, [referenceTableOptions, tables]);
|
||||
|
||||
// early return 은 반드시 모든 hook 호출 뒤에 (Rules of Hooks).
|
||||
// overlay 패턴으로 항상 마운트되므로 column null 케이스가 정상적으로 들어옴.
|
||||
if (!column) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col border-l bg-card">
|
||||
{/* 헤더 */}
|
||||
@@ -365,14 +375,17 @@ export function ColumnDetailPanel({
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">공통코드 카테고리</Label>
|
||||
<Select
|
||||
value={column.code_category ?? "none"}
|
||||
onValueChange={(v) => onColumnChange("code_category", v === "none" ? undefined : v)}
|
||||
value={column.code_info ?? "none"}
|
||||
onValueChange={(v) => onColumnChange("code_info", v === "none" ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue placeholder="코드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[{ value: "none", label: "선택 안함" }, ...codeCategoryOptions].map((opt) => (
|
||||
{[
|
||||
{ value: "none", label: "선택 안함" },
|
||||
...codeInfoOptions.filter((opt) => opt.value !== "none"),
|
||||
].map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
@@ -380,7 +393,7 @@ export function ColumnDetailPanel({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{column.code_category && column.code_category !== "none" && (
|
||||
{column.code_info && column.code_info !== "none" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">계층 역할</Label>
|
||||
<Select
|
||||
|
||||
@@ -144,7 +144,7 @@ export function ColumnGrid({
|
||||
|
||||
{/* 라벨 + 컬럼명 (한글라벨 (영어명) 동시 표시) */}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">
|
||||
<div className="truncate text-xs font-medium">
|
||||
{column.display_name && column.display_name !== column.column_name
|
||||
? `${column.display_name} (${column.column_name})`
|
||||
: column.column_name}
|
||||
@@ -193,7 +193,7 @@ export function ColumnGrid({
|
||||
)}
|
||||
{column.input_type === "code" && (
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{column.code_category ?? "—"} · {column.default_value ?? ""}
|
||||
{column.code_info ?? "—"} · {column.default_value ?? ""}
|
||||
</span>
|
||||
)}
|
||||
{column.input_type === "numbering" && column.numbering_rule_id && (
|
||||
|
||||
@@ -22,11 +22,24 @@ function countByInputType(columns: ColumnTypeInfo[]): Record<string, number> {
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** 도넛 차트용 비율 (0~1) 배열 및 라벨 순서 (8개 사용자 선택 가능 타입 한정) */
|
||||
function getDonutSegments(counts: Record<string, number>, total: number): Array<{ type: string; ratio: number }> {
|
||||
return (USER_SELECTABLE_INPUT_TYPE_ORDER as readonly string[])
|
||||
/** 도넛 차트용 segment (8개 base + legacy 그룹) */
|
||||
function getDonutSegments(
|
||||
counts: Record<string, number>,
|
||||
total: number,
|
||||
): Array<{ type: string; ratio: number; isLegacy: boolean }> {
|
||||
const baseSegments = (USER_SELECTABLE_INPUT_TYPE_ORDER as readonly string[])
|
||||
.filter((type) => (counts[type] || 0) > 0)
|
||||
.map((type) => ({ type, ratio: (counts[type] || 0) / total }));
|
||||
.map((type) => ({ type, ratio: (counts[type] || 0) / total, isLegacy: false }));
|
||||
|
||||
const legacyCount = Object.entries(counts)
|
||||
.filter(([type]) => !(USER_SELECTABLE_INPUT_TYPE_ORDER as readonly string[]).includes(type))
|
||||
.reduce((sum, [, count]) => sum + count, 0);
|
||||
|
||||
if (legacyCount > 0) {
|
||||
baseSegments.push({ type: "__legacy__", ratio: legacyCount / total, isLegacy: true });
|
||||
}
|
||||
|
||||
return baseSegments;
|
||||
}
|
||||
|
||||
export function TypeOverviewStrip({
|
||||
@@ -44,16 +57,25 @@ export function TypeOverviewStrip({
|
||||
/** stroke-dasharray: 비율만큼 둘레에 할당 (둘레 100 기준) */
|
||||
const circumference = 100;
|
||||
let offset = 0;
|
||||
const segmentPaths = segments.map(({ type, ratio }) => {
|
||||
const LEGACY_CONF = {
|
||||
color: "text-amber-600",
|
||||
bgColor: "bg-amber-50",
|
||||
barColor: "bg-amber-400",
|
||||
label: "Legacy",
|
||||
desc: "구버전 타입",
|
||||
iconChar: "?",
|
||||
};
|
||||
const segmentPaths = segments.map(({ type, ratio, isLegacy }) => {
|
||||
const length = ratio * circumference;
|
||||
const dashArray = `${length} ${circumference - length}`;
|
||||
const dashOffset = -offset;
|
||||
offset += length;
|
||||
const conf = INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted" };
|
||||
const conf = isLegacy ? LEGACY_CONF : (INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted" });
|
||||
return {
|
||||
type,
|
||||
dashArray,
|
||||
dashOffset,
|
||||
isLegacy,
|
||||
...conf,
|
||||
};
|
||||
});
|
||||
@@ -84,7 +106,7 @@ export function TypeOverviewStrip({
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 타입 칩 목록 (8개 사용자 선택 가능 타입 한정, 클릭 시 필터 토글) */}
|
||||
{/* 타입 칩 목록 (8개 base + legacy 그룹, 클릭 시 필터 토글) */}
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
{(USER_SELECTABLE_INPUT_TYPE_ORDER as readonly string[])
|
||||
.filter((type) => (counts[type] || 0) > 0)
|
||||
@@ -109,6 +131,29 @@ export function TypeOverviewStrip({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* Legacy 칩 — 8개 외 구버전 타입 합산 */}
|
||||
{(() => {
|
||||
const legacyCount = Object.entries(counts)
|
||||
.filter(([type]) => !(USER_SELECTABLE_INPUT_TYPE_ORDER as readonly string[]).includes(type))
|
||||
.reduce((sum, [, count]) => sum + count, 0);
|
||||
if (legacyCount === 0) return null;
|
||||
const isActive = activeFilter === null || activeFilter === "__legacy__";
|
||||
return (
|
||||
<button
|
||||
key="__legacy__"
|
||||
type="button"
|
||||
onClick={() => onFilterChange?.(activeFilter === "__legacy__" ? null : "__legacy__")}
|
||||
className={cn(
|
||||
"rounded-md border px-2 py-1 text-xs font-medium transition-colors",
|
||||
"bg-amber-50 text-amber-600",
|
||||
"border-amber-200",
|
||||
isActive ? "ring-1 ring-ring" : "opacity-70 hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
Legacy {legacyCount}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user