refactor(common-code): 마스터-디테일 재설계 — code_info(그룹) + code_detail(재귀 트리)
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m22s
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:
@@ -1,125 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CascadingAutoFillService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/cascading-auto-fill")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CascadingAutoFillController {
|
||||
private final CascadingAutoFillService cascadingAutoFillService;
|
||||
|
||||
// Pipeline api_test compatibility alias
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupListAlias(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingAutoFillService.getCascadingAutoFillGroupList(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/groups")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingAutoFillService.getCascadingAutoFillGroupList(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/groups/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupDetail(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
Map<String, Object> result = cascadingAutoFillService.getCascadingAutoFillGroupDetail(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@PostMapping("/groups")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> createGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.success(cascadingAutoFillService.insertCascadingAutoFillGroup(body)));
|
||||
}
|
||||
|
||||
@PutMapping("/groups/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("group_code", groupCode);
|
||||
Map<String, Object> result = cascadingAutoFillService.updateCascadingAutoFillGroup(body);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@DeleteMapping("/groups/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
boolean deleted = cascadingAutoFillService.deleteCascadingAutoFillGroup(params);
|
||||
if (!deleted) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
@GetMapping("/options/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getMasterOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
List<Map<String, Object>> result = cascadingAutoFillService.getAutoFillMasterOptions(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@GetMapping("/data/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getAutoFillData(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode,
|
||||
@RequestParam(required = false) String masterValue) {
|
||||
if (masterValue == null || masterValue.isBlank()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error("masterValue 파라미터가 필요합니다."));
|
||||
}
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
params.put("master_value", masterValue);
|
||||
Map<String, Object> result = cascadingAutoFillService.getAutoFillData(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CascadingConditionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/cascading-condition")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CascadingConditionController {
|
||||
private final CascadingConditionService cascadingConditionService;
|
||||
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingConditionListAlias(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.getCascadingConditionList(params)));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingConditionList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.getCascadingConditionList(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/filtered-options/{relationCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getFilteredOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String relationCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("relation_code", relationCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.getFilteredOptions(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/{conditionId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingConditionInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long conditionId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("condition_id", conditionId);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.getCascadingConditionInfo(params)));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCascadingCondition(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.insertCascadingCondition(body)));
|
||||
}
|
||||
|
||||
@PutMapping("/{conditionId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCascadingCondition(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long conditionId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("condition_id", conditionId);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.updateCascadingCondition(body)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{conditionId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCascadingCondition(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long conditionId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("condition_id", conditionId);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.deleteCascadingCondition(params)));
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CascadingHierarchyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/cascading-hierarchy")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CascadingHierarchyController {
|
||||
private final CascadingHierarchyService cascadingHierarchyService;
|
||||
|
||||
// Pipeline api_test compatibility alias
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupListAlias(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingHierarchyService.getCascadingHierarchyGroupList(params)));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingHierarchyService.getCascadingHierarchyGroupList(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupDetail(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
Map<String, Object> result = cascadingHierarchyService.getCascadingHierarchyGroupDetail(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("계층 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> createGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "user_id", required = false) String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
if (userId != null) body.put("user_id", userId);
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.success(cascadingHierarchyService.insertCascadingHierarchyGroup(body)));
|
||||
}
|
||||
|
||||
@PutMapping("/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "user_id", required = false) String userId,
|
||||
@PathVariable String groupCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("group_code", groupCode);
|
||||
if (userId != null) body.put("user_id", userId);
|
||||
Map<String, Object> result = cascadingHierarchyService.updateCascadingHierarchyGroup(body);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("계층 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
boolean deleted = cascadingHierarchyService.deleteCascadingHierarchyGroup(params);
|
||||
if (!deleted) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("계층 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
@PostMapping("/{groupCode}/levels")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> addLevel(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("group_code", groupCode);
|
||||
Map<String, Object> result = cascadingHierarchyService.addCascadingHierarchyLevel(body);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("계층 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@PutMapping("/levels/{levelId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateLevel(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long levelId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("level_id", levelId);
|
||||
Map<String, Object> result = cascadingHierarchyService.updateCascadingHierarchyLevel(body);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("레벨을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@DeleteMapping("/levels/{levelId}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteLevel(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long levelId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("level_id", levelId);
|
||||
boolean deleted = cascadingHierarchyService.deleteCascadingHierarchyLevel(params);
|
||||
if (!deleted) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("레벨을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
@GetMapping("/{groupCode}/options/{levelOrder}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getLevelOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode,
|
||||
@PathVariable Integer levelOrder,
|
||||
@RequestParam(required = false) String parentValue) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
params.put("level_order", levelOrder);
|
||||
if (parentValue != null) params.put("parent_value", parentValue);
|
||||
Map<String, Object> result = cascadingHierarchyService.getLevelOptions(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("레벨을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
}
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CascadingMutualExclusionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 상호 배제 API
|
||||
* Node.js: app.use("/api/cascading-mutual-exclusion", cascadingMutualExclusionRoutes)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/cascading-mutual-exclusion")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CascadingMutualExclusionController {
|
||||
private final CascadingMutualExclusionService cascadingMutualExclusionService;
|
||||
|
||||
/** GET /list — 목록 조회 (alias) */
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingMutualExclusionListAlias(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.getCascadingMutualExclusionList(params)));
|
||||
}
|
||||
|
||||
/** GET / — 목록 조회 */
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingMutualExclusionList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.getCascadingMutualExclusionList(params)));
|
||||
}
|
||||
|
||||
/** GET /{exclusionId} — 상세 조회 */
|
||||
@GetMapping("/{exclusionId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingMutualExclusionInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long exclusionId) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("id", exclusionId);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.getCascadingMutualExclusionInfo(params)));
|
||||
}
|
||||
|
||||
/** POST / — 생성 */
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCascadingMutualExclusion(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.insertCascadingMutualExclusion(body)));
|
||||
}
|
||||
|
||||
/** PUT /{exclusionId} — 수정 */
|
||||
@PutMapping("/{exclusionId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCascadingMutualExclusion(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long exclusionId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("id", exclusionId);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.updateCascadingMutualExclusion(body)));
|
||||
}
|
||||
|
||||
/** DELETE /{exclusionId} — 하드 삭제 */
|
||||
@DeleteMapping("/{exclusionId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCascadingMutualExclusion(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long exclusionId) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("id", exclusionId);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.deleteCascadingMutualExclusion(params)));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /validate/{exclusionCode} — 상호 배제 검증
|
||||
* body: { "field_values": { "field_a": "val1", "field_b": "val1" } }
|
||||
*/
|
||||
@PostMapping("/validate/{exclusionCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> validateCascadingMutualExclusion(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String exclusionCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("code", exclusionCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.validateCascadingMutualExclusion(body)));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /options/{exclusionCode} — 배제 옵션 조회
|
||||
* query: selectedValues (콤마 구분된 이미 선택된 값들)
|
||||
*/
|
||||
@GetMapping("/options/{exclusionCode}")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getExcludedOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String exclusionCode,
|
||||
@RequestParam(required = false) String currentField,
|
||||
@RequestParam(required = false) String selectedValues) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", exclusionCode);
|
||||
params.put("current_field", currentField);
|
||||
params.put("selected_values", selectedValues);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.getExcludedOptions(params)));
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CascadingRelationService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 연쇄 관계 API
|
||||
* Node.js: app.use("/api/cascading-relation", cascadingRelationRoutes)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/cascading-relation")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CascadingRelationController {
|
||||
private final CascadingRelationService cascadingRelationService;
|
||||
|
||||
/** GET /api/cascading-relation/list — 목록 조회 (alias) */
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingRelationListAlias(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.getCascadingRelationList(params)));
|
||||
}
|
||||
|
||||
/** GET /api/cascading-relation — 목록 조회 */
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingRelationList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.getCascadingRelationList(params)));
|
||||
}
|
||||
|
||||
/** GET /api/cascading-relation/{id} — 상세 조회 */
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingRelationInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long id) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("id", id);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.getCascadingRelationInfo(params)));
|
||||
}
|
||||
|
||||
/** GET /api/cascading-relation/code/{code} — 코드로 단건 조회 */
|
||||
@GetMapping("/code/{code}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingRelationByCode(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.getCascadingRelationByCode(params)));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cascading-relation/parent-options/{code}
|
||||
* 부모 옵션 조회 (parent_table 동적 쿼리)
|
||||
*/
|
||||
@GetMapping("/parent-options/{code}")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getParentOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.getParentOptions(params)));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cascading-relation/options/{code}?parentValue=&parentValues=
|
||||
* 연쇄 자식 옵션 조회 (child_table 동적 쿼리)
|
||||
*/
|
||||
@GetMapping("/options/{code}")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCascadingOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code,
|
||||
@RequestParam(required = false) String parentValue,
|
||||
@RequestParam(required = false) String parentValues) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
params.put("parent_value", parentValue);
|
||||
params.put("parent_values", parentValues);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.getCascadingOptions(params)));
|
||||
}
|
||||
|
||||
/** POST /api/cascading-relation — 생성 */
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCascadingRelation(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "user_id", required = false) String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("user_id", userId != null ? userId : "system");
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.insertCascadingRelation(body)));
|
||||
}
|
||||
|
||||
/** PUT /api/cascading-relation/{id} — 수정 */
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCascadingRelation(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "user_id", required = false) String userId,
|
||||
@PathVariable Long id,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("user_id", userId != null ? userId : "system");
|
||||
body.put("id", id);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.updateCascadingRelation(body)));
|
||||
}
|
||||
|
||||
/** DELETE /api/cascading-relation/{id} — 소프트 삭제 (is_active = 'N') */
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCascadingRelation(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "user_id", required = false) String userId,
|
||||
@PathVariable Long id) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("user_id", userId != null ? userId : "system");
|
||||
params.put("id", id);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.deleteCascadingRelation(params)));
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CategoryTreeService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/category-tree")
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryTreeController {
|
||||
|
||||
private final CategoryTreeService categoryTreeService;
|
||||
|
||||
/**
|
||||
* GET /api/category-tree/test/all-category-keys
|
||||
* 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합)
|
||||
* 주의: /test/{tableName}/{columnName} 보다 먼저 매핑되어야 함
|
||||
*/
|
||||
@GetMapping("/test/all-category-keys")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryTreeKeyList(
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
|
||||
List<Map<String, Object>> keys = categoryTreeService.getCategoryTreeKeyList(companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(keys));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/category-tree/test/{tableName}/{columnName}
|
||||
* 카테고리 트리 조회
|
||||
*/
|
||||
@GetMapping("/test/{tableName}/{columnName}")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryTreeList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName) {
|
||||
|
||||
List<Map<String, Object>> tree = categoryTreeService.getCategoryTreeList(companyCode, tableName, columnName);
|
||||
return ResponseEntity.ok(ApiResponse.success(tree));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/category-tree/test/{tableName}/{columnName}/flat
|
||||
* 카테고리 플랫 리스트 조회
|
||||
*/
|
||||
@GetMapping("/test/{tableName}/{columnName}/flat")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryTreeFlatList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName) {
|
||||
|
||||
List<Map<String, Object>> list = categoryTreeService.getCategoryTreeFlatList(companyCode, tableName, columnName);
|
||||
return ResponseEntity.ok(ApiResponse.success(list));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/category-tree/test/value/{valueId}
|
||||
* 카테고리 값 단건 조회
|
||||
*/
|
||||
@GetMapping("/test/value/{valueId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryTreeInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable int valueId) {
|
||||
|
||||
Map<String, Object> value = categoryTreeService.getCategoryTreeInfo(companyCode, valueId);
|
||||
if (value == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값을 찾을 수 없습니다"));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/category-tree/test/value
|
||||
* 카테고리 값 생성
|
||||
*/
|
||||
@PostMapping("/test/value")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCategoryTree(
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (body.get("table_name") == null || body.get("column_name") == null
|
||||
|| body.get("value_code") == null || body.get("value_label") == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("tableName, columnName, valueCode, valueLabel은 필수입니다"));
|
||||
}
|
||||
|
||||
// 최고 관리자(*) 는 targetCompanyCode 로 회사 코드 오버라이드 가능
|
||||
String companyCode = userCompanyCode;
|
||||
String targetCompanyCode = (String) body.get("target_company_code");
|
||||
if (targetCompanyCode != null && "*".equals(userCompanyCode)) {
|
||||
companyCode = targetCompanyCode;
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, Object> value = categoryTreeService.insertCategoryTree(body, companyCode, userId);
|
||||
return ResponseEntity.ok(ApiResponse.success(value));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 생성 오류", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/category-tree/test/value/{valueId}
|
||||
* 카테고리 값 수정
|
||||
*/
|
||||
@PutMapping("/test/value/{valueId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCategoryTree(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@PathVariable int valueId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
try {
|
||||
Map<String, Object> value = categoryTreeService.updateCategoryTree(companyCode, valueId, body, userId);
|
||||
if (value == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값을 찾을 수 없습니다"));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(value));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 수정 오류", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/category-tree/test/value/{valueId}/can-delete
|
||||
* 카테고리 값 삭제 가능 여부 사전 확인
|
||||
*/
|
||||
@GetMapping("/test/value/{valueId}/can-delete")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCanDelete(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable int valueId) {
|
||||
|
||||
Map<String, Object> result = categoryTreeService.checkCanDelete(companyCode, valueId);
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/category-tree/test/value/{valueId}
|
||||
* 카테고리 값 삭제
|
||||
*/
|
||||
@DeleteMapping("/test/value/{valueId}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteCategoryTree(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable int valueId) {
|
||||
|
||||
try {
|
||||
boolean deleted = categoryTreeService.deleteCategoryTree(companyCode, valueId);
|
||||
if (!deleted) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값을 찾을 수 없습니다"));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "삭제되었습니다"));
|
||||
} catch (IllegalStateException e) {
|
||||
String msg = e.getMessage();
|
||||
if (msg != null && msg.startsWith("VALIDATION:")) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error(msg.substring("VALIDATION:".length())));
|
||||
}
|
||||
log.error("카테고리 값 삭제 오류", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error(msg));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 삭제 오류", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/category-tree/test/columns/{tableName}
|
||||
* 테이블의 카테고리 컬럼 목록 조회
|
||||
*/
|
||||
@GetMapping("/test/columns/{tableName}")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryTreeColumnList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String tableName) {
|
||||
|
||||
List<Map<String, Object>> columns = categoryTreeService.getCategoryTreeColumnList(companyCode, tableName);
|
||||
return ResponseEntity.ok(ApiResponse.success(columns));
|
||||
}
|
||||
}
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CategoryValueCascadingService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/category-value-cascading")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CategoryValueCascadingController {
|
||||
private final CategoryValueCascadingService categoryValueCascadingService;
|
||||
|
||||
/** GET /groups → 그룹 목록 조회 */
|
||||
@GetMapping("/groups")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingGroupList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingGroupList(params)));
|
||||
}
|
||||
|
||||
/** GET /groups/{groupId} → 그룹 상세 조회 (매핑 포함) */
|
||||
@GetMapping("/groups/{groupId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingGroupInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long groupId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_id", groupId);
|
||||
Map<String, Object> result = categoryValueCascadingService.getCategoryValueCascadingGroupInfo(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값 연쇄관계 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
/** GET /code/{code} → 관계 코드로 조회 */
|
||||
@GetMapping("/code/{code}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingGroupByCode(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
Map<String, Object> result = categoryValueCascadingService.getCategoryValueCascadingGroupByCode(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값 연쇄관계를 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
/** POST /groups → 그룹 생성 */
|
||||
@PostMapping("/groups")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCategoryValueCascadingGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.insertCategoryValueCascadingGroup(body)));
|
||||
}
|
||||
|
||||
/** PUT /groups/{groupId} → 그룹 수정 */
|
||||
@PutMapping("/groups/{groupId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCategoryValueCascadingGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long groupId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("group_id", groupId);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.updateCategoryValueCascadingGroup(body)));
|
||||
}
|
||||
|
||||
/** DELETE /groups/{groupId} → 그룹 소프트 삭제 */
|
||||
@DeleteMapping("/groups/{groupId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCategoryValueCascadingGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long groupId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_id", groupId);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.deleteCategoryValueCascadingGroup(params)));
|
||||
}
|
||||
|
||||
/** POST /groups/{groupId}/mappings → 매핑 일괄 저장 */
|
||||
@PostMapping("/groups/{groupId}/mappings")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> saveCategoryValueCascadingMappings(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long groupId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("group_id", groupId);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.saveCategoryValueCascadingMappings(body)));
|
||||
}
|
||||
|
||||
/** GET /parent-options/{code} → 부모 카테고리 값 목록 */
|
||||
@GetMapping("/parent-options/{code}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingParentOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingParentOptions(params)));
|
||||
}
|
||||
|
||||
/** GET /child-options/{code} → 자식 카테고리 값 목록 */
|
||||
@GetMapping("/child-options/{code}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingChildOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingChildOptions(params)));
|
||||
}
|
||||
|
||||
/** GET /options/{code} → 연쇄 옵션 조회 (parentValue/parentValues 파라미터) */
|
||||
@GetMapping("/options/{code}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingOptions(params)));
|
||||
}
|
||||
|
||||
/** GET /table/{tableName}/mappings → 테이블별 매핑 조회 */
|
||||
@GetMapping("/table/{tableName}/mappings")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingMappingsByTable(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String tableName) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("table_name", tableName);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingMappingsByTable(params)));
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CodeMergeService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/code-merge")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CodeMergeController {
|
||||
|
||||
private final CodeMergeService codeMergeService;
|
||||
|
||||
/** POST /api/code-merge/merge-all-tables — columnName 기준 전체 테이블 코드 병합 */
|
||||
@PostMapping("/merge-all-tables")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> mergeAllTables(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
codeMergeService.mergeAllTables(body),
|
||||
"코드 병합이 완료되었습니다."));
|
||||
}
|
||||
|
||||
/** GET /api/code-merge/tables-with-column/:columnName — 해당 컬럼을 가진 테이블 목록 */
|
||||
@GetMapping("/tables-with-column/{columnName}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getTablesWithColumn(
|
||||
@PathVariable String columnName) {
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
codeMergeService.getTablesWithColumn(columnName)));
|
||||
}
|
||||
|
||||
/** POST /api/code-merge/preview — columnName + oldValue 기준 영향 미리보기 */
|
||||
@PostMapping("/preview")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> previewCodeMerge(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
codeMergeService.previewCodeMerge(body)));
|
||||
}
|
||||
|
||||
/** POST /api/code-merge/merge-by-value — 값 기반 전체 테이블/컬럼 코드 병합 */
|
||||
@PostMapping("/merge-by-value")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> mergeByValue(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
codeMergeService.mergeByValue(body),
|
||||
"코드 병합이 완료되었습니다."));
|
||||
}
|
||||
|
||||
/** POST /api/code-merge/preview-by-value — 값 기반 영향 미리보기 */
|
||||
@PostMapping("/preview-by-value")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> previewByValue(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
codeMergeService.previewByValue(body)));
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,18 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Common Code Controller
|
||||
* Common Code Controller — 마스터-디테일 패턴.
|
||||
*
|
||||
* commonCodeRoutes.ts 포팅 — /api/common-codes 기준 15개 엔드포인트.
|
||||
* /info : code_info (1레벨 그룹 마스터)
|
||||
* /detail : code_detail (2레벨~ 트리)
|
||||
*
|
||||
* NOTE: Spring MVC 는 리터럴 세그먼트를 경로변수보다 우선하므로
|
||||
* /check-duplicate, /reorder 는 별도 선언 순서 없이도 정상 동작하나,
|
||||
* 가독성을 위해 구체적인 경로를 먼저 선언한다.
|
||||
* /check-duplicate 는 /{codeInfo} / /{id} 보다 먼저 매칭된다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/common-codes")
|
||||
@@ -27,151 +28,200 @@ public class CommonCodeController {
|
||||
|
||||
private final CommonCodeService service;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories
|
||||
// Node.js: { success, data: [...], total, message } (flat, not nested)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// CODE_INFO — 그룹 마스터
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
|
||||
@GetMapping("/categories")
|
||||
public ResponseEntity<Map<String, Object>> getCommonCodeCategoryList(
|
||||
/** 그룹 목록 (페이징/검색) */
|
||||
@GetMapping("/info")
|
||||
public ResponseEntity<Map<String, Object>> getCodeInfoList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
|
||||
params.put("company_code", companyCode);
|
||||
Map<String, Object> svcResult = service.getCommonCodeCategoryList(params);
|
||||
Map<String, Object> svc = service.getCodeInfoList(params);
|
||||
|
||||
Map<String, Object> response = new java.util.LinkedHashMap<>();
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", svcResult.get("data"));
|
||||
response.put("total", svcResult.get("total"));
|
||||
response.put("message", "카테고리 목록 조회 성공");
|
||||
response.put("data", svc.get("data"));
|
||||
response.put("total", svc.get("total"));
|
||||
response.put("message", "코드 그룹 목록 조회 성공");
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/check-duplicate ← /{categoryCode} 보다 먼저
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/categories/check-duplicate")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCategoryDuplicate(
|
||||
/** 그룹 중복 체크 — /{codeInfo} 보다 먼저 선언 */
|
||||
@GetMapping("/info/check-duplicate")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCodeInfoDuplicate(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam(defaultValue = "category_code") String field,
|
||||
@RequestParam(defaultValue = "code_info") String field,
|
||||
@RequestParam String value,
|
||||
@RequestParam(required = false) String excludeCode) {
|
||||
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.checkCategoryDuplicate(field, value, excludeCode, companyCode)));
|
||||
ApiResponse.success(service.checkCodeInfoDuplicate(field, value, excludeCode, companyCode)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// POST /categories
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
/** 그룹 단건 */
|
||||
@GetMapping("/info/{codeInfo}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCodeInfoInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String codeInfo) {
|
||||
|
||||
@PostMapping("/categories")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCommonCodeCategory(
|
||||
Map<String, Object> info = service.getCodeInfoInfo(codeInfo, companyCode);
|
||||
if (info == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("코드 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(info));
|
||||
}
|
||||
|
||||
/** 그룹 생성 */
|
||||
@PostMapping("/info")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCodeInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (body.get("category_code") == null || body.get("category_name") == null) {
|
||||
if (body.get("code_info") == null || body.get("code_name") == null) {
|
||||
return ResponseEntity.status(400)
|
||||
.body(ApiResponse.error("필수 필드가 누락되었습니다. (categoryCode, categoryName)"));
|
||||
.body(ApiResponse.error("필수 필드가 누락되었습니다. (code_info, code_name)"));
|
||||
}
|
||||
try {
|
||||
Map<String, Object> created = service.insertCommonCodeCategory(body, companyCode, userId);
|
||||
Map<String, Object> created = service.insertCodeInfo(body, companyCode, userId);
|
||||
return ResponseEntity.status(201)
|
||||
.body(ApiResponse.success(created, "카테고리가 성공적으로 생성되었습니다."));
|
||||
.body(ApiResponse.success(created, "코드 그룹이 성공적으로 생성되었습니다."));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 생성 실패", e);
|
||||
log.error("코드 그룹 생성 실패", e);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("카테고리 생성에 실패했습니다."));
|
||||
.body(ApiResponse.error("코드 그룹 생성에 실패했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// PUT /categories/:categoryCode
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@PutMapping("/categories/{categoryCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCommonCodeCategory(
|
||||
/** 그룹 수정 */
|
||||
@PutMapping("/info/{codeInfo}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCodeInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@PathVariable String categoryCode,
|
||||
@PathVariable String codeInfo,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
try {
|
||||
Map<String, Object> updated = service.updateCommonCodeCategory(categoryCode, body, companyCode, userId);
|
||||
Map<String, Object> updated = service.updateCodeInfo(codeInfo, body, companyCode, userId);
|
||||
if (updated == null) {
|
||||
return ResponseEntity.status(404)
|
||||
.body(ApiResponse.error("카테고리를 찾을 수 없습니다."));
|
||||
.body(ApiResponse.error("코드 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(updated, "카테고리가 성공적으로 수정되었습니다."));
|
||||
return ResponseEntity.ok(ApiResponse.success(updated, "코드 그룹이 성공적으로 수정되었습니다."));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 수정 실패", e);
|
||||
log.error("코드 그룹 수정 실패", e);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("카테고리 수정에 실패했습니다."));
|
||||
.body(ApiResponse.error("코드 그룹 수정에 실패했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DELETE /categories/:categoryCode
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@DeleteMapping("/categories/{categoryCode}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteCommonCodeCategory(
|
||||
/** 그룹 삭제 (CASCADE 로 code_detail 자식 자동 삭제) */
|
||||
@DeleteMapping("/info/{codeInfo}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteCodeInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode) {
|
||||
@PathVariable String codeInfo) {
|
||||
|
||||
try {
|
||||
service.deleteCommonCodeCategory(categoryCode, companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "카테고리가 성공적으로 삭제되었습니다."));
|
||||
service.deleteCodeInfo(codeInfo, companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "코드 그룹이 성공적으로 삭제되었습니다."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 삭제 실패", e);
|
||||
log.error("코드 그룹 삭제 실패", e);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("카테고리 삭제에 실패했습니다."));
|
||||
.body(ApiResponse.error("코드 그룹 삭제에 실패했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/:categoryCode/codes
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// CODE_DETAIL — 디테일 트리
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
|
||||
@GetMapping("/categories/{categoryCode}/codes")
|
||||
public ResponseEntity<Map<String, Object>> getCommonCodeList(
|
||||
/**
|
||||
* 디테일 트리.
|
||||
* - code_info 필수 (어느 그룹)
|
||||
* - parent_detail_id (optional): 지정 시 해당 부모의 자식만, 미지정 시 그룹 전체 트리 (재귀 CTE)
|
||||
* - flat=true 인 경우 동일 (트리는 평탄화된 depth+sort_order 순)
|
||||
*/
|
||||
@GetMapping("/detail")
|
||||
public ResponseEntity<Map<String, Object>> getCodeDetail(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@RequestParam("code_info") String codeInfo,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
|
||||
params.put("company_code", companyCode);
|
||||
Map<String, Object> svcResult = service.getCommonCodeList(categoryCode, params);
|
||||
|
||||
Map<String, Object> response = new java.util.LinkedHashMap<>();
|
||||
Object parentRaw = params.get("parent_detail_id");
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", svcResult.get("data"));
|
||||
response.put("total", svcResult.get("total"));
|
||||
response.put("message", "코드 목록 조회 성공");
|
||||
|
||||
if (parentRaw != null && !parentRaw.toString().isEmpty()) {
|
||||
// 특정 부모 직속 자식만
|
||||
Map<String, Object> svc = service.getCodeDetailList(codeInfo, params);
|
||||
response.put("data", svc.get("data"));
|
||||
response.put("total", svc.get("total"));
|
||||
} else {
|
||||
// 그룹 전체 트리 (재귀 CTE 로 평탄화)
|
||||
List<Map<String, Object>> tree = service.getCodeDetailTree(codeInfo, companyCode);
|
||||
response.put("data", tree);
|
||||
response.put("total", tree.size());
|
||||
}
|
||||
response.put("message", "코드 디테일 조회 성공");
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// POST /categories/:categoryCode/codes
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
/** 디테일 중복 체크 — /{id} 보다 먼저 선언 */
|
||||
@GetMapping("/detail/check-duplicate")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCodeDetailDuplicate(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam("code_info") String codeInfo,
|
||||
@RequestParam("code_value") String codeValue,
|
||||
@RequestParam(value = "exclude_id", required = false) Long excludeId) {
|
||||
|
||||
@PostMapping("/categories/{categoryCode}/codes")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCommonCode(
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.checkCodeDetailDuplicate(codeInfo, codeValue, excludeId, companyCode)));
|
||||
}
|
||||
|
||||
/** 디테일 단건 */
|
||||
@GetMapping("/detail/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCodeDetailInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable("id") Long codeDetailId) {
|
||||
|
||||
Map<String, Object> info = service.getCodeDetailInfo(codeDetailId, companyCode);
|
||||
if (info == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("코드를 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(info));
|
||||
}
|
||||
|
||||
/** 디테일 자식 존재 여부 */
|
||||
@GetMapping("/detail/{id}/has-children")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> hasCodeDetailChildren(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable("id") Long codeDetailId) {
|
||||
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.hasCodeDetailChildren(codeDetailId, companyCode)));
|
||||
}
|
||||
|
||||
/** 디테일 생성 */
|
||||
@PostMapping("/detail")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCodeDetail(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@PathVariable String categoryCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (body.get("code_value") == null || body.get("code_name") == null) {
|
||||
Object codeInfoRaw = body.get("code_info");
|
||||
if (codeInfoRaw == null || body.get("code_value") == null || body.get("code_name") == null) {
|
||||
return ResponseEntity.status(400)
|
||||
.body(ApiResponse.error("필수 필드가 누락되었습니다. (codeValue, codeName)"));
|
||||
.body(ApiResponse.error("필수 필드가 누락되었습니다. (code_info, code_value, code_name)"));
|
||||
}
|
||||
try {
|
||||
Map<String, Object> created = service.insertCommonCode(categoryCode, body, companyCode, userId);
|
||||
Map<String, Object> created = service.insertCodeDetail(codeInfoRaw.toString(), body, companyCode, userId);
|
||||
return ResponseEntity.status(201)
|
||||
.body(ApiResponse.success(created, "코드가 성공적으로 생성되었습니다."));
|
||||
} catch (Exception e) {
|
||||
@@ -181,122 +231,18 @@ public class CommonCodeController {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/:categoryCode/codes/check-duplicate ← /{codeValue} 보다 먼저
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/categories/{categoryCode}/codes/check-duplicate")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCodeDuplicate(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@RequestParam(defaultValue = "code_value") String field,
|
||||
@RequestParam String value,
|
||||
@RequestParam(required = false) String excludeCode) {
|
||||
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.checkCodeDuplicate(categoryCode, field, value, excludeCode, companyCode)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// PUT /categories/:categoryCode/codes/reorder ← /{codeValue} 보다 먼저
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@PutMapping("/categories/{categoryCode}/codes/reorder")
|
||||
public ResponseEntity<ApiResponse<Void>> updateCommonCodeOrder(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
Object codesRaw = body.get("codes");
|
||||
if (!(codesRaw instanceof List)) {
|
||||
return ResponseEntity.status(400)
|
||||
.body(ApiResponse.error("codes 배열이 필요합니다."));
|
||||
}
|
||||
try {
|
||||
service.updateCommonCodeOrder(categoryCode, (List<Map<String, Object>>) codesRaw, companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "정렬 순서가 변경되었습니다."));
|
||||
} catch (Exception e) {
|
||||
log.error("코드 정렬 변경 실패", e);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("정렬 순서 변경에 실패했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/:categoryCode/hierarchy
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/categories/{categoryCode}/hierarchy")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCommonCodeHierarchicalList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.getCommonCodeHierarchicalList(categoryCode, params)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/:categoryCode/tree
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/categories/{categoryCode}/tree")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCommonCodeTree(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode) {
|
||||
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.getCommonCodeTree(categoryCode, companyCode)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/:categoryCode/options
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/categories/{categoryCode}/options")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCommonCodeOptionList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.getCommonCodeOptionList(categoryCode, params)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/:categoryCode/codes/:codeValue/has-children
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/categories/{categoryCode}/codes/{codeValue}/has-children")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> hasChildren(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@PathVariable String codeValue) {
|
||||
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.hasChildren(categoryCode, codeValue, companyCode)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// PUT /categories/:categoryCode/codes/:codeValue
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@PutMapping("/categories/{categoryCode}/codes/{codeValue}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCommonCode(
|
||||
/** 디테일 수정 */
|
||||
@PutMapping("/detail/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCodeDetail(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@PathVariable String categoryCode,
|
||||
@PathVariable String codeValue,
|
||||
@PathVariable("id") Long codeDetailId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
try {
|
||||
Map<String, Object> updated = service.updateCommonCode(categoryCode, codeValue, body, companyCode, userId);
|
||||
Map<String, Object> updated = service.updateCodeDetail(codeDetailId, body, companyCode, userId);
|
||||
if (updated == null) {
|
||||
return ResponseEntity.status(404)
|
||||
.body(ApiResponse.error("코드를 찾을 수 없습니다."));
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("코드를 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(updated, "코드가 성공적으로 수정되었습니다."));
|
||||
} catch (Exception e) {
|
||||
@@ -306,18 +252,14 @@ public class CommonCodeController {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DELETE /categories/:categoryCode/codes/:codeValue
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@DeleteMapping("/categories/{categoryCode}/codes/{codeValue}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteCommonCode(
|
||||
/** 디테일 삭제 (CASCADE 로 자식 자동 삭제) */
|
||||
@DeleteMapping("/detail/{id}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteCodeDetail(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@PathVariable String codeValue) {
|
||||
@PathVariable("id") Long codeDetailId) {
|
||||
|
||||
try {
|
||||
service.deleteCommonCode(categoryCode, codeValue, companyCode);
|
||||
service.deleteCodeDetail(codeDetailId, companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "코드가 성공적으로 삭제되었습니다."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error(e.getMessage()));
|
||||
|
||||
@@ -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,296 +34,47 @@ 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("company_code", companyCode);
|
||||
params.put("updated_by", userId);
|
||||
|
||||
if (body.containsKey("category_name")) params.put("category_name", body.get("category_name"));
|
||||
if (body.containsKey("category_name_eng")) params.put("category_name_eng", body.get("category_name_eng"));
|
||||
if (body.containsKey("description")) params.put("description", body.get("description"));
|
||||
if (body.containsKey("sort_order")) params.put("sort_order", body.get("sort_order"));
|
||||
if (body.containsKey("is_active")) params.put("is_active", toActiveStr(body.get("is_active")));
|
||||
|
||||
int updated = sqlSession.update(NS + "updateCommonCodeCategory", params);
|
||||
if (updated == 0) return null;
|
||||
|
||||
Map<String, Object> q = new HashMap<>();
|
||||
q.put("category_code", categoryCode);
|
||||
q.put("company_code", companyCode);
|
||||
return sqlSession.selectOne(NS + "getCommonCodeCategoryInfo", q);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 카테고리 삭제
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public void deleteCommonCodeCategory(String categoryCode, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("company_code", companyCode);
|
||||
int deleted = sqlSession.delete(NS + "deleteCommonCodeCategory", params);
|
||||
if (deleted == 0) throw new IllegalArgumentException("카테고리를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 목록 (snake_case + camelCase 이중 필드)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public Map<String, Object> getCommonCodeList(String categoryCode, Map<String, Object> params) {
|
||||
int page = toInt(params.get("page"), 1);
|
||||
int size = toInt(params.get("size"), 20);
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("limit", size);
|
||||
params.put("offset", (page - 1) * size);
|
||||
|
||||
Object isActiveRaw = params.get("is_active");
|
||||
if (isActiveRaw != null) params.put("is_active", toActiveStr(isActiveRaw));
|
||||
|
||||
List<Map<String, Object>> rawList = sqlSession.selectList(NS + "getCommonCodeList", params);
|
||||
Integer totalObj = sqlSession.selectOne(NS + "getCommonCodeListCnt", params);
|
||||
int total = totalObj != null ? totalObj : 0;
|
||||
|
||||
List<Map<String, Object>> codes = new ArrayList<>();
|
||||
for (Map<String, Object> raw : rawList) {
|
||||
codes.add(transformCode(raw));
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", codes);
|
||||
result.put("total", total);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 중복 확인
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public Map<String, Object> checkCodeDuplicate(String categoryCode, String field, String value,
|
||||
String excludeCode, String companyCode) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("is_duplicate", false);
|
||||
result.put("message", "값을 입력해주세요.");
|
||||
return result;
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("field", field != null ? field : "code_value");
|
||||
params.put("value", value.trim());
|
||||
params.put("exclude_code", excludeCode);
|
||||
params.put("company_code", companyCode);
|
||||
Integer countObj = sqlSession.selectOne(NS + "getCommonCodeDuplicateByField", params);
|
||||
boolean isDuplicate = countObj != null && countObj > 0;
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("is_duplicate", isDuplicate);
|
||||
result.put("message", isDuplicate ? "이미 사용 중인 값입니다." : "사용 가능한 값입니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 생성
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCommonCode(String categoryCode, Map<String, Object> body,
|
||||
String companyCode, String userId) {
|
||||
// parentCodeValue 기반 depth 자동 계산
|
||||
Object parentCodeValueRaw = body.getOrDefault("parent_code_value", null);
|
||||
int depth = 1;
|
||||
if (parentCodeValueRaw != null && !parentCodeValueRaw.toString().isEmpty()) {
|
||||
Map<String, Object> parentParams = new HashMap<>();
|
||||
parentParams.put("category_code", categoryCode);
|
||||
parentParams.put("code_value", parentCodeValueRaw.toString());
|
||||
parentParams.put("company_code", companyCode);
|
||||
Integer parentDepth = sqlSession.selectOne(NS + "getCommonCodeParentDepth", parentParams);
|
||||
depth = (parentDepth != null ? parentDepth : 0) + 1;
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("code_value", body.get("code_value"));
|
||||
params.put("code_info", body.get("code_info"));
|
||||
params.put("code_name", body.get("code_name"));
|
||||
params.put("code_name_eng", body.getOrDefault("code_name_eng", null));
|
||||
params.put("description", body.getOrDefault("description", null));
|
||||
params.put("sort_order", body.getOrDefault("sort_order", 0));
|
||||
params.put("is_active", toActiveStr(body.getOrDefault("is_active", true)));
|
||||
params.put("menu_objid", body.getOrDefault("menu_objid", DEFAULT_MENU_OBJID));
|
||||
params.put("menu_objid", body.getOrDefault("menu_objid", null));
|
||||
params.put("company_code", companyCode);
|
||||
params.put("parent_code_value", parentCodeValueRaw);
|
||||
params.put("depth", depth);
|
||||
params.put("created_by", userId);
|
||||
params.put("updated_by", userId);
|
||||
|
||||
sqlSession.insert(NS + "insertCommonCode", params);
|
||||
sqlSession.insert(NS + "insertCodeInfo", params);
|
||||
|
||||
Map<String, Object> q = new HashMap<>();
|
||||
q.put("category_code", categoryCode);
|
||||
q.put("code_value", params.get("code_value"));
|
||||
q.put("company_code", companyCode);
|
||||
Map<String, Object> raw = sqlSession.selectOne(NS + "getCommonCodeInfo", q);
|
||||
return raw != null ? transformCode(raw) : null;
|
||||
return getCodeInfoInfo(String.valueOf(params.get("code_info")), companyCode);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 정렬 순서 변경
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public void updateCommonCodeOrder(String categoryCode, List<Map<String, Object>> codes, String companyCode) {
|
||||
for (Map<String, Object> code : codes) {
|
||||
public Map<String, Object> updateCodeInfo(String codeInfo, Map<String, Object> body,
|
||||
String companyCode, String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("code_value", code.get("code_value"));
|
||||
params.put("sort_order", code.get("sort_order"));
|
||||
params.put("company_code", companyCode);
|
||||
sqlSession.update(NS + "updateCommonCodeSortOrder", params);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 계층형 코드 목록
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public List<Map<String, Object>> getCommonCodeHierarchicalList(String categoryCode, Map<String, Object> params) {
|
||||
params.put("category_code", categoryCode);
|
||||
|
||||
Object isActiveRaw = params.get("is_active");
|
||||
if (isActiveRaw != null) params.put("is_active", toActiveStr(isActiveRaw));
|
||||
else params.remove("is_active");
|
||||
|
||||
// parentCodeValue, depth 필터는 params에 그대로 전달 (XML에서 처리)
|
||||
|
||||
List<Map<String, Object>> rawList = sqlSession.selectList(NS + "getCommonCodeHierarchicalList", params);
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Map<String, Object> raw : rawList) {
|
||||
result.add(transformCode(raw));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 트리 구조 — { flat: [...], tree: [...] }
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public Map<String, Object> getCommonCodeTree(String categoryCode, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("company_code", companyCode);
|
||||
|
||||
List<Map<String, Object>> flatList = sqlSession.selectList(NS + "getCommonCodeTreeList", params);
|
||||
|
||||
List<Map<String, Object>> flatTransformed = new ArrayList<>();
|
||||
for (Map<String, Object> raw : flatList) {
|
||||
flatTransformed.add(transformCode(raw));
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("flat", flatTransformed);
|
||||
result.put("tree", buildTree(flatList));
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 자식 존재 여부
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public Map<String, Object> hasChildren(String categoryCode, String codeValue, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("code_value", codeValue);
|
||||
params.put("company_code", companyCode);
|
||||
Integer countObj = sqlSession.selectOne(NS + "getCommonCodeChildrenCnt", params);
|
||||
int count = countObj != null ? countObj : 0;
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("has_children", count > 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 수정
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCommonCode(String categoryCode, String codeValue,
|
||||
Map<String, Object> body, String companyCode, String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("code_value", codeValue);
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("company_code", companyCode);
|
||||
params.put("updated_by", userId);
|
||||
|
||||
@@ -332,127 +83,224 @@ public class CommonCodeService extends BaseService {
|
||||
if (body.containsKey("description")) params.put("description", body.get("description"));
|
||||
if (body.containsKey("sort_order")) params.put("sort_order", body.get("sort_order"));
|
||||
if (body.containsKey("is_active")) params.put("is_active", toActiveStr(body.get("is_active")));
|
||||
if (body.containsKey("menu_objid")) params.put("menu_objid", body.get("menu_objid"));
|
||||
|
||||
if (body.containsKey("parent_code_value")) {
|
||||
Object newParent = body.get("parent_code_value");
|
||||
params.put("parent_code_value", newParent);
|
||||
// parentCodeValue 변경 시 depth 재계산
|
||||
if (newParent != null && !newParent.toString().isEmpty()) {
|
||||
Map<String, Object> parentParams = new HashMap<>();
|
||||
parentParams.put("category_code", categoryCode);
|
||||
parentParams.put("code_value", newParent.toString());
|
||||
parentParams.put("company_code", companyCode);
|
||||
Integer parentDepth = sqlSession.selectOne(NS + "getCommonCodeParentDepth", parentParams);
|
||||
params.put("depth", (parentDepth != null ? parentDepth : 0) + 1);
|
||||
} else {
|
||||
params.put("depth", 1);
|
||||
}
|
||||
} else if (body.containsKey("depth")) {
|
||||
params.put("depth", body.get("depth"));
|
||||
}
|
||||
|
||||
int updated = sqlSession.update(NS + "updateCommonCode", params);
|
||||
int updated = sqlSession.update(NS + "updateCodeInfo", params);
|
||||
if (updated == 0) return null;
|
||||
|
||||
Map<String, Object> q = new HashMap<>();
|
||||
q.put("category_code", categoryCode);
|
||||
q.put("code_value", codeValue);
|
||||
q.put("company_code", companyCode);
|
||||
Map<String, Object> raw = sqlSession.selectOne(NS + "getCommonCodeInfo", q);
|
||||
return raw != null ? transformCode(raw) : null;
|
||||
return getCodeInfoInfo(codeInfo, companyCode);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteCodeInfo(String codeInfo, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("company_code", companyCode);
|
||||
|
||||
int deleted = sqlSession.delete(NS + "deleteCodeInfo", params);
|
||||
if (deleted == 0) throw new IllegalArgumentException("코드 그룹을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
public Map<String, Object> checkCodeInfoDuplicate(String field, String value,
|
||||
String excludeCode, String companyCode) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
result.put("is_duplicate", false);
|
||||
result.put("message", "값을 입력해주세요.");
|
||||
return result;
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("field", field != null ? field : "code_info");
|
||||
params.put("value", value.trim());
|
||||
params.put("exclude_code", excludeCode);
|
||||
params.put("company_code", companyCode);
|
||||
|
||||
Integer countObj = sqlSession.selectOne(NS + "getCodeInfoDuplicateByField", params);
|
||||
boolean isDuplicate = countObj != null && countObj > 0;
|
||||
|
||||
result.put("is_duplicate", isDuplicate);
|
||||
result.put("message", isDuplicate ? "이미 사용 중인 값입니다." : "사용 가능한 값입니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 삭제
|
||||
// CODE_DETAIL — 디테일 트리 CRUD
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public void deleteCommonCode(String categoryCode, String codeValue, String companyCode) {
|
||||
public Map<String, Object> getCodeDetailList(String codeInfo, Map<String, Object> params) {
|
||||
int page = toInt(params.get("page"), 1);
|
||||
int size = toInt(params.get("size"), 20);
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("limit", size);
|
||||
params.put("offset", (page - 1) * size);
|
||||
|
||||
Object isActiveRaw = params.get("is_active");
|
||||
if (isActiveRaw != null) params.put("is_active", toActiveStr(isActiveRaw));
|
||||
|
||||
Object parentRaw = params.get("parent_detail_id");
|
||||
if (parentRaw != null && !parentRaw.toString().isEmpty()) {
|
||||
params.put("parent_detail_id", toLong(parentRaw));
|
||||
} else {
|
||||
params.remove("parent_detail_id");
|
||||
}
|
||||
|
||||
List<Map<String, Object>> data = sqlSession.selectList(NS + "getCodeDetailList", params);
|
||||
Integer totalObj = sqlSession.selectOne(NS + "getCodeDetailListCnt", params);
|
||||
int total = totalObj != null ? totalObj : 0;
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", data);
|
||||
result.put("total", total);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹 전체 트리 — 평탄화된 리스트로 반환 (depth + sort_order 순).
|
||||
* 프론트가 parent_detail_id 로 nest 처리하기 좋게.
|
||||
*/
|
||||
public List<Map<String, Object>> getCodeDetailTree(String codeInfo, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("code_value", codeValue);
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("company_code", companyCode);
|
||||
int deleted = sqlSession.delete(NS + "deleteCommonCode", params);
|
||||
return sqlSession.selectList(NS + "getCodeDetailTree", params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getCodeDetailInfo(Long codeDetailId, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_detail_id", codeDetailId);
|
||||
params.put("company_code", companyCode);
|
||||
return sqlSession.selectOne(NS + "getCodeDetailInfo", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCodeDetail(String codeInfo, Map<String, Object> body,
|
||||
String companyCode, String userId) {
|
||||
// parent_detail_id 기반 depth 자동 계산. NULL = 그룹 직속 (depth=2).
|
||||
Long parentDetailId = toLong(body.getOrDefault("parent_detail_id", null));
|
||||
int depth = 2;
|
||||
if (parentDetailId != null) {
|
||||
Map<String, Object> parentParams = new HashMap<>();
|
||||
parentParams.put("code_detail_id", parentDetailId);
|
||||
parentParams.put("company_code", companyCode);
|
||||
Integer parentDepth = sqlSession.selectOne(NS + "getCodeDetailParentDepth", parentParams);
|
||||
depth = (parentDepth != null ? parentDepth : 1) + 1;
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("parent_detail_id", parentDetailId);
|
||||
params.put("code_value", body.get("code_value"));
|
||||
params.put("code_name", body.get("code_name"));
|
||||
params.put("code_name_eng", body.getOrDefault("code_name_eng", null));
|
||||
params.put("description", body.getOrDefault("description", null));
|
||||
params.put("depth", depth);
|
||||
params.put("sort_order", body.getOrDefault("sort_order", 0));
|
||||
params.put("is_active", toActiveStr(body.getOrDefault("is_active", true)));
|
||||
params.put("company_code", companyCode);
|
||||
params.put("created_by", userId);
|
||||
params.put("updated_by", userId);
|
||||
|
||||
sqlSession.insert(NS + "insertCodeDetail", params);
|
||||
|
||||
Object newIdRaw = params.get("code_detail_id");
|
||||
Long newId = toLong(newIdRaw);
|
||||
return newId != null ? getCodeDetailInfo(newId, companyCode) : null;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCodeDetail(Long codeDetailId, Map<String, Object> body,
|
||||
String companyCode, String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_detail_id", codeDetailId);
|
||||
params.put("company_code", companyCode);
|
||||
params.put("updated_by", userId);
|
||||
|
||||
if (body.containsKey("code_value")) params.put("code_value", body.get("code_value"));
|
||||
if (body.containsKey("code_name")) params.put("code_name", body.get("code_name"));
|
||||
if (body.containsKey("code_name_eng")) params.put("code_name_eng", body.get("code_name_eng"));
|
||||
if (body.containsKey("description")) params.put("description", body.get("description"));
|
||||
if (body.containsKey("sort_order")) params.put("sort_order", body.get("sort_order"));
|
||||
if (body.containsKey("is_active")) params.put("is_active", toActiveStr(body.get("is_active")));
|
||||
|
||||
// parent_detail_id 변경 시 depth 재계산.
|
||||
if (body.containsKey("parent_detail_id")) {
|
||||
Long newParent = toLong(body.get("parent_detail_id"));
|
||||
int newDepth = 2;
|
||||
if (newParent != null) {
|
||||
Map<String, Object> parentParams = new HashMap<>();
|
||||
parentParams.put("code_detail_id", newParent);
|
||||
parentParams.put("company_code", companyCode);
|
||||
Integer parentDepth = sqlSession.selectOne(NS + "getCodeDetailParentDepth", parentParams);
|
||||
newDepth = (parentDepth != null ? parentDepth : 1) + 1;
|
||||
}
|
||||
params.put("reparent", true);
|
||||
params.put("parent_detail_id", newParent);
|
||||
params.put("depth", newDepth);
|
||||
}
|
||||
|
||||
int updated = sqlSession.update(NS + "updateCodeDetail", params);
|
||||
if (updated == 0) return null;
|
||||
|
||||
return getCodeDetailInfo(codeDetailId, companyCode);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteCodeDetail(Long codeDetailId, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_detail_id", codeDetailId);
|
||||
params.put("company_code", companyCode);
|
||||
|
||||
int deleted = sqlSession.delete(NS + "deleteCodeDetail", params);
|
||||
if (deleted == 0) throw new IllegalArgumentException("코드를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 옵션 목록
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public List<Map<String, Object>> getCommonCodeOptionList(String categoryCode, Map<String, Object> params) {
|
||||
params.put("category_code", categoryCode);
|
||||
|
||||
Object isActiveRaw = params.get("is_active");
|
||||
// 미지정 시 활성 코드만 반환 (드롭다운 기본 동작)
|
||||
params.put("is_active", isActiveRaw != null ? toActiveStr(isActiveRaw) : "Y");
|
||||
|
||||
List<Map<String, Object>> rawList = sqlSession.selectList(NS + "getCommonCodeOptionList", params);
|
||||
List<Map<String, Object>> options = new ArrayList<>();
|
||||
for (Map<String, Object> raw : rawList) {
|
||||
Map<String, Object> opt = new LinkedHashMap<>();
|
||||
opt.put("value", raw.get("code_value"));
|
||||
opt.put("label", raw.get("code_name"));
|
||||
opt.put("label_eng", raw.get("code_name_eng"));
|
||||
options.add(opt);
|
||||
public Map<String, Object> checkCodeDetailDuplicate(String codeInfo, String codeValue,
|
||||
Long excludeId, String companyCode) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
if (codeValue == null || codeValue.trim().isEmpty()) {
|
||||
result.put("is_duplicate", false);
|
||||
result.put("message", "값을 입력해주세요.");
|
||||
return result;
|
||||
}
|
||||
return options;
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("code_value", codeValue.trim());
|
||||
params.put("exclude_id", excludeId);
|
||||
params.put("company_code", companyCode);
|
||||
|
||||
Integer countObj = sqlSession.selectOne(NS + "getCodeDetailDuplicateCnt", params);
|
||||
boolean isDuplicate = countObj != null && countObj > 0;
|
||||
|
||||
result.put("is_duplicate", isDuplicate);
|
||||
result.put("message", isDuplicate ? "이미 사용 중인 값입니다." : "사용 가능한 값입니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Object> hasCodeDetailChildren(Long codeDetailId, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_detail_id", codeDetailId);
|
||||
params.put("company_code", companyCode);
|
||||
|
||||
Integer countObj = sqlSession.selectOne(NS + "getCodeDetailChildrenCnt", params);
|
||||
int count = countObj != null ? countObj : 0;
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("has_children", count > 0);
|
||||
result.put("count", count);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Private helpers
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/** snake_case 원본 + camelCase 별칭을 모두 포함하는 Map 반환 */
|
||||
private Map<String, Object> transformCode(Map<String, Object> raw) {
|
||||
Map<String, Object> item = new LinkedHashMap<>(raw);
|
||||
item.put("code_value", raw.get("code_value"));
|
||||
item.put("code_name", raw.get("code_name"));
|
||||
item.put("code_name_eng", raw.get("code_name_eng"));
|
||||
item.put("code_category", raw.get("code_category"));
|
||||
item.put("sort_order", raw.get("sort_order"));
|
||||
item.put("is_active", raw.get("is_active"));
|
||||
item.put("menu_objid", raw.get("menu_objid"));
|
||||
item.put("company_code", raw.get("company_code"));
|
||||
item.put("parent_code_value", raw.get("parent_code_value"));
|
||||
item.put("created_by", raw.get("created_by"));
|
||||
item.put("updated_by", raw.get("updated_by"));
|
||||
item.put("created_date", raw.get("created_date"));
|
||||
item.put("updated_date", raw.get("updated_date"));
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 평탄 목록을 부모-자식 트리로 변환 */
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Map<String, Object>> buildTree(List<Map<String, Object>> codes) {
|
||||
Map<String, Map<String, Object>> byValue = new LinkedHashMap<>();
|
||||
for (Map<String, Object> code : codes) {
|
||||
String val = objToStr(code.get("code_value"));
|
||||
Map<String, Object> node = new LinkedHashMap<>(transformCode(code));
|
||||
node.put("children", new ArrayList<Map<String, Object>>());
|
||||
byValue.put(val, node);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> roots = new ArrayList<>();
|
||||
for (Map<String, Object> code : codes) {
|
||||
String val = objToStr(code.get("code_value"));
|
||||
Object parentRaw = code.get("parent_code_value");
|
||||
String parentVal = (parentRaw != null) ? parentRaw.toString() : null;
|
||||
|
||||
Map<String, Object> node = byValue.get(val);
|
||||
if (parentVal == null || parentVal.isEmpty() || !byValue.containsKey(parentVal)) {
|
||||
roots.add(node);
|
||||
} else {
|
||||
((List<Map<String, Object>>) byValue.get(parentVal).get("children")).add(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/** boolean/String → VARCHAR 'Y'/'N' 변환 */
|
||||
/** boolean / String / Number → VARCHAR(1) 'Y'/'N'. */
|
||||
private String toActiveStr(Object val) {
|
||||
if (val == null) return "Y";
|
||||
if (val instanceof Boolean b) return b ? "Y" : "N";
|
||||
if (val instanceof Number n) return n.intValue() != 0 ? "Y" : "N";
|
||||
String s = val.toString().toLowerCase();
|
||||
return ("true".equals(s) || "y".equals(s) || "1".equals(s)) ? "Y" : "N";
|
||||
}
|
||||
@@ -463,7 +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 != "*"">
|
||||
AND (G.COMPANY_CODE = #{company_code} OR G.COMPANY_CODE = '*')
|
||||
</if>
|
||||
<include refid="cascadingAutoFillGroupSearchCondition"/>
|
||||
GROUP BY G.GROUP_ID
|
||||
ORDER BY G.GROUP_NAME
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingAutoFillGroupListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(DISTINCT G.GROUP_ID)
|
||||
FROM CASCADING_AUTO_FILL_GROUP G
|
||||
WHERE 1=1
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (G.COMPANY_CODE = #{company_code} OR G.COMPANY_CODE = '*')
|
||||
</if>
|
||||
<include refid="cascadingAutoFillGroupSearchCondition"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingAutoFillGroupByCode" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_AUTO_FILL_GROUP
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
<if test="is_active != null">
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingAutoFillMappingList" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_AUTO_FILL_MAPPING
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
ORDER BY SORT_ORDER, MAPPING_ID
|
||||
</select>
|
||||
|
||||
<select id="getCascadingAutoFillGroupCount" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM CASCADING_AUTO_FILL_GROUP
|
||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</select>
|
||||
|
||||
<insert id="insertCascadingAutoFillGroup" parameterType="map" useGeneratedKeys="true" keyProperty="groupId">
|
||||
INSERT INTO CASCADING_AUTO_FILL_GROUP (
|
||||
GROUP_CODE, GROUP_NAME, DESCRIPTION,
|
||||
MASTER_TABLE, MASTER_VALUE_COLUMN, MASTER_LABEL_COLUMN,
|
||||
COMPANY_CODE, IS_ACTIVE, CREATED_DATE
|
||||
) VALUES (
|
||||
#{group_code},
|
||||
#{group_name},
|
||||
#{description, jdbcType=VARCHAR},
|
||||
#{master_table},
|
||||
#{master_value_column},
|
||||
#{master_label_column, jdbcType=VARCHAR},
|
||||
#{company_code},
|
||||
#{is_active, jdbcType=VARCHAR},
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<insert id="insertCascadingAutoFillMapping" parameterType="map" useGeneratedKeys="true" keyProperty="mappingId">
|
||||
INSERT INTO CASCADING_AUTO_FILL_MAPPING (
|
||||
GROUP_CODE, COMPANY_CODE, SOURCE_COLUMN, TARGET_FIELD, TARGET_LABEL,
|
||||
IS_EDITABLE, IS_REQUIRED, DEFAULT_VALUE, SORT_ORDER
|
||||
) VALUES (
|
||||
#{group_code},
|
||||
#{company_code},
|
||||
#{source_column},
|
||||
#{target_field},
|
||||
#{target_label, jdbcType=VARCHAR},
|
||||
#{is_editable, jdbcType=VARCHAR},
|
||||
#{is_required, jdbcType=VARCHAR},
|
||||
#{default_value, jdbcType=VARCHAR},
|
||||
#{sort_order}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCascadingAutoFillGroup" parameterType="map">
|
||||
UPDATE CASCADING_AUTO_FILL_GROUP SET
|
||||
GROUP_NAME = COALESCE(#{group_name, jdbcType=VARCHAR}, GROUP_NAME),
|
||||
DESCRIPTION = COALESCE(#{description, jdbcType=VARCHAR}, DESCRIPTION),
|
||||
MASTER_TABLE = COALESCE(#{master_table, jdbcType=VARCHAR}, MASTER_TABLE),
|
||||
MASTER_VALUE_COLUMN = COALESCE(#{master_value_column, jdbcType=VARCHAR}, MASTER_VALUE_COLUMN),
|
||||
MASTER_LABEL_COLUMN = COALESCE(#{master_label_column, jdbcType=VARCHAR}, MASTER_LABEL_COLUMN),
|
||||
IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE),
|
||||
UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</update>
|
||||
|
||||
<delete id="deleteCascadingAutoFillMappings" parameterType="map">
|
||||
DELETE FROM CASCADING_AUTO_FILL_MAPPING
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</delete>
|
||||
|
||||
<delete id="deleteCascadingAutoFillGroup" parameterType="map">
|
||||
DELETE FROM CASCADING_AUTO_FILL_GROUP
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</delete>
|
||||
|
||||
</mapper>
|
||||
@@ -1,100 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="cascadingCondition">
|
||||
|
||||
<sql id="cascadingConditionSearchCondition">
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND CONDITION_NAME ILIKE '%' || #{keyword} || '%'
|
||||
</if>
|
||||
<if test="is_active != null and is_active != ''">
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
<if test="relation_code != null and relation_code != ''">
|
||||
AND RELATION_CODE = #{relation_code}
|
||||
</if>
|
||||
<if test="relation_type != null and relation_type != ''">
|
||||
AND RELATION_TYPE = #{relation_type}
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<select id="getCascadingConditionList" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_CONDITION
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="cascadingConditionSearchCondition"/>
|
||||
<choose>
|
||||
<when test="sort_column != null and sort_column != ''">
|
||||
ORDER BY ${sortColumn}
|
||||
<if test="sort_direction != null and sort_direction != ''">
|
||||
${sortDirection}
|
||||
</if>
|
||||
</when>
|
||||
<otherwise>
|
||||
ORDER BY RELATION_CODE, PRIORITY, CONDITION_NAME
|
||||
</otherwise>
|
||||
</choose>
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingConditionListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM CASCADING_CONDITION
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="cascadingConditionSearchCondition"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingConditionInfo" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_CONDITION
|
||||
WHERE CONDITION_ID = #{condition_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<insert id="insertCascadingCondition" parameterType="map" useGeneratedKeys="true" keyProperty="conditionId">
|
||||
INSERT INTO CASCADING_CONDITION (
|
||||
RELATION_TYPE, RELATION_CODE, CONDITION_NAME,
|
||||
CONDITION_FIELD, CONDITION_OPERATOR, CONDITION_VALUE,
|
||||
FILTER_COLUMN, FILTER_VALUES, PRIORITY,
|
||||
COMPANY_CODE, IS_ACTIVE, CREATED_DATE
|
||||
) VALUES (
|
||||
COALESCE(#{relation_type}, 'RELATION'), #{relation_code}, #{condition_name},
|
||||
#{condition_field}, COALESCE(#{condition_operator}, 'EQ'), #{condition_value},
|
||||
#{filter_column}, #{filter_values}, COALESCE(#{priority}, 0),
|
||||
#{company_code}, COALESCE(#{is_active}, 'Y'), CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCascadingCondition" parameterType="map">
|
||||
UPDATE CASCADING_CONDITION SET
|
||||
CONDITION_NAME = COALESCE(#{condition_name}, CONDITION_NAME),
|
||||
CONDITION_FIELD = COALESCE(#{condition_field}, CONDITION_FIELD),
|
||||
CONDITION_OPERATOR = COALESCE(#{condition_operator}, CONDITION_OPERATOR),
|
||||
CONDITION_VALUE = COALESCE(#{condition_value}, CONDITION_VALUE),
|
||||
FILTER_COLUMN = COALESCE(#{filter_column}, FILTER_COLUMN),
|
||||
FILTER_VALUES = COALESCE(#{filter_values}, FILTER_VALUES),
|
||||
PRIORITY = COALESCE(#{priority}, PRIORITY),
|
||||
IS_ACTIVE = COALESCE(#{is_active}, IS_ACTIVE),
|
||||
UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE CONDITION_ID = #{condition_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<delete id="deleteCascadingCondition" parameterType="map">
|
||||
DELETE FROM CASCADING_CONDITION
|
||||
WHERE CONDITION_ID = #{condition_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</delete>
|
||||
|
||||
<select id="getCascadingConditionsByRelationCode" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_CONDITION
|
||||
WHERE RELATION_CODE = #{relation_code}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
ORDER BY PRIORITY DESC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -1,219 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="cascadingHierarchy">
|
||||
|
||||
<sql id="cascadingHierarchyGroupSearchCondition">
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (G.GROUP_NAME ILIKE '%' || #{keyword} || '%')
|
||||
</if>
|
||||
<if test="is_active != null and is_active != ''">
|
||||
AND G.IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
<if test="hierarchy_type != null and hierarchy_type != ''">
|
||||
AND G.HIERARCHY_TYPE = #{hierarchy_type}
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<select id="getCascadingHierarchyGroupList" parameterType="map" resultType="map">
|
||||
SELECT G.*,
|
||||
(SELECT COUNT(*)
|
||||
FROM CASCADING_HIERARCHY_LEVEL L
|
||||
WHERE L.GROUP_CODE = G.GROUP_CODE AND L.COMPANY_CODE = G.COMPANY_CODE) AS LEVEL_COUNT
|
||||
FROM CASCADING_HIERARCHY_GROUP G
|
||||
WHERE 1=1
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (G.COMPANY_CODE = #{company_code} OR G.COMPANY_CODE = '*')
|
||||
</if>
|
||||
<include refid="cascadingHierarchyGroupSearchCondition"/>
|
||||
ORDER BY G.GROUP_NAME
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingHierarchyGroupListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(DISTINCT G.GROUP_ID)
|
||||
FROM CASCADING_HIERARCHY_GROUP G
|
||||
WHERE 1=1
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (G.COMPANY_CODE = #{company_code} OR G.COMPANY_CODE = '*')
|
||||
</if>
|
||||
<include refid="cascadingHierarchyGroupSearchCondition"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingHierarchyGroupByCode" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_HIERARCHY_GROUP
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingHierarchyGroupCount" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM CASCADING_HIERARCHY_GROUP
|
||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</select>
|
||||
|
||||
<select id="getCascadingHierarchyLevelList" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_HIERARCHY_LEVEL
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
ORDER BY LEVEL_ORDER
|
||||
</select>
|
||||
|
||||
<select id="getCascadingHierarchyLevelInfo" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_HIERARCHY_LEVEL
|
||||
WHERE LEVEL_ID = #{level_id}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingHierarchyLevelForOptions" parameterType="map" resultType="map">
|
||||
SELECT L.*, G.HIERARCHY_TYPE
|
||||
FROM CASCADING_HIERARCHY_LEVEL L
|
||||
JOIN CASCADING_HIERARCHY_GROUP G
|
||||
ON L.GROUP_CODE = G.GROUP_CODE AND L.COMPANY_CODE = G.COMPANY_CODE
|
||||
WHERE L.GROUP_CODE = #{group_code}
|
||||
AND L.LEVEL_ORDER = #{level_order}
|
||||
AND L.IS_ACTIVE = 'Y'
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (L.COMPANY_CODE = #{company_code} OR L.COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<insert id="insertCascadingHierarchyGroup" parameterType="map" useGeneratedKeys="true" keyProperty="groupId">
|
||||
INSERT INTO CASCADING_HIERARCHY_GROUP (
|
||||
GROUP_CODE, GROUP_NAME, DESCRIPTION, HIERARCHY_TYPE,
|
||||
MAX_LEVELS, IS_FIXED_LEVELS,
|
||||
SELF_REF_TABLE, SELF_REF_ID_COLUMN, SELF_REF_PARENT_COLUMN,
|
||||
SELF_REF_VALUE_COLUMN, SELF_REF_LABEL_COLUMN, SELF_REF_LEVEL_COLUMN, SELF_REF_ORDER_COLUMN,
|
||||
BOM_TABLE, BOM_PARENT_COLUMN, BOM_CHILD_COLUMN,
|
||||
BOM_ITEM_TABLE, BOM_ITEM_ID_COLUMN, BOM_ITEM_LABEL_COLUMN, BOM_QTY_COLUMN, BOM_LEVEL_COLUMN,
|
||||
EMPTY_MESSAGE, NO_OPTIONS_MESSAGE, LOADING_MESSAGE,
|
||||
COMPANY_CODE, IS_ACTIVE, CREATED_BY, CREATED_DATE
|
||||
) VALUES (
|
||||
#{group_code},
|
||||
#{group_name},
|
||||
#{description, jdbcType=VARCHAR},
|
||||
#{hierarchy_type},
|
||||
#{max_levels, jdbcType=INTEGER},
|
||||
#{is_fixed_levels, jdbcType=VARCHAR},
|
||||
#{self_ref_table, jdbcType=VARCHAR},
|
||||
#{self_ref_id_column, jdbcType=VARCHAR},
|
||||
#{self_ref_parent_column, jdbcType=VARCHAR},
|
||||
#{self_ref_value_column, jdbcType=VARCHAR},
|
||||
#{self_ref_label_column, jdbcType=VARCHAR},
|
||||
#{self_ref_level_column, jdbcType=VARCHAR},
|
||||
#{self_ref_order_column, jdbcType=VARCHAR},
|
||||
#{bom_table, jdbcType=VARCHAR},
|
||||
#{bom_parent_column, jdbcType=VARCHAR},
|
||||
#{bom_child_column, jdbcType=VARCHAR},
|
||||
#{bom_item_table, jdbcType=VARCHAR},
|
||||
#{bom_item_id_column, jdbcType=VARCHAR},
|
||||
#{bom_item_label_column, jdbcType=VARCHAR},
|
||||
#{bom_qty_column, jdbcType=VARCHAR},
|
||||
#{bom_level_column, jdbcType=VARCHAR},
|
||||
#{empty_message, jdbcType=VARCHAR},
|
||||
#{no_options_message, jdbcType=VARCHAR},
|
||||
#{loading_message, jdbcType=VARCHAR},
|
||||
#{company_code},
|
||||
'Y',
|
||||
#{created_by, jdbcType=VARCHAR},
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<insert id="insertCascadingHierarchyLevel" parameterType="map" useGeneratedKeys="true" keyProperty="levelId">
|
||||
INSERT INTO CASCADING_HIERARCHY_LEVEL (
|
||||
GROUP_CODE, COMPANY_CODE, LEVEL_ORDER, LEVEL_NAME, LEVEL_CODE,
|
||||
TABLE_NAME, VALUE_COLUMN, LABEL_COLUMN, PARENT_KEY_COLUMN,
|
||||
FILTER_COLUMN, FILTER_VALUE, ORDER_COLUMN, ORDER_DIRECTION,
|
||||
PLACEHOLDER, IS_REQUIRED, IS_SEARCHABLE, IS_ACTIVE, CREATED_DATE
|
||||
) VALUES (
|
||||
#{group_code},
|
||||
#{company_code},
|
||||
#{level_order},
|
||||
#{level_name},
|
||||
#{level_code, jdbcType=VARCHAR},
|
||||
#{table_name},
|
||||
#{value_column},
|
||||
#{label_column},
|
||||
#{parent_key_column, jdbcType=VARCHAR},
|
||||
#{filter_column, jdbcType=VARCHAR},
|
||||
#{filter_value, jdbcType=VARCHAR},
|
||||
#{order_column, jdbcType=VARCHAR},
|
||||
#{order_direction, jdbcType=VARCHAR},
|
||||
#{placeholder, jdbcType=VARCHAR},
|
||||
#{is_required, jdbcType=VARCHAR},
|
||||
#{is_searchable, jdbcType=VARCHAR},
|
||||
'Y',
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCascadingHierarchyGroup" parameterType="map">
|
||||
UPDATE CASCADING_HIERARCHY_GROUP SET
|
||||
GROUP_NAME = COALESCE(#{group_name, jdbcType=VARCHAR}, GROUP_NAME),
|
||||
DESCRIPTION = COALESCE(#{description, jdbcType=VARCHAR}, DESCRIPTION),
|
||||
MAX_LEVELS = COALESCE(#{max_levels, jdbcType=INTEGER}, MAX_LEVELS),
|
||||
IS_FIXED_LEVELS = COALESCE(#{is_fixed_levels, jdbcType=VARCHAR}, IS_FIXED_LEVELS),
|
||||
EMPTY_MESSAGE = COALESCE(#{empty_message, jdbcType=VARCHAR}, EMPTY_MESSAGE),
|
||||
NO_OPTIONS_MESSAGE = COALESCE(#{no_options_message, jdbcType=VARCHAR}, NO_OPTIONS_MESSAGE),
|
||||
LOADING_MESSAGE = COALESCE(#{loading_message, jdbcType=VARCHAR}, LOADING_MESSAGE),
|
||||
IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE),
|
||||
UPDATED_BY = #{updated_by, jdbcType=VARCHAR},
|
||||
UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</update>
|
||||
|
||||
<update id="updateCascadingHierarchyLevel" parameterType="map">
|
||||
UPDATE CASCADING_HIERARCHY_LEVEL SET
|
||||
LEVEL_NAME = COALESCE(#{level_name, jdbcType=VARCHAR}, LEVEL_NAME),
|
||||
TABLE_NAME = COALESCE(#{table_name, jdbcType=VARCHAR}, TABLE_NAME),
|
||||
VALUE_COLUMN = COALESCE(#{value_column, jdbcType=VARCHAR}, VALUE_COLUMN),
|
||||
LABEL_COLUMN = COALESCE(#{label_column, jdbcType=VARCHAR}, LABEL_COLUMN),
|
||||
PARENT_KEY_COLUMN = COALESCE(#{parent_key_column, jdbcType=VARCHAR}, PARENT_KEY_COLUMN),
|
||||
FILTER_COLUMN = COALESCE(#{filter_column, jdbcType=VARCHAR}, FILTER_COLUMN),
|
||||
FILTER_VALUE = COALESCE(#{filter_value, jdbcType=VARCHAR}, FILTER_VALUE),
|
||||
ORDER_COLUMN = COALESCE(#{order_column, jdbcType=VARCHAR}, ORDER_COLUMN),
|
||||
ORDER_DIRECTION = COALESCE(#{order_direction, jdbcType=VARCHAR}, ORDER_DIRECTION),
|
||||
PLACEHOLDER = COALESCE(#{placeholder, jdbcType=VARCHAR}, PLACEHOLDER),
|
||||
IS_REQUIRED = COALESCE(#{is_required, jdbcType=VARCHAR}, IS_REQUIRED),
|
||||
IS_SEARCHABLE = COALESCE(#{is_searchable, jdbcType=VARCHAR}, IS_SEARCHABLE),
|
||||
IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE),
|
||||
UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE LEVEL_ID = #{level_id}
|
||||
</update>
|
||||
|
||||
<delete id="deleteCascadingHierarchyLevels" parameterType="map">
|
||||
DELETE FROM CASCADING_HIERARCHY_LEVEL
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</delete>
|
||||
|
||||
<delete id="deleteCascadingHierarchyLevel" parameterType="map">
|
||||
DELETE FROM CASCADING_HIERARCHY_LEVEL
|
||||
WHERE LEVEL_ID = #{level_id}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</delete>
|
||||
|
||||
<delete id="deleteCascadingHierarchyGroup" parameterType="map">
|
||||
DELETE FROM CASCADING_HIERARCHY_GROUP
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</delete>
|
||||
|
||||
</mapper>
|
||||
@@ -1,145 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="cascadingMutualExclusion">
|
||||
|
||||
<sql id="cascadingMutualExclusionSearchCondition">
|
||||
<if test="is_active != null and is_active != ''">
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND EXCLUSION_NAME ILIKE '%' || #{keyword} || '%'
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<select id="getCascadingMutualExclusionList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
EXCLUSION_ID
|
||||
, EXCLUSION_CODE
|
||||
, EXCLUSION_NAME
|
||||
, FIELD_NAMES
|
||||
, SOURCE_TABLE
|
||||
, VALUE_COLUMN
|
||||
, LABEL_COLUMN
|
||||
, EXCLUSION_TYPE
|
||||
, ERROR_MESSAGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_DATE
|
||||
FROM CASCADING_MUTUAL_EXCLUSION
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="cascadingMutualExclusionSearchCondition"/>
|
||||
ORDER BY CREATED_DATE DESC
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingMutualExclusionListCnt" parameterType="map" resultType="int">
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM CASCADING_MUTUAL_EXCLUSION
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="cascadingMutualExclusionSearchCondition"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingMutualExclusionInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
EXCLUSION_ID
|
||||
, EXCLUSION_CODE
|
||||
, EXCLUSION_NAME
|
||||
, FIELD_NAMES
|
||||
, SOURCE_TABLE
|
||||
, VALUE_COLUMN
|
||||
, LABEL_COLUMN
|
||||
, EXCLUSION_TYPE
|
||||
, ERROR_MESSAGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_DATE
|
||||
FROM CASCADING_MUTUAL_EXCLUSION
|
||||
WHERE EXCLUSION_ID = #{id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<!-- 코드로 단건 조회 (is_active = 'Y') -->
|
||||
<select id="getCascadingMutualExclusionByCode" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
EXCLUSION_ID
|
||||
, EXCLUSION_CODE
|
||||
, EXCLUSION_NAME
|
||||
, FIELD_NAMES
|
||||
, SOURCE_TABLE
|
||||
, VALUE_COLUMN
|
||||
, LABEL_COLUMN
|
||||
, EXCLUSION_TYPE
|
||||
, ERROR_MESSAGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
FROM CASCADING_MUTUAL_EXCLUSION
|
||||
WHERE EXCLUSION_CODE = #{code}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 코드 자동 생성용 카운트 -->
|
||||
<select id="getCascadingMutualExclusionCount" parameterType="map" resultType="int">
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM CASCADING_MUTUAL_EXCLUSION
|
||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</select>
|
||||
|
||||
<insert id="insertCascadingMutualExclusion" parameterType="map"
|
||||
useGeneratedKeys="true" keyProperty="exclusionId" keyColumn="exclusion_id">
|
||||
INSERT INTO CASCADING_MUTUAL_EXCLUSION (
|
||||
EXCLUSION_CODE
|
||||
, EXCLUSION_NAME
|
||||
, FIELD_NAMES
|
||||
, SOURCE_TABLE
|
||||
, VALUE_COLUMN
|
||||
, LABEL_COLUMN
|
||||
, EXCLUSION_TYPE
|
||||
, ERROR_MESSAGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_DATE
|
||||
) VALUES (
|
||||
#{exclusion_code, jdbcType=VARCHAR}
|
||||
, #{exclusion_name, jdbcType=VARCHAR}
|
||||
, #{field_names, jdbcType=VARCHAR}
|
||||
, #{source_table, jdbcType=VARCHAR}
|
||||
, #{value_column, jdbcType=VARCHAR}
|
||||
, #{label_column, jdbcType=VARCHAR}
|
||||
, #{exclusion_type, jdbcType=VARCHAR}
|
||||
, #{error_message, jdbcType=VARCHAR}
|
||||
, #{company_code}
|
||||
, 'Y'
|
||||
, CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCascadingMutualExclusion" parameterType="map">
|
||||
UPDATE CASCADING_MUTUAL_EXCLUSION
|
||||
SET
|
||||
EXCLUSION_NAME = COALESCE(#{exclusion_name, jdbcType=VARCHAR}, EXCLUSION_NAME)
|
||||
, FIELD_NAMES = COALESCE(#{field_names, jdbcType=VARCHAR}, FIELD_NAMES)
|
||||
, SOURCE_TABLE = COALESCE(#{source_table, jdbcType=VARCHAR}, SOURCE_TABLE)
|
||||
, VALUE_COLUMN = COALESCE(#{value_column, jdbcType=VARCHAR}, VALUE_COLUMN)
|
||||
, LABEL_COLUMN = COALESCE(#{label_column, jdbcType=VARCHAR}, LABEL_COLUMN)
|
||||
, EXCLUSION_TYPE = COALESCE(#{exclusion_type, jdbcType=VARCHAR}, EXCLUSION_TYPE)
|
||||
, ERROR_MESSAGE = COALESCE(#{error_message, jdbcType=VARCHAR}, ERROR_MESSAGE)
|
||||
, IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE)
|
||||
WHERE EXCLUSION_ID = #{id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<!-- 하드 삭제 (Node.js와 동일) -->
|
||||
<delete id="deleteCascadingMutualExclusion" parameterType="map">
|
||||
DELETE FROM CASCADING_MUTUAL_EXCLUSION
|
||||
WHERE EXCLUSION_ID = #{id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</delete>
|
||||
|
||||
</mapper>
|
||||
@@ -1,160 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="cascadingRelation">
|
||||
|
||||
<sql id="cascadingRelationColumns">
|
||||
RELATION_ID
|
||||
, RELATION_CODE
|
||||
, RELATION_NAME
|
||||
, DESCRIPTION
|
||||
, PARENT_TABLE
|
||||
, PARENT_VALUE_COLUMN
|
||||
, PARENT_LABEL_COLUMN
|
||||
, CHILD_TABLE
|
||||
, CHILD_FILTER_COLUMN
|
||||
, CHILD_VALUE_COLUMN
|
||||
, CHILD_LABEL_COLUMN
|
||||
, CHILD_ORDER_COLUMN
|
||||
, CHILD_ORDER_DIRECTION
|
||||
, EMPTY_PARENT_MESSAGE
|
||||
, NO_OPTIONS_MESSAGE
|
||||
, LOADING_MESSAGE
|
||||
, CLEAR_ON_PARENT_CHANGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_BY
|
||||
, UPDATED_DATE
|
||||
</sql>
|
||||
|
||||
<sql id="cascadingRelationSearchCondition">
|
||||
<if test="is_active != null and is_active != ''">
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (RELATION_NAME ILIKE '%' || #{keyword} || '%'
|
||||
OR RELATION_CODE ILIKE '%' || #{keyword} || '%')
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<select id="getCascadingRelationList" parameterType="map" resultType="map">
|
||||
SELECT <include refid="cascadingRelationColumns"/>
|
||||
FROM CASCADING_RELATION
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="cascadingRelationSearchCondition"/>
|
||||
ORDER BY CREATED_DATE DESC
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingRelationListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM CASCADING_RELATION
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="cascadingRelationSearchCondition"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingRelationInfo" parameterType="map" resultType="map">
|
||||
SELECT <include refid="cascadingRelationColumns"/>
|
||||
FROM CASCADING_RELATION
|
||||
WHERE RELATION_ID = #{id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<!-- 코드로 단건 조회 (is_active = 'Y' 조건 포함) -->
|
||||
<select id="getCascadingRelationByCode" parameterType="map" resultType="map">
|
||||
SELECT <include refid="cascadingRelationColumns"/>
|
||||
FROM CASCADING_RELATION
|
||||
WHERE RELATION_CODE = #{code}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<insert id="insertCascadingRelation" parameterType="map"
|
||||
useGeneratedKeys="true" keyProperty="relationId" keyColumn="relation_id">
|
||||
INSERT INTO CASCADING_RELATION (
|
||||
RELATION_CODE
|
||||
, RELATION_NAME
|
||||
, DESCRIPTION
|
||||
, PARENT_TABLE
|
||||
, PARENT_VALUE_COLUMN
|
||||
, PARENT_LABEL_COLUMN
|
||||
, CHILD_TABLE
|
||||
, CHILD_FILTER_COLUMN
|
||||
, CHILD_VALUE_COLUMN
|
||||
, CHILD_LABEL_COLUMN
|
||||
, CHILD_ORDER_COLUMN
|
||||
, CHILD_ORDER_DIRECTION
|
||||
, EMPTY_PARENT_MESSAGE
|
||||
, NO_OPTIONS_MESSAGE
|
||||
, LOADING_MESSAGE
|
||||
, CLEAR_ON_PARENT_CHANGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
) VALUES (
|
||||
#{relation_code, jdbcType=VARCHAR}
|
||||
, #{relation_name, jdbcType=VARCHAR}
|
||||
, #{description, jdbcType=VARCHAR}
|
||||
, #{parent_table, jdbcType=VARCHAR}
|
||||
, #{parent_value_column, jdbcType=VARCHAR}
|
||||
, #{parent_label_column, jdbcType=VARCHAR}
|
||||
, #{child_table, jdbcType=VARCHAR}
|
||||
, #{child_filter_column, jdbcType=VARCHAR}
|
||||
, #{child_value_column, jdbcType=VARCHAR}
|
||||
, #{child_label_column, jdbcType=VARCHAR}
|
||||
, #{child_order_column, jdbcType=VARCHAR}
|
||||
, #{child_order_direction, jdbcType=VARCHAR}
|
||||
, #{empty_parent_message, jdbcType=VARCHAR}
|
||||
, #{no_options_message, jdbcType=VARCHAR}
|
||||
, #{loading_message, jdbcType=VARCHAR}
|
||||
, #{clear_on_parent_change, jdbcType=VARCHAR}
|
||||
, #{company_code}
|
||||
, #{is_active, jdbcType=VARCHAR}
|
||||
, #{user_id, jdbcType=VARCHAR}
|
||||
, CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCascadingRelation" parameterType="map">
|
||||
UPDATE CASCADING_RELATION
|
||||
SET
|
||||
RELATION_NAME = COALESCE(#{relation_name, jdbcType=VARCHAR}, RELATION_NAME)
|
||||
, DESCRIPTION = COALESCE(#{description, jdbcType=VARCHAR}, DESCRIPTION)
|
||||
, PARENT_TABLE = COALESCE(#{parent_table, jdbcType=VARCHAR}, PARENT_TABLE)
|
||||
, PARENT_VALUE_COLUMN = COALESCE(#{parent_value_column, jdbcType=VARCHAR}, PARENT_VALUE_COLUMN)
|
||||
, PARENT_LABEL_COLUMN = COALESCE(#{parent_label_column, jdbcType=VARCHAR}, PARENT_LABEL_COLUMN)
|
||||
, CHILD_TABLE = COALESCE(#{child_table, jdbcType=VARCHAR}, CHILD_TABLE)
|
||||
, CHILD_FILTER_COLUMN = COALESCE(#{child_filter_column, jdbcType=VARCHAR}, CHILD_FILTER_COLUMN)
|
||||
, CHILD_VALUE_COLUMN = COALESCE(#{child_value_column, jdbcType=VARCHAR}, CHILD_VALUE_COLUMN)
|
||||
, CHILD_LABEL_COLUMN = COALESCE(#{child_label_column, jdbcType=VARCHAR}, CHILD_LABEL_COLUMN)
|
||||
, CHILD_ORDER_COLUMN = COALESCE(#{child_order_column, jdbcType=VARCHAR}, CHILD_ORDER_COLUMN)
|
||||
, CHILD_ORDER_DIRECTION = COALESCE(#{child_order_direction, jdbcType=VARCHAR}, CHILD_ORDER_DIRECTION)
|
||||
, EMPTY_PARENT_MESSAGE = COALESCE(#{empty_parent_message, jdbcType=VARCHAR}, EMPTY_PARENT_MESSAGE)
|
||||
, NO_OPTIONS_MESSAGE = COALESCE(#{no_options_message, jdbcType=VARCHAR}, NO_OPTIONS_MESSAGE)
|
||||
, LOADING_MESSAGE = COALESCE(#{loading_message, jdbcType=VARCHAR}, LOADING_MESSAGE)
|
||||
, CLEAR_ON_PARENT_CHANGE = COALESCE(#{clear_on_parent_change, jdbcType=VARCHAR}, CLEAR_ON_PARENT_CHANGE)
|
||||
, IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE)
|
||||
, UPDATED_BY = #{user_id, jdbcType=VARCHAR}
|
||||
, UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE RELATION_ID = #{id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<!-- 소프트 삭제: is_active = 'N' -->
|
||||
<update id="deleteCascadingRelation" parameterType="map">
|
||||
UPDATE CASCADING_RELATION
|
||||
SET
|
||||
IS_ACTIVE = 'N'
|
||||
, UPDATED_BY = #{user_id, jdbcType=VARCHAR}
|
||||
, UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE RELATION_ID = #{id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
@@ -1,182 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="categoryTree">
|
||||
|
||||
<!-- 공통 컬럼 -->
|
||||
<sql id="categoryValueColumns">
|
||||
value_id, table_name, column_name, value_code, value_label, value_order,
|
||||
parent_value_id, depth, path, description, color, icon,
|
||||
is_active, is_default, company_code,
|
||||
CREATED_DATE, UPDATED_DATE, created_by, updated_by
|
||||
</sql>
|
||||
|
||||
<!-- 카테고리 플랫 리스트 조회 (트리/플랫 모두 사용) -->
|
||||
<select id="getCategoryTreeList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
<include refid="categoryValueColumns"/>
|
||||
|
||||
FROM category_values
|
||||
|
||||
WHERE (company_code = #{company_code} OR company_code = '*')
|
||||
AND table_name = #{table_name}
|
||||
AND column_name = #{column_name}
|
||||
|
||||
ORDER BY depth ASC, value_order ASC, value_label ASC
|
||||
</select>
|
||||
|
||||
<!-- 카테고리 값 단건 조회 -->
|
||||
<select id="getCategoryTreeInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
<include refid="categoryValueColumns"/>
|
||||
|
||||
FROM category_values
|
||||
|
||||
WHERE (company_code = #{company_code} OR company_code = '*')
|
||||
AND value_id = #{value_id}
|
||||
</select>
|
||||
|
||||
<!-- 카테고리 값 생성 -->
|
||||
<insert id="insertCategoryTree" parameterType="map"
|
||||
useGeneratedKeys="true" keyProperty="valueId" keyColumn="value_id">
|
||||
INSERT INTO category_values (
|
||||
table_name, column_name, value_code, value_label, value_order,
|
||||
parent_value_id, depth, path, description, color, icon,
|
||||
is_active, is_default, company_code, created_by, updated_by
|
||||
) VALUES (
|
||||
#{table_name}, #{column_name}, #{value_code}, #{value_label}, #{value_order},
|
||||
#{parent_value_id}, #{depth}, #{path}, #{description}, #{color}, #{icon},
|
||||
#{is_active}, #{is_default}, #{company_code}, #{created_by}, #{created_by}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<!-- 카테고리 값 수정 (COALESCE로 부분 업데이트) -->
|
||||
<update id="updateCategoryTree" parameterType="map">
|
||||
UPDATE category_values
|
||||
SET
|
||||
value_code = COALESCE(#{value_code}, value_code),
|
||||
value_label = COALESCE(#{value_label}, value_label),
|
||||
value_order = COALESCE(#{value_order}, value_order),
|
||||
parent_value_id = #{parent_value_id},
|
||||
depth = #{depth},
|
||||
path = #{path},
|
||||
description = COALESCE(#{description}, description),
|
||||
color = COALESCE(#{color}, color),
|
||||
icon = COALESCE(#{icon}, icon),
|
||||
is_active = COALESCE(#{is_active}, is_active),
|
||||
is_default = COALESCE(#{is_default}, is_default),
|
||||
UPDATED_DATE = NOW(),
|
||||
updated_by = #{updated_by}
|
||||
|
||||
WHERE (company_code = #{company_code} OR company_code = '*')
|
||||
AND value_id = #{value_id}
|
||||
</update>
|
||||
|
||||
<!-- 카테고리 값 삭제 -->
|
||||
<delete id="deleteCategoryTree" parameterType="map">
|
||||
DELETE FROM category_values
|
||||
|
||||
WHERE (company_code = #{company_code} OR company_code = '*')
|
||||
AND value_id = #{value_id}
|
||||
</delete>
|
||||
|
||||
<!-- 자식 카테고리 수 조회 -->
|
||||
<select id="getCategoryTreeChildrenCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*) FROM category_values
|
||||
|
||||
WHERE parent_value_id = #{value_id}
|
||||
AND (company_code = #{company_code} OR company_code = '*')
|
||||
</select>
|
||||
|
||||
<!-- 테이블 존재 여부 확인 (0 또는 1 반환) -->
|
||||
<select id="checkTableExists" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = #{table_name}
|
||||
</select>
|
||||
|
||||
<!-- 컬럼 존재 여부 확인 (0 또는 1 반환) -->
|
||||
<select id="checkColumnExists" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = #{table_name}
|
||||
AND column_name = #{column_name}
|
||||
</select>
|
||||
|
||||
<!-- 카테고리 값 사용 건수 조회 (company_code 필터 포함) -->
|
||||
<select id="countCategoryUsageWithCompany" parameterType="map" resultType="int">
|
||||
<![CDATA[
|
||||
SELECT COUNT(*) FROM "${tableName}"
|
||||
|
||||
WHERE (company_code = #{company_code} OR company_code = '*')
|
||||
AND (#{value_code} = ANY(string_to_array("${columnName}"::text, ','))
|
||||
OR "${columnName}"::text = #{value_code})
|
||||
]]>
|
||||
</select>
|
||||
|
||||
<!-- 카테고리 값 사용 건수 조회 (company_code 필터 없음) -->
|
||||
<select id="countCategoryUsage" parameterType="map" resultType="int">
|
||||
<![CDATA[
|
||||
SELECT COUNT(*) FROM "${tableName}"
|
||||
|
||||
WHERE #{value_code} = ANY(string_to_array("${columnName}"::text, ','))
|
||||
OR "${columnName}"::text = #{value_code}
|
||||
]]>
|
||||
</select>
|
||||
|
||||
<!-- 직계 자식 목록 조회 (path 업데이트용) -->
|
||||
<select id="getCategoryTreeChildrenList" parameterType="map" resultType="map">
|
||||
SELECT value_id, value_label
|
||||
|
||||
FROM category_values
|
||||
|
||||
WHERE (company_code = #{company_code} OR company_code = '*')
|
||||
AND parent_value_id = #{parent_value_id}
|
||||
</select>
|
||||
|
||||
<!-- 자식 path 단건 업데이트 -->
|
||||
<update id="updateCategoryTreeChildPath" parameterType="map">
|
||||
UPDATE category_values
|
||||
SET path = #{path}, UPDATED_DATE = NOW()
|
||||
|
||||
WHERE value_id = #{value_id}
|
||||
</update>
|
||||
|
||||
<!-- 테이블의 카테고리 컬럼 목록 조회 -->
|
||||
<select id="getCategoryTreeColumnList" parameterType="map" resultType="map">
|
||||
SELECT DISTINCT column_name, column_label
|
||||
|
||||
FROM table_type_columns
|
||||
|
||||
WHERE table_name = #{table_name}
|
||||
AND input_type = 'category'
|
||||
AND (company_code = #{company_code} OR company_code = '*')
|
||||
|
||||
ORDER BY column_name
|
||||
</select>
|
||||
|
||||
<!-- 전체 카테고리 키 목록 조회 (table_name + column_name 조합) -->
|
||||
<select id="getCategoryTreeKeyList" parameterType="map" resultType="map">
|
||||
SELECT DISTINCT
|
||||
cv.table_name,
|
||||
cv.column_name,
|
||||
COALESCE(tl.table_label, cv.table_name) AS table_label,
|
||||
COALESCE(ttc.column_label, cv.column_name) AS column_label
|
||||
|
||||
FROM category_values cv
|
||||
|
||||
LEFT JOIN table_labels tl ON tl.table_name = cv.table_name
|
||||
|
||||
LEFT JOIN table_type_columns ttc ON ttc.table_name = cv.table_name
|
||||
AND ttc.column_name = cv.column_name
|
||||
AND ttc.company_code = '*'
|
||||
|
||||
WHERE (cv.company_code = #{company_code} OR cv.company_code = '*')
|
||||
OR cv.company_code = '*'
|
||||
|
||||
ORDER BY cv.table_name, cv.column_name
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -1,179 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="categoryValueCascading">
|
||||
|
||||
<sql id="groupSearchCondition">
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (RELATION_NAME ILIKE '%' || #{keyword} || '%' OR RELATION_CODE ILIKE '%' || #{keyword} || '%')
|
||||
</if>
|
||||
<if test="is_active != null and is_active != ''">
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<select id="getCategoryValueCascadingGroupList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
*
|
||||
FROM CATEGORY_VALUE_CASCADING_GROUP
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="groupSearchCondition"/>
|
||||
<choose>
|
||||
<when test="sort_column != null and sort_column != ''">
|
||||
ORDER BY ${sortColumn}
|
||||
<if test="sort_direction != null and sort_direction != ''">
|
||||
${sortDirection}
|
||||
</if>
|
||||
</when>
|
||||
<otherwise>
|
||||
ORDER BY RELATION_NAME ASC
|
||||
</otherwise>
|
||||
</choose>
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCategoryValueCascadingGroupListCnt" parameterType="map" resultType="int">
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM CATEGORY_VALUE_CASCADING_GROUP
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="groupSearchCondition"/>
|
||||
</select>
|
||||
|
||||
<select id="getCategoryValueCascadingGroupInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
*
|
||||
FROM CATEGORY_VALUE_CASCADING_GROUP
|
||||
WHERE GROUP_ID = #{group_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<select id="getCategoryValueCascadingGroupByCode" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
*
|
||||
FROM CATEGORY_VALUE_CASCADING_GROUP
|
||||
WHERE RELATION_CODE = #{code}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<insert id="insertCategoryValueCascadingGroup" parameterType="map" useGeneratedKeys="true" keyProperty="groupId">
|
||||
INSERT INTO CATEGORY_VALUE_CASCADING_GROUP (
|
||||
RELATION_CODE
|
||||
, RELATION_NAME
|
||||
, DESCRIPTION
|
||||
, PARENT_TABLE_NAME
|
||||
, PARENT_COLUMN_NAME
|
||||
, PARENT_MENU_OBJID
|
||||
, CHILD_TABLE_NAME
|
||||
, CHILD_COLUMN_NAME
|
||||
, CHILD_MENU_OBJID
|
||||
, CLEAR_ON_PARENT_CHANGE
|
||||
, SHOW_GROUP_LABEL
|
||||
, EMPTY_PARENT_MESSAGE
|
||||
, NO_OPTIONS_MESSAGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
) VALUES (
|
||||
#{relation_code}
|
||||
, #{relation_name}
|
||||
, #{description}
|
||||
, #{parent_table_name}
|
||||
, #{parent_column_name}
|
||||
, #{parent_menu_objid}
|
||||
, #{child_table_name}
|
||||
, #{child_column_name}
|
||||
, #{child_menu_objid}
|
||||
, COALESCE(#{clear_on_parent_change}, 'Y')
|
||||
, COALESCE(#{show_group_label}, 'Y')
|
||||
, COALESCE(#{empty_parent_message}, '상위 항목을 먼저 선택하세요')
|
||||
, COALESCE(#{no_options_message}, '선택 가능한 항목이 없습니다')
|
||||
, #{company_code}
|
||||
, 'Y'
|
||||
, #{created_by}
|
||||
, NOW()
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCategoryValueCascadingGroup" parameterType="map">
|
||||
UPDATE CATEGORY_VALUE_CASCADING_GROUP
|
||||
SET
|
||||
RELATION_NAME = COALESCE(#{relation_name}, RELATION_NAME)
|
||||
, DESCRIPTION = COALESCE(#{description}, DESCRIPTION)
|
||||
, PARENT_TABLE_NAME = COALESCE(#{parent_table_name}, PARENT_TABLE_NAME)
|
||||
, PARENT_COLUMN_NAME = COALESCE(#{parent_column_name}, PARENT_COLUMN_NAME)
|
||||
, PARENT_MENU_OBJID = COALESCE(#{parent_menu_objid}, PARENT_MENU_OBJID)
|
||||
, CHILD_TABLE_NAME = COALESCE(#{child_table_name}, CHILD_TABLE_NAME)
|
||||
, CHILD_COLUMN_NAME = COALESCE(#{child_column_name}, CHILD_COLUMN_NAME)
|
||||
, CHILD_MENU_OBJID = COALESCE(#{child_menu_objid}, CHILD_MENU_OBJID)
|
||||
, CLEAR_ON_PARENT_CHANGE = COALESCE(#{clear_on_parent_change}, CLEAR_ON_PARENT_CHANGE)
|
||||
, SHOW_GROUP_LABEL = COALESCE(#{show_group_label}, SHOW_GROUP_LABEL)
|
||||
, EMPTY_PARENT_MESSAGE = COALESCE(#{empty_parent_message}, EMPTY_PARENT_MESSAGE)
|
||||
, NO_OPTIONS_MESSAGE = COALESCE(#{no_options_message}, NO_OPTIONS_MESSAGE)
|
||||
, IS_ACTIVE = COALESCE(#{is_active}, IS_ACTIVE)
|
||||
, UPDATED_BY = #{updated_by}
|
||||
, UPDATED_DATE = NOW()
|
||||
WHERE GROUP_ID = #{group_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<update id="deleteCategoryValueCascadingGroup" parameterType="map">
|
||||
UPDATE CATEGORY_VALUE_CASCADING_GROUP
|
||||
SET
|
||||
IS_ACTIVE = 'N'
|
||||
, UPDATED_BY = #{updated_by}
|
||||
, UPDATED_DATE = NOW()
|
||||
WHERE GROUP_ID = #{group_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<select id="getCategoryValueCascadingMappingsByGroupId" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
MAPPING_ID
|
||||
, PARENT_VALUE_CODE
|
||||
, PARENT_VALUE_LABEL
|
||||
, CHILD_VALUE_CODE
|
||||
, CHILD_VALUE_LABEL
|
||||
, DISPLAY_ORDER
|
||||
, IS_ACTIVE
|
||||
FROM CATEGORY_VALUE_CASCADING_MAPPING
|
||||
WHERE GROUP_ID = #{group_id}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
ORDER BY PARENT_VALUE_CODE, DISPLAY_ORDER, CHILD_VALUE_LABEL
|
||||
</select>
|
||||
|
||||
<delete id="deleteCategoryValueCascadingMappingsByGroupId" parameterType="map">
|
||||
DELETE FROM CATEGORY_VALUE_CASCADING_MAPPING
|
||||
WHERE GROUP_ID = #{group_id}
|
||||
</delete>
|
||||
|
||||
<insert id="insertCategoryValueCascadingMapping" parameterType="map" useGeneratedKeys="true" keyProperty="mappingId">
|
||||
INSERT INTO CATEGORY_VALUE_CASCADING_MAPPING (
|
||||
GROUP_ID
|
||||
, PARENT_VALUE_CODE
|
||||
, PARENT_VALUE_LABEL
|
||||
, CHILD_VALUE_CODE
|
||||
, CHILD_VALUE_LABEL
|
||||
, DISPLAY_ORDER
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_DATE
|
||||
) VALUES (
|
||||
#{group_id}
|
||||
, #{parent_value_code}
|
||||
, #{parent_value_label}
|
||||
, #{child_value_code}
|
||||
, #{child_value_label}
|
||||
, COALESCE(#{display_order}, 0)
|
||||
, #{company_code}
|
||||
, 'Y'
|
||||
, NOW()
|
||||
)
|
||||
</insert>
|
||||
|
||||
</mapper>
|
||||
@@ -1,30 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="codeMerge">
|
||||
|
||||
<!--
|
||||
columnName 컬럼과 company_code 컬럼을 함께 가진 public BASE TABLE 목록 조회
|
||||
테이블명은 information_schema 검증값이므로 동적 SQL 사용 시 안전
|
||||
-->
|
||||
<select id="getTablesWithColumn" parameterType="map" resultType="map">
|
||||
SELECT DISTINCT t.table_name
|
||||
|
||||
FROM information_schema.columns c
|
||||
|
||||
JOIN information_schema.tables t
|
||||
ON c.table_name = t.table_name
|
||||
|
||||
WHERE c.column_name = #{column_name}
|
||||
AND t.table_schema = 'public'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM information_schema.columns c2
|
||||
WHERE c2.table_name = t.table_name
|
||||
AND c2.column_name = 'company_code'
|
||||
)
|
||||
|
||||
ORDER BY t.table_name
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -4,455 +4,436 @@
|
||||
<mapper namespace="commonCode">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- code_category -->
|
||||
<!-- CODE_INFO — 1레벨 그룹 마스터 -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="getCommonCodeCategoryList" parameterType="map" resultType="map">
|
||||
<select id="getCodeInfoList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
category_code,
|
||||
category_name,
|
||||
category_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
CODE_INFO
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, MENU_OBJID
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
|
||||
FROM code_category
|
||||
FROM CODE_INFO
|
||||
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<if test="search != null and search != ''">
|
||||
AND (
|
||||
LOWER(category_code) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(category_name) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(category_name_eng, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
LOWER(CODE_INFO) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(CODE_NAME) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(CODE_NAME_ENG, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
)
|
||||
</if>
|
||||
<if test="is_active != null">
|
||||
AND is_active = #{is_active}
|
||||
</if>
|
||||
<if test="menu_objid != null">
|
||||
AND menu_objid = #{menu_objid}
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
|
||||
ORDER BY sort_order ASC, category_code ASC
|
||||
ORDER BY SORT_ORDER ASC, CODE_INFO ASC
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeCategoryListCnt" parameterType="map" resultType="int">
|
||||
<select id="getCodeInfoListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM code_category
|
||||
FROM CODE_INFO
|
||||
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<if test="search != null and search != ''">
|
||||
AND (
|
||||
LOWER(category_code) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(category_name) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(category_name_eng, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
LOWER(CODE_INFO) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(CODE_NAME) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(CODE_NAME_ENG, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
)
|
||||
</if>
|
||||
<if test="is_active != null">
|
||||
AND is_active = #{is_active}
|
||||
</if>
|
||||
<if test="menu_objid != null">
|
||||
AND menu_objid = #{menu_objid}
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeCategoryInfo" parameterType="map" resultType="map">
|
||||
<select id="getCodeInfoInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
category_code,
|
||||
category_name,
|
||||
category_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
CODE_INFO
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, MENU_OBJID
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
|
||||
FROM code_category
|
||||
FROM CODE_INFO
|
||||
|
||||
WHERE category_code = #{category_code}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<insert id="insertCommonCodeCategory" parameterType="map">
|
||||
INSERT INTO code_category (
|
||||
category_code,
|
||||
category_name,
|
||||
category_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
<insert id="insertCodeInfo" parameterType="map">
|
||||
INSERT INTO CODE_INFO (
|
||||
CODE_INFO
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, MENU_OBJID
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
) VALUES (
|
||||
#{category_code},
|
||||
#{category_name},
|
||||
#{category_name_eng},
|
||||
#{description},
|
||||
#{sort_order},
|
||||
#{is_active},
|
||||
#{menu_objid},
|
||||
#{company_code},
|
||||
#{created_by},
|
||||
#{updated_by},
|
||||
NOW(),
|
||||
NOW()
|
||||
#{code_info}
|
||||
, #{code_name}
|
||||
, #{code_name_eng}
|
||||
, #{description}
|
||||
, #{sort_order}
|
||||
, #{is_active}
|
||||
, #{menu_objid}
|
||||
, #{company_code}
|
||||
, #{created_by}
|
||||
, #{updated_by}
|
||||
, NOW()
|
||||
, NOW()
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCommonCodeCategory" parameterType="map">
|
||||
UPDATE code_category
|
||||
<update id="updateCodeInfo" parameterType="map">
|
||||
UPDATE CODE_INFO
|
||||
<set>
|
||||
<if test="category_name != null">category_name = #{category_name},</if>
|
||||
<if test="category_name_eng != null">category_name_eng = #{category_name_eng},</if>
|
||||
<if test="description != null">description = #{description},</if>
|
||||
<if test="sort_order != null">sort_order = #{sort_order},</if>
|
||||
<if test="is_active != null">is_active = #{is_active},</if>
|
||||
updated_by = #{updated_by},
|
||||
updated_date = NOW()
|
||||
<if test="code_name != null">CODE_NAME = #{code_name},</if>
|
||||
<if test="code_name_eng != null">CODE_NAME_ENG = #{code_name_eng},</if>
|
||||
<if test="description != null">DESCRIPTION = #{description},</if>
|
||||
<if test="sort_order != null">SORT_ORDER = #{sort_order},</if>
|
||||
<if test="is_active != null">IS_ACTIVE = #{is_active},</if>
|
||||
<if test="menu_objid != null">MENU_OBJID = #{menu_objid},</if>
|
||||
UPDATED_BY = #{updated_by},
|
||||
UPDATED_DATE = NOW()
|
||||
</set>
|
||||
|
||||
WHERE category_code = #{category_code}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<delete id="deleteCommonCodeCategory" parameterType="map">
|
||||
DELETE FROM code_category
|
||||
<delete id="deleteCodeInfo" parameterType="map">
|
||||
DELETE FROM CODE_INFO
|
||||
|
||||
WHERE category_code = #{category_code}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</delete>
|
||||
|
||||
<select id="getCommonCodeCategoryDuplicateCnt" parameterType="map" resultType="int">
|
||||
<select id="getCodeInfoDuplicateCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM code_category
|
||||
FROM CODE_INFO
|
||||
|
||||
WHERE category_code = #{category_code}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeCategoryDuplicateByField" parameterType="map" resultType="int">
|
||||
<select id="getCodeInfoDuplicateByField" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM code_category
|
||||
FROM CODE_INFO
|
||||
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<choose>
|
||||
<when test="field == 'categoryCode'">AND category_code = #{value}</when>
|
||||
<when test="field == 'categoryName'">AND category_name = #{value}</when>
|
||||
<when test="field == 'categoryNameEng'">AND category_name_eng = #{value}</when>
|
||||
<otherwise>AND category_code = #{value}</otherwise>
|
||||
<when test='field == "code_info"'>AND CODE_INFO = #{value}</when>
|
||||
<when test='field == "code_name"'>AND CODE_NAME = #{value}</when>
|
||||
<when test='field == "code_name_eng"'>AND CODE_NAME_ENG = #{value}</when>
|
||||
<otherwise>AND CODE_INFO = #{value}</otherwise>
|
||||
</choose>
|
||||
<if test="exclude_code != null and exclude_code != ''">
|
||||
AND category_code != #{exclude_code}
|
||||
AND CODE_INFO != #{exclude_code}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- code_info -->
|
||||
<!-- CODE_DETAIL — 2레벨 ~ 무한대 트리 -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="getCommonCodeList" parameterType="map" resultType="map">
|
||||
<select id="getCodeDetailList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
code_category,
|
||||
code_value,
|
||||
code_name,
|
||||
code_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
parent_code_value,
|
||||
depth,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
CODE_DETAIL_ID
|
||||
, CODE_INFO
|
||||
, PARENT_DETAIL_ID
|
||||
, CODE_VALUE
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, DEPTH
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
|
||||
FROM code_info
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<choose>
|
||||
<when test="parent_detail_id != null">
|
||||
AND PARENT_DETAIL_ID = #{parent_detail_id}
|
||||
</when>
|
||||
<when test="only_roots != null and only_roots == true">
|
||||
AND PARENT_DETAIL_ID IS NULL
|
||||
</when>
|
||||
</choose>
|
||||
<if test="search != null and search != ''">
|
||||
AND (
|
||||
LOWER(code_value) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(code_name) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(code_name_eng, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
LOWER(CODE_VALUE) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(CODE_NAME) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(CODE_NAME_ENG, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
)
|
||||
</if>
|
||||
<if test="is_active != null">
|
||||
AND is_active = #{is_active}
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
|
||||
ORDER BY sort_order ASC, code_value ASC
|
||||
ORDER BY DEPTH ASC, SORT_ORDER ASC, CODE_VALUE ASC
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeListCnt" parameterType="map" resultType="int">
|
||||
<select id="getCodeDetailListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM code_info
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<choose>
|
||||
<when test="parent_detail_id != null">
|
||||
AND PARENT_DETAIL_ID = #{parent_detail_id}
|
||||
</when>
|
||||
<when test="only_roots != null and only_roots == true">
|
||||
AND PARENT_DETAIL_ID IS NULL
|
||||
</when>
|
||||
</choose>
|
||||
<if test="search != null and search != ''">
|
||||
AND (
|
||||
LOWER(code_value) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(code_name) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(code_name_eng, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
LOWER(CODE_VALUE) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(CODE_NAME) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(CODE_NAME_ENG, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
)
|
||||
</if>
|
||||
<if test="is_active != null">
|
||||
AND is_active = #{is_active}
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeInfo" parameterType="map" resultType="map">
|
||||
<select id="getCodeDetailInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
code_category,
|
||||
code_value,
|
||||
code_name,
|
||||
code_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
parent_code_value,
|
||||
depth,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
CODE_DETAIL_ID
|
||||
, CODE_INFO
|
||||
, PARENT_DETAIL_ID
|
||||
, CODE_VALUE
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, DEPTH
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
|
||||
FROM code_info
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND code_value = #{code_value}
|
||||
WHERE CODE_DETAIL_ID = #{code_detail_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<insert id="insertCommonCode" parameterType="map">
|
||||
INSERT INTO code_info (
|
||||
code_category,
|
||||
code_value,
|
||||
code_name,
|
||||
code_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
parent_code_value,
|
||||
depth,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
<!--
|
||||
그룹 전체 트리 — 재귀 CTE 로 평탄화.
|
||||
depth 오름차순 → sort_order 오름차순 → code_value 오름차순.
|
||||
-->
|
||||
<select id="getCodeDetailTree" parameterType="map" resultType="map">
|
||||
WITH RECURSIVE TREE AS (
|
||||
SELECT
|
||||
CODE_DETAIL_ID
|
||||
, CODE_INFO
|
||||
, PARENT_DETAIL_ID
|
||||
, CODE_VALUE
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, DEPTH
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
, ARRAY[SORT_ORDER, CODE_DETAIL_ID] AS PATH
|
||||
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
AND PARENT_DETAIL_ID IS NULL
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
C.CODE_DETAIL_ID
|
||||
, C.CODE_INFO
|
||||
, C.PARENT_DETAIL_ID
|
||||
, C.CODE_VALUE
|
||||
, C.CODE_NAME
|
||||
, C.CODE_NAME_ENG
|
||||
, C.DESCRIPTION
|
||||
, C.DEPTH
|
||||
, C.SORT_ORDER
|
||||
, C.IS_ACTIVE
|
||||
, C.COMPANY_CODE
|
||||
, C.CREATED_BY
|
||||
, C.UPDATED_BY
|
||||
, C.CREATED_DATE
|
||||
, C.UPDATED_DATE
|
||||
, TREE.PATH || ARRAY[C.SORT_ORDER, C.CODE_DETAIL_ID]
|
||||
|
||||
FROM CODE_DETAIL C
|
||||
INNER JOIN TREE ON C.PARENT_DETAIL_ID = TREE.CODE_DETAIL_ID
|
||||
WHERE C.CODE_INFO = #{code_info}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (C.COMPANY_CODE = #{company_code} OR C.COMPANY_CODE = '*')
|
||||
</if>
|
||||
)
|
||||
SELECT
|
||||
CODE_DETAIL_ID
|
||||
, CODE_INFO
|
||||
, PARENT_DETAIL_ID
|
||||
, CODE_VALUE
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, DEPTH
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
|
||||
FROM TREE
|
||||
|
||||
ORDER BY PATH
|
||||
</select>
|
||||
|
||||
<insert id="insertCodeDetail" parameterType="map" useGeneratedKeys="true" keyProperty="code_detail_id" keyColumn="code_detail_id">
|
||||
INSERT INTO CODE_DETAIL (
|
||||
CODE_INFO
|
||||
, PARENT_DETAIL_ID
|
||||
, CODE_VALUE
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, DEPTH
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
) VALUES (
|
||||
#{category_code},
|
||||
#{code_value},
|
||||
#{code_name},
|
||||
#{code_name_eng},
|
||||
#{description},
|
||||
#{sort_order},
|
||||
#{is_active},
|
||||
#{menu_objid},
|
||||
#{company_code},
|
||||
#{parent_code_value},
|
||||
#{depth},
|
||||
#{created_by},
|
||||
#{updated_by},
|
||||
NOW(),
|
||||
NOW()
|
||||
#{code_info}
|
||||
, #{parent_detail_id}
|
||||
, #{code_value}
|
||||
, #{code_name}
|
||||
, #{code_name_eng}
|
||||
, #{description}
|
||||
, #{depth}
|
||||
, #{sort_order}
|
||||
, #{is_active}
|
||||
, #{company_code}
|
||||
, #{created_by}
|
||||
, #{updated_by}
|
||||
, NOW()
|
||||
, NOW()
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCommonCode" parameterType="map">
|
||||
UPDATE code_info
|
||||
<update id="updateCodeDetail" parameterType="map">
|
||||
UPDATE CODE_DETAIL
|
||||
<set>
|
||||
<if test="code_name != null">code_name = #{code_name},</if>
|
||||
<if test="code_name_eng != null">code_name_eng = #{code_name_eng},</if>
|
||||
<if test="description != null">description = #{description},</if>
|
||||
<if test="sort_order != null">sort_order = #{sort_order},</if>
|
||||
<if test="is_active != null">is_active = #{is_active},</if>
|
||||
<if test="parent_code_value != null">parent_code_value = #{parent_code_value},</if>
|
||||
<if test="depth != null">depth = #{depth},</if>
|
||||
updated_by = #{updated_by},
|
||||
updated_date = NOW()
|
||||
<if test="code_value != null">CODE_VALUE = #{code_value},</if>
|
||||
<if test="code_name != null">CODE_NAME = #{code_name},</if>
|
||||
<if test="code_name_eng != null">CODE_NAME_ENG = #{code_name_eng},</if>
|
||||
<if test="description != null">DESCRIPTION = #{description},</if>
|
||||
<if test="sort_order != null">SORT_ORDER = #{sort_order},</if>
|
||||
<if test="is_active != null">IS_ACTIVE = #{is_active},</if>
|
||||
<if test="reparent != null and reparent == true">
|
||||
PARENT_DETAIL_ID = #{parent_detail_id},
|
||||
DEPTH = #{depth},
|
||||
</if>
|
||||
UPDATED_BY = #{updated_by},
|
||||
UPDATED_DATE = NOW()
|
||||
</set>
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND code_value = #{code_value}
|
||||
WHERE CODE_DETAIL_ID = #{code_detail_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<delete id="deleteCommonCode" parameterType="map">
|
||||
DELETE FROM code_info
|
||||
<delete id="deleteCodeDetail" parameterType="map">
|
||||
DELETE FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND code_value = #{code_value}
|
||||
WHERE CODE_DETAIL_ID = #{code_detail_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</delete>
|
||||
|
||||
<update id="updateCommonCodeSortOrder" parameterType="map">
|
||||
UPDATE code_info
|
||||
SET sort_order = #{sort_order},
|
||||
updated_date = NOW()
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND code_value = #{code_value}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<select id="getCommonCodeDuplicateCnt" parameterType="map" resultType="int">
|
||||
<select id="getCodeDetailDuplicateCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM code_info
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND code_value = #{code_value}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
AND CODE_VALUE = #{code_value}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<if test="exclude_id != null">
|
||||
AND CODE_DETAIL_ID != #{exclude_id}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeDuplicateByField" parameterType="map" resultType="int">
|
||||
<select id="getCodeDetailChildrenCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM code_info
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<choose>
|
||||
<when test="field == 'codeValue'">AND code_value = #{value}</when>
|
||||
<when test="field == 'codeName'">AND code_name = #{value}</when>
|
||||
<when test="field == 'codeNameEng'">AND code_name_eng = #{value}</when>
|
||||
<otherwise>AND code_value = #{value}</otherwise>
|
||||
</choose>
|
||||
<if test="exclude_code != null and exclude_code != ''">
|
||||
AND code_value != #{exclude_code}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeParentDepth" parameterType="map" resultType="int">
|
||||
SELECT COALESCE(depth, 0)
|
||||
|
||||
FROM code_info
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND code_value = #{code_value}
|
||||
WHERE PARENT_DETAIL_ID = #{code_detail_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeChildrenCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
<select id="getCodeDetailParentDepth" parameterType="map" resultType="int">
|
||||
SELECT COALESCE(DEPTH, 1)
|
||||
|
||||
FROM code_info
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND parent_code_value = #{code_value}
|
||||
WHERE CODE_DETAIL_ID = #{code_detail_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- 계층 / 트리 / 옵션 -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="getCommonCodeHierarchicalList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
code_category,
|
||||
code_value,
|
||||
code_name,
|
||||
code_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
parent_code_value,
|
||||
depth,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
|
||||
FROM code_info
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<if test="is_active != null">
|
||||
AND is_active = #{is_active}
|
||||
</if>
|
||||
<if test="parent_code_value != null">
|
||||
AND parent_code_value = #{parent_code_value}
|
||||
</if>
|
||||
<if test="depth != null">
|
||||
AND depth = #{depth}
|
||||
</if>
|
||||
|
||||
ORDER BY depth ASC, sort_order ASC, code_value ASC
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeTreeList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
code_category,
|
||||
code_value,
|
||||
code_name,
|
||||
code_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
parent_code_value,
|
||||
depth,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
|
||||
FROM code_info
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
|
||||
ORDER BY depth ASC, sort_order ASC, code_value ASC
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeOptionList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
code_value,
|
||||
code_name,
|
||||
code_name_eng
|
||||
|
||||
FROM code_info
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<if test="is_active != null">
|
||||
AND is_active = #{is_active}
|
||||
</if>
|
||||
|
||||
ORDER BY sort_order ASC, code_value ASC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,502 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Filter, Plus, RefreshCw, Search, Pencil, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import {
|
||||
cascadingConditionApi,
|
||||
CascadingCondition,
|
||||
CONDITION_OPERATORS,
|
||||
} from "@/lib/api/cascadingCondition";
|
||||
import { cascadingRelationApi } from "@/lib/api/cascadingRelation";
|
||||
|
||||
export default function ConditionTab() {
|
||||
// 목록 상태
|
||||
const [conditions, setConditions] = useState<CascadingCondition[]>([]);
|
||||
const [relations, setRelations] = useState<Array<{ relation_code: string; relation_name: string }>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [editingCondition, setEditingCondition] = useState<CascadingCondition | null>(null);
|
||||
const [deletingConditionId, setDeletingConditionId] = useState<number | null>(null);
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<Omit<CascadingCondition, "conditionId">>({
|
||||
relationType: "RELATION",
|
||||
relationCode: "",
|
||||
conditionName: "",
|
||||
conditionField: "",
|
||||
conditionOperator: "EQ",
|
||||
conditionValue: "",
|
||||
filterColumn: "",
|
||||
filterValues: "",
|
||||
priority: 0,
|
||||
});
|
||||
|
||||
// 목록 로드
|
||||
const loadConditions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await cascadingConditionApi.getList();
|
||||
if (response.success && response.data) {
|
||||
setConditions(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("조건 목록 로드 실패:", error);
|
||||
toast.error("조건 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 연쇄 관계 목록 로드
|
||||
const loadRelations = useCallback(async () => {
|
||||
try {
|
||||
const response = await cascadingRelationApi.getList("Y");
|
||||
if (response.success && response.data) {
|
||||
setRelations(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("연쇄 관계 목록 로드 실패:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadConditions();
|
||||
loadRelations();
|
||||
}, [loadConditions, loadRelations]);
|
||||
|
||||
// 필터된 목록
|
||||
const filteredConditions = conditions.filter(
|
||||
(c) =>
|
||||
c.conditionName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
c.relationCode?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
c.conditionField?.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
|
||||
// 모달 열기 (생성)
|
||||
const handleOpenCreate = () => {
|
||||
setEditingCondition(null);
|
||||
setFormData({
|
||||
relationType: "RELATION",
|
||||
relationCode: "",
|
||||
conditionName: "",
|
||||
conditionField: "",
|
||||
conditionOperator: "EQ",
|
||||
conditionValue: "",
|
||||
filterColumn: "",
|
||||
filterValues: "",
|
||||
priority: 0,
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 모달 열기 (수정)
|
||||
const handleOpenEdit = (condition: CascadingCondition) => {
|
||||
setEditingCondition(condition);
|
||||
setFormData({
|
||||
relationType: condition.relationType || "RELATION",
|
||||
relationCode: condition.relationCode,
|
||||
conditionName: condition.conditionName,
|
||||
conditionField: condition.conditionField,
|
||||
conditionOperator: condition.conditionOperator,
|
||||
conditionValue: condition.conditionValue,
|
||||
filterColumn: condition.filterColumn,
|
||||
filterValues: condition.filterValues,
|
||||
priority: condition.priority || 0,
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleDeleteConfirm = (conditionId: number) => {
|
||||
setDeletingConditionId(conditionId);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 실행
|
||||
const handleDelete = async () => {
|
||||
if (!deletingConditionId) return;
|
||||
|
||||
try {
|
||||
const response = await cascadingConditionApi.delete(deletingConditionId);
|
||||
if (response.success) {
|
||||
toast.success("조건부 규칙이 삭제되었습니다.");
|
||||
loadConditions();
|
||||
} else {
|
||||
toast.error(response.error || "삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast("조건 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
||||
} finally {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeletingConditionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
// 유효성 검사
|
||||
if (!formData.relationCode || !formData.conditionName || !formData.conditionField) {
|
||||
toast.error("필수 항목을 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.conditionValue || !formData.filterColumn || !formData.filterValues) {
|
||||
toast.error("조건 값, 필터 컬럼, 필터 값을 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (editingCondition) {
|
||||
response = await cascadingConditionApi.update(editingCondition.conditionId!, formData);
|
||||
} else {
|
||||
response = await cascadingConditionApi.create(formData);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(editingCondition ? "수정되었습니다." : "생성되었습니다.");
|
||||
setIsModalOpen(false);
|
||||
loadConditions();
|
||||
} else {
|
||||
toast.error(response.error || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast("조건 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
||||
}
|
||||
};
|
||||
|
||||
// 연산자 라벨 찾기
|
||||
const getOperatorLabel = (operator: string) => {
|
||||
return CONDITION_OPERATORS.find((op) => op.value === operator)?.label || operator;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 검색 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="조건명, 관계 코드, 조건 필드로 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={loadConditions}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 목록 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Filter className="h-5 w-5" />
|
||||
조건부 필터 규칙
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
특정 필드 값에 따라 드롭다운 옵션을 필터링합니다. (총 {filteredConditions.length}개)
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={handleOpenCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
새 규칙 추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||
<span className="ml-2">로딩 중...</span>
|
||||
</div>
|
||||
) : filteredConditions.length === 0 ? (
|
||||
<div className="text-muted-foreground space-y-4 py-8 text-center">
|
||||
<div className="text-sm">
|
||||
{searchText ? "검색 결과가 없습니다." : "등록된 조건부 필터 규칙이 없습니다."}
|
||||
</div>
|
||||
<div className="mx-auto max-w-md space-y-3 text-left">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="text-foreground mb-2 text-sm font-medium">예시: 상태별 품목 필터</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
"상태" 필드가 "활성"일 때만 "품목" 드롭다운에 활성 품목만 표시
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="text-foreground mb-2 text-sm font-medium">예시: 유형별 옵션 필터</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
"유형" 필드가 "입고"일 때 "창고" 드롭다운에 입고 가능 창고만 표시
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>연쇄 관계</TableHead>
|
||||
<TableHead>조건명</TableHead>
|
||||
<TableHead>조건</TableHead>
|
||||
<TableHead>필터</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredConditions.map((condition) => (
|
||||
<TableRow key={condition.conditionId}>
|
||||
<TableCell className="font-mono text-sm">{condition.relationCode}</TableCell>
|
||||
<TableCell className="font-medium">{condition.conditionName}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{condition.conditionField}</span>
|
||||
<span className="mx-1 text-primary">{getOperatorLabel(condition.conditionOperator)}</span>
|
||||
<span className="font-medium">{condition.conditionValue}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{condition.filterColumn}</span>
|
||||
<span className="mx-1">=</span>
|
||||
<span className="font-mono text-xs">{condition.filterValues}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={condition.isActive === "Y" ? "default" : "secondary"}>
|
||||
{condition.isActive === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(condition)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteConfirm(condition.conditionId!)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 생성/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingCondition ? "조건부 규칙 수정" : "조건부 규칙 생성"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
특정 필드 값에 따라 드롭다운 옵션을 필터링하는 규칙을 설정합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 연쇄 관계 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label>연쇄 관계 *</Label>
|
||||
<Select
|
||||
value={formData.relationCode}
|
||||
onValueChange={(value) => setFormData({ ...formData, relationCode: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="연쇄 관계 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{relations.map((rel) => (
|
||||
<SelectItem key={rel.relation_code} value={rel.relation_code}>
|
||||
{rel.relation_name} ({rel.relation_code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 조건명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>조건명 *</Label>
|
||||
<Input
|
||||
value={formData.conditionName}
|
||||
onChange={(e) => setFormData({ ...formData, conditionName: e.target.value })}
|
||||
placeholder="예: 활성 품목만 표시"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 조건 설정 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="mb-3 text-sm font-semibold">조건 설정</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">조건 필드 *</Label>
|
||||
<Input
|
||||
value={formData.conditionField}
|
||||
onChange={(e) => setFormData({ ...formData, conditionField: e.target.value })}
|
||||
placeholder="예: status"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">연산자 *</Label>
|
||||
<Select
|
||||
value={formData.conditionOperator}
|
||||
onValueChange={(value) => setFormData({ ...formData, conditionOperator: value })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONDITION_OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">조건 값 *</Label>
|
||||
<Input
|
||||
value={formData.conditionValue}
|
||||
onChange={(e) => setFormData({ ...formData, conditionValue: e.target.value })}
|
||||
placeholder="예: active"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
폼의 "{formData.conditionField || "필드"}" 값이 "{formData.conditionValue || "값"}"일 때 필터 적용
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 필터 설정 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="mb-3 text-sm font-semibold">필터 설정</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">필터 컬럼 *</Label>
|
||||
<Input
|
||||
value={formData.filterColumn}
|
||||
onChange={(e) => setFormData({ ...formData, filterColumn: e.target.value })}
|
||||
placeholder="예: status"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">필터 값 *</Label>
|
||||
<Input
|
||||
value={formData.filterValues}
|
||||
onChange={(e) => setFormData({ ...formData, filterValues: e.target.value })}
|
||||
placeholder="예: active,pending"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
드롭다운 옵션 중 "{formData.filterColumn || "컬럼"}"이 "{formData.filterValues || "값"}"인 항목만 표시
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 우선순위 */}
|
||||
<div className="space-y-2">
|
||||
<Label>우선순위</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: Number(e.target.value) })}
|
||||
placeholder="높을수록 먼저 적용"
|
||||
className="w-32"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
여러 조건이 일치할 경우 우선순위가 높은 규칙이 적용됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{editingCondition ? "수정" : "생성"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>조건부 규칙 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 조건부 규칙을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,627 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Plus, Pencil, Trash2, Database, RefreshCw, Layers } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import {
|
||||
hierarchyColumnApi,
|
||||
HierarchyColumnGroup,
|
||||
CreateHierarchyGroupRequest,
|
||||
} from "@/lib/api/hierarchyColumn";
|
||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
import apiClient from "@/lib/api/client";
|
||||
|
||||
interface TableInfo {
|
||||
tableName: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
columnName: string;
|
||||
displayName?: string;
|
||||
dataType?: string;
|
||||
}
|
||||
|
||||
interface CategoryInfo {
|
||||
categoryCode: string;
|
||||
categoryName: string;
|
||||
}
|
||||
|
||||
export default function HierarchyColumnTab() {
|
||||
// 상태
|
||||
const [groups, setGroups] = useState<HierarchyColumnGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [selectedGroup, setSelectedGroup] = useState<HierarchyColumnGroup | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState({
|
||||
groupCode: "",
|
||||
groupName: "",
|
||||
description: "",
|
||||
codeCategory: "",
|
||||
tableName: "",
|
||||
maxDepth: 3,
|
||||
mappings: [
|
||||
{ depth: 1, levelLabel: "대분류", columnName: "", placeholder: "대분류 선택", isRequired: true },
|
||||
{ depth: 2, levelLabel: "중분류", columnName: "", placeholder: "중분류 선택", isRequired: false },
|
||||
{ depth: 3, levelLabel: "소분류", columnName: "", placeholder: "소분류 선택", isRequired: false },
|
||||
],
|
||||
});
|
||||
|
||||
// 참조 데이터
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||
const [categories, setCategories] = useState<CategoryInfo[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [loadingCategories, setLoadingCategories] = useState(false);
|
||||
|
||||
// 그룹 목록 로드
|
||||
const loadGroups = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await hierarchyColumnApi.getAll();
|
||||
if (response.success && response.data) {
|
||||
setGroups(response.data);
|
||||
} else {
|
||||
toast.error(response.error || "계층구조 그룹 로드 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("계층구조 그룹 로드 에러:", error);
|
||||
toast.error("계층구조 그룹을 로드하는 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 테이블 목록 로드
|
||||
const loadTables = useCallback(async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const response = await apiClient.get("/table-management/tables");
|
||||
if (response.data?.success && response.data?.data) {
|
||||
setTables(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 로드 에러:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 카테고리 목록 로드
|
||||
const loadCategories = useCallback(async () => {
|
||||
setLoadingCategories(true);
|
||||
try {
|
||||
const response = await commonCodeApi.categories.getList();
|
||||
if (response.success && response.data) {
|
||||
setCategories(
|
||||
response.data.map((cat: any) => ({
|
||||
categoryCode: cat.categoryCode || cat.category_code,
|
||||
categoryName: cat.categoryName || cat.category_name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 로드 에러:", error);
|
||||
} finally {
|
||||
setLoadingCategories(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 테이블 선택 시 컬럼 로드
|
||||
const loadColumns = useCallback(async (tableName: string) => {
|
||||
if (!tableName) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
if (response.data?.success && response.data?.data) {
|
||||
setColumns(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 로드 에러:", error);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
loadGroups();
|
||||
loadTables();
|
||||
loadCategories();
|
||||
}, [loadGroups, loadTables, loadCategories]);
|
||||
|
||||
// 테이블 선택 변경 시 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (formData.tableName) {
|
||||
loadColumns(formData.tableName);
|
||||
}
|
||||
}, [formData.tableName, loadColumns]);
|
||||
|
||||
// 폼 초기화
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
groupCode: "",
|
||||
groupName: "",
|
||||
description: "",
|
||||
codeCategory: "",
|
||||
tableName: "",
|
||||
maxDepth: 3,
|
||||
mappings: [
|
||||
{ depth: 1, levelLabel: "대분류", columnName: "", placeholder: "대분류 선택", isRequired: true },
|
||||
{ depth: 2, levelLabel: "중분류", columnName: "", placeholder: "중분류 선택", isRequired: false },
|
||||
{ depth: 3, levelLabel: "소분류", columnName: "", placeholder: "소분류 선택", isRequired: false },
|
||||
],
|
||||
});
|
||||
setSelectedGroup(null);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
// 모달 열기 (신규)
|
||||
const openCreateModal = () => {
|
||||
resetForm();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// 모달 열기 (수정)
|
||||
const openEditModal = (group: HierarchyColumnGroup) => {
|
||||
setSelectedGroup(group);
|
||||
setIsEditing(true);
|
||||
|
||||
// 매핑 데이터 변환
|
||||
const mappings = [1, 2, 3].map((depth) => {
|
||||
const existing = group.mappings?.find((m) => m.depth === depth);
|
||||
return {
|
||||
depth,
|
||||
levelLabel: existing?.level_label || (depth === 1 ? "대분류" : depth === 2 ? "중분류" : "소분류"),
|
||||
columnName: existing?.column_name || "",
|
||||
placeholder: existing?.placeholder || `${depth === 1 ? "대분류" : depth === 2 ? "중분류" : "소분류"} 선택`,
|
||||
isRequired: existing?.is_required === "Y",
|
||||
};
|
||||
});
|
||||
|
||||
setFormData({
|
||||
groupCode: group.group_code,
|
||||
groupName: group.group_name,
|
||||
description: group.description || "",
|
||||
codeCategory: group.code_category,
|
||||
tableName: group.table_name,
|
||||
maxDepth: group.max_depth,
|
||||
mappings,
|
||||
});
|
||||
|
||||
// 컬럼 로드
|
||||
loadColumns(group.table_name);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 확인 열기
|
||||
const openDeleteDialog = (group: HierarchyColumnGroup) => {
|
||||
setSelectedGroup(group);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
// 필수 필드 검증
|
||||
if (!formData.groupCode || !formData.groupName || !formData.codeCategory || !formData.tableName) {
|
||||
toast.error("필수 필드를 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 최소 1개 컬럼 매핑 검증
|
||||
const validMappings = formData.mappings
|
||||
.filter((m) => m.depth <= formData.maxDepth && m.columnName)
|
||||
.map((m) => ({
|
||||
depth: m.depth,
|
||||
levelLabel: m.levelLabel,
|
||||
columnName: m.columnName,
|
||||
placeholder: m.placeholder,
|
||||
isRequired: m.isRequired,
|
||||
}));
|
||||
|
||||
if (validMappings.length === 0) {
|
||||
toast.error("최소 하나의 컬럼 매핑이 필요합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEditing && selectedGroup) {
|
||||
// 수정
|
||||
const response = await hierarchyColumnApi.update(selectedGroup.group_id, {
|
||||
groupName: formData.groupName,
|
||||
description: formData.description,
|
||||
maxDepth: formData.maxDepth,
|
||||
mappings: validMappings,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
toast.success("계층구조 그룹이 수정되었습니다.");
|
||||
setModalOpen(false);
|
||||
loadGroups();
|
||||
} else {
|
||||
toast.error(response.error || "수정 실패");
|
||||
}
|
||||
} else {
|
||||
// 생성
|
||||
const request: CreateHierarchyGroupRequest = {
|
||||
groupCode: formData.groupCode,
|
||||
groupName: formData.groupName,
|
||||
description: formData.description,
|
||||
codeCategory: formData.codeCategory,
|
||||
tableName: formData.tableName,
|
||||
maxDepth: formData.maxDepth,
|
||||
mappings: validMappings,
|
||||
};
|
||||
|
||||
const response = await hierarchyColumnApi.create(request);
|
||||
|
||||
if (response.success) {
|
||||
toast.success("계층구조 그룹이 생성되었습니다.");
|
||||
setModalOpen(false);
|
||||
loadGroups();
|
||||
} else {
|
||||
toast.error(response.error || "생성 실패");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("저장 에러:", error);
|
||||
showErrorToast("계층구조 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const handleDelete = async () => {
|
||||
if (!selectedGroup) return;
|
||||
|
||||
try {
|
||||
const response = await hierarchyColumnApi.delete(selectedGroup.group_id);
|
||||
if (response.success) {
|
||||
toast.success("계층구조 그룹이 삭제되었습니다.");
|
||||
setDeleteDialogOpen(false);
|
||||
loadGroups();
|
||||
} else {
|
||||
toast.error(response.error || "삭제 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("삭제 에러:", error);
|
||||
showErrorToast("계층구조 설정 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
||||
}
|
||||
};
|
||||
|
||||
// 매핑 컬럼 변경
|
||||
const handleMappingChange = (depth: number, field: string, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
mappings: prev.mappings.map((m) =>
|
||||
m.depth === depth ? { ...m, [field]: value } : m
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">계층구조 컬럼 그룹</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
공통코드 계층구조를 테이블 컬럼에 매핑하여 대분류/중분류/소분류를 각각 별도 컬럼에 저장합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={loadGroups} disabled={loading}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreateModal}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
새 그룹 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 그룹 목록 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<LoadingSpinner />
|
||||
<span className="ml-2 text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Layers className="h-12 w-12 text-muted-foreground" />
|
||||
<p className="mt-4 text-muted-foreground">계층구조 컬럼 그룹이 없습니다.</p>
|
||||
<Button className="mt-4" onClick={openCreateModal}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
첫 번째 그룹 만들기
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{groups.map((group) => (
|
||||
<Card key={group.group_id} className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">{group.group_name}</CardTitle>
|
||||
<CardDescription className="text-xs">{group.group_code}</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEditModal(group)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => openDeleteDialog(group)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{group.table_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{group.code_category}</Badge>
|
||||
<Badge variant="secondary">{group.max_depth}단계</Badge>
|
||||
</div>
|
||||
{group.mappings && group.mappings.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{group.mappings.map((mapping) => (
|
||||
<div key={mapping.depth} className="flex items-center gap-2 text-xs">
|
||||
<Badge variant="outline" className="w-14 justify-center">
|
||||
{mapping.level_label}
|
||||
</Badge>
|
||||
<span className="font-mono text-muted-foreground">{mapping.column_name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 생성/수정 모달 */}
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className="max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? "계층구조 그룹 수정" : "계층구조 그룹 생성"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
공통코드 계층구조를 테이블 컬럼에 매핑합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 기본 정보 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>그룹 코드 *</Label>
|
||||
<Input
|
||||
value={formData.groupCode}
|
||||
onChange={(e) => setFormData({ ...formData, groupCode: e.target.value.toUpperCase() })}
|
||||
placeholder="예: ITEM_CAT_HIERARCHY"
|
||||
disabled={isEditing}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>그룹명 *</Label>
|
||||
<Input
|
||||
value={formData.groupName}
|
||||
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
|
||||
placeholder="예: 품목분류 계층"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>설명</Label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="계층구조에 대한 설명"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>공통코드 카테고리 *</Label>
|
||||
<Select
|
||||
value={formData.codeCategory}
|
||||
onValueChange={(value) => setFormData({ ...formData, codeCategory: value })}
|
||||
disabled={isEditing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loadingCategories ? (
|
||||
<SelectItem value="_loading" disabled>로딩 중...</SelectItem>
|
||||
) : (
|
||||
categories.map((cat) => (
|
||||
<SelectItem key={cat.categoryCode} value={cat.categoryCode}>
|
||||
{cat.categoryName} ({cat.categoryCode})
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>적용 테이블 *</Label>
|
||||
<Select
|
||||
value={formData.tableName}
|
||||
onValueChange={(value) => setFormData({ ...formData, tableName: value })}
|
||||
disabled={isEditing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loadingTables ? (
|
||||
<SelectItem value="_loading" disabled>로딩 중...</SelectItem>
|
||||
) : (
|
||||
tables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{table.displayName || table.tableName}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>최대 깊이</Label>
|
||||
<Select
|
||||
value={String(formData.maxDepth)}
|
||||
onValueChange={(value) => setFormData({ ...formData, maxDepth: Number(value) })}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1단계 (대분류만)</SelectItem>
|
||||
<SelectItem value="2">2단계 (대/중분류)</SelectItem>
|
||||
<SelectItem value="3">3단계 (대/중/소분류)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 매핑 */}
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<Label className="text-base font-medium">컬럼 매핑</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
각 계층 레벨에 저장할 컬럼을 선택합니다.
|
||||
</p>
|
||||
|
||||
{formData.mappings
|
||||
.filter((m) => m.depth <= formData.maxDepth)
|
||||
.map((mapping) => (
|
||||
<div key={mapping.depth} className="grid grid-cols-4 gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={mapping.depth === 1 ? "default" : "outline"}>
|
||||
{mapping.depth}단계
|
||||
</Badge>
|
||||
<Input
|
||||
value={mapping.levelLabel}
|
||||
onChange={(e) => handleMappingChange(mapping.depth, "levelLabel", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
placeholder="라벨"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={mapping.columnName || "_none"}
|
||||
onValueChange={(value) => handleMappingChange(mapping.depth, "columnName", value === "_none" ? "" : value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none">컬럼 선택</SelectItem>
|
||||
{loadingColumns ? (
|
||||
<SelectItem value="_loading" disabled>로딩 중...</SelectItem>
|
||||
) : (
|
||||
columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={mapping.placeholder}
|
||||
onChange={(e) => handleMappingChange(mapping.depth, "placeholder", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
placeholder="플레이스홀더"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mapping.isRequired}
|
||||
onChange={(e) => handleMappingChange(mapping.depth, "isRequired", e.target.checked)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">필수</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{isEditing ? "수정" : "생성"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>계층구조 그룹 삭제</DialogTitle>
|
||||
<DialogDescription>
|
||||
"{selectedGroup?.group_name}" 그룹을 삭제하시겠습니까?
|
||||
<br />
|
||||
이 작업은 되돌릴 수 없습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
삭제
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,847 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Layers,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Pencil,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { hierarchyApi, HierarchyGroup, HierarchyLevel, HIERARCHY_TYPES } from "@/lib/api/cascadingHierarchy";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
|
||||
export default function HierarchyTab() {
|
||||
// 목록 상태
|
||||
const [groups, setGroups] = useState<HierarchyGroup[]>([]);
|
||||
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
// 확장된 그룹 (레벨 표시)
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const [groupLevels, setGroupLevels] = useState<Record<string, HierarchyLevel[]>>({});
|
||||
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<HierarchyGroup | null>(null);
|
||||
const [deletingGroupCode, setDeletingGroupCode] = useState<string | null>(null);
|
||||
|
||||
// 레벨 모달
|
||||
const [isLevelModalOpen, setIsLevelModalOpen] = useState(false);
|
||||
const [editingLevel, setEditingLevel] = useState<HierarchyLevel | null>(null);
|
||||
const [currentGroupCode, setCurrentGroupCode] = useState<string>("");
|
||||
const [levelColumns, setLevelColumns] = useState<Array<{ columnName: string; displayName: string }>>([]);
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<Partial<HierarchyGroup>>({
|
||||
groupName: "",
|
||||
description: "",
|
||||
hierarchyType: "MULTI_TABLE",
|
||||
maxLevels: undefined,
|
||||
isFixedLevels: "Y",
|
||||
emptyMessage: "선택해주세요",
|
||||
noOptionsMessage: "옵션이 없습니다",
|
||||
loadingMessage: "로딩 중...",
|
||||
});
|
||||
|
||||
// 레벨 폼 데이터
|
||||
const [levelFormData, setLevelFormData] = useState<Partial<HierarchyLevel>>({
|
||||
levelOrder: 1,
|
||||
levelName: "",
|
||||
levelCode: "",
|
||||
tableName: "",
|
||||
valueColumn: "",
|
||||
labelColumn: "",
|
||||
parentKeyColumn: "",
|
||||
orderColumn: "",
|
||||
orderDirection: "ASC",
|
||||
placeholder: "",
|
||||
isRequired: "Y",
|
||||
isSearchable: "N",
|
||||
});
|
||||
|
||||
// 테이블 Combobox 상태
|
||||
const [tableComboOpen, setTableComboOpen] = useState(false);
|
||||
|
||||
// snake_case를 camelCase로 변환하는 함수
|
||||
const transformGroup = (g: any): HierarchyGroup => ({
|
||||
groupId: g.group_id,
|
||||
groupCode: g.group_code,
|
||||
groupName: g.group_name,
|
||||
description: g.description,
|
||||
hierarchyType: g.hierarchy_type,
|
||||
maxLevels: g.max_levels,
|
||||
isFixedLevels: g.is_fixed_levels,
|
||||
selfRefTable: g.self_ref_table,
|
||||
selfRefIdColumn: g.self_ref_id_column,
|
||||
selfRefParentColumn: g.self_ref_parent_column,
|
||||
selfRefValueColumn: g.self_ref_value_column,
|
||||
selfRefLabelColumn: g.self_ref_label_column,
|
||||
selfRefLevelColumn: g.self_ref_level_column,
|
||||
selfRefOrderColumn: g.self_ref_order_column,
|
||||
bomTable: g.bom_table,
|
||||
bomParentColumn: g.bom_parent_column,
|
||||
bomChildColumn: g.bom_child_column,
|
||||
bomItemTable: g.bom_item_table,
|
||||
bomItemIdColumn: g.bom_item_id_column,
|
||||
bomItemLabelColumn: g.bom_item_label_column,
|
||||
bomQtyColumn: g.bom_qty_column,
|
||||
bomLevelColumn: g.bom_level_column,
|
||||
emptyMessage: g.empty_message,
|
||||
noOptionsMessage: g.no_options_message,
|
||||
loadingMessage: g.loading_message,
|
||||
companyCode: g.company_code,
|
||||
isActive: g.is_active,
|
||||
createdBy: g.created_by,
|
||||
createdDate: g.created_date,
|
||||
updatedBy: g.updated_by,
|
||||
updatedDate: g.updated_date,
|
||||
levelCount: g.level_count || 0,
|
||||
levels: g.levels,
|
||||
});
|
||||
|
||||
// 목록 로드
|
||||
const loadGroups = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await hierarchyApi.getGroups();
|
||||
if (response.success && response.data) {
|
||||
// snake_case를 camelCase로 변환
|
||||
const transformedData = response.data.map(transformGroup);
|
||||
setGroups(transformedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("계층 그룹 목록 로드 실패:", error);
|
||||
toast.error("목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 테이블 목록 로드
|
||||
const loadTables = useCallback(async () => {
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setTables(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadGroups();
|
||||
loadTables();
|
||||
}, [loadGroups, loadTables]);
|
||||
|
||||
// 그룹 레벨 로드
|
||||
const loadGroupLevels = async (groupCode: string) => {
|
||||
try {
|
||||
const response = await hierarchyApi.getDetail(groupCode);
|
||||
if (response.success && response.data?.levels) {
|
||||
setGroupLevels((prev) => ({
|
||||
...prev,
|
||||
[groupCode]: response.data!.levels || [],
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("레벨 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 그룹 확장 토글
|
||||
const toggleGroupExpand = async (groupCode: string) => {
|
||||
const newExpanded = new Set(expandedGroups);
|
||||
if (newExpanded.has(groupCode)) {
|
||||
newExpanded.delete(groupCode);
|
||||
} else {
|
||||
newExpanded.add(groupCode);
|
||||
if (!groupLevels[groupCode]) {
|
||||
await loadGroupLevels(groupCode);
|
||||
}
|
||||
}
|
||||
setExpandedGroups(newExpanded);
|
||||
};
|
||||
|
||||
// 컬럼 로드 (레벨 폼용)
|
||||
const loadLevelColumns = async (tableName: string) => {
|
||||
if (!tableName) {
|
||||
setLevelColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(tableName);
|
||||
if (response.success && response.data?.columns) {
|
||||
setLevelColumns(response.data.columns);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 필터된 목록
|
||||
const filteredGroups = groups.filter(
|
||||
(g) =>
|
||||
g.groupName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
g.groupCode?.toLowerCase().includes(searchText.toLowerCase()),
|
||||
);
|
||||
|
||||
// 모달 열기 (생성)
|
||||
const handleOpenCreate = () => {
|
||||
setEditingGroup(null);
|
||||
setFormData({
|
||||
groupName: "",
|
||||
description: "",
|
||||
hierarchyType: "MULTI_TABLE",
|
||||
maxLevels: undefined,
|
||||
isFixedLevels: "Y",
|
||||
emptyMessage: "선택해주세요",
|
||||
noOptionsMessage: "옵션이 없습니다",
|
||||
loadingMessage: "로딩 중...",
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 모달 열기 (수정)
|
||||
const handleOpenEdit = (group: HierarchyGroup) => {
|
||||
setEditingGroup(group);
|
||||
setFormData({
|
||||
groupCode: group.groupCode,
|
||||
groupName: group.groupName,
|
||||
description: group.description || "",
|
||||
hierarchyType: group.hierarchyType,
|
||||
maxLevels: group.maxLevels,
|
||||
isFixedLevels: group.isFixedLevels || "Y",
|
||||
emptyMessage: group.emptyMessage || "선택해주세요",
|
||||
noOptionsMessage: group.noOptionsMessage || "옵션이 없습니다",
|
||||
loadingMessage: group.loadingMessage || "로딩 중...",
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleDeleteConfirm = (groupCode: string) => {
|
||||
setDeletingGroupCode(groupCode);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 실행
|
||||
const handleDelete = async () => {
|
||||
if (!deletingGroupCode) return;
|
||||
|
||||
try {
|
||||
const response = await hierarchyApi.deleteGroup(deletingGroupCode);
|
||||
if (response.success) {
|
||||
toast.success("계층 그룹이 삭제되었습니다.");
|
||||
loadGroups();
|
||||
} else {
|
||||
toast.error(response.error || "삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("삭제 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeletingGroupCode(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!formData.groupName || !formData.hierarchyType) {
|
||||
toast.error("필수 항목을 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (editingGroup) {
|
||||
response = await hierarchyApi.updateGroup(editingGroup.groupCode!, formData);
|
||||
} else {
|
||||
response = await hierarchyApi.createGroup(formData as HierarchyGroup);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(editingGroup ? "수정되었습니다." : "생성되었습니다.");
|
||||
setIsModalOpen(false);
|
||||
loadGroups();
|
||||
} else {
|
||||
toast.error(response.error || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 레벨 모달 열기 (생성)
|
||||
const handleOpenCreateLevel = (groupCode: string) => {
|
||||
setCurrentGroupCode(groupCode);
|
||||
setEditingLevel(null);
|
||||
const existingLevels = groupLevels[groupCode] || [];
|
||||
setLevelFormData({
|
||||
levelOrder: existingLevels.length + 1,
|
||||
levelName: "",
|
||||
levelCode: "",
|
||||
tableName: "",
|
||||
valueColumn: "",
|
||||
labelColumn: "",
|
||||
parentKeyColumn: "",
|
||||
orderColumn: "",
|
||||
orderDirection: "ASC",
|
||||
placeholder: "",
|
||||
isRequired: "Y",
|
||||
isSearchable: "N",
|
||||
});
|
||||
setLevelColumns([]);
|
||||
setIsLevelModalOpen(true);
|
||||
};
|
||||
|
||||
// 레벨 모달 열기 (수정)
|
||||
const handleOpenEditLevel = async (level: HierarchyLevel) => {
|
||||
setCurrentGroupCode(level.groupCode);
|
||||
setEditingLevel(level);
|
||||
setLevelFormData({
|
||||
levelOrder: level.levelOrder,
|
||||
levelName: level.levelName,
|
||||
levelCode: level.levelCode || "",
|
||||
tableName: level.tableName,
|
||||
valueColumn: level.valueColumn,
|
||||
labelColumn: level.labelColumn,
|
||||
parentKeyColumn: level.parentKeyColumn || "",
|
||||
orderColumn: level.orderColumn || "",
|
||||
orderDirection: level.orderDirection || "ASC",
|
||||
placeholder: level.placeholder || "",
|
||||
isRequired: level.isRequired || "Y",
|
||||
isSearchable: level.isSearchable || "N",
|
||||
});
|
||||
await loadLevelColumns(level.tableName);
|
||||
setIsLevelModalOpen(true);
|
||||
};
|
||||
|
||||
// 레벨 저장
|
||||
const handleSaveLevel = async () => {
|
||||
if (
|
||||
!levelFormData.levelName ||
|
||||
!levelFormData.tableName ||
|
||||
!levelFormData.valueColumn ||
|
||||
!levelFormData.labelColumn
|
||||
) {
|
||||
toast.error("필수 항목을 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (editingLevel) {
|
||||
response = await hierarchyApi.updateLevel(editingLevel.levelId!, levelFormData);
|
||||
} else {
|
||||
response = await hierarchyApi.addLevel(currentGroupCode, levelFormData);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(editingLevel ? "레벨이 수정되었습니다." : "레벨이 추가되었습니다.");
|
||||
setIsLevelModalOpen(false);
|
||||
await loadGroupLevels(currentGroupCode);
|
||||
} else {
|
||||
toast.error(response.error || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 레벨 삭제
|
||||
const handleDeleteLevel = async (levelId: number, groupCode: string) => {
|
||||
if (!confirm("이 레벨을 삭제하시겠습니까?")) return;
|
||||
|
||||
try {
|
||||
const response = await hierarchyApi.deleteLevel(levelId);
|
||||
if (response.success) {
|
||||
toast.success("레벨이 삭제되었습니다.");
|
||||
await loadGroupLevels(groupCode);
|
||||
} else {
|
||||
toast.error(response.error || "삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("삭제 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 계층 타입 라벨
|
||||
const getHierarchyTypeLabel = (type: string) => {
|
||||
return HIERARCHY_TYPES.find((t) => t.value === type)?.label || type;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 검색 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="그룹 코드, 이름으로 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={loadGroups}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 목록 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Layers className="h-5 w-5" />
|
||||
다단계 계층
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
국가 > 도시 > 구 같은 다단계 연쇄 드롭다운을 관리합니다. (총 {filteredGroups.length}개)
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={handleOpenCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 계층 추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||
<span className="ml-2">로딩 중...</span>
|
||||
</div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="text-muted-foreground space-y-4 py-8 text-center">
|
||||
<div className="text-sm">{searchText ? "검색 결과가 없습니다." : "등록된 계층 그룹이 없습니다."}</div>
|
||||
<div className="mx-auto max-w-md space-y-3 text-left">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="text-foreground mb-2 text-sm font-medium">예시: 지역 계층</div>
|
||||
<div className="text-muted-foreground text-xs">국가 > 시/도 > 시/군/구 > 읍/면/동</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="text-foreground mb-2 text-sm font-medium">예시: 조직 계층</div>
|
||||
<div className="text-muted-foreground text-xs">본부 > 팀 > 파트 (자기 참조 구조)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredGroups.map((group) => (
|
||||
<div key={group.groupCode} className="rounded-lg border">
|
||||
<div
|
||||
className="hover:bg-muted/50 flex cursor-pointer items-center justify-between p-4"
|
||||
onClick={() => toggleGroupExpand(group.groupCode)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{expandedGroups.has(group.groupCode) ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium">{group.groupName}</div>
|
||||
<div className="text-muted-foreground text-xs">{group.groupCode}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="outline">{getHierarchyTypeLabel(group.hierarchyType)}</Badge>
|
||||
<Badge variant="secondary">{group.levelCount || 0}개 레벨</Badge>
|
||||
<Badge variant={group.isActive === "Y" ? "default" : "secondary"}>
|
||||
{group.isActive === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(group)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(group.groupCode)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 레벨 목록 */}
|
||||
{expandedGroups.has(group.groupCode) && (
|
||||
<div className="bg-muted/20 border-t p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">레벨 목록</span>
|
||||
<Button size="sm" variant="outline" onClick={() => handleOpenCreateLevel(group.groupCode)}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
레벨 추가
|
||||
</Button>
|
||||
</div>
|
||||
{(groupLevels[group.groupCode] || []).length === 0 ? (
|
||||
<div className="text-muted-foreground py-4 text-center text-sm">등록된 레벨이 없습니다.</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">순서</TableHead>
|
||||
<TableHead>레벨명</TableHead>
|
||||
<TableHead>테이블</TableHead>
|
||||
<TableHead>값 컬럼</TableHead>
|
||||
<TableHead>부모 키</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(groupLevels[group.groupCode] || []).map((level) => (
|
||||
<TableRow key={level.levelId}>
|
||||
<TableCell>{level.levelOrder}</TableCell>
|
||||
<TableCell className="font-medium">{level.levelName}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{level.tableName}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{level.valueColumn}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{level.parentKeyColumn || "-"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleOpenEditLevel(level)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteLevel(level.levelId!, group.groupCode)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 그룹 생성/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingGroup ? "계층 그룹 수정" : "계층 그룹 생성"}</DialogTitle>
|
||||
<DialogDescription>다단계 연쇄 드롭다운의 기본 정보를 설정합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>그룹명 *</Label>
|
||||
<Input
|
||||
value={formData.groupName}
|
||||
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
|
||||
placeholder="예: 지역 계층"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>계층 유형 *</Label>
|
||||
<Select
|
||||
value={formData.hierarchyType}
|
||||
onValueChange={(v: any) => setFormData({ ...formData, hierarchyType: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HIERARCHY_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>설명</Label>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="계층 구조에 대한 설명"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>최대 레벨 수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.maxLevels || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, maxLevels: e.target.value ? Number(e.target.value) : undefined })
|
||||
}
|
||||
placeholder="예: 4"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>고정 레벨 여부</Label>
|
||||
<Select
|
||||
value={formData.isFixedLevels}
|
||||
onValueChange={(v) => setFormData({ ...formData, isFixedLevels: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y">고정</SelectItem>
|
||||
<SelectItem value="N">가변</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{editingGroup ? "수정" : "생성"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 레벨 생성/수정 모달 */}
|
||||
<Dialog open={isLevelModalOpen} onOpenChange={setIsLevelModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingLevel ? "레벨 수정" : "레벨 추가"}</DialogTitle>
|
||||
<DialogDescription>계층의 개별 레벨 정보를 설정합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>레벨 순서 *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={levelFormData.levelOrder}
|
||||
onChange={(e) => setLevelFormData({ ...levelFormData, levelOrder: Number(e.target.value) })}
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>레벨명 *</Label>
|
||||
<Input
|
||||
value={levelFormData.levelName}
|
||||
onChange={(e) => setLevelFormData({ ...levelFormData, levelName: e.target.value })}
|
||||
placeholder="예: 시/도"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>테이블 *</Label>
|
||||
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboOpen}
|
||||
className="h-10 w-full justify-between text-sm"
|
||||
>
|
||||
{levelFormData.tableName
|
||||
? tables.find((t) => t.tableName === levelFormData.tableName)?.displayName ||
|
||||
levelFormData.tableName
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
|
||||
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables.map((t) => (
|
||||
<CommandItem
|
||||
key={t.tableName}
|
||||
value={`${t.tableName} ${t.displayName || ""}`}
|
||||
onSelect={async () => {
|
||||
setLevelFormData({
|
||||
...levelFormData,
|
||||
tableName: t.tableName,
|
||||
valueColumn: "",
|
||||
labelColumn: "",
|
||||
parentKeyColumn: "",
|
||||
});
|
||||
await loadLevelColumns(t.tableName);
|
||||
setTableComboOpen(false);
|
||||
}}
|
||||
className="text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
levelFormData.tableName === t.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{t.displayName || t.tableName}</span>
|
||||
{t.displayName && t.displayName !== t.tableName && (
|
||||
<span className="text-muted-foreground text-xs">{t.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>값 컬럼 *</Label>
|
||||
<Select
|
||||
value={levelFormData.valueColumn}
|
||||
onValueChange={(v) => setLevelFormData({ ...levelFormData, valueColumn: v })}
|
||||
disabled={!levelFormData.tableName}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{levelColumns.map((c) => (
|
||||
<SelectItem key={c.columnName} value={c.columnName}>
|
||||
{c.displayName || c.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>라벨 컬럼 *</Label>
|
||||
<Select
|
||||
value={levelFormData.labelColumn}
|
||||
onValueChange={(v) => setLevelFormData({ ...levelFormData, labelColumn: v })}
|
||||
disabled={!levelFormData.tableName}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{levelColumns.map((c) => (
|
||||
<SelectItem key={c.columnName} value={c.columnName}>
|
||||
{c.displayName || c.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>부모 키 컬럼 (레벨 2 이상)</Label>
|
||||
<Select
|
||||
value={levelFormData.parentKeyColumn || "__none__"}
|
||||
onValueChange={(v) =>
|
||||
setLevelFormData({ ...levelFormData, parentKeyColumn: v === "__none__" ? "" : v })
|
||||
}
|
||||
disabled={!levelFormData.tableName}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{levelColumns.map((c) => (
|
||||
<SelectItem key={c.columnName} value={c.columnName}>
|
||||
{c.displayName || c.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">상위 레벨의 선택 값을 참조하는 컬럼입니다.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>플레이스홀더</Label>
|
||||
<Input
|
||||
value={levelFormData.placeholder}
|
||||
onChange={(e) => setLevelFormData({ ...levelFormData, placeholder: e.target.value })}
|
||||
placeholder={`${levelFormData.levelName || "레벨"} 선택`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsLevelModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSaveLevel}>{editingLevel ? "수정" : "추가"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>계층 그룹 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 계층 그룹과 모든 레벨을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,582 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Ban, Check, ChevronsUpDown, Plus, RefreshCw, Search, Pencil, Trash2 } from "lucide-react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { mutualExclusionApi, MutualExclusion, EXCLUSION_TYPES } from "@/lib/api/cascadingMutualExclusion";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
|
||||
export default function MutualExclusionTab() {
|
||||
// 목록 상태
|
||||
const [exclusions, setExclusions] = useState<MutualExclusion[]>([]);
|
||||
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||
const [columns, setColumns] = useState<Array<{ columnName: string; displayName: string }>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
// 테이블 Combobox 상태
|
||||
const [tableComboOpen, setTableComboOpen] = useState(false);
|
||||
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [editingExclusion, setEditingExclusion] = useState<MutualExclusion | null>(null);
|
||||
const [deletingExclusionId, setDeletingExclusionId] = useState<number | null>(null);
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<Omit<MutualExclusion, "exclusionId" | "exclusionCode">>({
|
||||
exclusionName: "",
|
||||
fieldNames: "",
|
||||
sourceTable: "",
|
||||
valueColumn: "",
|
||||
labelColumn: "",
|
||||
exclusionType: "SAME_VALUE",
|
||||
errorMessage: "동일한 값을 선택할 수 없습니다",
|
||||
});
|
||||
|
||||
// 필드 목록 (동적 추가)
|
||||
const [fieldList, setFieldList] = useState<string[]>(["", ""]);
|
||||
|
||||
// 목록 로드
|
||||
const loadExclusions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await mutualExclusionApi.getList();
|
||||
if (response.success && response.data) {
|
||||
setExclusions(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("상호 배제 목록 로드 실패:", error);
|
||||
toast.error("목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 테이블 목록 로드
|
||||
const loadTables = useCallback(async () => {
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setTables(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadExclusions();
|
||||
loadTables();
|
||||
}, [loadExclusions, loadTables]);
|
||||
|
||||
// 테이블 선택 시 컬럼 로드
|
||||
const loadColumns = async (tableName: string) => {
|
||||
if (!tableName) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(tableName);
|
||||
if (response.success && response.data?.columns) {
|
||||
setColumns(response.data.columns);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 필터된 목록
|
||||
const filteredExclusions = exclusions.filter(
|
||||
(e) =>
|
||||
e.exclusionName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
e.exclusionCode?.toLowerCase().includes(searchText.toLowerCase()),
|
||||
);
|
||||
|
||||
// 모달 열기 (생성)
|
||||
const handleOpenCreate = () => {
|
||||
setEditingExclusion(null);
|
||||
setFormData({
|
||||
exclusionName: "",
|
||||
fieldNames: "",
|
||||
sourceTable: "",
|
||||
valueColumn: "",
|
||||
labelColumn: "",
|
||||
exclusionType: "SAME_VALUE",
|
||||
errorMessage: "동일한 값을 선택할 수 없습니다",
|
||||
});
|
||||
setFieldList(["", ""]);
|
||||
setColumns([]);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 모달 열기 (수정)
|
||||
const handleOpenEdit = async (exclusion: MutualExclusion) => {
|
||||
setEditingExclusion(exclusion);
|
||||
setFormData({
|
||||
exclusionCode: exclusion.exclusionCode,
|
||||
exclusionName: exclusion.exclusionName,
|
||||
fieldNames: exclusion.fieldNames,
|
||||
sourceTable: exclusion.sourceTable,
|
||||
valueColumn: exclusion.valueColumn,
|
||||
labelColumn: exclusion.labelColumn || "",
|
||||
exclusionType: exclusion.exclusionType || "SAME_VALUE",
|
||||
errorMessage: exclusion.errorMessage || "동일한 값을 선택할 수 없습니다",
|
||||
});
|
||||
setFieldList(exclusion.fieldNames.split(",").map((f) => f.trim()));
|
||||
await loadColumns(exclusion.sourceTable);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleDeleteConfirm = (exclusionId: number) => {
|
||||
setDeletingExclusionId(exclusionId);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 실행
|
||||
const handleDelete = async () => {
|
||||
if (!deletingExclusionId) return;
|
||||
|
||||
try {
|
||||
const response = await mutualExclusionApi.delete(deletingExclusionId);
|
||||
if (response.success) {
|
||||
toast.success("상호 배제 규칙이 삭제되었습니다.");
|
||||
loadExclusions();
|
||||
} else {
|
||||
toast.error(response.error || "삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("삭제 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeletingExclusionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 추가
|
||||
const addField = () => {
|
||||
setFieldList([...fieldList, ""]);
|
||||
};
|
||||
|
||||
// 필드 제거
|
||||
const removeField = (index: number) => {
|
||||
if (fieldList.length <= 2) {
|
||||
toast.error("최소 2개의 필드가 필요합니다.");
|
||||
return;
|
||||
}
|
||||
setFieldList(fieldList.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 필드 값 변경
|
||||
const updateField = (index: number, value: string) => {
|
||||
const newFields = [...fieldList];
|
||||
newFields[index] = value;
|
||||
setFieldList(newFields);
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
// 필드 목록 합치기
|
||||
const cleanedFields = fieldList.filter((f) => f.trim());
|
||||
if (cleanedFields.length < 2) {
|
||||
toast.error("최소 2개의 필드를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 유효성 검사
|
||||
if (!formData.exclusionName || !formData.sourceTable || !formData.valueColumn) {
|
||||
toast.error("필수 항목을 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const dataToSave = {
|
||||
...formData,
|
||||
fieldNames: cleanedFields.join(","),
|
||||
};
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (editingExclusion) {
|
||||
response = await mutualExclusionApi.update(editingExclusion.exclusionId!, dataToSave);
|
||||
} else {
|
||||
response = await mutualExclusionApi.create(dataToSave);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(editingExclusion ? "수정되었습니다." : "생성되었습니다.");
|
||||
setIsModalOpen(false);
|
||||
loadExclusions();
|
||||
} else {
|
||||
toast.error(response.error || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블 선택 핸들러
|
||||
const handleTableChange = async (tableName: string) => {
|
||||
setFormData({ ...formData, sourceTable: tableName, valueColumn: "", labelColumn: "" });
|
||||
await loadColumns(tableName);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 검색 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="배제 코드, 이름으로 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={loadExclusions}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 목록 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Ban className="h-5 w-5" />
|
||||
상호 배제 규칙
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
두 필드가 같은 값을 선택할 수 없도록 제한합니다. (총 {filteredExclusions.length}개)
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={handleOpenCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 규칙 추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||
<span className="ml-2">로딩 중...</span>
|
||||
</div>
|
||||
) : filteredExclusions.length === 0 ? (
|
||||
<div className="text-muted-foreground space-y-4 py-8 text-center">
|
||||
<div className="text-sm">
|
||||
{searchText ? "검색 결과가 없습니다." : "등록된 상호 배제 규칙이 없습니다."}
|
||||
</div>
|
||||
<div className="mx-auto max-w-md space-y-3 text-left">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="text-foreground mb-2 text-sm font-medium">예시: 창고 이동</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
"출발 창고"와 "도착 창고"가 같은 창고를 선택할 수 없도록 제한
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="text-foreground mb-2 text-sm font-medium">예시: 부서 이동</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
"현재 부서"와 "이동 부서"가 같은 부서를 선택할 수 없도록 제한
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>배제 코드</TableHead>
|
||||
<TableHead>배제명</TableHead>
|
||||
<TableHead>대상 필드</TableHead>
|
||||
<TableHead>소스 테이블</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredExclusions.map((exclusion) => (
|
||||
<TableRow key={exclusion.exclusionId}>
|
||||
<TableCell className="font-mono text-sm">{exclusion.exclusionCode}</TableCell>
|
||||
<TableCell className="font-medium">{exclusion.exclusionName}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{exclusion.fieldNames.split(",").map((field, idx) => (
|
||||
<Badge key={idx} variant="outline" className="text-xs">
|
||||
{field.trim()}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{exclusion.sourceTable}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={exclusion.isActive === "Y" ? "default" : "secondary"}>
|
||||
{exclusion.isActive === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(exclusion)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(exclusion.exclusionId!)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 생성/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingExclusion ? "상호 배제 규칙 수정" : "상호 배제 규칙 생성"}</DialogTitle>
|
||||
<DialogDescription>두 필드가 같은 값을 선택할 수 없도록 제한합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 배제명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>배제명 *</Label>
|
||||
<Input
|
||||
value={formData.exclusionName}
|
||||
onChange={(e) => setFormData({ ...formData, exclusionName: e.target.value })}
|
||||
placeholder="예: 창고 이동 제한"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 대상 필드 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold">대상 필드 (최소 2개)</h4>
|
||||
<Button variant="outline" size="sm" onClick={addField}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{fieldList.map((field, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={field}
|
||||
onChange={(e) => updateField(index, e.target.value)}
|
||||
placeholder={`필드 ${index + 1} (예: source_warehouse)`}
|
||||
className="flex-1"
|
||||
/>
|
||||
{fieldList.length > 2 && (
|
||||
<Button variant="ghost" size="icon" onClick={() => removeField(index)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2 text-xs">이 필드들은 서로 같은 값을 선택할 수 없습니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 소스 테이블 및 컬럼 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>소스 테이블 *</Label>
|
||||
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboOpen}
|
||||
className="h-10 w-full justify-between text-sm"
|
||||
>
|
||||
{formData.sourceTable
|
||||
? tables.find((t) => t.tableName === formData.sourceTable)?.displayName || formData.sourceTable
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
|
||||
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables
|
||||
.filter((t) => t.tableName)
|
||||
.map((t) => (
|
||||
<CommandItem
|
||||
key={t.tableName}
|
||||
value={`${t.tableName} ${t.displayName || ""}`}
|
||||
onSelect={async () => {
|
||||
setFormData({
|
||||
...formData,
|
||||
sourceTable: t.tableName,
|
||||
valueColumn: "",
|
||||
labelColumn: "",
|
||||
});
|
||||
await loadColumns(t.tableName);
|
||||
setTableComboOpen(false);
|
||||
}}
|
||||
className="text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.sourceTable === t.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{t.displayName || t.tableName}</span>
|
||||
{t.displayName && t.displayName !== t.tableName && (
|
||||
<span className="text-muted-foreground text-xs">{t.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>값 컬럼 *</Label>
|
||||
<Select
|
||||
value={formData.valueColumn}
|
||||
onValueChange={(v) => setFormData({ ...formData, valueColumn: v })}
|
||||
disabled={!formData.sourceTable}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="값 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns
|
||||
.filter((c) => c.columnName)
|
||||
.map((c) => (
|
||||
<SelectItem key={c.columnName} value={c.columnName}>
|
||||
{c.displayName || c.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>라벨 컬럼</Label>
|
||||
<Select
|
||||
value={formData.labelColumn}
|
||||
onValueChange={(v) => setFormData({ ...formData, labelColumn: v })}
|
||||
disabled={!formData.sourceTable}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="라벨 컬럼 선택 (선택)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns
|
||||
.filter((c) => c.columnName)
|
||||
.map((c) => (
|
||||
<SelectItem key={c.columnName} value={c.columnName}>
|
||||
{c.displayName || c.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>배제 유형</Label>
|
||||
<Select
|
||||
value={formData.exclusionType}
|
||||
onValueChange={(v) => setFormData({ ...formData, exclusionType: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXCLUSION_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
<div className="space-y-2">
|
||||
<Label>에러 메시지</Label>
|
||||
<Input
|
||||
value={formData.errorMessage}
|
||||
onChange={(e) => setFormData({ ...formData, errorMessage: e.target.value })}
|
||||
placeholder="동일한 값을 선택할 수 없습니다"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{editingExclusion ? "수정" : "생성"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>상호 배제 규칙 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 상호 배제 규칙을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
/**
|
||||
* 기존 연쇄관계 페이지 → 통합 관리 페이지로 리다이렉트
|
||||
*/
|
||||
export default function CascadingRelationsRedirect() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace("/admin/cascading-management");
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground text-sm">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -9,226 +9,167 @@ import { CodeFormModal } from "./CodeFormModal";
|
||||
import { SortableCodeItem } from "./SortableCodeItem";
|
||||
import { AlertModal } from "@/components/common/AlertModal";
|
||||
import { Search, Plus } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDeleteCode, useReorderCodes } from "@/hooks/queries/useCodes";
|
||||
import { useCodesInfinite } from "@/hooks/queries/useCodesInfinite";
|
||||
import type { CodeInfo } from "@/types/commonCode";
|
||||
|
||||
// Drag and Drop
|
||||
import { useDeleteCodeDetail, useUpdateCodeDetail, useCodeDetailTree } from "@/hooks/queries/useCodeDetail";
|
||||
import type { CodeDetail } from "@/types/commonCode";
|
||||
import { DndContext, DragOverlay } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { SortableContext, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
||||
import { useDragAndDrop } from "@/hooks/useDragAndDrop";
|
||||
import { useSearchAndFilter } from "@/hooks/useSearchAndFilter";
|
||||
|
||||
interface CodeDetailPanelProps {
|
||||
categoryCode: string;
|
||||
codeInfo: string;
|
||||
}
|
||||
|
||||
export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||
// 검색 및 필터 상태 (먼저 선언)
|
||||
/**
|
||||
* 디테일 트리 패널.
|
||||
*
|
||||
* BE 가 `/api/common-codes/detail?code_info=X` 로 depth+sort_order 평탄화 리스트를 내려준다.
|
||||
* FE 는 parent_detail_id 로 children 맵을 만들어 indent + collapse 처리.
|
||||
* sort_order 변경은 형제 단위 reorder → 각 행 update PUT 으로 직렬 호출.
|
||||
*/
|
||||
export function CodeDetailPanel({ codeInfo }: CodeDetailPanelProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [showActiveOnly, setShowActiveOnly] = useState(false);
|
||||
|
||||
// React Query로 코드 데이터 관리 (무한 스크롤)
|
||||
const {
|
||||
data: codes = [],
|
||||
isLoading,
|
||||
error,
|
||||
handleScroll,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
} = useCodesInfinite(categoryCode, {
|
||||
const treeQuery = useCodeDetailTree(codeInfo || undefined, {
|
||||
search: searchTerm || undefined,
|
||||
active: showActiveOnly || undefined,
|
||||
});
|
||||
const deleteCodeMutation = useDeleteCode();
|
||||
const reorderCodesMutation = useReorderCodes();
|
||||
const rows: CodeDetail[] = useMemo(() => treeQuery.data || [], [treeQuery.data]);
|
||||
const isLoading = treeQuery.isLoading;
|
||||
const error = treeQuery.error;
|
||||
|
||||
// 드래그앤드롭을 위해 필터링된 코드 목록 사용
|
||||
const { filteredItems: filteredCodesRaw } = useSearchAndFilter(codes, {
|
||||
searchFields: ["code_name", "code_value"],
|
||||
});
|
||||
const deleteMutation = useDeleteCodeDetail();
|
||||
const updateMutation = useUpdateCodeDetail();
|
||||
|
||||
// 계층 구조로 정렬 (부모 → 자식 순서)
|
||||
const filteredCodes = useMemo(() => {
|
||||
if (!filteredCodesRaw || filteredCodesRaw.length === 0) return [];
|
||||
|
||||
// 코드를 계층 순서로 정렬하는 함수
|
||||
const sortHierarchically = (codes: CodeInfo[]): CodeInfo[] => {
|
||||
const result: CodeInfo[] = [];
|
||||
const codeMap = new Map<string, CodeInfo>();
|
||||
const childrenMap = new Map<string, CodeInfo[]>();
|
||||
|
||||
// 코드 맵 생성
|
||||
codes.forEach((code) => {
|
||||
const codeValue = code.code_value || "";
|
||||
const parentValue = code.parent_code_value;
|
||||
codeMap.set(codeValue, code);
|
||||
|
||||
if (parentValue) {
|
||||
if (!childrenMap.has(parentValue)) {
|
||||
childrenMap.set(parentValue, []);
|
||||
}
|
||||
childrenMap.get(parentValue)!.push(code);
|
||||
}
|
||||
});
|
||||
|
||||
// 재귀적으로 트리 구조 순회
|
||||
const traverse = (parentValue: string | null, depth: number) => {
|
||||
const children = parentValue
|
||||
? childrenMap.get(parentValue) || []
|
||||
: codes.filter((c) => !c.parent_code_value);
|
||||
|
||||
// 정렬 순서로 정렬
|
||||
children
|
||||
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0))
|
||||
.forEach((code) => {
|
||||
result.push(code);
|
||||
const codeValue = code.code_value || "";
|
||||
traverse(codeValue, depth + 1);
|
||||
});
|
||||
};
|
||||
|
||||
traverse(null, 1);
|
||||
|
||||
// 트리에 포함되지 않은 코드들도 추가 (orphan 코드)
|
||||
codes.forEach((code) => {
|
||||
if (!result.includes(code)) {
|
||||
result.push(code);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
return sortHierarchically(filteredCodesRaw);
|
||||
}, [filteredCodesRaw]);
|
||||
|
||||
// 모달 상태
|
||||
const [showFormModal, setShowFormModal] = useState(false);
|
||||
const [editingCode, setEditingCode] = useState<CodeInfo | null>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deletingCode, setDeletingCode] = useState<CodeInfo | null>(null);
|
||||
const [defaultParentCode, setDefaultParentCode] = useState<string | undefined>(undefined);
|
||||
|
||||
// 트리 접기/펼치기 상태 (코드값 Set)
|
||||
const [collapsedCodes, setCollapsedCodes] = useState<Set<string>>(new Set());
|
||||
|
||||
// 자식 정보 계산
|
||||
/** parent_detail_id → children */
|
||||
const childrenMap = useMemo(() => {
|
||||
const map = new Map<string, CodeInfo[]>();
|
||||
codes.forEach((code) => {
|
||||
const parentValue = code.parent_code_value;
|
||||
if (parentValue) {
|
||||
if (!map.has(parentValue)) {
|
||||
map.set(parentValue, []);
|
||||
const map = new Map<string, CodeDetail[]>();
|
||||
for (const row of rows) {
|
||||
const key = row.parent_detail_id == null ? "ROOT" : String(row.parent_detail_id);
|
||||
const list = map.get(key) || [];
|
||||
list.push(row);
|
||||
map.set(key, list);
|
||||
}
|
||||
map.get(parentValue)!.push(code);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [codes]);
|
||||
}, [rows]);
|
||||
|
||||
// 접기/펼치기 토글
|
||||
const toggleExpand = (codeValue: string) => {
|
||||
setCollapsedCodes((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(codeValue)) {
|
||||
newSet.delete(codeValue);
|
||||
} else {
|
||||
newSet.add(codeValue);
|
||||
}
|
||||
return newSet;
|
||||
// 접기/펼치기 (code_detail_id Set)
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
const toggleExpand = (id: string) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 특정 코드가 표시되어야 하는지 확인 (부모가 접혀있으면 숨김)
|
||||
const isCodeVisible = (code: CodeInfo): boolean => {
|
||||
const parentValue = code.parent_code_value;
|
||||
if (!parentValue) return true; // 최상위 코드는 항상 표시
|
||||
|
||||
// 부모가 접혀있으면 숨김
|
||||
if (collapsedCodes.has(parentValue)) return false;
|
||||
|
||||
// 부모의 부모도 확인 (재귀적으로)
|
||||
const parentCode = codes.find((c) => c.code_value === parentValue);
|
||||
if (parentCode) {
|
||||
return isCodeVisible(parentCode);
|
||||
// 부모 행 중 하나라도 접혀있으면 자식 숨김
|
||||
const isRowVisible = (row: CodeDetail): boolean => {
|
||||
let parentId = row.parent_detail_id;
|
||||
while (parentId != null) {
|
||||
if (collapsed.has(String(parentId))) return false;
|
||||
const parentRow = rows.find((r) => String(r.code_detail_id) === String(parentId));
|
||||
parentId = parentRow ? parentRow.parent_detail_id : null;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 표시할 코드 목록 (접힌 상태 반영)
|
||||
const visibleCodes = useMemo(() => {
|
||||
return filteredCodes.filter(isCodeVisible);
|
||||
}, [filteredCodes, collapsedCodes, codes]);
|
||||
const visibleRows = useMemo(() => rows.filter(isRowVisible), [rows, collapsed]);
|
||||
|
||||
// 드래그 앤 드롭 훅 사용
|
||||
const dragAndDrop = useDragAndDrop<CodeInfo>({
|
||||
items: filteredCodes,
|
||||
onReorder: async (reorderedItems) => {
|
||||
await reorderCodesMutation.mutateAsync({
|
||||
categoryCode,
|
||||
codes: reorderedItems.map((item) => ({
|
||||
codeValue: item.id,
|
||||
sortOrder: item.sortOrder,
|
||||
})),
|
||||
// 모달 상태
|
||||
const [showFormModal, setShowFormModal] = useState(false);
|
||||
const [editingRow, setEditingRow] = useState<CodeDetail | null>(null);
|
||||
const [defaultParentDetailId, setDefaultParentDetailId] = useState<number | null>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deletingRow, setDeletingRow] = useState<CodeDetail | null>(null);
|
||||
|
||||
// sort_order 재할당 (형제 단위) — 드래그 결과를 PUT 으로 직렬 호출
|
||||
const dragAndDrop = useDragAndDrop<CodeDetail>({
|
||||
items: visibleRows,
|
||||
getItemId: (row) => String(row.code_detail_id),
|
||||
onReorder: async (reordered) => {
|
||||
// visibleRows 기준으로 재정렬. 형제만 reorder 의미가 있으므로
|
||||
// parent_detail_id 가 같은 그룹별로 처리.
|
||||
const idMap = new Map<string, number>();
|
||||
reordered.forEach(({ id, sortOrder }) => idMap.set(id, sortOrder));
|
||||
|
||||
// 같은 부모 안의 형제만 묶어서 정렬
|
||||
const byParent = new Map<string, CodeDetail[]>();
|
||||
for (const row of visibleRows) {
|
||||
const parentKey =
|
||||
row.parent_detail_id == null ? "ROOT" : String(row.parent_detail_id);
|
||||
const list = byParent.get(parentKey) || [];
|
||||
list.push(row);
|
||||
byParent.set(parentKey, list);
|
||||
}
|
||||
|
||||
for (const list of byParent.values()) {
|
||||
const sorted = [...list].sort((a, b) => {
|
||||
const ao = idMap.get(String(a.code_detail_id)) ?? a.sort_order ?? 0;
|
||||
const bo = idMap.get(String(b.code_detail_id)) ?? b.sort_order ?? 0;
|
||||
return ao - bo;
|
||||
});
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const row = sorted[i];
|
||||
const nextOrder = (i + 1) * 10;
|
||||
if ((row.sort_order ?? 0) === nextOrder) continue;
|
||||
await updateMutation.mutateAsync({
|
||||
codeDetailId: row.code_detail_id,
|
||||
data: {
|
||||
code_info: row.code_info,
|
||||
parent_detail_id: row.parent_detail_id ?? null,
|
||||
code_value: row.code_value,
|
||||
code_name: row.code_name,
|
||||
code_name_eng: row.code_name_eng || "",
|
||||
description: row.description || "",
|
||||
sort_order: nextOrder,
|
||||
is_active: row.is_active || "Y",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
getItemId: (code: CodeInfo) => code.code_value || "",
|
||||
});
|
||||
|
||||
// 새 코드 생성
|
||||
const handleNewCode = () => {
|
||||
setEditingCode(null);
|
||||
setDefaultParentCode(undefined);
|
||||
const handleNew = () => {
|
||||
setEditingRow(null);
|
||||
setDefaultParentDetailId(null);
|
||||
setShowFormModal(true);
|
||||
};
|
||||
|
||||
// 코드 수정
|
||||
const handleEditCode = (code: CodeInfo) => {
|
||||
setEditingCode(code);
|
||||
setDefaultParentCode(undefined);
|
||||
const handleEdit = (row: CodeDetail) => {
|
||||
setEditingRow(row);
|
||||
setDefaultParentDetailId(null);
|
||||
setShowFormModal(true);
|
||||
};
|
||||
|
||||
// 하위 코드 추가
|
||||
const handleAddChild = (parentCode: CodeInfo) => {
|
||||
setEditingCode(null);
|
||||
setDefaultParentCode(parentCode.code_value || "");
|
||||
const handleAddChild = (parent: CodeDetail) => {
|
||||
setEditingRow(null);
|
||||
setDefaultParentDetailId(Number(parent.code_detail_id));
|
||||
setShowFormModal(true);
|
||||
};
|
||||
|
||||
// 코드 삭제 확인
|
||||
const handleDeleteCode = (code: CodeInfo) => {
|
||||
setDeletingCode(code);
|
||||
const handleDelete = (row: CodeDetail) => {
|
||||
setDeletingRow(row);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
// 코드 삭제 실행
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deletingCode) return;
|
||||
|
||||
if (!deletingRow) return;
|
||||
try {
|
||||
await deleteCodeMutation.mutateAsync({
|
||||
categoryCode,
|
||||
codeValue: deletingCode.code_value || "",
|
||||
});
|
||||
|
||||
await deleteMutation.mutateAsync(deletingRow.code_detail_id);
|
||||
setShowDeleteModal(false);
|
||||
setDeletingCode(null);
|
||||
setDeletingRow(null);
|
||||
} catch (error) {
|
||||
console.error("코드 삭제 실패:", error);
|
||||
console.error("디테일 삭제 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 드래그 앤 드롭 로직은 useDragAndDrop 훅에서 처리
|
||||
|
||||
if (!categoryCode) {
|
||||
if (!codeInfo) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">카테고리를 선택하세요</p>
|
||||
<p className="text-sm text-muted-foreground">그룹을 선택하세요</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -237,8 +178,14 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-destructive text-sm font-semibold">코드를 불러오는 중 오류가 발생했습니다.</p>
|
||||
<Button variant="outline" onClick={() => window.location.reload()} className="mt-4 h-10 text-sm font-medium">
|
||||
<p className="text-sm font-semibold text-destructive">
|
||||
디테일을 불러오는 중 오류가 발생했습니다.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 h-10 text-sm font-medium"
|
||||
>
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
@@ -248,77 +195,71 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4">
|
||||
{/* 검색 및 액션 */}
|
||||
<div className="space-y-3">
|
||||
{/* 검색 + 버튼 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="코드 검색..."
|
||||
placeholder="디테일 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleNewCode} className="h-10 gap-2 text-sm font-medium">
|
||||
<Button onClick={handleNew} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 활성 필터 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="activeOnlyCodes"
|
||||
id="activeOnlyDetail"
|
||||
checked={showActiveOnly}
|
||||
onChange={(e) => setShowActiveOnly(e.target.checked)}
|
||||
className="border-input h-4 w-4 rounded"
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<label htmlFor="activeOnlyCodes" className="text-muted-foreground text-sm">
|
||||
<label htmlFor="activeOnlyDetail" className="text-sm text-muted-foreground">
|
||||
활성만 표시
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 코드 목록 (무한 스크롤) */}
|
||||
<div className="space-y-3" onScroll={handleScroll}>
|
||||
<div className="space-y-3">
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : visibleCodes.length === 0 ? (
|
||||
) : visibleRows.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{codes.length === 0 ? "코드가 없습니다." : "검색 결과가 없습니다."}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{rows.length === 0 ? "디테일이 없습니다." : "검색 결과가 없습니다."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DndContext {...dragAndDrop.dndContextProps}>
|
||||
<SortableContext
|
||||
items={visibleCodes.map((code) => code.code_value || "")}
|
||||
items={visibleRows.map((r) => String(r.code_detail_id))}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{visibleCodes.map((code, index) => {
|
||||
const codeValue = code.code_value || "";
|
||||
const children = childrenMap.get(codeValue) || [];
|
||||
{visibleRows.map((row) => {
|
||||
const idStr = String(row.code_detail_id);
|
||||
const children = childrenMap.get(idStr) || [];
|
||||
const hasChildren = children.length > 0;
|
||||
const isExpanded = !collapsedCodes.has(codeValue);
|
||||
const isExpanded = !collapsed.has(idStr);
|
||||
|
||||
return (
|
||||
<SortableCodeItem
|
||||
key={`${codeValue}-${index}`}
|
||||
code={code}
|
||||
categoryCode={categoryCode}
|
||||
onEdit={() => handleEditCode(code)}
|
||||
onDelete={() => handleDeleteCode(code)}
|
||||
onAddChild={() => handleAddChild(code)}
|
||||
key={idStr}
|
||||
row={row}
|
||||
onEdit={() => handleEdit(row)}
|
||||
onDelete={() => handleDelete(row)}
|
||||
onAddChild={() => handleAddChild(row)}
|
||||
hasChildren={hasChildren}
|
||||
childCount={children.length}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={() => toggleExpand(codeValue)}
|
||||
onToggleExpand={() => toggleExpand(idStr)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -326,78 +267,53 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{dragAndDrop.activeItem ? (
|
||||
<div className="bg-card cursor-grabbing rounded-lg border p-4 shadow-lg">
|
||||
{(() => {
|
||||
const activeCode = dragAndDrop.activeItem;
|
||||
if (!activeCode) return null;
|
||||
return (
|
||||
<div className="cursor-grabbing rounded-lg border bg-card p-4 shadow-lg">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold">{activeCode.code_name}</h4>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{dragAndDrop.activeItem.code_name}
|
||||
</h4>
|
||||
<Badge
|
||||
variant={
|
||||
activeCode.is_active === "Y" ? "default" : "secondary"
|
||||
}
|
||||
variant={dragAndDrop.activeItem.is_active === "Y" ? "default" : "secondary"}
|
||||
>
|
||||
{activeCode.is_active === "Y" ? "활성" : "비활성"}
|
||||
{dragAndDrop.activeItem.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{activeCode.code_value}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{dragAndDrop.activeItem.code_value}
|
||||
</p>
|
||||
{activeCode.description && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">{activeCode.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
{/* 무한 스크롤 로딩 인디케이터 */}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<LoadingSpinner size="sm" />
|
||||
<span className="text-muted-foreground ml-2 text-sm">코드를 더 불러오는 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모든 코드 로드 완료 메시지 */}
|
||||
{!hasNextPage && codes.length > 0 && (
|
||||
<div className="text-muted-foreground py-4 text-center text-sm">모든 코드를 불러왔습니다.</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 코드 폼 모달 */}
|
||||
{showFormModal && (
|
||||
<CodeFormModal
|
||||
isOpen={showFormModal}
|
||||
onClose={() => {
|
||||
setShowFormModal(false);
|
||||
setEditingCode(null);
|
||||
setDefaultParentCode(undefined);
|
||||
setEditingRow(null);
|
||||
setDefaultParentDetailId(null);
|
||||
}}
|
||||
categoryCode={categoryCode}
|
||||
editingCode={editingCode}
|
||||
codes={codes}
|
||||
defaultParentCode={defaultParentCode}
|
||||
codeInfo={codeInfo}
|
||||
editingRow={editingRow}
|
||||
allRows={rows}
|
||||
defaultParentDetailId={defaultParentDetailId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
{showDeleteModal && (
|
||||
<AlertModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
type="error"
|
||||
title="코드 삭제"
|
||||
message="정말로 이 코드를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
||||
title="디테일 삭제"
|
||||
message="정말로 이 코드를 삭제하시겠습니까? 하위 코드도 모두 함께 삭제됩니다 (CASCADE)."
|
||||
confirmText="삭제"
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
@@ -405,3 +321,6 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// `arrayMove` import 유지 위해 keep no-op (트리 reorder 로직에서 향후 활용 가능)
|
||||
void arrayMove;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
@@ -9,32 +9,31 @@ import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { ValidationMessage } from "@/components/common/ValidationMessage";
|
||||
import { useCreateCode, useUpdateCode } from "@/hooks/queries/useCodes";
|
||||
import { useCheckCodeDuplicate } from "@/hooks/queries/useValidation";
|
||||
import { createCodeSchema, updateCodeSchema, type CreateCodeData, type UpdateCodeData } from "@/lib/schemas/commonCode";
|
||||
import type { CodeInfo } from "@/types/commonCode";
|
||||
import type { FieldError } from "react-hook-form";
|
||||
import { useCreateCodeDetail, useUpdateCodeDetail } from "@/hooks/queries/useCodeDetail";
|
||||
import { useCheckCodeDetailDuplicate } from "@/hooks/queries/useValidation";
|
||||
import {
|
||||
createCodeDetailSchema,
|
||||
updateCodeDetailSchema,
|
||||
type CreateCodeDetailData,
|
||||
type UpdateCodeDetailData,
|
||||
} from "@/lib/schemas/commonCode";
|
||||
import type { CodeDetail } from "@/types/commonCode";
|
||||
|
||||
interface CodeFormModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
categoryCode: string;
|
||||
editingCode?: CodeInfo | null;
|
||||
codes: CodeInfo[];
|
||||
defaultParentCode?: string; // 하위 코드 추가 시 기본 부모 코드
|
||||
codeInfo: string;
|
||||
editingRow?: CodeDetail | null;
|
||||
allRows: CodeDetail[];
|
||||
defaultParentDetailId?: number | null;
|
||||
}
|
||||
|
||||
// 에러 메시지를 안전하게 문자열로 변환하는 헬퍼 함수
|
||||
const getErrorMessage = (error: FieldError | undefined): string => {
|
||||
if (!error) return "";
|
||||
if (typeof error === "string") return error;
|
||||
return error.message || "";
|
||||
};
|
||||
const PARENT_NONE = "__NONE__";
|
||||
|
||||
// 코드값 자동 생성 함수 (UUID 기반 짧은 코드)
|
||||
/** code_value 자동 생성 (timestamp+random) */
|
||||
const generateCodeValue = (): string => {
|
||||
const timestamp = Date.now().toString(36).toUpperCase();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
@@ -44,194 +43,257 @@ const generateCodeValue = (): string => {
|
||||
export function CodeFormModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
categoryCode,
|
||||
editingCode,
|
||||
codes,
|
||||
defaultParentCode,
|
||||
codeInfo,
|
||||
editingRow,
|
||||
allRows,
|
||||
defaultParentDetailId,
|
||||
}: CodeFormModalProps) {
|
||||
const createCodeMutation = useCreateCode();
|
||||
const updateCodeMutation = useUpdateCode();
|
||||
const createMutation = useCreateCodeDetail();
|
||||
const updateMutation = useUpdateCodeDetail();
|
||||
|
||||
const isEditing = !!editingCode;
|
||||
|
||||
// 검증 상태 관리 (코드명만 중복 검사)
|
||||
const [validationStates, setValidationStates] = useState({
|
||||
codeName: { enabled: false, value: "" },
|
||||
});
|
||||
|
||||
// 코드명 중복 검사
|
||||
const codeNameCheck = useCheckCodeDuplicate(
|
||||
categoryCode,
|
||||
"codeName",
|
||||
validationStates.codeName.value,
|
||||
isEditing ? editingCode?.code_value : undefined,
|
||||
validationStates.codeName.enabled,
|
||||
);
|
||||
|
||||
// 중복 검사 결과 확인
|
||||
const hasDuplicateErrors = codeNameCheck.data?.isDuplicate && validationStates.codeName.enabled;
|
||||
|
||||
// 중복 검사 로딩 중인지 확인
|
||||
const isDuplicateChecking = codeNameCheck.isLoading;
|
||||
|
||||
// 폼 스키마 선택 (생성/수정에 따라)
|
||||
const schema = isEditing ? updateCodeSchema : createCodeSchema;
|
||||
const isEditing = !!editingRow;
|
||||
const schema = isEditing ? updateCodeDetailSchema : createCodeDetailSchema;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
mode: "onChange", // 실시간 검증 활성화
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
codeValue: "",
|
||||
codeName: "",
|
||||
codeNameEng: "",
|
||||
code_info: codeInfo,
|
||||
parent_detail_id: null as number | null,
|
||||
code_value: "",
|
||||
code_name: "",
|
||||
code_name_eng: "",
|
||||
description: "",
|
||||
sortOrder: 1,
|
||||
parentCodeValue: "" as string | undefined,
|
||||
...(isEditing && { isActive: "Y" as const }),
|
||||
sort_order: 10,
|
||||
...(isEditing && { is_active: "Y" as const }),
|
||||
},
|
||||
});
|
||||
|
||||
// 편집 모드일 때 기존 데이터 로드
|
||||
// 부모 후보: 자기 자신 + 자기 자손은 제외
|
||||
const parentOptions = useMemo(() => {
|
||||
if (!isEditing) return allRows;
|
||||
if (!editingRow) return allRows;
|
||||
const selfId = String(editingRow.code_detail_id);
|
||||
|
||||
const descendants = new Set<string>([selfId]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const row of allRows) {
|
||||
const parentKey =
|
||||
row.parent_detail_id == null ? "" : String(row.parent_detail_id);
|
||||
if (
|
||||
descendants.has(parentKey) &&
|
||||
!descendants.has(String(row.code_detail_id))
|
||||
) {
|
||||
descendants.add(String(row.code_detail_id));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return allRows.filter((r) => !descendants.has(String(r.code_detail_id)));
|
||||
}, [allRows, editingRow, isEditing]);
|
||||
|
||||
// 중복 검사 (code_name)
|
||||
const [duplicateState, setDuplicateState] = useState<{ enabled: boolean; value: string }>({
|
||||
enabled: false,
|
||||
value: "",
|
||||
});
|
||||
const nameCheck = useCheckCodeDetailDuplicate(
|
||||
codeInfo,
|
||||
"code_name",
|
||||
duplicateState.value,
|
||||
isEditing ? editingRow?.code_detail_id : undefined,
|
||||
duplicateState.enabled,
|
||||
);
|
||||
const hasDuplicateErrors = !!nameCheck.data?.isDuplicate && duplicateState.enabled;
|
||||
const isDuplicateChecking = nameCheck.isLoading;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (isEditing && editingCode) {
|
||||
// 수정 모드: 기존 데이터 로드 (codeValue는 표시용으로만 설정)
|
||||
const parentValue = editingCode.parent_code_value || "";
|
||||
|
||||
if (!isOpen) return;
|
||||
if (isEditing && editingRow) {
|
||||
form.reset({
|
||||
codeName: editingCode.code_name,
|
||||
codeNameEng: editingCode.code_name_eng || "",
|
||||
description: editingCode.description || "",
|
||||
sortOrder: editingCode.sort_order,
|
||||
isActive: editingCode.is_active as "Y" | "N",
|
||||
parentCodeValue: parentValue,
|
||||
});
|
||||
|
||||
// codeValue는 별도로 설정 (표시용)
|
||||
form.setValue("codeValue" as any, editingCode.code_value);
|
||||
code_info: editingRow.code_info || codeInfo,
|
||||
parent_detail_id: editingRow.parent_detail_id ?? null,
|
||||
code_value: editingRow.code_value || "",
|
||||
code_name: editingRow.code_name || "",
|
||||
code_name_eng: editingRow.code_name_eng || "",
|
||||
description: editingRow.description || "",
|
||||
sort_order: editingRow.sort_order ?? 10,
|
||||
is_active: ((editingRow.is_active as "Y" | "N") || "Y") as any,
|
||||
} as any);
|
||||
} else {
|
||||
// 새 코드 모드: 자동 순서 계산
|
||||
const maxSortOrder = codes.length > 0 ? Math.max(...codes.map((c) => c.sort_order || 0)) : 0;
|
||||
|
||||
// 기본 부모 코드가 있으면 설정 (하위 코드 추가 시)
|
||||
const parentValue = defaultParentCode || "";
|
||||
|
||||
// 코드값 자동 생성
|
||||
const autoCodeValue = generateCodeValue();
|
||||
|
||||
// 형제 중 최대 sort_order + 10
|
||||
const siblings = allRows.filter((r) => {
|
||||
const pid = r.parent_detail_id ?? null;
|
||||
return pid === (defaultParentDetailId ?? null);
|
||||
});
|
||||
const maxSort =
|
||||
siblings.length > 0
|
||||
? Math.max(...siblings.map((r) => r.sort_order ?? 0))
|
||||
: 0;
|
||||
form.reset({
|
||||
codeValue: autoCodeValue,
|
||||
codeName: "",
|
||||
codeNameEng: "",
|
||||
code_info: codeInfo,
|
||||
parent_detail_id: defaultParentDetailId ?? null,
|
||||
code_value: generateCodeValue(),
|
||||
code_name: "",
|
||||
code_name_eng: "",
|
||||
description: "",
|
||||
sortOrder: maxSortOrder + 1,
|
||||
parentCodeValue: parentValue,
|
||||
sort_order: maxSort + 10,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen, isEditing, editingCode, codes, defaultParentCode]);
|
||||
setDuplicateState({ enabled: false, value: "" });
|
||||
}, [isOpen, isEditing, editingRow, codeInfo, defaultParentDetailId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
if (isEditing && editingCode) {
|
||||
// 수정
|
||||
await updateCodeMutation.mutateAsync({
|
||||
categoryCode,
|
||||
codeValue: editingCode.code_value || "",
|
||||
data: data as UpdateCodeData,
|
||||
if (isEditing && editingRow) {
|
||||
await updateMutation.mutateAsync({
|
||||
codeDetailId: editingRow.code_detail_id,
|
||||
data: data as UpdateCodeDetailData,
|
||||
});
|
||||
} else {
|
||||
// 생성
|
||||
await createCodeMutation.mutateAsync({
|
||||
categoryCode,
|
||||
data: data as CreateCodeData,
|
||||
});
|
||||
await createMutation.mutateAsync(data as CreateCodeDetailData);
|
||||
}
|
||||
|
||||
onClose();
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
console.error("코드 저장 실패:", error);
|
||||
console.error("디테일 저장 실패:", error);
|
||||
}
|
||||
});
|
||||
|
||||
const isLoading = createCodeMutation.isPending || updateCodeMutation.isPending;
|
||||
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const watchedParent = form.watch("parent_detail_id");
|
||||
const parentSelectValue =
|
||||
watchedParent == null || watchedParent === undefined
|
||||
? PARENT_NONE
|
||||
: String(watchedParent);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{isEditing ? "코드 수정" : defaultParentCode ? "하위 코드 추가" : "새 코드"}
|
||||
{isEditing
|
||||
? "디테일 수정"
|
||||
: defaultParentDetailId != null
|
||||
? "하위 코드 추가"
|
||||
: "새 코드"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||
{/* 코드값 (자동 생성, 수정 시에만 표시) */}
|
||||
{isEditing && (
|
||||
{/* code_value */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">코드값</Label>
|
||||
<div className="bg-muted h-8 rounded-md border px-3 py-1.5 text-xs sm:h-10 sm:py-2 sm:text-sm">
|
||||
{form.watch("codeValue")}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[10px] sm:text-xs">코드값은 변경할 수 없습니다</p>
|
||||
</div>
|
||||
<Label htmlFor="code_value" className="text-xs sm:text-sm">
|
||||
코드값 *
|
||||
</Label>
|
||||
<Input
|
||||
id="code_value"
|
||||
{...form.register("code_value")}
|
||||
disabled={isLoading || isEditing}
|
||||
placeholder="코드값"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
{isEditing && (
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
코드값은 변경할 수 없습니다
|
||||
</p>
|
||||
)}
|
||||
{form.formState.errors.code_value && (
|
||||
<p className="text-[10px] sm:text-xs text-destructive">
|
||||
{form.formState.errors.code_value.message as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 부모 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parent_detail_id" className="text-xs sm:text-sm">
|
||||
상위 코드
|
||||
</Label>
|
||||
<Select
|
||||
value={parentSelectValue}
|
||||
onValueChange={(v) =>
|
||||
form.setValue("parent_detail_id", v === PARENT_NONE ? null : Number(v), {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="상위 코드를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={PARENT_NONE}>그룹 직속 (2 레벨)</SelectItem>
|
||||
{parentOptions.map((opt) => (
|
||||
<SelectItem
|
||||
key={String(opt.code_detail_id)}
|
||||
value={String(opt.code_detail_id)}
|
||||
>
|
||||
{" ".repeat(Math.max(0, (opt.depth ?? 2) - 2))}
|
||||
{opt.code_name || opt.code_value}{" "}
|
||||
<span className="ml-1 text-muted-foreground">({opt.code_value})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
비워두면 그룹 직속(2 레벨)으로 등록됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 코드명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="codeName" className="text-xs sm:text-sm">
|
||||
<Label htmlFor="code_name" className="text-xs sm:text-sm">
|
||||
코드명 *
|
||||
</Label>
|
||||
<Input
|
||||
id="codeName"
|
||||
{...form.register("codeName")}
|
||||
id="code_name"
|
||||
{...form.register("code_name")}
|
||||
disabled={isLoading}
|
||||
placeholder="코드명을 입력하세요"
|
||||
placeholder="코드명"
|
||||
className={
|
||||
form.formState.errors.codeName
|
||||
? "border-destructive h-8 text-xs sm:h-10 sm:text-sm"
|
||||
form.formState.errors.code_name
|
||||
? "h-8 border-destructive text-xs sm:h-10 sm:text-sm"
|
||||
: "h-8 text-xs sm:h-10 sm:text-sm"
|
||||
}
|
||||
onBlur={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
if (value) {
|
||||
setValidationStates((prev) => ({
|
||||
...prev,
|
||||
codeName: { enabled: true, value },
|
||||
}));
|
||||
}
|
||||
if (value) setDuplicateState({ enabled: true, value });
|
||||
}}
|
||||
/>
|
||||
{form.formState.errors.codeName && (
|
||||
<p className="text-destructive text-[10px] sm:text-xs">
|
||||
{getErrorMessage(form.formState.errors.codeName)}
|
||||
{form.formState.errors.code_name && (
|
||||
<p className="text-[10px] sm:text-xs text-destructive">
|
||||
{form.formState.errors.code_name.message as string}
|
||||
</p>
|
||||
)}
|
||||
{!form.formState.errors.codeName && (
|
||||
{!form.formState.errors.code_name && (
|
||||
<ValidationMessage
|
||||
message={codeNameCheck.data?.message}
|
||||
isValid={!codeNameCheck.data?.isDuplicate}
|
||||
isLoading={codeNameCheck.isLoading}
|
||||
message={nameCheck.data?.message}
|
||||
isValid={!nameCheck.data?.isDuplicate}
|
||||
isLoading={nameCheck.isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 영문명 (선택) */}
|
||||
{/* 영문명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="codeNameEng" className="text-xs sm:text-sm">
|
||||
<Label htmlFor="code_name_eng" className="text-xs sm:text-sm">
|
||||
코드 영문명
|
||||
</Label>
|
||||
<Input
|
||||
id="codeNameEng"
|
||||
{...form.register("codeNameEng")}
|
||||
id="code_name_eng"
|
||||
{...form.register("code_name_eng")}
|
||||
disabled={isLoading}
|
||||
placeholder="코드 영문명을 입력하세요 (선택사항)"
|
||||
placeholder="영문명 (선택)"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 설명 (선택) */}
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-xs sm:text-sm">
|
||||
설명
|
||||
@@ -240,64 +302,41 @@ export function CodeFormModal({
|
||||
id="description"
|
||||
{...form.register("description")}
|
||||
disabled={isLoading}
|
||||
placeholder="설명을 입력하세요 (선택사항)"
|
||||
placeholder="설명 (선택)"
|
||||
rows={2}
|
||||
className="text-xs sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 부모 코드 표시 (하위 코드 추가 시에만 표시, 읽기 전용) */}
|
||||
{defaultParentCode && (
|
||||
{/* 정렬 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">상위 코드</Label>
|
||||
<div className="bg-muted h-8 rounded-md border px-3 py-1.5 text-xs sm:h-10 sm:py-2 sm:text-sm">
|
||||
{(() => {
|
||||
const parentCode = codes.find((c) => c.code_value === defaultParentCode);
|
||||
return parentCode
|
||||
? `${parentCode.code_name} (${defaultParentCode})`
|
||||
: defaultParentCode;
|
||||
})()}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[10px] sm:text-xs">이 코드는 위 코드의 하위로 추가됩니다</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 정렬 순서 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sortOrder" className="text-xs sm:text-sm">
|
||||
<Label htmlFor="sort_order" className="text-xs sm:text-sm">
|
||||
정렬 순서
|
||||
</Label>
|
||||
<Input
|
||||
id="sortOrder"
|
||||
id="sort_order"
|
||||
type="number"
|
||||
{...form.register("sortOrder", { valueAsNumber: true })}
|
||||
{...form.register("sort_order", { valueAsNumber: true })}
|
||||
disabled={isLoading}
|
||||
min={1}
|
||||
className={
|
||||
form.formState.errors.sortOrder
|
||||
? "border-destructive h-8 text-xs sm:h-10 sm:text-sm"
|
||||
: "h-8 text-xs sm:h-10 sm:text-sm"
|
||||
}
|
||||
min={0}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
{form.formState.errors.sortOrder && (
|
||||
<p className="text-destructive text-[10px] sm:text-xs">
|
||||
{getErrorMessage(form.formState.errors.sortOrder)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 활성 상태 (수정 시에만) */}
|
||||
{/* 활성 (수정) */}
|
||||
{isEditing && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="isActive"
|
||||
checked={form.watch("isActive") === "Y"}
|
||||
onCheckedChange={(checked) => form.setValue("isActive", checked ? "Y" : "N")}
|
||||
id="is_active"
|
||||
checked={form.watch("is_active" as any) === "Y"}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue("is_active" as any, checked ? "Y" : "N")
|
||||
}
|
||||
disabled={isLoading}
|
||||
aria-label="활성 상태"
|
||||
/>
|
||||
<Label htmlFor="isActive" className="text-xs sm:text-sm">
|
||||
{form.watch("isActive") === "Y" ? "활성" : "비활성"}
|
||||
<Label htmlFor="is_active" className="text-xs sm:text-sm">
|
||||
{form.watch("is_active" as any) === "Y" ? "활성" : "비활성"}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
@@ -315,7 +354,12 @@ export function CodeFormModal({
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !form.formState.isValid || hasDuplicateErrors || isDuplicateChecking}
|
||||
disabled={
|
||||
isLoading ||
|
||||
!form.formState.isValid ||
|
||||
hasDuplicateErrors ||
|
||||
isDuplicateChecking
|
||||
}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isLoading ? (
|
||||
@@ -324,9 +368,9 @@ export function CodeFormModal({
|
||||
{isEditing ? "수정 중..." : "저장 중..."}
|
||||
</>
|
||||
) : isEditing ? (
|
||||
"코드 수정"
|
||||
"디테일 수정"
|
||||
) : (
|
||||
"코드 저장"
|
||||
"디테일 저장"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { ValidationMessage } from "@/components/common/ValidationMessage";
|
||||
import { useCreateCodeInfo, useUpdateCodeInfo } from "@/hooks/queries/useCodeInfo";
|
||||
import { useCheckCodeInfoDuplicate } from "@/hooks/queries/useValidation";
|
||||
import type { CodeInfo } from "@/types/commonCode";
|
||||
import {
|
||||
createCodeInfoSchema,
|
||||
updateCodeInfoSchema,
|
||||
type CreateCodeInfoData,
|
||||
type UpdateCodeInfoData,
|
||||
} from "@/lib/schemas/commonCode";
|
||||
|
||||
interface CodeInfoFormModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
editingCodeInfo?: string;
|
||||
existingRows: CodeInfo[];
|
||||
}
|
||||
|
||||
export function CodeInfoFormModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
editingCodeInfo,
|
||||
existingRows,
|
||||
}: CodeInfoFormModalProps) {
|
||||
const createMutation = useCreateCodeInfo();
|
||||
const updateMutation = useUpdateCodeInfo();
|
||||
|
||||
const isEditing = !!editingCodeInfo;
|
||||
const editingRow = existingRows.find((r) => r.code_info === editingCodeInfo);
|
||||
|
||||
const createForm = useForm<CreateCodeInfoData>({
|
||||
resolver: zodResolver(createCodeInfoSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
code_info: "",
|
||||
code_name: "",
|
||||
code_name_eng: "",
|
||||
description: "",
|
||||
sort_order: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const updateForm = useForm<UpdateCodeInfoData>({
|
||||
resolver: zodResolver(updateCodeInfoSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
code_name: "",
|
||||
code_name_eng: "",
|
||||
description: "",
|
||||
sort_order: 0,
|
||||
is_active: "Y",
|
||||
},
|
||||
});
|
||||
|
||||
const [validatedFields, setValidatedFields] = useState<Set<string>>(new Set());
|
||||
const handleFieldBlur = (name: string) => {
|
||||
setValidatedFields((prev) => new Set(prev).add(name));
|
||||
};
|
||||
|
||||
const codeCheck = useCheckCodeInfoDuplicate(
|
||||
"code_info",
|
||||
isEditing ? "" : createForm.watch("code_info"),
|
||||
isEditing ? editingCodeInfo : undefined,
|
||||
validatedFields.has("code_info"),
|
||||
);
|
||||
const nameCheck = useCheckCodeInfoDuplicate(
|
||||
"code_name",
|
||||
isEditing ? updateForm.watch("code_name") : createForm.watch("code_name"),
|
||||
isEditing ? editingCodeInfo : undefined,
|
||||
validatedFields.has("code_name"),
|
||||
);
|
||||
const nameEngCheck = useCheckCodeInfoDuplicate(
|
||||
"code_name_eng",
|
||||
isEditing
|
||||
? updateForm.watch("code_name_eng") || ""
|
||||
: createForm.watch("code_name_eng") || "",
|
||||
isEditing ? editingCodeInfo : undefined,
|
||||
validatedFields.has("code_name_eng"),
|
||||
);
|
||||
|
||||
const hasDuplicateErrors =
|
||||
(!isEditing && codeCheck.data?.isDuplicate && validatedFields.has("code_info")) ||
|
||||
(nameCheck.data?.isDuplicate && validatedFields.has("code_name")) ||
|
||||
(nameEngCheck.data?.isDuplicate && validatedFields.has("code_name_eng"));
|
||||
|
||||
const isDuplicateChecking =
|
||||
(!isEditing && codeCheck.isLoading) || nameCheck.isLoading || nameEngCheck.isLoading;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setValidatedFields(new Set());
|
||||
|
||||
if (isEditing && editingRow) {
|
||||
updateForm.reset({
|
||||
code_name: editingRow.code_name,
|
||||
code_name_eng: editingRow.code_name_eng || "",
|
||||
description: editingRow.description || "",
|
||||
sort_order: editingRow.sort_order ?? 0,
|
||||
is_active: (editingRow.is_active as "Y" | "N") || "Y",
|
||||
});
|
||||
} else {
|
||||
const maxSort =
|
||||
existingRows.length > 0
|
||||
? Math.max(...existingRows.map((r) => r.sort_order ?? 0))
|
||||
: 0;
|
||||
createForm.reset({
|
||||
code_info: "",
|
||||
code_name: "",
|
||||
code_name_eng: "",
|
||||
description: "",
|
||||
sort_order: maxSort + 1,
|
||||
});
|
||||
}
|
||||
}, [isOpen, isEditing, editingRow, existingRows]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSubmit = isEditing
|
||||
? updateForm.handleSubmit(async (data) => {
|
||||
try {
|
||||
await updateMutation.mutateAsync({ codeInfo: editingCodeInfo!, data });
|
||||
onClose();
|
||||
updateForm.reset();
|
||||
} catch (error) {
|
||||
console.error("그룹 수정 실패:", error);
|
||||
}
|
||||
})
|
||||
: createForm.handleSubmit(async (data) => {
|
||||
try {
|
||||
await createMutation.mutateAsync(data);
|
||||
onClose();
|
||||
createForm.reset();
|
||||
} catch (error) {
|
||||
console.error("그룹 생성 실패:", error);
|
||||
}
|
||||
});
|
||||
|
||||
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{isEditing ? "그룹 수정" : "새 그룹"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||
{/* 그룹 코드 (code_info) */}
|
||||
{!isEditing && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code_info" className="text-xs sm:text-sm">
|
||||
그룹 코드 *
|
||||
</Label>
|
||||
<Input
|
||||
id="code_info"
|
||||
{...createForm.register("code_info")}
|
||||
disabled={isLoading}
|
||||
placeholder="예: STATUS, USER_ROLE"
|
||||
className={
|
||||
createForm.formState.errors.code_info
|
||||
? "h-8 border-destructive text-xs sm:h-10 sm:text-sm"
|
||||
: "h-8 text-xs sm:h-10 sm:text-sm"
|
||||
}
|
||||
onBlur={() => handleFieldBlur("code_info")}
|
||||
/>
|
||||
{createForm.formState.errors.code_info && (
|
||||
<p className="text-[10px] sm:text-xs text-destructive">
|
||||
{createForm.formState.errors.code_info.message}
|
||||
</p>
|
||||
)}
|
||||
{!createForm.formState.errors.code_info && (
|
||||
<ValidationMessage
|
||||
message={codeCheck.data?.message}
|
||||
isValid={!codeCheck.data?.isDuplicate}
|
||||
isLoading={codeCheck.isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing && editingRow && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code_info_display" className="text-xs sm:text-sm">
|
||||
그룹 코드
|
||||
</Label>
|
||||
<Input
|
||||
id="code_info_display"
|
||||
value={editingRow.code_info}
|
||||
disabled
|
||||
className="h-8 cursor-not-allowed bg-muted text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
그룹 코드는 수정할 수 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 그룹명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code_name">그룹명 *</Label>
|
||||
<Input
|
||||
id="code_name"
|
||||
{...(isEditing
|
||||
? updateForm.register("code_name")
|
||||
: createForm.register("code_name"))}
|
||||
disabled={isLoading}
|
||||
placeholder="그룹명을 입력하세요"
|
||||
className={
|
||||
(isEditing
|
||||
? updateForm.formState.errors.code_name
|
||||
: createForm.formState.errors.code_name)
|
||||
? "border-destructive"
|
||||
: ""
|
||||
}
|
||||
onBlur={() => handleFieldBlur("code_name")}
|
||||
/>
|
||||
{(isEditing
|
||||
? updateForm.formState.errors.code_name
|
||||
: createForm.formState.errors.code_name) && (
|
||||
<p className="text-sm text-destructive">
|
||||
{
|
||||
(isEditing
|
||||
? updateForm.formState.errors.code_name
|
||||
: createForm.formState.errors.code_name
|
||||
)?.message
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
{!(isEditing
|
||||
? updateForm.formState.errors.code_name
|
||||
: createForm.formState.errors.code_name) && (
|
||||
<ValidationMessage
|
||||
message={nameCheck.data?.message}
|
||||
isValid={!nameCheck.data?.isDuplicate}
|
||||
isLoading={nameCheck.isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 영문명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code_name_eng">그룹 영문명</Label>
|
||||
<Input
|
||||
id="code_name_eng"
|
||||
{...(isEditing
|
||||
? updateForm.register("code_name_eng")
|
||||
: createForm.register("code_name_eng"))}
|
||||
disabled={isLoading}
|
||||
placeholder="영문명을 입력하세요 (선택)"
|
||||
onBlur={() => handleFieldBlur("code_name_eng")}
|
||||
/>
|
||||
{!(isEditing
|
||||
? updateForm.formState.errors.code_name_eng
|
||||
: createForm.formState.errors.code_name_eng) && (
|
||||
<ValidationMessage
|
||||
message={nameEngCheck.data?.message}
|
||||
isValid={!nameEngCheck.data?.isDuplicate}
|
||||
isLoading={nameEngCheck.isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
{...(isEditing
|
||||
? updateForm.register("description")
|
||||
: createForm.register("description"))}
|
||||
disabled={isLoading}
|
||||
placeholder="설명을 입력하세요 (선택)"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 순서 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sort_order">정렬 순서</Label>
|
||||
<Input
|
||||
id="sort_order"
|
||||
type="number"
|
||||
{...(isEditing
|
||||
? updateForm.register("sort_order", { valueAsNumber: true })
|
||||
: createForm.register("sort_order", { valueAsNumber: true }))}
|
||||
disabled={isLoading}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 활성 상태 (수정 시) */}
|
||||
{isEditing && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={updateForm.watch("is_active") === "Y"}
|
||||
onCheckedChange={(checked) =>
|
||||
updateForm.setValue("is_active", checked ? "Y" : "N")
|
||||
}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Label htmlFor="is_active">
|
||||
{updateForm.watch("is_active") === "Y" ? "활성" : "비활성"}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex gap-2 pt-4 sm:justify-end sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isLoading ||
|
||||
!(isEditing ? updateForm.formState.isValid : createForm.formState.isValid) ||
|
||||
hasDuplicateErrors ||
|
||||
isDuplicateChecking
|
||||
}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<LoadingSpinner size="sm" className="mr-2" />
|
||||
{isEditing ? "수정 중..." : "저장 중..."}
|
||||
</>
|
||||
) : isEditing ? (
|
||||
"그룹 수정"
|
||||
) : (
|
||||
"그룹 저장"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+26
-28
@@ -5,35 +5,34 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Edit, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useUpdateCategory } from "@/hooks/queries/useCategories";
|
||||
import type { CategoryInfo } from "@/types/commonCode";
|
||||
import { useUpdateCodeInfo } from "@/hooks/queries/useCodeInfo";
|
||||
import type { CodeInfo } from "@/types/commonCode";
|
||||
|
||||
interface CategoryItemProps {
|
||||
category: CategoryInfo;
|
||||
interface CodeInfoItemProps {
|
||||
row: CodeInfo;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete }: CategoryItemProps) {
|
||||
const updateCategoryMutation = useUpdateCategory();
|
||||
export function CodeInfoItem({ row, isSelected, onSelect, onEdit, onDelete }: CodeInfoItemProps) {
|
||||
const updateMutation = useUpdateCodeInfo();
|
||||
|
||||
// 활성/비활성 토글 핸들러
|
||||
const handleToggleActive = async (checked: boolean) => {
|
||||
const handleToggleActive = async (next: "Y" | "N") => {
|
||||
try {
|
||||
await updateCategoryMutation.mutateAsync({
|
||||
categoryCode: category.category_code,
|
||||
await updateMutation.mutateAsync({
|
||||
codeInfo: row.code_info,
|
||||
data: {
|
||||
categoryName: category.category_name,
|
||||
categoryNameEng: category.category_name_eng || "",
|
||||
description: category.description || "",
|
||||
sortOrder: category.sort_order,
|
||||
isActive: checked ? "Y" : "N",
|
||||
code_name: row.code_name,
|
||||
code_name_eng: row.code_name_eng || "",
|
||||
description: row.description || "",
|
||||
sort_order: row.sort_order ?? 0,
|
||||
is_active: next,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("카테고리 활성 상태 변경 실패:", error);
|
||||
console.error("그룹 활성 상태 변경 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -41,40 +40,39 @@ export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete
|
||||
<div
|
||||
className={cn(
|
||||
"cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all",
|
||||
isSelected
|
||||
? "shadow-md"
|
||||
: "hover:shadow-md",
|
||||
isSelected ? "border-primary shadow-md" : "hover:shadow-md",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold">{category.category_name}</h4>
|
||||
<h4 className="text-sm font-semibold">{row.code_name}</h4>
|
||||
<Badge
|
||||
variant={category.is_active === "Y" ? "default" : "secondary"}
|
||||
variant={row.is_active === "Y" ? "default" : "secondary"}
|
||||
className={cn(
|
||||
"cursor-pointer text-xs transition-colors",
|
||||
updateCategoryMutation.isPending && "cursor-not-allowed opacity-50",
|
||||
updateMutation.isPending && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!updateCategoryMutation.isPending) {
|
||||
handleToggleActive(category.is_active !== "Y");
|
||||
if (!updateMutation.isPending) {
|
||||
handleToggleActive(row.is_active === "Y" ? "N" : "Y");
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{category.is_active === "Y" ? "활성" : "비활성"}
|
||||
{row.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{category.category_code}</p>
|
||||
{category.description && <p className="mt-1 text-xs text-muted-foreground">{category.description}</p>}
|
||||
<p className="mt-1 text-xs text-muted-foreground">{row.code_info}</p>
|
||||
{row.description && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{row.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
{isSelected && (
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="sm" onClick={onEdit}>
|
||||
+52
-67
@@ -4,77 +4,66 @@ import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { CodeCategoryFormModal } from "./CodeCategoryFormModal";
|
||||
import { CategoryItem } from "./CategoryItem";
|
||||
import { CodeInfoFormModal } from "./CodeInfoFormModal";
|
||||
import { CodeInfoItem } from "./CodeInfoItem";
|
||||
import { AlertModal } from "@/components/common/AlertModal";
|
||||
import { Search, Plus } from "lucide-react";
|
||||
import { useDeleteCategory } from "@/hooks/queries/useCategories";
|
||||
import { useCategoriesInfinite } from "@/hooks/queries/useCategoriesInfinite";
|
||||
import { useDeleteCodeInfo } from "@/hooks/queries/useCodeInfo";
|
||||
import { useCodeInfoInfinite } from "@/hooks/queries/useCodeInfoInfinite";
|
||||
|
||||
interface CodeCategoryPanelProps {
|
||||
selectedCategoryCode: string;
|
||||
onSelectCategory: (categoryCode: string) => void;
|
||||
interface CodeInfoPanelProps {
|
||||
selectedCodeInfo: string;
|
||||
onSelectCodeInfo: (codeInfo: string) => void;
|
||||
}
|
||||
|
||||
export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: CodeCategoryPanelProps) {
|
||||
// 검색 및 필터 상태 (먼저 선언)
|
||||
export function CodeInfoPanel({ selectedCodeInfo, onSelectCodeInfo }: CodeInfoPanelProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [showActiveOnly, setShowActiveOnly] = useState(false);
|
||||
|
||||
// React Query로 카테고리 데이터 관리 (무한 스크롤)
|
||||
const {
|
||||
data: categories = [],
|
||||
data: rows = [],
|
||||
isLoading,
|
||||
error,
|
||||
handleScroll,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
} = useCategoriesInfinite({
|
||||
} = useCodeInfoInfinite({
|
||||
search: searchTerm || undefined,
|
||||
active: showActiveOnly || undefined, // isActive -> active로 수정
|
||||
active: showActiveOnly || undefined,
|
||||
});
|
||||
const deleteCategoryMutation = useDeleteCategory();
|
||||
const deleteMutation = useDeleteCodeInfo();
|
||||
|
||||
// 모달 상태
|
||||
const [showFormModal, setShowFormModal] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<string>("");
|
||||
const [editingCode, setEditingCode] = useState<string>("");
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deletingCategory, setDeletingCategory] = useState<string>("");
|
||||
const [deletingCode, setDeletingCode] = useState<string>("");
|
||||
|
||||
// 새 카테고리 생성
|
||||
const handleNewCategory = () => {
|
||||
setEditingCategory("");
|
||||
const handleNew = () => {
|
||||
setEditingCode("");
|
||||
setShowFormModal(true);
|
||||
};
|
||||
|
||||
// 카테고리 수정
|
||||
const handleEditCategory = (categoryCode: string) => {
|
||||
setEditingCategory(categoryCode);
|
||||
const handleEdit = (code: string) => {
|
||||
setEditingCode(code);
|
||||
setShowFormModal(true);
|
||||
};
|
||||
|
||||
// 카테고리 삭제 확인
|
||||
const handleDeleteCategory = (categoryCode: string) => {
|
||||
setDeletingCategory(categoryCode);
|
||||
const handleDelete = (code: string) => {
|
||||
setDeletingCode(code);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
// 카테고리 삭제 실행
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deletingCategory) return;
|
||||
|
||||
if (!deletingCode) return;
|
||||
try {
|
||||
await deleteCategoryMutation.mutateAsync(deletingCategory);
|
||||
|
||||
// 삭제된 카테고리가 선택된 상태라면 선택 해제
|
||||
if (selectedCategoryCode === deletingCategory) {
|
||||
onSelectCategory("");
|
||||
await deleteMutation.mutateAsync(deletingCode);
|
||||
if (selectedCodeInfo === deletingCode) {
|
||||
onSelectCodeInfo("");
|
||||
}
|
||||
|
||||
setShowDeleteModal(false);
|
||||
setDeletingCategory("");
|
||||
setDeletingCode("");
|
||||
} catch (error) {
|
||||
console.error("카테고리 삭제 실패:", error);
|
||||
console.error("그룹 삭제 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,7 +71,7 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-destructive">카테고리를 불러오는 중 오류가 발생했습니다.</p>
|
||||
<p className="text-destructive">그룹을 불러오는 중 오류가 발생했습니다.</p>
|
||||
<Button variant="outline" onClick={() => window.location.reload()} className="mt-2">
|
||||
다시 시도
|
||||
</Button>
|
||||
@@ -93,66 +82,63 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4">
|
||||
{/* 검색 및 액션 */}
|
||||
{/* 검색 + 등록 */}
|
||||
<div className="space-y-3">
|
||||
{/* 검색 + 버튼 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="카테고리 검색..."
|
||||
placeholder="그룹 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleNewCategory} className="h-10 gap-2 text-sm font-medium">
|
||||
<Button onClick={handleNew} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 활성 필터 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="activeOnly"
|
||||
id="activeOnlyInfo"
|
||||
checked={showActiveOnly}
|
||||
onChange={(e) => setShowActiveOnly(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<label htmlFor="activeOnly" className="text-sm text-muted-foreground">
|
||||
<label htmlFor="activeOnlyInfo" className="text-sm text-muted-foreground">
|
||||
활성만 표시
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 목록 (무한 스크롤) */}
|
||||
{/* 목록 */}
|
||||
<div className="space-y-3" onScroll={handleScroll}>
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
) : rows.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{searchTerm ? "검색 결과가 없습니다." : "카테고리가 없습니다."}
|
||||
{searchTerm ? "검색 결과가 없습니다." : "그룹이 없습니다."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{categories.map((category, index) => (
|
||||
<CategoryItem
|
||||
key={`${category.category_code}-${index}`}
|
||||
category={category}
|
||||
isSelected={selectedCategoryCode === category.category_code}
|
||||
onSelect={() => onSelectCategory(category.category_code)}
|
||||
onEdit={() => handleEditCategory(category.category_code)}
|
||||
onDelete={() => handleDeleteCategory(category.category_code)}
|
||||
{rows.map((row, index) => (
|
||||
<CodeInfoItem
|
||||
key={`${row.code_info}-${index}`}
|
||||
row={row}
|
||||
isSelected={selectedCodeInfo === row.code_info}
|
||||
onSelect={() => onSelectCodeInfo(row.code_info)}
|
||||
onEdit={() => handleEdit(row.code_info)}
|
||||
onDelete={() => handleDelete(row.code_info)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 추가 로딩 표시 */}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<LoadingSpinner size="sm" />
|
||||
@@ -160,32 +146,31 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 더 이상 데이터가 없을 때 */}
|
||||
{!hasNextPage && categories.length > 0 && (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">모든 카테고리를 불러왔습니다.</div>
|
||||
{!hasNextPage && rows.length > 0 && (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
모든 그룹을 불러왔습니다.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 카테고리 폼 모달 */}
|
||||
{showFormModal && (
|
||||
<CodeCategoryFormModal
|
||||
<CodeInfoFormModal
|
||||
isOpen={showFormModal}
|
||||
onClose={() => setShowFormModal(false)}
|
||||
editingCategoryCode={editingCategory}
|
||||
categories={categories}
|
||||
editingCodeInfo={editingCode}
|
||||
existingRows={rows}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
{showDeleteModal && (
|
||||
<AlertModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
type="error"
|
||||
title="카테고리 삭제"
|
||||
message="정말로 이 카테고리를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
||||
title="그룹 삭제"
|
||||
message="정말로 이 그룹을 삭제하시겠습니까? 그룹의 모든 디테일 코드도 함께 삭제됩니다 (CASCADE)."
|
||||
confirmText="삭제"
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
@@ -58,7 +58,7 @@ export function MenuCopyDialog({
|
||||
const [addPrefix, setAddPrefix] = useState("");
|
||||
|
||||
// 카테고리/코드 복사 옵션
|
||||
const [copyCodeCategory, setCopyCodeCategory] = useState(false);
|
||||
const [copyCodeInfo, setCopyCodeInfo] = useState(false);
|
||||
const [copyNumberingRules, setCopyNumberingRules] = useState(false);
|
||||
const [copyCategoryMapping, setCopyCategoryMapping] = useState(false);
|
||||
const [copyTableTypeColumns, setCopyTableTypeColumns] = useState(false);
|
||||
@@ -74,7 +74,7 @@ export function MenuCopyDialog({
|
||||
setUseBulkRename(false);
|
||||
setRemoveText("");
|
||||
setAddPrefix("");
|
||||
setCopyCodeCategory(false);
|
||||
setCopyCodeInfo(false);
|
||||
setCopyNumberingRules(false);
|
||||
setCopyCategoryMapping(false);
|
||||
setCopyTableTypeColumns(false);
|
||||
@@ -127,7 +127,7 @@ export function MenuCopyDialog({
|
||||
|
||||
// 추가 복사 옵션
|
||||
const additionalCopyOptions = {
|
||||
copyCodeCategory,
|
||||
copyCodeInfo,
|
||||
copyNumberingRules,
|
||||
copyCategoryMapping,
|
||||
copyTableTypeColumns,
|
||||
@@ -294,13 +294,13 @@ export function MenuCopyDialog({
|
||||
<div className="space-y-2 pl-2 border-l-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="copyCodeCategory"
|
||||
checked={copyCodeCategory}
|
||||
onCheckedChange={(checked) => setCopyCodeCategory(checked as boolean)}
|
||||
id="copyCodeInfo"
|
||||
checked={copyCodeInfo}
|
||||
onCheckedChange={(checked) => setCopyCodeInfo(checked as boolean)}
|
||||
disabled={copying}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="copyCodeCategory"
|
||||
htmlFor="copyCodeInfo"
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
코드 카테고리 + 코드 복사
|
||||
|
||||
@@ -7,85 +7,76 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Edit, Trash2, CornerDownRight, Plus, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useUpdateCode } from "@/hooks/queries/useCodes";
|
||||
import type { CodeInfo } from "@/types/commonCode";
|
||||
import { useUpdateCodeDetail } from "@/hooks/queries/useCodeDetail";
|
||||
import type { CodeDetail } from "@/types/commonCode";
|
||||
|
||||
interface SortableCodeItemProps {
|
||||
code: CodeInfo;
|
||||
categoryCode: string;
|
||||
row: CodeDetail;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onAddChild: () => void; // 하위 코드 추가
|
||||
onAddChild: () => void;
|
||||
isDragOverlay?: boolean;
|
||||
maxDepth?: number; // 최대 깊이 (기본값 3)
|
||||
hasChildren?: boolean; // 자식이 있는지 여부
|
||||
childCount?: number; // 자식 개수
|
||||
isExpanded?: boolean; // 펼쳐진 상태
|
||||
onToggleExpand?: () => void; // 접기/펼치기 토글
|
||||
hasChildren?: boolean;
|
||||
childCount?: number;
|
||||
isExpanded?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
}
|
||||
|
||||
export function SortableCodeItem({
|
||||
code,
|
||||
categoryCode,
|
||||
row,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddChild,
|
||||
isDragOverlay = false,
|
||||
maxDepth = 3,
|
||||
hasChildren = false,
|
||||
childCount = 0,
|
||||
isExpanded = true,
|
||||
onToggleExpand,
|
||||
}: SortableCodeItemProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: code.code_value || "",
|
||||
id: String(row.code_detail_id),
|
||||
disabled: isDragOverlay,
|
||||
});
|
||||
const updateCodeMutation = useUpdateCode();
|
||||
const updateMutation = useUpdateCodeDetail();
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
// 활성/비활성 토글 핸들러
|
||||
const handleToggleActive = async (checked: boolean) => {
|
||||
const handleToggleActive = async (next: "Y" | "N") => {
|
||||
try {
|
||||
const codeValue = code.code_value;
|
||||
if (!codeValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateCodeMutation.mutateAsync({
|
||||
categoryCode,
|
||||
codeValue: codeValue,
|
||||
await updateMutation.mutateAsync({
|
||||
codeDetailId: row.code_detail_id,
|
||||
data: {
|
||||
codeName: code.code_name,
|
||||
codeNameEng: code.code_name_eng || "",
|
||||
description: code.description || "",
|
||||
sortOrder: code.sort_order,
|
||||
isActive: checked ? "Y" : "N",
|
||||
code_info: row.code_info,
|
||||
parent_detail_id: row.parent_detail_id ?? null,
|
||||
code_value: row.code_value,
|
||||
code_name: row.code_name,
|
||||
code_name_eng: row.code_name_eng || "",
|
||||
description: row.description || "",
|
||||
sort_order: row.sort_order ?? 10,
|
||||
is_active: next,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("코드 활성 상태 변경 실패:", error);
|
||||
console.error("디테일 활성 상태 변경 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 계층구조 깊이에 따른 들여쓰기
|
||||
const depth = code.depth || 1;
|
||||
const indentLevel = (depth - 1) * 28; // 28px per level
|
||||
const hasParent = !!code.parent_code_value;
|
||||
// depth 는 2 부터. (그룹 직속 = 2)
|
||||
const depth: number = row.depth ?? 2;
|
||||
const indentLevel = Math.max(0, depth - 2) * 28;
|
||||
const hasParent = row.parent_detail_id != null;
|
||||
|
||||
return (
|
||||
<div className="flex items-stretch">
|
||||
{/* 계층구조 들여쓰기 영역 */}
|
||||
{depth > 1 && (
|
||||
{indentLevel > 0 && (
|
||||
<div
|
||||
className="flex items-center justify-end pr-2"
|
||||
style={{ width: `${indentLevel}px`, minWidth: `${indentLevel}px` }}
|
||||
>
|
||||
<CornerDownRight className="text-muted-foreground/50 h-4 w-4" />
|
||||
<CornerDownRight className="h-4 w-4 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -95,17 +86,17 @@ export function SortableCodeItem({
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={cn(
|
||||
"group bg-card flex-1 cursor-grab rounded-lg border p-4 shadow-sm transition-all hover:shadow-md",
|
||||
"group flex-1 cursor-grab rounded-lg border bg-card p-4 shadow-sm transition-all hover:shadow-md",
|
||||
isDragging && "cursor-grabbing opacity-50",
|
||||
depth === 1 && "border-l-primary border-l-4",
|
||||
depth === 2 && "border-l-4 border-l-blue-400",
|
||||
depth === 3 && "border-l-4 border-l-green-400",
|
||||
depth === 2 && "border-l-4 border-l-primary",
|
||||
depth === 3 && "border-l-4 border-l-blue-400",
|
||||
depth === 4 && "border-l-4 border-l-green-400",
|
||||
depth >= 5 && "border-l-4 border-l-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* 접기/펼치기 버튼 (자식이 있을 때만 표시) */}
|
||||
{hasChildren && onToggleExpand && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -116,77 +107,54 @@ export function SortableCodeItem({
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="text-muted-foreground hover:text-foreground -ml-1 flex h-5 w-5 items-center justify-center rounded transition-colors hover:bg-muted"
|
||||
className="-ml-1 flex h-5 w-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title={isExpanded ? "접기" : "펼치기"}
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
)}
|
||||
<h4 className="text-sm font-semibold">{code.code_name}</h4>
|
||||
{/* 접힌 상태에서 자식 개수 표시 */}
|
||||
{hasChildren && !isExpanded && <span className="text-muted-foreground text-[10px]">({childCount})</span>}
|
||||
{/* 깊이 표시 배지 */}
|
||||
{depth === 1 && (
|
||||
<h4 className="text-sm font-semibold">{row.code_name}</h4>
|
||||
{hasChildren && !isExpanded && (
|
||||
<span className="text-[10px] text-muted-foreground">({childCount})</span>
|
||||
)}
|
||||
<Badge variant="outline" className="bg-muted/40 px-1.5 py-0 text-[10px]">
|
||||
depth {depth}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-primary/30 bg-primary/10 text-primary px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
대분류
|
||||
</Badge>
|
||||
)}
|
||||
{depth === 2 && (
|
||||
<Badge variant="outline" className="bg-primary/10 px-1.5 py-0 text-[10px] text-primary">
|
||||
중분류
|
||||
</Badge>
|
||||
)}
|
||||
{depth === 3 && (
|
||||
<Badge variant="outline" className="bg-emerald-50 px-1.5 py-0 text-[10px] text-emerald-600">
|
||||
소분류
|
||||
</Badge>
|
||||
)}
|
||||
{depth > 3 && (
|
||||
<Badge variant="outline" className="bg-muted px-1.5 py-0 text-[10px]">
|
||||
{depth}단계
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant={code.is_active === "Y" ? "default" : "secondary"}
|
||||
variant={row.is_active === "Y" ? "default" : "secondary"}
|
||||
className={cn(
|
||||
"cursor-pointer text-xs transition-colors",
|
||||
updateCodeMutation.isPending && "cursor-not-allowed opacity-50",
|
||||
updateMutation.isPending && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!updateCodeMutation.isPending) {
|
||||
const isActive = code.is_active === "Y";
|
||||
handleToggleActive(!isActive);
|
||||
if (!updateMutation.isPending) {
|
||||
handleToggleActive(row.is_active === "Y" ? "N" : "Y");
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{code.is_active === "Y" ? "활성" : "비활성"}
|
||||
{row.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{code.code_value}</p>
|
||||
{/* 부모 코드 표시 */}
|
||||
<p className="mt-1 text-xs text-muted-foreground">{row.code_value}</p>
|
||||
{hasParent && (
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||
상위: {code.parent_code_value}
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
상위: detail#{row.parent_detail_id}
|
||||
</p>
|
||||
)}
|
||||
{code.description && <p className="text-muted-foreground mt-1 text-xs">{code.description}</p>}
|
||||
{row.description && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{row.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 하위 코드 추가 버튼 (최대 깊이 미만일 때만 표시) */}
|
||||
{depth < maxDepth && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -200,7 +168,6 @@ export function SortableCodeItem({
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface ColumnDetailPanelProps {
|
||||
onClose: () => void;
|
||||
onLoadReferenceColumns?: (tableName: string) => void;
|
||||
/** 코드 카테고리 옵션 (value, label) */
|
||||
codeCategoryOptions?: Array<{ value: string; label: string }>;
|
||||
codeInfoOptions?: Array<{ value: string; label: string }>;
|
||||
/** 참조 테이블 옵션 (value, label) */
|
||||
referenceTableOptions?: Array<{ value: string; label: string }>;
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export function ColumnDetailPanel({
|
||||
onColumnChange,
|
||||
onClose,
|
||||
onLoadReferenceColumns,
|
||||
codeCategoryOptions = [],
|
||||
codeInfoOptions = [],
|
||||
referenceTableOptions = [],
|
||||
}: ColumnDetailPanelProps) {
|
||||
const [advancedOpen, setAdvancedOpen] = React.useState(false);
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,73 +3,51 @@
|
||||
/**
|
||||
* 계층구조 코드 선택 컴포넌트 (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 [allRows, setAllRows] = useState<CodeDetail[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 각 단계별 선택값
|
||||
const [selectedLevel1, setSelectedLevel1] = useState<string>("");
|
||||
const [selectedLevel2, setSelectedLevel2] = useState<string>("");
|
||||
const [selectedLevel3, setSelectedLevel3] = useState<string>("");
|
||||
const [selected1, setSelected1] = useState<string>("");
|
||||
const [selected2, setSelected2] = useState<string>("");
|
||||
const [selected3, setSelected3] = useState<string>("");
|
||||
|
||||
// 로딩 상태
|
||||
const [loading, setLoading] = useState<LoadingState>({
|
||||
level1: false,
|
||||
level2: false,
|
||||
level3: false,
|
||||
});
|
||||
|
||||
// 모든 코드 데이터 (경로 추적용)
|
||||
const [allCodes, setAllCodes] = useState<CodeInfo[]>([]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// 2단계 코드 로드 (1단계 선택값 기준)
|
||||
const loadLevel2Codes = useCallback(async (parentCodeValue: string) => {
|
||||
if (!categoryCode || !parentCodeValue) {
|
||||
setLevel2Options([]);
|
||||
// 트리 전체 로드
|
||||
useEffect(() => {
|
||||
if (!categoryCode) {
|
||||
setAllRows([]);
|
||||
return;
|
||||
}
|
||||
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]);
|
||||
|
||||
setLoading(prev => ({ ...prev, level2: true }));
|
||||
try {
|
||||
const response = await commonCodeApi.hierarchy.getHierarchicalCodes(
|
||||
categoryCode,
|
||||
parentCodeValue,
|
||||
undefined,
|
||||
menuObjid
|
||||
// depth=2 = 1단계, depth=3 = 2단계, depth=4 = 3단계
|
||||
const level1Options = useMemo(
|
||||
() => allRows.filter((r) => (r.depth ?? 2) === 2),
|
||||
[allRows],
|
||||
);
|
||||
|
||||
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]);
|
||||
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,
|
||||
);
|
||||
}, [allRows, selected1]);
|
||||
|
||||
// 3단계 코드 로드 (2단계 선택값 기준)
|
||||
const loadLevel3Codes = useCallback(async (parentCodeValue: string) => {
|
||||
if (!categoryCode || !parentCodeValue) {
|
||||
setLevel3Options([]);
|
||||
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]);
|
||||
|
||||
// value prop 으로 역추적
|
||||
useEffect(() => {
|
||||
if (!value || allRows.length === 0) {
|
||||
return;
|
||||
}
|
||||
const target = allRows.find((r) => r.code_value === value);
|
||||
if (!target) 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]);
|
||||
|
||||
// 초기 로드 및 카테고리 변경 시
|
||||
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
|
||||
);
|
||||
|
||||
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);
|
||||
const depth = target.depth ?? 2;
|
||||
if (depth === 2) {
|
||||
setSelected1(value);
|
||||
setSelected2("");
|
||||
setSelected3("");
|
||||
} 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);
|
||||
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);
|
||||
}
|
||||
}, [value, allCodes]);
|
||||
}, [value, allRows]);
|
||||
|
||||
// 1단계 선택 변경
|
||||
const handleLevel1Change = (codeValue: string) => {
|
||||
setSelectedLevel1(codeValue);
|
||||
setSelectedLevel2("");
|
||||
setSelectedLevel3("");
|
||||
setLevel2Options([]);
|
||||
setLevel3Options([]);
|
||||
|
||||
if (codeValue && maxDepth > 1) {
|
||||
loadLevel2Codes(codeValue);
|
||||
}
|
||||
|
||||
// 최대 깊이가 1이면 즉시 onChange 호출
|
||||
const handle1 = useCallback(
|
||||
(v: string) => {
|
||||
setSelected1(v);
|
||||
setSelected2("");
|
||||
setSelected3("");
|
||||
if (maxDepth === 1 && onChange) {
|
||||
const selectedCodeInfo = level1Options.find(c => c.code_value === codeValue);
|
||||
onChange(codeValue, selectedCodeInfo, selectedCodeInfo ? [selectedCodeInfo] : []);
|
||||
const row = level1Options.find((r) => r.code_value === v);
|
||||
onChange(v, row, row ? [row] : []);
|
||||
}
|
||||
};
|
||||
},
|
||||
[maxDepth, onChange, level1Options],
|
||||
);
|
||||
|
||||
// 2단계 선택 변경
|
||||
const handleLevel2Change = (codeValue: string) => {
|
||||
setSelectedLevel2(codeValue);
|
||||
setSelectedLevel3("");
|
||||
setLevel3Options([]);
|
||||
|
||||
if (codeValue && maxDepth > 2) {
|
||||
loadLevel3Codes(codeValue);
|
||||
}
|
||||
|
||||
// 최대 깊이가 2이면 onChange 호출
|
||||
const handle2 = useCallback(
|
||||
(v: string) => {
|
||||
setSelected2(v);
|
||||
setSelected3("");
|
||||
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);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// 3단계 선택 변경
|
||||
const handleLevel3Change = (codeValue: string) => {
|
||||
setSelectedLevel3(codeValue);
|
||||
},
|
||||
[maxDepth, onChange, level1Options, level2Options, selected1],
|
||||
);
|
||||
|
||||
const handle3 = useCallback(
|
||||
(v: string) => {
|
||||
setSelected3(v);
|
||||
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 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 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 제한 없이)
|
||||
useEffect(() => {
|
||||
if (!onChange) return;
|
||||
|
||||
// 현재 선택된 깊이 확인
|
||||
if (selectedLevel3 && maxDepth >= 3) {
|
||||
// 3단계까지 선택됨
|
||||
return; // handleLevel3Change에서 처리
|
||||
}
|
||||
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] : []);
|
||||
}
|
||||
}, [level2Options, level3Options]);
|
||||
|
||||
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 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}
|
||||
{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}
|
||||
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}
|
||||
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 [allRows, setAllRows] = useState<CodeDetail[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 로딩 상태
|
||||
const [loading, setLoading] = useState<LoadingState>({
|
||||
large: false,
|
||||
medium: false,
|
||||
small: false,
|
||||
useEffect(() => {
|
||||
if (!categoryCode) {
|
||||
setAllRows([]);
|
||||
return;
|
||||
}
|
||||
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]);
|
||||
|
||||
// 대분류 로드 (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
|
||||
const largeOptions = useMemo(
|
||||
() => allRows.filter((r) => (r.depth ?? 2) === 2),
|
||||
[allRows],
|
||||
);
|
||||
|
||||
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([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(prev => ({ ...prev, medium: true }));
|
||||
try {
|
||||
const response = await commonCodeApi.hierarchy.getHierarchicalCodes(
|
||||
categoryCode,
|
||||
parentCodeValue,
|
||||
undefined,
|
||||
menuObjid
|
||||
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]);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setMediumOptions(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("중분류 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, medium: false }));
|
||||
}
|
||||
}, [categoryCode, menuObjid]);
|
||||
|
||||
// 소분류 로드 (중분류 선택 기준)
|
||||
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
|
||||
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]);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setSmallOptions(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("소분류 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, small: false }));
|
||||
}
|
||||
}, [categoryCode, menuObjid]);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
loadLargeOptions();
|
||||
}, [loadLargeOptions]);
|
||||
|
||||
// 대분류 값이 있으면 중분류 로드
|
||||
useEffect(() => {
|
||||
if (values.large) {
|
||||
loadMediumOptions(values.large);
|
||||
} else {
|
||||
setMediumOptions([]);
|
||||
setSmallOptions([]);
|
||||
}
|
||||
}, [values.large, loadMediumOptions]);
|
||||
|
||||
// 중분류 값이 있으면 소분류 로드
|
||||
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}
|
||||
{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 || "중분류 선택")
|
||||
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}
|
||||
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 || "소분류 선택")
|
||||
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}
|
||||
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>
|
||||
|
||||
+2
-2
@@ -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) => ({
|
||||
|
||||
+1
-1
@@ -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) => {
|
||||
|
||||
@@ -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,19 +39,8 @@ 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("");
|
||||
const [newOptionValue, setNewOptionValue] = 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,34 +75,8 @@ 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) => {
|
||||
const newConfig = { ...localConfig, [field]: value };
|
||||
@@ -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,104 +240,7 @@ 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}
|
||||
/>
|
||||
</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">
|
||||
@@ -427,10 +255,8 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||
</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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
테이블 타입 관리에서 컬럼의 입력 타입을 '카테고리'로 설정하세요
|
||||
</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">
|
||||
'{searchQuery}'에 대한 검색 결과가 없습니다
|
||||
</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">
|
||||
'{searchQuery}'에 대한 검색 결과가 없습니다
|
||||
</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;
|
||||
@@ -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,32 +553,15 @@ 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;
|
||||
// 새 구조 (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: string; label: string; hasChildren: boolean }) => ({
|
||||
fetchedOptions = data.data.map((item) => ({
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else if (source === "db" && table) {
|
||||
// DB 테이블에서 로드
|
||||
const response = await apiClient.get(`/entity/${table}/options`, {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{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
Reference in New Issue
Block a user