refactor(common-code): 마스터-디테일 재설계 — code_info(그룹) + code_detail(재귀 트리)
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m22s

카테고리/캐스케이딩 시스템 (B/C/D) 전부 폐기:
- BE: mapper/Service/Controller 9세트 삭제 (cascading*, categoryTree, tableCategoryValue, categoryValueCascading, codeMerge)
- FE: 페이지 3 + API 8 + hooks 2 + 폐기 컴포넌트 6 삭제, 14곳 의존성 정리
- DB: 12 테이블 DROP, TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO rename

신설 commonCode 마스터-디테일:
- code_info: 1레벨 그룹 마스터
- code_detail: 2~∞ depth 재귀 트리 (parent_detail_id self-FK, depth 자동 계산)
- API: /api/common-codes/{info,detail}
- CodeCategoryFormModal/Panel → CodeInfoFormModal/Panel rename
- code_category 컬럼명 전부 code_info 로 치환 (mapper/Java/FE)
- 옛 commonCode API URL (/categories/...) → getCodeOptions 어댑터 + /detail?code_info=... 전환

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DDD1542
2026-05-15 16:50:50 +09:00
parent 387a5c2bd7
commit 2348800e68
186 changed files with 9799 additions and 26330 deletions
@@ -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));
}
}
@@ -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));
}
}
@@ -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()));
@@ -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("공통 코드 데이터 조회 중 오류가 발생했습니다."));
}
}
@@ -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; }
}
}
@@ -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,425 +34,273 @@ 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) {
public Map<String, Object> insertCodeInfo(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("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", null));
params.put("company_code", companyCode);
params.put("created_by", userId);
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")));
sqlSession.insert(NS + "insertCodeInfo", params);
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);
return getCodeInfoInfo(String.valueOf(params.get("code_info")), companyCode);
}
// ══════════════════════════════════════════════════════════════
// 카테고리 삭제
// ══════════════════════════════════════════════════════════════
@Transactional
public void deleteCommonCodeCategory(String categoryCode, String companyCode) {
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("company_code", companyCode);
int deleted = sqlSession.delete(NS + "deleteCommonCodeCategory", params);
if (deleted == 0) throw new IllegalArgumentException("카테고리를 찾을 수 없습니다.");
params.put("code_info", codeInfo);
params.put("company_code", companyCode);
params.put("updated_by", userId);
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")));
if (body.containsKey("menu_objid")) params.put("menu_objid", body.get("menu_objid"));
int updated = sqlSession.update(NS + "updateCodeInfo", params);
if (updated == 0) return null;
return getCodeInfoInfo(codeInfo, companyCode);
}
// ══════════════════════════════════════════════════════════════
// 코드 목록 (snake_case + camelCase 이중 필드)
// ══════════════════════════════════════════════════════════════
@Transactional
public void deleteCodeInfo(String codeInfo, String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("code_info", codeInfo);
params.put("company_code", companyCode);
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));
}
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<>();
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", "값을 입력해주세요.");
result.put("message", "값을 입력해주세요.");
return result;
}
Map<String, Object> params = new HashMap<>();
params.put("category_code", categoryCode);
params.put("field", field != null ? field : "code_value");
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 + "getCommonCodeDuplicateByField", params);
params.put("exclude_code", excludeCode);
params.put("company_code", companyCode);
Integer countObj = sqlSession.selectOne(NS + "getCodeInfoDuplicateByField", params);
boolean isDuplicate = countObj != null && countObj > 0;
Map<String, Object> result = new LinkedHashMap<>();
result.put("is_duplicate", isDuplicate);
result.put("message", isDuplicate ? "이미 사용 중인 값입니다." : "사용 가능한 값입니다.");
return result;
}
// ══════════════════════════════════════════════════════════════
// 코드 생성
// CODE_DETAIL — 디테일 트리 CRUD
// ══════════════════════════════════════════════════════════════
@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_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("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);
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;
}
// ══════════════════════════════════════════════════════════════
// 코드 정렬 순서 변경
// ══════════════════════════════════════════════════════════════
@Transactional
public void updateCommonCodeOrder(String categoryCode, List<Map<String, Object>> codes, String companyCode) {
for (Map<String, Object> code : codes) {
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);
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));
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));
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");
}
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));
}
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("flat", flatTransformed);
result.put("tree", buildTree(flatList));
result.put("data", data);
result.put("total", total);
return result;
}
// ══════════════════════════════════════════════════════════════
// 자식 존재 여부
// ══════════════════════════════════════════════════════════════
public Map<String, Object> hasChildren(String categoryCode, String codeValue, String companyCode) {
/**
* 그룹 전체 트리 — 평탄화된 리스트로 반환 (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("company_code", companyCode);
Integer countObj = sqlSession.selectOne(NS + "getCommonCodeChildrenCnt", params);
params.put("code_info", codeInfo);
params.put("company_code", companyCode);
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 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;
}
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;
}
// ══════════════════════════════════════════════════════════════
// 코드 수정
// ══════════════════════════════════════════════════════════════
@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("company_code", companyCode);
params.put("updated_by", userId);
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")));
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);
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;
}
// ══════════════════════════════════════════════════════════════
// 코드 삭제
// ══════════════════════════════════════════════════════════════
@Transactional
public void deleteCommonCode(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);
int deleted = sqlSession.delete(NS + "deleteCommonCode", 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);
}
return options;
}
// ══════════════════════════════════════════════════════════════
// 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 +311,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; }
}
}
@@ -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);
@@ -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; }
}
}
@@ -169,7 +169,7 @@ public class TableManagementService extends BaseService {
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);
@@ -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 != &quot;*&quot;">
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 != &quot;*&quot;">
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 != &quot;*&quot;">
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 != &quot;*&quot;">
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 != &quot;*&quot;">
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 != &quot;*&quot;">
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 != &quot;*&quot;">
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 != &quot;*&quot;">
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 != &quot;*&quot;">
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 != &quot;*&quot;">
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 != &quot;*&quot;">
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 != &quot;*&quot;">
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 != &quot;*&quot;">
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>
@@ -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
@@ -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()
+1 -1
View File
@@ -78,7 +78,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" },
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
&gt; &gt; . ( {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"> &gt; / &gt; // &gt; //</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"> &gt; &gt; ( )</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>
);
}
@@ -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>
);
@@ -30,10 +30,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";
@@ -242,19 +245,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);
@@ -436,7 +433,7 @@ export default function TableManagementPage() {
// 코드가 아닌 타입으로 변경 시 코드 설정 초기화
if (newInputType !== "code") {
updated.code_category = undefined;
updated.code_info = undefined;
updated.code_value = undefined;
updated.hierarchy_role = undefined;
}
@@ -462,7 +459,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;
@@ -472,17 +469,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") {
@@ -532,7 +529,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,
@@ -635,7 +632,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 || "",
@@ -838,7 +835,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 || "",
@@ -1719,7 +1716,7 @@ export default function TableManagementPage() {
}}
onClose={() => setSelectedColumn(null)}
onLoadReferenceColumns={loadReferenceTableColumns}
codeCategoryOptions={commonCodeOptions}
codeInfoOptions={commonCodeOptions}
referenceTableOptions={referenceTableOptions}
/>
</div>
@@ -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>
);
}
+190 -271
View File
@@ -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, []);
}
map.get(parentValue)!.push(code);
}
});
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);
}
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,156 +195,125 @@ 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 || "")}
strategy={verticalListSortingStrategy}
>
{visibleCodes.map((code, index) => {
const codeValue = code.code_value || "";
const children = childrenMap.get(codeValue) || [];
const hasChildren = children.length > 0;
const isExpanded = !collapsedCodes.has(codeValue);
<DndContext {...dragAndDrop.dndContextProps}>
<SortableContext
items={visibleRows.map((r) => String(r.code_detail_id))}
strategy={verticalListSortingStrategy}
>
{visibleRows.map((row) => {
const idStr = String(row.code_detail_id);
const children = childrenMap.get(idStr) || [];
const hasChildren = children.length > 0;
const isExpanded = !collapsed.has(idStr);
return (
<SortableCodeItem
key={`${codeValue}-${index}`}
code={code}
categoryCode={categoryCode}
onEdit={() => handleEditCode(code)}
onDelete={() => handleDeleteCode(code)}
onAddChild={() => handleAddChild(code)}
hasChildren={hasChildren}
childCount={children.length}
isExpanded={isExpanded}
onToggleExpand={() => toggleExpand(codeValue)}
/>
);
})}
</SortableContext>
return (
<SortableCodeItem
key={idStr}
row={row}
onEdit={() => handleEdit(row)}
onDelete={() => handleDelete(row)}
onAddChild={() => handleAddChild(row)}
hasChildren={hasChildren}
childCount={children.length}
isExpanded={isExpanded}
onToggleExpand={() => toggleExpand(idStr)}
/>
);
})}
</SortableContext>
<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="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>
<Badge
variant={
activeCode.is_active === "Y" ? "default" : "secondary"
}
>
{activeCode.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{activeCode.code_value}
</p>
{activeCode.description && (
<p className="text-muted-foreground mt-1 text-xs">{activeCode.description}</p>
)}
</div>
</div>
);
})()}
<DragOverlay dropAnimation={null}>
{dragAndDrop.activeItem ? (
<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">
{dragAndDrop.activeItem.code_name}
</h4>
<Badge
variant={dragAndDrop.activeItem.is_active === "Y" ? "default" : "secondary"}
>
{dragAndDrop.activeItem.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{dragAndDrop.activeItem.code_value}
</p>
</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>
) : null}
</DragOverlay>
</DndContext>
)}
</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;
+226 -182
View File
@@ -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 }),
},
});
// 편집 모드일 때 기존 데이터 로드
useEffect(() => {
if (isOpen) {
if (isEditing && editingCode) {
// 수정 모드: 기존 데이터 로드 (codeValue는 표시용으로만 설정)
const parentValue = editingCode.parent_code_value || "";
// 부모 후보: 자기 자신 + 자기 자손은 제외
const parentOptions = useMemo(() => {
if (!isEditing) return allRows;
if (!editingRow) return allRows;
const selfId = String(editingRow.code_detail_id);
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);
} else {
// 새 코드 모드: 자동 순서 계산
const maxSortOrder = codes.length > 0 ? Math.max(...codes.map((c) => c.sort_order || 0)) : 0;
// 기본 부모 코드가 있으면 설정 (하위 코드 추가 시)
const parentValue = defaultParentCode || "";
// 코드값 자동 생성
const autoCodeValue = generateCodeValue();
form.reset({
codeValue: autoCodeValue,
codeName: "",
codeNameEng: "",
description: "",
sortOrder: maxSortOrder + 1,
parentCodeValue: parentValue,
});
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;
}
}
}
}, [isOpen, isEditing, editingCode, codes, defaultParentCode]);
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) return;
if (isEditing && editingRow) {
form.reset({
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 {
// 형제 중 최대 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({
code_info: codeInfo,
parent_detail_id: defaultParentDetailId ?? null,
code_value: generateCodeValue(),
code_name: "",
code_name_eng: "",
description: "",
sort_order: maxSort + 10,
});
}
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 && (
<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>
)}
{/* code_value */}
<div className="space-y-2">
<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>
);
}
@@ -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}>
@@ -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}
/>
+7 -7
View File
@@ -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"
>
+
+65 -98
View File
@@ -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,91 +107,67 @@ 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 && (
<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>
<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={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"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onAddChild();
}}
title="하위 코드 추가"
className="text-primary hover:bg-primary/10 hover:text-primary"
>
<Plus className="h-3 w-3" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onAddChild();
}}
title="하위 코드 추가"
className="text-primary hover:bg-primary/10 hover:text-primary"
>
<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);
@@ -365,14 +365,14 @@ 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].map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
@@ -380,7 +380,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
@@ -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 && (
@@ -24,7 +24,7 @@ export interface ColumnTypeInfo {
max_length?: number;
numeric_precision?: number;
numeric_scale?: number;
code_category?: string;
code_info?: string;
code_value?: string;
reference_table?: string;
reference_column?: string;
@@ -1,188 +0,0 @@
"use client";
/**
* 🔗 (Cascading Dropdown)
*
* .
*
* @example
* // 창고 → 위치 연쇄 드롭다운
* <CascadingDropdown
* config={{
* enabled: true,
* parentField: "warehouse_code",
* sourceTable: "warehouse_location",
* parentKeyColumn: "warehouse_id",
* valueColumn: "location_code",
* labelColumn: "location_name",
* }}
* parentValue={formData.warehouse_code}
* value={formData.location_code}
* onChange={(value) => setFormData({ ...formData, location_code: value })}
* placeholder="위치 선택"
* />
*/
import React, { useEffect, useRef } from "react";
import { Loader2 } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useCascadingDropdown, CascadingOption } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig } from "@/types/screen-management";
import { cn } from "@/lib/utils";
export interface CascadingDropdownProps {
/** 연쇄 드롭다운 설정 */
config: CascadingDropdownConfig;
/** 부모 필드의 현재 값 */
parentValue?: string | number | null;
/** 현재 선택된 값 */
value?: string;
/** 값 변경 핸들러 */
onChange?: (value: string, option?: CascadingOption) => void;
/** 플레이스홀더 */
placeholder?: string;
/** 비활성화 여부 */
disabled?: boolean;
/** 읽기 전용 여부 */
readOnly?: boolean;
/** 필수 입력 여부 */
required?: boolean;
/** 추가 클래스명 */
className?: string;
/** 추가 스타일 */
style?: React.CSSProperties;
/** 검색 가능 여부 */
searchable?: boolean;
}
export function CascadingDropdown({
config,
parentValue,
value,
onChange,
placeholder,
disabled = false,
readOnly = false,
required = false,
className,
style,
searchable = false,
}: CascadingDropdownProps) {
const prevParentValueRef = useRef<string | number | null | undefined>(undefined);
const {
options,
loading,
error,
getLabelByValue,
} = useCascadingDropdown({
config,
parentValue,
});
// 부모 값 변경 시 자동 초기화
useEffect(() => {
if (config.clear_on_parent_change !== false) {
if (prevParentValueRef.current !== undefined &&
prevParentValueRef.current !== parentValue &&
value) {
// 부모 값이 변경되면 현재 값 초기화
onChange?.("");
}
}
prevParentValueRef.current = parentValue;
}, [parentValue, config.clear_on_parent_change, value, onChange]);
// 부모 값이 없을 때 메시지
const getPlaceholder = () => {
if (!parentValue) {
return config.empty_parent_message || "상위 항목을 먼저 선택하세요";
}
if (loading) {
return config.loading_message || "로딩 중...";
}
if (options.length === 0) {
return config.no_options_message || "선택 가능한 항목이 없습니다";
}
return placeholder || "선택하세요";
};
// 값 변경 핸들러
const handleValueChange = (newValue: string) => {
if (readOnly) return;
const selectedOption = options.find((opt) => opt.value === newValue);
onChange?.(newValue, selectedOption);
};
// 비활성화 상태 계산
const isDisabled = disabled || readOnly || !parentValue || loading;
return (
<div className={cn("relative", className)} style={style}>
<Select
value={value || ""}
onValueChange={handleValueChange}
disabled={isDisabled}
>
<SelectTrigger
className={cn(
"w-full",
!parentValue && "text-muted-foreground",
error && "border-destructive"
)}
>
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm">
{config.loading_message || "로딩 중..."}
</span>
</div>
) : (
<SelectValue placeholder={getPlaceholder()} />
)}
</SelectTrigger>
<SelectContent>
{options.length === 0 ? (
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
{!parentValue
? config.empty_parent_message || "상위 항목을 먼저 선택하세요"
: config.no_options_message || "선택 가능한 항목이 없습니다"}
</div>
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
{/* 에러 메시지 */}
{error && (
<p className="text-destructive mt-1 text-xs">{error}</p>
)}
</div>
);
}
export default CascadingDropdown;
+3 -355
View File
@@ -29,11 +29,7 @@ import {
Zap,
Copy,
Loader2,
Check,
ChevronsUpDown,
} from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Checkbox } from "@/components/ui/checkbox";
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
import { DynamicFormApi } from "@/lib/api/dynamicForm";
@@ -41,8 +37,6 @@ import { getTableSchema, TableColumn } from "@/lib/api/tableSchema";
import { cn } from "@/lib/utils";
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
import { EditableSpreadsheet } from "./EditableSpreadsheet";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
import { getTableColumns } from "@/lib/api/tableManagement";
// 마스터-디테일 엑셀 업로드 설정 (버튼 설정에서 설정)
export interface MasterDetailExcelConfig {
@@ -104,34 +98,6 @@ interface ColumnMapping {
checkDuplicate?: boolean;
}
interface FlatCategoryValue {
valueCode: string;
valueLabel: string;
depth: number;
ancestors: string[];
}
function flattenCategoryValues(
values: Array<{ valueCode: string; valueLabel: string; children?: any[] }>
): FlatCategoryValue[] {
const result: FlatCategoryValue[] = [];
const traverse = (items: any[], depth: number, ancestors: string[]) => {
for (const item of items) {
result.push({
valueCode: item.valueCode,
valueLabel: item.valueLabel,
depth,
ancestors,
});
if (item.children?.length > 0) {
traverse(item.children, depth + 1, [...ancestors, item.valueLabel]);
}
}
};
traverse(values, 0, []);
return result;
}
export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
open,
onOpenChange,
@@ -175,19 +141,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const [isDataValidating, setIsDataValidating] = useState(false);
const [validationResult, setValidationResult] = useState<import("@/lib/api/tableManagement").ExcelValidationResult | null>(null);
// 카테고리 검증 관련
const [showCategoryValidation, setShowCategoryValidation] = useState(false);
const [isCategoryValidating, setIsCategoryValidating] = useState(false);
// { [columnName]: { invalidValue: string, replacement: string | null, validOptions: {code: string, label: string}[], rowIndices: number[] }[] }
const [categoryMismatches, setCategoryMismatches] = useState<
Record<string, Array<{
invalidValue: string;
replacement: string | null;
validOptions: Array<{ code: string; label: string; depth: number; ancestors: string[] }>;
rowIndices: number[];
}>>
>({});
// 3단계: 확인
const [isUploading, setIsUploading] = useState(false);
@@ -656,172 +609,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
// 중복 체크 설정된 컬럼 수
const duplicateCheckCount = columnMappings.filter((m) => m.checkDuplicate && m.systemColumn).length;
// 카테고리 컬럼 검증: 엑셀 데이터에서 유효하지 않은 카테고리 값 감지
const validateCategoryColumns = async () => {
try {
setIsCategoryValidating(true);
const targetTableName = isMasterDetail && masterDetailRelation
? masterDetailRelation.detailTable
: tableName;
// 테이블의 카테고리 타입 컬럼 조회
const colResponse = await getTableColumns(targetTableName);
if (!colResponse.success || !colResponse.data?.columns) {
return null;
}
const categoryColumns = colResponse.data.columns.filter(
(col: any) => col.input_type === "category"
);
if (categoryColumns.length === 0) {
return null;
}
// 매핑된 컬럼 중 카테고리 타입인 것 찾기
const mappedCategoryColumns: Array<{
systemCol: string;
excelCol: string;
displayName: string;
}> = [];
for (const mapping of columnMappings) {
if (!mapping.systemColumn) continue;
const rawName = mapping.systemColumn.includes(".")
? mapping.systemColumn.split(".")[1]
: mapping.systemColumn;
const catCol = categoryColumns.find(
(cc: any) => cc.column_name === rawName
);
if (catCol) {
mappedCategoryColumns.push({
systemCol: rawName,
excelCol: mapping.excelColumn,
displayName: catCol.display_name || rawName,
});
}
}
if (mappedCategoryColumns.length === 0) {
return null;
}
// 각 카테고리 컬럼의 유효값 조회 및 엑셀 데이터 검증
const mismatches: typeof categoryMismatches = {};
for (const catCol of mappedCategoryColumns) {
const valuesResponse = await getCategoryValues(targetTableName, catCol.systemCol);
if (!valuesResponse.success || !valuesResponse.data) continue;
const validValues = flattenCategoryValues(valuesResponse.data as any[]);
const validCodes = new Set(validValues.map((v) => v.valueCode));
const validLabels = new Set(validValues.map((v) => v.valueLabel));
const validLabelsLower = new Set(validValues.map((v) => v.valueLabel.toLowerCase()));
// 엑셀 데이터에서 유효하지 않은 값 수집
const invalidMap = new Map<string, number[]>();
allData.forEach((row, rowIdx) => {
const val = row[catCol.excelCol];
if (val === undefined || val === null || String(val).trim() === "") return;
const strVal = String(val).trim();
// 코드 매칭 → 라벨 매칭 → 소문자 라벨 매칭
if (validCodes.has(strVal)) return;
if (validLabels.has(strVal)) return;
if (validLabelsLower.has(strVal.toLowerCase())) return;
if (!invalidMap.has(strVal)) {
invalidMap.set(strVal, []);
}
invalidMap.get(strVal)!.push(rowIdx);
});
if (invalidMap.size > 0) {
const options = validValues.map((v) => ({
code: v.valueCode,
label: v.valueLabel,
depth: v.depth,
ancestors: v.ancestors,
}));
mismatches[`${catCol.systemCol}|||${catCol.displayName}`] = Array.from(invalidMap.entries()).map(
([invalidValue, rowIndices]) => ({
invalidValue,
replacement: null,
validOptions: options,
rowIndices,
})
);
}
}
if (Object.keys(mismatches).length > 0) {
return mismatches;
}
return null;
} catch (error) {
console.error("카테고리 검증 실패:", error);
return null;
} finally {
setIsCategoryValidating(false);
}
};
// 카테고리 대체값 선택 후 데이터에 적용
const applyCategoryReplacements = () => {
// 모든 대체값이 선택되었는지 확인
for (const [key, items] of Object.entries(categoryMismatches)) {
for (const item of items) {
if (item.replacement === null) {
toast.error("모든 항목의 대체 값을 선택해주세요.");
return false;
}
}
}
// 엑셀 컬럼명 → 시스템 컬럼명 매핑 구축
const systemToExcelMap = new Map<string, string>();
for (const mapping of columnMappings) {
if (!mapping.systemColumn) continue;
const rawName = mapping.systemColumn.includes(".")
? mapping.systemColumn.split(".")[1]
: mapping.systemColumn;
systemToExcelMap.set(rawName, mapping.excelColumn);
}
const newData = allData.map((row) => ({ ...row }));
for (const [key, items] of Object.entries(categoryMismatches)) {
const systemCol = key.split("|||")[0];
const excelCol = systemToExcelMap.get(systemCol);
if (!excelCol) continue;
for (const item of items) {
if (!item.replacement) continue;
// 선택된 대체값의 라벨 찾기
const selectedOption = item.validOptions.find((opt) => opt.code === item.replacement);
const replacementLabel = selectedOption?.label || item.replacement;
for (const rowIdx of item.rowIndices) {
if (newData[rowIdx]) {
newData[rowIdx][excelCol] = replacementLabel;
}
}
}
}
setAllData(newData);
setDisplayData(newData);
setShowCategoryValidation(false);
setCategoryMismatches({});
toast.success("카테고리 값이 대체되었습니다. '다음'을 눌러 진행해주세요.");
return true;
};
// 다음 단계
const handleNext = async () => {
if (currentStep === 1 && !file) {
@@ -903,14 +690,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
return;
}
// 카테고리 컬럼 검증
const mismatches = await validateCategoryColumns();
if (mismatches) {
setCategoryMismatches(mismatches);
setShowCategoryValidation(true);
return;
}
// 데이터 사전 검증 (NOT NULL 값 누락, UNIQUE 중복)
setDbDuplicateAction(null);
setIsDataValidating(true);
@@ -1403,18 +1182,13 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
// 검증 상태 초기화
setValidationResult(null);
setIsDataValidating(false);
// 카테고리 검증 초기화
setShowCategoryValidation(false);
setCategoryMismatches({});
setIsCategoryValidating(false);
// 🆕 마스터-디테일 모드 초기화
setMasterFieldValues({});
}
}, [open]);
return (
<>
<Dialog open={open} onOpenChange={(v) => { if (!showCategoryValidation) onOpenChange(v); }}>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
style={{
@@ -2189,10 +1963,10 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
{currentStep < 3 ? (
<Button
onClick={handleNext}
disabled={isUploading || isCategoryValidating || isDataValidating || (currentStep === 1 && !file)}
disabled={isUploading || isDataValidating || (currentStep === 1 && !file)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isCategoryValidating || isDataValidating ? (
{isDataValidating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
@@ -2230,131 +2004,5 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
</DialogFooter>
</DialogContent>
</Dialog>
{/* 카테고리 대체값 선택 다이얼로그 */}
<Dialog open={showCategoryValidation} onOpenChange={(open) => {
if (!open) {
setShowCategoryValidation(false);
setCategoryMismatches({});
}
}}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<AlertCircle className="h-5 w-5 text-warning" />
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. .
</DialogDescription>
</DialogHeader>
<div className="max-h-[400px] space-y-4 overflow-y-auto pr-1">
{Object.entries(categoryMismatches).map(([key, items]) => {
const [columnName, displayName] = key.split("|||");
return (
<div key={key} className="space-y-2">
<h4 className="text-sm font-semibold text-foreground">
{displayName || columnName}
</h4>
{items.map((item, idx) => (
<div
key={`${key}-${idx}`}
className="grid grid-cols-[1fr_auto_1fr] items-center gap-2 rounded-md border border-border bg-muted/30 p-2"
>
<div className="flex flex-col">
<span className="text-xs font-medium text-destructive line-through">
{item.invalidValue}
</span>
<span className="text-[10px] text-muted-foreground">
{item.rowIndices.length}
</span>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs sm:h-9 sm:text-sm"
>
<span className="truncate">
{item.replacement
? item.validOptions.find((o) => o.code === item.replacement)?.label || item.replacement
: "대체 값 선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-0" align="start">
<Command
filter={(value, search) => {
const opt = item.validOptions.find((o) => o.code === value);
if (!opt) return 0;
const s = search.toLowerCase();
if (opt.label.toLowerCase().includes(s)) return 1;
if (opt.ancestors.some((a) => a.toLowerCase().includes(s))) return 1;
return 0;
}}
>
<CommandInput placeholder="카테고리 검색..." className="text-xs" />
<CommandList className="max-h-52">
<CommandEmpty className="py-3 text-xs"> </CommandEmpty>
<CommandGroup>
{item.validOptions.map((opt) => (
<CommandItem
key={opt.code}
value={opt.code}
onSelect={(val) => {
setCategoryMismatches((prev) => {
const updated = { ...prev };
updated[key] = updated[key].map((it, i) =>
i === idx ? { ...it, replacement: val } : it
);
return updated;
});
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-3 w-3", item.replacement === opt.code ? "opacity-100" : "opacity-0")} />
<span style={{ paddingLeft: `${opt.depth * 12}px` }}>
{opt.depth > 0 && <span className="mr-1 text-muted-foreground"></span>}
{opt.label}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
))}
</div>
);
})}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setShowCategoryValidation(false);
setCategoryMismatches({});
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={applyCategoryReplacements}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -2,74 +2,52 @@
/**
* (1, 2, 3 )
*
*
* 2026-05-15 - :
* - /depth
* - depth 2 "1단계" = depth 2, "2단계" = depth 3, "3단계" = depth 4
*
* @example
* // 기본 사용
* <HierarchicalCodeSelect
* categoryCode="PRODUCT_CATEGORY"
* maxDepth={3}
* value={selectedCode}
* onChange={(code) => setSelectedCode(code)}
* />
*
* @example
* // 특정 depth까지만 선택
* <HierarchicalCodeSelect
* categoryCode="LOCATION"
* maxDepth={2}
* value={selectedCode}
* onChange={(code) => setSelectedCode(code)}
* />
*/
import React, { useState, useEffect, useCallback, useMemo } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { commonCodeApi } from "@/lib/api/commonCode";
import { getCodeDetailTree } from "@/lib/api/commonCode";
import { Loader2 } from "lucide-react";
import type { CodeInfo } from "@/types/commonCode";
import type { CodeDetail } from "@/types/commonCode";
export interface HierarchicalCodeSelectProps {
/** 코드 카테고리 */
/** 그룹 코드 (code_info) — prop 명은 하위 호환 위해 categoryCode 유지 */
categoryCode: string;
/** 최대 깊이 (1, 2, 3) */
/** 최대 깊이 (1=tree depth 2, 2=tree depth 3, 3=tree depth 4) */
maxDepth?: 1 | 2 | 3;
/** 현재 선택된 값 (최종 선택된 코드값) */
/** 현재 선택된 코드값 */
value?: string;
/** 값 변경 핸들러 */
onChange?: (codeValue: string, codeInfo?: CodeInfo, fullPath?: CodeInfo[]) => void;
onChange?: (codeValue: string, row?: CodeDetail, fullPath?: CodeDetail[]) => void;
/** 각 단계별 라벨 */
labels?: [string, string?, string?];
/** 각 단계별 placeholder */
placeholders?: [string, string?, string?];
/** 비활성화 */
disabled?: boolean;
/** 필수 입력 */
required?: boolean;
/** 메뉴 OBJID (메뉴 기반 필터링) */
/** 메뉴 OBJID (현재 미사용 — 호환용) */
menuObjid?: number;
/** 추가 클래스 */
className?: string;
/** 인라인 표시 (가로 배열) */
inline?: boolean;
}
interface LoadingState {
level1: boolean;
level2: boolean;
level3: boolean;
}
export function HierarchicalCodeSelect({
categoryCode,
maxDepth = 3,
@@ -79,270 +57,148 @@ export function HierarchicalCodeSelect({
placeholders = ["선택하세요", "선택하세요", "선택하세요"],
disabled = false,
required = false,
menuObjid,
className = "",
inline = false,
}: HierarchicalCodeSelectProps) {
// 각 단계별 옵션
const [level1Options, setLevel1Options] = useState<CodeInfo[]>([]);
const [level2Options, setLevel2Options] = useState<CodeInfo[]>([]);
const [level3Options, setLevel3Options] = useState<CodeInfo[]>([]);
// 각 단계별 선택값
const [selectedLevel1, setSelectedLevel1] = useState<string>("");
const [selectedLevel2, setSelectedLevel2] = useState<string>("");
const [selectedLevel3, setSelectedLevel3] = useState<string>("");
// 로딩 상태
const [loading, setLoading] = useState<LoadingState>({
level1: false,
level2: false,
level3: false,
});
// 모든 코드 데이터 (경로 추적용)
const [allCodes, setAllCodes] = useState<CodeInfo[]>([]);
const [allRows, setAllRows] = useState<CodeDetail[]>([]);
const [loading, setLoading] = useState(false);
// 1단계 코드 로드 (최상위)
const loadLevel1Codes = useCallback(async () => {
if (!categoryCode) return;
setLoading(prev => ({ ...prev, level1: true }));
try {
const response = await commonCodeApi.hierarchy.getHierarchicalCodes(
categoryCode,
null, // 부모 없음 (최상위)
1, // depth = 1
menuObjid
);
if (response.success && response.data) {
setLevel1Options(response.data);
setAllCodes(prev => {
const filtered = prev.filter(c => c.depth !== 1);
return [...filtered, ...response.data];
});
}
} catch (error) {
console.error("1단계 코드 로드 실패:", error);
} finally {
setLoading(prev => ({ ...prev, level1: false }));
}
}, [categoryCode, menuObjid]);
const [selected1, setSelected1] = useState<string>("");
const [selected2, setSelected2] = useState<string>("");
const [selected3, setSelected3] = useState<string>("");
// 2단계 코드 로드 (1단계 선택값 기준)
const loadLevel2Codes = useCallback(async (parentCodeValue: string) => {
if (!categoryCode || !parentCodeValue) {
setLevel2Options([]);
// 트리 전체 로드
useEffect(() => {
if (!categoryCode) {
setAllRows([]);
return;
}
setLoading(prev => ({ ...prev, level2: true }));
try {
const response = await commonCodeApi.hierarchy.getHierarchicalCodes(
categoryCode,
parentCodeValue,
undefined,
menuObjid
);
if (response.success && response.data) {
setLevel2Options(response.data);
setAllCodes(prev => {
const filtered = prev.filter(c => c.depth !== 2 || c.parent_code_value !== parentCodeValue);
return [...filtered, ...response.data];
});
}
} catch (error) {
console.error("2단계 코드 로드 실패:", error);
} finally {
setLoading(prev => ({ ...prev, level2: false }));
}
}, [categoryCode, menuObjid]);
let cancelled = false;
setLoading(true);
getCodeDetailTree({ code_info: categoryCode, is_active: true })
.then((res) => {
if (!cancelled) setAllRows(res.data || []);
})
.catch((err) => {
console.error("계층 코드 로드 실패:", err);
if (!cancelled) setAllRows([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [categoryCode]);
// 3단계 코드 로드 (2단계 선택값 기준)
const loadLevel3Codes = useCallback(async (parentCodeValue: string) => {
if (!categoryCode || !parentCodeValue) {
setLevel3Options([]);
return;
}
setLoading(prev => ({ ...prev, level3: true }));
try {
const response = await commonCodeApi.hierarchy.getHierarchicalCodes(
categoryCode,
parentCodeValue,
undefined,
menuObjid
);
if (response.success && response.data) {
setLevel3Options(response.data);
setAllCodes(prev => {
const filtered = prev.filter(c => c.depth !== 3 || c.parent_code_value !== parentCodeValue);
return [...filtered, ...response.data];
});
}
} catch (error) {
console.error("3단계 코드 로드 실패:", error);
} finally {
setLoading(prev => ({ ...prev, level3: false }));
}
}, [categoryCode, menuObjid]);
// depth=2 = 1단계, depth=3 = 2단계, depth=4 = 3단계
const level1Options = useMemo(
() => allRows.filter((r) => (r.depth ?? 2) === 2),
[allRows],
);
// 초기 로드 및 카테고리 변경 시
useEffect(() => {
loadLevel1Codes();
setSelectedLevel1("");
setSelectedLevel2("");
setSelectedLevel3("");
setLevel2Options([]);
setLevel3Options([]);
}, [loadLevel1Codes]);
// value prop 변경 시 역추적 (외부에서 값이 설정된 경우)
useEffect(() => {
if (!value || allCodes.length === 0) return;
// 선택된 코드 찾기
const selectedCode = allCodes.find(c =>
c.code_value === value
const level2Options = useMemo(() => {
if (!selected1) return [];
const parent = allRows.find((r) => r.code_value === selected1 && (r.depth ?? 2) === 2);
if (!parent) return [];
return allRows.filter(
(r) => (r.depth ?? 2) === 3 && r.parent_detail_id === parent.code_detail_id,
);
if (!selectedCode) return;
const depth = selectedCode.depth || 1;
if (depth === 1) {
setSelectedLevel1(value);
setSelectedLevel2("");
setSelectedLevel3("");
} else if (depth === 2) {
const parentValue = selectedCode.parent_code_value || "";
setSelectedLevel1(parentValue);
setSelectedLevel2(value);
setSelectedLevel3("");
loadLevel2Codes(parentValue);
} else if (depth === 3) {
const parentValue = selectedCode.parent_code_value || "";
// 2단계 부모 찾기
const level2Code = allCodes.find(c => c.code_value === parentValue);
const level1Value = level2Code?.parent_code_value || "";
setSelectedLevel1(level1Value);
setSelectedLevel2(parentValue);
setSelectedLevel3(value);
loadLevel2Codes(level1Value);
loadLevel3Codes(parentValue);
}
}, [value, allCodes]);
}, [allRows, selected1]);
// 1단계 선택 변경
const handleLevel1Change = (codeValue: string) => {
setSelectedLevel1(codeValue);
setSelectedLevel2("");
setSelectedLevel3("");
setLevel2Options([]);
setLevel3Options([]);
if (codeValue && maxDepth > 1) {
loadLevel2Codes(codeValue);
}
// 최대 깊이가 1이면 즉시 onChange 호출
if (maxDepth === 1 && onChange) {
const selectedCodeInfo = level1Options.find(c => c.code_value === codeValue);
onChange(codeValue, selectedCodeInfo, selectedCodeInfo ? [selectedCodeInfo] : []);
}
};
const level3Options = useMemo(() => {
if (!selected2) return [];
const parent = allRows.find((r) => r.code_value === selected2 && (r.depth ?? 2) === 3);
if (!parent) return [];
return allRows.filter(
(r) => (r.depth ?? 2) === 4 && r.parent_detail_id === parent.code_detail_id,
);
}, [allRows, selected2]);
// 2단계 선택 변경
const handleLevel2Change = (codeValue: string) => {
setSelectedLevel2(codeValue);
setSelectedLevel3("");
setLevel3Options([]);
if (codeValue && maxDepth > 2) {
loadLevel3Codes(codeValue);
}
// 최대 깊이가 2이면 onChange 호출
if (maxDepth === 2 && onChange) {
const level1Code = level1Options.find(c => c.code_value === selectedLevel1);
const level2Code = level2Options.find(c => c.code_value === codeValue);
const fullPath = [level1Code, level2Code].filter(Boolean) as CodeInfo[];
onChange(codeValue, level2Code, fullPath);
}
};
// 3단계 선택 변경
const handleLevel3Change = (codeValue: string) => {
setSelectedLevel3(codeValue);
if (onChange) {
const level1Code = level1Options.find(c => c.code_value === selectedLevel1);
const level2Code = level2Options.find(c => c.code_value === selectedLevel2);
const level3Code = level3Options.find(c => c.code_value === codeValue);
const fullPath = [level1Code, level2Code, level3Code].filter(Boolean) as CodeInfo[];
onChange(codeValue, level3Code, fullPath);
}
};
// 최종 선택값 계산
const finalValue = useMemo(() => {
if (maxDepth >= 3 && selectedLevel3) return selectedLevel3;
if (maxDepth >= 2 && selectedLevel2) return selectedLevel2;
if (selectedLevel1) return selectedLevel1;
return "";
}, [maxDepth, selectedLevel1, selectedLevel2, selectedLevel3]);
// 최종 선택값이 변경되면 onChange 호출 (maxDepth 제한 없이)
// value prop 으로 역추적
useEffect(() => {
if (!onChange) return;
// 현재 선택된 깊이 확인
if (selectedLevel3 && maxDepth >= 3) {
// 3단계까지 선택됨
return; // handleLevel3Change에서 처리
if (!value || allRows.length === 0) {
return;
}
if (selectedLevel2 && maxDepth >= 2 && !selectedLevel3 && level3Options.length === 0) {
// 2단계까지 선택되고 3단계 옵션이 없음
const level1Code = level1Options.find(c => c.code_value === selectedLevel1);
const level2Code = level2Options.find(c => c.code_value === selectedLevel2);
const fullPath = [level1Code, level2Code].filter(Boolean) as CodeInfo[];
onChange(selectedLevel2, level2Code, fullPath);
} else if (selectedLevel1 && maxDepth >= 1 && !selectedLevel2 && level2Options.length === 0) {
// 1단계까지 선택되고 2단계 옵션이 없음
const level1Code = level1Options.find(c => c.code_value === selectedLevel1);
onChange(selectedLevel1, level1Code, level1Code ? [level1Code] : []);
const target = allRows.find((r) => r.code_value === value);
if (!target) return;
const depth = target.depth ?? 2;
if (depth === 2) {
setSelected1(value);
setSelected2("");
setSelected3("");
} else if (depth === 3) {
const parent = allRows.find((r) => r.code_detail_id === target.parent_detail_id);
setSelected1(parent?.code_value || "");
setSelected2(value);
setSelected3("");
} else if (depth === 4) {
const parent2 = allRows.find((r) => r.code_detail_id === target.parent_detail_id);
const parent1 = parent2
? allRows.find((r) => r.code_detail_id === parent2.parent_detail_id)
: undefined;
setSelected1(parent1?.code_value || "");
setSelected2(parent2?.code_value || "");
setSelected3(value);
}
}, [level2Options, level3Options]);
}, [value, allRows]);
const containerClass = inline
? "flex flex-wrap gap-4 items-end"
: "space-y-4";
const handle1 = useCallback(
(v: string) => {
setSelected1(v);
setSelected2("");
setSelected3("");
if (maxDepth === 1 && onChange) {
const row = level1Options.find((r) => r.code_value === v);
onChange(v, row, row ? [row] : []);
}
},
[maxDepth, onChange, level1Options],
);
const selectItemClass = inline
? "flex-1 min-w-[150px] space-y-1"
: "space-y-1";
const handle2 = useCallback(
(v: string) => {
setSelected2(v);
setSelected3("");
if (maxDepth === 2 && onChange) {
const row1 = level1Options.find((r) => r.code_value === selected1);
const row2 = level2Options.find((r) => r.code_value === v);
const path = [row1, row2].filter(Boolean) as CodeDetail[];
onChange(v, row2, path);
}
},
[maxDepth, onChange, level1Options, level2Options, selected1],
);
const handle3 = useCallback(
(v: string) => {
setSelected3(v);
if (onChange) {
const row1 = level1Options.find((r) => r.code_value === selected1);
const row2 = level2Options.find((r) => r.code_value === selected2);
const row3 = level3Options.find((r) => r.code_value === v);
const path = [row1, row2, row3].filter(Boolean) as CodeDetail[];
onChange(v, row3, path);
}
},
[onChange, level1Options, level2Options, level3Options, selected1, selected2],
);
const containerClass = inline ? "flex flex-wrap gap-4 items-end" : "space-y-4";
const selectItemClass = inline ? "flex-1 min-w-[150px] space-y-1" : "space-y-1";
return (
<div className={`${containerClass} ${className}`}>
{/* 1단계 선택 */}
{/* 1단계 */}
<div className={selectItemClass}>
<Label className="text-xs font-medium">
{labels[0]}
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
<Select
value={selectedLevel1}
onValueChange={handleLevel1Change}
disabled={disabled || loading.level1}
>
<Select value={selected1} onValueChange={handle1} disabled={disabled || loading}>
<SelectTrigger className="h-9 text-sm">
{loading.level1 ? (
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="text-muted-foreground"> ...</span>
@@ -352,97 +208,73 @@ export function HierarchicalCodeSelect({
)}
</SelectTrigger>
<SelectContent>
{level1Options.map((code) => {
const codeValue = code.code_value || "";
const codeName = code.code_name || "";
return (
<SelectItem key={codeValue} value={codeValue}>
{codeName}
</SelectItem>
);
})}
{level1Options.map((row) => (
<SelectItem key={String(row.code_detail_id)} value={row.code_value}>
{row.code_name || row.code_value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 2단계 선택 */}
{maxDepth >= 2 && (
<div className={selectItemClass}>
<Label className="text-xs font-medium">
{labels[1] || "2단계"}
</Label>
<Label className="text-xs font-medium">{labels[1] || "2단계"}</Label>
<Select
value={selectedLevel2}
onValueChange={handleLevel2Change}
disabled={disabled || loading.level2 || !selectedLevel1}
value={selected2}
onValueChange={handle2}
disabled={disabled || loading || !selected1}
>
<SelectTrigger className="h-9 text-sm">
{loading.level2 ? (
<div className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="text-muted-foreground"> ...</span>
</div>
) : (
<SelectValue placeholder={selectedLevel1 ? (placeholders[1] || "선택하세요") : "1단계를 먼저 선택하세요"} />
)}
<SelectValue
placeholder={
selected1 ? placeholders[1] || "선택하세요" : "1단계를 먼저 선택하세요"
}
/>
</SelectTrigger>
<SelectContent>
{level2Options.length === 0 && selectedLevel1 && !loading.level2 ? (
{level2Options.length === 0 && selected1 ? (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
</div>
) : (
level2Options.map((code) => {
const codeValue = code.code_value || "";
const codeName = code.code_name || "";
return (
<SelectItem key={codeValue} value={codeValue}>
{codeName}
</SelectItem>
);
})
level2Options.map((row) => (
<SelectItem key={String(row.code_detail_id)} value={row.code_value}>
{row.code_name || row.code_value}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
)}
{/* 3단계 선택 */}
{maxDepth >= 3 && (
<div className={selectItemClass}>
<Label className="text-xs font-medium">
{labels[2] || "3단계"}
</Label>
<Label className="text-xs font-medium">{labels[2] || "3단계"}</Label>
<Select
value={selectedLevel3}
onValueChange={handleLevel3Change}
disabled={disabled || loading.level3 || !selectedLevel2}
value={selected3}
onValueChange={handle3}
disabled={disabled || loading || !selected2}
>
<SelectTrigger className="h-9 text-sm">
{loading.level3 ? (
<div className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="text-muted-foreground"> ...</span>
</div>
) : (
<SelectValue placeholder={selectedLevel2 ? (placeholders[2] || "선택하세요") : "2단계를 먼저 선택하세요"} />
)}
<SelectValue
placeholder={
selected2 ? placeholders[2] || "선택하세요" : "2단계를 먼저 선택하세요"
}
/>
</SelectTrigger>
<SelectContent>
{level3Options.length === 0 && selectedLevel2 && !loading.level3 ? (
{level3Options.length === 0 && selected2 ? (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
</div>
) : (
level3Options.map((code) => {
const codeValue = code.code_value || "";
const codeName = code.code_name || "";
return (
<SelectItem key={codeValue} value={codeValue}>
{codeName}
</SelectItem>
);
})
level3Options.map((row) => (
<SelectItem key={String(row.code_detail_id)} value={row.code_value}>
{row.code_name || row.code_value}
</SelectItem>
))
)}
</SelectContent>
</Select>
@@ -453,5 +285,3 @@ export function HierarchicalCodeSelect({
}
export default HierarchicalCodeSelect;
@@ -1,35 +1,19 @@
"use client";
/**
*
*
* , ,
*
* @example
* <MultiColumnHierarchySelect
* categoryCode="PRODUCT_CATEGORY"
* columns={{
* large: { columnName: "category_large", label: "대분류" },
* medium: { columnName: "category_medium", label: "중분류" },
* small: { columnName: "category_small", label: "소분류" },
* }}
* values={{
* large: formData.category_large,
* medium: formData.category_medium,
* small: formData.category_small,
* }}
* onChange={(role, columnName, value) => {
* setFormData(prev => ({ ...prev, [columnName]: value }));
* }}
* />
* // .
*
* 2026-05-15 - :
* - depth + parent_detail_id
* - = depth 2, = depth 3, = depth 4
*/
import React, { useState, useEffect, useCallback } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { commonCodeApi } from "@/lib/api/commonCode";
import { getCodeDetailTree } from "@/lib/api/commonCode";
import { Loader2 } from "lucide-react";
import type { CodeInfo } from "@/types/commonCode";
import type { CodeDetail } from "@/types/commonCode";
export type HierarchyRole = "large" | "medium" | "small";
@@ -40,231 +24,122 @@ export interface HierarchyColumnConfig {
}
export interface MultiColumnHierarchySelectProps {
/** 코드 카테고리 */
/** 그룹 코드 (code_info) */
categoryCode: string;
/** 각 계층별 컬럼 설정 */
columns: {
large?: HierarchyColumnConfig;
medium?: HierarchyColumnConfig;
small?: HierarchyColumnConfig;
};
/** 현재 값들 */
values?: {
large?: string;
medium?: string;
small?: string;
};
/** 값 변경 핸들러 (역할, 컬럼명, 값) */
onChange?: (role: HierarchyRole, columnName: string, value: string) => void;
/** 비활성화 */
disabled?: boolean;
/** 메뉴 OBJID */
/** 현재 미사용 — 호환용 */
menuObjid?: number;
/** 추가 클래스 */
className?: string;
/** 인라인 표시 (가로 배열) */
inline?: boolean;
}
interface LoadingState {
large: boolean;
medium: boolean;
small: boolean;
}
export function MultiColumnHierarchySelect({
categoryCode,
columns,
values = {},
onChange,
disabled = false,
menuObjid,
className = "",
inline = false,
}: MultiColumnHierarchySelectProps) {
// 각 단계별 옵션
const [largeOptions, setLargeOptions] = useState<CodeInfo[]>([]);
const [mediumOptions, setMediumOptions] = useState<CodeInfo[]>([]);
const [smallOptions, setSmallOptions] = useState<CodeInfo[]>([]);
// 로딩 상태
const [loading, setLoading] = useState<LoadingState>({
large: false,
medium: false,
small: false,
});
const [allRows, setAllRows] = useState<CodeDetail[]>([]);
const [loading, setLoading] = useState(false);
// 대분류 로드 (depth = 1)
const loadLargeOptions = useCallback(async () => {
if (!categoryCode) return;
setLoading(prev => ({ ...prev, large: true }));
try {
const response = await commonCodeApi.hierarchy.getHierarchicalCodes(
categoryCode,
null, // 부모 없음
1, // depth = 1
menuObjid
);
if (response.success && response.data) {
setLargeOptions(response.data);
}
} catch (error) {
console.error("대분류 로드 실패:", error);
} finally {
setLoading(prev => ({ ...prev, large: false }));
}
}, [categoryCode, menuObjid]);
// 중분류 로드 (대분류 선택 기준)
const loadMediumOptions = useCallback(async (parentCodeValue: string) => {
if (!categoryCode || !parentCodeValue) {
setMediumOptions([]);
useEffect(() => {
if (!categoryCode) {
setAllRows([]);
return;
}
setLoading(prev => ({ ...prev, medium: true }));
try {
const response = await commonCodeApi.hierarchy.getHierarchicalCodes(
categoryCode,
parentCodeValue,
undefined,
menuObjid
);
if (response.success && response.data) {
setMediumOptions(response.data);
}
} catch (error) {
console.error("중분류 로드 실패:", error);
} finally {
setLoading(prev => ({ ...prev, medium: false }));
}
}, [categoryCode, menuObjid]);
let cancelled = false;
setLoading(true);
getCodeDetailTree({ code_info: categoryCode, is_active: true })
.then((res) => {
if (!cancelled) setAllRows(res.data || []);
})
.catch((err) => {
console.error("계층 코드 로드 실패:", err);
if (!cancelled) setAllRows([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [categoryCode]);
// 소분류 로드 (중분류 선택 기준)
const loadSmallOptions = useCallback(async (parentCodeValue: string) => {
if (!categoryCode || !parentCodeValue) {
setSmallOptions([]);
return;
}
setLoading(prev => ({ ...prev, small: true }));
try {
const response = await commonCodeApi.hierarchy.getHierarchicalCodes(
categoryCode,
parentCodeValue,
undefined,
menuObjid
);
if (response.success && response.data) {
setSmallOptions(response.data);
}
} catch (error) {
console.error("소분류 로드 실패:", error);
} finally {
setLoading(prev => ({ ...prev, small: false }));
}
}, [categoryCode, menuObjid]);
const largeOptions = useMemo(
() => allRows.filter((r) => (r.depth ?? 2) === 2),
[allRows],
);
// 초기 로드
useEffect(() => {
loadLargeOptions();
}, [loadLargeOptions]);
const mediumOptions = useMemo(() => {
if (!values.large) return [];
const parent = allRows.find((r) => r.code_value === values.large && (r.depth ?? 2) === 2);
if (!parent) return [];
return allRows.filter(
(r) => (r.depth ?? 2) === 3 && r.parent_detail_id === parent.code_detail_id,
);
}, [allRows, values.large]);
// 대분류 값이 있으면 중분류 로드
useEffect(() => {
if (values.large) {
loadMediumOptions(values.large);
} else {
setMediumOptions([]);
setSmallOptions([]);
}
}, [values.large, loadMediumOptions]);
const smallOptions = useMemo(() => {
if (!values.medium) return [];
const parent = allRows.find(
(r) => r.code_value === values.medium && (r.depth ?? 2) === 3,
);
if (!parent) return [];
return allRows.filter(
(r) => (r.depth ?? 2) === 4 && r.parent_detail_id === parent.code_detail_id,
);
}, [allRows, values.medium]);
// 중분류 값이 있으면 소분류 로드
useEffect(() => {
if (values.medium) {
loadSmallOptions(values.medium);
} else {
setSmallOptions([]);
}
}, [values.medium, loadSmallOptions]);
// 대분류 변경
const handleLargeChange = (codeValue: string) => {
const columnName = columns.large?.columnName || "";
if (onChange && columnName) {
onChange("large", columnName, codeValue);
}
// 하위 값 초기화
if (columns.medium?.columnName && onChange) {
onChange("medium", columns.medium.columnName, "");
}
if (columns.small?.columnName && onChange) {
onChange("small", columns.small.columnName, "");
const handleLargeChange = (value: string) => {
if (onChange) {
if (columns.large?.columnName) onChange("large", columns.large.columnName, value);
if (columns.medium?.columnName) onChange("medium", columns.medium.columnName, "");
if (columns.small?.columnName) onChange("small", columns.small.columnName, "");
}
};
// 중분류 변경
const handleMediumChange = (codeValue: string) => {
const columnName = columns.medium?.columnName || "";
if (onChange && columnName) {
onChange("medium", columnName, codeValue);
}
// 하위 값 초기화
if (columns.small?.columnName && onChange) {
onChange("small", columns.small.columnName, "");
const handleMediumChange = (value: string) => {
if (onChange) {
if (columns.medium?.columnName) onChange("medium", columns.medium.columnName, value);
if (columns.small?.columnName) onChange("small", columns.small.columnName, "");
}
};
// 소분류 변경
const handleSmallChange = (codeValue: string) => {
const columnName = columns.small?.columnName || "";
if (onChange && columnName) {
onChange("small", columnName, codeValue);
const handleSmallChange = (value: string) => {
if (onChange && columns.small?.columnName) {
onChange("small", columns.small.columnName, value);
}
};
const containerClass = inline
? "flex flex-wrap gap-4 items-end"
: "space-y-4";
const selectItemClass = inline
? "flex-1 min-w-[150px] space-y-1"
: "space-y-1";
// 설정된 컬럼만 렌더링
const hasLarge = !!columns.large;
const hasMedium = !!columns.medium;
const hasSmall = !!columns.small;
const containerClass = inline ? "flex flex-wrap gap-4 items-end" : "space-y-4";
const selectItemClass = inline ? "flex-1 min-w-[150px] space-y-1" : "space-y-1";
return (
<div className={`${containerClass} ${className}`}>
{/* 대분류 */}
{hasLarge && (
{columns.large && (
<div className={selectItemClass}>
<Label className="text-xs font-medium">
{columns.large?.label || "대분류"}
</Label>
<Label className="text-xs font-medium">{columns.large?.label || "대분류"}</Label>
<Select
value={values.large || ""}
onValueChange={handleLargeChange}
disabled={disabled || loading.large}
disabled={disabled || loading}
>
<SelectTrigger className="h-9 text-sm">
{loading.large ? (
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="text-muted-foreground"> ...</span>
@@ -274,108 +149,78 @@ export function MultiColumnHierarchySelect({
)}
</SelectTrigger>
<SelectContent>
{largeOptions.map((code) => {
const codeValue = code.code_value || "";
const codeName = code.code_name || "";
return (
<SelectItem key={codeValue} value={codeValue}>
{codeName}
</SelectItem>
);
})}
{largeOptions.map((row) => (
<SelectItem key={String(row.code_detail_id)} value={row.code_value}>
{row.code_name || row.code_value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 중분류 */}
{hasMedium && (
{columns.medium && (
<div className={selectItemClass}>
<Label className="text-xs font-medium">
{columns.medium?.label || "중분류"}
</Label>
<Label className="text-xs font-medium">{columns.medium?.label || "중분류"}</Label>
<Select
value={values.medium || ""}
onValueChange={handleMediumChange}
disabled={disabled || loading.medium || !values.large}
disabled={disabled || loading || !values.large}
>
<SelectTrigger className="h-9 text-sm">
{loading.medium ? (
<div className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="text-muted-foreground"> ...</span>
</div>
) : (
<SelectValue
placeholder={values.large
? (columns.medium?.placeholder || "중분류 선택")
<SelectValue
placeholder={
values.large
? columns.medium?.placeholder || "중분류 선택"
: "대분류를 먼저 선택하세요"
}
/>
)}
}
/>
</SelectTrigger>
<SelectContent>
{mediumOptions.length === 0 && values.large && !loading.medium ? (
{mediumOptions.length === 0 && values.large ? (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
</div>
) : (
mediumOptions.map((code) => {
const codeValue = code.code_value || "";
const codeName = code.code_name || "";
return (
<SelectItem key={codeValue} value={codeValue}>
{codeName}
</SelectItem>
);
})
mediumOptions.map((row) => (
<SelectItem key={String(row.code_detail_id)} value={row.code_value}>
{row.code_name || row.code_value}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
)}
{/* 소분류 */}
{hasSmall && (
{columns.small && (
<div className={selectItemClass}>
<Label className="text-xs font-medium">
{columns.small?.label || "소분류"}
</Label>
<Label className="text-xs font-medium">{columns.small?.label || "소분류"}</Label>
<Select
value={values.small || ""}
onValueChange={handleSmallChange}
disabled={disabled || loading.small || !values.medium}
disabled={disabled || loading || !values.medium}
>
<SelectTrigger className="h-9 text-sm">
{loading.small ? (
<div className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="text-muted-foreground"> ...</span>
</div>
) : (
<SelectValue
placeholder={values.medium
? (columns.small?.placeholder || "소분류 선택")
<SelectValue
placeholder={
values.medium
? columns.small?.placeholder || "소분류 선택"
: "중분류를 먼저 선택하세요"
}
/>
)}
}
/>
</SelectTrigger>
<SelectContent>
{smallOptions.length === 0 && values.medium && !loading.small ? (
{smallOptions.length === 0 && values.medium ? (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
</div>
) : (
smallOptions.map((code) => {
const codeValue = code.code_value || "";
const codeName = code.code_name || "";
return (
<SelectItem key={codeValue} value={codeValue}>
{codeName}
</SelectItem>
);
})
smallOptions.map((row) => (
<SelectItem key={String(row.code_detail_id)} value={row.code_value}>
{row.code_name || row.code_value}
</SelectItem>
))
)}
</SelectContent>
</Select>
@@ -386,4 +231,3 @@ export function MultiColumnHierarchySelect({
}
export default MultiColumnHierarchySelect;
@@ -28,11 +28,7 @@ import {
Zap,
Download,
Loader2,
Check,
ChevronsUpDown,
} from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { importFromExcel, getExcelSheetNames, exportToExcel } from "@/lib/utils/excelExport";
import { cn } from "@/lib/utils";
import { EditableSpreadsheet } from "./EditableSpreadsheet";
@@ -40,8 +36,6 @@ import {
TableChainConfig,
uploadMultiTableExcel,
} from "@/lib/api/multiTableExcel";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
import { getTableColumns } from "@/lib/api/tableManagement";
export interface MultiTableExcelUploadModalProps {
open: boolean;
@@ -55,34 +49,6 @@ interface ColumnMapping {
targetColumn: string | null;
}
interface FlatCategoryValue {
valueCode: string;
valueLabel: string;
depth: number;
ancestors: string[];
}
function flattenCategoryValues(
values: Array<{ valueCode: string; valueLabel: string; children?: any[] }>
): FlatCategoryValue[] {
const result: FlatCategoryValue[] = [];
const traverse = (items: any[], depth: number, ancestors: string[]) => {
for (const item of items) {
result.push({
valueCode: item.valueCode,
valueLabel: item.valueLabel,
depth,
ancestors,
});
if (item.children?.length > 0) {
traverse(item.children, depth + 1, [...ancestors, item.valueLabel]);
}
}
};
traverse(values, 0, []);
return result;
}
export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProps> = ({
open,
onOpenChange,
@@ -113,18 +79,6 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
// 업로드
const [isUploading, setIsUploading] = useState(false);
// 카테고리 검증 관련
const [showCategoryValidation, setShowCategoryValidation] = useState(false);
const [isCategoryValidating, setIsCategoryValidating] = useState(false);
const [categoryMismatches, setCategoryMismatches] = useState<
Record<string, Array<{
invalidValue: string;
replacement: string | null;
validOptions: Array<{ code: string; label: string; depth: number; ancestors: string[] }>;
rowIndices: number[];
}>>
>({});
const selectedMode = config.uploadModes.find((m) => m.id === selectedModeId);
// 선택된 모드에서 활성화되는 컬럼 목록
@@ -348,157 +302,6 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
}
};
// 카테고리 검증: 매핑된 컬럼 중 카테고리 타입인 것의 유효하지 않은 값 감지
const validateCategoryColumns = async () => {
try {
setIsCategoryValidating(true);
if (!selectedMode) return null;
const mismatches: typeof categoryMismatches = {};
// 활성 레벨별로 카테고리 컬럼 검증
for (const levelIdx of selectedMode.activeLevels) {
const level = config.levels[levelIdx];
if (!level) continue;
// 해당 테이블의 카테고리 타입 컬럼 조회
const colResponse = await getTableColumns(level.tableName);
if (!colResponse.success || !colResponse.data?.columns) continue;
const categoryColumns = colResponse.data.columns.filter(
(col: any) => col.input_type === "category"
);
if (categoryColumns.length === 0) continue;
// 매핑된 컬럼 중 카테고리 타입인 것 찾기
for (const catCol of categoryColumns) {
const catColName = catCol.column_name;
const catDisplayName = catCol.display_name || catColName;
// level.columns에서 해당 dbColumn 찾기
const levelCol = level.columns.find((lc) => lc.dbColumn === catColName);
if (!levelCol) continue;
// 매핑에서 해당 excelHeader에 연결된 엑셀 컬럼 찾기
const mapping = columnMappings.find((m) => m.targetColumn === levelCol.excelHeader);
if (!mapping) continue;
// 유효한 카테고리 값 조회
const valuesResponse = await getCategoryValues(level.tableName, catColName);
if (!valuesResponse.success || !valuesResponse.data) continue;
const validValues = flattenCategoryValues(valuesResponse.data as any[]);
const validCodes = new Set(validValues.map((v) => v.valueCode));
const validLabels = new Set(validValues.map((v) => v.valueLabel));
const validLabelsLower = new Set(validValues.map((v) => v.valueLabel.toLowerCase()));
// 엑셀 데이터에서 유효하지 않은 값 수집
const invalidMap = new Map<string, number[]>();
allData.forEach((row, rowIdx) => {
const val = row[mapping.excelColumn];
if (val === undefined || val === null || String(val).trim() === "") return;
const strVal = String(val).trim();
if (validCodes.has(strVal)) return;
if (validLabels.has(strVal)) return;
if (validLabelsLower.has(strVal.toLowerCase())) return;
if (!invalidMap.has(strVal)) {
invalidMap.set(strVal, []);
}
invalidMap.get(strVal)!.push(rowIdx);
});
if (invalidMap.size > 0) {
const options = validValues.map((v) => ({
code: v.valueCode,
label: v.valueLabel,
depth: v.depth,
ancestors: v.ancestors,
}));
const key = `${catColName}|||[${level.label}] ${catDisplayName}`;
mismatches[key] = Array.from(invalidMap.entries()).map(
([invalidValue, rowIndices]) => ({
invalidValue,
replacement: null,
validOptions: options,
rowIndices,
})
);
}
}
}
if (Object.keys(mismatches).length > 0) {
return mismatches;
}
return null;
} catch (error) {
console.error("카테고리 검증 실패:", error);
return null;
} finally {
setIsCategoryValidating(false);
}
};
// 카테고리 대체값 적용
const applyCategoryReplacements = () => {
for (const [, items] of Object.entries(categoryMismatches)) {
for (const item of items) {
if (item.replacement === null) {
toast.error("모든 항목의 대체 값을 선택해주세요.");
return false;
}
}
}
// 시스템 컬럼명 → 엑셀 컬럼명 역매핑 구축
const dbColToExcelCol = new Map<string, string>();
if (selectedMode) {
for (const levelIdx of selectedMode.activeLevels) {
const level = config.levels[levelIdx];
if (!level) continue;
for (const lc of level.columns) {
const mapping = columnMappings.find((m) => m.targetColumn === lc.excelHeader);
if (mapping) {
dbColToExcelCol.set(lc.dbColumn, mapping.excelColumn);
}
}
}
}
const newData = allData.map((row) => ({ ...row }));
for (const [key, items] of Object.entries(categoryMismatches)) {
const systemCol = key.split("|||")[0];
const excelCol = dbColToExcelCol.get(systemCol);
if (!excelCol) continue;
for (const item of items) {
if (!item.replacement) continue;
const selectedOption = item.validOptions.find((opt) => opt.code === item.replacement);
const replacementLabel = selectedOption?.label || item.replacement;
for (const rowIdx of item.rowIndices) {
if (newData[rowIdx]) {
newData[rowIdx][excelCol] = replacementLabel;
}
}
}
}
setAllData(newData);
setDisplayData(newData);
setShowCategoryValidation(false);
setCategoryMismatches({});
toast.success("카테고리 값이 대체되었습니다. '다음'을 눌러 진행해주세요.");
return true;
};
// 다음/이전 단계
const handleNext = async () => {
if (currentStep === 1) {
@@ -525,14 +328,6 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
toast.error(`필수 컬럼이 매핑되지 않았습니다: ${unmappedRequired.join(", ")}`);
return;
}
// 카테고리 컬럼 검증
const mismatches = await validateCategoryColumns();
if (mismatches) {
setCategoryMismatches(mismatches);
setShowCategoryValidation(true);
return;
}
}
setCurrentStep((prev) => Math.min(prev + 1, 3));
@@ -554,15 +349,11 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
setDisplayData([]);
setExcelColumns([]);
setColumnMappings([]);
setShowCategoryValidation(false);
setCategoryMismatches({});
setIsCategoryValidating(false);
}
}, [open, config.uploadModes]);
return (
<>
<Dialog open={open} onOpenChange={(v) => { if (!showCategoryValidation) onOpenChange(v); }}>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
style={{ width: "1000px", height: "700px", minWidth: "700px", minHeight: "500px", maxWidth: "1400px", maxHeight: "900px" }}
@@ -967,17 +758,10 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
{currentStep < 3 ? (
<Button
onClick={handleNext}
disabled={isUploading || isCategoryValidating || (currentStep === 1 && !file)}
disabled={isUploading || (currentStep === 1 && !file)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isCategoryValidating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"다음"
)}
</Button>
) : (
<Button
@@ -998,131 +782,5 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
</DialogFooter>
</DialogContent>
</Dialog>
{/* 카테고리 대체값 선택 다이얼로그 */}
<Dialog open={showCategoryValidation} onOpenChange={(open) => {
if (!open) {
setShowCategoryValidation(false);
setCategoryMismatches({});
}
}}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<AlertCircle className="h-5 w-5 text-warning" />
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. .
</DialogDescription>
</DialogHeader>
<div className="max-h-[400px] space-y-4 overflow-y-auto pr-1">
{Object.entries(categoryMismatches).map(([key, items]) => {
const [, displayName] = key.split("|||");
return (
<div key={key} className="space-y-2">
<h4 className="text-sm font-semibold text-foreground">
{displayName}
</h4>
{items.map((item, idx) => (
<div
key={`${key}-${idx}`}
className="grid grid-cols-[1fr_auto_1fr] items-center gap-2 rounded-md border border-border bg-muted/30 p-2"
>
<div className="flex flex-col">
<span className="text-xs font-medium text-destructive line-through">
{item.invalidValue}
</span>
<span className="text-[10px] text-muted-foreground">
{item.rowIndices.length}
</span>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs sm:h-9 sm:text-sm"
>
<span className="truncate">
{item.replacement
? item.validOptions.find((o) => o.code === item.replacement)?.label || item.replacement
: "대체 값 선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-0" align="start">
<Command
filter={(value, search) => {
const opt = item.validOptions.find((o) => o.code === value);
if (!opt) return 0;
const s = search.toLowerCase();
if (opt.label.toLowerCase().includes(s)) return 1;
if (opt.ancestors.some((a) => a.toLowerCase().includes(s))) return 1;
return 0;
}}
>
<CommandInput placeholder="카테고리 검색..." className="text-xs" />
<CommandList className="max-h-52">
<CommandEmpty className="py-3 text-xs"> </CommandEmpty>
<CommandGroup>
{item.validOptions.map((opt) => (
<CommandItem
key={opt.code}
value={opt.code}
onSelect={(val) => {
setCategoryMismatches((prev) => {
const updated = { ...prev };
updated[key] = updated[key].map((it, i) =>
i === idx ? { ...it, replacement: val } : it
);
return updated;
});
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-3 w-3", item.replacement === opt.code ? "opacity-100" : "opacity-0")} />
<span style={{ paddingLeft: `${opt.depth * 12}px` }}>
{opt.depth > 0 && <span className="mr-1 text-muted-foreground"></span>}
{opt.label}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
))}
</div>
);
})}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setShowCategoryValidation(false);
setCategoryMismatches({});
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={applyCategoryReplacements}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -41,7 +41,7 @@ export const WebTypeInput: React.FC<WebTypeInputProps> = ({
const [loading, setLoading] = useState(false);
// detailSettings 안전하게 파싱 (메모이제이션)
const { detailSettings, fallbackCodeCategory } = useMemo(() => {
const { detailSettings, fallbackCodeInfo } = useMemo(() => {
let parsedSettings: Record<string, unknown> = {};
let fallbackCategory = "";
@@ -55,7 +55,7 @@ export const WebTypeInput: React.FC<WebTypeInputProps> = ({
parsedSettings = {};
}
} else {
// JSON이 아닌 일반 문자열인 경우, code 타입이면 codeCategory로 사용
// JSON이 아닌 일반 문자열인 경우, code 타입이면 codeInfo로 사용
if (webType === "code") {
// "공통코드: 상태" 형태에서 실제 코드 추출 시도
if (column.detailSettings.includes(":")) {
@@ -75,7 +75,7 @@ export const WebTypeInput: React.FC<WebTypeInputProps> = ({
parsedSettings = column.detailSettings;
}
return { detailSettings: parsedSettings, fallbackCodeCategory: fallbackCategory };
return { detailSettings: parsedSettings, fallbackCodeInfo: fallbackCategory };
}, [column.detailSettings, webType]);
const loadEntityData = useCallback(async () => {
@@ -101,9 +101,9 @@ export const WebTypeInput: React.FC<WebTypeInputProps> = ({
const loadCodeData = useCallback(async () => {
try {
setLoading(true);
const codeCategory = column.codeCategory || (detailSettings.codeCategory as string) || fallbackCodeCategory;
if (codeCategory) {
const data = await EntityReferenceAPI.getCodeReferenceData(codeCategory, { limit: 100 });
const codeInfo = column.codeInfo || (detailSettings.codeInfo as string) || fallbackCodeInfo;
if (codeInfo) {
const data = await EntityReferenceAPI.getCodeReferenceData(codeInfo, { limit: 100 });
setCodeOptions(data.options);
}
} catch {
@@ -111,7 +111,7 @@ export const WebTypeInput: React.FC<WebTypeInputProps> = ({
} finally {
setLoading(false);
}
}, [column.codeCategory, detailSettings.codeCategory, fallbackCodeCategory]);
}, [column.codeInfo, detailSettings.codeInfo, fallbackCodeInfo]);
// webType에 따른 데이터 로드
useEffect(() => {
@@ -133,7 +133,7 @@ export const WebTypeInput: React.FC<WebTypeInputProps> = ({
// entity 타입: 다른 테이블 참조
console.log("🚀 Entity 데이터 로드 시작:", effectiveTableName, column.columnName);
loadEntityData();
} else if (webType === "code" && (column.codeCategory || detailSettings.codeCategory || fallbackCodeCategory)) {
} else if (webType === "code" && (column.codeInfo || detailSettings.codeInfo || fallbackCodeInfo)) {
// code 타입: code_info 테이블에서 공통 코드 조회
loadCodeData();
}
@@ -143,13 +143,13 @@ export const WebTypeInput: React.FC<WebTypeInputProps> = ({
webType,
effectiveTableName,
column.columnName,
column.codeCategory,
column.codeInfo,
column.referenceTable,
column.referenceColumn,
(column as any).displayColumn,
tableName,
fallbackCodeCategory,
detailSettings.codeCategory,
fallbackCodeInfo,
detailSettings.codeInfo,
loadEntityData,
loadCodeData,
]);
@@ -337,11 +337,11 @@ export const WebTypeInput: React.FC<WebTypeInputProps> = ({
case "code":
// 공통코드 선택 - 실제 API에서 코드 목록 가져옴
const codeCategory = column.codeCategory || (detailSettings.codeCategory as string) || fallbackCodeCategory;
const codeInfo = column.codeInfo || (detailSettings.codeInfo as string) || fallbackCodeInfo;
return (
<Select value={value || ""} onValueChange={onChange} disabled={loading}>
<SelectTrigger className={className}>
<SelectValue placeholder={loading ? "코드 로딩 중..." : placeholder || `${codeCategory || "코드"} 선택`} />
<SelectValue placeholder={loading ? "코드 로딩 중..." : placeholder || `${codeInfo || "코드"} 선택`} />
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
</SelectTrigger>
<SelectContent>
@@ -27,7 +27,7 @@ interface FieldValueMapping {
valueType: "static" | "source_field" | "code" | "calculated";
value: string;
sourceField?: string;
codeCategory?: string;
codeInfo?: string;
}
interface ActionConditionBuilderProps {
@@ -127,7 +127,7 @@ const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
for (const field of codeFields) {
try {
const codes = await getCodesForColumn(field.columnName, field.webType, field.codeCategory);
const codes = await getCodesForColumn(field.columnName, field.webType, field.codeInfo);
if (codes.length > 0) {
setAvailableCodes((prev) => ({
@@ -93,7 +93,7 @@ const ControlConditionStep: React.FC<ControlConditionStepProps> = ({ state, acti
const codePromises = codeColumns.map(async (col) => {
try {
const codes = await getCodesForColumn(col.columnName, col.webType, col.codeCategory);
const codes = await getCodesForColumn(col.columnName, col.webType, col.codeInfo);
return { columnName: col.columnName, codes };
} catch (error) {
console.error(`코드 로딩 실패 (${col.columnName}):`, error);
@@ -79,7 +79,6 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/admin/systemMng/numberingRuleList": dynamic(() => import("@/app/(main)/admin/systemMng/numberingRuleList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/i18nList": dynamic(() => import("@/app/(main)/admin/systemMng/i18nList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/collection-managementList": dynamic(() => import("@/app/(main)/admin/systemMng/collection-managementList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/cascading-managementList": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/dataflow": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/dataflow/node-editorList": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }),
@@ -136,15 +135,11 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/admin/audit-log": dynamic(() => import("@/app/(main)/admin/audit-log/page"), { ssr: false, loading: LoadingFallback }),
"/admin/system-notices": dynamic(() => import("@/app/(main)/admin/system-notices/page"), { ssr: false, loading: LoadingFallback }),
// 기타
"/admin/cascading-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }),
"/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }),
"/admin/layouts": dynamic(() => import("@/app/(main)/admin/layouts/page"), { ssr: false, loading: LoadingFallback }),
"/admin/templates": dynamic(() => import("@/app/(main)/admin/templates/page"), { ssr: false, loading: LoadingFallback }),
"/admin/monitoring": dynamic(() => import("@/app/(main)/admin/monitoring/page"), { ssr: false, loading: LoadingFallback }),
"/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }),
"/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }),
"/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }),
// SCADA 디지털 트윈 데모 (DB 메뉴 등록은 siflex_invyone 만)
"/scada": dynamic(() => import("@/app/(main)/scada/page"), { ssr: false, loading: LoadingFallback }),
};
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import React, { useState, useEffect, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -152,17 +152,6 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
);
}
// 5. 카테고리
if (partType === "category") {
return (
<CategoryConfigPanel
config={config}
onChange={onChange}
isPreview={isPreview}
/>
);
}
// 6. 참조 (마스터-디테일 분번)
if (partType === "reference") {
return (
@@ -489,620 +478,6 @@ const DateConfigPanel: React.FC<DateConfigPanelProps> = ({
);
};
/**
*
* - (.)
* -
*/
import { CategoryFormatMapping } from "@/types/numbering-rule";
import { Plus, Trash2, FolderTree } from "lucide-react";
import { getCategoryTree, getAllCategoryKeys } from "@/lib/api/categoryTree";
interface CategoryValueNode {
value_id: number;
value_code: string;
value_label: string;
depth: number;
path: string;
parent_value_id: number | null;
children?: CategoryValueNode[];
}
interface CategoryConfigPanelProps {
config?: {
category_key?: string;
category_mappings?: CategoryFormatMapping[];
};
onChange: (config: any) => void;
isPreview?: boolean;
}
const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({
config = {},
onChange,
isPreview = false,
}) => {
// 카테고리 옵션 (테이블.컬럼 + 라벨)
const [categoryOptions, setCategoryOptions] = useState<{
tableName: string;
columnName: string;
displayName: string;
displayLabel: string; // 라벨 (테이블라벨.컬럼라벨)
}[]>([]);
const [categoryKeyOpen, setCategoryKeyOpen] = useState(false);
// 카테고리 값 트리
const [categoryValues, setCategoryValues] = useState<CategoryValueNode[]>([]);
const [loadingValues, setLoadingValues] = useState(false);
// 계층적 선택 상태 (대분류, 중분류, 소분류)
const [level1Id, setLevel1Id] = useState<number | null>(null);
const [level2Id, setLevel2Id] = useState<number | null>(null);
const [level3Id, setLevel3Id] = useState<number | null>(null);
const [level1Open, setLevel1Open] = useState(false);
const [level2Open, setLevel2Open] = useState(false);
const [level3Open, setLevel3Open] = useState(false);
// 형식 입력
const [newFormat, setNewFormat] = useState("");
// 수정 모드
const [editingId, setEditingId] = useState<number | null>(null);
// 수정 모드 진입 중 플래그 (useEffect 초기화 방지)
const isEditingRef = useRef(false);
const categoryKey = config.category_key || "";
const mappings = config.category_mappings || [];
// 이미 추가된 카테고리 ID 목록 (수정 중인 항목 제외)
const addedValueIds = useMemo(() => {
return mappings
.filter(m => m.category_value_id !== editingId)
.map(m => m.category_value_id);
}, [mappings, editingId]);
// 카테고리 옵션 로드
useEffect(() => {
loadCategoryOptions();
}, []);
// 카테고리 키 변경 시 값 로드 및 선택 초기화
useEffect(() => {
if (categoryKey) {
const [tableName, columnName] = categoryKey.split(".");
if (tableName && columnName) {
loadCategoryValues(tableName, columnName);
}
} else {
setCategoryValues([]);
}
// 선택 초기화
setLevel1Id(null);
setLevel2Id(null);
setLevel3Id(null);
}, [categoryKey]);
// 대분류 변경 시 중분류/소분류 초기화 (수정 모드 진입 중에는 건너뜀)
useEffect(() => {
if (isEditingRef.current) return;
setLevel2Id(null);
setLevel3Id(null);
}, [level1Id]);
// 중분류 변경 시 소분류 초기화 (수정 모드 진입 중에는 건너뜀)
useEffect(() => {
if (isEditingRef.current) return;
setLevel3Id(null);
}, [level2Id]);
const loadCategoryOptions = async () => {
try {
const response = await getAllCategoryKeys();
if (response.success && response.data) {
const options = response.data.map((item: { table_name: string; column_name: string; table_label?: string; column_label?: string }) => ({
tableName: item.table_name,
columnName: item.column_name,
displayName: `${item.table_name}.${item.column_name}`,
displayLabel: `${item.table_label || item.table_name}.${item.column_label || item.column_name}`,
}));
setCategoryOptions(options);
}
} catch (error) {
console.error("카테고리 옵션 로드 실패:", error);
}
};
const loadCategoryValues = async (tableName: string, columnName: string) => {
console.log("loadCategoryValues 호출:", { tableName, columnName });
setLoadingValues(true);
try {
const response = await getCategoryTree(tableName, columnName);
console.log("getCategoryTree 응답:", response);
if (response.success && response.data) {
console.log("카테고리 트리 로드 성공:", response.data);
setCategoryValues(response.data);
} else {
console.log("카테고리 트리 로드 실패:", response.error);
setCategoryValues([]);
}
} catch (error) {
console.error("카테고리 값 로드 실패:", error);
setCategoryValues([]);
} finally {
setLoadingValues(false);
}
};
// 이미 추가된 항목 확인 (해당 노드 또는 하위 노드가 추가되었는지)
const isNodeOrDescendantAdded = useCallback((node: CategoryValueNode): boolean => {
if (addedValueIds.includes(node.value_id)) return true;
if (node.children?.length) {
return node.children.every(child => isNodeOrDescendantAdded(child));
}
return false;
}, [addedValueIds]);
// 각 레벨별 항목 계산 (이미 추가된 항목 필터링)
const level1Items = useMemo(() => {
return categoryValues.filter(v => !isNodeOrDescendantAdded(v));
}, [categoryValues, isNodeOrDescendantAdded]);
const level2Items = useMemo(() => {
if (!level1Id) return [];
const parent = categoryValues.find(v => v.value_id === level1Id);
const children = parent?.children || [];
return children.filter(v => !isNodeOrDescendantAdded(v));
}, [categoryValues, level1Id, isNodeOrDescendantAdded]);
const level3Items = useMemo(() => {
if (!level2Id) return [];
const parent = categoryValues.find(v => v.value_id === level1Id);
const level2Parent = parent?.children?.find(v => v.value_id === level2Id);
const children = level2Parent?.children || [];
return children.filter(v => !addedValueIds.includes(v.value_id));
}, [categoryValues, level1Id, level2Id, addedValueIds]);
// 선택된 값 정보 계산
const getSelectedInfo = () => {
// 가장 깊은 레벨의 선택된 값
const selectedId = level3Id || level2Id || level1Id;
if (!selectedId) return null;
// 선택된 노드 찾기
const findNode = (nodes: CategoryValueNode[], id: number): CategoryValueNode | null => {
for (const node of nodes) {
if (node.value_id === id) return node;
if (node.children?.length) {
const found = findNode(node.children, id);
if (found) return found;
}
}
return null;
};
const node = findNode(categoryValues, selectedId);
if (!node) return null;
// 경로 생성
const pathParts: string[] = [];
const l1 = categoryValues.find(v => v.value_id === level1Id);
if (l1) pathParts.push(l1.value_label);
if (level2Id) {
const l2 = level2Items.find(v => v.value_id === level2Id);
if (l2) pathParts.push(l2.value_label);
}
if (level3Id) {
const l3 = level3Items.find(v => v.value_id === level3Id);
if (l3) pathParts.push(l3.value_label);
}
return {
valueId: selectedId,
valueCode: node.value_code, // value_code 추가 (canonical input 의 카테고리 옵션 매칭용)
valueLabel: node.value_label,
valuePath: pathParts.join(" > "),
};
};
const selectedInfo = getSelectedInfo();
// 매핑 추가/수정
const handleAddMapping = () => {
if (!selectedInfo || !newFormat.trim()) return;
const newMapping: CategoryFormatMapping = {
category_value_id: selectedInfo.valueId,
category_value_code: selectedInfo.valueCode, // canonical input 의 카테고리 옵션이 valueCode 를 value 로 사용하므로 매칭용 저장
category_value_label: selectedInfo.valueLabel,
category_value_path: selectedInfo.valuePath,
format: newFormat.trim(),
};
let updatedMappings: CategoryFormatMapping[];
if (editingId !== null) {
// 수정 모드: 기존 항목 교체
updatedMappings = mappings.map(m =>
m.category_value_id === editingId ? newMapping : m
);
} else {
// 추가 모드: 중복 체크
const exists = mappings.some(m => m.category_value_id === selectedInfo.valueId);
if (exists) {
alert("이미 추가된 카테고리입니다");
return;
}
updatedMappings = [...mappings, newMapping];
}
onChange({
...config,
category_mappings: updatedMappings,
});
// 초기화
setLevel1Id(null);
setLevel2Id(null);
setLevel3Id(null);
setNewFormat("");
setEditingId(null);
};
// 매핑 수정 모드 진입
const handleEditMapping = (mapping: CategoryFormatMapping) => {
// useEffect 초기화 방지 플래그 설정
isEditingRef.current = true;
// 해당 카테고리의 경로를 파싱해서 레벨별로 설정
const findParentIds = (nodes: CategoryValueNode[], targetId: number, path: number[] = []): number[] | null => {
for (const node of nodes) {
if (node.value_id === targetId) {
return path;
}
if (node.children?.length) {
const result = findParentIds(node.children, targetId, [...path, node.value_id]);
if (result) return result;
}
}
return null;
};
const parentPath = findParentIds(categoryValues, mapping.category_value_id);
if (parentPath && parentPath.length > 0) {
setLevel1Id(parentPath[0] || null);
if (parentPath.length === 2) {
// 3단계: 대분류 > 중분류 > 소분류
setLevel2Id(parentPath[1]);
setLevel3Id(mapping.category_value_id);
} else if (parentPath.length === 1) {
// 2단계: 대분류 > 중분류
setLevel2Id(mapping.category_value_id);
setLevel3Id(null);
} else {
setLevel2Id(null);
setLevel3Id(null);
}
} else {
// 루트 레벨 항목 (1단계)
setLevel1Id(mapping.category_value_id);
setLevel2Id(null);
setLevel3Id(null);
}
setNewFormat(mapping.format);
setEditingId(mapping.category_value_id);
// 다음 렌더링 사이클에서 플래그 해제
setTimeout(() => {
isEditingRef.current = false;
}, 0);
};
// 수정 취소
const handleCancelEdit = () => {
setLevel1Id(null);
setLevel2Id(null);
setLevel3Id(null);
setNewFormat("");
setEditingId(null);
};
// 매핑 삭제
const handleRemoveMapping = (valueId: number) => {
onChange({
...config,
category_mappings: mappings.filter(m => m.category_value_id !== valueId),
});
};
return (
<div className="space-y-4">
{/* 카테고리 선택 */}
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Popover open={categoryKeyOpen} onOpenChange={setCategoryKeyOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={categoryKeyOpen}
disabled={isPreview}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
<span className="truncate">
{categoryKey
? categoryOptions.find(o => o.displayName === categoryKey)?.displayLabel || categoryKey
: "카테고리 선택"}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<Command>
<CommandInput placeholder="카테고리 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs"> </CommandEmpty>
<CommandGroup>
{categoryOptions.map((opt) => (
<CommandItem
key={opt.displayName}
value={opt.displayLabel}
onSelect={() => {
onChange({ ...config, category_key: opt.displayName, category_mappings: [] });
setCategoryKeyOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", categoryKey === opt.displayName ? "opacity-100" : "opacity-0")} />
{opt.displayLabel}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 형식 설정 */}
{categoryKey && (
<div className="space-y-3 border-t pt-3">
<Label className="flex items-center gap-2 text-xs font-medium sm:text-sm">
<FolderTree className="h-4 w-4" />
</Label>
{/* 계층적 선택 UI */}
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{/* 대분류 선택 */}
<div className="min-w-[100px] flex-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Popover open={level1Open} onOpenChange={setLevel1Open}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={isPreview || loadingValues || level1Items.length === 0}
className="h-8 w-full justify-between text-xs"
>
<span className="truncate">
{loadingValues ? "로딩..." : level1Items.find(v => v.value_id === level1Id)?.value_label || "선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-0" align="start">
<Command>
<CommandInput placeholder="검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> </CommandEmpty>
<CommandGroup>
{level1Items.map((val) => (
<CommandItem
key={val.value_id}
value={val.value_label}
onSelect={() => {
setLevel1Id(val.value_id);
setLevel1Open(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", level1Id === val.value_id ? "opacity-100" : "opacity-0")} />
{val.value_label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 중분류 선택 (대분류 선택 후 하위가 있을 때만 표시) */}
{level1Id && level2Items.length > 0 && (
<div className="min-w-[100px] flex-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Popover open={level2Open} onOpenChange={setLevel2Open}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={isPreview}
className="h-8 w-full justify-between text-xs"
>
<span className="truncate">
{level2Items.find(v => v.value_id === level2Id)?.value_label || "선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-0" align="start">
<Command>
<CommandInput placeholder="검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> </CommandEmpty>
<CommandGroup>
{level2Items.map((val) => (
<CommandItem
key={val.value_id}
value={val.value_label}
onSelect={() => {
setLevel2Id(val.value_id);
setLevel2Open(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", level2Id === val.value_id ? "opacity-100" : "opacity-0")} />
{val.value_label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* 소분류 선택 (중분류 선택 후 하위가 있을 때만 표시) */}
{level2Id && level3Items.length > 0 && (
<div className="min-w-[100px] flex-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Popover open={level3Open} onOpenChange={setLevel3Open}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={isPreview}
className="h-8 w-full justify-between text-xs"
>
<span className="truncate">
{level3Items.find(v => v.value_id === level3Id)?.value_label || "선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-0" align="start">
<Command>
<CommandInput placeholder="검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> </CommandEmpty>
<CommandGroup>
{level3Items.map((val) => (
<CommandItem
key={val.value_id}
value={val.value_label}
onSelect={() => {
setLevel3Id(val.value_id);
setLevel3Open(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", level3Id === val.value_id ? "opacity-100" : "opacity-0")} />
{val.value_label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
</div>
{/* 형식 입력 + 추가/수정 버튼 */}
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Input
value={newFormat}
onChange={(e) => setNewFormat(e.target.value.toUpperCase())}
placeholder="예: ITM, VLV, PIP"
disabled={isPreview || !selectedInfo}
className="h-8 text-xs"
maxLength={10}
/>
</div>
<div className="flex items-end gap-1">
{editingId !== null && (
<Button
size="sm"
variant="outline"
onClick={handleCancelEdit}
className="h-8 text-xs"
>
</Button>
)}
<Button
size="sm"
onClick={handleAddMapping}
disabled={isPreview || !selectedInfo || !newFormat.trim()}
className="h-8"
>
{editingId !== null ? <Check className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
</Button>
</div>
</div>
{/* 선택된 경로 표시 */}
{selectedInfo && (
<p className="text-[10px] text-muted-foreground">
{editingId !== null ? "수정 중: " : "선택: "}{selectedInfo.valuePath}
</p>
)}
</div>
{/* 추가된 매핑 목록 */}
{mappings.length > 0 && (
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> ( )</Label>
<div className="space-y-1">
{mappings.map((m) => (
<div
key={m.category_value_id}
className={cn(
"flex cursor-pointer items-center justify-between rounded px-2 py-1 transition-colors hover:bg-muted",
editingId === m.category_value_id ? "bg-primary/10 ring-1 ring-primary" : "bg-muted/50"
)}
onClick={() => !isPreview && handleEditMapping(m)}
>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{m.category_value_path || m.category_value_label}</span>
<span></span>
<span className="font-mono font-medium">{m.format}</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleRemoveMapping(m.category_value_id);
}}
disabled={isPreview}
className="h-5 w-5"
>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
))}
</div>
</div>
)}
<p className="text-[10px] text-muted-foreground">
</p>
</div>
)}
</div>
);
};
function ReferenceConfigSection({
config,
onChange,
@@ -42,7 +42,7 @@ import {
Filter,
} from "lucide-react";
import { tableTypeApi } from "@/lib/api/screen";
import { commonCodeApi } from "@/lib/api/commonCode";
import { getCodeOptions } from "@/lib/api/commonCode";
import { apiClient, getCurrentUser, UserInfo, getFullImageUrl } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
@@ -57,80 +57,6 @@ import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility } from "@/types/table-options";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig } from "@/types/screen-management";
/**
* 🔗 ( )
*/
interface CascadingDropdownInFormProps {
config: CascadingDropdownConfig;
parentValue?: string | number | null;
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
className?: string;
}
const CascadingDropdownInForm: React.FC<CascadingDropdownInFormProps> = ({
config,
parentValue,
value,
onChange,
placeholder,
className,
}) => {
const { options, loading } = useCascadingDropdown({
config,
parentValue,
});
const getPlaceholder = () => {
if (!parentValue) {
return config.empty_parent_message || "상위 항목을 먼저 선택하세요";
}
if (loading) {
return config.loading_message || "로딩 중...";
}
if (options.length === 0) {
return config.no_options_message || "선택 가능한 항목이 없습니다";
}
return placeholder || "선택하세요";
};
const isDisabled = !parentValue || loading;
return (
<Select value={value || ""} onValueChange={(newValue) => onChange?.(newValue)} disabled={isDisabled}>
<SelectTrigger className={className}>
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : (
<SelectValue placeholder={getPlaceholder()} />
)}
</SelectTrigger>
<SelectContent>
{options.length === 0 ? (
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
{!parentValue
? config.empty_parent_message || "상위 항목을 먼저 선택하세요"
: config.no_options_message || "선택 가능한 항목이 없습니다"}
</div>
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
};
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
interface FileInfo {
// AttachedFileInfo 기본 속성들
@@ -285,7 +211,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
}
try {
const response = await commonCodeApi.options.getOptions(categoryCode);
const response = await getCodeOptions(categoryCode);
if (response.success && response.data) {
const options = response.data.map((code) => ({
value: code.value,
@@ -916,12 +842,12 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
);
// 컬럼의 코드 카테고리 가져오기
const getColumnCodeCategory = useCallback(
const getColumnCodeInfo = useCallback(
(columnName: string) => {
const column = component.columns.find((col) => col.columnName === columnName);
// webTypeConfig가 CodeTypeConfig인 경우 codeCategory 반환
// webTypeConfig가 CodeTypeConfig인 경우 codeInfo 반환
const webTypeConfig = column?.webTypeConfig as any;
return webTypeConfig?.codeCategory || column?.codeCategory;
return webTypeConfig?.codeInfo || column?.codeInfo;
},
[component.columns],
);
@@ -1674,26 +1600,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
);
case "select":
case "dropdown":
// 🆕 연쇄 드롭다운 처리
const cascadingConfig = detailSettings?.cascading as CascadingDropdownConfig | undefined;
if (cascadingConfig?.enabled) {
const parentValue = editFormData[cascadingConfig.parent_field];
return (
<div>
<CascadingDropdownInForm
config={cascadingConfig}
parentValue={parentValue}
value={value}
onChange={(newValue: any) => handleEditFormChange(column.columnName, newValue)}
placeholder={commonProps.placeholder}
className={commonProps.className}
/>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-muted-foreground">{advancedConfig.helpText}</p>}
</div>
);
}
case "dropdown": {
// 상세 설정에서 옵션 목록 가져오기
const options = detailSettings?.options || [];
if (options.length > 0) {
@@ -1717,6 +1624,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
} else {
return <Input {...commonProps} placeholder={`${column.label} 선택... (옵션 설정 필요)`} readOnly />;
}
}
case "radio":
// 상세 설정에서 옵션 목록 가져오기
@@ -1932,25 +1840,6 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
case "select":
case "dropdown":
// 🆕 연쇄 드롭다운 처리
const cascadingConfigAdd = detailSettings?.cascading as CascadingDropdownConfig | undefined;
if (cascadingConfigAdd?.enabled) {
const parentValueAdd = addFormData[cascadingConfigAdd.parent_field];
return (
<div>
<CascadingDropdownInForm
config={cascadingConfigAdd}
parentValue={parentValueAdd}
value={value}
onChange={(newValue: any) => handleAddFormChange(column.columnName, newValue)}
placeholder={commonProps.placeholder}
className={commonProps.className}
/>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-muted-foreground">{advancedConfig.helpText}</p>}
</div>
);
}
// 상세 설정에서 옵션 목록 가져오기
const optionsAdd = detailSettings?.options || [];
if (optionsAdd.length > 0) {
@@ -2049,13 +1938,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
case "code":
// 코드 카테고리에서 코드 옵션 가져오기
const codeCategory = getColumnCodeCategory(column.columnName);
if (codeCategory) {
const codeOptionsForCategory = codeOptions[codeCategory] || [];
const codeInfo = getColumnCodeInfo(column.columnName);
if (codeInfo) {
const codeOptionsForCategory = codeOptions[codeInfo] || [];
// 코드 옵션이 없으면 로드
if (codeOptionsForCategory.length === 0) {
loadCodeOptions(codeCategory);
loadCodeOptions(codeInfo);
}
return (
@@ -16,8 +16,7 @@ import { useAuth } from "@/hooks/useAuth";
import { uploadFilesAndCreateData } from "@/lib/api/file";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig, LayerDefinition } from "@/types/screen-management";
import { LayerDefinition } from "@/types/screen-management";
import {
ComponentData,
WidgetComponent,
@@ -52,96 +51,6 @@ import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
/**
* 🔗
* InteractiveScreenViewer
*/
interface CascadingDropdownWrapperProps {
/** 직접 설정 방식 */
config?: CascadingDropdownConfig;
/** 공통 관리 방식 (관계 코드) */
relationCode?: string;
/** 부모 필드명 (relationCode 사용 시 필요) */
parentFieldName?: string;
parentValue?: string | number | null;
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
required?: boolean;
}
const CascadingDropdownWrapper: React.FC<CascadingDropdownWrapperProps> = ({
config,
relationCode,
parentFieldName,
parentValue,
value,
onChange,
placeholder,
disabled,
required,
}) => {
const { options, loading, error, relationConfig } = useCascadingDropdown({
config,
relationCode,
parentValue,
});
// 실제 사용할 설정 (직접 설정 또는 API에서 가져온 설정)
const effectiveConfig = config || relationConfig;
// 부모 값이 없을 때 메시지
const getPlaceholder = () => {
if (!parentValue) {
return effectiveConfig?.empty_parent_message || "상위 항목을 먼저 선택하세요";
}
if (loading) {
return effectiveConfig?.loading_message || "로딩 중...";
}
if (options.length === 0) {
return effectiveConfig?.no_options_message || "선택 가능한 항목이 없습니다";
}
return placeholder || "선택하세요";
};
const isDisabled = disabled || !parentValue || loading;
return (
<Select
value={value || ""}
onValueChange={(newValue) => onChange?.(newValue)}
disabled={isDisabled}
>
<SelectTrigger className="h-full w-full">
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : (
<SelectValue placeholder={getPlaceholder()} />
)}
</SelectTrigger>
<SelectContent>
{options.length === 0 ? (
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
{!parentValue
? config?.empty_parent_message || "상위 항목을 먼저 선택하세요"
: config?.no_options_message || "선택 가능한 항목이 없습니다"}
</div>
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
};
interface InteractiveScreenViewerProps {
component: ComponentData;
allComponents: ComponentData[];
@@ -906,54 +815,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
searchable: config?.searchable,
placeholder: config?.placeholder,
defaultValue: config?.default_value,
cascading: config?.cascading,
},
});
const finalPlaceholder = config?.placeholder || placeholder || "선택하세요...";
// 🆕 연쇄 드롭다운 처리 (방법 1: 관계 코드 방식 - 권장)
if (config?.cascading_relation_code && config?.cascading_parent_field) {
const parentFieldValue = formData[config.cascading_parent_field];
console.log("🔗 연쇄 드롭다운 (관계코드 방식):", {
relationCode: config.cascading_relation_code,
parentField: config.cascading_parent_field,
parentValue: parentFieldValue,
});
return applyStyles(
<CascadingDropdownWrapper
relationCode={config.cascading_relation_code}
parentFieldName={config.cascading_parent_field}
parentValue={parentFieldValue}
value={currentValue}
onChange={(value) => updateFormData(fieldName, value)}
placeholder={finalPlaceholder}
disabled={isReadonly}
required={required}
/>,
);
}
// 🔄 연쇄 드롭다운 처리 (방법 2: 직접 설정 방식 - 레거시)
if (config?.cascading?.enabled) {
const cascadingConfig = config.cascading;
const parentValue = formData[cascadingConfig.parent_field];
return applyStyles(
<CascadingDropdownWrapper
config={cascadingConfig}
parentValue={parentValue}
value={currentValue}
onChange={(value) => updateFormData(fieldName, value)}
placeholder={finalPlaceholder}
disabled={isReadonly}
required={required}
/>,
);
}
// 일반 Select
const options = config?.options || [
{ label: "옵션 1", value: "option1" },
@@ -1428,7 +1294,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
console.log(`🔍 [InteractiveScreenViewer] Code 위젯 렌더링:`, {
componentId: widget.id,
columnName: widget.column_name,
codeCategory: config?.codeCategory,
codeInfo: config?.codeInfo,
menuObjid,
hasMenuObjid: !!menuObjid,
});
@@ -1453,7 +1319,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}}
config={{
...config,
codeCategory: config?.codeCategory,
codeInfo: config?.codeInfo,
isCodeType: true, // 코드 타입임을 명시
}}
onEvent={(event: string, data: any) => {
+13 -13
View File
@@ -294,7 +294,7 @@ function buildAutoCrudView(
widgetType,
columnName: column.columnName,
columnLabel: column.columnLabel,
codeCategory: column.codeCategory,
codeInfo: column.codeInfo,
inputType: column.inputType,
required: column.required,
detailSettings: column.detailSettings,
@@ -1926,7 +1926,7 @@ export default function InvyoneStudio({
required: col.required !== undefined ? col.required : col.is_nullable === "NO",
columnDefault: col.column_default,
characterMaximumLength: col.character_maximum_length,
codeCategory: col.code_category,
codeInfo: col.code_info,
codeValue: col.code_value,
// 엔티티 타입용 참조 테이블 정보 (detailSettings에서 추출)
referenceTable: detailSettings?.referenceTable || col.reference_table,
@@ -2011,7 +2011,7 @@ export default function InvyoneStudio({
required: col.required !== undefined ? col.required : col.is_nullable === "NO",
columnDefault: col.column_default,
characterMaximumLength: col.character_maximum_length,
codeCategory: col.code_category,
codeInfo: col.code_info,
codeValue: col.code_value,
referenceTable: detailSettings?.referenceTable || col.reference_table,
referenceColumn: detailSettings?.referenceColumn || col.reference_column,
@@ -4459,7 +4459,7 @@ export default function InvyoneStudio({
widgetType: column.widgetType,
columnName: column.columnName,
columnLabel: column.columnLabel,
codeCategory: column.codeCategory,
codeInfo: column.codeInfo,
inputType: column.inputType,
required: column.required,
detailSettings: column.detailSettings,
@@ -4632,7 +4632,7 @@ export default function InvyoneStudio({
widgetType: column.widgetType,
columnName: column.columnName,
columnLabel: column.columnLabel,
codeCategory: column.codeCategory,
codeInfo: column.codeInfo,
inputType: column.inputType,
required: column.required,
detailSettings: column.detailSettings,
@@ -4766,7 +4766,7 @@ export default function InvyoneStudio({
widgetType: column.widgetType,
columnName: column.columnName,
columnLabel: column.columnLabel,
codeCategory: column.codeCategory,
codeInfo: column.codeInfo,
inputType: column.inputType,
required: column.required,
detailSettings: column.detailSettings,
@@ -5016,7 +5016,7 @@ export default function InvyoneStudio({
};
case "code":
return {
codeCategory: "", // 기본값, 실제로는 컬럼 정보에서 가져옴
codeInfo: "", // 기본값, 실제로는 컬럼 정보에서 가져옴
placeholder: "선택하세요",
options: [], // 기본 빈 배열, 실제로는 API에서 로드
};
@@ -5073,7 +5073,7 @@ export default function InvyoneStudio({
widgetType: column.widgetType,
columnName: column.columnName,
columnLabel: column.columnLabel,
codeCategory: column.codeCategory,
codeInfo: column.codeInfo,
inputType: column.inputType,
required: column.required,
detailSettings: column.detailSettings, // 엔티티 참조 정보 전달
@@ -5110,8 +5110,8 @@ export default function InvyoneStudio({
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
codeCategory: column.codeCategory,
column.codeInfo && {
codeInfo: column.codeInfo,
}),
// 엔티티 조인 정보 저장
...(isEntityJoinColumn && {
@@ -5140,7 +5140,7 @@ export default function InvyoneStudio({
widgetType: column.widgetType,
columnName: column.columnName,
columnLabel: column.columnLabel,
codeCategory: column.codeCategory,
codeInfo: column.codeInfo,
inputType: column.inputType,
required: column.required,
detailSettings: column.detailSettings, // 엔티티 참조 정보 전달
@@ -5176,8 +5176,8 @@ export default function InvyoneStudio({
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
codeCategory: column.codeCategory,
column.codeInfo && {
codeInfo: column.codeInfo,
}),
// 엔티티 조인 정보 저장
...(isEntityJoinColumn && {
@@ -133,7 +133,7 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
// 선택된 컴포넌트의 데이터 소스 정보 추출
const dataSourceInfo = useMemo<{
type: DataSourceType;
codeCategory?: string;
codeInfo?: string;
// 엔티티: 원본 테이블.컬럼 (entity-reference API용)
originTable?: string;
originColumn?: string;
@@ -169,15 +169,15 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
return { type: "category", categoryTable, categoryColumn };
}
// 2. 코드 카테고리 확인 (V2: source === "code" + codeGroup, 기존: codeCategory)
const codeCategory =
// 2. 코드 카테고리 확인 (V2: source === "code" + codeGroup, 기존: codeInfo)
const codeInfo =
config.codeGroup || // V2 컴포넌트
config.codeCategory ||
comp.codeCategory ||
detailSettings.codeCategory;
config.codeInfo ||
comp.codeInfo ||
detailSettings.codeInfo;
if (source === "code" || codeCategory) {
return { type: "code", codeCategory };
if (source === "code" || codeInfo) {
return { type: "code", codeInfo };
}
// 3. 엔티티 참조 확인 (V2: source === "entity")
@@ -213,8 +213,8 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
// 의존성 안정화를 위한 직렬화 키
const dataSourceKey = useMemo(() => {
const { type, categoryTable, categoryColumn, codeCategory, originTable, originColumn, referenceTable, referenceColumn } = dataSourceInfo;
return `${type}|${categoryTable || ""}|${categoryColumn || ""}|${codeCategory || ""}|${originTable || ""}|${originColumn || ""}|${referenceTable || ""}|${referenceColumn || ""}`;
const { type, categoryTable, categoryColumn, codeInfo, originTable, originColumn, referenceTable, referenceColumn } = dataSourceInfo;
return `${type}|${categoryTable || ""}|${categoryColumn || ""}|${codeInfo || ""}|${originTable || ""}|${originColumn || ""}|${referenceTable || ""}|${referenceColumn || ""}`;
}, [dataSourceInfo]);
// 컴포넌트 선택 시 옵션 목록 로드 (카테고리, 코드, 엔티티, 정적)
@@ -273,9 +273,9 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
} else {
setOptions([]);
}
} else if (dataSourceInfo.type === "code" && dataSourceInfo.codeCategory) {
} else if (dataSourceInfo.type === "code" && dataSourceInfo.codeInfo) {
// 코드 카테고리에서 옵션 로드
const codes = await getCodesByCategory(dataSourceInfo.codeCategory);
const codes = await getCodesByCategory(dataSourceInfo.codeInfo);
if (cancelled) return;
setOptions(codes.map((code) => ({
value: code.code,
@@ -462,12 +462,12 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
</Select>
{/* 데이터 소스 표시 */}
{dataSourceInfo.type === "code" && dataSourceInfo.codeCategory && (
{dataSourceInfo.type === "code" && dataSourceInfo.codeInfo && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Code2 className="h-3 w-3" />
<span>:</span>
<Badge variant="secondary" className="text-[10px]">
{dataSourceInfo.codeCategory}
{dataSourceInfo.codeInfo}
</Badge>
</div>
)}
@@ -225,11 +225,11 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
}
}
} else if (source === "code" && config.codeGroup) {
// 공통코드 소스
const response = await apiClient.get(`/common-codes/categories/${config.codeGroup}/options`);
const data = response.data;
// 공통코드 소스 — 새 구조 (code_info + code_detail)
const { getCodeOptions } = await import("@/lib/api/commonCode");
const data = await getCodeOptions(config.codeGroup);
if (data.success && data.data) {
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
fetchedOptions = data.data.map((item) => ({
value: item.value,
label: item.label,
}));
@@ -951,7 +951,7 @@ function TableColumnAccordion({
columnLabel: columns.find(c => c.column_name === editingJoin.columnName)?.display_name || editingJoin.columnName,
web_type: "entity",
detailSettings: JSON.stringify({}),
codeCategory: "",
codeInfo: "",
codeValue: "",
referenceTable: editingJoin.referenceTable,
referenceColumn: editingJoin.referenceColumn,
@@ -311,8 +311,8 @@ export function TableSettingModal({
if (col.reference_table && effectiveInputType !== "entity") {
effectiveInputType = "entity";
}
// code_category/code_value가 설정되어 있으면 input_type은 code여야 함
if (col.code_category && effectiveInputType !== "code") {
// code_info/code_value가 설정되어 있으면 input_type은 code여야 함
if (col.code_info && effectiveInputType !== "code") {
effectiveInputType = "code";
}
@@ -452,7 +452,7 @@ export function TableSettingModal({
[columnName]: {
...prev[columnName],
input_type: value,
code_category: "",
code_info: "",
code_value: "",
},
}));
@@ -499,8 +499,8 @@ export function TableSettingModal({
if (mergedColumn.reference_table && currentInputType !== "entity") {
currentInputType = "entity";
}
// code_category가 설정되어 있으면 input_type을 code로 자동 설정
if (mergedColumn.code_category && currentInputType !== "code") {
// code_info가 설정되어 있으면 input_type을 code로 자동 설정
if (mergedColumn.code_info && currentInputType !== "code") {
currentInputType = "code";
}
@@ -553,9 +553,9 @@ export function TableSettingModal({
const columnSetting: ColumnSettings = {
columnName: columnName,
columnLabel: mergedColumn.display_name || originalColumn.display_name || "",
inputType: currentInputType || "text", // reference_table/code_category가 설정된 경우 자동 보정된 값 사용
inputType: currentInputType || "text", // reference_table/code_info가 설정된 경우 자동 보정된 값 사용
detailSettings: finalDetailSettings,
codeCategory: mergedColumn.code_category || originalColumn.code_category || "",
codeInfo: mergedColumn.code_info || originalColumn.code_info || "",
codeValue: mergedColumn.code_value || originalColumn.code_value || "",
referenceTable: mergedColumn.reference_table || "",
referenceColumn: mergedColumn.reference_column || "",
@@ -10,7 +10,6 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { Trash2, Plus, ChevronDown, ChevronRight } from "lucide-react";
import { ColumnFilter, DataFilterConfig } from "@/types/screen-management";
import { V2ColumnInfo } from "@/types/table-management";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
interface DataFilterConfigPanelProps {
tableName?: string;
@@ -90,12 +89,10 @@ export function DataFilterConfigPanel({
columns = [],
config,
onConfigChange,
menuObjid, // 🆕 메뉴 OBJID
}: DataFilterConfigPanelProps) {
console.log("🔍 [DataFilterConfigPanel] 초기화:", {
tableName,
columnsCount: columns.length,
menuObjid,
sampleColumns: columns.slice(0, 3),
});
@@ -107,66 +104,12 @@ export function DataFilterConfigPanel({
},
);
// 카테고리 값 캐시 (컬럼명 -> 카테고리 값 목록)
const [categoryValues, setCategoryValues] = useState<Record<string, Array<{ value: string; label: string }>>>({});
const [loadingCategories, setLoadingCategories] = useState<Record<string, boolean>>({});
useEffect(() => {
if (config) {
setLocalConfig(config);
// 🆕 기존 필터 중 카테고리 타입인 것들의 값을 로드
config.filters?.forEach((filter) => {
if (filter.value_type === "category" && filter.column_name) {
console.log("🔄 기존 카테고리 필터 감지, 값 로딩:", filter.column_name);
loadCategoryValues(filter.column_name);
}
});
}
}, [config]);
// 카테고리 값 로드
const loadCategoryValues = async (columnName: string) => {
if (!tableName || categoryValues[columnName] || loadingCategories[columnName]) {
return; // 이미 로드되었거나 로딩 중이면 스킵
}
setLoadingCategories((prev) => ({ ...prev, [columnName]: true }));
try {
console.log("🔍 카테고리 값 로드 시작:", {
tableName,
columnName,
menuObjid,
});
const response = await getCategoryValues(
tableName,
columnName,
false, // includeInactive
menuObjid, // 🆕 메뉴 OBJID 전달
);
console.log("📦 카테고리 값 로드 응답:", response);
if (response.success && 'data' in response && response.data) {
const values = response.data.map((item: any) => ({
value: item.value_code,
label: item.value_label,
}));
console.log("✅ 카테고리 값 설정:", { columnName, valuesCount: values.length });
setCategoryValues((prev) => ({ ...prev, [columnName]: values }));
} else {
console.warn("⚠️ 카테고리 값 로드 실패 또는 데이터 없음:", response);
}
} catch (error) {
console.error(`❌ 카테고리 값 로드 실패 (${columnName}):`, error);
} finally {
setLoadingCategories((prev) => ({ ...prev, [columnName]: false }));
}
};
const handleEnabledChange = (enabled: boolean) => {
const newConfig = { ...localConfig, enabled };
setLocalConfig(newConfig);
@@ -220,10 +163,10 @@ export function DataFilterConfigPanel({
return column?.input_type || column?.web_type || "text";
};
// 카테고리/코드 타입인지 확인
const isCategoryOrCodeColumn = (columnName: string) => {
// 코드 타입인지 확인
const isCodeColumn = (columnName: string) => {
const inputType = getColumnInputType(columnName);
return inputType === "category" || inputType === "code";
return inputType === "code";
};
return (
@@ -318,12 +261,8 @@ export function DataFilterConfigPanel({
});
// 컬럼 타입에 따라 valueType 자동 설정
let valueType: "static" | "category" | "code" = "static";
if (column?.input_type === "category") {
valueType = "category";
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
loadCategoryValues(value); // 카테고리 값 로드
} else if (column?.input_type === "code") {
let valueType: "static" | "code" = "static";
if (column?.input_type === "code") {
valueType = "code";
}
@@ -483,8 +422,8 @@ export function DataFilterConfigPanel({
</>
)}
{/* 값 타입 선택 (카테고리/코드 컬럼 또는 date_range_contains) */}
{(isCategoryOrCodeColumn(filter.column_name) || filter.operator === "date_range_contains") && (
{/* 값 타입 선택 (코드 컬럼 또는 date_range_contains) */}
{(isCodeColumn(filter.column_name) || filter.operator === "date_range_contains") && (
<div>
<Label className="text-xs"> </Label>
<Select
@@ -521,12 +460,7 @@ export function DataFilterConfigPanel({
{filter.operator === "date_range_contains" && (
<SelectItem value="dynamic"> ( )</SelectItem>
)}
{isCategoryOrCodeColumn(filter.column_name) && (
<>
<SelectItem value="category"> </SelectItem>
<SelectItem value="code"> </SelectItem>
</>
)}
{isCodeColumn(filter.column_name) && <SelectItem value="code"> </SelectItem>}
</SelectContent>
</Select>
</div>
@@ -538,49 +472,7 @@ export function DataFilterConfigPanel({
!(filter.operator === "date_range_contains" && filter.value_type === "dynamic") && (
<div>
<Label className="text-xs"></Label>
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
{filter.value_type === "category" && categoryValues[filter.column_name] ? (
<Select
value={
filter.operator === "in" || filter.operator === "not_in"
? Array.isArray(filter.value) && filter.value.length > 0
? filter.value[0]
: ""
: Array.isArray(filter.value)
? filter.value[0]
: filter.value
}
onValueChange={(selectedValue) => {
if (filter.operator === "in" || filter.operator === "not_in") {
const currentValues = Array.isArray(filter.value) ? filter.value : [];
if (currentValues.includes(selectedValue)) {
handleFilterChange(
filter.id,
"value",
currentValues.filter((v) => v !== selectedValue),
);
} else {
handleFilterChange(filter.id, "value", [...currentValues, selectedValue]);
}
} else {
handleFilterChange(filter.id, "value", selectedValue);
}
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue
placeholder={loadingCategories[filter.column_name] ? "로딩 중..." : "값 선택"}
/>
</SelectTrigger>
<SelectContent>
{categoryValues[filter.column_name].map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : filter.operator === "in" || filter.operator === "not_in" ? (
{filter.operator === "in" || filter.operator === "not_in" ? (
<Input
value={Array.isArray(filter.value) ? filter.value.join(", ") : filter.value}
onChange={(e) => {
@@ -616,9 +508,7 @@ export function DataFilterConfigPanel({
/>
)}
<p className="text-muted-foreground mt-1 text-[10px]">
{filter.value_type === "category" && categoryValues[filter.column_name]
? "카테고리 값을 선택하세요"
: filter.operator === "in" || filter.operator === "not_in"
{filter.operator === "in" || filter.operator === "not_in"
? "여러 값은 쉼표(,)로 구분하세요"
: filter.operator === "between"
? "시작과 종료 값을 ~로 구분하세요"
@@ -8,11 +8,9 @@ import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Trash2, List, Link2, ExternalLink } from "lucide-react";
import { Plus, Trash2, List } from "lucide-react";
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
import { WidgetComponent, SelectTypeConfig } from "@/types/screen";
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
import Link from "next/link";
interface SelectOption {
label: string;
@@ -41,18 +39,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
required: config.required || false,
readonly: config.readonly || false,
empty_message: config.empty_message || "선택 가능한 옵션이 없습니다",
cascading_relation_code: config.cascading_relation_code,
cascading_parent_field: config.cascading_parent_field,
});
// 연쇄 드롭다운 설정 상태
const [cascadingEnabled, setCascadingEnabled] = useState(!!config.cascading_relation_code);
const [selectedRelationCode, setSelectedRelationCode] = useState(config.cascading_relation_code || "");
const [selectedParentField, setSelectedParentField] = useState(config.cascading_parent_field || "");
// 연쇄 관계 목록
const [relationList, setRelationList] = useState<CascadingRelation[]>([]);
const [loadingRelations, setLoadingRelations] = useState(false);
// 새 옵션 추가용 상태
const [newOptionLabel, setNewOptionLabel] = useState("");
@@ -80,7 +67,6 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
required: currentConfig.required || false,
readonly: currentConfig.readonly || false,
empty_message: currentConfig.empty_message || "선택 가능한 옵션이 없습니다",
cascading_relation_code: currentConfig.cascading_relation_code,
});
// 입력 필드 로컬 상태도 동기화
@@ -89,33 +75,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
empty_message: currentConfig.empty_message || "",
});
// 연쇄 드롭다운 설정 동기화
setCascadingEnabled(!!currentConfig.cascading_relation_code);
setSelectedRelationCode(currentConfig.cascading_relation_code || "");
setSelectedParentField(currentConfig.cascading_parent_field || "");
}, [widget.web_type_config]);
// 연쇄 관계 목록 로드
useEffect(() => {
if (cascadingEnabled && relationList.length === 0) {
loadRelationList();
}
}, [cascadingEnabled]);
// 연쇄 관계 목록 로드 함수
const loadRelationList = async () => {
setLoadingRelations(true);
try {
const response = await cascadingRelationApi.getList("Y");
if (response.success && response.data) {
setRelationList(response.data);
}
} catch (error) {
console.error("연쇄 관계 목록 로드 실패:", error);
} finally {
setLoadingRelations(false);
}
};
// 설정 업데이트 핸들러
const updateConfig = (field: keyof SelectTypeConfig, value: any) => {
@@ -124,38 +84,6 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
onUpdateProperty("web_type_config", newConfig);
};
// 연쇄 드롭다운 활성화/비활성화
const handleCascadingToggle = (enabled: boolean) => {
setCascadingEnabled(enabled);
if (!enabled) {
// 비활성화 시 관계 코드 제거
setSelectedRelationCode("");
const newConfig = { ...localConfig, cascading_relation_code: undefined };
setLocalConfig(newConfig);
onUpdateProperty("web_type_config", newConfig);
} else {
// 활성화 시 관계 목록 로드
loadRelationList();
}
};
// 연쇄 관계 선택
const handleRelationSelect = (code: string) => {
setSelectedRelationCode(code);
const newConfig = { ...localConfig, cascading_relation_code: code || undefined };
setLocalConfig(newConfig);
onUpdateProperty("web_type_config", newConfig);
};
// 부모 필드 선택
const handleParentFieldChange = (field: string) => {
setSelectedParentField(field);
const newConfig = { ...localConfig, cascading_parent_field: field || undefined };
setLocalConfig(newConfig);
onUpdateProperty("web_type_config", newConfig);
};
// 옵션 추가
const addOption = () => {
if (!newOptionLabel.trim() || !newOptionValue.trim()) return;
@@ -241,9 +169,6 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
updateConfig("options", defaultOptionSets[setName]);
};
// 선택된 관계 정보
const selectedRelation = relationList.find(r => r.relation_code === selectedRelationCode);
return (
<Card>
<CardHeader>
@@ -315,122 +240,23 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</div>
</div>
{/* 연쇄 드롭다운 설정 */}
{/* 기본 옵션 세트 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4" />
<h4 className="text-sm font-medium"> </h4>
</div>
<Switch
checked={cascadingEnabled}
onCheckedChange={handleCascadingToggle}
/>
<h4 className="text-sm font-medium"> </h4>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("yesno")} className="text-xs">
/
</Button>
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("status")} className="text-xs">
</Button>
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("priority")} className="text-xs">
</Button>
</div>
<p className="text-muted-foreground text-xs">
. (: 창고 )
</p>
{cascadingEnabled && (
<div className="space-y-3 rounded-md border p-3">
{/* 관계 선택 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={selectedRelationCode}
onValueChange={handleRelationSelect}
>
<SelectTrigger className="text-xs">
<SelectValue placeholder={loadingRelations ? "로딩 중..." : "관계 선택"} />
</SelectTrigger>
<SelectContent>
{relationList.map((relation) => (
<SelectItem key={relation.relation_code} value={relation.relation_code}>
<div className="flex flex-col">
<span>{relation.relation_name}</span>
<span className="text-muted-foreground text-xs">
{relation.parent_table} {relation.child_table}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
.
</p>
</div>
{/* 부모 필드 설정 */}
{selectedRelationCode && (
<div className="space-y-2">
<Label className="text-xs"> ( )</Label>
<Input
value={selectedParentField}
onChange={(e) => handleParentFieldChange(e.target.value)}
placeholder="예: warehouse_code"
className="text-xs"
/>
<p className="text-muted-foreground text-xs">
.
</p>
</div>
)}
{/* 선택된 관계 정보 표시 */}
{selectedRelation && (
<div className="bg-muted/50 space-y-2 rounded-md p-2">
<div className="text-xs">
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.parent_table}</span>
<span className="text-muted-foreground"> ({selectedRelation.parent_value_column})</span>
</div>
<div className="text-xs">
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.child_table}</span>
<span className="text-muted-foreground">
{" "}({selectedRelation.child_filter_column} {selectedRelation.child_value_column})
</span>
</div>
{selectedRelation.description && (
<div className="text-muted-foreground text-xs">{selectedRelation.description}</div>
)}
</div>
)}
{/* 관계 관리 페이지 링크 */}
<div className="flex justify-end">
<Link href="/admin/cascading-relations" target="_blank">
<Button variant="link" size="sm" className="h-auto p-0 text-xs">
<ExternalLink className="mr-1 h-3 w-3" />
</Button>
</Link>
</div>
</div>
)}
</div>
{/* 기본 옵션 세트 (연쇄 드롭다운 비활성화 시에만 표시) */}
{!cascadingEnabled && (
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("yesno")} className="text-xs">
/
</Button>
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("status")} className="text-xs">
</Button>
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("priority")} className="text-xs">
</Button>
</div>
</div>
)}
{/* 옵션 관리 (연쇄 드롭다운 비활성화 시에만 표시) */}
{!cascadingEnabled && (
{/* 옵션 관리 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
@@ -513,10 +339,8 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</div>
</div>
</div>
)}
{/* 기본값 설정 (연쇄 드롭다운 비활성화 시에만 표시) */}
{!cascadingEnabled && (
{/* 기본값 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"></h4>
@@ -539,7 +363,6 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</select>
</div>
</div>
)}
{/* 상태 설정 */}
<div className="space-y-3">
@@ -574,8 +397,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</div>
</div>
{/* 미리보기 (연쇄 드롭다운 비활성화 시에만 표시) */}
{!cascadingEnabled && (
{/* 미리보기 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"></h4>
<div className="bg-muted/50 rounded-md border p-3">
@@ -602,7 +424,6 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
@@ -5,10 +5,8 @@ import { Button } from "@/components/ui/button";
import { Search, X } from "lucide-react";
import { ModernDatePicker } from "./ModernDatePicker";
import { cn } from "@/lib/utils";
import { commonCodeApi } from "@/lib/api/commonCode";
import { EntityReferenceAPI } from "@/lib/api/entityReference";
import type { DataTableFilter } from "@/types/screen-legacy-backup";
import type { CodeInfo } from "@/types/commonCode";
interface AdvancedSearchFiltersProps {
filters: DataTableFilter[];
@@ -80,7 +78,7 @@ export const AdvancedSearchFilters: React.FC<AdvancedSearchFiltersProps> = ({
widgetType: col.webType || col.web_type,
label: col.displayName || col.column_label || col.columnName || col.column_name,
gridColumns: 3,
codeCategory: col.codeCategory || col.code_category,
codeInfo: col.codeInfo || col.code_info,
referenceTable: col.referenceTable || col.reference_table,
referenceColumn: col.referenceColumn || col.reference_column,
displayColumn: col.displayColumn || col.display_column,
@@ -94,24 +92,24 @@ export const AdvancedSearchFilters: React.FC<AdvancedSearchFiltersProps> = ({
// 코드 데이터 로드
const loadCodeOptions = useCallback(
async (codeCategory: string) => {
if (codeOptions[codeCategory] || loadingStates[codeCategory]) return;
async (codeInfo: string) => {
if (codeOptions[codeInfo] || loadingStates[codeInfo]) return;
setLoadingStates((prev) => ({ ...prev, [codeCategory]: true }));
setLoadingStates((prev) => ({ ...prev, [codeInfo]: true }));
try {
const response = await EntityReferenceAPI.getCodeReferenceData(codeCategory, { limit: 1000 });
const response = await EntityReferenceAPI.getCodeReferenceData(codeInfo, { limit: 1000 });
const options = response.options.map((option) => ({
value: option.value,
label: option.label,
}));
setCodeOptions((prev) => ({ ...prev, [codeCategory]: options }));
setCodeOptions((prev) => ({ ...prev, [codeInfo]: options }));
} catch (error) {
// console.error(`코드 카테고리 ${codeCategory} 로드 실패:`, error);
setCodeOptions((prev) => ({ ...prev, [codeCategory]: [] }));
// console.error(`코드 카테고리 ${codeInfo} 로드 실패:`, error);
setCodeOptions((prev) => ({ ...prev, [codeInfo]: [] }));
} finally {
setLoadingStates((prev) => ({ ...prev, [codeCategory]: false }));
setLoadingStates((prev) => ({ ...prev, [codeInfo]: false }));
}
},
[codeOptions, loadingStates],
@@ -253,9 +251,9 @@ export const AdvancedSearchFilters: React.FC<AdvancedSearchFiltersProps> = ({
filter={filter}
value={value}
onChange={(newValue) => handleChange(filter.columnName, newValue)}
options={codeOptions[filter.codeCategory || ""] || []}
loading={loadingStates[filter.codeCategory || ""]}
onLoadOptions={() => filter.codeCategory && loadCodeOptions(filter.codeCategory)}
options={codeOptions[filter.codeInfo || ""] || []}
loading={loadingStates[filter.codeInfo || ""]}
onLoadOptions={() => filter.codeInfo && loadCodeOptions(filter.codeInfo)}
/>
);
@@ -362,10 +360,10 @@ const CodeFilter: React.FC<{
onLoadOptions: () => void;
}> = ({ filter, value, onChange, options, loading, onLoadOptions }) => {
useEffect(() => {
if (filter.codeCategory && options.length === 0 && !loading) {
if (filter.codeInfo && options.length === 0 && !loading) {
onLoadOptions();
}
}, [filter.codeCategory, options.length, loading, onLoadOptions]);
}, [filter.codeInfo, options.length, loading, onLoadOptions]);
return (
<Select value={value} onValueChange={onChange}>
@@ -780,7 +780,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
label: targetColumn.columnLabel || targetColumn.columnName,
gridColumns: getDefaultGridColumns(widgetType),
// 웹타입별 추가 정보 설정
codeCategory: targetColumn.codeCategory,
codeInfo: targetColumn.codeInfo,
referenceTable: targetColumn.referenceTable,
referenceColumn: targetColumn.referenceColumn,
displayColumn: targetColumn.displayColumn,
@@ -1,96 +1,24 @@
"use client";
import React, { useState } from "react";
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
import { ResponsiveSplitPanel } from "@/components/common/ResponsiveSplitPanel";
import React from "react";
interface CategoryWidgetProps {
widgetId?: string;
tableName?: string; // 현재 화면의 테이블 (옵션 - 형제 메뉴 전체 표시)
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프) - 필수
component?: any; // DynamicComponentRenderer에서 전달되는 컴포넌트 정보
[key: string]: any; // 추가 props 허용
tableName?: string;
menuObjid?: number;
component?: any;
[key: string]: any;
}
/**
* ( )
* - 좌측: 형제 ( )
* - 우측: 선택된 ( )
* ( - stub)
*/
export function CategoryWidget({ widgetId, tableName, menuObjid, component, ...props }: CategoryWidgetProps) {
// menuObjid가 없으면 경고 로그
React.useEffect(() => {
console.log("🔍 CategoryWidget 받은 props:", {
widgetId,
tableName,
menuObjid,
hasComponent: !!component,
propsKeys: Object.keys(props),
propsMenuObjid: props.menuObjid,
allProps: { widgetId, tableName, menuObjid, ...props },
});
if (!menuObjid && !props.menuObjid) {
console.warn("⚠️ CategoryWidget: menuObjid가 전달되지 않았습니다", {
component,
props,
allAvailableProps: { widgetId, tableName, menuObjid, ...props }
});
} else {
console.log("✅ CategoryWidget 렌더링", {
widgetId,
tableName,
menuObjid: menuObjid || props.menuObjid
});
}
}, [menuObjid, widgetId, tableName, component, props]);
// menuObjid 우선순위: 직접 전달된 값 > props에서 추출한 값
const effectiveMenuObjid = menuObjid || props.menuObjid;
const [selectedColumn, setSelectedColumn] = useState<{
uniqueKey: string; // 테이블명.컬럼명 형식
columnName: string;
columnLabel: string;
tableName: string;
} | null>(null);
export function CategoryWidget(_props: CategoryWidgetProps) {
return (
<ResponsiveSplitPanel
left={
<CategoryColumnList
tableName={tableName ?? ""}
selectedColumn={selectedColumn?.uniqueKey || null}
onColumnSelect={(uniqueKey, columnLabel, tblName) => {
const columnName = uniqueKey.split('.')[1];
setSelectedColumn({ uniqueKey, columnName, columnLabel, tableName: tblName });
}}
menuObjid={effectiveMenuObjid}
/>
}
right={
selectedColumn ? (
<CategoryValueManager
key={selectedColumn.uniqueKey}
tableName={selectedColumn.tableName}
columnName={selectedColumn.columnName}
columnLabel={selectedColumn.columnLabel}
menuObjid={effectiveMenuObjid}
/>
) : (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground">
</p>
</div>
</div>
)
}
leftTitle="카테고리 컬럼"
leftWidth={15}
minLeftWidth={10}
maxLeftWidth={40}
/>
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
<p className="text-sm text-muted-foreground">
.
</p>
</div>
);
}
@@ -7,7 +7,7 @@ import { WebTypeComponentProps } from "@/lib/registry/types";
import { WidgetComponent } from "@/types/screen";
interface CodeTypeConfig {
codeCategory?: string;
codeInfo?: string;
showDetails?: boolean;
placeholder?: string;
}
@@ -22,13 +22,13 @@ export const CodeWidget: React.FC<WebTypeComponentProps> = ({ component, value,
// 코드 목록 가져오기
const getCodeOptions = () => {
if (config?.codeCategory) {
if (config?.codeInfo) {
// 실제 구현에서는 API를 통해 코드 목록을 가져옴
// 여기서는 예시 데이터 사용
return [
{ code: "CODE001", name: "코드 1", category: config.codeCategory },
{ code: "CODE002", name: "코드 2", category: config.codeCategory },
{ code: "CODE003", name: "코드 3", category: config.codeCategory },
{ code: "CODE001", name: "코드 1", category: config.codeInfo },
{ code: "CODE002", name: "코드 2", category: config.codeInfo },
{ code: "CODE003", name: "코드 3", category: config.codeInfo },
];
}
@@ -73,10 +73,10 @@ export const CodeWidget: React.FC<WebTypeComponentProps> = ({ component, value,
)}
{/* 코드 카테고리 표시 */}
{config?.codeCategory && (
{config?.codeInfo && (
<div className="mt-1">
<Badge variant="secondary" className="text-xs">
{config.codeCategory}
{config.codeInfo}
</Badge>
</div>
)}
@@ -1,303 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Plus } from "lucide-react";
import { toast } from "sonner";
import { createColumnMapping } from "@/lib/api/tableCategoryValue";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { apiClient } from "@/lib/api/client";
interface SecondLevelMenu {
menu_objid: number;
menu_name: string;
parent_menu_name: string;
screen_code?: string;
}
interface AddCategoryColumnDialogProps {
tableName: string;
onSuccess: () => void;
}
/**
*
*
*
*
* 2
*/
export function AddCategoryColumnDialog({
tableName,
onSuccess,
}: AddCategoryColumnDialogProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [physicalColumns, setPhysicalColumns] = useState<string[]>([]);
const [secondLevelMenus, setSecondLevelMenus] = useState<SecondLevelMenu[]>([]);
const [selectedMenus, setSelectedMenus] = useState<number[]>([]);
const [logical_column_name, set_logical_column_name] = useState("");
const [physical_column_name, set_physical_column_name] = useState("");
const [description, setDescription] = useState("");
// 다이얼로그 열릴 때 데이터 로드
useEffect(() => {
if (open) {
loadPhysicalColumns();
loadSecondLevelMenus();
}
}, [open, tableName]);
// 테이블의 실제 컬럼 목록 조회
const loadPhysicalColumns = async () => {
try {
const response = await tableManagementApi.getColumnList(tableName);
if (response.success && response.data) {
setPhysicalColumns(response.data.columns.map((col) => col.column_name));
}
} catch (error) {
console.error("컬럼 목록 조회 실패:", error);
toast.error("컬럼 목록을 불러올 수 없습니다");
}
};
// 2레벨 메뉴 목록 조회
const loadSecondLevelMenus = async () => {
try {
const response = await apiClient.get<{
success: boolean;
data: SecondLevelMenu[];
}>("table-categories/second-level-menus");
if (response.data.success && response.data.data) {
setSecondLevelMenus(response.data.data);
}
} catch (error) {
console.error("2레벨 메뉴 목록 조회 실패:", error);
toast.error("메뉴 목록을 불러올 수 없습니다");
}
};
// 메뉴 선택/해제
const toggleMenu = (menu_objid: number) => {
setSelectedMenus((prev) =>
prev.includes(menu_objid)
? prev.filter((id) => id !== menu_objid)
: [...prev, menu_objid]
);
};
const handleSave = async () => {
// 입력 검증
if (!logical_column_name.trim()) {
toast.error("논리적 컬럼명을 입력해주세요");
return;
}
if (!physical_column_name) {
toast.error("실제 컬럼을 선택해주세요");
return;
}
if (selectedMenus.length === 0) {
toast.error("최소 하나 이상의 메뉴를 선택해주세요");
return;
}
setLoading(true);
try {
// 선택된 각 메뉴에 대해 매핑 생성
const promises = selectedMenus.map((menu_objid) =>
createColumnMapping({
tableName,
logicalColumnName: logical_column_name.trim(),
physicalColumnName: physical_column_name,
menuObjid: menu_objid,
description: description.trim() || undefined,
})
);
const results = await Promise.all(promises);
// 모든 요청이 성공했는지 확인
const failedCount = results.filter((r) => !r.success).length;
if (failedCount === 0) {
toast.success(`논리적 컬럼이 ${selectedMenus.length}개 메뉴에 추가되었습니다`);
setOpen(false);
resetForm();
onSuccess();
} else if (failedCount < results.length) {
toast.warning(
`${results.length - failedCount}개 메뉴에 추가 성공, ${failedCount}개 실패`
);
onSuccess();
} else {
toast.error("모든 메뉴에 대한 매핑 생성에 실패했습니다");
}
} catch (error: any) {
console.error("컬럼 매핑 생성 실패:", error);
toast.error(error.message || "컬럼 매핑 생성 중 오류가 발생했습니다");
} finally {
setLoading(false);
}
};
const resetForm = () => {
set_logical_column_name("");
set_physical_column_name("");
setDescription("");
setSelectedMenus([]);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
2
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 실제 컬럼 선택 */}
<div>
<Label className="text-xs sm:text-sm">
() *
</Label>
<Select value={physical_column_name} onValueChange={set_physical_column_name}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{physicalColumns.map((col) => (
<SelectItem key={col} value={col} className="text-xs sm:text-sm">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
{/* 논리적 컬럼명 입력 */}
<div>
<Label className="text-xs sm:text-sm">
( ) *
</Label>
<Input
value={logical_column_name}
onChange={(e) => set_logical_column_name(e.target.value)}
placeholder="예: status_stock, status_sales"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
{/* 적용할 2레벨 메뉴 선택 (체크박스) */}
<div>
<Label className="text-xs sm:text-sm">
(2) *
</Label>
<div className="border rounded-lg p-3 sm:p-4 space-y-2 max-h-48 overflow-y-auto mt-2">
{secondLevelMenus.length === 0 ? (
<p className="text-xs text-muted-foreground"> ...</p>
) : (
secondLevelMenus.map((menu) => (
<div key={menu.menu_objid} className="flex items-center gap-2">
<Checkbox
id={`menu-${menu.menu_objid}`}
checked={selectedMenus.includes(menu.menu_objid)}
onCheckedChange={() => toggleMenu(menu.menu_objid)}
className="h-4 w-4"
/>
<label
htmlFor={`menu-${menu.menu_objid}`}
className="text-xs sm:text-sm cursor-pointer flex-1"
>
{menu.parent_menu_name} {menu.menu_name}
</label>
</div>
))
)}
</div>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
{selectedMenus.length > 0 && (
<p className="text-primary mt-1 text-[10px] sm:text-xs">
{selectedMenus.length}
</p>
)}
</div>
{/* 설명 (선택사항) */}
<div>
<Label className="text-xs sm:text-sm"></Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="이 컬럼의 용도를 설명하세요 (선택사항)"
className="text-xs sm:text-sm"
rows={2}
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={loading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={!logical_column_name || !physical_column_name || selectedMenus.length === 0 || loading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{loading ? "추가 중..." : "추가"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,516 +0,0 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { apiClient } from "@/lib/api/client";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
export interface CategoryColumn {
table_name: string;
table_label?: string;
column_name: string;
column_label: string;
input_type: string;
value_count?: number;
}
interface CategoryColumnListProps {
tableName: string;
selectedColumn: string | null;
onColumnSelect: (uniqueKeyOrColumnName: string, columnLabel: string, tableName: string) => void;
menuObjid?: number;
/** 대시보드 모드: 테이블 단위 네비만 표시, 선택 시 onTableSelect 호출 */
selectedTable?: string | null;
onTableSelect?: (tableName: string) => void;
/** 컬럼 로드 완료 시 부모에 전달 (Stat Strip 등 계산용) */
onColumnsLoaded?: (columns: CategoryColumn[]) => void;
}
/**
* ( )
* - ( )
*/
export function CategoryColumnList({
tableName,
selectedColumn,
onColumnSelect,
menuObjid,
selectedTable = null,
onTableSelect,
onColumnsLoaded,
}: CategoryColumnListProps) {
const [columns, setColumns] = useState<CategoryColumn[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
// 검색어로 필터링된 컬럼 목록
const filteredColumns = useMemo(() => {
if (!searchQuery.trim()) return columns;
const query = searchQuery.toLowerCase();
return columns.filter((col) => {
const column_name = (col.column_name || "").toLowerCase();
const column_label = (col.column_label || "").toLowerCase();
const table_name = (col.table_name || "").toLowerCase();
const table_label = (col.table_label || "").toLowerCase();
return column_name.includes(query) ||
column_label.includes(query) ||
table_name.includes(query) ||
table_label.includes(query);
});
}, [columns, searchQuery]);
// 테이블별로 그룹화된 컬럼 목록
const groupedColumns = useMemo(() => {
const groups: { tableName: string; tableLabel: string; columns: CategoryColumn[] }[] = [];
const groupMap = new Map<string, CategoryColumn[]>();
for (const col of filteredColumns) {
const key = col.table_name;
if (!groupMap.has(key)) {
groupMap.set(key, []);
}
groupMap.get(key)!.push(col);
}
for (const [tblName, cols] of groupMap) {
groups.push({
tableName: tblName,
tableLabel: cols[0]?.table_label || tblName,
columns: cols,
});
}
return groups;
}, [filteredColumns]);
// 선택된 컬럼이 있는 그룹을 자동 펼침
useEffect(() => {
if (!selectedColumn) return;
const tblName = selectedColumn.split(".")[0];
if (tblName) {
setExpandedGroups((prev) => {
if (prev.has(tblName)) return prev;
const next = new Set(prev);
next.add(tblName);
return next;
});
}
}, [selectedColumn]);
useEffect(() => {
// 메뉴 종속 없이 항상 회사 기준으로 카테고리 컬럼 조회
loadCategoryColumnsByMenu();
}, [menuObjid, tableName]);
// tableName 기반으로 카테고리 컬럼 조회
const loadCategoryColumnsByTable = async () => {
setIsLoading(true);
try {
console.log("🔍 테이블 기반 카테고리 컬럼 조회 시작", { tableName });
// table_type_columns에서 input_type='category'인 컬럼 조회
const response = await apiClient.get(`/screen-management/tables/${tableName}/columns`);
console.log("✅ 테이블 컬럼 API 응답:", response.data);
let allColumns: any[] = [];
if (response.data.success && response.data.data) {
allColumns = response.data.data;
} else if (Array.isArray(response.data)) {
allColumns = response.data;
}
// category 타입 중 자체 카테고리만 필터링 (참조 컬럼 제외)
const categoryColumns = allColumns.filter(
(col: any) => col.input_type === "category"
&& !col.category_ref
);
console.log("✅ 카테고리 컬럼 필터링 완료:", {
total: allColumns.length,
categoryCount: categoryColumns.length,
});
// 값 개수 조회 (테스트 테이블 사용)
const columnsWithCount = await Promise.all(
categoryColumns.map(async (col: any) => {
const colName = col.column_name;
const colLabel = col.column_label || colName;
let value_count = 0;
try {
// 테스트 테이블에서 조회
const treeResponse = await apiClient.get(`/category-tree/test/${tableName}/${colName}`);
if (treeResponse.data.success && treeResponse.data.data) {
value_count = countTreeNodes(treeResponse.data.data);
}
} catch (error) {
console.error(`항목 개수 조회 실패 (${tableName}.${colName}):`, error);
}
return {
table_name: tableName,
table_label: tableName,
column_name: colName,
column_label: colLabel,
input_type: "category",
value_count,
};
}),
);
setColumns(columnsWithCount);
onColumnsLoaded?.(columnsWithCount);
if (columnsWithCount.length > 0 && !selectedColumn) {
const firstCol = columnsWithCount[0];
onColumnSelect(`${firstCol.table_name}.${firstCol.column_name}`, firstCol.column_label, firstCol.table_name);
}
} catch (error) {
console.error("❌ 테이블 기반 카테고리 컬럼 조회 실패:", error);
setColumns([]);
onColumnsLoaded?.([]);
} finally {
setIsLoading(false);
}
};
// 트리 노드 수 계산 함수
const countTreeNodes = (nodes: any[]): number => {
let count = nodes.length;
for (const node of nodes) {
if (node.children && Array.isArray(node.children)) {
count += countTreeNodes(node.children);
}
}
return count;
};
const loadCategoryColumnsByMenu = async () => {
setIsLoading(true);
try {
console.log("🔍 회사 기준 카테고리 컬럼 조회 시작", { menuObjid });
// 회사 기준 카테고리 컬럼 조회 (menuObjid는 선택사항)
const url = menuObjid
? `/table-management/menu/${menuObjid}/category-columns`
: `/table-management/category-columns`;
const response = await apiClient.get(url);
console.log("✅ 메뉴별 카테고리 컬럼 API 응답:", {
menuObjid,
response: response.data,
});
let categoryColumns: any[] = [];
if (response.data.success && response.data.data) {
categoryColumns = response.data.data;
} else if (Array.isArray(response.data)) {
categoryColumns = response.data;
} else {
console.warn("⚠️ 예상하지 못한 API 응답 구조:", response.data);
categoryColumns = [];
}
console.log("✅ 카테고리 컬럼 파싱 완료:", {
count: categoryColumns.length,
columns: categoryColumns.map((c: any) => ({
table: c.table_name,
column: c.column_name,
label: c.column_label,
})),
});
// 각 컬럼의 값 개수 가져오기
const columnsWithCount = await Promise.all(
categoryColumns.map(async (col: any) => {
const colTable = col.table_name;
const colName = col.column_name;
const colLabel = col.column_label || colName;
let value_count = 0;
try {
const valuesResult = await getCategoryValues(colTable, colName, false, menuObjid);
const valuesData = (valuesResult as any).data;
if (valuesResult.success && valuesData) {
value_count = valuesData.length;
}
} catch (error) {
console.error(`항목 개수 조회 실패 (${colTable}.${colName}):`, error);
}
return {
table_name: colTable,
table_label: col.table_label || colTable,
column_name: colName,
column_label: colLabel,
input_type: col.input_type,
value_count,
};
}),
);
// 결과가 0개면 tableName 기반으로 fallback
if (columnsWithCount.length === 0 && tableName) {
console.log("⚠️ menuObjid 기반 조회 결과 없음, tableName 기반으로 fallback:", tableName);
await loadCategoryColumnsByTable();
return;
}
setColumns(columnsWithCount);
onColumnsLoaded?.(columnsWithCount);
if (columnsWithCount.length > 0 && !selectedColumn) {
const firstCol = columnsWithCount[0];
onColumnSelect(`${firstCol.table_name}.${firstCol.column_name}`, firstCol.column_label, firstCol.table_name);
}
} catch (error) {
console.error("❌ 카테고리 컬럼 조회 실패:", error);
if (tableName) {
await loadCategoryColumnsByTable();
return;
} else {
setColumns([]);
onColumnsLoaded?.([]);
}
}
setIsLoading(false);
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
</div>
);
}
if (columns.length === 0) {
return (
<div className="space-y-3">
<h3 className="text-lg font-semibold"> </h3>
<div className="bg-muted/50 rounded-lg border p-6 text-center">
<FolderTree className="text-muted-foreground mx-auto h-8 w-8" />
<p className="text-muted-foreground mt-2 text-sm"> </p>
<p className="text-muted-foreground mt-1 text-xs">
&apos;&apos;
</p>
</div>
</div>
);
}
// 대시보드 모드: 테이블 단위 네비만 표시
if (onTableSelect != null) {
return (
<div className="flex h-full flex-col">
<div className="border-b p-2.5">
<div className="relative">
<Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
type="text"
placeholder="테이블 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 border-0 bg-transparent pl-8 pr-8 text-xs shadow-none focus-visible:ring-0"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-muted-foreground hover:text-foreground absolute right-2 top-1/2 -translate-y-1/2"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
<div className="flex-1 space-y-0 overflow-y-auto">
{filteredColumns.length === 0 && searchQuery ? (
<div className="text-muted-foreground py-4 text-center text-xs">
&apos;{searchQuery}&apos;
</div>
) : null}
{groupedColumns.map((group) => {
const totalValues = group.columns.reduce((sum, c) => sum + (c.value_count ?? 0), 0);
const isActive = selectedTable === group.tableName;
return (
<button
key={group.tableName}
type="button"
onClick={() => onTableSelect(group.tableName)}
className={cn(
"flex w-full items-center gap-2 px-3 py-2.5 text-left transition-colors",
isActive
? "border-l-[3px] border-primary bg-primary/5 font-bold text-primary"
: "hover:bg-muted/50",
)}
>
<div
className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-[5px] bg-primary/20 text-primary"
aria-hidden
>
<FolderTree className="h-3.5 w-3.5" />
</div>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{group.tableLabel || group.tableName}
</span>
<span className="bg-muted text-muted-foreground shrink-0 rounded-full px-1.5 py-0.5 text-[9px] font-bold">
{group.columns.length}
</span>
</button>
);
})}
</div>
</div>
);
}
return (
<div className="space-y-3">
<div className="space-y-1">
<h3 className="text-lg font-semibold"> </h3>
<p className="text-muted-foreground text-xs"> </p>
</div>
<div className="relative">
<Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
type="text"
placeholder="컬럼 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 pr-8 text-xs"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-muted-foreground hover:text-foreground absolute right-2 top-1/2 -translate-y-1/2"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<div className="space-y-1">
{filteredColumns.length === 0 && searchQuery ? (
<div className="text-muted-foreground py-4 text-center text-xs">
&apos;{searchQuery}&apos;
</div>
) : null}
{groupedColumns.map((group) => {
const isExpanded = expandedGroups.has(group.tableName);
const totalValues = group.columns.reduce((sum, c) => sum + (c.value_count ?? 0), 0);
const hasSelectedInGroup = group.columns.some(
(c) => selectedColumn === `${c.table_name}.${c.column_name}`,
);
// 그룹이 1개뿐이면 드롭다운 없이 바로 표시
if (groupedColumns.length <= 1) {
return (
<div key={group.tableName} className="space-y-1.5">
{group.columns.map((column) => {
const uniqueKey = `${column.table_name}.${column.column_name}`;
const isSelected = selectedColumn === uniqueKey;
return (
<div
key={uniqueKey}
onClick={() => onColumnSelect(uniqueKey, column.column_label || column.column_name, column.table_name)}
className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${
isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
}`}
>
<div className="flex items-center gap-2">
<FolderTree
className={`h-4 w-4 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
/>
<div className="flex-1">
<h4 className="text-sm font-semibold">{column.column_label || column.column_name}</h4>
<p className="text-muted-foreground text-xs">{column.table_label || column.table_name}</p>
</div>
<span className="text-muted-foreground text-xs font-medium">
{column.value_count !== undefined ? `${column.value_count}` : "..."}
</span>
</div>
</div>
);
})}
</div>
);
}
return (
<div key={group.tableName} className="overflow-hidden rounded-lg border">
{/* 드롭다운 헤더 */}
<button
type="button"
onClick={() => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(group.tableName)) {
next.delete(group.tableName);
} else {
next.add(group.tableName);
}
return next;
});
}}
className={`flex w-full items-center gap-2 px-3 py-2 text-left transition-colors ${
hasSelectedInGroup ? "bg-primary/5" : "hover:bg-muted/50"
}`}
>
<ChevronRight
className={`h-3.5 w-3.5 shrink-0 transition-transform duration-200 ${
isExpanded ? "rotate-90" : ""
} ${hasSelectedInGroup ? "text-primary" : "text-muted-foreground"}`}
/>
<span className={`flex-1 text-xs font-semibold ${hasSelectedInGroup ? "text-primary" : ""}`}>
{group.tableLabel}
</span>
<span className="text-muted-foreground text-[10px]">
{group.columns.length} / {totalValues}
</span>
</button>
{/* 펼쳐진 컬럼 목록 */}
{isExpanded && (
<div className="space-y-1 border-t px-2 py-2">
{group.columns.map((column) => {
const uniqueKey = `${column.table_name}.${column.column_name}`;
const isSelected = selectedColumn === uniqueKey;
return (
<div
key={uniqueKey}
onClick={() => onColumnSelect(uniqueKey, column.column_label || column.column_name, column.table_name)}
className={`cursor-pointer rounded-md px-3 py-1.5 transition-all ${
isSelected ? "bg-primary/10 font-semibold text-primary" : "hover:bg-muted/50"
}`}
>
<div className="flex items-center gap-2">
<FolderTree
className={`h-3.5 w-3.5 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
/>
<span className="flex-1 text-xs">{column.column_label || column.column_name}</span>
<span className="text-muted-foreground text-[10px]">
{column.value_count !== undefined ? `${column.value_count}` : "..."}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
@@ -1,228 +0,0 @@
"use client";
import React, { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
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 { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { TableCategoryValue } from "@/types/tableCategoryValue";
// 기본 색상 팔레트
const DEFAULT_COLORS = [
"#ef4444", // red
"#f97316", // orange
"#f59e0b", // amber
"#eab308", // yellow
"#84cc16", // lime
"#22c55e", // green
"#10b981", // emerald
"#14b8a6", // teal
"#06b6d4", // cyan
"#0ea5e9", // sky
"#3b82f6", // blue
"#6366f1", // indigo
"#8b5cf6", // violet
"#a855f7", // purple
"#d946ef", // fuchsia
"#ec4899", // pink
"#64748b", // slate
"#6b7280", // gray
];
interface CategoryValueAddDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (value: TableCategoryValue) => void;
columnLabel: string;
}
export const CategoryValueAddDialog: React.FC<
CategoryValueAddDialogProps
> = ({ open, onOpenChange, onAdd, columnLabel }) => {
const [valueLabel, setValueLabel] = useState("");
const [description, setDescription] = useState("");
const [color, setColor] = useState("none");
const [continuousAdd, setContinuousAdd] = useState(false); // 연속 입력 체크박스
// 라벨에서 코드 자동 생성 (항상 고유한 코드 생성)
const generateCode = (): string => {
// 항상 CATEGORY_TIMESTAMP_RANDOM 형식으로 고유 코드 생성
const timestamp = Date.now().toString().slice(-6);
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `CATEGORY_${timestamp}${random}`;
};
const resetForm = () => {
setValueLabel("");
setDescription("");
setColor("none");
};
const handleSubmit = () => {
if (!valueLabel.trim()) {
return;
}
const valueCode = generateCode();
onAdd({
table_name: "", // CategoryValueManager에서 오버라이드됨
column_name: "", // CategoryValueManager에서 오버라이드됨
value_code: valueCode,
value_label: valueLabel.trim(),
description: description.trim() || undefined, // 빈 문자열 대신 undefined
color: color === "none" ? undefined : color, // "none"은 undefined로
is_default: false,
} as TableCategoryValue);
// 연속 입력 체크되어 있으면 폼만 초기화하고 모달 유지
if (continuousAdd) {
resetForm();
} else {
// 연속 입력 아니면 모달 닫기
resetForm();
onOpenChange(false);
}
};
const handleClose = () => {
resetForm();
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={(isOpen) => {
if (!isOpen) {
resetForm();
}
onOpenChange(isOpen);
}}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{columnLabel}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="valueLabel"
placeholder="예: 개발, 긴급, 진행중"
value={valueLabel}
onChange={(e) => setValueLabel(e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm mt-1.5"
autoFocus
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<div className="mt-1.5 space-y-2">
<div className="flex items-center gap-3">
<div className="grid grid-cols-9 gap-2">
{DEFAULT_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`h-7 w-7 rounded-md border-2 transition-all ${
color === c ? "border-foreground scale-110" : "border-transparent hover:scale-105"
}`}
style={{ backgroundColor: c }}
title={c}
/>
))}
</div>
{color && color !== "none" ? (
<Badge style={{ backgroundColor: color, borderColor: color }} className="text-white">
</Badge>
) : (
<span className="text-xs text-muted-foreground"> </span>
)}
</div>
<button
type="button"
onClick={() => setColor("none")}
className={`text-xs px-3 py-1.5 rounded-md border transition-colors ${
color === "none"
? "border-primary bg-primary/10 text-primary font-medium"
: "border-border hover:bg-accent"
}`}
>
</button>
</div>
</div>
<div>
<Label htmlFor="description" className="text-xs sm:text-sm">
()
</Label>
<Textarea
id="description"
placeholder="설명을 입력하세요"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-xs sm:text-sm mt-1.5"
rows={3}
/>
</div>
</div>
<DialogFooter className="flex-col gap-3 sm:flex-row sm:gap-0">
{/* 연속 입력 체크박스 */}
<div className="flex items-center gap-2 w-full sm:w-auto sm:mr-auto">
<Checkbox
id="continuousAdd"
checked={continuousAdd}
onCheckedChange={(checked) => setContinuousAdd(checked as boolean)}
/>
<label
htmlFor="continuousAdd"
className="text-xs sm:text-sm text-muted-foreground cursor-pointer"
>
</label>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={!valueLabel.trim()}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,176 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
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 { Badge } from "@/components/ui/badge";
import { TableCategoryValue } from "@/types/tableCategoryValue";
interface CategoryValueEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
value: TableCategoryValue;
onUpdate: (valueId: number, updates: Partial<TableCategoryValue>) => void;
columnLabel: string;
}
// 기본 색상 팔레트
const DEFAULT_COLORS = [
"#ef4444", // red
"#f97316", // orange
"#f59e0b", // amber
"#eab308", // yellow
"#84cc16", // lime
"#22c55e", // green
"#10b981", // emerald
"#14b8a6", // teal
"#06b6d4", // cyan
"#0ea5e9", // sky
"#3b82f6", // blue
"#6366f1", // indigo
"#8b5cf6", // violet
"#a855f7", // purple
"#d946ef", // fuchsia
"#ec4899", // pink
"#64748b", // slate
"#6b7280", // gray
];
export const CategoryValueEditDialog: React.FC<
CategoryValueEditDialogProps
> = ({ open, onOpenChange, value, onUpdate, columnLabel }) => {
const [valueLabel, setValueLabel] = useState(value.value_label);
const [description, setDescription] = useState(value.description || "");
const [color, setColor] = useState(value.color || "none");
useEffect(() => {
setValueLabel(value.value_label);
setDescription(value.description || "");
setColor(value.color || "none");
}, [value]);
const handleSubmit = () => {
if (!valueLabel.trim()) {
return;
}
onUpdate(value.value_id!, {
value_label: valueLabel.trim(),
description: description.trim() || undefined, // 빈 문자열 대신 undefined
color: color === "none" ? undefined : color, // "none"은 undefined로 (배지 없음)
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{columnLabel} - {value.value_code}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="valueLabel"
placeholder="예: 개발, 긴급, 진행중"
value={valueLabel}
onChange={(e) => setValueLabel(e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm mt-1.5"
autoFocus
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<div className="mt-1.5 space-y-2">
<div className="flex items-center gap-3">
<div className="grid grid-cols-9 gap-2">
{DEFAULT_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`h-7 w-7 rounded-md border-2 transition-all ${
color === c ? "border-foreground scale-110" : "border-transparent hover:scale-105"
}`}
style={{ backgroundColor: c }}
title={c}
/>
))}
</div>
{color && color !== "none" ? (
<Badge style={{ backgroundColor: color, borderColor: color }} className="text-white">
</Badge>
) : (
<span className="text-xs text-muted-foreground"> </span>
)}
</div>
<button
type="button"
onClick={() => setColor("none")}
className={`text-xs px-3 py-1.5 rounded-md border transition-colors ${
color === "none"
? "border-primary bg-primary/10 text-primary font-medium"
: "border-border hover:bg-accent"
}`}
>
</button>
</div>
</div>
<div>
<Label htmlFor="description" className="text-xs sm:text-sm">
()
</Label>
<Textarea
id="description"
placeholder="설명을 입력하세요"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-xs sm:text-sm mt-1.5"
rows={3}
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSubmit}
disabled={!valueLabel.trim()}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,486 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import {
Plus,
Search,
Trash2,
Edit2,
} from "lucide-react";
import {
getCategoryValues,
addCategoryValue,
updateCategoryValue,
deleteCategoryValue,
bulkDeleteCategoryValues,
} from "@/lib/api/tableCategoryValue";
import { TableCategoryValue } from "@/types/tableCategoryValue";
import { useToast } from "@/hooks/use-toast";
import { CategoryValueEditDialog } from "./CategoryValueEditDialog";
import { CategoryValueAddDialog } from "./CategoryValueAddDialog";
interface CategoryValueManagerProps {
tableName: string;
columnName: string;
columnLabel: string;
onValueCountChange?: (count: number) => void;
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
/** 편집기 헤더 오른쪽에 표시할 내용 (예: 트리/목록 세그먼트) */
headerRight?: React.ReactNode;
}
export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
tableName,
columnName,
columnLabel,
onValueCountChange,
menuObjid,
headerRight,
}) => {
const { toast } = useToast();
const [values, setValues] = useState<TableCategoryValue[]>([]);
const [filteredValues, setFilteredValues] = useState<TableCategoryValue[]>(
[]
);
const [selectedValueIds, setSelectedValueIds] = useState<number[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [editingValue, setEditingValue] = useState<TableCategoryValue | null>(
null
);
const [showInactive, setShowInactive] = useState(false); // 비활성 항목 표시 옵션 (기본: 숨김)
// 카테고리 값 로드
useEffect(() => {
loadCategoryValues();
}, [tableName, columnName]);
// 검색 필터링 + 비활성 필터링
useEffect(() => {
let filtered = values;
// 비활성 항목 필터링 (기본: 활성만 표시, 체크하면 비활성도 표시)
if (!showInactive) {
filtered = filtered.filter((v) => v.is_active !== false);
}
// 검색어 필터링
if (searchQuery) {
filtered = filtered.filter(
(v) =>
v.value_code.toLowerCase().includes(searchQuery.toLowerCase()) ||
v.value_label.toLowerCase().includes(searchQuery.toLowerCase())
);
}
setFilteredValues(filtered);
}, [searchQuery, values, showInactive]);
const loadCategoryValues = async () => {
setIsLoading(true);
try {
// includeInactive: true로 비활성 값도 포함
const response = await getCategoryValues(tableName, columnName, true, menuObjid);
const responseData = (response as any).data as TableCategoryValue[] | undefined;
if (response.success && responseData) {
setValues(responseData);
setFilteredValues(responseData);
onValueCountChange?.(responseData.length);
}
} catch (error) {
console.error("카테고리 값 로드 실패:", error);
toast({
title: "오류",
description: "카테고리 값을 불러올 수 없습니다",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const handleAddValue = async (newValue: TableCategoryValue) => {
try {
if (!menuObjid) {
toast({
title: "오류",
description: "메뉴 정보가 없습니다. 카테고리 값을 추가할 수 없습니다.",
variant: "destructive",
});
return;
}
const response = await addCategoryValue(
{
...newValue,
table_name: tableName,
column_name: columnName,
},
menuObjid
);
if (response.success && (response as any).data) {
await loadCategoryValues();
// 모달 닫기는 CategoryValueAddDialog에서 연속 입력 체크박스로 제어
toast({
title: "성공",
description: "카테고리 값이 추가되었습니다",
});
} else {
console.error("❌ 카테고리 값 추가 실패:", response);
toast({
title: "오류",
description: (response as any).error || "카테고리 값 추가에 실패했습니다",
variant: "destructive",
});
}
} catch (error: any) {
console.error("❌ 카테고리 값 추가 예외:", error);
toast({
title: "오류",
description: error.message || "카테고리 값 추가에 실패했습니다",
variant: "destructive",
});
}
};
const handleUpdateValue = async (
valueId: number,
updates: Partial<TableCategoryValue>
) => {
try {
const response = await updateCategoryValue(valueId, updates);
if (response.success) {
await loadCategoryValues();
setEditingValue(null);
toast({
title: "성공",
description: "카테고리 값이 수정되었습니다",
});
}
} catch (error) {
toast({
title: "오류",
description: "카테고리 값 수정에 실패했습니다",
variant: "destructive",
});
}
};
const handleDeleteValue = async (valueId: number) => {
if (!confirm("정말로 이 카테고리 값을 삭제하시겠습니까?")) {
return;
}
try {
const response = await deleteCategoryValue(valueId);
if (response.success) {
await loadCategoryValues();
toast({
title: "성공",
description: "카테고리 값이 삭제되었습니다",
});
} else {
// 백엔드에서 반환한 상세 에러 메시지 표시
toast({
title: "삭제 불가",
description: (response as any).error || (response as any).message || "카테고리 값 삭제에 실패했습니다",
variant: "destructive",
});
}
} catch (error) {
toast({
title: "오류",
description: "카테고리 값 삭제 중 오류가 발생했습니다",
variant: "destructive",
});
}
};
const handleBulkDelete = async () => {
if (selectedValueIds.length === 0) {
toast({
title: "알림",
description: "삭제할 항목을 선택해주세요",
variant: "destructive",
});
return;
}
if (
!confirm(`선택한 ${selectedValueIds.length}개 항목을 삭제하시겠습니까?`)
) {
return;
}
try {
const response = await bulkDeleteCategoryValues(selectedValueIds);
if (response.success) {
setSelectedValueIds([]);
await loadCategoryValues();
toast({
title: "성공",
description: (response as any).message,
});
}
} catch (error) {
toast({
title: "오류",
description: "일괄 삭제에 실패했습니다",
variant: "destructive",
});
}
};
const handleSelectAll = () => {
if (selectedValueIds.length === filteredValues.length) {
setSelectedValueIds([]);
} else {
setSelectedValueIds(filteredValues.map((v) => v.value_id!));
}
};
const handleSelectValue = (valueId: number) => {
setSelectedValueIds((prev) =>
prev.includes(valueId)
? prev.filter((id) => id !== valueId)
: [...prev, valueId]
);
};
const handleToggleActive = async (valueId: number, currentIsActive: boolean) => {
try {
const response = await updateCategoryValue(valueId, {
is_active: !currentIsActive,
});
if (response.success) {
await loadCategoryValues();
toast({
title: "성공",
description: `카테고리 값이 ${!currentIsActive ? "활성화" : "비활성화"}되었습니다`,
});
} else {
toast({
title: "오류",
description: (response as any).error || "상태 변경에 실패했습니다",
variant: "destructive",
});
}
} catch (error: any) {
console.error("❌ 활성 상태 변경 실패:", error);
toast({
title: "오류",
description: error.message || "상태 변경에 실패했습니다",
variant: "destructive",
});
}
};
return (
<div className="flex h-full flex-col">
{/* 편집기 헤더: 컬럼명 + 값 수 + 비활성 토글 + 새 값 추가 + headerRight(트리·목록 세그먼트 등) */}
<div className="border-b p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">{columnLabel}</h3>
<p className="text-xs text-muted-foreground">
{filteredValues.length}
</p>
</div>
<div className="flex items-center gap-3">
{/* 비활성 항목 표시 옵션 */}
<div className="flex items-center gap-2">
<Checkbox
id="show-inactive"
checked={showInactive}
onCheckedChange={(checked) => setShowInactive(checked as boolean)}
/>
<label
htmlFor="show-inactive"
className="text-sm text-muted-foreground cursor-pointer whitespace-nowrap"
>
</label>
</div>
<Button onClick={() => setIsAddDialogOpen(true)} size="sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
{headerRight != null ? <div className="flex items-center">{headerRight}</div> : null}
</div>
</div>
{/* 검색바 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="코드 또는 라벨 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
</div>
{/* 값 목록 */}
<div className="flex-1 overflow-y-auto p-4">
{filteredValues.length === 0 ? (
<div className="flex h-full items-center justify-center text-center">
<p className="text-sm text-muted-foreground">
{searchQuery
? "검색 결과가 없습니다"
: "카테고리 값을 추가해주세요"}
</p>
</div>
) : (
<div className="space-y-2">
{filteredValues.map((value) => {
const isInactive = value.is_active === false;
return (
<div
key={value.value_id}
className={`flex items-center gap-3 rounded-md border bg-card p-3 transition-colors hover:bg-accent ${
isInactive ? "opacity-50" : ""
}`}
>
<Checkbox
checked={selectedValueIds.includes(value.value_id!)}
onCheckedChange={() => handleSelectValue(value.value_id!)}
/>
<div className="flex flex-1 items-center gap-2">
{/* 색상 표시 (배지 없음 옵션 지원) */}
{value.color && value.color !== "none" && (
<div
className="h-4 w-4 rounded-full border flex-shrink-0"
style={{ backgroundColor: value.color }}
/>
)}
{value.color === "none" && (
<span className="text-[10px] text-muted-foreground px-1.5 py-0.5 bg-muted rounded">
</span>
)}
{/* 라벨 */}
<span className={`text-sm font-medium ${isInactive ? "line-through" : ""}`}>
{value.value_label}
</span>
{/* 설명 */}
{value.description && (
<span className="text-xs text-muted-foreground">
- {value.description}
</span>
)}
{/* 기본값 배지 */}
{value.is_default && (
<Badge variant="secondary" className="text-[10px]">
</Badge>
)}
{/* 비활성 배지 */}
{isInactive && (
<Badge variant="outline" className="text-[10px] text-muted-foreground">
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Switch
checked={value.is_active !== false}
onCheckedChange={() =>
handleToggleActive(
value.value_id!,
value.is_active !== false
)
}
/>
<Button
variant="ghost"
size="icon"
onClick={() => setEditingValue(value)}
className="h-8 w-8"
>
<Edit2 className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteValue(value.value_id!)}
className="h-8 w-8 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
{/* 푸터: 일괄 작업 */}
{selectedValueIds.length > 0 && (
<div className="border-t p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={
selectedValueIds.length === filteredValues.length &&
filteredValues.length > 0
}
onCheckedChange={handleSelectAll}
/>
<span className="text-sm text-muted-foreground">
{selectedValueIds.length}
</span>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleBulkDelete}>
<Trash2 className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
{/* 추가 다이얼로그 */}
<CategoryValueAddDialog
open={isAddDialogOpen}
onOpenChange={setIsAddDialogOpen}
onAdd={handleAddValue}
columnLabel={columnLabel}
/>
{/* 편집 다이얼로그 */}
{editingValue && (
<CategoryValueEditDialog
open={!!editingValue}
onOpenChange={(open) => !open && setEditingValue(null)}
value={editingValue}
onUpdate={handleUpdateValue}
columnLabel={columnLabel}
/>
)}
</div>
);
};
@@ -1,955 +0,0 @@
"use client";
/**
* -
* - 3 (//)
* -
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import {
ChevronRight,
ChevronDown,
Plus,
Pencil,
Trash2,
Folder,
FolderOpen,
Tag,
Search,
RefreshCw,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import {
CategoryValue,
getCategoryTree,
createCategoryValue,
updateCategoryValue,
deleteCategoryValue,
checkCanDeleteCategoryValue,
CreateCategoryValueInput,
} from "@/lib/api/categoryTree";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
interface CategoryValueManagerTreeProps {
tableName: string;
columnName: string;
columnLabel: string;
onValueCountChange?: (count: number) => void;
/** 편집기 헤더 오른쪽에 표시할 내용 (예: 트리/목록 세그먼트) */
headerRight?: React.ReactNode;
}
// 트리 노드 컴포넌트
interface TreeNodeProps {
node: CategoryValue;
level: number;
expandedNodes: Set<number>;
selectedValueId?: number;
searchQuery: string;
checkedIds: Set<number>;
onToggle: (valueId: number) => void;
onSelect: (value: CategoryValue) => void;
onAdd: (parentValue: CategoryValue | null) => void;
onEdit: (value: CategoryValue) => void;
onDelete: (value: CategoryValue) => void;
onCheck: (valueId: number, checked: boolean) => void;
}
// 검색어가 노드 또는 하위에 매칭되는지 확인
const nodeMatchesSearch = (node: CategoryValue, query: string): boolean => {
if (!query) return true;
const lowerQuery = query.toLowerCase();
if (node.value_label.toLowerCase().includes(lowerQuery)) return true;
if (node.value_code.toLowerCase().includes(lowerQuery)) return true;
if (node.children) {
return node.children.some((child) => nodeMatchesSearch(child, query));
}
return false;
};
const TreeNode: React.FC<TreeNodeProps> = ({
node,
level,
expandedNodes,
selectedValueId,
searchQuery,
checkedIds,
onToggle,
onSelect,
onAdd,
onEdit,
onDelete,
onCheck,
}) => {
const hasChildren = node.children && node.children.length > 0;
const isExpanded = expandedNodes.has(node.value_id);
const isSelected = selectedValueId === node.value_id;
const isChecked = checkedIds.has(node.value_id);
const canAddChild = node.depth < 3;
// 검색 필터링
if (searchQuery && !nodeMatchesSearch(node, searchQuery)) {
return null;
}
// 깊이별 아이콘 (대/중분류 = Folder, 소분류 = Tag)
const getIcon = () => {
if (hasChildren) {
return isExpanded ? (
<FolderOpen className="text-muted-foreground h-4 w-4" />
) : (
<Folder className="text-muted-foreground h-4 w-4" />
);
}
return <Tag className="h-4 w-4 text-primary" />;
};
// 깊이별 라벨
const getDepthLabel = () => {
switch (node.depth) {
case 1:
return "대분류";
case 2:
return "중분류";
case 3:
return "소분류";
default:
return "";
}
};
return (
<div className="mb-px">
<div
className={cn(
"group flex cursor-pointer items-center gap-[5px] rounded-[6px] px-[8px] py-[5px] transition-colors",
isSelected ? "border-primary border-l-2 bg-primary/10" : "hover:bg-muted/50",
isChecked && "bg-primary/5",
)}
style={{ paddingLeft: `${level * 20 + 8}px` }}
onClick={() => onSelect(node)}
>
<Checkbox
checked={isChecked}
onCheckedChange={(checked) => {
onCheck(node.value_id, checked as boolean);
}}
onClick={(e) => e.stopPropagation()}
className="mr-1 shrink-0"
/>
<button
type="button"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded hover:bg-muted"
onClick={(e) => {
e.stopPropagation();
if (hasChildren) {
onToggle(node.value_id);
}
}}
>
{hasChildren ? (
isExpanded ? (
<ChevronDown className="text-muted-foreground h-4 w-4" />
) : (
<ChevronRight className="text-muted-foreground h-4 w-4" />
)
) : (
<span className="h-4 w-4" />
)}
</button>
{getIcon()}
<div className="flex min-w-0 flex-1 items-center gap-[5px]">
<span className={cn("truncate text-sm", node.depth === 1 && "font-medium")}>
{node.value_label}
</span>
<span className="bg-muted text-muted-foreground shrink-0 rounded-[4px] px-1.5 py-0.5 text-[8px] font-bold">
{getDepthLabel()}
</span>
</div>
{!node.is_active && (
<span className="bg-destructive/5 text-destructive shrink-0 rounded-[4px] px-1.5 py-0.5 text-[8px] font-bold">
</span>
)}
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
{canAddChild && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
onAdd(node);
}}
title="하위 추가"
>
<Plus className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
onEdit(node);
}}
title="수정"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive h-7 w-7"
onClick={(e) => {
e.stopPropagation();
onDelete(node);
}}
title="삭제"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 자식 노드 */}
{hasChildren && isExpanded && (
<div>
{node.children!.map((child) => (
<TreeNode
key={child.value_id}
node={child}
level={level + 1}
expandedNodes={expandedNodes}
selectedValueId={selectedValueId}
searchQuery={searchQuery}
checkedIds={checkedIds}
onToggle={onToggle}
onSelect={onSelect}
onAdd={onAdd}
onEdit={onEdit}
onDelete={onDelete}
onCheck={onCheck}
/>
))}
</div>
)}
</div>
);
};
export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> = ({
tableName,
columnName,
columnLabel,
onValueCountChange,
headerRight,
}) => {
// 상태
const [tree, setTree] = useState<CategoryValue[]>([]);
const [loading, setLoading] = useState(false);
const [expandedNodes, setExpandedNodes] = useState<Set<number>>(new Set());
const [selectedValue, setSelectedValue] = useState<CategoryValue | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [showInactive, setShowInactive] = useState(false);
const [checkedIds, setCheckedIds] = useState<Set<number>>(new Set());
// 모달 상태
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [parentValue, setParentValue] = useState<CategoryValue | null>(null);
const [continuousAdd, setContinuousAdd] = useState(false);
const [editingValue, setEditingValue] = useState<CategoryValue | null>(null);
const [deletingValue, setDeletingValue] = useState<CategoryValue | null>(null);
// 추가 모달 input ref
const addNameRef = useRef<HTMLInputElement>(null);
const addDescRef = useRef<HTMLInputElement>(null);
// 폼 상태
const [formData, setFormData] = useState({
valueCode: "",
valueLabel: "",
description: "",
color: "",
isActive: true,
});
// 전체 값 개수 계산
const countAllValues = useCallback((nodes: CategoryValue[]): number => {
let count = nodes.length;
for (const node of nodes) {
if (node.children) {
count += countAllValues(node.children);
}
}
return count;
}, []);
// 활성 노드만 필터링
const filterActiveNodes = useCallback((nodes: CategoryValue[]): CategoryValue[] => {
return nodes
.filter((node) => node.is_active !== false)
.map((node) => ({
...node,
children: node.children ? filterActiveNodes(node.children) : undefined,
}));
}, []);
// 데이터 로드 (keepExpanded: true면 기존 펼침 상태 유지)
const loadTree = useCallback(
async (keepExpanded = false) => {
if (!tableName || !columnName) return;
setLoading(true);
try {
const response = await getCategoryTree(tableName, columnName);
if (response.success && response.data) {
let filteredTree = response.data;
// 비활성 필터링
if (!showInactive) {
filteredTree = filterActiveNodes(response.data);
}
setTree(filteredTree);
// 기존 펼침 상태 유지하지 않으면 모두 접기 (대분류만 표시)
if (!keepExpanded) {
setExpandedNodes(new Set());
}
// 전체 개수 업데이트
const totalCount = countAllValues(response.data);
onValueCountChange?.(totalCount);
}
} catch (error) {
console.error("카테고리 트리 로드 오류:", error);
} finally {
setLoading(false);
}
},
[tableName, columnName, showInactive, countAllValues, filterActiveNodes, onValueCountChange],
);
useEffect(() => {
loadTree();
}, [loadTree]);
// 모든 노드 펼치기
const expandAll = () => {
const allIds = new Set<number>();
const collectIds = (nodes: CategoryValue[]) => {
for (const node of nodes) {
allIds.add(node.value_id);
if (node.children) {
collectIds(node.children);
}
}
};
collectIds(tree);
setExpandedNodes(allIds);
};
// 모든 노드 접기
const collapseAll = () => {
setExpandedNodes(new Set());
};
// 토글 핸들러
const handleToggle = (valueId: number) => {
setExpandedNodes((prev) => {
const next = new Set(prev);
if (next.has(valueId)) {
next.delete(valueId);
} else {
next.add(valueId);
}
return next;
});
};
// 체크박스 핸들러
const handleCheck = useCallback((valueId: number, checked: boolean) => {
setCheckedIds((prev) => {
const next = new Set(prev);
if (checked) {
next.add(valueId);
} else {
next.delete(valueId);
}
return next;
});
}, []);
// 전체 선택/해제
const handleSelectAll = useCallback(() => {
if (checkedIds.size === countAllValues(tree)) {
setCheckedIds(new Set());
} else {
const allIds = new Set<number>();
const collectAllIds = (nodes: CategoryValue[]) => {
for (const node of nodes) {
allIds.add(node.value_id);
if (node.children) {
collectAllIds(node.children);
}
}
};
collectAllIds(tree);
setCheckedIds(allIds);
}
}, [checkedIds.size, tree, countAllValues]);
// 선택 해제
const handleClearSelection = useCallback(() => {
setCheckedIds(new Set());
}, []);
// 추가 모달 열기
const handleOpenAddModal = (parent: CategoryValue | null) => {
setParentValue(parent);
setFormData({
valueCode: "",
valueLabel: "",
description: "",
color: "",
isActive: true,
});
setIsAddModalOpen(true);
};
// 수정 모달 열기
const handleOpenEditModal = (value: CategoryValue) => {
setEditingValue(value);
setFormData({
valueCode: value.value_code,
valueLabel: value.value_label,
description: value.description || "",
color: value.color || "",
isActive: value.is_active,
});
setIsEditModalOpen(true);
};
// 삭제 다이얼로그 열기 (사전 확인 후)
const handleOpenDeleteDialog = async (value: CategoryValue) => {
try {
const response = await checkCanDeleteCategoryValue(value.value_id);
if (response.success && response.data) {
if (!response.data.canDelete) {
toast.error(response.data.reason || "이 카테고리는 삭제할 수 없습니다");
return;
}
}
} catch {
// 사전 확인 실패 시에도 다이얼로그는 열어줌 (삭제 시 백엔드에서 재검증)
}
setDeletingValue(value);
setIsDeleteDialogOpen(true);
};
// 코드 자동 생성 함수
const generateCode = () => {
const timestamp = Date.now().toString(36).toUpperCase();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `CAT_${timestamp}_${random}`;
};
// 추가 처리
const handleAdd = async () => {
if (!formData.valueLabel) {
toast.error("이름은 필수입니다");
return;
}
try {
// 코드 자동 생성
const autoCode = generateCode();
const input: CreateCategoryValueInput = {
table_name: tableName,
column_name: columnName,
value_code: autoCode,
value_label: formData.valueLabel,
parent_value_id: parentValue?.value_id || null,
description: formData.description || undefined,
color: formData.color || undefined,
is_active: formData.isActive,
};
const response = await createCategoryValue(input);
if (response.success) {
toast.success("카테고리가 추가되었습니다");
await loadTree(true);
if (parentValue) {
setExpandedNodes((prev) => new Set([...prev, parentValue.value_id]));
}
if (continuousAdd) {
setFormData((prev) => ({
...prev,
valueCode: "",
valueLabel: "",
description: "",
color: "",
}));
setTimeout(() => addNameRef.current?.focus(), 50);
} else {
setFormData({ valueCode: "", valueLabel: "", description: "", color: "", isActive: true });
setIsAddModalOpen(false);
}
} else {
toast.error(response.error || "추가 실패");
}
} catch (error) {
console.error("카테고리 추가 오류:", error);
toast.error("카테고리 추가 중 오류가 발생했습니다");
}
};
// 수정 처리
const handleEdit = async () => {
if (!editingValue) return;
try {
// 코드는 변경하지 않음 (기존 코드 유지)
const response = await updateCategoryValue(editingValue.value_id, {
value_label: formData.valueLabel,
description: formData.description || undefined,
color: formData.color || undefined,
is_active: formData.isActive,
});
if (response.success) {
toast.success("카테고리가 수정되었습니다");
setIsEditModalOpen(false);
loadTree(true); // 기존 펼침 상태 유지
} else {
toast.error(response.error || "수정 실패");
}
} catch (error) {
console.error("카테고리 수정 오류:", error);
toast.error("카테고리 수정 중 오류가 발생했습니다");
}
};
// 삭제 처리
const handleDelete = async () => {
if (!deletingValue) return;
try {
const response = await deleteCategoryValue(deletingValue.value_id);
if (response.success) {
toast.success("카테고리가 삭제되었습니다");
setIsDeleteDialogOpen(false);
setSelectedValue(null);
setCheckedIds((prev) => {
const next = new Set(prev);
next.delete(deletingValue.value_id);
return next;
});
loadTree(true); // 기존 펼침 상태 유지
} else {
toast.error(response.error || "삭제 실패");
}
} catch (error) {
console.error("카테고리 삭제 오류:", error);
toast.error("카테고리 삭제 중 오류가 발생했습니다");
}
};
// 다중 삭제 처리
const handleBulkDelete = async () => {
if (checkedIds.size === 0) return;
try {
let successCount = 0;
let failCount = 0;
const failMessages: string[] = [];
for (const valueId of Array.from(checkedIds)) {
try {
const response = await deleteCategoryValue(valueId);
if (response.success) {
successCount++;
} else {
failCount++;
if (response.error) failMessages.push(response.error);
}
} catch {
failCount++;
}
}
setIsBulkDeleteDialogOpen(false);
setCheckedIds(new Set());
setSelectedValue(null);
loadTree(true);
if (failCount === 0) {
toast.success(`${successCount}개 카테고리가 삭제되었습니다`);
} else if (successCount === 0) {
toast.error(`삭제할 수 없습니다: ${failMessages[0] || "삭제 실패"}`);
} else {
toast.warning(`${successCount}개 삭제 성공, ${failCount}개 삭제 실패 (사용 중이거나 하위 항목 존재)`);
}
} catch (error) {
console.error("카테고리 일괄 삭제 오류:", error);
toast.error("카테고리 일괄 삭제 중 오류가 발생했습니다");
}
};
return (
<div className="flex h-full flex-col">
{/* 편집기 헤더: 컬럼명 + 값 수 Badge + 비활성/전체펼침/대분류추가 + headerRight(트리·목록 세그먼트 등) */}
<div className="mb-3 flex items-center justify-between border-b pb-3">
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold">{columnLabel} </h3>
<Badge variant="secondary" className="rounded-full px-2 py-0.5 text-xs font-bold">
{countAllValues(tree)}
</Badge>
{checkedIds.size > 0 && (
<span className="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs">
{checkedIds.size}
</span>
)}
</div>
<div className="flex items-center gap-2">
{checkedIds.size > 0 && (
<>
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={handleClearSelection}>
</Button>
<Button
variant="destructive"
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
)}
<Button variant="default" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => handleOpenAddModal(null)}>
<Plus className="h-3.5 w-3.5" />
</Button>
{headerRight != null ? <div className="flex items-center">{headerRight}</div> : null}
</div>
</div>
{/* 툴바 */}
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
{/* 검색 */}
<div className="relative max-w-xs flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 text-sm"
/>
</div>
{/* 옵션 */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Switch id="showInactive" checked={showInactive} onCheckedChange={setShowInactive} />
<Label htmlFor="showInactive" className="cursor-pointer text-xs">
</Label>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={handleSelectAll}>
{checkedIds.size === countAllValues(tree) && tree.length > 0 ? "전체 해제" : "전체 선택"}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={expandAll}>
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={collapseAll}>
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => loadTree()} title="새로고침">
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
{/* 트리 */}
<div className="bg-card min-h-[300px] flex-1 overflow-y-auto rounded-md border">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-sm"> ...</div>
</div>
) : tree.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Folder className="text-muted-foreground/30 mb-3 h-12 w-12" />
<p className="text-muted-foreground text-sm"> </p>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
) : (
<div className="py-1">
{tree.map((node) => (
<TreeNode
key={node.value_id}
node={node}
level={0}
expandedNodes={expandedNodes}
selectedValueId={selectedValue?.value_id}
searchQuery={searchQuery}
checkedIds={checkedIds}
onToggle={handleToggle}
onSelect={setSelectedValue}
onAdd={handleOpenAddModal}
onEdit={handleOpenEditModal}
onDelete={handleOpenDeleteDialog}
onCheck={handleCheck}
/>
))}
</div>
)}
</div>
{/* 추가 모달 */}
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[450px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{parentValue ? `"${parentValue.value_label}" 하위 추가` : "대분류 추가"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{parentValue
? `${parentValue.depth + 1}단계 카테고리를 추가합니다`
: "1단계 대분류 카테고리를 추가합니다"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
ref={addNameRef}
id="valueLabel"
value={formData.valueLabel}
onChange={(e) => setFormData({ ...formData, valueLabel: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
addDescRef.current?.focus();
}
}}
placeholder="카테고리 이름을 입력하세요"
className="h-9 text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px]"> </p>
</div>
<div>
<Label htmlFor="description" className="text-xs sm:text-sm">
</Label>
<Input
ref={addDescRef}
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
handleAdd();
}
}}
placeholder="선택 사항"
className="h-9 text-sm"
/>
</div>
<div className="flex items-center gap-2">
<Switch
id="isActive"
checked={formData.isActive}
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
/>
<Label htmlFor="isActive" className="cursor-pointer text-sm">
</Label>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsAddModalOpen(false)}
className="h-9 flex-1 text-sm sm:flex-none"
>
</Button>
<Button onClick={handleAdd} className="h-9 flex-1 text-sm sm:flex-none">
</Button>
</DialogFooter>
<div className="border-t px-4 py-3">
<div className="flex items-center gap-2">
<Checkbox
id="tree-continuous-add"
checked={continuousAdd}
onCheckedChange={(checked) => setContinuousAdd(checked as boolean)}
/>
<Label htmlFor="tree-continuous-add" className="cursor-pointer text-sm font-normal select-none">
( )
</Label>
</div>
</div>
</DialogContent>
</Dialog>
{/* 수정 모달 */}
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[450px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm"> </DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="editValueLabel" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="editValueLabel"
value={formData.valueLabel}
onChange={(e) => setFormData({ ...formData, valueLabel: e.target.value })}
className="h-9 text-sm"
/>
</div>
<div>
<Label htmlFor="editDescription" className="text-xs sm:text-sm">
</Label>
<Input
id="editDescription"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="h-9 text-sm"
/>
</div>
<div className="flex items-center gap-2">
<Switch
id="editIsActive"
checked={formData.isActive}
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
/>
<Label htmlFor="editIsActive" className="cursor-pointer text-sm">
</Label>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsEditModalOpen(false)}
className="h-9 flex-1 text-sm sm:flex-none"
>
</Button>
<Button onClick={handleEdit} className="h-9 flex-1 text-sm sm:flex-none">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
<strong>{deletingValue?.value_label}</strong>() ?
<br />
<span className="text-muted-foreground text-xs"> .</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 다중 삭제 확인 다이얼로그 */}
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
<strong>{checkedIds.size}</strong> ?
<br />
<span className="text-muted-foreground text-xs"> .</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleBulkDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{checkedIds.size}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default CategoryValueManagerTree;
+9 -25
View File
@@ -26,6 +26,7 @@ import { cn } from "@/lib/utils";
import { UnifiedSelectProps, SelectOption } from "@/types/unified-components";
import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react";
import { apiClient } from "@/lib/api/client";
import { getCodeOptions } from "@/lib/api/commonCode";
import UnifiedFormContext from "./UnifiedFormContext";
/**
@@ -552,31 +553,14 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>((pro
if (source === "code" && codeGroup) {
// 계층 구조 사용 시 자식 코드만 로드
if (hierarchical) {
const params = new URLSearchParams();
if (parentValue) {
params.append("parentCodeValue", parentValue);
}
const queryString = params.toString();
const url = `/common-codes/categories/${codeGroup}/children${queryString ? `?${queryString}` : ""}`;
const response = await apiClient.get(url);
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data.map((item: { value: string; label: string; hasChildren: boolean }) => ({
value: item.value,
label: item.label,
}));
}
} else {
// 일반 공통코드에서 로드 (올바른 API 경로: /common-codes/categories/:categoryCode/options)
const response = await apiClient.get(`/common-codes/categories/${codeGroup}/options`);
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
value: item.value,
label: item.label,
}));
}
// 새 구조 (code_info 마스터 + code_detail 트리) — getCodeOptions 어댑터가 옛 {value,label} 형식 반환
// hierarchical 의 parentValue 단일 레벨 필터는 추후 명세 받으면 추가
const data = await getCodeOptions(codeGroup);
if (data.success && data.data) {
fetchedOptions = data.data.map((item) => ({
value: item.value,
label: item.label,
}));
}
} else if (source === "db" && table) {
// DB 테이블에서 로드
@@ -117,11 +117,11 @@ export function ConditionalConfigPanel({
if (selectedField.codeGroup) {
setLoadingOptions(true);
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/common-codes/categories/${selectedField.codeGroup}/options`);
if (response.data.success && response.data.data) {
const { getCodeOptions } = await import("@/lib/api/commonCode");
const response = await getCodeOptions(selectedField.codeGroup);
if (response.success && response.data) {
setDynamicOptions(
response.data.data.map((item: { value: string; label: string }) => ({
response.data.map((item) => ({
value: item.value,
label: item.label,
})),
@@ -36,7 +36,6 @@ import {
import { cn } from "@/lib/utils";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { tableTypeApi } from "@/lib/api/screen";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
import type {
AggregationWidgetConfig,
AggregationItem,
@@ -136,55 +135,6 @@ function LabeledRow({ label, children }: {
);
}
// ─── 카테고리 값 콤보박스 ───
function CategoryValueCombobox({
value,
options,
onChange,
placeholder = "값 선택",
}: {
value: string;
options: Array<{ value: string; label: string }>;
onChange: (value: string) => void;
placeholder?: string;
}) {
const [open, setOpen] = useState(false);
const selectedOption = options.find((opt) => opt.value === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="h-7 w-full justify-between text-xs font-normal">
<span className="truncate">{selectedOption ? selectedOption.label : placeholder}</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{options.map((opt, index) => (
<CommandItem
key={`${opt.value}-${index}`}
value={`${opt.label} ${opt.value}`}
onSelect={() => { onChange(opt.value); setOpen(false); }}
className="cursor-pointer text-xs"
>
<Check className={cn("mr-2 h-3 w-3", value === opt.value ? "opacity-100" : "opacity-0")} />
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// ─── 메인 컴포넌트 ───
interface ColumnInfo {
@@ -233,7 +183,6 @@ export const V2AggregationWidgetConfigPanel: React.FC<V2AggregationWidgetConfigP
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName?: string }>>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
const [categoryOptionsCache, setCategoryOptionsCache] = useState<Record<string, Array<{ value: string; label: string }>>>({});
const [sourceComponentColumnsCache, setSourceComponentColumnsCache] = useState<Record<string, Array<{ columnName: string; label?: string }>>>({});
// Collapsible 상태
@@ -340,10 +289,6 @@ export const V2AggregationWidgetConfigPanel: React.FC<V2AggregationWidgetConfigP
categoryCode: col.categoryCode || col.category_code,
}));
setColumns(mapped);
const categoryCols = mapped.filter(
(c: ColumnInfo) => c.inputType === "category" || c.web_type === "category"
);
if (categoryCols.length > 0) loadCategoryOptions(categoryCols);
} else {
setColumns([]);
}
@@ -385,51 +330,6 @@ export const V2AggregationWidgetConfigPanel: React.FC<V2AggregationWidgetConfigP
});
}, [config.filters, loadSourceComponentColumns]);
// 카테고리 옵션 로드
const loadCategoryOptions = useCallback(async (categoryCols: Array<{ columnName: string; categoryCode?: string }>) => {
if (!targetTableName) return;
const newCache: Record<string, Array<{ value: string; label: string }>> = { ...categoryOptionsCache };
for (const col of categoryCols) {
const cacheKey = `${targetTableName}_${col.columnName}`;
if (newCache[cacheKey]) continue;
try {
const result = await getCategoryValues(targetTableName, col.columnName, false);
if (result.success && "data" in result && Array.isArray(result.data)) {
const seenCodes = new Set<string>();
const uniqueOptions: Array<{ value: string; label: string }> = [];
for (const item of result.data) {
const itemAny = item as any;
const code = item.value_code || itemAny.code || itemAny.value || itemAny.id;
if (!seenCodes.has(code)) {
seenCodes.add(code);
uniqueOptions.push({
value: code,
label: item.value_label || itemAny.valueName || itemAny.name || itemAny.label || itemAny.displayName || code,
});
}
}
newCache[cacheKey] = uniqueOptions;
} else {
newCache[cacheKey] = [];
}
} catch {
newCache[cacheKey] = [];
}
}
setCategoryOptionsCache(newCache);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [targetTableName]);
const getCategoryOptionsForColumn = useCallback((columnName: string) => {
if (!targetTableName) return [];
return categoryOptionsCache[`${targetTableName}_${columnName}`] || [];
}, [targetTableName, categoryOptionsCache]);
const isCategoryColumn = useCallback((columnName: string) => {
const col = columns.find((c) => c.columnName === columnName);
return col?.inputType === "category" || col?.web_type === "category";
}, [columns]);
// ─── 집계 항목 CRUD ───
const addItem = useCallback(() => {
const newItem: AggregationItem = {
@@ -894,10 +794,6 @@ export const V2AggregationWidgetConfigPanel: React.FC<V2AggregationWidgetConfigP
value={filter.columnName}
onValueChange={(value) => {
updateFilter(filter.id, { columnName: value, staticValue: "" });
const col = columns.find((c) => c.columnName === value);
if (col && (col.inputType === "category" || col.web_type === "category")) {
loadCategoryOptions([col]);
}
}}
disabled={loadingColumns}
>
@@ -961,21 +857,12 @@ export const V2AggregationWidgetConfigPanel: React.FC<V2AggregationWidgetConfigP
</span>
{filter.valueSourceType === "static" && (
isCategoryColumn(filter.columnName) ? (
<CategoryValueCombobox
value={String(filter.staticValue || "")}
options={getCategoryOptionsForColumn(filter.columnName)}
onChange={(value) => updateFilter(filter.id, { staticValue: value })}
placeholder="값 선택"
/>
) : (
<Input
value={String(filter.staticValue || "")}
onChange={(e) => updateFilter(filter.id, { staticValue: e.target.value })}
placeholder="값 입력"
className="h-7 text-xs"
/>
)
<Input
value={String(filter.staticValue || "")}
onChange={(e) => updateFilter(filter.id, { staticValue: e.target.value })}
placeholder="값 입력"
className="h-7 text-xs"
/>
)}
{filter.valueSourceType === "formField" && (
@@ -1,194 +1,22 @@
"use client";
/**
* V2 ( )
* UX: -> -> ()
* V2 ( - stub)
*/
import React, { useState } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Settings, ChevronDown, FolderTree } from "lucide-react";
import { cn } from "@/lib/utils";
import type { V2CategoryManagerConfig, ViewMode } from "@/lib/registry/components/v2-category-manager/types";
import { defaultV2CategoryManagerConfig } from "@/lib/registry/components/v2-category-manager/types";
import React from "react";
interface V2CategoryManagerConfigPanelProps {
config: Partial<V2CategoryManagerConfig>;
onChange: (config: Partial<V2CategoryManagerConfig>) => void;
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
}
export const V2CategoryManagerConfigPanel: React.FC<V2CategoryManagerConfigPanelProps> = ({
config: externalConfig,
onChange,
}) => {
const [layoutOpen, setLayoutOpen] = useState(false);
const config: V2CategoryManagerConfig = {
...defaultV2CategoryManagerConfig,
...externalConfig,
};
const handleChange = <K extends keyof V2CategoryManagerConfig>(key: K, value: V2CategoryManagerConfig[K]) => {
const newConfig = { ...config, [key]: value };
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", { detail: { config: newConfig } })
);
}
};
export const V2CategoryManagerConfigPanel: React.FC<V2CategoryManagerConfigPanelProps> = () => {
return (
<div className="space-y-4">
{/* ─── 1단계: 뷰 모드 설정 ─── */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<FolderTree className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium"> </p>
</div>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={config.viewMode}
onValueChange={(value: ViewMode) => handleChange("viewMode", value)}
>
<SelectTrigger className="h-7 w-[120px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tree"> </SelectItem>
<SelectItem value="list"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">/ </p>
</div>
<Switch
checked={config.showViewModeToggle}
onCheckedChange={(checked) => handleChange("showViewModeToggle", checked)}
/>
</div>
</div>
{/* ─── 2단계: 트리 설정 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<div>
<span className="text-xs text-muted-foreground"> </span>
<p className="text-[10px] text-muted-foreground mt-0.5"> </p>
</div>
<Select
value={String(config.defaultExpandLevel)}
onValueChange={(value) => handleChange("defaultExpandLevel", Number(value))}
>
<SelectTrigger className="h-7 w-[120px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 ()</SelectItem>
<SelectItem value="2">2 ()</SelectItem>
<SelectItem value="3">3 ( )</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.showInactiveItems}
onCheckedChange={(checked) => handleChange("showInactiveItems", checked)}
/>
</div>
</div>
{/* ─── 3단계: 레이아웃 (Collapsible) ─── */}
<Collapsible open={layoutOpen} onOpenChange={setLayoutOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
layoutOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.showColumnList}
onCheckedChange={(checked) => handleChange("showColumnList", checked)}
/>
</div>
{config.showColumnList && (
<div className="ml-1 border-l-2 border-primary/20 pl-3">
<div className="flex items-center justify-between py-1">
<div>
<span className="text-xs text-muted-foreground"> (%)</span>
<p className="text-[10px] text-muted-foreground mt-0.5">10~40% </p>
</div>
<Input
type="number"
min={10}
max={40}
value={config.leftPanelWidth}
onChange={(e) => handleChange("leftPanelWidth", Number(e.target.value))}
className="h-7 w-[80px] text-xs"
/>
</div>
</div>
)}
<div className="flex items-center justify-between py-1">
<div>
<span className="text-xs text-muted-foreground"></span>
<p className="text-[10px] text-muted-foreground mt-0.5">px % (: 100%, 600)</p>
</div>
<Input
value={String(config.height)}
onChange={(e) => {
const v = e.target.value;
handleChange("height", isNaN(Number(v)) ? v : Number(v));
}}
placeholder="100%"
className="h-7 w-[100px] text-xs"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
<div className="p-4">
<p className="text-sm text-muted-foreground">
.
</p>
</div>
);
};
@@ -291,8 +291,8 @@ export const V2LocationSwapSelectorConfigPanel: React.FC<V2LocationSwapSelectorC
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={config?.dataSource?.codeCategory || ""}
onValueChange={(value) => handleChange("dataSource.codeCategory", value)}
value={config?.dataSource?.codeInfo || ""}
onValueChange={(value) => handleChange("dataSource.codeInfo", value)}
>
<SelectTrigger className="h-7 w-[160px] text-xs">
<SelectValue placeholder="선택" />
@@ -71,7 +71,7 @@ interface ColumnInfo {
columnLabel?: string;
dataType?: string;
inputType?: string;
codeCategory?: string;
codeInfo?: string;
referenceTable?: string;
referenceColumn?: string;
}
@@ -329,7 +329,7 @@ export const V2SelectedItemsDetailInputConfigPanel: React.FC<
columnLabel: col.displayName || col.columnLabel || col.columnName,
dataType: col.dataType,
inputType: col.inputType,
codeCategory: col.codeCategory,
codeInfo: col.codeInfo,
referenceTable: col.referenceTable,
referenceColumn: col.referenceColumn,
}));
@@ -971,7 +971,7 @@ export const V2SelectedItemsDetailInputConfigPanel: React.FC<
name,
label: col.columnLabel || name,
inputType: col.inputType || "text",
codeCategory: col.codeCategory,
codeInfo: col.codeInfo,
})
}
/>

Some files were not shown because too many files have changed in this diff Show More