fix(admin): 외부커넥션 mapper varchar 캐스팅 + 외부커넥션/배치관리 UI 정돈 #21

Merged
hjjeong merged 3 commits from hjjeong into main 2026-05-19 02:25:10 +00:00
434 changed files with 37643 additions and 50684 deletions
Showing only changes of commit 947b31eff5 - Show all commits
@@ -0,0 +1,19 @@
package com.erp.constants;
import java.util.Set;
public final class InputTypeConstants {
private InputTypeConstants() {}
/**
* INSERT/UPDATE-type 검증용 허용 INPUT_TYPE.
* 신규 표준 8종 + 운영 DB 에 잔존하는 legacy 7종(category/select/textarea/checkbox/radio/datetime/boolean).
* 5/15 common-code 재설계가 화이트리스트를 8종으로 좁히면서도 옛 데이터/프론트 정리를 빠뜨려
* 컬럼 설정 저장 batch 가 일괄 거부됐던 회귀 회복. legacy 정리는 별도 PR 로.
*/
public static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
"text", "number", "date", "code", "entity",
"numbering", "file", "image",
"category", "select", "textarea", "checkbox", "radio", "datetime", "boolean"
);
}
@@ -0,0 +1,8 @@
package com.erp.constants;
public enum InputTypeContext {
USER_INSERT,
USER_UPDATE_TYPE,
USER_UPDATE_OTHER,
SYSTEM_NORMALIZE
}
@@ -1,7 +1,9 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.provisioning.SuperAdminGuard;
import com.erp.service.AdminService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
@@ -30,13 +32,17 @@ public class AdminController {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role,
@RequestAttribute("user_id") String userId,
@RequestParam Map<String, Object> params) {
@RequestParam Map<String, Object> params,
HttpServletRequest request) {
params.put("company_code", companyCode);
params.put("user_type", role);
params.put("user_id", userId);
params.putIfAbsent("user_lang", "ko");
params.put("is_management_screen",
params.get("menu_type") == null || "true".equals(params.get("include_inactive")));
// 관리 호스트(solution.invyone.com 등) 여부 — 테넌트 호스트이면 IS_SOLUTION_ONLY 메뉴를 SQL 단계에서 제외
String host = request.getHeader("Host");
params.put("is_management_host", !SuperAdminGuard.isTenantHost(host));
return ResponseEntity.ok(ApiResponse.success(adminService.getAdminMenuList(params), "관리자 메뉴 목록 조회 성공"));
}
@@ -49,11 +55,15 @@ public class AdminController {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role,
@RequestAttribute("user_id") String userId,
@RequestParam Map<String, Object> params) {
@RequestParam Map<String, Object> params,
HttpServletRequest request) {
params.put("company_code", companyCode);
params.put("user_type", role);
params.put("user_id", userId);
params.putIfAbsent("user_lang", "ko");
// 관리 호스트(solution.invyone.com 등) 여부 — 테넌트 호스트이면 IS_SOLUTION_ONLY 메뉴를 SQL 단계에서 제외
String host = request.getHeader("Host");
params.put("is_management_host", !SuperAdminGuard.isTenantHost(host));
return ResponseEntity.ok(ApiResponse.success(adminService.getUserMenuList(params), "사용자 메뉴 목록 조회 성공"));
}
@@ -1,125 +0,0 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.CascadingAutoFillService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/cascading-auto-fill")
@RequiredArgsConstructor
@Slf4j
public class CascadingAutoFillController {
private final CascadingAutoFillService cascadingAutoFillService;
// Pipeline api_test compatibility alias
@GetMapping("/list")
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupListAlias(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(cascadingAutoFillService.getCascadingAutoFillGroupList(params)));
}
@GetMapping("/groups")
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupList(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(cascadingAutoFillService.getCascadingAutoFillGroupList(params)));
}
@GetMapping("/groups/{groupCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupDetail(
@RequestAttribute("company_code") String companyCode,
@PathVariable String groupCode) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("group_code", groupCode);
Map<String, Object> result = cascadingAutoFillService.getCascadingAutoFillGroupDetail(params);
if (result == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
@PostMapping("/groups")
public ResponseEntity<ApiResponse<Map<String, Object>>> createGroup(
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(cascadingAutoFillService.insertCascadingAutoFillGroup(body)));
}
@PutMapping("/groups/{groupCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateGroup(
@RequestAttribute("company_code") String companyCode,
@PathVariable String groupCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("group_code", groupCode);
Map<String, Object> result = cascadingAutoFillService.updateCascadingAutoFillGroup(body);
if (result == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
@DeleteMapping("/groups/{groupCode}")
public ResponseEntity<ApiResponse<Void>> deleteGroup(
@RequestAttribute("company_code") String companyCode,
@PathVariable String groupCode) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("group_code", groupCode);
boolean deleted = cascadingAutoFillService.deleteCascadingAutoFillGroup(params);
if (!deleted) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(null));
}
@GetMapping("/options/{groupCode}")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getMasterOptions(
@RequestAttribute("company_code") String companyCode,
@PathVariable String groupCode) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("group_code", groupCode);
List<Map<String, Object>> result = cascadingAutoFillService.getAutoFillMasterOptions(params);
if (result == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
@GetMapping("/data/{groupCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getAutoFillData(
@RequestAttribute("company_code") String companyCode,
@PathVariable String groupCode,
@RequestParam(required = false) String masterValue) {
if (masterValue == null || masterValue.isBlank()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("masterValue 파라미터가 필요합니다."));
}
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("group_code", groupCode);
params.put("master_value", masterValue);
Map<String, Object> result = cascadingAutoFillService.getAutoFillData(params);
if (result == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
}
@@ -1,81 +0,0 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.CascadingConditionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/cascading-condition")
@RequiredArgsConstructor
@Slf4j
public class CascadingConditionController {
private final CascadingConditionService cascadingConditionService;
@GetMapping("/list")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingConditionListAlias(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.getCascadingConditionList(params)));
}
@GetMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingConditionList(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.getCascadingConditionList(params)));
}
@GetMapping("/filtered-options/{relationCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getFilteredOptions(
@RequestAttribute("company_code") String companyCode,
@PathVariable String relationCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
params.put("relation_code", relationCode);
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.getFilteredOptions(params)));
}
@GetMapping("/{conditionId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingConditionInfo(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long conditionId) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("condition_id", conditionId);
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.getCascadingConditionInfo(params)));
}
@PostMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCascadingCondition(
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.insertCascadingCondition(body)));
}
@PutMapping("/{conditionId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCascadingCondition(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long conditionId,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("condition_id", conditionId);
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.updateCascadingCondition(body)));
}
@DeleteMapping("/{conditionId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCascadingCondition(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long conditionId) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("condition_id", conditionId);
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.deleteCascadingCondition(params)));
}
}
@@ -1,157 +0,0 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.CascadingHierarchyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/cascading-hierarchy")
@RequiredArgsConstructor
@Slf4j
public class CascadingHierarchyController {
private final CascadingHierarchyService cascadingHierarchyService;
// Pipeline api_test compatibility alias
@GetMapping("/list")
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupListAlias(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(cascadingHierarchyService.getCascadingHierarchyGroupList(params)));
}
@GetMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupList(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(cascadingHierarchyService.getCascadingHierarchyGroupList(params)));
}
@GetMapping("/{groupCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupDetail(
@RequestAttribute("company_code") String companyCode,
@PathVariable String groupCode) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("group_code", groupCode);
Map<String, Object> result = cascadingHierarchyService.getCascadingHierarchyGroupDetail(params);
if (result == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("계층 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
@PostMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> createGroup(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "user_id", required = false) String userId,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
if (userId != null) body.put("user_id", userId);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(cascadingHierarchyService.insertCascadingHierarchyGroup(body)));
}
@PutMapping("/{groupCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateGroup(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "user_id", required = false) String userId,
@PathVariable String groupCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("group_code", groupCode);
if (userId != null) body.put("user_id", userId);
Map<String, Object> result = cascadingHierarchyService.updateCascadingHierarchyGroup(body);
if (result == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("계층 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
@DeleteMapping("/{groupCode}")
public ResponseEntity<ApiResponse<Void>> deleteGroup(
@RequestAttribute("company_code") String companyCode,
@PathVariable String groupCode) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("group_code", groupCode);
boolean deleted = cascadingHierarchyService.deleteCascadingHierarchyGroup(params);
if (!deleted) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("계층 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(null));
}
@PostMapping("/{groupCode}/levels")
public ResponseEntity<ApiResponse<Map<String, Object>>> addLevel(
@RequestAttribute("company_code") String companyCode,
@PathVariable String groupCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("group_code", groupCode);
Map<String, Object> result = cascadingHierarchyService.addCascadingHierarchyLevel(body);
if (result == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("계층 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(result));
}
@PutMapping("/levels/{levelId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateLevel(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long levelId,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("level_id", levelId);
Map<String, Object> result = cascadingHierarchyService.updateCascadingHierarchyLevel(body);
if (result == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("레벨을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
@DeleteMapping("/levels/{levelId}")
public ResponseEntity<ApiResponse<Void>> deleteLevel(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long levelId) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("level_id", levelId);
boolean deleted = cascadingHierarchyService.deleteCascadingHierarchyLevel(params);
if (!deleted) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("레벨을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(null));
}
@GetMapping("/{groupCode}/options/{levelOrder}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getLevelOptions(
@RequestAttribute("company_code") String companyCode,
@PathVariable String groupCode,
@PathVariable Integer levelOrder,
@RequestParam(required = false) String parentValue) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("group_code", groupCode);
params.put("level_order", levelOrder);
if (parentValue != null) params.put("parent_value", parentValue);
Map<String, Object> result = cascadingHierarchyService.getLevelOptions(params);
if (result == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("레벨을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
}
@@ -1,121 +0,0 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.CascadingMutualExclusionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* 상호 배제 API
* Node.js: app.use("/api/cascading-mutual-exclusion", cascadingMutualExclusionRoutes)
*/
@RestController
@RequestMapping("/api/cascading-mutual-exclusion")
@RequiredArgsConstructor
@Slf4j
public class CascadingMutualExclusionController {
private final CascadingMutualExclusionService cascadingMutualExclusionService;
/** GET /list — 목록 조회 (alias) */
@GetMapping("/list")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingMutualExclusionListAlias(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(
cascadingMutualExclusionService.getCascadingMutualExclusionList(params)));
}
/** GET / — 목록 조회 */
@GetMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingMutualExclusionList(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(
cascadingMutualExclusionService.getCascadingMutualExclusionList(params)));
}
/** GET /{exclusionId} — 상세 조회 */
@GetMapping("/{exclusionId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingMutualExclusionInfo(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long exclusionId) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("company_code", companyCode);
params.put("id", exclusionId);
return ResponseEntity.ok(ApiResponse.success(
cascadingMutualExclusionService.getCascadingMutualExclusionInfo(params)));
}
/** POST / — 생성 */
@PostMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCascadingMutualExclusion(
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(
cascadingMutualExclusionService.insertCascadingMutualExclusion(body)));
}
/** PUT /{exclusionId} — 수정 */
@PutMapping("/{exclusionId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCascadingMutualExclusion(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long exclusionId,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("id", exclusionId);
return ResponseEntity.ok(ApiResponse.success(
cascadingMutualExclusionService.updateCascadingMutualExclusion(body)));
}
/** DELETE /{exclusionId} — 하드 삭제 */
@DeleteMapping("/{exclusionId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCascadingMutualExclusion(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long exclusionId) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("company_code", companyCode);
params.put("id", exclusionId);
return ResponseEntity.ok(ApiResponse.success(
cascadingMutualExclusionService.deleteCascadingMutualExclusion(params)));
}
/**
* POST /validate/{exclusionCode} — 상호 배제 검증
* body: { "field_values": { "field_a": "val1", "field_b": "val1" } }
*/
@PostMapping("/validate/{exclusionCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> validateCascadingMutualExclusion(
@RequestAttribute("company_code") String companyCode,
@PathVariable String exclusionCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("code", exclusionCode);
return ResponseEntity.ok(ApiResponse.success(
cascadingMutualExclusionService.validateCascadingMutualExclusion(body)));
}
/**
* GET /options/{exclusionCode} — 배제 옵션 조회
* query: selectedValues (콤마 구분된 이미 선택된 값들)
*/
@GetMapping("/options/{exclusionCode}")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getExcludedOptions(
@RequestAttribute("company_code") String companyCode,
@PathVariable String exclusionCode,
@RequestParam(required = false) String currentField,
@RequestParam(required = false) String selectedValues) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("company_code", companyCode);
params.put("code", exclusionCode);
params.put("current_field", currentField);
params.put("selected_values", selectedValues);
return ResponseEntity.ok(ApiResponse.success(
cascadingMutualExclusionService.getExcludedOptions(params)));
}
}
@@ -1,139 +0,0 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.CascadingRelationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* 연쇄 관계 API
* Node.js: app.use("/api/cascading-relation", cascadingRelationRoutes)
*/
@RestController
@RequestMapping("/api/cascading-relation")
@RequiredArgsConstructor
@Slf4j
public class CascadingRelationController {
private final CascadingRelationService cascadingRelationService;
/** GET /api/cascading-relation/list — 목록 조회 (alias) */
@GetMapping("/list")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingRelationListAlias(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(
cascadingRelationService.getCascadingRelationList(params)));
}
/** GET /api/cascading-relation — 목록 조회 */
@GetMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingRelationList(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(
cascadingRelationService.getCascadingRelationList(params)));
}
/** GET /api/cascading-relation/{id} — 상세 조회 */
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingRelationInfo(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long id) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("company_code", companyCode);
params.put("id", id);
return ResponseEntity.ok(ApiResponse.success(
cascadingRelationService.getCascadingRelationInfo(params)));
}
/** GET /api/cascading-relation/code/{code} — 코드로 단건 조회 */
@GetMapping("/code/{code}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingRelationByCode(
@RequestAttribute("company_code") String companyCode,
@PathVariable String code) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("company_code", companyCode);
params.put("code", code);
return ResponseEntity.ok(ApiResponse.success(
cascadingRelationService.getCascadingRelationByCode(params)));
}
/**
* GET /api/cascading-relation/parent-options/{code}
* 부모 옵션 조회 (parent_table 동적 쿼리)
*/
@GetMapping("/parent-options/{code}")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getParentOptions(
@RequestAttribute("company_code") String companyCode,
@PathVariable String code) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("company_code", companyCode);
params.put("code", code);
return ResponseEntity.ok(ApiResponse.success(
cascadingRelationService.getParentOptions(params)));
}
/**
* GET /api/cascading-relation/options/{code}?parentValue=&parentValues=
* 연쇄 자식 옵션 조회 (child_table 동적 쿼리)
*/
@GetMapping("/options/{code}")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCascadingOptions(
@RequestAttribute("company_code") String companyCode,
@PathVariable String code,
@RequestParam(required = false) String parentValue,
@RequestParam(required = false) String parentValues) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("company_code", companyCode);
params.put("code", code);
params.put("parent_value", parentValue);
params.put("parent_values", parentValues);
return ResponseEntity.ok(ApiResponse.success(
cascadingRelationService.getCascadingOptions(params)));
}
/** POST /api/cascading-relation — 생성 */
@PostMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCascadingRelation(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "user_id", required = false) String userId,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("user_id", userId != null ? userId : "system");
return ResponseEntity.ok(ApiResponse.success(
cascadingRelationService.insertCascadingRelation(body)));
}
/** PUT /api/cascading-relation/{id} — 수정 */
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCascadingRelation(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "user_id", required = false) String userId,
@PathVariable Long id,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("user_id", userId != null ? userId : "system");
body.put("id", id);
return ResponseEntity.ok(ApiResponse.success(
cascadingRelationService.updateCascadingRelation(body)));
}
/** DELETE /api/cascading-relation/{id} — 소프트 삭제 (is_active = 'N') */
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCascadingRelation(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "user_id", required = false) String userId,
@PathVariable Long id) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("company_code", companyCode);
params.put("user_id", userId != null ? userId : "system");
params.put("id", id);
return ResponseEntity.ok(ApiResponse.success(
cascadingRelationService.deleteCascadingRelation(params)));
}
}
@@ -1,191 +0,0 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.CategoryTreeService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/category-tree")
@RequiredArgsConstructor
public class CategoryTreeController {
private final CategoryTreeService categoryTreeService;
/**
* GET /api/category-tree/test/all-category-keys
* 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합)
* 주의: /test/{tableName}/{columnName} 보다 먼저 매핑되어야 함
*/
@GetMapping("/test/all-category-keys")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryTreeKeyList(
@RequestAttribute("company_code") String companyCode) {
List<Map<String, Object>> keys = categoryTreeService.getCategoryTreeKeyList(companyCode);
return ResponseEntity.ok(ApiResponse.success(keys));
}
/**
* GET /api/category-tree/test/{tableName}/{columnName}
* 카테고리 트리 조회
*/
@GetMapping("/test/{tableName}/{columnName}")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryTreeList(
@RequestAttribute("company_code") String companyCode,
@PathVariable String tableName,
@PathVariable String columnName) {
List<Map<String, Object>> tree = categoryTreeService.getCategoryTreeList(companyCode, tableName, columnName);
return ResponseEntity.ok(ApiResponse.success(tree));
}
/**
* GET /api/category-tree/test/{tableName}/{columnName}/flat
* 카테고리 플랫 리스트 조회
*/
@GetMapping("/test/{tableName}/{columnName}/flat")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryTreeFlatList(
@RequestAttribute("company_code") String companyCode,
@PathVariable String tableName,
@PathVariable String columnName) {
List<Map<String, Object>> list = categoryTreeService.getCategoryTreeFlatList(companyCode, tableName, columnName);
return ResponseEntity.ok(ApiResponse.success(list));
}
/**
* GET /api/category-tree/test/value/{valueId}
* 카테고리 값 단건 조회
*/
@GetMapping("/test/value/{valueId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryTreeInfo(
@RequestAttribute("company_code") String companyCode,
@PathVariable int valueId) {
Map<String, Object> value = categoryTreeService.getCategoryTreeInfo(companyCode, valueId);
if (value == null) {
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값을 찾을 수 없습니다"));
}
return ResponseEntity.ok(ApiResponse.success(value));
}
/**
* POST /api/category-tree/test/value
* 카테고리 값 생성
*/
@PostMapping("/test/value")
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCategoryTree(
@RequestAttribute("company_code") String userCompanyCode,
@RequestAttribute("user_id") String userId,
@RequestBody Map<String, Object> body) {
if (body.get("table_name") == null || body.get("column_name") == null
|| body.get("value_code") == null || body.get("value_label") == null) {
return ResponseEntity.badRequest()
.body(ApiResponse.error("tableName, columnName, valueCode, valueLabel은 필수입니다"));
}
// 최고 관리자(*) 는 targetCompanyCode 로 회사 코드 오버라이드 가능
String companyCode = userCompanyCode;
String targetCompanyCode = (String) body.get("target_company_code");
if (targetCompanyCode != null && "*".equals(userCompanyCode)) {
companyCode = targetCompanyCode;
}
try {
Map<String, Object> value = categoryTreeService.insertCategoryTree(body, companyCode, userId);
return ResponseEntity.ok(ApiResponse.success(value));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
log.error("카테고리 값 생성 오류", e);
return ResponseEntity.status(500).body(ApiResponse.error(e.getMessage()));
}
}
/**
* PUT /api/category-tree/test/value/{valueId}
* 카테고리 값 수정
*/
@PutMapping("/test/value/{valueId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCategoryTree(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId,
@PathVariable int valueId,
@RequestBody Map<String, Object> body) {
try {
Map<String, Object> value = categoryTreeService.updateCategoryTree(companyCode, valueId, body, userId);
if (value == null) {
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값을 찾을 수 없습니다"));
}
return ResponseEntity.ok(ApiResponse.success(value));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
log.error("카테고리 값 수정 오류", e);
return ResponseEntity.status(500).body(ApiResponse.error(e.getMessage()));
}
}
/**
* GET /api/category-tree/test/value/{valueId}/can-delete
* 카테고리 값 삭제 가능 여부 사전 확인
*/
@GetMapping("/test/value/{valueId}/can-delete")
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCanDelete(
@RequestAttribute("company_code") String companyCode,
@PathVariable int valueId) {
Map<String, Object> result = categoryTreeService.checkCanDelete(companyCode, valueId);
return ResponseEntity.ok(ApiResponse.success(result));
}
/**
* DELETE /api/category-tree/test/value/{valueId}
* 카테고리 값 삭제
*/
@DeleteMapping("/test/value/{valueId}")
public ResponseEntity<ApiResponse<Void>> deleteCategoryTree(
@RequestAttribute("company_code") String companyCode,
@PathVariable int valueId) {
try {
boolean deleted = categoryTreeService.deleteCategoryTree(companyCode, valueId);
if (!deleted) {
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값을 찾을 수 없습니다"));
}
return ResponseEntity.ok(ApiResponse.success(null, "삭제되었습니다"));
} catch (IllegalStateException e) {
String msg = e.getMessage();
if (msg != null && msg.startsWith("VALIDATION:")) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(msg.substring("VALIDATION:".length())));
}
log.error("카테고리 값 삭제 오류", e);
return ResponseEntity.status(500).body(ApiResponse.error(msg));
} catch (Exception e) {
log.error("카테고리 값 삭제 오류", e);
return ResponseEntity.status(500).body(ApiResponse.error(e.getMessage()));
}
}
/**
* GET /api/category-tree/test/columns/{tableName}
* 테이블의 카테고리 컬럼 목록 조회
*/
@GetMapping("/test/columns/{tableName}")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryTreeColumnList(
@RequestAttribute("company_code") String companyCode,
@PathVariable String tableName) {
List<Map<String, Object>> columns = categoryTreeService.getCategoryTreeColumnList(companyCode, tableName);
return ResponseEntity.ok(ApiResponse.success(columns));
}
}
@@ -1,142 +0,0 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.CategoryValueCascadingService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/category-value-cascading")
@RequiredArgsConstructor
@Slf4j
public class CategoryValueCascadingController {
private final CategoryValueCascadingService categoryValueCascadingService;
/** GET /groups → 그룹 목록 조회 */
@GetMapping("/groups")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingGroupList(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingGroupList(params)));
}
/** GET /groups/{groupId} → 그룹 상세 조회 (매핑 포함) */
@GetMapping("/groups/{groupId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingGroupInfo(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long groupId) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("group_id", groupId);
Map<String, Object> result = categoryValueCascadingService.getCategoryValueCascadingGroupInfo(params);
if (result == null) {
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값 연쇄관계 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
/** GET /code/{code} → 관계 코드로 조회 */
@GetMapping("/code/{code}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingGroupByCode(
@RequestAttribute("company_code") String companyCode,
@PathVariable String code) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("code", code);
Map<String, Object> result = categoryValueCascadingService.getCategoryValueCascadingGroupByCode(params);
if (result == null) {
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값 연쇄관계를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
/** POST /groups → 그룹 생성 */
@PostMapping("/groups")
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCategoryValueCascadingGroup(
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.insertCategoryValueCascadingGroup(body)));
}
/** PUT /groups/{groupId} → 그룹 수정 */
@PutMapping("/groups/{groupId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCategoryValueCascadingGroup(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long groupId,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("group_id", groupId);
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.updateCategoryValueCascadingGroup(body)));
}
/** DELETE /groups/{groupId} → 그룹 소프트 삭제 */
@DeleteMapping("/groups/{groupId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCategoryValueCascadingGroup(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long groupId) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("group_id", groupId);
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.deleteCategoryValueCascadingGroup(params)));
}
/** POST /groups/{groupId}/mappings → 매핑 일괄 저장 */
@PostMapping("/groups/{groupId}/mappings")
public ResponseEntity<ApiResponse<Map<String, Object>>> saveCategoryValueCascadingMappings(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long groupId,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("group_id", groupId);
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.saveCategoryValueCascadingMappings(body)));
}
/** GET /parent-options/{code} → 부모 카테고리 값 목록 */
@GetMapping("/parent-options/{code}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingParentOptions(
@RequestAttribute("company_code") String companyCode,
@PathVariable String code,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
params.put("code", code);
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingParentOptions(params)));
}
/** GET /child-options/{code} → 자식 카테고리 값 목록 */
@GetMapping("/child-options/{code}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingChildOptions(
@RequestAttribute("company_code") String companyCode,
@PathVariable String code,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
params.put("code", code);
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingChildOptions(params)));
}
/** GET /options/{code} → 연쇄 옵션 조회 (parentValue/parentValues 파라미터) */
@GetMapping("/options/{code}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingOptions(
@RequestAttribute("company_code") String companyCode,
@PathVariable String code,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
params.put("code", code);
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingOptions(params)));
}
/** GET /table/{tableName}/mappings → 테이블별 매핑 조회 */
@GetMapping("/table/{tableName}/mappings")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingMappingsByTable(
@RequestAttribute("company_code") String companyCode,
@PathVariable String tableName) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("table_name", tableName);
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingMappingsByTable(params)));
}
}
@@ -1,69 +0,0 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.CodeMergeService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/code-merge")
@RequiredArgsConstructor
@Slf4j
public class CodeMergeController {
private final CodeMergeService codeMergeService;
/** POST /api/code-merge/merge-all-tables — columnName 기준 전체 테이블 코드 병합 */
@PostMapping("/merge-all-tables")
public ResponseEntity<ApiResponse<Map<String, Object>>> mergeAllTables(
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(
codeMergeService.mergeAllTables(body),
"코드 병합이 완료되었습니다."));
}
/** GET /api/code-merge/tables-with-column/:columnName — 해당 컬럼을 가진 테이블 목록 */
@GetMapping("/tables-with-column/{columnName}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getTablesWithColumn(
@PathVariable String columnName) {
return ResponseEntity.ok(ApiResponse.success(
codeMergeService.getTablesWithColumn(columnName)));
}
/** POST /api/code-merge/preview — columnName + oldValue 기준 영향 미리보기 */
@PostMapping("/preview")
public ResponseEntity<ApiResponse<Map<String, Object>>> previewCodeMerge(
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(
codeMergeService.previewCodeMerge(body)));
}
/** POST /api/code-merge/merge-by-value — 값 기반 전체 테이블/컬럼 코드 병합 */
@PostMapping("/merge-by-value")
public ResponseEntity<ApiResponse<Map<String, Object>>> mergeByValue(
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(
codeMergeService.mergeByValue(body),
"코드 병합이 완료되었습니다."));
}
/** POST /api/code-merge/preview-by-value — 값 기반 영향 미리보기 */
@PostMapping("/preview-by-value")
public ResponseEntity<ApiResponse<Map<String, Object>>> previewByValue(
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(
codeMergeService.previewByValue(body)));
}
}
@@ -7,17 +7,18 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Common Code Controller
* Common Code Controller — 마스터-디테일 패턴.
*
* commonCodeRoutes.ts 포팅 — /api/common-codes 기준 15개 엔드포인트.
* /info : code_info (1레벨 그룹 마스터)
* /detail : code_detail (2레벨~ 트리)
*
* NOTE: Spring MVC 는 리터럴 세그먼트를 경로변수보다 우선하므로
* /check-duplicate, /reorder 는 별도 선언 순서 없이도 정상 동작하나,
* 가독성을 위해 구체적인 경로를 먼저 선언한다.
* /check-duplicate 는 /{codeInfo} / /{id} 보다 먼저 매칭된다.
*/
@RestController
@RequestMapping("/api/common-codes")
@@ -27,151 +28,200 @@ public class CommonCodeController {
private final CommonCodeService service;
// ─────────────────────────────────────────────────────────────
// GET /categories
// Node.js: { success, data: [...], total, message } (flat, not nested)
// ─────────────────────────────────────────────────────────────
// ════════════════════════════════════════════════════════════════
// CODE_INFO — 그룹 마스터
// ════════════════════════════════════════════════════════════════
@GetMapping("/categories")
public ResponseEntity<Map<String, Object>> getCommonCodeCategoryList(
/** 그룹 목록 (페이징/검색) */
@GetMapping("/info")
public ResponseEntity<Map<String, Object>> getCodeInfoList(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
Map<String, Object> svcResult = service.getCommonCodeCategoryList(params);
Map<String, Object> svc = service.getCodeInfoList(params);
Map<String, Object> response = new java.util.LinkedHashMap<>();
Map<String, Object> response = new LinkedHashMap<>();
response.put("success", true);
response.put("data", svcResult.get("data"));
response.put("total", svcResult.get("total"));
response.put("message", "카테고리 목록 조회 성공");
response.put("data", svc.get("data"));
response.put("total", svc.get("total"));
response.put("message", "코드 그룹 목록 조회 성공");
return ResponseEntity.ok(response);
}
// ─────────────────────────────────────────────────────────────
// GET /categories/check-duplicate ← /{categoryCode} 보다 먼저
// ─────────────────────────────────────────────────────────────
@GetMapping("/categories/check-duplicate")
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCategoryDuplicate(
/** 그룹 중복 체크 — /{codeInfo} 보다 먼저 선언 */
@GetMapping("/info/check-duplicate")
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCodeInfoDuplicate(
@RequestAttribute("company_code") String companyCode,
@RequestParam(defaultValue = "category_code") String field,
@RequestParam(defaultValue = "code_info") String field,
@RequestParam String value,
@RequestParam(required = false) String excludeCode) {
return ResponseEntity.ok(
ApiResponse.success(service.checkCategoryDuplicate(field, value, excludeCode, companyCode)));
ApiResponse.success(service.checkCodeInfoDuplicate(field, value, excludeCode, companyCode)));
}
// ─────────────────────────────────────────────────────────────
// POST /categories
// ─────────────────────────────────────────────────────────────
/** 그룹 단건 */
@GetMapping("/info/{codeInfo}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCodeInfoInfo(
@RequestAttribute("company_code") String companyCode,
@PathVariable String codeInfo) {
@PostMapping("/categories")
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCommonCodeCategory(
Map<String, Object> info = service.getCodeInfoInfo(codeInfo, companyCode);
if (info == null) {
return ResponseEntity.status(404).body(ApiResponse.error("코드 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(info));
}
/** 그룹 생성 */
@PostMapping("/info")
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCodeInfo(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId,
@RequestBody Map<String, Object> body) {
if (body.get("category_code") == null || body.get("category_name") == null) {
if (body.get("code_info") == null || body.get("code_name") == null) {
return ResponseEntity.status(400)
.body(ApiResponse.error("필수 필드가 누락되었습니다. (categoryCode, categoryName)"));
.body(ApiResponse.error("필수 필드가 누락되었습니다. (code_info, code_name)"));
}
try {
Map<String, Object> created = service.insertCommonCodeCategory(body, companyCode, userId);
Map<String, Object> created = service.insertCodeInfo(body, companyCode, userId);
return ResponseEntity.status(201)
.body(ApiResponse.success(created, "카테고리가 성공적으로 생성되었습니다."));
.body(ApiResponse.success(created, "코드 그룹이 성공적으로 생성되었습니다."));
} catch (Exception e) {
log.error("카테고리 생성 실패", e);
log.error("코드 그룹 생성 실패", e);
return ResponseEntity.status(500)
.body(ApiResponse.error("카테고리 생성에 실패했습니다."));
.body(ApiResponse.error("코드 그룹 생성에 실패했습니다."));
}
}
// ─────────────────────────────────────────────────────────────
// PUT /categories/:categoryCode
// ─────────────────────────────────────────────────────────────
@PutMapping("/categories/{categoryCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCommonCodeCategory(
/** 그룹 수정 */
@PutMapping("/info/{codeInfo}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCodeInfo(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId,
@PathVariable String categoryCode,
@PathVariable String codeInfo,
@RequestBody Map<String, Object> body) {
try {
Map<String, Object> updated = service.updateCommonCodeCategory(categoryCode, body, companyCode, userId);
Map<String, Object> updated = service.updateCodeInfo(codeInfo, body, companyCode, userId);
if (updated == null) {
return ResponseEntity.status(404)
.body(ApiResponse.error("카테고리를 찾을 수 없습니다."));
.body(ApiResponse.error("코드 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(updated, "카테고리가 성공적으로 수정되었습니다."));
return ResponseEntity.ok(ApiResponse.success(updated, "코드 그룹이 성공적으로 수정되었습니다."));
} catch (Exception e) {
log.error("카테고리 수정 실패", e);
log.error("코드 그룹 수정 실패", e);
return ResponseEntity.status(500)
.body(ApiResponse.error("카테고리 수정에 실패했습니다."));
.body(ApiResponse.error("코드 그룹 수정에 실패했습니다."));
}
}
// ─────────────────────────────────────────────────────────────
// DELETE /categories/:categoryCode
// ─────────────────────────────────────────────────────────────
@DeleteMapping("/categories/{categoryCode}")
public ResponseEntity<ApiResponse<Void>> deleteCommonCodeCategory(
/** 그룹 삭제 (CASCADE 로 code_detail 자식 자동 삭제) */
@DeleteMapping("/info/{codeInfo}")
public ResponseEntity<ApiResponse<Void>> deleteCodeInfo(
@RequestAttribute("company_code") String companyCode,
@PathVariable String categoryCode) {
@PathVariable String codeInfo) {
try {
service.deleteCommonCodeCategory(categoryCode, companyCode);
return ResponseEntity.ok(ApiResponse.success(null, "카테고리가 성공적으로 삭제되었습니다."));
service.deleteCodeInfo(codeInfo, companyCode);
return ResponseEntity.ok(ApiResponse.success(null, "코드 그룹이 성공적으로 삭제되었습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(404).body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
log.error("카테고리 삭제 실패", e);
log.error("코드 그룹 삭제 실패", e);
return ResponseEntity.status(500)
.body(ApiResponse.error("카테고리 삭제에 실패했습니다."));
.body(ApiResponse.error("코드 그룹 삭제에 실패했습니다."));
}
}
// ─────────────────────────────────────────────────────────────
// GET /categories/:categoryCode/codes
// ─────────────────────────────────────────────────────────────
// ════════════════════════════════════════════════════════════════
// CODE_DETAIL — 디테일 트리
// ════════════════════════════════════════════════════════════════
@GetMapping("/categories/{categoryCode}/codes")
public ResponseEntity<Map<String, Object>> getCommonCodeList(
/**
* 디테일 트리.
* - code_info 필수 (어느 그룹)
* - parent_detail_id (optional): 지정 시 해당 부모의 자식만, 미지정 시 그룹 전체 트리 (재귀 CTE)
* - flat=true 인 경우 동일 (트리는 평탄화된 depth+sort_order 순)
*/
@GetMapping("/detail")
public ResponseEntity<Map<String, Object>> getCodeDetail(
@RequestAttribute("company_code") String companyCode,
@PathVariable String categoryCode,
@RequestParam("code_info") String codeInfo,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
Map<String, Object> svcResult = service.getCommonCodeList(categoryCode, params);
Map<String, Object> response = new java.util.LinkedHashMap<>();
Object parentRaw = params.get("parent_detail_id");
Map<String, Object> response = new LinkedHashMap<>();
response.put("success", true);
response.put("data", svcResult.get("data"));
response.put("total", svcResult.get("total"));
response.put("message", "코드 목록 조회 성공");
if (parentRaw != null && !parentRaw.toString().isEmpty()) {
// 특정 부모 직속 자식만
Map<String, Object> svc = service.getCodeDetailList(codeInfo, params);
response.put("data", svc.get("data"));
response.put("total", svc.get("total"));
} else {
// 그룹 전체 트리 (재귀 CTE 로 평탄화)
List<Map<String, Object>> tree = service.getCodeDetailTree(codeInfo, companyCode);
response.put("data", tree);
response.put("total", tree.size());
}
response.put("message", "코드 디테일 조회 성공");
return ResponseEntity.ok(response);
}
// ─────────────────────────────────────────────────────────────
// POST /categories/:categoryCode/codes
// ─────────────────────────────────────────────────────────────
/** 디테일 중복 체크 — /{id} 보다 먼저 선언 */
@GetMapping("/detail/check-duplicate")
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCodeDetailDuplicate(
@RequestAttribute("company_code") String companyCode,
@RequestParam("code_info") String codeInfo,
@RequestParam("code_value") String codeValue,
@RequestParam(value = "exclude_id", required = false) Long excludeId) {
@PostMapping("/categories/{categoryCode}/codes")
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCommonCode(
return ResponseEntity.ok(
ApiResponse.success(service.checkCodeDetailDuplicate(codeInfo, codeValue, excludeId, companyCode)));
}
/** 디테일 단건 */
@GetMapping("/detail/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCodeDetailInfo(
@RequestAttribute("company_code") String companyCode,
@PathVariable("id") Long codeDetailId) {
Map<String, Object> info = service.getCodeDetailInfo(codeDetailId, companyCode);
if (info == null) {
return ResponseEntity.status(404).body(ApiResponse.error("코드를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(info));
}
/** 디테일 자식 존재 여부 */
@GetMapping("/detail/{id}/has-children")
public ResponseEntity<ApiResponse<Map<String, Object>>> hasCodeDetailChildren(
@RequestAttribute("company_code") String companyCode,
@PathVariable("id") Long codeDetailId) {
return ResponseEntity.ok(
ApiResponse.success(service.hasCodeDetailChildren(codeDetailId, companyCode)));
}
/** 디테일 생성 */
@PostMapping("/detail")
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCodeDetail(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId,
@PathVariable String categoryCode,
@RequestBody Map<String, Object> body) {
if (body.get("code_value") == null || body.get("code_name") == null) {
Object codeInfoRaw = body.get("code_info");
if (codeInfoRaw == null || body.get("code_value") == null || body.get("code_name") == null) {
return ResponseEntity.status(400)
.body(ApiResponse.error("필수 필드가 누락되었습니다. (codeValue, codeName)"));
.body(ApiResponse.error("필수 필드가 누락되었습니다. (code_info, code_value, code_name)"));
}
try {
Map<String, Object> created = service.insertCommonCode(categoryCode, body, companyCode, userId);
Map<String, Object> created = service.insertCodeDetail(codeInfoRaw.toString(), body, companyCode, userId);
return ResponseEntity.status(201)
.body(ApiResponse.success(created, "코드가 성공적으로 생성되었습니다."));
} catch (Exception e) {
@@ -181,122 +231,18 @@ public class CommonCodeController {
}
}
// ─────────────────────────────────────────────────────────────
// GET /categories/:categoryCode/codes/check-duplicate ← /{codeValue} 보다 먼저
// ─────────────────────────────────────────────────────────────
@GetMapping("/categories/{categoryCode}/codes/check-duplicate")
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCodeDuplicate(
@RequestAttribute("company_code") String companyCode,
@PathVariable String categoryCode,
@RequestParam(defaultValue = "code_value") String field,
@RequestParam String value,
@RequestParam(required = false) String excludeCode) {
return ResponseEntity.ok(
ApiResponse.success(service.checkCodeDuplicate(categoryCode, field, value, excludeCode, companyCode)));
}
// ─────────────────────────────────────────────────────────────
// PUT /categories/:categoryCode/codes/reorder ← /{codeValue} 보다 먼저
// ─────────────────────────────────────────────────────────────
@SuppressWarnings("unchecked")
@PutMapping("/categories/{categoryCode}/codes/reorder")
public ResponseEntity<ApiResponse<Void>> updateCommonCodeOrder(
@RequestAttribute("company_code") String companyCode,
@PathVariable String categoryCode,
@RequestBody Map<String, Object> body) {
Object codesRaw = body.get("codes");
if (!(codesRaw instanceof List)) {
return ResponseEntity.status(400)
.body(ApiResponse.error("codes 배열이 필요합니다."));
}
try {
service.updateCommonCodeOrder(categoryCode, (List<Map<String, Object>>) codesRaw, companyCode);
return ResponseEntity.ok(ApiResponse.success(null, "정렬 순서가 변경되었습니다."));
} catch (Exception e) {
log.error("코드 정렬 변경 실패", e);
return ResponseEntity.status(500)
.body(ApiResponse.error("정렬 순서 변경에 실패했습니다."));
}
}
// ─────────────────────────────────────────────────────────────
// GET /categories/:categoryCode/hierarchy
// ─────────────────────────────────────────────────────────────
@GetMapping("/categories/{categoryCode}/hierarchy")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCommonCodeHierarchicalList(
@RequestAttribute("company_code") String companyCode,
@PathVariable String categoryCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(
ApiResponse.success(service.getCommonCodeHierarchicalList(categoryCode, params)));
}
// ─────────────────────────────────────────────────────────────
// GET /categories/:categoryCode/tree
// ─────────────────────────────────────────────────────────────
@GetMapping("/categories/{categoryCode}/tree")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCommonCodeTree(
@RequestAttribute("company_code") String companyCode,
@PathVariable String categoryCode) {
return ResponseEntity.ok(
ApiResponse.success(service.getCommonCodeTree(categoryCode, companyCode)));
}
// ─────────────────────────────────────────────────────────────
// GET /categories/:categoryCode/options
// ─────────────────────────────────────────────────────────────
@GetMapping("/categories/{categoryCode}/options")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCommonCodeOptionList(
@RequestAttribute("company_code") String companyCode,
@PathVariable String categoryCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(
ApiResponse.success(service.getCommonCodeOptionList(categoryCode, params)));
}
// ─────────────────────────────────────────────────────────────
// GET /categories/:categoryCode/codes/:codeValue/has-children
// ─────────────────────────────────────────────────────────────
@GetMapping("/categories/{categoryCode}/codes/{codeValue}/has-children")
public ResponseEntity<ApiResponse<Map<String, Object>>> hasChildren(
@RequestAttribute("company_code") String companyCode,
@PathVariable String categoryCode,
@PathVariable String codeValue) {
return ResponseEntity.ok(
ApiResponse.success(service.hasChildren(categoryCode, codeValue, companyCode)));
}
// ─────────────────────────────────────────────────────────────
// PUT /categories/:categoryCode/codes/:codeValue
// ─────────────────────────────────────────────────────────────
@PutMapping("/categories/{categoryCode}/codes/{codeValue}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCommonCode(
/** 디테일 수정 */
@PutMapping("/detail/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCodeDetail(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId,
@PathVariable String categoryCode,
@PathVariable String codeValue,
@PathVariable("id") Long codeDetailId,
@RequestBody Map<String, Object> body) {
try {
Map<String, Object> updated = service.updateCommonCode(categoryCode, codeValue, body, companyCode, userId);
Map<String, Object> updated = service.updateCodeDetail(codeDetailId, body, companyCode, userId);
if (updated == null) {
return ResponseEntity.status(404)
.body(ApiResponse.error("코드를 찾을 수 없습니다."));
return ResponseEntity.status(404).body(ApiResponse.error("코드를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(updated, "코드가 성공적으로 수정되었습니다."));
} catch (Exception e) {
@@ -306,18 +252,14 @@ public class CommonCodeController {
}
}
// ─────────────────────────────────────────────────────────────
// DELETE /categories/:categoryCode/codes/:codeValue
// ─────────────────────────────────────────────────────────────
@DeleteMapping("/categories/{categoryCode}/codes/{codeValue}")
public ResponseEntity<ApiResponse<Void>> deleteCommonCode(
/** 디테일 삭제 (CASCADE 로 자식 자동 삭제) */
@DeleteMapping("/detail/{id}")
public ResponseEntity<ApiResponse<Void>> deleteCodeDetail(
@RequestAttribute("company_code") String companyCode,
@PathVariable String categoryCode,
@PathVariable String codeValue) {
@PathVariable("id") Long codeDetailId) {
try {
service.deleteCommonCode(categoryCode, codeValue, companyCode);
service.deleteCodeDetail(codeDetailId, companyCode);
return ResponseEntity.ok(ApiResponse.success(null, "코드가 성공적으로 삭제되었습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(404).body(ApiResponse.error(e.getMessage()));
@@ -1,7 +1,9 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.provisioning.SuperAdminGuard;
import com.erp.service.CompanyManagementService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
@@ -16,6 +18,7 @@ import java.util.Map;
@Slf4j
public class CompanyManagementController {
private final SuperAdminGuard guard;
private final CompanyManagementService companyManagementService;
/**
@@ -24,9 +27,12 @@ public class CompanyManagementController {
*/
@DeleteMapping("/{companyCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCompany(
HttpServletRequest request,
@PathVariable String companyCode,
@RequestBody(required = false) Map<String, Object> body) {
guard.enforce(request);
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
if (body != null) {
@@ -52,7 +58,11 @@ public class CompanyManagementController {
* ※ /{companyCode}/disk-usage 보다 먼저 정의 (경로 특이성으로 충돌 없음)
*/
@GetMapping("/disk-usage/all")
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage() {
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage(
HttpServletRequest request) {
guard.enforce(request);
try {
Map<String, Object> data = companyManagementService.getAllCompaniesDiskUsage();
return ResponseEntity.ok(ApiResponse.success(data));
@@ -68,7 +78,11 @@ public class CompanyManagementController {
*/
@GetMapping("/{companyCode}/disk-usage")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyDiskUsage(
HttpServletRequest request,
@PathVariable String companyCode) {
guard.enforce(request);
try {
Map<String, Object> data = companyManagementService.getCompanyDiskUsage(companyCode);
return ResponseEntity.ok(ApiResponse.success(data));
@@ -18,23 +18,32 @@ public class DepartmentController {
private final DepartmentService departmentService;
private static final java.util.regex.Pattern ISO_DATE_PATTERN =
java.util.regex.Pattern.compile("\\d{4}-\\d{2}-\\d{2}");
/**
* 부서 목록 조회 (회사별).
* 기본은 active 부서만. ?include_deleted=true 시 soft-delete 된 부서도 포함.
* GET /api/departments/companies/{companyCode}/departments[?include_deleted=true]
* ?base_date=YYYY-MM-DD 시 해당 시점에 active 했던 부서만 반환.
* GET /api/departments/companies/{companyCode}/departments[?include_deleted=true][&base_date=YYYY-MM-DD]
*/
@GetMapping("/companies/{companyCode}/departments")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getDepartments(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted) {
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted,
@RequestParam(value = "base_date", required = false) String baseDate) {
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403)
.body(ApiResponse.error("해당 회사의 부서를 조회할 권한이 없습니다."));
}
if (baseDate != null && !baseDate.isBlank() && !ISO_DATE_PATTERN.matcher(baseDate).matches()) {
return ResponseEntity.status(400)
.body(ApiResponse.error("base_date 는 YYYY-MM-DD 형식이어야 합니다."));
}
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted);
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted, baseDate);
return ResponseEntity.ok(ApiResponse.success(departments, "부서 목록 조회 성공"));
}
@@ -66,6 +75,7 @@ public class DepartmentController {
/**
* 부서 생성
* POST /api/departments/companies/{companyCode}/departments
* body 에 approval_managers/dept_managers/org_leaders 배열 (각 element {user_id: 'xxx'}) 포함 가능. 최대 10명.
*/
@PostMapping("/companies/{companyCode}/departments")
public ResponseEntity<ApiResponse<Map<String, Object>>> createDepartment(
@@ -94,6 +104,7 @@ public class DepartmentController {
/**
* 부서 수정
* PUT /api/departments/{deptCode}
* body 에 approval_managers/dept_managers/org_leaders 배열 (각 element {user_id: 'xxx'}) 포함 가능. 최대 10명.
*/
@PutMapping("/{deptCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateDepartment(
@@ -131,6 +142,135 @@ public class DepartmentController {
}
}
/**
* 일괄 미리보기 (read-only validation).
* POST /api/departments/companies/{companyCode}/departments/bulk/preview
* body: { action: "create"|"update_department"|"update_manager", rows: List<Map> }
* response: { rows: [...with row_index/result/error_detail], ok_count, error_count }
*/
@PostMapping("/companies/{companyCode}/departments/bulk/preview")
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkPreview(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestAttribute("role") String role,
@RequestBody Map<String, Object> body) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 처리할 권한이 없습니다."));
}
String action = body.get("action") != null ? body.get("action").toString() : "";
@SuppressWarnings("unchecked")
List<Map<String, Object>> rows = body.get("rows") instanceof List
? (List<Map<String, Object>>) body.get("rows") : null;
if (rows == null) {
return ResponseEntity.status(400).body(ApiResponse.error("rows 가 없습니다."));
}
try {
List<Map<String, Object>> result;
switch (action) {
case "create":
result = departmentService.bulkPreviewCreate(companyCode, rows);
break;
case "update_department":
result = departmentService.bulkPreviewUpdate(companyCode, "department", rows);
break;
case "update_manager":
result = departmentService.bulkPreviewUpdate(companyCode, "manager", rows);
break;
default:
return ResponseEntity.status(400)
.body(ApiResponse.error("action 은 create|update_department|update_manager 중 하나."));
}
int ok = 0, err = 0;
for (Map<String, Object> r : result) {
if ("ok".equals(r.get("result"))) ok++; else err++;
}
Map<String, Object> data = new java.util.HashMap<>();
data.put("rows", result);
data.put("ok_count", ok);
data.put("error_count", err);
return ResponseEntity.ok(ApiResponse.success(data, "미리보기 완료"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/**
* 일괄등록 적용 (@Transactional, all-or-nothing).
* POST /api/departments/companies/{companyCode}/departments/bulk/create
* body: { rows: List<Map> } — 클라이언트가 미리보기 결과 중 ok 인 row 만 보내야 함.
*/
@PostMapping("/companies/{companyCode}/departments/bulk/create")
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkCreate(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestAttribute("role") String role,
@RequestBody Map<String, Object> body) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 등록할 권한이 없습니다."));
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> rows = body.get("rows") instanceof List
? (List<Map<String, Object>>) body.get("rows") : null;
if (rows == null || rows.isEmpty()) {
return ResponseEntity.status(400).body(ApiResponse.error("등록할 데이터가 없습니다."));
}
try {
int inserted = departmentService.bulkSaveCreate(companyCode, rows);
Map<String, Object> data = new java.util.HashMap<>();
data.put("inserted", inserted);
return ResponseEntity.status(201).body(ApiResponse.success(data, inserted + "건이 등록되었습니다."));
} catch (DepartmentService.DuplicateDeptNameException e) {
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/**
* 일괄업데이트 적용 (@Transactional). mode = department | manager.
* POST /api/departments/companies/{companyCode}/departments/bulk/update
* body: { mode: "department"|"manager", rows: List<Map> } — 각 row 에 dept_code 필수.
*/
@PostMapping("/companies/{companyCode}/departments/bulk/update")
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkUpdate(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestAttribute("role") String role,
@RequestBody Map<String, Object> body) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 수정할 권한이 없습니다."));
}
String mode = body.get("mode") != null ? body.get("mode").toString() : "";
@SuppressWarnings("unchecked")
List<Map<String, Object>> rows = body.get("rows") instanceof List
? (List<Map<String, Object>>) body.get("rows") : null;
if (rows == null || rows.isEmpty()) {
return ResponseEntity.status(400).body(ApiResponse.error("수정할 데이터가 없습니다."));
}
try {
int updated = departmentService.bulkUpdate(companyCode, mode, rows);
Map<String, Object> data = new java.util.HashMap<>();
data.put("updated", updated);
return ResponseEntity.ok(ApiResponse.success(data, updated + "건이 수정되었습니다."));
} catch (DepartmentService.DuplicateDeptNameException e) {
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/**
* 부서 삭제 (soft-delete, V1 slim scope).
* - 기존 hard-delete → DELETED_AT = NOW() 마킹으로 변경
@@ -19,21 +19,21 @@ public class EntityReferenceController {
private final EntityReferenceService entityReferenceService;
/**
* GET /api/entity-reference/code/:codeCategory
* GET /api/entity-reference/code/:codeInfo
* 공통 코드 데이터 조회
*
* NOTE: Spring MVC는 리터럴 경로 세그먼트("code")를 변수 경로({tableName})보다 우선하므로
* /code/{codeCategory} 가 /{tableName}/{columnName} 보다 먼저 매핑됨.
* /code/{codeInfo} 가 /{tableName}/{columnName} 보다 먼저 매핑됨.
*/
@GetMapping("/code/{codeCategory}")
@GetMapping("/code/{codeInfo}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCodeData(
@PathVariable String codeCategory,
@PathVariable String codeInfo,
@RequestParam(required = false, defaultValue = "100") Integer limit,
@RequestParam(required = false) String search,
@RequestAttribute("company_code") String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("code_category", codeCategory);
params.put("code_info", codeInfo);
params.put("company_code", companyCode);
params.put("limit", limit);
if (search != null) params.put("search", search);
@@ -41,7 +41,7 @@ public class EntityReferenceController {
try {
return ResponseEntity.ok(ApiResponse.success(entityReferenceService.getCodeData(params)));
} catch (Exception e) {
log.error("공통 코드 데이터 조회 실패: codeCategory={}", codeCategory, e);
log.error("공통 코드 데이터 조회 실패: codeInfo={}", codeInfo, e);
return ResponseEntity.status(500).body(ApiResponse.error("공통 코드 데이터 조회 중 오류가 발생했습니다."));
}
}
@@ -11,7 +11,7 @@ import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/numbering-rule")
@RequestMapping("/api/numbering-rules")
@RequiredArgsConstructor
@Slf4j
public class NumberingRuleController {
@@ -136,7 +136,7 @@ public class NumberingRuleController {
Map<String, Object> formData = body != null ? (Map<String, Object>) body.get("form_data") : null;
String manualInputValue = body != null ? (String) body.get("manual_input_value") : null;
String code = numberingRuleService.previewCode(ruleId, companyCode, formData, manualInputValue);
return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "미리보기 생성이 완료되었습니다."));
return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "미리보기 생성이 완료되었습니다."));
}
// ================================================================
@@ -202,7 +202,7 @@ public class NumberingRuleController {
Map<String, Object> formData = body != null ? (Map<String, Object>) body.get("form_data") : null;
String manualInputValue = body != null ? (String) body.get("manual_input_value") : null;
String code = numberingRuleService.previewCode(ruleId, companyCode, formData, manualInputValue);
return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "미리보기 생성이 완료되었습니다."));
return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "미리보기 생성이 완료되었습니다."));
}
/** POST /{ruleId}/allocate → 코드 할당 (순번 증가) */
@@ -215,7 +215,7 @@ public class NumberingRuleController {
Map<String, Object> formData = body != null ? (Map<String, Object>) body.get("form_data") : null;
String userInputCode = body != null ? (String) body.get("user_input_code") : null;
String code = numberingRuleService.allocateCode(ruleId, companyCode, formData, userInputCode);
return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "코드 할당이 완료되었습니다."));
return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "코드 할당이 완료되었습니다."));
}
/** POST /{ruleId}/generate (deprecated) → allocateCode 위임 */
@@ -224,18 +224,63 @@ public class NumberingRuleController {
@RequestAttribute("company_code") String companyCode,
@PathVariable String ruleId) {
String code = numberingRuleService.generateCode(ruleId, companyCode);
return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "코드 생성이 완료되었습니다."));
return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "코드 생성이 완료되었습니다."));
}
/** POST /{ruleId}/reset → 순번 초기화 */
/** admin 권한 (SUPER_ADMIN / ADMIN / COMPANY_ADMIN) 만 시퀀스 직접 조작 가능 */
private boolean isAdminRole(String role) {
return "SUPER_ADMIN".equals(role)
|| "ADMIN".equals(role)
|| "COMPANY_ADMIN".equals(role);
}
/** POST /{ruleId}/reset → 순번 초기화 (admin 전용) */
@PostMapping("/{ruleId}/reset")
public ResponseEntity<ApiResponse<Void>> resetSequence(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role,
@PathVariable String ruleId) {
if (!isAdminRole(role)) {
return ResponseEntity.status(403)
.body(ApiResponse.error("관리자 권한이 필요합니다."));
}
numberingRuleService.resetSequence(ruleId, companyCode);
return ResponseEntity.ok(ApiResponse.success(null, "시퀀스가 초기화되었습니다."));
}
/** PUT /{ruleId}/sequence → 현재 시퀀스 임의 값으로 수정 (admin 전용) */
@PutMapping("/{ruleId}/sequence")
public ResponseEntity<ApiResponse<Void>> updateRuleSequence(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role,
@PathVariable String ruleId,
@RequestBody Map<String, Object> body) {
if (!isAdminRole(role)) {
return ResponseEntity.status(403)
.body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Object seqObj = body.get("sequence");
if (seqObj == null) {
return ResponseEntity.badRequest()
.body(ApiResponse.error("sequence 값이 필요합니다."));
}
Integer newSequence;
try {
newSequence = (seqObj instanceof Number)
? ((Number) seqObj).intValue()
: Integer.parseInt(seqObj.toString());
} catch (NumberFormatException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error("sequence 는 정수여야 합니다."));
}
if (newSequence < 0) {
return ResponseEntity.badRequest()
.body(ApiResponse.error("sequence 는 0 이상이어야 합니다."));
}
numberingRuleService.updateRuleSequence(ruleId, newSequence, companyCode);
return ResponseEntity.ok(ApiResponse.success(null, "시퀀스가 수정되었습니다."));
}
// ================================================================
// ■ Admin
// ================================================================
@@ -593,10 +593,10 @@ public class ScreenManagementController {
}
@PostMapping("/copy-code-category")
public ResponseEntity<ApiResponse<Map<String, Object>>> copyCodeCategoryAndCodes(
public ResponseEntity<ApiResponse<Map<String, Object>>> copyCodeInfoAndCodes(
@RequestBody Map<String, Object> body) {
try {
int count = service.copyCodeCategoryAndCodes(body);
int count = service.copyCodeInfoAndCodes(body);
return ResponseEntity.ok(ApiResponse.success(Map.of("count", count)));
} catch (Exception e) {
log.error("코드 카테고리 복제 실패", e);
@@ -1,373 +0,0 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.TableCategoryValueService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/table-categories")
@RequiredArgsConstructor
@Slf4j
public class TableCategoryValueController {
private final TableCategoryValueService service;
// ══════════════════════════════════════════════════════════════
// Category Columns
// ══════════════════════════════════════════════════════════════
/** GET /api/table-categories/all-columns */
@GetMapping("/all-columns")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getAllCategoryColumns(
@RequestAttribute("company_code") String companyCode) {
try {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(service.getAllCategoryColumns(params)));
} catch (Exception e) {
log.error("전체 카테고리 컬럼 조회 실패", e);
return ResponseEntity.status(500)
.body(ApiResponse.error("전체 카테고리 컬럼 조회 중 오류가 발생했습니다"));
}
}
/** GET /api/table-categories/{tableName}/columns */
@GetMapping("/{tableName}/columns")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryColumns(
@PathVariable String tableName,
@RequestAttribute("company_code") String companyCode) {
try {
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(service.getCategoryColumns(params)));
} catch (Exception e) {
log.error("카테고리 컬럼 조회 실패: tableName={}", tableName, e);
return ResponseEntity.status(500)
.body(ApiResponse.error("카테고리 컬럼 조회 중 오류가 발생했습니다"));
}
}
// ══════════════════════════════════════════════════════════════
// Category Values — Read
// ══════════════════════════════════════════════════════════════
/** GET /api/table-categories/{tableName}/{columnName}/values */
@GetMapping("/{tableName}/{columnName}/values")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryValues(
@PathVariable String tableName,
@PathVariable String columnName,
@RequestParam(required = false) String menuObjid,
@RequestParam(required = false, defaultValue = "false") boolean includeInactive,
@RequestParam(required = false) String filterCompanyCode,
@RequestAttribute("company_code") String companyCode) {
try {
// SUPER_ADMIN 이 특정 회사 기준 필터링 요청 시 해당 companyCode 사용
String effectiveCompanyCode = ("*".equals(companyCode) && filterCompanyCode != null)
? filterCompanyCode : companyCode;
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("column_name", columnName);
params.put("company_code", effectiveCompanyCode);
params.put("include_inactive", includeInactive);
if (menuObjid != null) params.put("menu_objid", Long.parseLong(menuObjid));
return ResponseEntity.ok(ApiResponse.success(service.getCategoryValues(params)));
} catch (Exception e) {
log.error("카테고리 값 조회 실패: tableName={}, columnName={}", tableName, columnName, e);
return ResponseEntity.status(500)
.body(ApiResponse.error("카테고리 값 조회 중 오류가 발생했습니다"));
}
}
// ══════════════════════════════════════════════════════════════
// Category Values — Write
// ══════════════════════════════════════════════════════════════
/** POST /api/table-categories/values */
@PostMapping("/values")
public ResponseEntity<ApiResponse<Map<String, Object>>> addCategoryValue(
@RequestBody Map<String, Object> body,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId) {
if (body.get("menu_objid") == null) {
return ResponseEntity.status(400).body(ApiResponse.error("menuObjid는 필수입니다"));
}
body.put("company_code", companyCode);
body.put("user_id", userId);
try {
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(service.addCategoryValue(body)));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(500).body(ApiResponse.error(
e.getMessage() != null ? e.getMessage() : "카테고리 값 추가 중 오류가 발생했습니다"));
} catch (Exception e) {
log.error("카테고리 값 추가 실패", e);
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 추가 중 오류가 발생했습니다"));
}
}
/** PUT /api/table-categories/values/{valueId} */
@PutMapping("/values/{valueId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCategoryValue(
@PathVariable Long valueId,
@RequestBody Map<String, Object> body,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId) {
body.put("value_id", valueId);
body.put("company_code", companyCode);
body.put("user_id", userId);
try {
return ResponseEntity.ok(ApiResponse.success(service.updateCategoryValue(body)));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 수정 중 오류가 발생했습니다"));
} catch (Exception e) {
log.error("카테고리 값 수정 실패: valueId={}", valueId, e);
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 수정 중 오류가 발생했습니다"));
}
}
/** DELETE /api/table-categories/values/{valueId} */
@DeleteMapping("/values/{valueId}")
public ResponseEntity<ApiResponse<Void>> deleteCategoryValue(
@PathVariable Long valueId,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId) {
Map<String, Object> params = new HashMap<>();
params.put("value_id", valueId);
params.put("company_code", companyCode);
params.put("user_id", userId);
try {
service.deleteCategoryValue(params);
return ResponseEntity.ok(ApiResponse.success(null, "카테고리 값이 삭제되었습니다"));
} catch (IllegalArgumentException e) {
// 사용 중인 경우 400
if (e.getMessage() != null && e.getMessage().contains("삭제할 수 없습니다")) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
return ResponseEntity.status(500).body(ApiResponse.error(
e.getMessage() != null ? e.getMessage() : "카테고리 값 삭제 중 오류가 발생했습니다"));
} catch (Exception e) {
log.error("카테고리 값 삭제 실패: valueId={}", valueId, e);
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 삭제 중 오류가 발생했습니다"));
}
}
/** POST /api/table-categories/values/bulk-delete */
@PostMapping("/values/bulk-delete")
public ResponseEntity<ApiResponse<Void>> bulkDeleteCategoryValues(
@RequestBody Map<String, Object> body,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId) {
Object rawIds = body.get("value_ids");
if (!(rawIds instanceof List) || ((List<?>) rawIds).isEmpty()) {
return ResponseEntity.status(400).body(ApiResponse.error("삭제할 값 ID 목록이 필요합니다"));
}
body.put("company_code", companyCode);
body.put("user_id", userId);
try {
service.bulkDeleteCategoryValues(body);
int count = ((List<?>) rawIds).size();
return ResponseEntity.ok(
ApiResponse.success(null, count + "개의 카테고리 값이 삭제되었습니다"));
} catch (Exception e) {
log.error("카테고리 값 일괄 삭제 실패", e);
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 일괄 삭제 중 오류가 발생했습니다"));
}
}
/** POST /api/table-categories/values/reorder */
@PostMapping("/values/reorder")
public ResponseEntity<ApiResponse<Void>> reorderCategoryValues(
@RequestBody Map<String, Object> body,
@RequestAttribute("company_code") String companyCode) {
Object rawIds = body.get("ordered_value_ids");
if (!(rawIds instanceof List) || ((List<?>) rawIds).isEmpty()) {
return ResponseEntity.status(400).body(ApiResponse.error("순서 정보가 필요합니다"));
}
body.put("company_code", companyCode);
try {
service.reorderCategoryValues(body);
return ResponseEntity.ok(ApiResponse.success(null, "카테고리 값 순서가 변경되었습니다"));
} catch (Exception e) {
log.error("카테고리 값 순서 변경 실패", e);
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 순서 변경 중 오류가 발생했습니다"));
}
}
// ══════════════════════════════════════════════════════════════
// Labels by Codes
// ══════════════════════════════════════════════════════════════
/** POST /api/table-categories/labels-by-codes */
@PostMapping("/labels-by-codes")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryLabelsByCodes(
@RequestBody Map<String, Object> body,
@RequestAttribute("company_code") String companyCode) {
Object rawCodes = body.get("value_codes");
if (!(rawCodes instanceof List) || ((List<?>) rawCodes).isEmpty()) {
return ResponseEntity.ok(ApiResponse.success(new java.util.LinkedHashMap<>()));
}
body.put("company_code", companyCode);
try {
return ResponseEntity.ok(ApiResponse.success(service.getCategoryLabelsByCodes(body)));
} catch (Exception e) {
log.error("카테고리 라벨 조회 실패", e);
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 라벨 조회 중 오류가 발생했습니다"));
}
}
// ══════════════════════════════════════════════════════════════
// Second-Level Menus (NOTE: 리터럴 경로이므로 variable 경로보다 우선)
// ══════════════════════════════════════════════════════════════
/** GET /api/table-categories/second-level-menus */
@GetMapping("/second-level-menus")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getSecondLevelMenus(
@RequestAttribute("company_code") String companyCode) {
try {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(service.getSecondLevelMenus(params)));
} catch (Exception e) {
log.error("2레벨 메뉴 목록 조회 실패", e);
return ResponseEntity.status(500).body(ApiResponse.error("2레벨 메뉴 목록 조회 중 오류가 발생했습니다"));
}
}
// ══════════════════════════════════════════════════════════════
// Column Mapping
// ══════════════════════════════════════════════════════════════
/** GET /api/table-categories/column-mapping/{tableName}/{menuObjid} */
@GetMapping("/column-mapping/{tableName}/{menuObjid}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getColumnMapping(
@PathVariable String tableName,
@PathVariable Long menuObjid,
@RequestAttribute("company_code") String companyCode) {
try {
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("menu_objid", menuObjid);
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(service.getColumnMapping(params)));
} catch (Exception e) {
log.error("컬럼 매핑 조회 실패: tableName={}, menuObjid={}", tableName, menuObjid, e);
return ResponseEntity.status(500).body(ApiResponse.error("컬럼 매핑 조회 중 오류가 발생했습니다"));
}
}
/** GET /api/table-categories/logical-columns/{tableName}/{menuObjid} */
@GetMapping("/logical-columns/{tableName}/{menuObjid}")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getLogicalColumns(
@PathVariable String tableName,
@PathVariable Long menuObjid,
@RequestAttribute("company_code") String companyCode) {
try {
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("menu_objid", menuObjid);
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(service.getLogicalColumns(params)));
} catch (Exception e) {
log.error("논리적 컬럼 목록 조회 실패: tableName={}, menuObjid={}", tableName, menuObjid, e);
return ResponseEntity.status(500).body(ApiResponse.error("논리적 컬럼 목록 조회 중 오류가 발생했습니다"));
}
}
/** POST /api/table-categories/column-mapping */
@PostMapping("/column-mapping")
public ResponseEntity<ApiResponse<Map<String, Object>>> createColumnMapping(
@RequestBody Map<String, Object> body,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId) {
String tableName = (String) body.get("table_name");
String logicalColumnName = (String) body.get("logical_column_name");
String physicalColumnName = (String) body.get("physical_column_name");
Object menuObjid = body.get("menu_objid");
if (tableName == null || logicalColumnName == null
|| physicalColumnName == null || menuObjid == null) {
return ResponseEntity.status(400).body(ApiResponse.error(
"tableName, logicalColumnName, physicalColumnName, menuObjid는 필수입니다"));
}
body.put("company_code", companyCode);
body.put("user_id", userId);
// menuObjid를 Long으로 보장
body.put("menu_objid", toLong(menuObjid));
try {
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(service.createColumnMapping(body), "컬럼 매핑이 생성되었습니다"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(500).body(ApiResponse.error(
e.getMessage() != null ? e.getMessage() : "컬럼 매핑 생성 중 오류가 발생했습니다"));
} catch (Exception e) {
log.error("컬럼 매핑 생성 실패", e);
return ResponseEntity.status(500).body(ApiResponse.error("컬럼 매핑 생성 중 오류가 발생했습니다"));
}
}
/**
* DELETE /api/table-categories/column-mapping/{tableName}/{columnName}/all
* NOTE: 3-segment 경로이므로 /{mappingId} 1-segment 경로보다 Spring이 우선 매핑.
*/
@DeleteMapping("/column-mapping/{tableName}/{columnName}/all")
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteColumnMappingsByColumn(
@PathVariable String tableName,
@PathVariable String columnName,
@RequestAttribute("company_code") String companyCode) {
try {
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("column_name", columnName);
params.put("company_code", companyCode);
int deleted = service.deleteColumnMappingsByColumn(params);
Map<String, Object> data = new HashMap<>();
data.put("deleted_count", deleted);
return ResponseEntity.ok(ApiResponse.success(data,
deleted + "개의 컬럼 매핑이 삭제되었습니다"));
} catch (Exception e) {
log.error("테이블+컬럼 기준 매핑 삭제 실패: tableName={}, columnName={}", tableName, columnName, e);
return ResponseEntity.status(500).body(ApiResponse.error("컬럼 매핑 삭제 중 오류가 발생했습니다"));
}
}
/** DELETE /api/table-categories/column-mapping/{mappingId} */
@DeleteMapping("/column-mapping/{mappingId}")
public ResponseEntity<ApiResponse<Void>> deleteColumnMapping(
@PathVariable Long mappingId,
@RequestAttribute("company_code") String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("mapping_id", mappingId);
params.put("company_code", companyCode);
try {
service.deleteColumnMapping(params);
return ResponseEntity.ok(ApiResponse.success(null, "컬럼 매핑이 삭제되었습니다"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(500).body(ApiResponse.error(
e.getMessage() != null ? e.getMessage() : "컬럼 매핑 삭제 중 오류가 발생했습니다"));
} catch (Exception e) {
log.error("컬럼 매핑 삭제 실패: mappingId={}", mappingId, e);
return ResponseEntity.status(500).body(ApiResponse.error("컬럼 매핑 삭제 중 오류가 발생했습니다"));
}
}
// ── private util ───────────────────────────────────────────────
private long toLong(Object val) {
if (val == null) return 0L;
if (val instanceof Number) return ((Number) val).longValue();
try { return Long.parseLong(val.toString()); } catch (NumberFormatException e) { return 0L; }
}
}
@@ -75,7 +75,11 @@ public class TableManagementController {
@PutMapping("/tables/{tableName}/label")
public ResponseEntity<ApiResponse<Void>> updateTableLabel(
@PathVariable String tableName,
@RequestBody Map<String, Object> body) {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
String displayName = (String) body.get("display_name");
String description = (String) body.get("description");
if (displayName == null || displayName.isBlank()) {
@@ -105,7 +109,11 @@ public class TableManagementController {
@PathVariable String tableName,
@PathVariable String columnName,
@RequestBody Map<String, Object> settings,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
return doUpdateColumnSettings(tableName, columnName, settings, companyCode);
}
@@ -115,7 +123,11 @@ public class TableManagementController {
@PathVariable String tableName,
@PathVariable String columnName,
@RequestBody Map<String, Object> settings,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
return doUpdateColumnSettings(tableName, columnName, settings, companyCode);
}
@@ -136,7 +148,11 @@ public class TableManagementController {
public ResponseEntity<ApiResponse<Void>> updateAllColumnSettingsPost(
@PathVariable String tableName,
@RequestBody List<Map<String, Object>> columnSettings,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
return doUpdateAllColumnSettings(tableName, columnSettings, companyCode);
}
@@ -145,7 +161,11 @@ public class TableManagementController {
public ResponseEntity<ApiResponse<Void>> updateAllColumnSettingsBatch(
@PathVariable String tableName,
@RequestBody List<Map<String, Object>> columnSettings,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
return doUpdateAllColumnSettings(tableName, columnSettings, companyCode);
}
@@ -166,7 +186,11 @@ public class TableManagementController {
public ResponseEntity<ApiResponse<Void>> updateColumnWebType(
@PathVariable String tableName,
@PathVariable String columnName,
@RequestBody Map<String, Object> body) {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
String webType = (String) body.get("web_type");
if (webType == null || webType.isBlank()) {
return ResponseEntity.status(400).body(ApiResponse.error("웹 타입이 필요합니다."));
@@ -183,7 +207,11 @@ public class TableManagementController {
@PathVariable String tableName,
@PathVariable String columnName,
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
String inputType = (String) body.get("input_type");
if (tableName == null || columnName == null || inputType == null || inputType.isBlank()) {
return ResponseEntity.status(400).body(ApiResponse.error("테이블명, 컬럼명, 입력 타입이 모두 필요합니다."));
@@ -241,7 +269,11 @@ public class TableManagementController {
@PutMapping("/tables/{tableName}/primary-key")
public ResponseEntity<ApiResponse<Void>> setTablePrimaryKey(
@PathVariable String tableName,
@RequestBody Map<String, Object> body) {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@SuppressWarnings("unchecked")
List<String> columns = (List<String>) body.get("columns");
if (tableName == null || columns == null || columns.isEmpty()) {
@@ -256,7 +288,11 @@ public class TableManagementController {
@PostMapping("/tables/{tableName}/indexes")
public ResponseEntity<ApiResponse<Void>> toggleTableIndex(
@PathVariable String tableName,
@RequestBody Map<String, Object> body) {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
String columnName = (String) body.get("column_name");
String indexType = (String) body.get("index_type");
String action = (String) body.get("action");
@@ -281,7 +317,11 @@ public class TableManagementController {
@PathVariable String tableName,
@PathVariable String columnName,
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
Object nullableObj = body.get("nullable");
if (tableName == null || columnName == null || !(nullableObj instanceof Boolean)) {
return ResponseEntity.status(400).body(ApiResponse.error("tableName, columnName, nullable(boolean)이 필요합니다."));
@@ -299,7 +339,11 @@ public class TableManagementController {
@PathVariable String tableName,
@PathVariable String columnName,
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
Object uniqueObj = body.get("unique");
if (tableName == null || columnName == null || !(uniqueObj instanceof Boolean)) {
return ResponseEntity.status(400).body(ApiResponse.error("tableName, columnName, unique(boolean)이 필요합니다."));
@@ -325,6 +369,57 @@ public class TableManagementController {
"테이블 데이터를 성공적으로 조회했습니다."));
}
/** POST /api/table-management/tables/:tableName/aggregate
* body: { aggregation: "count"|"sum"|..., columnName?: string, filters?: [...] }
* → { value: number }
*/
@PostMapping("/tables/{tableName}/aggregate")
public ResponseEntity<ApiResponse<Map<String, Object>>> aggregateTableData(
@PathVariable String tableName,
@RequestBody Map<String, Object> options) {
try {
return ResponseEntity.ok(ApiResponse.success(
tableManagementService.aggregateTableData(tableName, options == null ? Map.of() : options),
"테이블 집계를 성공적으로 조회했습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/** POST /api/table-management/tables/:tableName/aggregate-group
* body: { aggregation, groupBy, valueColumn?, filters?, limit?, orderDir? }
* → { rows: [{ group, value }, ...] }
*/
@PostMapping("/tables/{tableName}/aggregate-group")
public ResponseEntity<ApiResponse<Map<String, Object>>> aggregateTableGroup(
@PathVariable String tableName,
@RequestBody Map<String, Object> options) {
try {
return ResponseEntity.ok(ApiResponse.success(
tableManagementService.aggregateTableGroup(tableName, options == null ? Map.of() : options),
"테이블 그룹 집계를 성공적으로 조회했습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/** POST /api/table-management/tables/:tableName/select-rows
* body: { columns?, filters?, orderBy?, limit?, offset? }
* → { rows: [{...}, ...] }
*/
@PostMapping("/tables/{tableName}/select-rows")
public ResponseEntity<ApiResponse<Map<String, Object>>> selectTableRows(
@PathVariable String tableName,
@RequestBody Map<String, Object> options) {
try {
return ResponseEntity.ok(ApiResponse.success(
tableManagementService.selectTableRows(tableName, options == null ? Map.of() : options),
"테이블 row 를 성공적으로 조회했습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/** POST /api/table-management/tables/:tableName/record (단일 레코드) */
@PostMapping("/tables/{tableName}/record")
public ResponseEntity<ApiResponse<Map<String, Object>>> getTableRecord(
@@ -366,7 +461,11 @@ public class TableManagementController {
public ResponseEntity<ApiResponse<Map<String, Object>>> addTableData(
@PathVariable String tableName,
@RequestBody Map<String, Object> data,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
if (data == null || data.isEmpty()) {
return ResponseEntity.status(400).body(ApiResponse.error("추가할 데이터가 필요합니다."));
}
@@ -399,7 +498,11 @@ public class TableManagementController {
public ResponseEntity<ApiResponse<Void>> editTableData(
@PathVariable String tableName,
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
@SuppressWarnings("unchecked")
Map<String, Object> originalData = (Map<String, Object>) body.get("original_data");
@SuppressWarnings("unchecked")
@@ -433,7 +536,11 @@ public class TableManagementController {
@DeleteMapping("/tables/{tableName}/delete")
public ResponseEntity<ApiResponse<Void>> deleteTableData(
@PathVariable String tableName,
@RequestBody Object body) {
@RequestBody Object body,
@RequestAttribute("role") String role) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
List<Map<String, Object>> dataList;
if (body instanceof List) {
@SuppressWarnings("unchecked")
@@ -457,7 +564,11 @@ public class TableManagementController {
@PostMapping("/tables/{tableName}/log")
public ResponseEntity<ApiResponse<Void>> createLogTable(
@PathVariable String tableName,
@RequestBody Map<String, Object> body) {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@SuppressWarnings("unchecked")
List<String> logColumns = (List<String>) body.get("log_columns");
boolean isActive = Boolean.TRUE.equals(body.get("is_active"));
@@ -487,7 +598,11 @@ public class TableManagementController {
@PostMapping("/tables/{tableName}/log/toggle")
public ResponseEntity<ApiResponse<Void>> toggleLogTable(
@PathVariable String tableName,
@RequestBody Map<String, Object> body) {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
boolean isActive = Boolean.TRUE.equals(body.get("is_active"));
tableManagementService.toggleLogTable(tableName, isActive);
return ResponseEntity.ok(ApiResponse.success(null,
@@ -544,7 +659,11 @@ public class TableManagementController {
@PostMapping("/multi-table-save")
public ResponseEntity<ApiResponse<Map<String, Object>>> multiTableSave(
@RequestBody Map<String, Object> payload,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
return ResponseEntity.ok(ApiResponse.success(
tableManagementService.multiTableSave(payload, companyCode),
"다중 테이블 저장이 완료되었습니다."));
@@ -575,4 +694,16 @@ public class TableManagementController {
return ResponseEntity.ok(ApiResponse.success(
tableManagementService.checkDatabaseConnection(), "데이터베이스 연결 상태를 확인했습니다."));
}
// ──────────────────────────────────────────────────────────
// 권한 헬퍼
// ──────────────────────────────────────────────────────────
private boolean isAdmin(String role) {
return isSuperAdmin(role) || "COMPANY_ADMIN".equals(role);
}
private boolean isSuperAdmin(String roleOrCode) {
return "*".equals(roleOrCode) || "SUPER_ADMIN".equals(roleOrCode);
}
}
@@ -2,6 +2,8 @@ package com.erp.crosstenant;
import com.erp.tenant.DbContextHolder;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
/**
* Cross-tenant 어드민 엔드포인트 진입 가드.
@@ -42,4 +44,16 @@ public final class CrossTenantContext {
public static boolean isMetaContext() {
return DbContextHolder.isMeta();
}
/**
* 관리 호스트(solution.invyone.com / admin.invyone.com / localhost / 베이스 도메인) 외엔 거절.
* cross-tenant 작업은 plane 격리상 관리 호스트에서만 허용. SuperAdminGuard.isTenantHost 와 동일 규칙.
*/
public static void requireManagementHost(HttpServletRequest request) {
String host = request.getHeader("Host");
if (com.erp.provisioning.SuperAdminGuard.isTenantHost(host)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"Cross-tenant operations are only available on the management site");
}
}
}
@@ -59,6 +59,12 @@ public class CrossTenantController {
*/
@GetMapping("/_active-companies")
public ResponseEntity<ApiResponse<Map<String, Object>>> activeCompaniesSmoke(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -92,6 +98,12 @@ public class CrossTenantController {
public ResponseEntity<ApiResponse<Map<String, Object>>> listUsers(
HttpServletRequest request,
@RequestParam Map<String, Object> queryParams) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -173,6 +185,12 @@ public class CrossTenantController {
Map<String, Object> queryParams,
String mapperId,
boolean wrapSearchWithPercent) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -39,6 +39,12 @@ public class CrossTenantDeptController {
public ResponseEntity<Map<String, Object>> listDepartments(
HttpServletRequest request,
@RequestParam("company_code") String companyCode) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(errorBody(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(errorBody("super_admin_required", request.getRequestURI()));
@@ -1,6 +1,7 @@
package com.erp.crosstenant;
import com.erp.dto.ApiResponse;
import com.erp.provisioning.CompanyAuditLogService;
import com.erp.service.RoleService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@@ -33,6 +34,7 @@ public class CrossTenantRoleController {
private final CrossTenantExecutor executor;
private final RoleService roleService;
private final CompanyAuditLogService auditLogService;
// ── 권한 그룹 CRUD ──────────────────────────────────────────────
@@ -49,6 +51,7 @@ public class CrossTenantRoleController {
if (g != null) return g;
String targetCompany = (String) body.get("company_code");
String actorId = (String) request.getAttribute("user_id");
try {
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
Map<String, Object> params = new HashMap<>(body);
@@ -62,6 +65,10 @@ public class CrossTenantRoleController {
}
return roleService.createRoleGroup(params);
});
auditLogService.log(targetCompany, actorId,
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
(String) body.get("auth_code"),
auditDetails(request, null));
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(result, "권한 그룹 생성 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -81,6 +88,7 @@ public class CrossTenantRoleController {
if (g != null) return g;
String targetCompany = (String) body.get("company_code");
String actorId = (String) request.getAttribute("user_id");
try {
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
Map<String, Object> params = new HashMap<>(body);
@@ -94,6 +102,10 @@ public class CrossTenantRoleController {
}
return roleService.updateRoleGroup(params);
});
auditLogService.log(targetCompany, actorId,
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
id,
auditDetails(request, id));
return ResponseEntity.ok(ApiResponse.success(result, "권한 그룹 수정 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -111,12 +123,17 @@ public class CrossTenantRoleController {
ResponseEntity<ApiResponse<Void>> g = guardVoid(request);
if (g != null) return g;
String actorId = (String) request.getAttribute("user_id");
try {
executor.runInCompany(companyCode, () -> {
Map<String, Object> p = new HashMap<>();
p.put("objid", id);
roleService.deleteRoleGroup(p);
});
auditLogService.log(companyCode, actorId,
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
id,
auditDetails(request, id));
return ResponseEntity.ok(ApiResponse.success(null, "권한 그룹 삭제 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -266,6 +283,12 @@ public class CrossTenantRoleController {
// ── 가드 헬퍼 (응답 타입별로 3가지 — Map/Void/List) ────────
private ResponseEntity<ApiResponse<Map<String, Object>>> guardMap(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -278,6 +301,12 @@ public class CrossTenantRoleController {
}
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -290,6 +319,12 @@ public class CrossTenantRoleController {
}
private ResponseEntity<ApiResponse<List<Map<String, Object>>>> guardList(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -301,6 +336,14 @@ public class CrossTenantRoleController {
return null;
}
/** audit log details 기본 맵 생성 헬퍼. */
private Map<String, Object> auditDetails(HttpServletRequest request, String roleId) {
Map<String, Object> d = new HashMap<>();
d.put("host", request.getHeader("Host"));
if (roleId != null) d.put("role_id", roleId);
return d;
}
/** "Y"/"N"/null 정규화 — RoleController 의 동일 헬퍼 미러. */
private String asYn(Object raw) {
if (raw == null) return null;
@@ -1,6 +1,7 @@
package com.erp.crosstenant;
import com.erp.dto.ApiResponse;
import com.erp.provisioning.CompanyAuditLogService;
import com.erp.service.AdminService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@@ -36,6 +37,7 @@ public class CrossTenantUserController {
private final CrossTenantExecutor executor;
private final AdminService adminService;
private final CompanyAuditLogService auditLogService;
// ── 등록 / 수정 ─────────────────────────────────────────────────────
@@ -51,9 +53,14 @@ public class CrossTenantUserController {
if (guard != null) return guard;
String targetCompanyCode = (String) body.get("company_code");
String actorId = (String) request.getAttribute("user_id");
try {
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
() -> adminService.saveUser(body));
auditLogService.log(targetCompanyCode, actorId,
CompanyAuditLogService.ACTION_CT_USER_CREATE,
(String) body.get("user_id"),
auditDetails(request, (String) body.get("user_id")));
return ResponseEntity.ok(ApiResponse.success(result, "사용자 저장 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -116,6 +123,7 @@ public class CrossTenantUserController {
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
if (guard != null) return guard;
String actorId = (String) request.getAttribute("user_id");
try {
executor.runInCompany(companyCode, () -> {
Map<String, Object> existing = adminService.getUserInfo(userId);
@@ -124,6 +132,10 @@ public class CrossTenantUserController {
}
adminService.changeUserStatus(userId, "inactive");
});
auditLogService.log(companyCode, actorId,
CompanyAuditLogService.ACTION_CT_USER_DELETE,
userId,
auditDetails(request, userId));
return ResponseEntity.ok(ApiResponse.success(null, "사용자 삭제 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
@@ -166,9 +178,14 @@ public class CrossTenantUserController {
String targetCompanyCode = (String) body.get("company_code");
String userId = (String) body.get("user_id");
String actorId = (String) request.getAttribute("user_id");
try {
executor.runInCompany(targetCompanyCode, () ->
adminService.resetUserPassword(userId));
auditLogService.log(targetCompanyCode, actorId,
CompanyAuditLogService.ACTION_CT_PW_RESET,
userId,
auditDetails(request, userId));
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
@@ -260,6 +277,12 @@ public class CrossTenantUserController {
/** Map<String,Object> 응답용 가드 — null 이면 통과, 아니면 그대로 반환. */
private ResponseEntity<ApiResponse<Map<String, Object>>> guard(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -273,6 +296,12 @@ public class CrossTenantUserController {
/** Void 응답용 가드 (제네릭만 다름). */
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -283,4 +312,12 @@ public class CrossTenantUserController {
}
return null;
}
/** audit log details 기본 맵 생성 헬퍼. */
private Map<String, Object> auditDetails(HttpServletRequest request, String targetUserId) {
Map<String, Object> d = new HashMap<>();
d.put("host", request.getHeader("Host"));
if (targetUserId != null) d.put("target_user_id", targetUserId);
return d;
}
}
@@ -183,7 +183,130 @@ public class StartupSchemaMigrator {
// conditional 매핑(when/then/default) 규칙 저장용.
// direct/fixed 매핑은 NULL. 메타 DB 는 Flyway V021 로도 적용되지만
// 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
"ALTER TABLE BATCH_MAPPINGS ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB"
"ALTER TABLE BATCH_MAPPINGS ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB",
// V022 / RUN_088: 부서별 다중 관리자(결재/부서/조직장) 매핑 테이블.
// 기존 DEPT_INFO.APPROVAL_MANAGER/DEPT_MANAGER 단일 컬럼 → 매핑 테이블로 다중화.
// role: 'approval' | 'dept' | 'org_leader'. 부서 hard-delete 시 CASCADE 로 정리.
// 메타 DB 는 Flyway V022 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
"""
CREATE TABLE IF NOT EXISTS DEPT_MANAGERS (
DEPT_CODE VARCHAR(1024) NOT NULL,
USER_ID VARCHAR(50) NOT NULL,
ROLE VARCHAR(20) NOT NULL,
SORT_ORDER INTEGER NOT NULL DEFAULT 1,
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (DEPT_CODE, USER_ID, ROLE),
CONSTRAINT chk_dept_managers_role
CHECK (ROLE IN ('approval', 'dept', 'org_leader')),
CONSTRAINT fk_dept_managers_dept
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
)
""",
"CREATE INDEX IF NOT EXISTS idx_dept_managers_role ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER)",
// V023 / RUN_089: MENU_INFO 에 IS_SOLUTION_ONLY 컬럼 추가.
// 솔루션 관리 호스트(solution.invyone.com 등) 에서만 노출되는 메뉴 플래그.
// 테넌트 사이트에선 mapper SQL 단계에서 제외. 메타 DB 는 Flyway V023 으로도 적용되지만
// 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
"ALTER TABLE MENU_INFO ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL",
// V023 데이터 동기화: 솔루션 전용 메뉴 마킹.
// 회사관리 / 회사 프로비저닝 / 감사로그는 관리 호스트에서만 노출돼야 함.
// 이미 TRUE 인 행은 그대로 두기 위해 false 인 행만 갱신.
"""
UPDATE MENU_INFO
SET IS_SOLUTION_ONLY = TRUE
WHERE IS_SOLUTION_ONLY = FALSE
AND MENU_URL IN (
'/admin/sysMng/subdomainList',
'/admin/userMng/companyList',
'/admin/audit-log'
)
""",
// V024 / RUN_089: TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO rename.
// 5/15 common-code 재설계(commit 2348800e) 가 mapper SQL 의 컬럼 참조명만
// 바꾸고 DB rename 을 빠뜨려, 테이블타입관리 컬럼 조회 API 가 500 반환.
// PostgreSQL 은 RENAME COLUMN 에 IF EXISTS 가 없어서 DO 블록으로 멱등 처리:
// - CODE_CATEGORY 만 있는 기존 테넌트: rename 수행
// - 이미 CODE_INFO 인 신규 테넌트: no-op
// - 둘 다 있거나 둘 다 없는 비정상 상태: no-op (방어적)
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'table_type_columns'
AND column_name = 'code_category'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'table_type_columns'
AND column_name = 'code_info'
) THEN
ALTER TABLE TABLE_TYPE_COLUMNS
RENAME COLUMN CODE_CATEGORY TO CODE_INFO;
END IF;
END $$
""",
// V025 / RUN_090 (1) TABLE_TYPE_COLUMNS 중복 행 정리.
// PK 가 id 단일 (varchar) 인데 (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) 에는
// UNIQUE 가 없어서 같은 키로 row 가 여러 개 INSERT 된 이력이 있음.
// 메타 DB 실측: 35K rows 중 2 그룹 4 row 가 중복. 그 그룹들은 동일 데이터를
// updated_date NULL 짜리 옛 row 와 2026-03-16 마지막 갱신 row 가 공존하는 형태.
// 가장 최근 (updated_date DESC NULLS LAST, id::bigint DESC) 행만 남기고 제거.
// 테넌트 DB 들은 실측상 중복 없음 → DELETE 0건. 멱등 (재실행해도 변화 없음).
"""
DELETE FROM TABLE_TYPE_COLUMNS
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE
ORDER BY UPDATED_DATE DESC NULLS LAST,
id::bigint DESC
) AS rn
FROM TABLE_TYPE_COLUMNS
) r
WHERE r.rn > 1
)
""",
// V025 / RUN_090 (2) ON CONFLICT 매칭용 UNIQUE INDEX 추가.
// mapper 의 upsertColumnSettings / upsertNullable / upsertUnique /
// upsertColumnInputType 모두 ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE)
// 를 쓰는데 DB 엔 매칭 unique 제약이 없어서 모든 쓰기 API 가 500.
// 인덱스 형태로 등록하면 ON CONFLICT 가 인식하고 ADD CONSTRAINT 식의
// IF NOT EXISTS 누락 문제도 회피.
"CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE)",
// V026 / RUN_091: TABLE_TYPE_COLUMNS.INPUT_TYPE legacy → 표준 8종 정리.
// 5/15 common-code 재설계가 화이트리스트를 8종으로 좁혔지만 운영 DB 의
// 옛 값(category 886, select 149, textarea 102, checkbox 55, radio 12,
// datetime 2, boolean 1) 을 정리하는 마이그레이션을 빠뜨림.
// 매핑:
// category / select / radio / checkbox / boolean → code (commonCode 통합 의도)
// textarea → text (single/multi line 구분 손실 — UI 동작 가벼움)
// datetime → date
// 메타 DB 1,207 row 갱신. 테넌트 DB 들은 비어있어 영향 0.
// WHERE 절로 멱등 (재실행 시 0 row).
"""
UPDATE TABLE_TYPE_COLUMNS
SET INPUT_TYPE = CASE INPUT_TYPE
WHEN 'category' THEN 'code'
WHEN 'select' THEN 'code'
WHEN 'radio' THEN 'code'
WHEN 'checkbox' THEN 'code'
WHEN 'boolean' THEN 'code'
WHEN 'textarea' THEN 'text'
WHEN 'datetime' THEN 'date'
END,
UPDATED_DATE = NOW()
WHERE INPUT_TYPE IN ('category','select','radio','checkbox','boolean','textarea','datetime')
"""
);
@EventListener(ApplicationReadyEvent.class)
@@ -206,9 +329,18 @@ public class StartupSchemaMigrator {
}
int ok = 0, fail = 0;
List<String> failedDbs = new java.util.ArrayList<>();
for (String db : tenantDbs) {
if (db == null || db.isBlank() || db.equalsIgnoreCase(metaDb)) continue;
if (applyTo(db, "tenant")) ok++; else fail++;
if (applyTo(db, "tenant")) {
ok++;
} else {
fail++;
failedDbs.add(db);
}
}
if (!failedDbs.isEmpty()) {
log.error("[SchemaMigrator] 마이그레이션 실패 테넌트 DB ({}건): {}", failedDbs.size(), failedDbs);
}
log.info("[SchemaMigrator] done — meta=done, tenants ok={}, fail={}", ok, fail);
}
@@ -40,6 +40,12 @@ public class CompanyAuditLogService {
public static final String ACTION_PW_RESET = "ADMIN_PASSWORD_RESET";
public static final String ACTION_RECOPY = "TEMPLATES_RECOPY";
// cross-tenant write 감사 액션
public static final String ACTION_CT_USER_CREATE = "CROSS_TENANT_USER_CREATE";
public static final String ACTION_CT_USER_DELETE = "CROSS_TENANT_USER_DELETE";
public static final String ACTION_CT_PW_RESET = "CROSS_TENANT_PASSWORD_RESET";
public static final String ACTION_CT_ROLE_UPDATE = "CROSS_TENANT_ROLE_UPDATE";
private final SqlSession sqlSession;
private final ObjectMapper objectMapper;
@@ -5,12 +5,9 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.security.SecureRandom;
import java.util.Arrays;
@@ -40,13 +37,7 @@ public class ProvisioningController {
private final ProvisioningRegistry registry;
private final SqlSession sqlSession;
private final CompanyStatsService statsService;
/**
* 프로덕션 배포 시엔 반드시 true 로. 개발 중엔 JWT 없는 curl 테스트를 허용하기 위해 false 기본.
* 환경변수: TENANT_PROVISIONING_REQUIRE_SUPER_ADMIN=true
*/
@Value("${tenant.provisioning.require-super-admin:false}")
private boolean requireSuperAdmin;
private final SuperAdminGuard guard;
@GetMapping("/table-groups")
public ResponseEntity<List<Map<String, Object>>> tableGroups(HttpServletRequest request) {
@@ -208,23 +199,11 @@ public class ProvisioningController {
}
// ------------------------------------------------------------------
// 권한 체크
//
// 현재 `/api/**` 가 permitAll 이라 Controller 레벨에서 수동 검증.
// JWT 가 있으면 JwtAuthenticationFilter 가 request.getAttribute("user_type") 세팅.
// 개발 모드(requireSuperAdmin=false): JWT 없이도 통과 (curl 테스트용). 단 다른 role 은 차단.
// 프로덕션 모드(requireSuperAdmin=true): SUPER_ADMIN 아니면 모두 403.
// 권한 체크 — SuperAdminGuard 로 위임 (호스트 격리 + role 검증).
// CompanyMgmtController 와 동일한 가드를 공유.
// ------------------------------------------------------------------
private void enforceSuperAdmin(HttpServletRequest request) {
String userType = (String) request.getAttribute("user_type");
if ("SUPER_ADMIN".equals(userType)) return;
if (!requireSuperAdmin && userType == null) {
log.warn("[Provisioning] anonymous access allowed in dev mode (set " +
"tenant.provisioning.require-super-admin=true in production)");
return;
}
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "SUPER_ADMIN only");
guard.enforce(request);
}
// --- Validation helpers ---
@@ -1,5 +1,6 @@
package com.erp.provisioning;
import com.erp.tenant.ReservedSubdomains;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -7,9 +8,14 @@ import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.util.regex.Pattern;
/**
* `/api/admin/provisioning/*` 계열 엔드포인트 공통 권한 가드.
*
* - 호스트 격리: 테넌트 서브도메인(qnc.invyone.com 등)에서 호출하면 무조건 403.
* 프로비저닝 plane 은 solution/admin/localhost/베이스 도메인 같은 "관리 호스트" 에서만 동작.
* (한 번 SUPER_ADMIN 토큰이 새도 임의의 테넌트 사이트에서는 회사를 만들 수 없게 막음)
* - 프로덕션 (tenant.provisioning.require-super-admin=true): SUPER_ADMIN 만 통과
* - 개발 (기본값 false): JWT 없어도 통과 (curl 테스트). 다른 role 은 여전히 차단
*
@@ -19,10 +25,22 @@ import org.springframework.web.server.ResponseStatusException;
@Slf4j
public class SuperAdminGuard {
private static final Pattern IPV4 = Pattern.compile("^\\d{1,3}(\\.\\d{1,3}){3}$");
@Value("${tenant.provisioning.require-super-admin:false}")
private boolean requireSuperAdmin;
public void enforce(HttpServletRequest request) {
// 1) 호스트 격리 — role 보다 먼저 체크. 어떤 role 도 테넌트 사이트에서는 통과 못 함.
String host = request.getHeader("Host");
if (isTenantHost(host)) {
log.warn("[ProvisioningGuard] blocked tenant-host call: host={} path={} userType={}",
host, request.getRequestURI(), request.getAttribute("user_type"));
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"Provisioning is only available on the management site");
}
// 2) role 체크
String userType = (String) request.getAttribute("user_type");
if ("SUPER_ADMIN".equals(userType)) return;
if (!requireSuperAdmin && userType == null) {
@@ -37,4 +55,40 @@ public class SuperAdminGuard {
String userId = (String) request.getAttribute("user_id");
return userId == null ? "dev-anonymous" : userId;
}
/** 감사 로그에 기록할 호스트 (Host 헤더 그대로, 포트 포함). null safe. */
public String requestHost(HttpServletRequest request) {
String host = request.getHeader("Host");
return host == null ? "" : host;
}
/**
* "테넌트 사이트에서 온 요청인지" 판단. SubdomainResolverFilter.extractSubdomain 와 같은 규칙.
* - localhost / IP / 베이스 도메인 → false (관리 호스트)
* - solution.invyone.com 등 예약어 prefix → false (관리 호스트)
* - qnc.invyone.com / test02.invyone.com 같은 실제 테넌트 → true
*/
public static boolean isTenantHost(String host) {
if (host == null || host.isBlank()) return false;
int colon = host.indexOf(':');
if (colon != -1) host = host.substring(0, colon);
host = host.toLowerCase();
if ("localhost".equals(host)) return false;
if (IPV4.matcher(host).matches()) return false;
String[] parts = host.split("\\.");
if (parts.length == 2) {
// {sub}.localhost (dev)
if (!"localhost".equals(parts[1])) return false;
String first = parts[0];
if (first.isEmpty()) return false;
return !ReservedSubdomains.VALUES.contains(first);
}
if (parts.length < 3) return false;
String first = parts[0];
return !ReservedSubdomains.VALUES.contains(first);
}
}
@@ -1,270 +0,0 @@
package com.erp.service;
import com.erp.common.BaseService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
@Service
@Slf4j
public class CascadingAutoFillService extends BaseService {
private static final String NS = "cascadingAutoFill.";
@Autowired
private CommonService commonService;
@Autowired
private JdbcTemplate jdbcTemplate;
public Map<String, Object> getCascadingAutoFillGroupList(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
commonService.applyPagination(params);
int totalCount = sqlSession.selectOne(NS + "getCascadingAutoFillGroupListCnt", params);
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCascadingAutoFillGroupList", params);
return commonService.buildListResponse(list, totalCount, params);
}
public Map<String, Object> getCascadingAutoFillGroupDetail(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
Map<String, Object> group = sqlSession.selectOne(NS + "getCascadingAutoFillGroupByCode", params);
if (group == null) return null;
Map<String, Object> mappingParams = new HashMap<>();
mappingParams.put("group_code", params.get("group_code"));
mappingParams.put("company_code", group.get("company_code"));
List<Map<String, Object>> mappings = sqlSession.selectList(NS + "getCascadingAutoFillMappingList", mappingParams);
Map<String, Object> result = new HashMap<>(group);
result.put("mappings", mappings);
return result;
}
@Transactional
public Map<String, Object> insertCascadingAutoFillGroup(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String companyCode = (String) params.get("company_code");
// Generate group code: AF_{timestamp_base36}_{count:03d}
Map<String, Object> countParams = new HashMap<>();
countParams.put("company_code", companyCode);
Number cntNum = sqlSession.selectOne(NS + "getCascadingAutoFillGroupCount", countParams);
int count = (cntNum != null ? cntNum.intValue() : 0) + 1;
String timestamp = Long.toString(System.currentTimeMillis(), 36).toUpperCase();
String suffix = timestamp.substring(Math.max(0, timestamp.length() - 4));
String groupCode = "AF_" + suffix + "_" + String.format("%03d", count);
params.put("group_code", groupCode);
if (params.get("is_active") == null) {
params.put("is_active", "Y");
}
sqlSession.insert(NS + "insertCascadingAutoFillGroup", params);
// Insert mappings
Object mappingsObj = params.get("mappings");
if (mappingsObj instanceof List) {
List<?> mappings = (List<?>) mappingsObj;
for (int i = 0; i < mappings.size(); i++) {
Object m = mappings.get(i);
if (m instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> mapping = (Map<String, Object>) m;
Map<String, Object> mp = new HashMap<>(mapping);
mp.put("group_code", groupCode);
mp.put("company_code", companyCode);
if (mp.get("is_editable") == null) mp.put("is_editable", "Y");
if (mp.get("is_required") == null) mp.put("is_required", "N");
if (mp.get("sort_order") == null) mp.put("sort_order", i + 1);
sqlSession.insert(NS + "insertCascadingAutoFillMapping", mp);
}
}
}
return params;
}
@Transactional
public Map<String, Object> updateCascadingAutoFillGroup(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String groupCode = (String) params.get("group_code");
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingAutoFillGroupByCode", params);
if (existing == null) return null;
String actualCompanyCode = (String) existing.get("company_code");
params.put("company_code", actualCompanyCode);
sqlSession.update(NS + "updateCascadingAutoFillGroup", params);
// Replace mappings if provided
if (params.containsKey("mappings")) {
Map<String, Object> delParams = new HashMap<>();
delParams.put("group_code", groupCode);
delParams.put("company_code", actualCompanyCode);
sqlSession.delete(NS + "deleteCascadingAutoFillMappings", delParams);
Object mappingsObj = params.get("mappings");
if (mappingsObj instanceof List) {
List<?> mappings = (List<?>) mappingsObj;
for (int i = 0; i < mappings.size(); i++) {
Object m = mappings.get(i);
if (m instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> mapping = (Map<String, Object>) m;
Map<String, Object> mp = new HashMap<>(mapping);
mp.put("group_code", groupCode);
mp.put("company_code", actualCompanyCode);
if (mp.get("is_editable") == null) mp.put("is_editable", "Y");
if (mp.get("is_required") == null) mp.put("is_required", "N");
if (mp.get("sort_order") == null) mp.put("sort_order", i + 1);
sqlSession.insert(NS + "insertCascadingAutoFillMapping", mp);
}
}
}
}
return params;
}
@Transactional
public boolean deleteCascadingAutoFillGroup(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingAutoFillGroupByCode", params);
if (existing == null) return false;
String groupCode = (String) params.get("group_code");
String companyCode = (String) existing.get("company_code");
Map<String, Object> delParams = new HashMap<>();
delParams.put("group_code", groupCode);
delParams.put("company_code", companyCode);
sqlSession.delete(NS + "deleteCascadingAutoFillMappings", delParams);
sqlSession.delete(NS + "deleteCascadingAutoFillGroup", delParams);
return true;
}
public List<Map<String, Object>> getAutoFillMasterOptions(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String companyCode = (String) params.get("company_code");
Map<String, Object> groupParams = new HashMap<>(params);
groupParams.put("is_active", "Y");
Map<String, Object> group = sqlSession.selectOne(NS + "getCascadingAutoFillGroupByCode", groupParams);
if (group == null) return null;
String masterTable = sanitizeIdentifier((String) group.get("master_table"));
String masterValueColumn = sanitizeIdentifier((String) group.get("master_value_column"));
Object labelColObj = group.get("master_label_column");
String labelColumn = (labelColObj != null && !labelColObj.toString().isEmpty())
? sanitizeIdentifier(labelColObj.toString()) : masterValueColumn;
StringBuilder sql = new StringBuilder();
sql.append("SELECT ").append(masterValueColumn).append(" AS value, ")
.append(labelColumn).append(" AS label")
.append(" FROM ").append(masterTable)
.append(" WHERE 1=1");
List<Object> sqlParams = new ArrayList<>();
if (!"*".equals(companyCode) && hasColumn(masterTable, "company_code")) {
sql.append(" AND company_code = ?");
sqlParams.add(companyCode);
}
sql.append(" ORDER BY ").append(labelColumn);
return jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
}
public Map<String, Object> getAutoFillData(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String groupCode = (String) params.get("group_code");
String masterValue = (String) params.get("master_value");
String companyCode = (String) params.get("company_code");
Map<String, Object> groupParams = new HashMap<>(params);
groupParams.put("is_active", "Y");
Map<String, Object> group = sqlSession.selectOne(NS + "getCascadingAutoFillGroupByCode", groupParams);
if (group == null) return null;
String actualCompanyCode = (String) group.get("company_code");
Map<String, Object> mappingParams = new HashMap<>();
mappingParams.put("group_code", groupCode);
mappingParams.put("company_code", actualCompanyCode);
List<Map<String, Object>> mappings = sqlSession.selectList(NS + "getCascadingAutoFillMappingList", mappingParams);
if (mappings.isEmpty()) {
Map<String, Object> empty = new HashMap<>();
empty.put("data", new HashMap<>());
empty.put("mappings", new ArrayList<>());
return empty;
}
String masterTable = sanitizeIdentifier((String) group.get("master_table"));
String masterValueColumn = sanitizeIdentifier((String) group.get("master_value_column"));
String sourceColumns = mappings.stream()
.map(m -> sanitizeIdentifier((String) m.get("source_column")))
.collect(Collectors.joining(", "));
StringBuilder sql = new StringBuilder();
sql.append("SELECT ").append(sourceColumns)
.append(" FROM ").append(masterTable)
.append(" WHERE ").append(masterValueColumn).append(" = ?");
List<Object> sqlParams = new ArrayList<>();
sqlParams.add(masterValue);
if (!"*".equals(companyCode) && hasColumn(masterTable, "company_code")) {
sql.append(" AND company_code = ?");
sqlParams.add(companyCode);
}
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
Map<String, Object> dataRow = rows.isEmpty() ? null : rows.get(0);
Map<String, Object> autoFillData = new LinkedHashMap<>();
List<Map<String, Object>> mappingInfo = new ArrayList<>();
for (Map<String, Object> mapping : mappings) {
String sourceColumn = (String) mapping.get("source_column");
String targetField = (String) mapping.get("target_field");
Object sourceValue = (dataRow != null) ? dataRow.get(sourceColumn) : null;
Object defaultVal = mapping.get("default_value");
Object finalValue = (sourceValue != null) ? sourceValue : defaultVal;
autoFillData.put(targetField, finalValue);
Map<String, Object> info = new LinkedHashMap<>();
info.put("target_field", targetField);
info.put("target_label", mapping.get("target_label"));
info.put("value", finalValue);
info.put("is_editable", "Y".equals(mapping.get("is_editable")));
info.put("is_required", "Y".equals(mapping.get("is_required")));
mappingInfo.add(info);
}
Map<String, Object> result = new HashMap<>();
result.put("data", autoFillData);
result.put("mappings", mappingInfo);
return result;
}
private String sanitizeIdentifier(String identifier) {
if (identifier == null || !identifier.matches("[a-zA-Z0-9_.]+")) {
throw new IllegalArgumentException("Invalid SQL identifier: " + identifier);
}
return identifier;
}
private boolean hasColumn(String tableName, String columnName) {
try {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?",
Integer.class, tableName, columnName);
return count != null && count > 0;
} catch (Exception e) {
return false;
}
}
}
@@ -1,198 +0,0 @@
package com.erp.service;
import com.erp.common.BaseService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
@Service
@Slf4j
public class CascadingConditionService extends BaseService {
private static final String NS = "cascadingCondition.";
private static final String NS_RELATION = "cascadingRelation.";
@Autowired
private CommonService commonService;
@Autowired
private JdbcTemplate jdbcTemplate;
public Map<String, Object> getCascadingConditionList(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
commonService.applyPagination(params);
int totalCount = sqlSession.selectOne(NS + "getCascadingConditionListCnt", params);
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCascadingConditionList", params);
return commonService.buildListResponse(list, totalCount, params);
}
public Map<String, Object> getCascadingConditionInfo(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
return sqlSession.selectOne(NS + "getCascadingConditionInfo", params);
}
@Transactional
public Map<String, Object> insertCascadingCondition(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
sqlSession.insert(NS + "insertCascadingCondition", params);
return params;
}
@Transactional
public Map<String, Object> updateCascadingCondition(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
sqlSession.update(NS + "updateCascadingCondition", params);
return params;
}
@Transactional
public Map<String, Object> deleteCascadingCondition(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
sqlSession.delete(NS + "deleteCascadingCondition", params);
return params;
}
public Map<String, Object> getFilteredOptions(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String companyCode = (String) params.get("company_code");
String conditionFieldValue = params.get("condition_field_value") != null
? String.valueOf(params.get("condition_field_value")) : null;
String parentValue = params.get("parent_value") != null
? String.valueOf(params.get("parent_value")) : null;
// 1. 연쇄 관계 조회
Map<String, Object> relation = sqlSession.selectOne(NS_RELATION + "get_cascading_relation_by_code", params);
if (relation == null) {
Map<String, Object> empty = new LinkedHashMap<>();
empty.put("data", Collections.emptyList());
empty.put("applied_condition", null);
return empty;
}
// 2. 조건 규칙 조회 (우선순위 내림차순)
List<Map<String, Object>> conditions = sqlSession.selectList(NS + "getCascadingConditionsByRelationCode", params);
// 3. 매칭 조건 탐색
Map<String, Object> matchedCondition = null;
if (conditionFieldValue != null) {
for (Map<String, Object> cond : conditions) {
String operator = (String) cond.get("condition_operator");
String expectedValue = (String) cond.get("condition_value");
if (evaluateCondition(conditionFieldValue, operator, expectedValue)) {
matchedCondition = cond;
break;
}
}
}
// 4. 동적 옵션 쿼리 생성
String childTable = String.valueOf(relation.get("child_table"));
String valueCol = String.valueOf(relation.get("child_value_column"));
String labelCol = String.valueOf(relation.get("child_label_column"));
Object filterColObj = relation.get("child_filter_column");
Object orderColObj = relation.get("child_order_column");
String filterCol = filterColObj != null ? String.valueOf(filterColObj) : null;
String orderCol = orderColObj != null ? String.valueOf(orderColObj) : null;
String orderDir = relation.get("child_order_direction") != null
? String.valueOf(relation.get("child_order_direction")) : "ASC";
StringBuilder sql = new StringBuilder("SELECT ")
.append(valueCol).append(" as value, ")
.append(labelCol).append(" as label FROM ")
.append(childTable).append(" WHERE 1=1");
List<Object> sqlParams = new ArrayList<>();
if (parentValue != null && filterCol != null && !filterCol.isEmpty()) {
sql.append(" AND ").append(filterCol).append(" = ?");
sqlParams.add(parentValue);
}
if (matchedCondition != null) {
String condFilterColumn = (String) matchedCondition.get("filter_column");
String condFilterValues = (String) matchedCondition.get("filter_values");
if (condFilterColumn != null && condFilterValues != null) {
String[] values = condFilterValues.split(",");
String placeholders = Arrays.stream(values).map(v -> "?").collect(Collectors.joining(","));
sql.append(" AND ").append(condFilterColumn).append(" IN (").append(placeholders).append(")");
for (String v : values) sqlParams.add(v.trim());
}
}
// 멀티테넌시 필터 (child_table에 company_code 컬럼이 있는 경우만)
if (companyCode != null && !"*".equals(companyCode)) {
try {
Integer cnt = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = 'company_code'",
Integer.class, childTable);
if (cnt != null && cnt > 0) {
sql.append(" AND company_code = ?");
sqlParams.add(companyCode);
}
} catch (Exception e) {
log.warn("company_code 컬럼 확인 실패: {}", e.getMessage());
}
}
sql.append(" ORDER BY ");
if (orderCol != null && !orderCol.isEmpty()) {
sql.append(orderCol).append(" ").append(orderDir);
} else {
sql.append(labelCol);
}
List<Map<String, Object>> options = jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", options);
if (matchedCondition != null) {
Map<String, Object> applied = new HashMap<>();
applied.put("condition_id", matchedCondition.get("condition_id"));
applied.put("condition_name", matchedCondition.get("condition_name"));
result.put("applied_condition", applied);
} else {
result.put("applied_condition", null);
}
return result;
}
private boolean evaluateCondition(String actualValue, String operator, String expectedValue) {
if (operator == null || actualValue == null || expectedValue == null) return false;
String actual = actualValue.toLowerCase().trim();
String expected = expectedValue.toLowerCase().trim();
return switch (operator.toUpperCase()) {
case "EQ", "=", "EQUALS" -> actual.equals(expected);
case "NEQ", "!=", "<>", "NOT_EQUALS" -> !actual.equals(expected);
case "CONTAINS", "LIKE" -> actual.contains(expected);
case "NOT_CONTAINS", "NOT_LIKE" -> !actual.contains(expected);
case "STARTS_WITH" -> actual.startsWith(expected);
case "ENDS_WITH" -> actual.endsWith(expected);
case "IN" -> Arrays.stream(expected.split(",")).map(String::trim).anyMatch(v -> v.equals(actual));
case "NOT_IN" -> Arrays.stream(expected.split(",")).map(String::trim).noneMatch(v -> v.equals(actual));
case "GT", ">" -> {
try { yield Double.parseDouble(actual) > Double.parseDouble(expected); }
catch (NumberFormatException e) { yield false; }
}
case "GTE", ">=" -> {
try { yield Double.parseDouble(actual) >= Double.parseDouble(expected); }
catch (NumberFormatException e) { yield false; }
}
case "LT", "<" -> {
try { yield Double.parseDouble(actual) < Double.parseDouble(expected); }
catch (NumberFormatException e) { yield false; }
}
case "LTE", "<=" -> {
try { yield Double.parseDouble(actual) <= Double.parseDouble(expected); }
catch (NumberFormatException e) { yield false; }
}
case "IS_NULL", "NULL" -> actual.isEmpty() || "null".equals(actual) || "undefined".equals(actual);
case "IS_NOT_NULL", "NOT_NULL" -> !actual.isEmpty() && !"null".equals(actual) && !"undefined".equals(actual);
default -> {
log.warn("알 수 없는 연산자: {}", operator);
yield false;
}
};
}
}
@@ -1,251 +0,0 @@
package com.erp.service;
import com.erp.common.BaseService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service
@RequiredArgsConstructor
@Slf4j
public class CascadingHierarchyService extends BaseService {
private static final String NS = "cascadingHierarchy.";
private final CommonService commonService;
private final JdbcTemplate jdbcTemplate;
public Map<String, Object> getCascadingHierarchyGroupList(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
commonService.applyPagination(params);
int totalCount = sqlSession.selectOne(NS + "getCascadingHierarchyGroupListCnt", params);
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCascadingHierarchyGroupList", params);
return commonService.buildListResponse(list, totalCount, params);
}
public Map<String, Object> getCascadingHierarchyGroupDetail(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
Map<String, Object> group = sqlSession.selectOne(NS + "getCascadingHierarchyGroupByCode", params);
if (group == null) return null;
Map<String, Object> levelParams = new HashMap<>();
levelParams.put("group_code", params.get("group_code"));
levelParams.put("company_code", group.get("company_code"));
List<Map<String, Object>> levels = sqlSession.selectList(NS + "getCascadingHierarchyLevelList", levelParams);
Map<String, Object> result = new HashMap<>(group);
result.put("levels", levels);
return result;
}
@Transactional
public Map<String, Object> insertCascadingHierarchyGroup(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String companyCode = (String) params.get("company_code");
String userId = (String) params.getOrDefault("user_id", "system");
// Generate group code: HG_{timestamp_base36}_{count:03d}
Map<String, Object> countParams = new HashMap<>();
countParams.put("company_code", companyCode);
Number cntNum = sqlSession.selectOne(NS + "getCascadingHierarchyGroupCount", countParams);
int count = (cntNum != null ? cntNum.intValue() : 0) + 1;
String timestamp = Long.toString(System.currentTimeMillis(), 36).toUpperCase();
String suffix = timestamp.substring(Math.max(0, timestamp.length() - 4));
String groupCode = "HG_" + suffix + "_" + String.format("%03d", count);
params.put("group_code", groupCode);
params.put("created_by", userId);
if (params.get("hierarchy_type") == null) params.put("hierarchy_type", "MULTI_TABLE");
if (params.get("is_fixed_levels") == null) params.put("is_fixed_levels", "Y");
if (params.get("empty_message") == null) params.put("empty_message", "선택해주세요");
if (params.get("no_options_message") == null) params.put("no_options_message", "옵션이 없습니다");
if (params.get("loading_message") == null) params.put("loading_message", "로딩 중...");
sqlSession.insert(NS + "insertCascadingHierarchyGroup", params);
// Insert levels for MULTI_TABLE type
Object levelsObj = params.get("levels");
if ("MULTI_TABLE".equals(params.get("hierarchy_type")) && levelsObj instanceof List) {
List<?> levels = (List<?>) levelsObj;
for (Object l : levels) {
if (l instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> level = (Map<String, Object>) l;
Map<String, Object> lp = new HashMap<>(level);
lp.put("group_code", groupCode);
lp.put("company_code", companyCode);
if (lp.get("order_direction") == null) lp.put("order_direction", "ASC");
if (lp.get("is_required") == null) lp.put("is_required", "Y");
if (lp.get("is_searchable") == null) lp.put("is_searchable", "N");
if (lp.get("placeholder") == null && lp.get("level_name") != null) {
lp.put("placeholder", lp.get("level_name") + " 선택");
}
sqlSession.insert(NS + "insertCascadingHierarchyLevel", lp);
}
}
}
return params;
}
@Transactional
public Map<String, Object> updateCascadingHierarchyGroup(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
params.put("updated_by", params.getOrDefault("user_id", "system"));
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingHierarchyGroupByCode", params);
if (existing == null) return null;
params.put("company_code", existing.get("company_code"));
sqlSession.update(NS + "updateCascadingHierarchyGroup", params);
return params;
}
@Transactional
public boolean deleteCascadingHierarchyGroup(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingHierarchyGroupByCode", params);
if (existing == null) return false;
String groupCode = (String) params.get("group_code");
String companyCode = (String) existing.get("company_code");
Map<String, Object> delParams = new HashMap<>();
delParams.put("group_code", groupCode);
delParams.put("company_code", companyCode);
sqlSession.delete(NS + "deleteCascadingHierarchyLevels", delParams);
sqlSession.delete(NS + "deleteCascadingHierarchyGroup", delParams);
return true;
}
@Transactional
public Map<String, Object> addCascadingHierarchyLevel(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String groupCode = (String) params.get("group_code");
Map<String, Object> groupParams = new HashMap<>();
groupParams.put("group_code", groupCode);
groupParams.put("company_code", params.get("company_code"));
Map<String, Object> group = sqlSession.selectOne(NS + "getCascadingHierarchyGroupByCode", groupParams);
if (group == null) return null;
params.put("company_code", group.get("company_code"));
if (params.get("order_direction") == null) params.put("order_direction", "ASC");
if (params.get("is_required") == null) params.put("is_required", "Y");
if (params.get("is_searchable") == null) params.put("is_searchable", "N");
if (params.get("placeholder") == null && params.get("level_name") != null) {
params.put("placeholder", params.get("level_name") + " 선택");
}
sqlSession.insert(NS + "insertCascadingHierarchyLevel", params);
return params;
}
@Transactional
public Map<String, Object> updateCascadingHierarchyLevel(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingHierarchyLevelInfo", params);
if (existing == null) return null;
sqlSession.update(NS + "updateCascadingHierarchyLevel", params);
return params;
}
@Transactional
public boolean deleteCascadingHierarchyLevel(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingHierarchyLevelInfo", params);
if (existing == null) return false;
sqlSession.delete(NS + "deleteCascadingHierarchyLevel", params);
return true;
}
public Map<String, Object> getLevelOptions(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String companyCode = (String) params.get("company_code");
Map<String, Object> level = sqlSession.selectOne(NS + "getCascadingHierarchyLevelForOptions", params);
if (level == null) return null;
String tableName = sanitizeIdentifier((String) level.get("table_name"));
String valueColumn = sanitizeIdentifier((String) level.get("value_column"));
String labelColumn = sanitizeIdentifier((String) level.get("label_column"));
StringBuilder sql = new StringBuilder();
sql.append("SELECT ").append(valueColumn).append(" AS value, ")
.append(labelColumn).append(" AS label")
.append(" FROM ").append(tableName)
.append(" WHERE 1=1");
List<Object> sqlParams = new ArrayList<>();
// Parent value filter (level 2+)
Object parentValue = params.get("parent_value");
Object parentKeyColumn = level.get("parent_key_column");
if (parentKeyColumn != null && !parentKeyColumn.toString().isEmpty() && parentValue != null) {
sql.append(" AND ").append(sanitizeIdentifier(parentKeyColumn.toString())).append(" = ?");
sqlParams.add(parentValue);
}
// Fixed filter
Object filterColumn = level.get("filter_column");
Object filterValue = level.get("filter_value");
if (filterColumn != null && !filterColumn.toString().isEmpty()
&& filterValue != null && !filterValue.toString().isEmpty()) {
sql.append(" AND ").append(sanitizeIdentifier(filterColumn.toString())).append(" = ?");
sqlParams.add(filterValue.toString());
}
// Multi-tenancy
if (!"*".equals(companyCode) && hasColumn(tableName, "company_code")) {
sql.append(" AND company_code = ?");
sqlParams.add(companyCode);
}
// Order
Object orderColumn = level.get("order_column");
if (orderColumn != null && !orderColumn.toString().isEmpty()) {
Object orderDir = level.get("order_direction");
String dir = (orderDir != null && "DESC".equalsIgnoreCase(orderDir.toString())) ? "DESC" : "ASC";
sql.append(" ORDER BY ").append(sanitizeIdentifier(orderColumn.toString())).append(" ").append(dir);
} else {
sql.append(" ORDER BY ").append(labelColumn);
}
List<Map<String, Object>> options = jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
Map<String, Object> levelInfo = new LinkedHashMap<>();
levelInfo.put("level_id", level.get("level_id"));
levelInfo.put("level_name", level.get("level_name"));
levelInfo.put("placeholder", level.get("placeholder"));
levelInfo.put("is_required", level.get("is_required"));
levelInfo.put("is_searchable", level.get("is_searchable"));
Map<String, Object> result = new HashMap<>();
result.put("data", options);
result.put("level_info", levelInfo);
return result;
}
private String sanitizeIdentifier(String identifier) {
if (identifier == null || !identifier.matches("[a-zA-Z0-9_.]+")) {
throw new IllegalArgumentException("Invalid SQL identifier: " + identifier);
}
return identifier;
}
private boolean hasColumn(String tableName, String columnName) {
try {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?",
Integer.class, tableName, columnName);
return count != null && count > 0;
} catch (Exception e) {
return false;
}
}
}
@@ -1,175 +0,0 @@
package com.erp.service;
import com.erp.common.BaseService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service
@RequiredArgsConstructor
@Slf4j
public class CascadingMutualExclusionService extends BaseService {
private static final String NS = "cascadingMutualExclusion.";
private final CommonService commonService;
private final JdbcTemplate jdbcTemplate;
public Map<String, Object> getCascadingMutualExclusionList(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
commonService.applyPagination(params);
int totalCount = sqlSession.selectOne(NS + "getCascadingMutualExclusionListCnt", params);
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCascadingMutualExclusionList", params);
return commonService.buildListResponse(list, totalCount, params);
}
public Map<String, Object> getCascadingMutualExclusionInfo(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
return sqlSession.selectOne(NS + "getCascadingMutualExclusionInfo", params);
}
@Transactional
public Map<String, Object> insertCascadingMutualExclusion(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
if (params.get("exclusion_type") == null) params.put("exclusion_type", "SAME_VALUE");
if (params.get("error_message") == null) params.put("error_message", "동일한 값을 선택할 수 없습니다");
// 배제 코드 자동 생성: EX_XXXX_NNN
String companyCode = (String) params.get("company_code");
Map<String, Object> countParams = new LinkedHashMap<>();
countParams.put("company_code", companyCode);
int count = sqlSession.selectOne(NS + "getCascadingMutualExclusionCount", countParams);
String ts = Long.toString(System.currentTimeMillis(), 36).toUpperCase();
ts = ts.substring(Math.max(0, ts.length() - 4));
params.put("exclusion_code", String.format("EX_%s_%03d", ts, count + 1));
sqlSession.insert(NS + "insertCascadingMutualExclusion", params);
return params;
}
@Transactional
public Map<String, Object> updateCascadingMutualExclusion(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
sqlSession.update(NS + "updateCascadingMutualExclusion", params);
return params;
}
@Transactional
public Map<String, Object> deleteCascadingMutualExclusion(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
sqlSession.delete(NS + "deleteCascadingMutualExclusion", params);
return params;
}
/**
* 상호 배제 검증: 선택한 값들 간 충돌 여부 확인 (SAME_VALUE 타입)
*/
public Map<String, Object> validateCascadingMutualExclusion(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
Map<String, Object> exclusion = sqlSession.selectOne(NS + "getCascadingMutualExclusionByCode", params);
if (exclusion == null) throw new NoSuchElementException("상호 배제 규칙을 찾을 수 없습니다.");
@SuppressWarnings("unchecked")
Map<String, Object> fieldValues = (Map<String, Object>) params.getOrDefault("field_values", Collections.emptyMap());
String fieldNamesStr = (String) exclusion.get("field_names");
String[] fields = fieldNamesStr != null ? fieldNamesStr.split(",") : new String[0];
List<String> values = new ArrayList<>();
for (String field : fields) {
Object v = fieldValues.get(field.trim());
if (v != null) values.add(v.toString());
}
boolean isValid = true;
String errorMessage = null;
List<String> conflictingFields = new ArrayList<>();
String exclusionType = (String) exclusion.getOrDefault("exclusion_type", "SAME_VALUE");
if ("SAME_VALUE".equals(exclusionType)) {
Set<String> seen = new LinkedHashSet<>();
boolean hasDuplicate = false;
for (String v : values) {
if (!seen.add(v)) { hasDuplicate = true; break; }
}
if (hasDuplicate) {
isValid = false;
errorMessage = (String) exclusion.get("error_message");
Map<String, List<String>> valueCounts = new LinkedHashMap<>();
for (String field : fields) {
Object v = fieldValues.get(field.trim());
if (v != null) {
valueCounts.computeIfAbsent(v.toString(), k -> new ArrayList<>()).add(field.trim());
}
}
for (List<String> fl : valueCounts.values()) {
if (fl.size() > 1) { conflictingFields = fl; break; }
}
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("is_valid", isValid);
result.put("error_message", isValid ? null : errorMessage);
result.put("conflicting_fields", conflictingFields);
return result;
}
/**
* 배제 옵션 조회: source_table에서 이미 선택된 값을 제외한 목록 반환
*/
public List<Map<String, Object>> getExcludedOptions(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String companyCode = (String) params.get("company_code");
Map<String, Object> exclusion = sqlSession.selectOne(NS + "getCascadingMutualExclusionByCode", params);
if (exclusion == null) throw new NoSuchElementException("상호 배제 규칙을 찾을 수 없습니다.");
String sourceTable = (String) exclusion.get("source_table");
String valueColumn = (String) exclusion.get("value_column");
String labelColumn = (String) exclusion.get("label_column");
if (labelColumn == null || labelColumn.isEmpty()) labelColumn = valueColumn;
boolean hasCompanyCode = hasColumn(sourceTable, "company_code");
List<Object> queryParams = new ArrayList<>();
StringBuilder sql = new StringBuilder();
sql.append("SELECT ")
.append(valueColumn).append(" AS value, ")
.append(labelColumn).append(" AS label")
.append(" FROM ").append(sourceTable)
.append(" WHERE 1=1");
if (hasCompanyCode && !"*".equals(companyCode)) {
sql.append(" AND company_code = ?");
queryParams.add(companyCode);
}
Object selectedValuesParam = params.get("selected_values");
if (selectedValuesParam != null) {
List<String> excludeValues = new ArrayList<>();
for (String v : selectedValuesParam.toString().split(",")) {
String trimmed = v.trim();
if (!trimmed.isEmpty()) excludeValues.add(trimmed);
}
if (!excludeValues.isEmpty()) {
String placeholders = String.join(", ", Collections.nCopies(excludeValues.size(), "?"));
sql.append(" AND ").append(valueColumn).append(" NOT IN (").append(placeholders).append(")");
queryParams.addAll(excludeValues);
}
}
sql.append(" ORDER BY ").append(labelColumn);
return jdbcTemplate.queryForList(sql.toString(), queryParams.toArray());
}
private boolean hasColumn(String tableName, String columnName) {
String sql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?";
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, tableName, columnName);
return count != null && count > 0;
}
}
@@ -1,193 +0,0 @@
package com.erp.service;
import com.erp.common.BaseService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service
@Slf4j
public class CascadingRelationService extends BaseService {
private static final String NS = "cascadingRelation.";
@Autowired
private CommonService commonService;
@Autowired
private JdbcTemplate jdbcTemplate;
public Map<String, Object> getCascadingRelationList(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
commonService.applyPagination(params);
int totalCount = sqlSession.selectOne(NS + "getCascadingRelationListCnt", params);
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCascadingRelationList", params);
return commonService.buildListResponse(list, totalCount, params);
}
public Map<String, Object> getCascadingRelationInfo(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
return sqlSession.selectOne(NS + "getCascadingRelationInfo", params);
}
public Map<String, Object> getCascadingRelationByCode(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
return sqlSession.selectOne(NS + "getCascadingRelationByCode", params);
}
@Transactional
public Map<String, Object> insertCascadingRelation(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
if (params.get("empty_parent_message") == null)
params.put("empty_parent_message", "상위 항목을 먼저 선택하세요");
if (params.get("no_options_message") == null)
params.put("no_options_message", "선택 가능한 항목이 없습니다");
if (params.get("loading_message") == null)
params.put("loading_message", "로딩 중...");
if (params.get("child_order_direction") == null)
params.put("child_order_direction", "ASC");
Object clearOnParentChange = params.get("clear_on_parent_change");
if (clearOnParentChange == null) {
params.put("clear_on_parent_change", "Y");
} else if (clearOnParentChange instanceof Boolean) {
params.put("clear_on_parent_change", Boolean.TRUE.equals(clearOnParentChange) ? "Y" : "N");
}
params.put("is_active", "Y");
sqlSession.insert(NS + "insertCascadingRelation", params);
return params;
}
@Transactional
public Map<String, Object> updateCascadingRelation(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
Object isActive = params.get("is_active");
if (isActive instanceof Boolean) {
params.put("is_active", Boolean.TRUE.equals(isActive) ? "Y" : "N");
}
Object clearOnParentChange = params.get("clear_on_parent_change");
if (clearOnParentChange instanceof Boolean) {
params.put("clear_on_parent_change", Boolean.TRUE.equals(clearOnParentChange) ? "Y" : "N");
}
sqlSession.update(NS + "updateCascadingRelation", params);
return params;
}
@Transactional
public Map<String, Object> deleteCascadingRelation(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
sqlSession.update(NS + "deleteCascadingRelation", params);
return params;
}
/**
* 부모 옵션 조회: relation_code로 관계 조회 후 parent_table에서 동적 쿼리
*/
public List<Map<String, Object>> getParentOptions(Map<String, Object> params) {
String companyCode = (String) params.get("company_code");
Map<String, Object> relation = sqlSession.selectOne(NS + "getCascadingRelationByCode", params);
if (relation == null) {
throw new NoSuchElementException("연쇄 관계를 찾을 수 없습니다.");
}
String parentTable = (String) relation.get("parent_table");
String parentValueColumn = (String) relation.get("parent_value_column");
String parentLabelColumn = (String) relation.get("parent_label_column");
if (parentLabelColumn == null || parentLabelColumn.isEmpty()) {
parentLabelColumn = parentValueColumn;
}
boolean hasCompanyCode = hasColumn(parentTable, "company_code");
boolean hasStatus = hasColumn(parentTable, "status");
List<Object> queryParams = new ArrayList<>();
StringBuilder sql = new StringBuilder();
sql.append("SELECT ")
.append(parentValueColumn).append(" AS value, ")
.append(parentLabelColumn).append(" AS label")
.append(" FROM ").append(parentTable)
.append(" WHERE 1=1");
if (hasCompanyCode && !"*".equals(companyCode)) {
sql.append(" AND company_code = ?");
queryParams.add(companyCode);
}
if (hasStatus) {
sql.append(" AND (status IS NULL OR status != 'N')");
}
sql.append(" ORDER BY ").append(parentLabelColumn).append(" ASC");
return jdbcTemplate.queryForList(sql.toString(), queryParams.toArray());
}
/**
* 연쇄 옵션 조회: relation_code로 관계 조회 후 child_table에서 동적 쿼리
* parentValue(단일) 또는 parentValues(콤마 구분 다중) 지원
*/
public List<Map<String, Object>> getCascadingOptions(Map<String, Object> params) {
String companyCode = (String) params.get("company_code");
Object parentValueParam = params.get("parent_value");
Object parentValuesParam = params.get("parent_values");
List<String> parentValueArray = new ArrayList<>();
if (parentValuesParam != null) {
for (String v : parentValuesParam.toString().split(",")) {
String trimmed = v.trim();
if (!trimmed.isEmpty()) parentValueArray.add(trimmed);
}
} else if (parentValueParam != null) {
parentValueArray.add(parentValueParam.toString());
}
if (parentValueArray.isEmpty()) {
return Collections.emptyList();
}
Map<String, Object> relation = sqlSession.selectOne(NS + "getCascadingRelationByCode", params);
if (relation == null) {
throw new NoSuchElementException("연쇄 관계를 찾을 수 없습니다.");
}
String childTable = (String) relation.get("child_table");
String childFilterColumn = (String) relation.get("child_filter_column");
String childValueColumn = (String) relation.get("child_value_column");
String childLabelColumn = (String) relation.get("child_label_column");
String childOrderColumn = (String) relation.get("child_order_column");
String childOrderDir = (String) relation.get("child_order_direction");
if (childOrderDir == null || childOrderDir.isEmpty()) childOrderDir = "ASC";
boolean hasCompanyCode = hasColumn(childTable, "company_code");
List<Object> queryParams = new ArrayList<>(parentValueArray);
String placeholders = String.join(", ", Collections.nCopies(parentValueArray.size(), "?"));
StringBuilder sql = new StringBuilder();
sql.append("SELECT DISTINCT ")
.append(childValueColumn).append(" AS value, ")
.append(childLabelColumn).append(" AS label, ")
.append(childFilterColumn).append(" AS parent_value")
.append(" FROM ").append(childTable)
.append(" WHERE ").append(childFilterColumn).append(" IN (").append(placeholders).append(")");
if (hasCompanyCode && !"*".equals(companyCode)) {
sql.append(" AND company_code = ?");
queryParams.add(companyCode);
}
if (childOrderColumn != null && !childOrderColumn.isEmpty()) {
sql.append(" ORDER BY ").append(childOrderColumn).append(" ").append(childOrderDir);
} else {
sql.append(" ORDER BY ").append(childLabelColumn).append(" ASC");
}
return jdbcTemplate.queryForList(sql.toString(), queryParams.toArray());
}
private boolean hasColumn(String tableName, String columnName) {
String sql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?";
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, tableName, columnName);
return count != null && count > 0;
}
}
@@ -1,415 +0,0 @@
package com.erp.service;
import com.erp.common.BaseService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service
@Slf4j
public class CategoryTreeService extends BaseService {
private static final String NS = "categoryTree.";
private Long toLong(Object val) {
if (val == null) return null;
if (val instanceof Number n) return n.longValue();
try { return Long.parseLong(val.toString()); } catch (Exception e) { return null; }
}
/**
* 카테고리 트리 조회 (플랫 리스트 → 트리 변환)
*/
public List<Map<String, Object>> getCategoryTreeList(String companyCode, String tableName, String columnName) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("table_name", tableName);
params.put("column_name", columnName);
List<Map<String, Object>> flatList = sqlSession.selectList(NS + "getCategoryTreeList", params);
return buildTree(flatList);
}
/**
* 카테고리 플랫 리스트 조회
*/
public List<Map<String, Object>> getCategoryTreeFlatList(String companyCode, String tableName, String columnName) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("table_name", tableName);
params.put("column_name", columnName);
return sqlSession.selectList(NS + "getCategoryTreeList", params);
}
/**
* 카테고리 값 단건 조회
*/
public Map<String, Object> getCategoryTreeInfo(String companyCode, int valueId) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("value_id", valueId);
return sqlSession.selectOne(NS + "getCategoryTreeInfo", params);
}
/**
* 카테고리 값 생성
*/
@Transactional
public Map<String, Object> insertCategoryTree(Map<String, Object> body, String companyCode, String createdBy) {
String tableName = (String) body.get("table_name");
String columnName = (String) body.get("column_name");
String valueCode = (String) body.get("value_code");
String valueLabel = (String) body.get("value_label");
Object valueOrderRaw = body.get("value_order");
int valueOrder = valueOrderRaw != null ? ((Number) valueOrderRaw).intValue() : 0;
Object parentValueIdRaw = body.get("parent_value_id");
// depth / path 계산
int depth = 1;
String path = valueLabel;
if (parentValueIdRaw != null) {
Map<String, Object> parentParams = new HashMap<>();
parentParams.put("company_code", companyCode);
parentParams.put("value_id", ((Number) parentValueIdRaw).intValue());
Map<String, Object> parent = sqlSession.selectOne(NS + "getCategoryTreeInfo", parentParams);
if (parent != null) {
depth = ((Number) parent.get("depth")).intValue() + 1;
if (depth > 3) {
throw new IllegalArgumentException("카테고리는 최대 3단계까지만 가능합니다");
}
String parentPath = (String) parent.get("path");
path = parentPath != null ? parentPath + "/" + valueLabel : valueLabel;
}
}
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("column_name", columnName);
params.put("value_code", valueCode);
params.put("value_label", valueLabel);
params.put("value_order", valueOrder);
params.put("parent_value_id", parentValueIdRaw);
params.put("depth", depth);
params.put("path", path);
params.put("description", body.get("description"));
params.put("color", body.get("color"));
params.put("icon", body.get("icon"));
Object isActiveRaw = body.get("is_active");
Object isDefaultRaw = body.get("is_default");
params.put("is_active", isActiveRaw != null ? isActiveRaw : true);
params.put("is_default", isDefaultRaw != null ? isDefaultRaw : false);
params.put("company_code", companyCode);
params.put("created_by", createdBy);
sqlSession.insert(NS + "insertCategoryTree", params);
// useGeneratedKeys → params.get("value_id") 에 생성된 ID 저장
Map<String, Object> fetchParams = new HashMap<>();
fetchParams.put("company_code", companyCode);
fetchParams.put("value_id", params.get("value_id"));
return sqlSession.selectOne(NS + "getCategoryTreeInfo", fetchParams);
}
/**
* 카테고리 값 수정
*/
@Transactional
public Map<String, Object> updateCategoryTree(String companyCode, int valueId,
Map<String, Object> body, String updatedBy) {
Map<String, Object> currentParams = new HashMap<>();
currentParams.put("company_code", companyCode);
currentParams.put("value_id", valueId);
Map<String, Object> current = sqlSession.selectOne(NS + "getCategoryTreeInfo", currentParams);
if (current == null) return null;
String currentLabel = (String) current.get("value_label");
int currentDepth = ((Number) current.get("depth")).intValue();
String currentPath = (String) current.get("path");
Object currentParentId = current.get("parent_value_id");
String newLabel = body.containsKey("value_label") ? (String) body.get("value_label") : currentLabel;
int newDepth = currentDepth;
String newPath = currentPath;
Object newParentId = body.containsKey("parent_value_id") ? body.get("parent_value_id") : currentParentId;
boolean labelChanged = body.containsKey("value_label")
&& !Objects.equals(newLabel, currentLabel);
boolean parentChanged = body.containsKey("parent_value_id")
&& !Objects.equals(toLong(body.get("parent_value_id")), toLong(currentParentId));
if (parentChanged) {
if (body.get("parent_value_id") != null) {
Map<String, Object> newParentParams = new HashMap<>();
newParentParams.put("company_code", companyCode);
newParentParams.put("value_id", ((Number) body.get("parent_value_id")).intValue());
Map<String, Object> newParent = sqlSession.selectOne(NS + "getCategoryTreeInfo", newParentParams);
if (newParent != null) {
newDepth = ((Number) newParent.get("depth")).intValue() + 1;
if (newDepth > 3) {
throw new IllegalArgumentException("카테고리는 최대 3단계까지만 가능합니다");
}
String parentPath = (String) newParent.get("path");
newPath = parentPath != null ? parentPath + "/" + newLabel : newLabel;
}
} else {
newDepth = 1;
newPath = newLabel;
}
} else if (labelChanged) {
if (currentParentId != null) {
Map<String, Object> parentParams = new HashMap<>();
parentParams.put("company_code", companyCode);
parentParams.put("value_id", ((Number) currentParentId).intValue());
Map<String, Object> parent = sqlSession.selectOne(NS + "getCategoryTreeInfo", parentParams);
String parentPath = parent != null ? (String) parent.get("path") : null;
newPath = parentPath != null ? parentPath + "/" + newLabel : newLabel;
} else {
newPath = newLabel;
}
}
Map<String, Object> updateParams = new HashMap<>();
updateParams.put("company_code", companyCode);
updateParams.put("value_id", valueId);
updateParams.put("value_code", body.get("value_code"));
updateParams.put("value_label", body.containsKey("value_label") ? newLabel : null);
updateParams.put("value_order", body.get("value_order"));
updateParams.put("parent_value_id", newParentId);
updateParams.put("depth", newDepth);
updateParams.put("path", newPath);
updateParams.put("description", body.get("description"));
updateParams.put("color", body.get("color"));
updateParams.put("icon", body.get("icon"));
updateParams.put("is_active", body.get("is_active"));
updateParams.put("is_default", body.get("is_default"));
updateParams.put("updated_by", updatedBy);
int affected = sqlSession.update(NS + "updateCategoryTree", updateParams);
if (affected == 0) return null;
if (labelChanged || parentChanged) {
updateChildrenPaths(companyCode, valueId, newPath != null ? newPath : "");
}
Map<String, Object> fetchParams = new HashMap<>();
fetchParams.put("company_code", companyCode);
fetchParams.put("value_id", valueId);
return sqlSession.selectOne(NS + "getCategoryTreeInfo", fetchParams);
}
/**
* 카테고리 값 삭제 가능 여부 사전 확인
*/
public Map<String, Object> checkCanDelete(String companyCode, int valueId) {
Map<String, Object> value = getCategoryTreeInfo(companyCode, valueId);
if (value == null) {
Map<String, Object> res = new LinkedHashMap<>();
res.put("can_delete", false);
res.put("reason", "카테고리 값을 찾을 수 없습니다");
return res;
}
Map<String, Object> childParams = new HashMap<>();
childParams.put("value_id", valueId);
childParams.put("company_code", companyCode);
Integer childCountObj = sqlSession.selectOne(NS + "getCategoryTreeChildrenCnt", childParams);
int childCount = childCountObj != null ? childCountObj : 0;
if (childCount > 0) {
Map<String, Object> res = new LinkedHashMap<>();
res.put("can_delete", false);
res.put("reason", "하위 카테고리가 " + childCount + "개 존재합니다. 하위 카테고리를 먼저 삭제해주세요.");
return res;
}
Map<String, Object> usage = checkCategoryValueInUse(companyCode, value);
boolean inUse = Boolean.TRUE.equals(usage.get("in_use"));
if (inUse) {
int count = ((Number) usage.get("count")).intValue();
Map<String, Object> res = new LinkedHashMap<>();
res.put("can_delete", false);
res.put("reason", "이 카테고리 값(" + value.get("value_label") + ")은 " + count + "건의 데이터에서 사용 중이므로 삭제할 수 없습니다.");
return res;
}
Map<String, Object> res = new LinkedHashMap<>();
res.put("can_delete", true);
return res;
}
/**
* 카테고리 값 삭제 (자식·사용 여부 검증 후 삭제)
*/
@Transactional
public boolean deleteCategoryTree(String companyCode, int valueId) {
Map<String, Object> value = getCategoryTreeInfo(companyCode, valueId);
if (value == null) return false;
// 1. 자식 존재 여부
Map<String, Object> childParams = new HashMap<>();
childParams.put("value_id", valueId);
childParams.put("company_code", companyCode);
Integer childCountObj = sqlSession.selectOne(NS + "getCategoryTreeChildrenCnt", childParams);
int childCount = childCountObj != null ? childCountObj : 0;
if (childCount > 0) {
throw new IllegalStateException(
"VALIDATION:하위 카테고리가 " + childCount + "개 존재합니다. 하위 카테고리를 먼저 삭제해주세요.");
}
// 2. 실제 데이터 사용 여부
Map<String, Object> usage = checkCategoryValueInUse(companyCode, value);
boolean inUse = Boolean.TRUE.equals(usage.get("in_use"));
if (inUse) {
int count = ((Number) usage.get("count")).intValue();
throw new IllegalStateException(
"VALIDATION:이 카테고리 값(" + value.get("value_label") + ")은 "
+ value.get("table_name") + " 테이블에서 " + count + "건의 데이터가 사용 중이므로 삭제할 수 없습니다.");
}
// 3. 삭제
Map<String, Object> deleteParams = new HashMap<>();
deleteParams.put("company_code", companyCode);
deleteParams.put("value_id", valueId);
return sqlSession.delete(NS + "deleteCategoryTree", deleteParams) > 0;
}
/**
* 테이블의 카테고리 컬럼 목록 조회
*/
public List<Map<String, Object>> getCategoryTreeColumnList(String companyCode, String tableName) {
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("company_code", companyCode);
return sqlSession.selectList(NS + "getCategoryTreeColumnList", params);
}
/**
* 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합)
*/
public List<Map<String, Object>> getCategoryTreeKeyList(String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
return sqlSession.selectList(NS + "getCategoryTreeKeyList", params);
}
// ─── private helpers ────────────────────────────────────────────────────────
/**
* 플랫 리스트 → 트리 구조 변환
*/
private List<Map<String, Object>> buildTree(List<Map<String, Object>> flatList) {
Map<Object, Map<String, Object>> map = new LinkedHashMap<>();
List<Map<String, Object>> roots = new ArrayList<>();
for (Map<String, Object> item : flatList) {
Map<String, Object> node = new LinkedHashMap<>(item);
node.put("children", new ArrayList<>());
map.put(item.get("value_id"), node);
}
for (Map<String, Object> item : flatList) {
Object parentId = item.get("parent_value_id");
Map<String, Object> node = map.get(item.get("value_id"));
if (parentId != null && map.containsKey(parentId)) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> children =
(List<Map<String, Object>>) map.get(parentId).get("children");
children.add(node);
} else {
roots.add(node);
}
}
return roots;
}
/**
* 하위 항목들의 path 재귀 업데이트
*/
private void updateChildrenPaths(String companyCode, int parentValueId, String parentPath) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("parent_value_id", parentValueId);
List<Map<String, Object>> children = sqlSession.selectList(NS + "getCategoryTreeChildrenList", params);
for (Map<String, Object> child : children) {
String valueLabel = (String) child.get("value_label");
String newPath = parentPath + "/" + valueLabel;
Map<String, Object> updateParams = new HashMap<>();
updateParams.put("value_id", child.get("value_id"));
updateParams.put("path", newPath);
sqlSession.update(NS + "updateCategoryTreeChildPath", updateParams);
int childId = ((Number) child.get("value_id")).intValue();
updateChildrenPaths(companyCode, childId, newPath);
}
}
/**
* 카테고리 값이 실제 데이터 테이블에서 사용 중인지 확인
* 오류 발생 시 무시하고 삭제 허용 (Node.js 동일 동작)
*/
private Map<String, Object> checkCategoryValueInUse(String companyCode, Map<String, Object> value) {
String tableName = (String) value.get("table_name");
String columnName = (String) value.get("column_name");
String valueCode = (String) value.get("value_code");
Map<String, Object> notInUse = Map.of("in_use", false, "count", 0);
try {
// 1. 테이블 존재 확인
Map<String, Object> tableParams = new HashMap<>();
tableParams.put("table_name", tableName);
Integer teObj = sqlSession.selectOne(NS + "checkTableExists", tableParams);
if (teObj == null || teObj == 0) return notInUse;
// 2. 컬럼 존재 확인
Map<String, Object> colParams = new HashMap<>();
colParams.put("table_name", tableName);
colParams.put("column_name", columnName);
Integer ceObj = sqlSession.selectOne(NS + "checkColumnExists", colParams);
if (ceObj == null || ceObj == 0) return notInUse;
// 3. company_code 컬럼 존재 확인
Map<String, Object> companyColParams = new HashMap<>();
companyColParams.put("table_name", tableName);
companyColParams.put("column_name", "company_code");
Integer ccObj = sqlSession.selectOne(NS + "checkColumnExists", companyColParams);
boolean hasCompanyCode = ccObj != null && ccObj > 0;
// 4. 사용 건수 조회
int count;
if (hasCompanyCode && !"*".equals(companyCode)) {
Map<String, Object> countParams = new HashMap<>();
countParams.put("table_name", tableName);
countParams.put("column_name", columnName);
countParams.put("company_code", companyCode);
countParams.put("value_code", valueCode);
Integer cntObj = sqlSession.selectOne(NS + "countCategoryUsageWithCompany", countParams);
count = cntObj != null ? cntObj : 0;
} else {
Map<String, Object> countParams = new HashMap<>();
countParams.put("table_name", tableName);
countParams.put("column_name", columnName);
countParams.put("value_code", valueCode);
Integer cntObj = sqlSession.selectOne(NS + "countCategoryUsage", countParams);
count = cntObj != null ? cntObj : 0;
}
Map<String, Object> result = new HashMap<>();
result.put("in_use", count > 0);
result.put("count", count);
return result;
} catch (Exception e) {
log.warn("카테고리 사용 여부 확인 중 오류 (무시하고 삭제 허용): {}", e.getMessage());
return notInUse;
}
}
}
@@ -1,270 +0,0 @@
package com.erp.service;
import com.erp.common.BaseService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
@Service
@Slf4j
public class CategoryValueCascadingService extends BaseService {
private static final String NS = "categoryValueCascading.";
@Autowired
private CommonService commonService;
@Autowired
private JdbcTemplate jdbcTemplate;
public Map<String, Object> getCategoryValueCascadingGroupList(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
commonService.applyPagination(params);
int totalCount = sqlSession.selectOne(NS + "getCategoryValueCascadingGroupListCnt", params);
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCategoryValueCascadingGroupList", params);
return commonService.buildListResponse(list, totalCount, params);
}
public Map<String, Object> getCategoryValueCascadingGroupInfo(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
Map<String, Object> group = sqlSession.selectOne(NS + "getCategoryValueCascadingGroupInfo", params);
if (group == null) return null;
List<Map<String, Object>> mappings = sqlSession.selectList(NS + "getCategoryValueCascadingMappingsByGroupId", params);
Map<String, List<Map<String, Object>>> mappingsByParent = new LinkedHashMap<>();
for (Map<String, Object> m : mappings) {
String parentKey = String.valueOf(m.get("parent_value_code"));
mappingsByParent.computeIfAbsent(parentKey, k -> new ArrayList<>()).add(Map.of(
"child_value_code", m.getOrDefault("child_value_code", ""),
"child_value_label", m.getOrDefault("child_value_label", ""),
"display_order", m.getOrDefault("display_order", 0)
));
}
Map<String, Object> result = new LinkedHashMap<>(group);
result.put("mappings", mappings);
result.put("mappings_by_parent", mappingsByParent);
return result;
}
public Map<String, Object> getCategoryValueCascadingGroupByCode(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
return sqlSession.selectOne(NS + "getCategoryValueCascadingGroupByCode", params);
}
@Transactional
public Map<String, Object> insertCategoryValueCascadingGroup(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
sqlSession.insert(NS + "insertCategoryValueCascadingGroup", params);
return params;
}
@Transactional
public Map<String, Object> updateCategoryValueCascadingGroup(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
sqlSession.update(NS + "updateCategoryValueCascadingGroup", params);
return params;
}
@Transactional
public Map<String, Object> deleteCategoryValueCascadingGroup(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
sqlSession.update(NS + "deleteCategoryValueCascadingGroup", params);
return params;
}
@Transactional
@SuppressWarnings("unchecked")
public Map<String, Object> saveCategoryValueCascadingMappings(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String companyCode = (String) params.get("company_code");
Object groupId = params.get("group_id");
sqlSession.delete(NS + "deleteCategoryValueCascadingMappingsByGroupId", params);
int savedCount = 0;
Object mappingsObj = params.get("mappings");
if (mappingsObj instanceof List<?>) {
List<Map<String, Object>> mappings = (List<Map<String, Object>>) mappingsObj;
for (Map<String, Object> mapping : mappings) {
Map<String, Object> mappingParams = new HashMap<>(mapping);
mappingParams.put("group_id", groupId);
mappingParams.put("company_code", companyCode);
sqlSession.insert(NS + "insertCategoryValueCascadingMapping", mappingParams);
savedCount++;
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("saved_count", savedCount);
result.put("group_id", groupId);
return result;
}
public Map<String, Object> getCategoryValueCascadingParentOptions(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String companyCode = (String) params.get("company_code");
Map<String, Object> group = sqlSession.selectOne(NS + "getCategoryValueCascadingGroupByCode", params);
if (group == null) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", Collections.emptyList());
return result;
}
String tableName = String.valueOf(group.get("parent_table_name"));
String columnName = String.valueOf(group.get("parent_column_name"));
Object menuObjid = group.get("parent_menu_objid");
StringBuilder sql = new StringBuilder(
"SELECT value_code as value, value_label as label, value_order as display_order" +
" FROM category_values WHERE table_name = ? AND column_name = ? AND is_active = true");
List<Object> sqlParams = new ArrayList<>(Arrays.asList(tableName, columnName));
if (menuObjid != null) {
sql.append(" AND menu_objid = ?");
sqlParams.add(menuObjid);
}
if (companyCode != null && !"*".equals(companyCode)) {
sql.append(" AND (company_code = ? OR company_code = '*')");
sqlParams.add(companyCode);
}
sql.append(" ORDER BY value_order, value_label");
List<Map<String, Object>> options = jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", options);
return result;
}
public Map<String, Object> getCategoryValueCascadingChildOptions(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String companyCode = (String) params.get("company_code");
Map<String, Object> group = sqlSession.selectOne(NS + "getCategoryValueCascadingGroupByCode", params);
if (group == null) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", Collections.emptyList());
return result;
}
String tableName = String.valueOf(group.get("child_table_name"));
String columnName = String.valueOf(group.get("child_column_name"));
Object menuObjid = group.get("child_menu_objid");
StringBuilder sql = new StringBuilder(
"SELECT value_code as value, value_label as label, value_order as display_order" +
" FROM category_values WHERE table_name = ? AND column_name = ? AND is_active = true");
List<Object> sqlParams = new ArrayList<>(Arrays.asList(tableName, columnName));
if (menuObjid != null) {
sql.append(" AND menu_objid = ?");
sqlParams.add(menuObjid);
}
if (companyCode != null && !"*".equals(companyCode)) {
sql.append(" AND (company_code = ? OR company_code = '*')");
sqlParams.add(companyCode);
}
sql.append(" ORDER BY value_order, value_label");
List<Map<String, Object>> options = jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", options);
return result;
}
public Map<String, Object> getCategoryValueCascadingOptions(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String parentValuesStr = params.get("parent_values") != null ? String.valueOf(params.get("parent_values")) : null;
String parentValue = params.get("parent_value") != null ? String.valueOf(params.get("parent_value")) : null;
List<String> parentValueArray = new ArrayList<>();
if (parentValuesStr != null && !parentValuesStr.isEmpty()) {
for (String v : parentValuesStr.split(",")) {
String trimmed = v.trim();
if (!trimmed.isEmpty()) parentValueArray.add(trimmed);
}
} else if (parentValue != null && !parentValue.isEmpty()) {
parentValueArray.add(parentValue);
}
if (parentValueArray.isEmpty()) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", Collections.emptyList());
return result;
}
Map<String, Object> group = sqlSession.selectOne(NS + "getCategoryValueCascadingGroupByCode", params);
if (group == null) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", Collections.emptyList());
return result;
}
Object groupId = group.get("group_id");
String showGroupLabel = group.get("show_group_label") != null ? String.valueOf(group.get("show_group_label")) : "N";
String placeholders = parentValueArray.stream().map(v -> "?").collect(Collectors.joining(", "));
String sql = "SELECT DISTINCT child_value_code as value, child_value_label as label," +
" parent_value_code as parent_value, parent_value_label as parent_label, display_order" +
" FROM category_value_cascading_mapping" +
" WHERE group_id = ? AND parent_value_code IN (" + placeholders + ") AND is_active = 'Y'" +
" ORDER BY parent_value_code, display_order, child_value_label";
List<Object> sqlParams = new ArrayList<>();
sqlParams.add(groupId);
sqlParams.addAll(parentValueArray);
List<Map<String, Object>> options = jdbcTemplate.queryForList(sql, sqlParams.toArray());
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", options);
result.put("show_group_label", "Y".equals(showGroupLabel));
return result;
}
public Map<String, Object> getCategoryValueCascadingMappingsByTable(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String companyCode = (String) params.get("company_code");
String tableName = (String) params.get("table_name");
StringBuilder groupSql = new StringBuilder(
"SELECT group_id, relation_code, child_column_name" +
" FROM category_value_cascading_group" +
" WHERE child_table_name = ? AND is_active = 'Y'");
List<Object> groupSqlParams = new ArrayList<>();
groupSqlParams.add(tableName);
if (companyCode != null && !"*".equals(companyCode)) {
groupSql.append(" AND (company_code = ? OR company_code = '*')");
groupSqlParams.add(companyCode);
}
List<Map<String, Object>> groups = jdbcTemplate.queryForList(groupSql.toString(), groupSqlParams.toArray());
Map<String, Object> mappings = new LinkedHashMap<>();
for (Map<String, Object> group : groups) {
Object groupId = group.get("group_id");
String childColumnName = String.valueOf(group.get("child_column_name"));
List<Map<String, Object>> groupMappings = jdbcTemplate.queryForList(
"SELECT DISTINCT child_value_code as code, child_value_label as label" +
" FROM category_value_cascading_mapping" +
" WHERE group_id = ? AND is_active = 'Y'" +
" ORDER BY child_value_label",
groupId);
if (!groupMappings.isEmpty()) {
mappings.put(childColumnName, groupMappings);
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", mappings);
return result;
}
}
@@ -1,247 +0,0 @@
package com.erp.service;
import com.erp.common.BaseService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Slf4j
public class CodeMergeService extends BaseService {
private final JdbcTemplate jdbcTemplate;
private static final String NS = "codeMerge.";
// ── Tables With Column ────────────────────────────────────────────────────
/**
* GET /tables-with-column/:columnName
* 해당 컬럼과 company_code 컬럼을 함께 가진 public 테이블 목록 반환
*/
public Map<String, Object> getTablesWithColumn(String columnName) {
Map<String, Object> params = new HashMap<>();
params.put("column_name", columnName);
List<Map<String, Object>> rows = sqlSession.selectList(NS + "getTablesWithColumn", params);
List<String> tables = rows.stream()
.map(r -> {
Object val = r.get("table_name");
return val != null ? val.toString() : null;
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
Map<String, Object> result = new LinkedHashMap<>();
result.put("column_name", columnName);
result.put("tables", tables);
result.put("count", tables.size());
return result;
}
// ── Preview (column-based) ────────────────────────────────────────────────
/**
* POST /preview
* columnName + oldValue 기준으로 영향받을 테이블/행 수 미리보기 (DB 변경 없음)
*/
public Map<String, Object> previewCodeMerge(Map<String, Object> body) {
String columnName = str(body.get("column_name"));
String oldValue = str(body.get("old_value"));
String companyCode = str(body.get("company_code"));
if (isBlank(columnName) || isBlank(oldValue)) {
throw new IllegalArgumentException("필수 필드가 누락되었습니다. (columnName, oldValue)");
}
log.info("코드 병합 미리보기: column={}, oldValue={}, company={}", columnName, oldValue, companyCode);
Map<String, Object> params = new HashMap<>();
params.put("column_name", columnName);
List<Map<String, Object>> tableRows = sqlSession.selectList(NS + "getTablesWithColumn", params);
List<Map<String, Object>> preview = new ArrayList<>();
int totalRows = 0;
for (Map<String, Object> tableRow : tableRows) {
Object nameVal = tableRow.get("table_name");
if (nameVal == null) continue;
String tableName = nameVal.toString();
// 테이블명·컬럼명은 information_schema에서 검증된 값 — SQL 인젝션 위험 없음
String countSql = String.format(
"SELECT COUNT(*) FROM \"%s\" WHERE \"%s\" = ? AND company_code = ?",
tableName, columnName);
try {
Integer count = jdbcTemplate.queryForObject(countSql, Integer.class, oldValue, companyCode);
if (count != null && count > 0) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("table_name", tableName);
item.put("affected_rows", count);
preview.add(item);
totalRows += count;
}
} catch (Exception e) {
log.warn("테이블 {} 조회 실패 (건너뜀): {}", tableName, e.getMessage());
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("column_name", columnName);
result.put("old_value", oldValue);
result.put("preview", preview);
result.put("total_affected_rows", totalRows);
return result;
}
// ── Merge All Tables (column-based) ───────────────────────────────────────
/**
* POST /merge-all-tables
* PostgreSQL 함수 merge_code_all_tables(columnName, oldValue, newValue, companyCode) 호출
*/
@Transactional
public Map<String, Object> mergeAllTables(Map<String, Object> body) {
String columnName = str(body.get("column_name"));
String oldValue = str(body.get("old_value"));
String newValue = str(body.get("new_value"));
String companyCode = str(body.get("company_code"));
if (isBlank(columnName) || isBlank(oldValue) || isBlank(newValue)) {
throw new IllegalArgumentException("필수 필드가 누락되었습니다. (columnName, oldValue, newValue)");
}
if (oldValue.equals(newValue)) {
throw new IllegalArgumentException("기존 값과 새 값이 동일합니다.");
}
log.info("코드 병합 시작: column={}, {} → {}, company={}", columnName, oldValue, newValue, companyCode);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT * FROM merge_code_all_tables(?, ?, ?, ?)",
columnName, oldValue, newValue, companyCode);
int totalRows = rows.stream()
.mapToInt(r -> r.get("rows_updated") != null ? ((Number) r.get("rows_updated")).intValue() : 0)
.sum();
List<Map<String, Object>> affectedTables = rows.stream().map(r -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("table_name", r.get("table_name"));
item.put("rows_updated", r.get("rows_updated") != null ? ((Number) r.get("rows_updated")).intValue() : 0);
return item;
}).collect(Collectors.toList());
log.info("코드 병합 완료: 영향 테이블 {}개, 총 {}행", affectedTables.size(), totalRows);
Map<String, Object> result = new LinkedHashMap<>();
result.put("column_name", columnName);
result.put("old_value", oldValue);
result.put("new_value", newValue);
result.put("affected_tables", affectedTables);
result.put("total_rows_updated", totalRows);
return result;
}
// ── Merge By Value ────────────────────────────────────────────────────────
/**
* POST /merge-by-value
* PostgreSQL 함수 merge_code_by_value(oldValue, newValue, companyCode) 호출
* 컬럼명에 관계없이 해당 값을 가진 모든 위치를 변경
*/
@Transactional
public Map<String, Object> mergeByValue(Map<String, Object> body) {
String oldValue = str(body.get("old_value"));
String newValue = str(body.get("new_value"));
String companyCode = str(body.get("company_code"));
if (isBlank(oldValue) || isBlank(newValue)) {
throw new IllegalArgumentException("필수 필드가 누락되었습니다. (oldValue, newValue)");
}
if (oldValue.equals(newValue)) {
throw new IllegalArgumentException("기존 값과 새 값이 동일합니다.");
}
log.info("값 기반 코드 병합 시작: {} → {}, company={}", oldValue, newValue, companyCode);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT * FROM merge_code_by_value(?, ?, ?)",
oldValue, newValue, companyCode);
int totalRows = rows.stream()
.mapToInt(r -> r.get("out_rows_updated") != null ? ((Number) r.get("out_rows_updated")).intValue() : 0)
.sum();
List<Map<String, Object>> affectedData = rows.stream().map(r -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("table_name", r.get("out_table_name"));
item.put("column_name", r.get("out_column_name"));
item.put("rows_updated", r.get("out_rows_updated") != null ? ((Number) r.get("out_rows_updated")).intValue() : 0);
return item;
}).collect(Collectors.toList());
log.info("값 기반 코드 병합 완료: {} → {}, 총 {}행", oldValue, newValue, totalRows);
Map<String, Object> result = new LinkedHashMap<>();
result.put("old_value", oldValue);
result.put("new_value", newValue);
result.put("affected_data", affectedData);
result.put("total_rows_updated", totalRows);
return result;
}
// ── Preview By Value ──────────────────────────────────────────────────────
/**
* POST /preview-by-value
* PostgreSQL 함수 preview_merge_code_by_value(oldValue, companyCode) 호출
*/
public Map<String, Object> previewByValue(Map<String, Object> body) {
String oldValue = str(body.get("old_value"));
String companyCode = str(body.get("company_code"));
if (isBlank(oldValue)) {
throw new IllegalArgumentException("필수 필드가 누락되었습니다. (oldValue)");
}
log.info("값 기반 코드 병합 미리보기: oldValue={}, company={}", oldValue, companyCode);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT * FROM preview_merge_code_by_value(?, ?)",
oldValue, companyCode);
int totalRows = rows.stream()
.mapToInt(r -> r.get("out_affected_rows") != null ? ((Number) r.get("out_affected_rows")).intValue() : 0)
.sum();
List<Map<String, Object>> preview = rows.stream().map(r -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("table_name", r.get("out_table_name"));
item.put("column_name", r.get("out_column_name"));
item.put("affected_rows", r.get("out_affected_rows") != null ? ((Number) r.get("out_affected_rows")).intValue() : 0);
return item;
}).collect(Collectors.toList());
Map<String, Object> result = new LinkedHashMap<>();
result.put("old_value", oldValue);
result.put("preview", preview);
result.put("total_affected_rows", totalRows);
return result;
}
// ── Helpers ───────────────────────────────────────────────────────────────
private String str(Object val) {
return val != null ? val.toString() : null;
}
private boolean isBlank(String s) {
return s == null || s.isBlank();
}
}
@@ -8,10 +8,12 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.*;
/**
* Common Code Service
* Common Code Service — 마스터-디테일 패턴.
*
* commonCodeRoutes.ts 포팅.
* 테이블: code_category, code_info
* • code_info : 1레벨 그룹 마스터 (PK = code_info + company_code)
* code_detail : 2레벨 ~ 무한대 트리 (PK = code_detail_id, parent_detail_id 로 self-ref)
*
* 옛 캐스케이딩/카테고리 구조 폐기. 단일 그룹 안에서 재귀 트리.
*/
@Service
@Slf4j
@@ -19,13 +21,11 @@ public class CommonCodeService extends BaseService {
private static final String NS = "commonCode.";
private static final long DEFAULT_MENU_OBJID = 1757401858940L;
// ══════════════════════════════════════════════════════════════
// 카테고리 목록
// CODE_INFO — 그룹 마스터 CRUD
// ══════════════════════════════════════════════════════════════
public Map<String, Object> getCommonCodeCategoryList(Map<String, Object> params) {
public Map<String, Object> getCodeInfoList(Map<String, Object> params) {
int page = toInt(params.get("page"), 1);
int size = toInt(params.get("size"), 20);
params.put("limit", size);
@@ -34,425 +34,280 @@ 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> getCodeInfoInfo(String codeInfo, String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("code_info", codeInfo);
params.put("company_code", companyCode);
return sqlSession.selectOne(NS + "getCodeInfoInfo", params);
}
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;
@Transactional
public Map<String, Object> insertCodeInfo(Map<String, Object> body, String companyCode, String userId) {
Object rawCodeInfo = body.get("code_info");
String codeInfo = rawCodeInfo == null ? null : rawCodeInfo.toString().trim();
if (codeInfo != null && !codeInfo.isEmpty()
&& getCodeInfoInfo(codeInfo, companyCode) != null) {
throw new IllegalArgumentException("이미 존재하는 그룹 코드입니다: " + codeInfo);
}
Map<String, Object> params = new HashMap<>();
params.put("field", field != null ? field : "category_code");
params.put("value", value.trim());
params.put("exclude_code", excludeCode);
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;
}
// ══════════════════════════════════════════════════════════════
// 카테고리 생성
// ══════════════════════════════════════════════════════════════
@Transactional
public Map<String, Object> insertCommonCodeCategory(Map<String, Object> body, String companyCode, String userId) {
Map<String, Object> params = new HashMap<>();
params.put("category_code", body.get("category_code"));
params.put("category_name", body.get("category_name"));
params.put("category_name_eng", body.getOrDefault("category_name_eng", null));
params.put("description", body.getOrDefault("description", null));
params.put("sort_order", body.getOrDefault("sort_order", 0));
params.put("is_active", toActiveStr(body.getOrDefault("is_active", true)));
params.put("menu_objid", body.getOrDefault("menu_objid", DEFAULT_MENU_OBJID));
params.put("company_code", companyCode);
params.put("created_by", userId);
params.put("updated_by", userId);
sqlSession.insert(NS + "insertCommonCodeCategory", params);
Map<String, Object> q = new HashMap<>();
q.put("category_code", params.get("category_code"));
q.put("company_code", companyCode);
return sqlSession.selectOne(NS + "getCommonCodeCategoryInfo", q);
}
// ══════════════════════════════════════════════════════════════
// 카테고리 수정
// ══════════════════════════════════════════════════════════════
@Transactional
public Map<String, Object> updateCommonCodeCategory(String categoryCode, Map<String, Object> body,
String companyCode, String userId) {
Map<String, Object> params = new HashMap<>();
params.put("category_code", categoryCode);
params.put("code_info", body.get("code_info"));
params.put("code_name", body.get("code_name"));
params.put("code_name_eng", body.getOrDefault("code_name_eng", null));
params.put("description", body.getOrDefault("description", null));
params.put("sort_order", body.getOrDefault("sort_order", 0));
params.put("is_active", toActiveStr(body.getOrDefault("is_active", true)));
params.put("menu_objid", body.getOrDefault("menu_objid", null));
params.put("company_code", companyCode);
params.put("created_by", userId);
params.put("updated_by", userId);
if (body.containsKey("category_name")) params.put("category_name", body.get("category_name"));
if (body.containsKey("category_name_eng")) params.put("category_name_eng", body.get("category_name_eng"));
if (body.containsKey("description")) params.put("description", body.get("description"));
if (body.containsKey("sort_order")) params.put("sort_order", body.get("sort_order"));
if (body.containsKey("is_active")) params.put("is_active", toActiveStr(body.get("is_active")));
sqlSession.insert(NS + "insertCodeInfo", params);
int updated = sqlSession.update(NS + "updateCommonCodeCategory", params);
if (updated == 0) return null;
Map<String, Object> q = new HashMap<>();
q.put("category_code", categoryCode);
q.put("company_code", companyCode);
return sqlSession.selectOne(NS + "getCommonCodeCategoryInfo", q);
return getCodeInfoInfo(String.valueOf(params.get("code_info")), companyCode);
}
// ══════════════════════════════════════════════════════════════
// 카테고리 삭제
// ══════════════════════════════════════════════════════════════
@Transactional
public void deleteCommonCodeCategory(String categoryCode, String companyCode) {
public Map<String, Object> updateCodeInfo(String codeInfo, Map<String, Object> body,
String companyCode, String userId) {
Map<String, Object> params = new HashMap<>();
params.put("category_code", categoryCode);
params.put("company_code", companyCode);
int deleted = sqlSession.delete(NS + "deleteCommonCodeCategory", params);
if (deleted == 0) throw new IllegalArgumentException("카테고리를 찾을 수 없습니다.");
params.put("code_info", codeInfo);
params.put("company_code", companyCode);
params.put("updated_by", userId);
if (body.containsKey("code_name")) params.put("code_name", body.get("code_name"));
if (body.containsKey("code_name_eng")) params.put("code_name_eng", body.get("code_name_eng"));
if (body.containsKey("description")) params.put("description", body.get("description"));
if (body.containsKey("sort_order")) params.put("sort_order", body.get("sort_order"));
if (body.containsKey("is_active")) params.put("is_active", toActiveStr(body.get("is_active")));
if (body.containsKey("menu_objid")) params.put("menu_objid", body.get("menu_objid"));
int updated = sqlSession.update(NS + "updateCodeInfo", params);
if (updated == 0) return null;
return getCodeInfoInfo(codeInfo, companyCode);
}
// ══════════════════════════════════════════════════════════════
// 코드 목록 (snake_case + camelCase 이중 필드)
// ══════════════════════════════════════════════════════════════
@Transactional
public void deleteCodeInfo(String codeInfo, String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("code_info", codeInfo);
params.put("company_code", companyCode);
public Map<String, Object> getCommonCodeList(String categoryCode, Map<String, Object> params) {
int page = toInt(params.get("page"), 1);
int size = toInt(params.get("size"), 20);
params.put("category_code", categoryCode);
params.put("limit", size);
params.put("offset", (page - 1) * size);
Object isActiveRaw = params.get("is_active");
if (isActiveRaw != null) params.put("is_active", toActiveStr(isActiveRaw));
List<Map<String, Object>> rawList = sqlSession.selectList(NS + "getCommonCodeList", params);
Integer totalObj = sqlSession.selectOne(NS + "getCommonCodeListCnt", params);
int total = totalObj != null ? totalObj : 0;
List<Map<String, Object>> codes = new ArrayList<>();
for (Map<String, Object> raw : rawList) {
codes.add(transformCode(raw));
}
int deleted = sqlSession.delete(NS + "deleteCodeInfo", params);
if (deleted == 0) throw new IllegalArgumentException("코드 그룹을 찾을 수 없습니다.");
}
public Map<String, Object> checkCodeInfoDuplicate(String field, String value,
String excludeCode, String companyCode) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", codes);
result.put("total", total);
return result;
}
// ══════════════════════════════════════════════════════════════
// 코드 중복 확인
// ══════════════════════════════════════════════════════════════
public Map<String, Object> checkCodeDuplicate(String categoryCode, String field, String value,
String excludeCode, String companyCode) {
if (value == null || value.trim().isEmpty()) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("is_duplicate", false);
result.put("message", "값을 입력해주세요.");
result.put("message", "값을 입력해주세요.");
return result;
}
Map<String, Object> params = new HashMap<>();
params.put("category_code", categoryCode);
params.put("field", field != null ? field : "code_value");
params.put("field", field != null ? field : "code_info");
params.put("value", value.trim());
params.put("exclude_code", excludeCode);
params.put("company_code", companyCode);
Integer countObj = sqlSession.selectOne(NS + "getCommonCodeDuplicateByField", params);
params.put("exclude_code", excludeCode);
params.put("company_code", companyCode);
Integer countObj = sqlSession.selectOne(NS + "getCodeInfoDuplicateByField", params);
boolean isDuplicate = countObj != null && countObj > 0;
Map<String, Object> result = new LinkedHashMap<>();
result.put("is_duplicate", isDuplicate);
result.put("message", isDuplicate ? "이미 사용 중인 값입니다." : "사용 가능한 값입니다.");
return result;
}
// ══════════════════════════════════════════════════════════════
// 코드 생성
// CODE_DETAIL — 디테일 트리 CRUD
// ══════════════════════════════════════════════════════════════
@Transactional
public Map<String, Object> insertCommonCode(String categoryCode, Map<String, Object> body,
String companyCode, String userId) {
// parentCodeValue 기반 depth 자동 계산
Object parentCodeValueRaw = body.getOrDefault("parent_code_value", null);
int depth = 1;
if (parentCodeValueRaw != null && !parentCodeValueRaw.toString().isEmpty()) {
Map<String, Object> parentParams = new HashMap<>();
parentParams.put("category_code", categoryCode);
parentParams.put("code_value", parentCodeValueRaw.toString());
parentParams.put("company_code", companyCode);
Integer parentDepth = sqlSession.selectOne(NS + "getCommonCodeParentDepth", parentParams);
depth = (parentDepth != null ? parentDepth : 0) + 1;
}
Map<String, Object> params = new HashMap<>();
params.put("category_code", categoryCode);
params.put("code_value", body.get("code_value"));
params.put("code_name", body.get("code_name"));
params.put("code_name_eng", body.getOrDefault("code_name_eng", null));
params.put("description", body.getOrDefault("description", null));
params.put("sort_order", body.getOrDefault("sort_order", 0));
params.put("is_active", toActiveStr(body.getOrDefault("is_active", true)));
params.put("menu_objid", body.getOrDefault("menu_objid", DEFAULT_MENU_OBJID));
params.put("company_code", companyCode);
params.put("parent_code_value", parentCodeValueRaw);
params.put("depth", depth);
params.put("created_by", userId);
params.put("updated_by", userId);
sqlSession.insert(NS + "insertCommonCode", params);
Map<String, Object> q = new HashMap<>();
q.put("category_code", categoryCode);
q.put("code_value", params.get("code_value"));
q.put("company_code", companyCode);
Map<String, Object> raw = sqlSession.selectOne(NS + "getCommonCodeInfo", q);
return raw != null ? transformCode(raw) : null;
}
// ══════════════════════════════════════════════════════════════
// 코드 정렬 순서 변경
// ══════════════════════════════════════════════════════════════
@Transactional
public void updateCommonCodeOrder(String categoryCode, List<Map<String, Object>> codes, String companyCode) {
for (Map<String, Object> code : codes) {
Map<String, Object> params = new HashMap<>();
params.put("category_code", categoryCode);
params.put("code_value", code.get("code_value"));
params.put("sort_order", code.get("sort_order"));
params.put("company_code", companyCode);
sqlSession.update(NS + "updateCommonCodeSortOrder", params);
}
}
// ══════════════════════════════════════════════════════════════
// 계층형 코드 목록
// ══════════════════════════════════════════════════════════════
public List<Map<String, Object>> getCommonCodeHierarchicalList(String categoryCode, Map<String, Object> params) {
params.put("category_code", categoryCode);
public Map<String, Object> getCodeDetailList(String codeInfo, Map<String, Object> params) {
int page = toInt(params.get("page"), 1);
int size = toInt(params.get("size"), 20);
params.put("code_info", codeInfo);
params.put("limit", size);
params.put("offset", (page - 1) * size);
Object isActiveRaw = params.get("is_active");
if (isActiveRaw != null) params.put("is_active", toActiveStr(isActiveRaw));
else params.remove("is_active");
// parentCodeValue, depth 필터는 params에 그대로 전달 (XML에서 처리)
List<Map<String, Object>> rawList = sqlSession.selectList(NS + "getCommonCodeHierarchicalList", params);
List<Map<String, Object>> result = new ArrayList<>();
for (Map<String, Object> raw : rawList) {
result.add(transformCode(raw));
Object parentRaw = params.get("parent_detail_id");
if (parentRaw != null && !parentRaw.toString().isEmpty()) {
params.put("parent_detail_id", toLong(parentRaw));
} else {
params.remove("parent_detail_id");
}
return result;
}
// ══════════════════════════════════════════════════════════════
// 트리 구조 — { flat: [...], tree: [...] }
// ══════════════════════════════════════════════════════════════
public Map<String, Object> getCommonCodeTree(String categoryCode, String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("category_code", categoryCode);
params.put("company_code", companyCode);
List<Map<String, Object>> flatList = sqlSession.selectList(NS + "getCommonCodeTreeList", params);
List<Map<String, Object>> flatTransformed = new ArrayList<>();
for (Map<String, Object> raw : flatList) {
flatTransformed.add(transformCode(raw));
}
List<Map<String, Object>> data = sqlSession.selectList(NS + "getCodeDetailList", params);
Integer totalObj = sqlSession.selectOne(NS + "getCodeDetailListCnt", params);
int total = totalObj != null ? totalObj : 0;
Map<String, Object> result = new LinkedHashMap<>();
result.put("flat", flatTransformed);
result.put("tree", buildTree(flatList));
result.put("data", data);
result.put("total", total);
return result;
}
// ══════════════════════════════════════════════════════════════
// 자식 존재 여부
// ══════════════════════════════════════════════════════════════
public Map<String, Object> hasChildren(String categoryCode, String codeValue, String companyCode) {
/**
* 그룹 전체 트리 — 평탄화된 리스트로 반환 (depth + sort_order 순).
* 프론트가 parent_detail_id 로 nest 처리하기 좋게.
*/
public List<Map<String, Object>> getCodeDetailTree(String codeInfo, String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("category_code", categoryCode);
params.put("code_value", codeValue);
params.put("company_code", companyCode);
Integer countObj = sqlSession.selectOne(NS + "getCommonCodeChildrenCnt", params);
params.put("code_info", codeInfo);
params.put("company_code", companyCode);
return sqlSession.selectList(NS + "getCodeDetailTree", params);
}
public Map<String, Object> getCodeDetailInfo(Long codeDetailId, String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("code_detail_id", codeDetailId);
params.put("company_code", companyCode);
return sqlSession.selectOne(NS + "getCodeDetailInfo", params);
}
@Transactional
public Map<String, Object> insertCodeDetail(String codeInfo, Map<String, Object> body,
String companyCode, String userId) {
// parent_detail_id 기반 depth 자동 계산. NULL = 그룹 직속 (depth=2).
Long parentDetailId = toLong(body.getOrDefault("parent_detail_id", null));
int depth = 2;
if (parentDetailId != null) {
Map<String, Object> parentParams = new HashMap<>();
parentParams.put("code_detail_id", parentDetailId);
parentParams.put("company_code", companyCode);
Integer parentDepth = sqlSession.selectOne(NS + "getCodeDetailParentDepth", parentParams);
depth = (parentDepth != null ? parentDepth : 1) + 1;
}
Map<String, Object> params = new HashMap<>();
params.put("code_info", codeInfo);
params.put("parent_detail_id", parentDetailId);
params.put("code_value", body.get("code_value"));
params.put("code_name", body.get("code_name"));
params.put("code_name_eng", body.getOrDefault("code_name_eng", null));
params.put("description", body.getOrDefault("description", null));
params.put("depth", depth);
params.put("sort_order", body.getOrDefault("sort_order", 0));
params.put("is_active", toActiveStr(body.getOrDefault("is_active", true)));
params.put("company_code", companyCode);
params.put("created_by", userId);
params.put("updated_by", userId);
sqlSession.insert(NS + "insertCodeDetail", params);
Object newIdRaw = params.get("code_detail_id");
Long newId = toLong(newIdRaw);
return newId != null ? getCodeDetailInfo(newId, companyCode) : null;
}
@Transactional
public Map<String, Object> updateCodeDetail(Long codeDetailId, Map<String, Object> body,
String companyCode, String userId) {
Map<String, Object> params = new HashMap<>();
params.put("code_detail_id", codeDetailId);
params.put("company_code", companyCode);
params.put("updated_by", userId);
if (body.containsKey("code_value")) params.put("code_value", body.get("code_value"));
if (body.containsKey("code_name")) params.put("code_name", body.get("code_name"));
if (body.containsKey("code_name_eng")) params.put("code_name_eng", body.get("code_name_eng"));
if (body.containsKey("description")) params.put("description", body.get("description"));
if (body.containsKey("sort_order")) params.put("sort_order", body.get("sort_order"));
if (body.containsKey("is_active")) params.put("is_active", toActiveStr(body.get("is_active")));
// parent_detail_id 변경 시 depth 재계산.
if (body.containsKey("parent_detail_id")) {
Long newParent = toLong(body.get("parent_detail_id"));
int newDepth = 2;
if (newParent != null) {
Map<String, Object> parentParams = new HashMap<>();
parentParams.put("code_detail_id", newParent);
parentParams.put("company_code", companyCode);
Integer parentDepth = sqlSession.selectOne(NS + "getCodeDetailParentDepth", parentParams);
newDepth = (parentDepth != null ? parentDepth : 1) + 1;
}
params.put("reparent", true);
params.put("parent_detail_id", newParent);
params.put("depth", newDepth);
}
int updated = sqlSession.update(NS + "updateCodeDetail", params);
if (updated == 0) return null;
return getCodeDetailInfo(codeDetailId, companyCode);
}
@Transactional
public void deleteCodeDetail(Long codeDetailId, String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("code_detail_id", codeDetailId);
params.put("company_code", companyCode);
int deleted = sqlSession.delete(NS + "deleteCodeDetail", params);
if (deleted == 0) throw new IllegalArgumentException("코드를 찾을 수 없습니다.");
}
public Map<String, Object> checkCodeDetailDuplicate(String codeInfo, String codeValue,
Long excludeId, String companyCode) {
Map<String, Object> result = new LinkedHashMap<>();
if (codeValue == null || codeValue.trim().isEmpty()) {
result.put("is_duplicate", false);
result.put("message", "값을 입력해주세요.");
return result;
}
Map<String, Object> params = new HashMap<>();
params.put("code_info", codeInfo);
params.put("code_value", codeValue.trim());
params.put("exclude_id", excludeId);
params.put("company_code", companyCode);
Integer countObj = sqlSession.selectOne(NS + "getCodeDetailDuplicateCnt", params);
boolean isDuplicate = countObj != null && countObj > 0;
result.put("is_duplicate", isDuplicate);
result.put("message", isDuplicate ? "이미 사용 중인 값입니다." : "사용 가능한 값입니다.");
return result;
}
public Map<String, Object> hasCodeDetailChildren(Long codeDetailId, String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("code_detail_id", codeDetailId);
params.put("company_code", companyCode);
Integer countObj = sqlSession.selectOne(NS + "getCodeDetailChildrenCnt", params);
int count = countObj != null ? countObj : 0;
Map<String, Object> result = new LinkedHashMap<>();
result.put("has_children", count > 0);
result.put("count", count);
return result;
}
// ══════════════════════════════════════════════════════════════
// 코드 수정
// ══════════════════════════════════════════════════════════════
@Transactional
public Map<String, Object> updateCommonCode(String categoryCode, String codeValue,
Map<String, Object> body, String companyCode, String userId) {
Map<String, Object> params = new HashMap<>();
params.put("category_code", categoryCode);
params.put("code_value", codeValue);
params.put("company_code", companyCode);
params.put("updated_by", userId);
if (body.containsKey("code_name")) params.put("code_name", body.get("code_name"));
if (body.containsKey("code_name_eng")) params.put("code_name_eng", body.get("code_name_eng"));
if (body.containsKey("description")) params.put("description", body.get("description"));
if (body.containsKey("sort_order")) params.put("sort_order", body.get("sort_order"));
if (body.containsKey("is_active")) params.put("is_active", toActiveStr(body.get("is_active")));
if (body.containsKey("parent_code_value")) {
Object newParent = body.get("parent_code_value");
params.put("parent_code_value", newParent);
// parentCodeValue 변경 시 depth 재계산
if (newParent != null && !newParent.toString().isEmpty()) {
Map<String, Object> parentParams = new HashMap<>();
parentParams.put("category_code", categoryCode);
parentParams.put("code_value", newParent.toString());
parentParams.put("company_code", companyCode);
Integer parentDepth = sqlSession.selectOne(NS + "getCommonCodeParentDepth", parentParams);
params.put("depth", (parentDepth != null ? parentDepth : 0) + 1);
} else {
params.put("depth", 1);
}
} else if (body.containsKey("depth")) {
params.put("depth", body.get("depth"));
}
int updated = sqlSession.update(NS + "updateCommonCode", params);
if (updated == 0) return null;
Map<String, Object> q = new HashMap<>();
q.put("category_code", categoryCode);
q.put("code_value", codeValue);
q.put("company_code", companyCode);
Map<String, Object> raw = sqlSession.selectOne(NS + "getCommonCodeInfo", q);
return raw != null ? transformCode(raw) : null;
}
// ══════════════════════════════════════════════════════════════
// 코드 삭제
// ══════════════════════════════════════════════════════════════
@Transactional
public void deleteCommonCode(String categoryCode, String codeValue, String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("category_code", categoryCode);
params.put("code_value", codeValue);
params.put("company_code", companyCode);
int deleted = sqlSession.delete(NS + "deleteCommonCode", params);
if (deleted == 0) throw new IllegalArgumentException("코드를 찾을 수 없습니다.");
}
// ══════════════════════════════════════════════════════════════
// 코드 옵션 목록
// ══════════════════════════════════════════════════════════════
public List<Map<String, Object>> getCommonCodeOptionList(String categoryCode, Map<String, Object> params) {
params.put("category_code", categoryCode);
Object isActiveRaw = params.get("is_active");
// 미지정 시 활성 코드만 반환 (드롭다운 기본 동작)
params.put("is_active", isActiveRaw != null ? toActiveStr(isActiveRaw) : "Y");
List<Map<String, Object>> rawList = sqlSession.selectList(NS + "getCommonCodeOptionList", params);
List<Map<String, Object>> options = new ArrayList<>();
for (Map<String, Object> raw : rawList) {
Map<String, Object> opt = new LinkedHashMap<>();
opt.put("value", raw.get("code_value"));
opt.put("label", raw.get("code_name"));
opt.put("label_eng", raw.get("code_name_eng"));
options.add(opt);
}
return options;
}
// ══════════════════════════════════════════════════════════════
// Private helpers
// ══════════════════════════════════════════════════════════════
/** snake_case 원본 + camelCase 별칭을 모두 포함하는 Map 반환 */
private Map<String, Object> transformCode(Map<String, Object> raw) {
Map<String, Object> item = new LinkedHashMap<>(raw);
item.put("code_value", raw.get("code_value"));
item.put("code_name", raw.get("code_name"));
item.put("code_name_eng", raw.get("code_name_eng"));
item.put("code_category", raw.get("code_category"));
item.put("sort_order", raw.get("sort_order"));
item.put("is_active", raw.get("is_active"));
item.put("menu_objid", raw.get("menu_objid"));
item.put("company_code", raw.get("company_code"));
item.put("parent_code_value", raw.get("parent_code_value"));
item.put("created_by", raw.get("created_by"));
item.put("updated_by", raw.get("updated_by"));
item.put("created_date", raw.get("created_date"));
item.put("updated_date", raw.get("updated_date"));
return item;
}
/** 평탄 목록을 부모-자식 트리로 변환 */
@SuppressWarnings("unchecked")
private List<Map<String, Object>> buildTree(List<Map<String, Object>> codes) {
Map<String, Map<String, Object>> byValue = new LinkedHashMap<>();
for (Map<String, Object> code : codes) {
String val = objToStr(code.get("code_value"));
Map<String, Object> node = new LinkedHashMap<>(transformCode(code));
node.put("children", new ArrayList<Map<String, Object>>());
byValue.put(val, node);
}
List<Map<String, Object>> roots = new ArrayList<>();
for (Map<String, Object> code : codes) {
String val = objToStr(code.get("code_value"));
Object parentRaw = code.get("parent_code_value");
String parentVal = (parentRaw != null) ? parentRaw.toString() : null;
Map<String, Object> node = byValue.get(val);
if (parentVal == null || parentVal.isEmpty() || !byValue.containsKey(parentVal)) {
roots.add(node);
} else {
((List<Map<String, Object>>) byValue.get(parentVal).get("children")).add(node);
}
}
return roots;
}
/** boolean/String → VARCHAR 'Y'/'N' 변환 */
/** boolean / String / Number → VARCHAR(1) 'Y'/'N'. */
private String toActiveStr(Object val) {
if (val == null) return "Y";
if (val instanceof Boolean b) return b ? "Y" : "N";
if (val instanceof Number n) return n.intValue() != 0 ? "Y" : "N";
String s = val.toString().toLowerCase();
return ("true".equals(s) || "y".equals(s) || "1".equals(s)) ? "Y" : "N";
}
@@ -463,7 +318,12 @@ public class CommonCodeService extends BaseService {
catch (NumberFormatException e) { return defaultVal; }
}
private String objToStr(Object val) {
return val != null ? val.toString() : "";
private Long toLong(Object val) {
if (val == null) return null;
if (val instanceof Number n) return n.longValue();
String s = val.toString().trim();
if (s.isEmpty() || "null".equalsIgnoreCase(s)) return null;
try { return Long.parseLong(s); }
catch (NumberFormatException e) { return null; }
}
}
@@ -1,6 +1,7 @@
package com.erp.service;
import com.erp.common.BaseService;
import com.erp.constants.InputTypeConstants;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@@ -39,12 +40,6 @@ public class DdlService extends BaseService {
"id", "created_date", "updated_date", "company_code"
);
/** 사용자가 신규 추가하는 컬럼에 허용되는 INPUT_TYPE 8종 (백엔드 백스톱) */
private static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
"text", "number", "date", "code", "entity",
"numbering", "file", "image"
);
public DdlService(JdbcTemplate jdbcTemplate, PlatformTransactionManager transactionManager) {
this.jdbcTemplate = jdbcTemplate;
this.transactionTemplate = new TransactionTemplate(transactionManager);
@@ -146,9 +141,9 @@ public class DdlService extends BaseService {
transactionTemplate.execute(status -> {
jdbcTemplate.execute(ddlQuery);
String inputType = convertToInputType(column);
if (!USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
if (!InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
throw new IllegalArgumentException(
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + USER_SELECTABLE_INPUT_TYPES
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
+ " (받은 값: " + inputType + ")"
);
}
@@ -421,9 +416,9 @@ public class DdlService extends BaseService {
for (int i = 0; i < columns.size(); i++) {
Map<String, Object> col = columns.get(i);
String inputType = convertToInputType(col);
if (!USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
if (!InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
throw new IllegalArgumentException(
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + USER_SELECTABLE_INPUT_TYPES
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
+ " (받은 값: " + inputType + ")"
);
}
@@ -532,6 +527,9 @@ public class DdlService extends BaseService {
case "radio" -> "radio";
case "code" -> "code";
case "entity" -> "entity";
case "file" -> "file";
case "image" -> "image";
case "numbering" -> "numbering";
default -> "text";
};
}
@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
@@ -20,17 +21,22 @@ public class DepartmentService extends BaseService {
// ──────────────────────────────────────────────────
public List<Map<String, Object>> getDepartments(String companyCode) {
return getDepartments(companyCode, false);
return getDepartments(companyCode, false, null);
}
/** soft-delete 대응 — includeDeleted=true 면 DELETED_AT 부서도 포함 */
public List<Map<String, Object>> getDepartments(String companyCode, boolean includeDeleted) {
return getDepartments(companyCode, includeDeleted, null);
}
/** 기준일 필터 — baseDate 가 있으면 해당 시점에 active 한 부서만 반환 (start_date ≤ baseDate ≤ end_date OR end_date IS NULL) */
public List<Map<String, Object>> getDepartments(String companyCode, boolean includeDeleted, String baseDate) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("include_deleted", includeDeleted);
params.put("base_date", baseDate); // null/빈문자면 XML if 가 skip
List<Map<String, Object>> departments = sqlSession.selectList("department.selectDepartments", params);
// member_count를 int로 변환
for (Map<String, Object> dept : departments) {
Object cnt = dept.get("member_count");
if (cnt != null) {
@@ -38,6 +44,10 @@ public class DepartmentService extends BaseService {
} else {
dept.put("member_count", 0);
}
// dept_managers JSON 컬럼들 (String) → List<Map> 으로 파싱
parseManagersJson(dept, "approval_managers");
parseManagersJson(dept, "dept_managers");
parseManagersJson(dept, "org_leaders");
}
return departments;
}
@@ -46,14 +56,26 @@ public class DepartmentService extends BaseService {
public Map<String, Object> getDepartment(String deptCode) {
Map<String, Object> params = new HashMap<>();
params.put("dept_code", deptCode);
return sqlSession.selectOne("department.selectDepartmentByCode", params);
Map<String, Object> dept = sqlSession.selectOne("department.selectDepartmentByCode", params);
if (dept != null) {
parseManagersJson(dept, "approval_managers");
parseManagersJson(dept, "dept_managers");
parseManagersJson(dept, "org_leaders");
}
return dept;
}
/** deleted 부서까지 포함 — 복구 검증 / 부모 deleted 체크 등 internal 흐름용 */
public Map<String, Object> getDepartmentIncludingDeleted(String deptCode) {
Map<String, Object> params = new HashMap<>();
params.put("dept_code", deptCode);
return sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", params);
Map<String, Object> dept = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", params);
if (dept != null) {
parseManagersJson(dept, "approval_managers");
parseManagersJson(dept, "dept_managers");
parseManagersJson(dept, "org_leaders");
}
return dept;
}
@Transactional
@@ -129,11 +151,15 @@ public class DepartmentService extends BaseService {
insertParams.put("location", nullIfBlank(bodyParam(body, "location", "location")));
sqlSession.insert("department.insertDepartment", insertParams);
syncManagers(deptCode, companyCode, body, "approval");
syncManagers(deptCode, companyCode, body, "dept");
syncManagers(deptCode, companyCode, body, "org_leader");
log.info("부서 생성 성공: deptCode={}, deptName={}", deptCode, deptName);
Map<String, Object> findParams = new HashMap<>();
findParams.put("dept_code", deptCode);
return sqlSession.selectOne("department.selectDepartmentByCode", findParams);
return getDepartment(deptCode);
}
@Transactional
@@ -196,10 +222,12 @@ public class DepartmentService extends BaseService {
return null;
}
syncManagers(deptCode, deptCompanyCode, body, "approval");
syncManagers(deptCode, deptCompanyCode, body, "dept");
syncManagers(deptCode, deptCompanyCode, body, "org_leader");
log.info("부서 수정 성공: deptCode={}", deptCode);
Map<String, Object> findParams = new HashMap<>();
findParams.put("dept_code", deptCode);
return sqlSession.selectOne("department.selectDepartmentByCode", findParams);
return getDepartment(deptCode);
}
/**
@@ -338,6 +366,330 @@ public class DepartmentService extends BaseService {
}
}
// ──────────────────────────────────────────────────
// 일괄등록 / 일괄업데이트 (Bulk)
// ──────────────────────────────────────────────────
private static final int BULK_MAX_ROWS = 1000;
/**
* 일괄등록 — preview (read-only validation). DB 쓰기 없음.
* batch 내 dept_name 중복 + DB active 중복 + parent/날짜/매니저 검증.
* 각 row 에 row_index / result(ok|error) / error_detail 채워서 반환.
*/
public List<Map<String, Object>> bulkPreviewCreate(String companyCode, List<Map<String, Object>> rows) {
List<Map<String, Object>> results = new ArrayList<>();
if (rows == null || rows.isEmpty()) return results;
if (rows.size() > BULK_MAX_ROWS) {
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 처리 가능합니다.");
}
Set<String> existingNames = collectActiveDeptNames(companyCode);
Set<String> batchNames = new HashSet<>();
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> input = rows.get(i);
Map<String, Object> out = new HashMap<>(input);
out.put("row_index", i);
String error = validateBulkCreateRow(input, companyCode, existingNames, batchNames);
if (error == null) {
out.put("result", "ok");
out.put("error_detail", null);
String dn = trimString(input.get("dept_name"));
if (dn != null) batchNames.add(dn.toLowerCase());
} else {
out.put("result", "error");
out.put("error_detail", error);
}
results.add(out);
}
return results;
}
/**
* 일괄업데이트 — preview (read-only). mode = department | manager.
*/
public List<Map<String, Object>> bulkPreviewUpdate(String companyCode, String mode, List<Map<String, Object>> rows) {
List<Map<String, Object>> results = new ArrayList<>();
if (rows == null || rows.isEmpty()) return results;
if (rows.size() > BULK_MAX_ROWS) {
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 처리 가능합니다.");
}
if (!"department".equals(mode) && !"manager".equals(mode)) {
throw new IllegalArgumentException("mode 는 department | manager 여야 합니다.");
}
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> input = rows.get(i);
Map<String, Object> out = new HashMap<>(input);
out.put("row_index", i);
String error = validateBulkUpdateRow(input, companyCode, mode);
if (error == null) {
out.put("result", "ok");
out.put("error_detail", null);
} else {
out.put("result", "error");
out.put("error_detail", error);
}
results.add(out);
}
return results;
}
/**
* 일괄등록 — 실제 저장 (@Transactional, all-or-nothing).
* 각 row 를 createDepartment 로 위임 — 검증 + manager sync 까지 동일 흐름.
* 중간 실패 시 IllegalArgumentException 으로 행번호+사유 합쳐서 던짐 → 전체 롤백.
*/
@Transactional
public int bulkSaveCreate(String companyCode, List<Map<String, Object>> rows) {
if (rows == null || rows.isEmpty()) return 0;
if (rows.size() > BULK_MAX_ROWS) {
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 등록 가능합니다.");
}
int inserted = 0;
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> row = rows.get(i);
String label = trimString(row.get("dept_name"));
try {
createDepartment(companyCode, row);
inserted++;
} catch (DuplicateDeptNameException | IllegalArgumentException | IllegalStateException e) {
throw new IllegalArgumentException("" + (i + 1) + " (" + (label != null ? label : "?") + "): " + e.getMessage());
}
}
log.info("부서 일괄등록 성공: company={}, inserted={}", companyCode, inserted);
return inserted;
}
/**
* 일괄업데이트 — 실제 적용 (@Transactional). mode = department | manager.
* department: 부서 정보 부분 업데이트 (row 의 null/미지정 필드는 기존값 보존).
* manager: row 에 명시된 매니저 role 만 sync (delete-all + insert-all).
*/
@Transactional
public int bulkUpdate(String companyCode, String mode, List<Map<String, Object>> rows) {
if (rows == null || rows.isEmpty()) return 0;
if (rows.size() > BULK_MAX_ROWS) {
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 수정 가능합니다.");
}
if (!"department".equals(mode) && !"manager".equals(mode)) {
throw new IllegalArgumentException("mode 는 department | manager 여야 합니다.");
}
int updated = 0;
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> row = rows.get(i);
String deptCode = trimString(row.get("dept_code"));
if (deptCode == null) {
throw new IllegalArgumentException("" + (i + 1) + ": 부서코드(dept_code) 필수.");
}
Map<String, Object> existing = getDepartment(deptCode);
if (existing == null) {
throw new IllegalArgumentException("" + (i + 1) + ": 부서를 찾을 수 없습니다: " + deptCode);
}
String deptCompanyCode = existing.get("company_code") != null
? existing.get("company_code").toString() : null;
if (!companyCode.equals(deptCompanyCode) && !"*".equals(deptCompanyCode)) {
throw new IllegalArgumentException("" + (i + 1) + ": 다른 회사의 부서입니다: " + deptCode);
}
try {
if ("department".equals(mode)) {
Map<String, Object> merged = buildMergedDeptBody(existing, row);
Map<String, Object> result = updateDepartment(deptCode, merged);
if (result == null) {
throw new IllegalStateException("수정 실패: " + deptCode);
}
} else {
// manager mode — row 에 명시된 role 만 sync
if (row.containsKey("approval_managers")) {
syncManagers(deptCode, companyCode, row, "approval");
}
if (row.containsKey("dept_managers")) {
syncManagers(deptCode, companyCode, row, "dept");
}
if (row.containsKey("org_leaders")) {
syncManagers(deptCode, companyCode, row, "org_leader");
}
}
updated++;
} catch (DuplicateDeptNameException | IllegalArgumentException | IllegalStateException e) {
throw new IllegalArgumentException("" + (i + 1) + " (" + deptCode + "): " + e.getMessage());
}
}
log.info("부서 일괄수정 성공: company={}, mode={}, updated={}", companyCode, mode, updated);
return updated;
}
/** company 의 active 부서명 lowercase set — 일괄등록 중복검증용 */
private Set<String> collectActiveDeptNames(String companyCode) {
Set<String> names = new HashSet<>();
for (Map<String, Object> d : getDepartments(companyCode, false, null)) {
Object name = d.get("dept_name");
if (name != null) names.add(name.toString().trim().toLowerCase());
}
return names;
}
/**
* 일괄등록 row 검증. null = ok. 에러 메시지 반환 시 해당 row 는 error.
*/
private String validateBulkCreateRow(Map<String, Object> row, String companyCode,
Set<String> existingNames, Set<String> batchNames) {
String deptName = trimString(row.get("dept_name"));
if (deptName == null || deptName.isEmpty()) return "부서명은 필수입니다.";
String lower = deptName.toLowerCase();
if (batchNames.contains(lower)) return "동일 일괄 내 부서명 중복: " + deptName;
if (existingNames.contains(lower)) return "이미 존재하는 부서명: " + deptName;
String dt = trimString(row.get("dept_type"));
if (dt != null && !"dept".equals(dt) && !"team".equals(dt) && !"temp".equals(dt)) {
return "부서유형은 dept|team|temp 중 하나: " + dt;
}
String parent = trimString(row.get("parent_dept_code"));
String parentErr = validateParentForBulk(parent, companyCode);
if (parentErr != null) return parentErr;
String dateErr = validateDateRange(row);
if (dateErr != null) return dateErr;
String mgrErr = validateManagerIds(row, companyCode);
if (mgrErr != null) return mgrErr;
return null;
}
/**
* 일괄업데이트 row 검증. dept_code 필수 + 회사 격리 + (department mode 한정) 부서명/유형/날짜/부모 검증.
*/
private String validateBulkUpdateRow(Map<String, Object> row, String companyCode, String mode) {
String deptCode = trimString(row.get("dept_code"));
if (deptCode == null) return "부서코드(dept_code) 필수.";
Map<String, Object> existing = getDepartment(deptCode);
if (existing == null) return "부서를 찾을 수 없습니다: " + deptCode;
String deptCompanyCode = existing.get("company_code") != null
? existing.get("company_code").toString() : null;
if (!companyCode.equals(deptCompanyCode) && !"*".equals(deptCompanyCode)) {
return "다른 회사의 부서: " + deptCode;
}
if ("department".equals(mode)) {
String newName = trimString(row.get("dept_name"));
if (newName != null && !newName.isEmpty()) {
String existingName = existing.get("dept_name") != null
? existing.get("dept_name").toString().trim() : "";
if (!newName.equalsIgnoreCase(existingName)) {
Map<String, Object> dupParams = new HashMap<>();
dupParams.put("company_code", companyCode);
dupParams.put("dept_name", newName);
Map<String, Object> dup = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
if (dup != null && !deptCode.equals(dup.get("dept_code"))) {
return "이미 존재하는 부서명: " + newName;
}
}
}
String dt = trimString(row.get("dept_type"));
if (dt != null && !"dept".equals(dt) && !"team".equals(dt) && !"temp".equals(dt)) {
return "부서유형은 dept|team|temp 중 하나: " + dt;
}
String dateErr = validateDateRange(row);
if (dateErr != null) return dateErr;
String parent = trimString(row.get("parent_dept_code"));
String parentErr = validateParentForBulk(parent, companyCode);
if (parentErr != null) return parentErr;
}
return validateManagerIds(row, companyCode);
}
private String validateParentForBulk(String parent, String companyCode) {
if (parent == null) return null;
Map<String, Object> p = sqlSession.selectOne(
"department.selectDepartmentByCodeIncludingDeleted", Map.of("dept_code", parent));
if (p == null) return "상위 부서를 찾을 수 없습니다: " + parent;
if (p.get("deleted_at") != null) return "삭제된 부서를 상위로 지정할 수 없음: " + parent;
Object pc = p.get("company_code");
if (pc == null || (!companyCode.equals(pc.toString()) && !"*".equals(pc.toString()))) {
return "다른 회사의 부서를 상위로 지정 불가: " + parent;
}
return null;
}
private String validateDateRange(Map<String, Object> row) {
String sd = trimString(row.get("start_date"));
String ed = trimString(row.get("end_date"));
if (sd != null && !sd.matches("\\d{4}-\\d{2}-\\d{2}")) return "시작일 형식 오류 (YYYY-MM-DD): " + sd;
if (ed != null && !ed.matches("\\d{4}-\\d{2}-\\d{2}")) return "종료일 형식 오류 (YYYY-MM-DD): " + ed;
if (sd != null && ed != null && sd.compareTo(ed) > 0) return "시작일이 종료일보다 늦을 수 없음.";
return null;
}
private String validateManagerIds(Map<String, Object> row, String companyCode) {
for (String key : new String[]{"approval_managers", "dept_managers", "org_leaders"}) {
Object raw = row.get(key);
if (raw instanceof List<?> list && list.size() > 10) {
return key + " 는 최대 10명까지 등록 가능합니다.";
}
}
List<String> ids = collectManagerUserIds(row);
if (ids.isEmpty()) return null;
Map<String, Object> vParams = new HashMap<>();
vParams.put("user_ids", ids);
vParams.put("company_code", companyCode);
List<String> valid = sqlSession.selectList("department.selectValidUserIds", vParams);
if (valid == null || valid.size() != ids.size()) {
Set<String> invalid = new HashSet<>(ids);
if (valid != null) invalid.removeAll(valid);
return "유효하지 않은 사용자 ID: " + invalid;
}
return null;
}
private List<String> collectManagerUserIds(Map<String, Object> row) {
List<String> ids = new ArrayList<>();
for (String key : new String[]{"approval_managers", "dept_managers", "org_leaders"}) {
Object raw = row.get(key);
if (raw instanceof List<?> list) {
for (Object item : list) {
String uid = null;
if (item instanceof Map<?, ?> m) {
Object v = m.get("user_id");
if (v != null) uid = v.toString().trim();
} else if (item != null) {
uid = item.toString().trim();
}
if (uid != null && !uid.isEmpty() && !ids.contains(uid)) ids.add(uid);
}
}
}
return ids;
}
/**
* 일괄업데이트 department mode — 기존값 + row override 머지.
* row 값이 null/미지정이면 기존값 보존 (PATCH semantic).
* 매니저 매핑 키는 항상 제거 (department mode 에서는 안 다룸).
*/
private Map<String, Object> buildMergedDeptBody(Map<String, Object> existing, Map<String, Object> row) {
Map<String, Object> merged = new HashMap<>();
String[] textKeys = {
"dept_name", "parent_dept_code", "short_name", "dept_type", "org_system",
"approval_manager", "dept_manager", "zipcode", "address1", "address2",
"sort_order", "status", "location"
};
for (String k : textKeys) merged.put(k, existing.get(k));
merged.put("start_date", stringifyDate(existing.get("start_date")));
merged.put("end_date", stringifyDate(existing.get("end_date")));
for (Map.Entry<String, Object> e : row.entrySet()) {
String k = e.getKey();
if ("dept_code".equals(k)) continue;
if (e.getValue() == null) continue;
if ("approval_managers".equals(k) || "dept_managers".equals(k) || "org_leaders".equals(k)) continue;
merged.put(k, e.getValue());
}
return merged;
}
private String stringifyDate(Object date) {
if (date == null) return null;
String s = date.toString();
return s.length() >= 10 ? s.substring(0, 10) : null;
}
// ──────────────────────────────────────────────────
// 부서원 관리
// ──────────────────────────────────────────────────
@@ -472,6 +824,108 @@ public class DepartmentService extends BaseService {
return value;
}
// ── 관리자 매핑 sync ────────────────────────────────
private static final com.fasterxml.jackson.databind.ObjectMapper JSON_MAPPER =
new com.fasterxml.jackson.databind.ObjectMapper();
private static final int MAX_MANAGERS_JSON_BYTES = 64 * 1024;
private void parseManagersJson(Map<String, Object> dept, String key) {
Object raw = dept.get(key);
if (raw == null) {
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
return;
}
String s = raw.toString();
if (s.length() > MAX_MANAGERS_JSON_BYTES) {
log.warn("parseManagersJson 크기 초과 dept_code={} key={} len={}",
dept.get("dept_code"), key, s.length());
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
return;
}
try {
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> parsed = JSON_MAPPER.readValue(s,
new com.fasterxml.jackson.core.type.TypeReference<java.util.List<Map<String, Object>>>() {});
dept.put(key, parsed);
} catch (Exception e) {
log.warn("parseManagersJson 실패 dept_code={} key={} err={}",
dept.get("dept_code"), key, e.getMessage());
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
}
}
/**
* 부서 관리자 role 단위 sync — 항상 delete-all + insert-all 패턴.
* body 의 키는 (role 별): "approval_managers" / "dept_managers" / "org_leaders".
* 각 값은 List&lt;Map&gt; 형태이며 각 element 에서 "user_id" 만 추출.
* 최대 10명 검증 + 빈 user_id 무시.
*/
private void syncManagers(String deptCode, String companyCode, Map<String, Object> body, String role) {
String bodyKey = switch (role) {
case "approval" -> "approval_managers";
case "dept" -> "dept_managers";
case "org_leader" -> "org_leaders";
default -> throw new IllegalArgumentException("Unknown role: " + role);
};
// PUT partial update: 키가 명시적으로 존재할 때만 sync.
// body 에 키 자체가 없으면 기존 매핑 보존 (partial update 의도).
if (!body.containsKey(bodyKey)) {
return;
}
Object raw = body.get(bodyKey);
java.util.List<String> userIds = new java.util.ArrayList<>();
if (raw instanceof java.util.List<?> list) {
for (Object item : list) {
String uid = null;
if (item instanceof Map<?, ?> m) {
Object v = m.get("user_id");
if (v != null) uid = v.toString().trim();
} else if (item != null) {
uid = item.toString().trim();
}
if (uid != null && !uid.isEmpty() && !userIds.contains(uid)) {
userIds.add(uid);
}
}
}
if (userIds.size() > 10) {
String roleLabel = switch (role) {
case "approval" -> "결재 관리자";
case "dept" -> "부서 관리자";
case "org_leader" -> "조직장";
default -> role;
};
throw new IllegalArgumentException(roleLabel + " 는 최대 10명까지 등록 가능합니다.");
}
// user_id 가 같은 회사 (or '*') 에 실존하는지 검증 — cross-tenant 차단
if (!userIds.isEmpty()) {
Map<String, Object> vParams = new HashMap<>();
vParams.put("user_ids", userIds);
vParams.put("company_code", companyCode);
List<String> validUserIds = sqlSession.selectList("department.selectValidUserIds", vParams);
if (validUserIds == null || validUserIds.size() != userIds.size()) {
Set<String> invalid = new HashSet<>(userIds);
if (validUserIds != null) invalid.removeAll(validUserIds);
throw new IllegalArgumentException("유효하지 않은 사용자 ID: " + invalid);
}
}
// delete-all
Map<String, Object> delParams = new HashMap<>();
delParams.put("dept_code", deptCode);
delParams.put("role", role);
sqlSession.delete("department.deleteDeptManagersByDeptAndRole", delParams);
// insert-all
if (!userIds.isEmpty()) {
Map<String, Object> insParams = new HashMap<>();
insParams.put("dept_code", deptCode);
insParams.put("role", role);
insParams.put("user_ids", userIds);
sqlSession.insert("department.insertDeptManagers", insParams);
}
}
// ── 중복 예외 클래스 ────────────────────────────────
public static class DuplicateDeptNameException extends RuntimeException {
@@ -101,20 +101,20 @@ public class EntityReferenceService extends BaseService {
}
public Map<String, Object> getCodeData(Map<String, Object> params) {
String codeCategory = (String) params.get("code_category");
String codeInfo = (String) params.get("code_info");
String companyCode = (String) params.get("company_code");
int limit = toInt(params.getOrDefault("limit", 100));
Object search = params.get("search");
Map<String, Object> queryParams = new HashMap<>();
queryParams.put("code_category", codeCategory);
queryParams.put("code_info", codeInfo);
queryParams.put("company_code", companyCode);
queryParams.put("limit", limit);
if (search != null && !search.toString().isBlank()) {
queryParams.put("search_like", "%" + search + "%");
}
log.info("공통 코드 데이터 조회: category={}, company={}", codeCategory, companyCode);
log.info("공통 코드 데이터 조회: category={}, company={}", codeInfo, companyCode);
List<Map<String, Object>> rows = sqlSession.selectList(NS + "selectCodeData", queryParams);
@@ -128,7 +128,7 @@ public class EntityReferenceService extends BaseService {
Map<String, Object> result = new LinkedHashMap<>();
result.put("options", options);
result.put("code_category", codeCategory);
result.put("code_info", codeInfo);
return result;
}
@@ -394,12 +394,12 @@ public class EntitySearchService extends BaseService {
Map<String, Object> ttcp = new HashMap<>();
ttcp.put("table_name", tableName);
ttcp.put("column_name", columnName);
Map<String, Object> ttcRow = sqlSession.selectOne(NS + "getCodeCategoryInfo", ttcp);
String codeCategory = ttcRow != null ? (String) ttcRow.get("code_category") : null;
Map<String, Object> ttcRow = sqlSession.selectOne(NS + "getCodeInfoInfo", ttcp);
String codeInfo = ttcRow != null ? (String) ttcRow.get("code_info") : null;
if (codeCategory != null) {
if (codeInfo != null) {
Map<String, Object> cip = new HashMap<>();
cip.put("code_category", codeCategory);
cip.put("code_info", codeInfo);
cip.put("raw_values", rawValues);
cip.put("company_code", companyCode);
List<Map<String, Object>> ciRows = sqlSession.selectList(NS + "getCodeInfoList", cip);
@@ -189,7 +189,14 @@ public class NumberingRuleService extends BaseService {
return allocateCode(ruleId, companyCode, null, null);
}
/** POST /:ruleId/reset → 순번 초기화 */
/**
* POST /:ruleId/reset 순번 초기화 (admin)
*
* 테이블 처리:
* 1. numbering_rule_sequences (prefix 발번 카운터, 실제 ground truth) 전체 DELETE 다음 발번 1 부터
* 2. numbering_rules.current_sequence (표시용) 직접 0 으로 set
* - admin 전용 SQL `setCurrentSequenceInRule` 사용 (GREATEST 없음)
*/
@Transactional
public void resetSequence(String ruleId, String companyCode) {
Map<String, Object> params = new HashMap<>();
@@ -197,10 +204,32 @@ public class NumberingRuleService extends BaseService {
params.put("company_code", companyCode);
params.put("current_sequence", 0);
sqlSession.delete(NS + "deleteSequencesByRuleId", params);
sqlSession.update(NS + "updateCurrentSequenceInRule", params);
sqlSession.update(NS + "setCurrentSequenceInRule", params);
log.info("시퀀스 초기화 완료: ruleId={}, companyCode={}", ruleId, companyCode);
}
/**
* PUT /:ruleId/sequence 현재 시퀀스 임의 값으로 수정 (admin)
*
* admin "지금 카운터를 N 으로 set" 의도. 다음 발번은 N+1 부터.
* 테이블 처리:
* 1. numbering_rule_sequences (prefix 실제 카운터) 전체 DELETE
* 다음 allocate row INSERT (current_sequence=1) 되거나
* 또는 admin set 값을 기반으로 시작하도록 별도 처리 필요할 있음
* - 운영 단계라 historical sequence 폐기 안전
* 2. numbering_rules.current_sequence newSequence set
*/
@Transactional
public void updateRuleSequence(String ruleId, Integer newSequence, String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("rule_id", ruleId);
params.put("company_code", companyCode);
params.put("current_sequence", newSequence);
sqlSession.delete(NS + "deleteSequencesByRuleId", params);
sqlSession.update(NS + "setCurrentSequenceInRule", params);
log.info("시퀀스 수정 완료: ruleId={}, newSequence={}, companyCode={}", ruleId, newSequence, companyCode);
}
// ================================================================
// Available Rules
// ================================================================
@@ -426,12 +455,31 @@ public class NumberingRuleService extends BaseService {
return seq == null ? 0L : ((Number) seq).longValue();
}
/** 순번 증가 UPSERT ON CONFLICT DO UPDATE RETURNING */
/**
* 순번 증가 UPSERT ON CONFLICT DO UPDATE RETURNING.
*
* INSERT 분기의 base :
* - 동일 prefix row 없을 ( 발번 / admin reset / 카테고리 )
* `numbering_rules.current_sequence + 1` 부터 시작.
* - 의미: admin sequence N 으로 set 하고 historical sequences 비웠을 ,
* 다음 발번이 N+1 부터 정확히 시작되도록.
* - numbering_rules row 없는 비정상 케이스는 0+1=1.
*/
private long incrementSequenceForPrefix(String ruleId, String companyCode, String prefixKey) {
String sql = """
INSERT INTO numbering_rule_sequences
(rule_id, company_code, prefix_key, current_sequence, last_allocated_at)
VALUES (?, ?, ?, 1, NOW())
VALUES (
?, ?, ?,
COALESCE((
SELECT current_sequence
FROM numbering_rules
WHERE rule_id = ?
AND (company_code = ? OR company_code = '*')
LIMIT 1
), 0) + 1,
NOW()
)
ON CONFLICT (rule_id, company_code, prefix_key)
DO UPDATE SET
current_sequence = numbering_rule_sequences.current_sequence + 1,
@@ -439,7 +487,7 @@ public class NumberingRuleService extends BaseService {
RETURNING current_sequence
""";
Long newSeq = jdbcTemplate.queryForObject(sql, Long.class,
ruleId, companyCode, prefixKey);
ruleId, companyCode, prefixKey, ruleId, companyCode);
return newSeq != null ? newSeq : 1L;
}
@@ -994,7 +994,7 @@ public class ScreenManagementService extends BaseService {
}
@Transactional
public int copyCodeCategoryAndCodes(Map<String, Object> body) {
public int copyCodeInfoAndCodes(Map<String, Object> body) {
String sourceCompanyCode = (String) body.get("source_company_code");
String targetCompanyCode = (String) body.get("target_company_code");
String userId = (String) body.get("user_id");
@@ -1002,16 +1002,16 @@ public class ScreenManagementService extends BaseService {
Map<String, Object> params = new HashMap<>();
params.put("source_company_code", sourceCompanyCode);
List<Map<String, Object>> categories = sqlSession.selectList(NS + "selectCodeCategoryForCopy", params);
List<Map<String, Object>> categories = sqlSession.selectList(NS + "selectCodeInfoForCopy", params);
int count = 0;
for (Map<String, Object> cat : categories) {
Map<String, Object> cp = new HashMap<>(cat);
cp.put("target_company_code", targetCompanyCode);
sqlSession.insert(NS + "upsertCodeCategory", cp);
sqlSession.insert(NS + "upsertCodeInfo", cp);
Map<String, Object> codeParams = new HashMap<>();
codeParams.put("source_company_code", sourceCompanyCode);
codeParams.put("code_category", cat.get("category_code"));
codeParams.put("code_info", cat.get("category_code"));
List<Map<String, Object>> codes = sqlSession.selectList(NS + "selectCodeInfoForCopy", codeParams);
for (Map<String, Object> code : codes) {
Map<String, Object> cop = new HashMap<>(code);
@@ -1,368 +0,0 @@
package com.erp.service;
import com.erp.common.BaseService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
@Service
@Slf4j
public class TableCategoryValueService extends BaseService {
private static final String NS = "tableCategoryValue.";
//
// Category Columns
//
public List<Map<String, Object>> getCategoryColumns(Map<String, Object> params) {
log.info("카테고리 컬럼 목록 조회: tableName={}, companyCode={}",
params.get("table_name"), params.get("company_code"));
return sqlSession.selectList(NS + "getCategoryColumnList", params);
}
public List<Map<String, Object>> getAllCategoryColumns(Map<String, Object> params) {
log.info("전체 카테고리 컬럼 목록 조회: companyCode={}", params.get("company_code"));
return sqlSession.selectList(NS + "getAllCategoryColumnList", params);
}
//
// Category Values Read
//
public List<Map<String, Object>> getCategoryValues(Map<String, Object> params) {
log.info("카테고리 값 목록 조회: tableName={}, columnName={}, companyCode={}",
params.get("table_name"), params.get("column_name"), params.get("company_code"));
List<Map<String, Object>> flatList = sqlSession.selectList(NS + "getCategoryValueList", params);
List<Map<String, Object>> hierarchy = buildHierarchy(flatList, null);
log.info("카테고리 값 {}개 조회 완료 (평면)", flatList.size());
return hierarchy;
}
//
// Category Values Write
//
@Transactional
public Map<String, Object> addCategoryValue(Map<String, Object> params) {
String tableName = (String) params.get("table_name");
String columnName = (String) params.get("column_name");
String valueCode = (String) params.get("value_code");
String valueLabel = (String) params.get("value_label");
String companyCode = (String) params.get("company_code");
log.info("카테고리 값 추가: tableName={}, columnName={}, valueCode={}, companyCode={}",
tableName, columnName, valueCode, companyCode);
Integer codeDup = sqlSession.selectOne(NS + "countDuplicateCode", params);
if (codeDup != null && codeDup > 0) {
throw new IllegalArgumentException("이미 존재하는 코드입니다");
}
Integer labelDup = sqlSession.selectOne(NS + "countDuplicateLabel", params);
if (labelDup != null && labelDup > 0) {
throw new IllegalArgumentException(
"이미 동일한 이름의 카테고리 값이 존재합니다: \"" + valueLabel + "\"");
}
if (params.get("value_order") == null) params.put("value_order", 0);
if (params.get("depth") == null) params.put("depth", 1);
if (params.get("is_active") == null) params.put("is_active", true);
if (params.get("is_default") == null) params.put("is_default", false);
sqlSession.insert(NS + "insertCategoryValue", params);
long valueId = toLong(params.get("value_id"));
log.info("카테고리 값 추가 완료: valueId={}", valueId);
Map<String, Object> fetchP = new HashMap<>();
fetchP.put("value_id", valueId);
return sqlSession.selectOne(NS + "getCategoryValueInfo", fetchP);
}
@Transactional
public Map<String, Object> updateCategoryValue(Map<String, Object> params) {
long valueId = toLong(params.get("value_id"));
String companyCode = (String) params.get("company_code");
log.info("카테고리 값 수정: valueId={}, companyCode={}", valueId, companyCode);
if (params.get("value_label") != null) {
Map<String, Object> current = sqlSession.selectOne(NS + "getCategoryValueLabelInfo",
Map.of("value_id", valueId));
if (current != null) {
Map<String, Object> labelP = new HashMap<>();
labelP.put("table_name", current.get("table_name"));
labelP.put("column_name", current.get("column_name"));
labelP.put("company_code", current.get("company_code"));
labelP.put("value_label", params.get("value_label"));
labelP.put("value_id", valueId);
Integer dup = sqlSession.selectOne(NS + "countDuplicateLabelExcludeSelf", labelP);
if (dup != null && dup > 0) {
throw new IllegalArgumentException(
"이미 동일한 이름의 카테고리 값이 존재합니다: \""
+ params.get("value_label") + "\"");
}
}
}
params.put("value_id", valueId);
Integer rows = sqlSession.selectOne(NS + "updateCategoryValue", params);
if (rows == null || rows == 0) {
// update returns affected rows via selectOne workaround; use update method instead
sqlSession.update(NS + "updateCategoryValue", params);
}
Map<String, Object> fetchP = new HashMap<>();
fetchP.put("value_id", valueId);
return sqlSession.selectOne(NS + "getCategoryValueInfo", fetchP);
}
//
// Category Values Delete
//
@Transactional
public void deleteCategoryValue(Map<String, Object> params) {
long valueId = toLong(params.get("value_id"));
String companyCode = (String) params.get("company_code");
log.info("카테고리 값 삭제: valueId={}, companyCode={}", valueId, companyCode);
List<Map<String, Object>> childRows = sqlSession.selectList(NS + "getChildValueIdList", params);
List<Long> allIds = new ArrayList<>();
allIds.add(valueId);
childRows.forEach(r -> allIds.add(toLong(r.get("value_id"))));
log.info("삭제 대상 카테고리 값 수집 완료: 자신={}, 하위={}", valueId, childRows.size());
for (Long id : allIds) {
checkNotInUse(id, companyCode);
}
List<Long> reversed = new ArrayList<>(allIds);
Collections.reverse(reversed);
for (Long id : reversed) {
Map<String, Object> delP = new HashMap<>();
delP.put("value_id", id);
delP.put("company_code", companyCode);
sqlSession.delete(NS + "deleteValueById", delP);
}
log.info("카테고리 값 삭제 완료: totalDeleted={}", allIds.size());
}
@Transactional
public void bulkDeleteCategoryValues(Map<String, Object> params) {
log.info("카테고리 값 일괄 삭제: count={}, companyCode={}",
((List<?>) params.get("value_ids")).size(), params.get("company_code"));
sqlSession.update(NS + "bulkSoftDeleteValues", params);
}
@Transactional
public void reorderCategoryValues(Map<String, Object> params) {
List<?> rawIds = (List<?>) params.get("ordered_value_ids");
String companyCode = (String) params.get("company_code");
log.info("카테고리 값 순서 변경: count={}, companyCode={}", rawIds.size(), companyCode);
for (int i = 0; i < rawIds.size(); i++) {
Map<String, Object> p = new HashMap<>();
p.put("value_id", toLong(rawIds.get(i)));
p.put("value_order", i + 1);
p.put("company_code", companyCode);
sqlSession.update(NS + "updateValueOrder", p);
}
}
//
// Column Mapping
//
public Map<String, Object> getColumnMapping(Map<String, Object> params) {
log.info("컬럼 매핑 조회: tableName={}, menuObjid={}, companyCode={}",
params.get("table_name"), params.get("menu_objid"), params.get("company_code"));
List<Map<String, Object>> rows = sqlSession.selectList(NS + "getColumnMappingList", params);
Map<String, Object> mapping = new LinkedHashMap<>();
for (Map<String, Object> row : rows) {
mapping.put(String.valueOf(row.get("logical_column_name")),
String.valueOf(row.get("physical_column_name")));
}
log.info("컬럼 매핑 {}개 조회 완료", mapping.size());
return mapping;
}
@Transactional
public Map<String, Object> createColumnMapping(Map<String, Object> params) {
String tableName = (String) params.get("table_name");
String logicalColumnName = (String) params.get("logical_column_name");
String physicalColumnName = (String) params.get("physical_column_name");
log.info("컬럼 매핑 생성: tableName={}, logical={}, physical={}, companyCode={}",
tableName, logicalColumnName, physicalColumnName, params.get("company_code"));
Integer colExists = sqlSession.selectOne(NS + "checkPhysicalColumnExists", params);
if (colExists == null || colExists == 0) {
throw new IllegalArgumentException(
"테이블 " + tableName + "에 컬럼 " + physicalColumnName + "이(가) 존재하지 않습니다");
}
sqlSession.insert(NS + "upsertColumnMapping", params);
Map<String, Object> result = sqlSession.selectOne(NS + "getColumnMappingInfo", params);
log.info("컬럼 매핑 생성 완료: mappingId={}", result != null ? result.get("mapping_id") : "?");
return result;
}
public List<Map<String, Object>> getLogicalColumns(Map<String, Object> params) {
log.info("논리적 컬럼 목록 조회: tableName={}, menuObjid={}, companyCode={}",
params.get("table_name"), params.get("menu_objid"), params.get("company_code"));
return sqlSession.selectList(NS + "getLogicalColumnList", params);
}
@Transactional
public void deleteColumnMapping(Map<String, Object> params) {
int deleted = sqlSession.delete(NS + "deleteColumnMappingById", params);
if (deleted == 0) {
throw new IllegalArgumentException("컬럼 매핑을 찾을 수 없거나 권한이 없습니다");
}
log.info("컬럼 매핑 삭제 완료: mappingId={}", params.get("mapping_id"));
}
@Transactional
public int deleteColumnMappingsByColumn(Map<String, Object> params) {
int deleted = sqlSession.delete(NS + "deleteColumnMappingsByColumn", params);
log.info("테이블+컬럼 기준 매핑 삭제 완료: tableName={}, columnName={}, deletedCount={}",
params.get("table_name"), params.get("column_name"), deleted);
return deleted;
}
//
// Labels by Codes
//
public Map<String, Object> getCategoryLabelsByCodes(Map<String, Object> params) {
Object rawCodes = params.get("value_codes");
if (!(rawCodes instanceof List) || ((List<?>) rawCodes).isEmpty()) {
return new LinkedHashMap<>();
}
log.info("카테고리 코드로 라벨 조회: count={}, companyCode={}",
((List<?>) rawCodes).size(), params.get("company_code"));
List<Map<String, Object>> rows = sqlSession.selectList(NS + "getLabelListByCodes", params);
Map<String, Object> labels = new LinkedHashMap<>();
for (Map<String, Object> row : rows) {
String code = String.valueOf(row.get("value_code"));
if (!labels.containsKey(code)) {
labels.put(code, row.get("value_label"));
}
}
log.info("카테고리 라벨 {}개 조회 완료", labels.size());
return labels;
}
//
// Second-Level Menus
//
public List<Map<String, Object>> getSecondLevelMenus(Map<String, Object> params) {
log.info("2레벨 메뉴 목록 조회: companyCode={}", params.get("company_code"));
Integer hasCC = sqlSession.selectOne(NS + "checkMenuInfoHasCompanyCode", null);
params.put("has_company_code", hasCC != null && hasCC > 0);
log.info("menu_info.company_code 컬럼 존재 여부: {}", hasCC != null && hasCC > 0);
List<Map<String, Object>> menus = sqlSession.selectList(NS + "getSecondLevelMenuList", params);
log.info("2레벨 메뉴 {}개 조회 완료", menus.size());
return menus;
}
//
// private helpers
//
private void checkNotInUse(long valueId, String companyCode) {
Map<String, Object> p = new HashMap<>();
p.put("value_id", valueId);
p.put("company_code", companyCode);
Map<String, Object> valueInfo = sqlSession.selectOne(NS + "getCategoryValueUsageInfo", p);
if (valueInfo == null) {
throw new IllegalArgumentException("카테고리 값을 찾을 수 없습니다");
}
String tableName = String.valueOf(valueInfo.get("table_name"));
String columnName = String.valueOf(valueInfo.get("column_name"));
String valueCode = String.valueOf(valueInfo.get("value_code"));
String valueLabel = String.valueOf(valueInfo.get("value_label"));
String safeTable = sanitize(tableName);
String safeColumn = sanitize(columnName);
if (safeTable.isEmpty() || safeColumn.isEmpty()) return;
Integer tableExists = sqlSession.selectOne(NS + "checkTableExistsForUsage",
Map.of("table_name", safeTable));
if (tableExists == null || tableExists == 0) return;
Map<String, Object> countP = new HashMap<>();
countP.put("safe_table_name", safeTable);
countP.put("safe_column_name", safeColumn);
countP.put("value_code", valueCode);
countP.put("company_code", companyCode);
Integer count = sqlSession.selectOne(NS + "countValueUsageInTable", countP);
if (count != null && count > 0) {
List<Map<String, Object>> menus = sqlSession.selectList(NS + "getMenuListUsingTable",
Map.of("table_name", tableName, "company_code", companyCode));
StringBuilder msg = new StringBuilder();
msg.append("카테고리 \"").append(valueLabel).append("\"을(를) 삭제할 수 없습니다.\n");
msg.append("\n현재 ").append(count).append("개의 데이터에서 사용 중입니다.");
if (!menus.isEmpty()) {
String menuNames = menus.stream()
.map(m -> String.valueOf(m.get("menu_name")))
.collect(Collectors.joining(", "));
msg.append("\n\n다음 메뉴에서 사용 중입니다:\n").append(menuNames);
}
msg.append("\n\n메뉴에서 사용하는 카테고리 항목을 수정한 후 다시 삭제해주세요.");
throw new IllegalArgumentException(msg.toString());
}
}
private List<Map<String, Object>> buildHierarchy(
List<Map<String, Object>> values, Object parentId) {
List<Map<String, Object>> result = new ArrayList<>();
for (Map<String, Object> v : values) {
Object pid = v.get("parent_value_id");
if (Objects.equals(pid, parentId)) {
List<Map<String, Object>> children = buildHierarchy(values, v.get("value_id"));
v.put("children", children);
result.add(v);
}
}
return result;
}
private String sanitize(String name) {
if (name == null) return "";
return name.replaceAll("[^a-zA-Z0-9_]", "");
}
private long toLong(Object val) {
if (val == null) return 0L;
if (val instanceof Number) return ((Number) val).longValue();
try { return Long.parseLong(val.toString()); } catch (NumberFormatException e) { return 0L; }
}
}
@@ -1,6 +1,8 @@
package com.erp.service;
import com.erp.common.BaseService;
import com.erp.constants.InputTypeConstants;
import com.erp.constants.InputTypeContext;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -26,10 +28,14 @@ public class TableManagementService extends BaseService {
private static final String NS = "tableManagement.";
/** 사용자가 직접 선택 가능한 INPUT_TYPE 8종 (INSERT/UPDATE-type 검증용) */
private static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
"text", "number", "date", "code", "entity",
"numbering", "file", "image"
/** 로그 테이블 컬럼 정의에 허용하는 PostgreSQL data_type 화이트리스트.
* information_schema.columns.data_type 값과 정확히 일치해야 한다. */
private static final Set<String> ALLOWED_LOG_COLUMN_TYPES = Set.of(
"varchar", "text", "char", "character", "character varying",
"integer", "bigint", "smallint", "numeric", "decimal", "real", "double precision",
"boolean", "date", "timestamp", "timestamp without time zone", "timestamp with time zone",
"time", "time without time zone", "time with time zone",
"uuid", "json", "jsonb", "bytea"
);
//
@@ -151,16 +157,19 @@ public class TableManagementService extends BaseService {
Map<String, Object> settings, String companyCode) {
ensureTableInLabels(tableName);
boolean inputTypeChanged = settings.containsKey("input_type");
String ctx = inputTypeChanged ? "user-update-type" : "user-update-other";
String inputType = normalizeInputType((String) settings.get("input_type"), ctx);
Object rawInputType = settings.get("input_type");
boolean inputTypeChanged = settings.containsKey("input_type") && rawInputType != null;
InputTypeContext ctx = inputTypeChanged
? InputTypeContext.USER_UPDATE_TYPE
: InputTypeContext.USER_UPDATE_OTHER;
String inputType = normalizeInputType((String) rawInputType, ctx);
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("column_name", columnName);
params.put("column_label", settings.get("column_label"));
params.put("input_type", inputType);
params.put("detail_settings", toJsonString(settings.get("detail_settings")));
params.put("code_category", "code".equals(inputType) ? settings.get("code_category") : null);
params.put("code_info", "code".equals(inputType) ? settings.get("code_info") : null);
params.put("code_value", "code".equals(inputType) ? settings.get("code_value") : null);
params.put("reference_table", "entity".equals(inputType) ? settings.get("reference_table") : null);
params.put("reference_column", "entity".equals(inputType) ? settings.get("reference_column") : null);
@@ -210,7 +219,7 @@ public class TableManagementService extends BaseService {
public void updateColumnInputType(String tableName, String columnName,
String inputType, String companyCode,
Map<String, Object> detailSettings) {
String finalType = normalizeInputType(inputType, "user-update-type");
String finalType = normalizeInputType(inputType, InputTypeContext.USER_UPDATE_TYPE);
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("column_name", columnName);
@@ -463,6 +472,369 @@ public class TableManagementService extends BaseService {
return result;
}
//
// 동적 테이블 집계 (count / sum / avg / min / max / distinctCount)
//
private static final Set<String> AGG_TYPES = Set.of(
"count", "sum", "avg", "min", "max", "distinctCount"
);
private static final Set<String> FILTER_OPS = Set.of(
"=", "!=", ">", "<", ">=", "<=",
"like", "in", "notIn", "isNull", "isNotNull"
);
/**
* 단일 집계 계산.
*
* count column 없이도 동작 (COUNT(*))
* sum/avg/min/max column 필수
* distinctCount column 필수 (COUNT(DISTINCT col))
*/
public Map<String, Object> aggregateTableData(String tableName, Map<String, Object> options) {
String safeTable = sanitize(tableName);
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
}
String aggregation = options.get("aggregation") instanceof String s ? s : "count";
if (!AGG_TYPES.contains(aggregation)) {
throw new IllegalArgumentException("지원하지 않는 집계 타입: " + aggregation);
}
String columnName = options.get("columnName") instanceof String s ? s : null;
String safeColumn = columnName != null ? sanitize(columnName) : "";
boolean columnRequired = !"count".equals(aggregation);
if (columnRequired) {
if (safeColumn.isBlank()) {
throw new IllegalArgumentException(aggregation + " 은 columnName 이 필요합니다.");
}
if (!hasColumn(safeTable, safeColumn)) {
throw new IllegalArgumentException("컬럼이 존재하지 않습니다: " + tableName + "." + columnName);
}
} else if (!safeColumn.isBlank() && !hasColumn(safeTable, safeColumn)) {
// count + columnName 들어왔지만 실제 없는 컬럼이면 명확히 거절
throw new IllegalArgumentException("컬럼이 존재하지 않습니다: " + tableName + "." + columnName);
}
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
List<Object> values = new ArrayList<>();
String where = buildAggregateWhere(safeTable, filters, values);
String selectExpr;
if ("count".equals(aggregation)) {
selectExpr = !safeColumn.isBlank()
? String.format("COUNT(\"%s\")", safeColumn)
: "COUNT(*)";
} else if ("distinctCount".equals(aggregation)) {
selectExpr = String.format("COUNT(DISTINCT \"%s\")", safeColumn);
} else {
// sum / avg / min / max 숫자 캐스팅 (avg numeric, 나머지는 컬럼 타입 그대로)
String upper = aggregation.toUpperCase();
if ("AVG".equals(upper) || "SUM".equals(upper)) {
selectExpr = String.format("%s(CAST(\"%s\" AS NUMERIC))", upper, safeColumn);
} else {
selectExpr = String.format("%s(\"%s\")", upper, safeColumn);
}
}
String sql = String.format("SELECT %s AS agg_value FROM \"%s\" main %s",
selectExpr, safeTable, where);
Number raw = jdbcTemplate.queryForObject(sql, Number.class, values.toArray());
double value = raw != null ? raw.doubleValue() : 0d;
Map<String, Object> result = new LinkedHashMap<>();
result.put("value", value);
return result;
}
private String buildAggregateWhere(String safeTable, List<Map<String, Object>> filters, List<Object> values) {
if (filters == null || filters.isEmpty()) return "";
List<String> clauses = new ArrayList<>();
for (Map<String, Object> f : filters) {
if (f == null) continue;
String col = f.get("column") instanceof String s ? s : null;
String op = f.get("operator") instanceof String s ? s : "=";
if (col == null || col.isBlank()) continue;
String safeCol = sanitize(col);
if (safeCol.isBlank() || !hasColumn(safeTable, safeCol)) continue;
if (!FILTER_OPS.contains(op)) continue;
Object val = f.get("value");
switch (op) {
case "isNull":
clauses.add(String.format("\"%s\" IS NULL", safeCol));
break;
case "isNotNull":
clauses.add(String.format("\"%s\" IS NOT NULL", safeCol));
break;
case "in":
case "notIn": {
List<Object> list = toList(val);
if (list.isEmpty()) continue;
String marks = list.stream().map(v -> "?").collect(Collectors.joining(", "));
String kw = "in".equals(op) ? "IN" : "NOT IN";
clauses.add(String.format("\"%s\" %s (%s)", safeCol, kw, marks));
values.addAll(list);
break;
}
case "like":
if (isEmptyAggregateFilterValue(val)) continue;
clauses.add(String.format("\"%s\"::text ILIKE ?", safeCol));
values.add("%" + val + "%");
break;
default:
if (isEmptyAggregateFilterValue(val)) continue;
clauses.add(String.format("\"%s\" %s ?", safeCol, op));
values.add(val);
}
}
return clauses.isEmpty() ? "" : "WHERE " + String.join(" AND ", clauses);
}
private List<Map<String, Object>> normalizeAggregateFilters(Object rawFilters) {
if (!(rawFilters instanceof List<?> rawList) || rawList.isEmpty()) {
return Collections.emptyList();
}
List<Map<String, Object>> out = new ArrayList<>();
for (Object item : rawList) {
if (item instanceof Map<?, ?> rawMap) {
Map<String, Object> normalized = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
if (entry.getKey() instanceof String key) {
normalized.put(key, entry.getValue());
}
}
if (!normalized.isEmpty()) out.add(normalized);
}
}
return out;
}
private boolean isEmptyAggregateFilterValue(Object val) {
if (val == null) return true;
if (val instanceof String s) return s.isBlank();
if (val instanceof Collection<?> c) return c.isEmpty();
return false;
}
private List<Object> toList(Object val) {
if (val == null) return List.of();
if (val instanceof List<?> l) {
List<Object> out = new ArrayList<>();
for (Object o : l) {
if (o == null) continue;
if (o instanceof String s && s.isBlank()) continue;
out.add(o);
}
return out;
}
if (val instanceof String s) {
if (s.isBlank()) return List.of();
return Arrays.stream(s.split(","))
.map(String::trim)
.filter(p -> !p.isEmpty())
.map(p -> (Object) p)
.collect(Collectors.toList());
}
return List.of(val);
}
//
// 그룹별 집계 (Phase G.3 canonical chart )
//
/**
* groupBy 컬럼별로 집계 결과 반환. canonical chart 컴포넌트가 bar / line / donut /
* horizontalBar 모두에서 같은 endpoint 사용.
*
* body :
* { "groupBy": "status", "aggregation": "count", "filters": [...] }
* { "groupBy": "dept_code", "aggregation": "sum", "valueColumn": "amount", "limit": 12 }
*
* response:
* { "rows": [{ "group": "재직", "value": 35 }, { "group": "휴직", "value": 4 }] }
*/
public Map<String, Object> aggregateTableGroup(String tableName, Map<String, Object> options) {
String safeTable = sanitize(tableName);
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
}
String groupBy = options.get("groupBy") instanceof String s ? s : null;
String safeGroupBy = groupBy != null ? sanitize(groupBy) : "";
if (safeGroupBy.isBlank() || !hasColumn(safeTable, safeGroupBy)) {
throw new IllegalArgumentException("groupBy 컬럼이 존재하지 않습니다: " + tableName + "." + groupBy);
}
String aggregation = options.get("aggregation") instanceof String s ? s : "count";
if (!AGG_TYPES.contains(aggregation)) {
throw new IllegalArgumentException("지원하지 않는 집계 타입: " + aggregation);
}
String valueColumn = options.get("valueColumn") instanceof String s ? s : null;
if (valueColumn == null && options.get("columnName") instanceof String s) valueColumn = s;
String safeValueCol = valueColumn != null ? sanitize(valueColumn) : "";
boolean columnRequired = !"count".equals(aggregation);
if (columnRequired) {
if (safeValueCol.isBlank()) {
throw new IllegalArgumentException(aggregation + " 은 valueColumn 이 필요합니다.");
}
if (!hasColumn(safeTable, safeValueCol)) {
throw new IllegalArgumentException("valueColumn 이 존재하지 않습니다: " + tableName + "." + valueColumn);
}
} else if (!safeValueCol.isBlank() && !hasColumn(safeTable, safeValueCol)) {
throw new IllegalArgumentException("valueColumn 이 존재하지 않습니다: " + tableName + "." + valueColumn);
}
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
int limit = toInt(options.get("limit"), 50);
if (limit < 1) limit = 50;
if (limit > 500) limit = 500;
String orderDir = options.get("orderDir") instanceof String s
&& ("asc".equalsIgnoreCase(s) || "desc".equalsIgnoreCase(s))
? s.toUpperCase()
: "DESC";
List<Object> values = new ArrayList<>();
String where = buildAggregateWhere(safeTable, filters, values);
String selectExpr;
if ("count".equals(aggregation)) {
selectExpr = !safeValueCol.isBlank()
? String.format("COUNT(\"%s\")", safeValueCol)
: "COUNT(*)";
} else if ("distinctCount".equals(aggregation)) {
selectExpr = String.format("COUNT(DISTINCT \"%s\")", safeValueCol);
} else {
String upper = aggregation.toUpperCase();
if ("AVG".equals(upper) || "SUM".equals(upper)) {
selectExpr = String.format("%s(CAST(\"%s\" AS NUMERIC))", upper, safeValueCol);
} else {
selectExpr = String.format("%s(\"%s\")", upper, safeValueCol);
}
}
String sql = String.format(
"SELECT \"%s\" AS group_value, %s AS agg_value " +
"FROM \"%s\" main %s " +
"GROUP BY \"%s\" " +
"ORDER BY agg_value %s NULLS LAST " +
"LIMIT %d",
safeGroupBy, selectExpr, safeTable, where, safeGroupBy, orderDir, limit);
List<Map<String, Object>> rawRows = jdbcTemplate.queryForList(sql, values.toArray());
List<Map<String, Object>> rows = new ArrayList<>();
for (Map<String, Object> r : rawRows) {
Object groupVal = r.get("group_value");
Object aggVal = r.get("agg_value");
double value = aggVal instanceof Number ? ((Number) aggVal).doubleValue() : 0d;
Map<String, Object> out = new LinkedHashMap<>();
out.put("group", groupVal);
out.put("value", value);
rows.add(out);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("rows", rows);
return result;
}
//
// 가벼운 select-rows (Phase G.3.1 card-list / grouped-table )
//
/**
* OptionFilter 호환 필터 + orderBy + limit/offset 임의 컬럼들의 row 들을 반환.
* `getTableData` 페이지네이션 + ILIKE search 묶여 있어 view 컴포넌트가
* 사용하기 무겁다. 메서드는 raw rows 깔끔하게 반환.
*
* body :
* { "columns": ["user_name", "dept_code"], "filters": [...], "limit": 50 }
* { "groupBy 없이 단순 다중 컬럼", "orderBy": [{ "column": "created_date", "direction": "desc" }] }
*
* response:
* { "rows": [{...}, {...}] }
*/
public Map<String, Object> selectTableRows(String tableName, Map<String, Object> options) {
String safeTable = sanitize(tableName);
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
}
@SuppressWarnings("unchecked")
List<Object> rawColumns = options.get("columns") instanceof List<?> raw
? (List<Object>) raw : Collections.emptyList();
List<String> safeColumns = new ArrayList<>();
for (Object c : rawColumns) {
if (!(c instanceof String s)) continue;
String safe = sanitize(s);
if (safe.isBlank()) continue;
if (!hasColumn(safeTable, safe)) continue;
safeColumns.add(safe);
}
String selectExpr;
if (safeColumns.isEmpty()) {
selectExpr = "main.*";
} else {
selectExpr = safeColumns.stream()
.map(c -> "\"" + c + "\"")
.collect(Collectors.joining(", "));
}
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
List<Object> values = new ArrayList<>();
String where = buildAggregateWhere(safeTable, filters, values);
// orderBy: [{ column, direction }]
List<Map<String, Object>> orderBy = normalizeAggregateFilters(options.get("orderBy"));
List<String> orderClauses = new ArrayList<>();
for (Map<String, Object> ob : orderBy) {
if (ob == null) continue;
String col = ob.get("column") instanceof String s ? s : null;
if (col == null) continue;
String safeCol = sanitize(col);
if (safeCol.isBlank() || !hasColumn(safeTable, safeCol)) continue;
String dir = ob.get("direction") instanceof String s
&& "desc".equalsIgnoreCase(s) ? "DESC" : "ASC";
orderClauses.add(String.format("\"%s\" %s", safeCol, dir));
}
String order = "";
if (!orderClauses.isEmpty()) {
order = "ORDER BY " + String.join(", ", orderClauses);
} else if (hasColumn(safeTable, "created_date")) {
order = "ORDER BY main.created_date DESC";
}
int limit = toInt(options.get("limit"), 50);
if (limit < 1) limit = 50;
if (limit > 500) limit = 500;
int offset = toInt(options.get("offset"), 0);
if (offset < 0) offset = 0;
String sql = String.format(
"SELECT %s FROM \"%s\" main %s %s LIMIT %d OFFSET %d",
selectExpr, safeTable, where, order, limit, offset);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, values.toArray());
Map<String, Object> result = new LinkedHashMap<>();
result.put("rows", rows);
return result;
}
@Transactional
public Map<String, Object> addTableData(String tableName, Map<String, Object> data) {
String safeTable = sanitize(tableName);
@@ -611,9 +983,14 @@ public class TableManagementService extends BaseService {
@Transactional
public void createLogTable(String tableName, List<String> logColumns, boolean isActive) {
String logTableName = tableName + "_log";
String safeLog = sanitize(logTableName);
String safeOrig = sanitize(tableName);
if (safeOrig.isBlank()) {
throw new IllegalArgumentException("유효하지 않은 테이블명입니다.");
}
String safeLog = sanitize(safeOrig + "_log");
if (safeLog.isBlank()) {
throw new IllegalArgumentException("유효하지 않은 로그 테이블명입니다.");
}
// 원본 테이블 컬럼 정보 조회
Map<String, String> colTypes = getColumnTypes(safeOrig);
@@ -625,13 +1002,32 @@ public class TableManagementService extends BaseService {
colDefs.add("log_date TIMESTAMP DEFAULT NOW()");
colDefs.add("log_user VARCHAR(100)");
List<String> targetCols = (logColumns != null && !logColumns.isEmpty())
? logColumns.stream().map(this::sanitize).filter(c -> !c.isBlank()).collect(Collectors.toList())
List<String> requestedCols = (logColumns != null && !logColumns.isEmpty())
? logColumns
: new ArrayList<>(colTypes.keySet());
for (String col : targetCols) {
String type = colTypes.getOrDefault(col, "TEXT");
colDefs.add(String.format("\"%s\" %s", col, type));
// 실제 SQL 들어간 컬럼만 메타에 저장 (skip 것은 log_columns 설정에서도 빠짐)
List<String> persistedCols = new ArrayList<>();
for (String col : requestedCols) {
if (col == null) continue;
String safeCol = sanitize(col);
if (safeCol.isBlank()) continue; // sanitize 결과 식별자 차단
if (!colTypes.containsKey(col)) continue; // 원본 테이블에 없는 컬럼 skip
String rawType = colTypes.get(col);
String normalized = (rawType == null ? "" : rawType.toLowerCase(Locale.ROOT).trim());
if (!ALLOWED_LOG_COLUMN_TYPES.contains(normalized)) {
// 없는 type text fallback (안전 default)
log.warn("로그 테이블 컬럼 타입 화이트리스트 미일치 → text 로 대체: table={}, col={}, type={}",
safeOrig, safeCol, rawType);
normalized = "text";
}
colDefs.add(String.format("\"%s\" %s", safeCol, normalized));
persistedCols.add(safeCol);
}
if (persistedCols.isEmpty()) {
throw new IllegalArgumentException("log 생성할 컬럼이 없습니다.");
}
String createSql = String.format(
@@ -642,7 +1038,7 @@ public class TableManagementService extends BaseService {
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("is_active", isActive);
params.put("log_columns", String.join(",", targetCols));
params.put("log_columns", String.join(",", persistedCols));
sqlSession.update(NS + "upsertLogConfig", params);
log.info("로그 테이블 생성: {}", safeLog);
@@ -872,19 +1268,18 @@ public class TableManagementService extends BaseService {
/**
* context 따라 INPUT_TYPE 정규화 검증.
* @param context "user-insert" | "user-update-type" | "user-update-other" | "system-normalize"
*/
private String normalizeInputType(String value, String context) {
if ("user-insert".equals(context) || "user-update-type".equals(context)) {
if (value == null || !USER_SELECTABLE_INPUT_TYPES.contains(value)) {
private String normalizeInputType(String value, InputTypeContext context) {
if (context == InputTypeContext.USER_INSERT || context == InputTypeContext.USER_UPDATE_TYPE) {
if (value == null || !InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(value)) {
throw new IllegalArgumentException(
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + USER_SELECTABLE_INPUT_TYPES
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
+ " (받은 값: " + value + ")"
);
}
return value;
}
// user-update-other / system-normalize: 기존 동작 그대로
// USER_UPDATE_OTHER / SYSTEM_NORMALIZE: 기존 동작 그대로
return normalizeInputType(value);
}
@@ -0,0 +1,22 @@
-- =================================================================
-- V022: DEPT_MANAGERS 테이블 (다중 결재/부서/조직장 매핑)
-- =================================================================
-- 기존 DEPT_INFO.APPROVAL_MANAGER / DEPT_MANAGER 단일 컬럼을 매핑 테이블로 다중화.
-- role: 'approval' | 'dept' | 'org_leader'. 부서 삭제(hard) 시 CASCADE 로 정리.
-- 멱등: IF NOT EXISTS 로 재실행 안전.
CREATE TABLE IF NOT EXISTS DEPT_MANAGERS (
DEPT_CODE VARCHAR(1024) NOT NULL,
USER_ID VARCHAR(50) NOT NULL,
ROLE VARCHAR(20) NOT NULL,
SORT_ORDER INTEGER NOT NULL DEFAULT 1,
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (DEPT_CODE, USER_ID, ROLE),
CONSTRAINT chk_dept_managers_role
CHECK (ROLE IN ('approval', 'dept', 'org_leader')),
CONSTRAINT fk_dept_managers_dept
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_dept_managers_role
ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER);
@@ -0,0 +1,12 @@
ALTER TABLE MENU_INFO
ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL;
COMMENT ON COLUMN MENU_INFO.IS_SOLUTION_ONLY IS '솔루션 사이트(solution.invyone.com 등 관리 호스트) 에서만 노출되는 메뉴. 테넌트 사이트에선 SQL 단계에서 제외.';
-- 솔루션 전용 메뉴 마킹
UPDATE MENU_INFO SET IS_SOLUTION_ONLY = TRUE
WHERE MENU_URL IN (
'/admin/sysMng/subdomainList',
'/admin/userMng/companyList',
'/admin/audit-log'
);
@@ -58,6 +58,9 @@
AND RMA.READ_YN = 'Y'
)
</if>
<if test='is_management_host == false'>
AND MENU.IS_SOLUTION_ONLY = FALSE
</if>
UNION ALL
@@ -105,6 +108,9 @@
AND RMA.READ_YN = 'Y'
)
</if>
<if test='is_management_host == false'>
AND S.IS_SOLUTION_ONLY = FALSE
</if>
)
SELECT
V.LEV
@@ -187,6 +193,9 @@
AND MENU.COMPANY_CODE = #{company_code}
</otherwise>
</choose>
<if test='is_management_host == false'>
AND MENU.IS_SOLUTION_ONLY = FALSE
</if>
UNION ALL
@@ -212,6 +221,9 @@
ON S.PARENT_OBJ_ID = V.OBJID
WHERE S.OBJID != ALL(V.PATH)
AND S.STATUS = 'active'
<if test='is_management_host == false'>
AND S.IS_SOLUTION_ONLY = FALSE
</if>
)
SELECT
V.LEV
@@ -1,128 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cascadingAutoFill">
<sql id="cascadingAutoFillGroupSearchCondition">
<if test="keyword != null and keyword != ''">
AND (G.GROUP_NAME ILIKE '%' || #{keyword} || '%')
</if>
<if test="is_active != null and is_active != ''">
AND G.IS_ACTIVE = #{is_active}
</if>
</sql>
<select id="getCascadingAutoFillGroupList" parameterType="map" resultType="map">
SELECT G.*, COUNT(M.MAPPING_ID) AS MAPPING_COUNT
FROM CASCADING_AUTO_FILL_GROUP G
LEFT JOIN CASCADING_AUTO_FILL_MAPPING M
ON G.GROUP_CODE = M.GROUP_CODE AND G.COMPANY_CODE = M.COMPANY_CODE
WHERE 1=1
<if test="company_code != null and company_code != &quot;*&quot;">
AND (G.COMPANY_CODE = #{company_code} OR G.COMPANY_CODE = '*')
</if>
<include refid="cascadingAutoFillGroupSearchCondition"/>
GROUP BY G.GROUP_ID
ORDER BY G.GROUP_NAME
<include refid="common.pagination"/>
</select>
<select id="getCascadingAutoFillGroupListCnt" parameterType="map" resultType="int">
SELECT COUNT(DISTINCT G.GROUP_ID)
FROM CASCADING_AUTO_FILL_GROUP G
WHERE 1=1
<if test="company_code != null and company_code != &quot;*&quot;">
AND (G.COMPANY_CODE = #{company_code} OR G.COMPANY_CODE = '*')
</if>
<include refid="cascadingAutoFillGroupSearchCondition"/>
</select>
<select id="getCascadingAutoFillGroupByCode" parameterType="map" resultType="map">
SELECT *
FROM CASCADING_AUTO_FILL_GROUP
WHERE GROUP_CODE = #{group_code}
<if test="company_code != null and company_code != &quot;*&quot;">
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
<if test="is_active != null">
AND IS_ACTIVE = #{is_active}
</if>
</select>
<select id="getCascadingAutoFillMappingList" parameterType="map" resultType="map">
SELECT *
FROM CASCADING_AUTO_FILL_MAPPING
WHERE GROUP_CODE = #{group_code}
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
ORDER BY SORT_ORDER, MAPPING_ID
</select>
<select id="getCascadingAutoFillGroupCount" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM CASCADING_AUTO_FILL_GROUP
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</select>
<insert id="insertCascadingAutoFillGroup" parameterType="map" useGeneratedKeys="true" keyProperty="groupId">
INSERT INTO CASCADING_AUTO_FILL_GROUP (
GROUP_CODE, GROUP_NAME, DESCRIPTION,
MASTER_TABLE, MASTER_VALUE_COLUMN, MASTER_LABEL_COLUMN,
COMPANY_CODE, IS_ACTIVE, CREATED_DATE
) VALUES (
#{group_code},
#{group_name},
#{description, jdbcType=VARCHAR},
#{master_table},
#{master_value_column},
#{master_label_column, jdbcType=VARCHAR},
#{company_code},
#{is_active, jdbcType=VARCHAR},
CURRENT_TIMESTAMP
)
</insert>
<insert id="insertCascadingAutoFillMapping" parameterType="map" useGeneratedKeys="true" keyProperty="mappingId">
INSERT INTO CASCADING_AUTO_FILL_MAPPING (
GROUP_CODE, COMPANY_CODE, SOURCE_COLUMN, TARGET_FIELD, TARGET_LABEL,
IS_EDITABLE, IS_REQUIRED, DEFAULT_VALUE, SORT_ORDER
) VALUES (
#{group_code},
#{company_code},
#{source_column},
#{target_field},
#{target_label, jdbcType=VARCHAR},
#{is_editable, jdbcType=VARCHAR},
#{is_required, jdbcType=VARCHAR},
#{default_value, jdbcType=VARCHAR},
#{sort_order}
)
</insert>
<update id="updateCascadingAutoFillGroup" parameterType="map">
UPDATE CASCADING_AUTO_FILL_GROUP SET
GROUP_NAME = COALESCE(#{group_name, jdbcType=VARCHAR}, GROUP_NAME),
DESCRIPTION = COALESCE(#{description, jdbcType=VARCHAR}, DESCRIPTION),
MASTER_TABLE = COALESCE(#{master_table, jdbcType=VARCHAR}, MASTER_TABLE),
MASTER_VALUE_COLUMN = COALESCE(#{master_value_column, jdbcType=VARCHAR}, MASTER_VALUE_COLUMN),
MASTER_LABEL_COLUMN = COALESCE(#{master_label_column, jdbcType=VARCHAR}, MASTER_LABEL_COLUMN),
IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE),
UPDATED_DATE = CURRENT_TIMESTAMP
WHERE GROUP_CODE = #{group_code}
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</update>
<delete id="deleteCascadingAutoFillMappings" parameterType="map">
DELETE FROM CASCADING_AUTO_FILL_MAPPING
WHERE GROUP_CODE = #{group_code}
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</delete>
<delete id="deleteCascadingAutoFillGroup" parameterType="map">
DELETE FROM CASCADING_AUTO_FILL_GROUP
WHERE GROUP_CODE = #{group_code}
<if test="company_code != null and company_code != &quot;*&quot;">
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</delete>
</mapper>
@@ -1,100 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cascadingCondition">
<sql id="cascadingConditionSearchCondition">
<if test="keyword != null and keyword != ''">
AND CONDITION_NAME ILIKE '%' || #{keyword} || '%'
</if>
<if test="is_active != null and is_active != ''">
AND IS_ACTIVE = #{is_active}
</if>
<if test="relation_code != null and relation_code != ''">
AND RELATION_CODE = #{relation_code}
</if>
<if test="relation_type != null and relation_type != ''">
AND RELATION_TYPE = #{relation_type}
</if>
</sql>
<select id="getCascadingConditionList" parameterType="map" resultType="map">
SELECT *
FROM CASCADING_CONDITION
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<include refid="cascadingConditionSearchCondition"/>
<choose>
<when test="sort_column != null and sort_column != ''">
ORDER BY ${sortColumn}
<if test="sort_direction != null and sort_direction != ''">
${sortDirection}
</if>
</when>
<otherwise>
ORDER BY RELATION_CODE, PRIORITY, CONDITION_NAME
</otherwise>
</choose>
<include refid="common.pagination"/>
</select>
<select id="getCascadingConditionListCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM CASCADING_CONDITION
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<include refid="cascadingConditionSearchCondition"/>
</select>
<select id="getCascadingConditionInfo" parameterType="map" resultType="map">
SELECT *
FROM CASCADING_CONDITION
WHERE CONDITION_ID = #{condition_id}
<include refid="common.companyCodeFilter"/>
</select>
<insert id="insertCascadingCondition" parameterType="map" useGeneratedKeys="true" keyProperty="conditionId">
INSERT INTO CASCADING_CONDITION (
RELATION_TYPE, RELATION_CODE, CONDITION_NAME,
CONDITION_FIELD, CONDITION_OPERATOR, CONDITION_VALUE,
FILTER_COLUMN, FILTER_VALUES, PRIORITY,
COMPANY_CODE, IS_ACTIVE, CREATED_DATE
) VALUES (
COALESCE(#{relation_type}, 'RELATION'), #{relation_code}, #{condition_name},
#{condition_field}, COALESCE(#{condition_operator}, 'EQ'), #{condition_value},
#{filter_column}, #{filter_values}, COALESCE(#{priority}, 0),
#{company_code}, COALESCE(#{is_active}, 'Y'), CURRENT_TIMESTAMP
)
</insert>
<update id="updateCascadingCondition" parameterType="map">
UPDATE CASCADING_CONDITION SET
CONDITION_NAME = COALESCE(#{condition_name}, CONDITION_NAME),
CONDITION_FIELD = COALESCE(#{condition_field}, CONDITION_FIELD),
CONDITION_OPERATOR = COALESCE(#{condition_operator}, CONDITION_OPERATOR),
CONDITION_VALUE = COALESCE(#{condition_value}, CONDITION_VALUE),
FILTER_COLUMN = COALESCE(#{filter_column}, FILTER_COLUMN),
FILTER_VALUES = COALESCE(#{filter_values}, FILTER_VALUES),
PRIORITY = COALESCE(#{priority}, PRIORITY),
IS_ACTIVE = COALESCE(#{is_active}, IS_ACTIVE),
UPDATED_DATE = CURRENT_TIMESTAMP
WHERE CONDITION_ID = #{condition_id}
<include refid="common.companyCodeFilter"/>
</update>
<delete id="deleteCascadingCondition" parameterType="map">
DELETE FROM CASCADING_CONDITION
WHERE CONDITION_ID = #{condition_id}
<include refid="common.companyCodeFilter"/>
</delete>
<select id="getCascadingConditionsByRelationCode" parameterType="map" resultType="map">
SELECT *
FROM CASCADING_CONDITION
WHERE RELATION_CODE = #{relation_code}
AND IS_ACTIVE = 'Y'
<include refid="common.companyCodeFilter"/>
ORDER BY PRIORITY DESC
</select>
</mapper>
@@ -1,219 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cascadingHierarchy">
<sql id="cascadingHierarchyGroupSearchCondition">
<if test="keyword != null and keyword != ''">
AND (G.GROUP_NAME ILIKE '%' || #{keyword} || '%')
</if>
<if test="is_active != null and is_active != ''">
AND G.IS_ACTIVE = #{is_active}
</if>
<if test="hierarchy_type != null and hierarchy_type != ''">
AND G.HIERARCHY_TYPE = #{hierarchy_type}
</if>
</sql>
<select id="getCascadingHierarchyGroupList" parameterType="map" resultType="map">
SELECT G.*,
(SELECT COUNT(*)
FROM CASCADING_HIERARCHY_LEVEL L
WHERE L.GROUP_CODE = G.GROUP_CODE AND L.COMPANY_CODE = G.COMPANY_CODE) AS LEVEL_COUNT
FROM CASCADING_HIERARCHY_GROUP G
WHERE 1=1
<if test="company_code != null and company_code != &quot;*&quot;">
AND (G.COMPANY_CODE = #{company_code} OR G.COMPANY_CODE = '*')
</if>
<include refid="cascadingHierarchyGroupSearchCondition"/>
ORDER BY G.GROUP_NAME
<include refid="common.pagination"/>
</select>
<select id="getCascadingHierarchyGroupListCnt" parameterType="map" resultType="int">
SELECT COUNT(DISTINCT G.GROUP_ID)
FROM CASCADING_HIERARCHY_GROUP G
WHERE 1=1
<if test="company_code != null and company_code != &quot;*&quot;">
AND (G.COMPANY_CODE = #{company_code} OR G.COMPANY_CODE = '*')
</if>
<include refid="cascadingHierarchyGroupSearchCondition"/>
</select>
<select id="getCascadingHierarchyGroupByCode" parameterType="map" resultType="map">
SELECT *
FROM CASCADING_HIERARCHY_GROUP
WHERE GROUP_CODE = #{group_code}
<if test="company_code != null and company_code != &quot;*&quot;">
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</select>
<select id="getCascadingHierarchyGroupCount" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM CASCADING_HIERARCHY_GROUP
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</select>
<select id="getCascadingHierarchyLevelList" parameterType="map" resultType="map">
SELECT *
FROM CASCADING_HIERARCHY_LEVEL
WHERE GROUP_CODE = #{group_code}
<if test="company_code != null and company_code != &quot;*&quot;">
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
ORDER BY LEVEL_ORDER
</select>
<select id="getCascadingHierarchyLevelInfo" parameterType="map" resultType="map">
SELECT *
FROM CASCADING_HIERARCHY_LEVEL
WHERE LEVEL_ID = #{level_id}
<if test="company_code != null and company_code != &quot;*&quot;">
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</select>
<select id="getCascadingHierarchyLevelForOptions" parameterType="map" resultType="map">
SELECT L.*, G.HIERARCHY_TYPE
FROM CASCADING_HIERARCHY_LEVEL L
JOIN CASCADING_HIERARCHY_GROUP G
ON L.GROUP_CODE = G.GROUP_CODE AND L.COMPANY_CODE = G.COMPANY_CODE
WHERE L.GROUP_CODE = #{group_code}
AND L.LEVEL_ORDER = #{level_order}
AND L.IS_ACTIVE = 'Y'
<if test="company_code != null and company_code != &quot;*&quot;">
AND (L.COMPANY_CODE = #{company_code} OR L.COMPANY_CODE = '*')
</if>
</select>
<insert id="insertCascadingHierarchyGroup" parameterType="map" useGeneratedKeys="true" keyProperty="groupId">
INSERT INTO CASCADING_HIERARCHY_GROUP (
GROUP_CODE, GROUP_NAME, DESCRIPTION, HIERARCHY_TYPE,
MAX_LEVELS, IS_FIXED_LEVELS,
SELF_REF_TABLE, SELF_REF_ID_COLUMN, SELF_REF_PARENT_COLUMN,
SELF_REF_VALUE_COLUMN, SELF_REF_LABEL_COLUMN, SELF_REF_LEVEL_COLUMN, SELF_REF_ORDER_COLUMN,
BOM_TABLE, BOM_PARENT_COLUMN, BOM_CHILD_COLUMN,
BOM_ITEM_TABLE, BOM_ITEM_ID_COLUMN, BOM_ITEM_LABEL_COLUMN, BOM_QTY_COLUMN, BOM_LEVEL_COLUMN,
EMPTY_MESSAGE, NO_OPTIONS_MESSAGE, LOADING_MESSAGE,
COMPANY_CODE, IS_ACTIVE, CREATED_BY, CREATED_DATE
) VALUES (
#{group_code},
#{group_name},
#{description, jdbcType=VARCHAR},
#{hierarchy_type},
#{max_levels, jdbcType=INTEGER},
#{is_fixed_levels, jdbcType=VARCHAR},
#{self_ref_table, jdbcType=VARCHAR},
#{self_ref_id_column, jdbcType=VARCHAR},
#{self_ref_parent_column, jdbcType=VARCHAR},
#{self_ref_value_column, jdbcType=VARCHAR},
#{self_ref_label_column, jdbcType=VARCHAR},
#{self_ref_level_column, jdbcType=VARCHAR},
#{self_ref_order_column, jdbcType=VARCHAR},
#{bom_table, jdbcType=VARCHAR},
#{bom_parent_column, jdbcType=VARCHAR},
#{bom_child_column, jdbcType=VARCHAR},
#{bom_item_table, jdbcType=VARCHAR},
#{bom_item_id_column, jdbcType=VARCHAR},
#{bom_item_label_column, jdbcType=VARCHAR},
#{bom_qty_column, jdbcType=VARCHAR},
#{bom_level_column, jdbcType=VARCHAR},
#{empty_message, jdbcType=VARCHAR},
#{no_options_message, jdbcType=VARCHAR},
#{loading_message, jdbcType=VARCHAR},
#{company_code},
'Y',
#{created_by, jdbcType=VARCHAR},
CURRENT_TIMESTAMP
)
</insert>
<insert id="insertCascadingHierarchyLevel" parameterType="map" useGeneratedKeys="true" keyProperty="levelId">
INSERT INTO CASCADING_HIERARCHY_LEVEL (
GROUP_CODE, COMPANY_CODE, LEVEL_ORDER, LEVEL_NAME, LEVEL_CODE,
TABLE_NAME, VALUE_COLUMN, LABEL_COLUMN, PARENT_KEY_COLUMN,
FILTER_COLUMN, FILTER_VALUE, ORDER_COLUMN, ORDER_DIRECTION,
PLACEHOLDER, IS_REQUIRED, IS_SEARCHABLE, IS_ACTIVE, CREATED_DATE
) VALUES (
#{group_code},
#{company_code},
#{level_order},
#{level_name},
#{level_code, jdbcType=VARCHAR},
#{table_name},
#{value_column},
#{label_column},
#{parent_key_column, jdbcType=VARCHAR},
#{filter_column, jdbcType=VARCHAR},
#{filter_value, jdbcType=VARCHAR},
#{order_column, jdbcType=VARCHAR},
#{order_direction, jdbcType=VARCHAR},
#{placeholder, jdbcType=VARCHAR},
#{is_required, jdbcType=VARCHAR},
#{is_searchable, jdbcType=VARCHAR},
'Y',
CURRENT_TIMESTAMP
)
</insert>
<update id="updateCascadingHierarchyGroup" parameterType="map">
UPDATE CASCADING_HIERARCHY_GROUP SET
GROUP_NAME = COALESCE(#{group_name, jdbcType=VARCHAR}, GROUP_NAME),
DESCRIPTION = COALESCE(#{description, jdbcType=VARCHAR}, DESCRIPTION),
MAX_LEVELS = COALESCE(#{max_levels, jdbcType=INTEGER}, MAX_LEVELS),
IS_FIXED_LEVELS = COALESCE(#{is_fixed_levels, jdbcType=VARCHAR}, IS_FIXED_LEVELS),
EMPTY_MESSAGE = COALESCE(#{empty_message, jdbcType=VARCHAR}, EMPTY_MESSAGE),
NO_OPTIONS_MESSAGE = COALESCE(#{no_options_message, jdbcType=VARCHAR}, NO_OPTIONS_MESSAGE),
LOADING_MESSAGE = COALESCE(#{loading_message, jdbcType=VARCHAR}, LOADING_MESSAGE),
IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE),
UPDATED_BY = #{updated_by, jdbcType=VARCHAR},
UPDATED_DATE = CURRENT_TIMESTAMP
WHERE GROUP_CODE = #{group_code}
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</update>
<update id="updateCascadingHierarchyLevel" parameterType="map">
UPDATE CASCADING_HIERARCHY_LEVEL SET
LEVEL_NAME = COALESCE(#{level_name, jdbcType=VARCHAR}, LEVEL_NAME),
TABLE_NAME = COALESCE(#{table_name, jdbcType=VARCHAR}, TABLE_NAME),
VALUE_COLUMN = COALESCE(#{value_column, jdbcType=VARCHAR}, VALUE_COLUMN),
LABEL_COLUMN = COALESCE(#{label_column, jdbcType=VARCHAR}, LABEL_COLUMN),
PARENT_KEY_COLUMN = COALESCE(#{parent_key_column, jdbcType=VARCHAR}, PARENT_KEY_COLUMN),
FILTER_COLUMN = COALESCE(#{filter_column, jdbcType=VARCHAR}, FILTER_COLUMN),
FILTER_VALUE = COALESCE(#{filter_value, jdbcType=VARCHAR}, FILTER_VALUE),
ORDER_COLUMN = COALESCE(#{order_column, jdbcType=VARCHAR}, ORDER_COLUMN),
ORDER_DIRECTION = COALESCE(#{order_direction, jdbcType=VARCHAR}, ORDER_DIRECTION),
PLACEHOLDER = COALESCE(#{placeholder, jdbcType=VARCHAR}, PLACEHOLDER),
IS_REQUIRED = COALESCE(#{is_required, jdbcType=VARCHAR}, IS_REQUIRED),
IS_SEARCHABLE = COALESCE(#{is_searchable, jdbcType=VARCHAR}, IS_SEARCHABLE),
IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE),
UPDATED_DATE = CURRENT_TIMESTAMP
WHERE LEVEL_ID = #{level_id}
</update>
<delete id="deleteCascadingHierarchyLevels" parameterType="map">
DELETE FROM CASCADING_HIERARCHY_LEVEL
WHERE GROUP_CODE = #{group_code}
<if test="company_code != null and company_code != &quot;*&quot;">
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</delete>
<delete id="deleteCascadingHierarchyLevel" parameterType="map">
DELETE FROM CASCADING_HIERARCHY_LEVEL
WHERE LEVEL_ID = #{level_id}
<if test="company_code != null and company_code != &quot;*&quot;">
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</delete>
<delete id="deleteCascadingHierarchyGroup" parameterType="map">
DELETE FROM CASCADING_HIERARCHY_GROUP
WHERE GROUP_CODE = #{group_code}
<if test="company_code != null and company_code != &quot;*&quot;">
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</delete>
</mapper>
@@ -1,145 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cascadingMutualExclusion">
<sql id="cascadingMutualExclusionSearchCondition">
<if test="is_active != null and is_active != ''">
AND IS_ACTIVE = #{is_active}
</if>
<if test="keyword != null and keyword != ''">
AND EXCLUSION_NAME ILIKE '%' || #{keyword} || '%'
</if>
</sql>
<select id="getCascadingMutualExclusionList" parameterType="map" resultType="map">
SELECT
EXCLUSION_ID
, EXCLUSION_CODE
, EXCLUSION_NAME
, FIELD_NAMES
, SOURCE_TABLE
, VALUE_COLUMN
, LABEL_COLUMN
, EXCLUSION_TYPE
, ERROR_MESSAGE
, COMPANY_CODE
, IS_ACTIVE
, CREATED_DATE
FROM CASCADING_MUTUAL_EXCLUSION
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<include refid="cascadingMutualExclusionSearchCondition"/>
ORDER BY CREATED_DATE DESC
<include refid="common.pagination"/>
</select>
<select id="getCascadingMutualExclusionListCnt" parameterType="map" resultType="int">
SELECT
COUNT(*)
FROM CASCADING_MUTUAL_EXCLUSION
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<include refid="cascadingMutualExclusionSearchCondition"/>
</select>
<select id="getCascadingMutualExclusionInfo" parameterType="map" resultType="map">
SELECT
EXCLUSION_ID
, EXCLUSION_CODE
, EXCLUSION_NAME
, FIELD_NAMES
, SOURCE_TABLE
, VALUE_COLUMN
, LABEL_COLUMN
, EXCLUSION_TYPE
, ERROR_MESSAGE
, COMPANY_CODE
, IS_ACTIVE
, CREATED_DATE
FROM CASCADING_MUTUAL_EXCLUSION
WHERE EXCLUSION_ID = #{id}
<include refid="common.companyCodeFilter"/>
</select>
<!-- 코드로 단건 조회 (is_active = 'Y') -->
<select id="getCascadingMutualExclusionByCode" parameterType="map" resultType="map">
SELECT
EXCLUSION_ID
, EXCLUSION_CODE
, EXCLUSION_NAME
, FIELD_NAMES
, SOURCE_TABLE
, VALUE_COLUMN
, LABEL_COLUMN
, EXCLUSION_TYPE
, ERROR_MESSAGE
, COMPANY_CODE
, IS_ACTIVE
FROM CASCADING_MUTUAL_EXCLUSION
WHERE EXCLUSION_CODE = #{code}
AND IS_ACTIVE = 'Y'
<include refid="common.companyCodeFilter"/>
LIMIT 1
</select>
<!-- 코드 자동 생성용 카운트 -->
<select id="getCascadingMutualExclusionCount" parameterType="map" resultType="int">
SELECT
COUNT(*)
FROM CASCADING_MUTUAL_EXCLUSION
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</select>
<insert id="insertCascadingMutualExclusion" parameterType="map"
useGeneratedKeys="true" keyProperty="exclusionId" keyColumn="exclusion_id">
INSERT INTO CASCADING_MUTUAL_EXCLUSION (
EXCLUSION_CODE
, EXCLUSION_NAME
, FIELD_NAMES
, SOURCE_TABLE
, VALUE_COLUMN
, LABEL_COLUMN
, EXCLUSION_TYPE
, ERROR_MESSAGE
, COMPANY_CODE
, IS_ACTIVE
, CREATED_DATE
) VALUES (
#{exclusion_code, jdbcType=VARCHAR}
, #{exclusion_name, jdbcType=VARCHAR}
, #{field_names, jdbcType=VARCHAR}
, #{source_table, jdbcType=VARCHAR}
, #{value_column, jdbcType=VARCHAR}
, #{label_column, jdbcType=VARCHAR}
, #{exclusion_type, jdbcType=VARCHAR}
, #{error_message, jdbcType=VARCHAR}
, #{company_code}
, 'Y'
, CURRENT_TIMESTAMP
)
</insert>
<update id="updateCascadingMutualExclusion" parameterType="map">
UPDATE CASCADING_MUTUAL_EXCLUSION
SET
EXCLUSION_NAME = COALESCE(#{exclusion_name, jdbcType=VARCHAR}, EXCLUSION_NAME)
, FIELD_NAMES = COALESCE(#{field_names, jdbcType=VARCHAR}, FIELD_NAMES)
, SOURCE_TABLE = COALESCE(#{source_table, jdbcType=VARCHAR}, SOURCE_TABLE)
, VALUE_COLUMN = COALESCE(#{value_column, jdbcType=VARCHAR}, VALUE_COLUMN)
, LABEL_COLUMN = COALESCE(#{label_column, jdbcType=VARCHAR}, LABEL_COLUMN)
, EXCLUSION_TYPE = COALESCE(#{exclusion_type, jdbcType=VARCHAR}, EXCLUSION_TYPE)
, ERROR_MESSAGE = COALESCE(#{error_message, jdbcType=VARCHAR}, ERROR_MESSAGE)
, IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE)
WHERE EXCLUSION_ID = #{id}
<include refid="common.companyCodeFilter"/>
</update>
<!-- 하드 삭제 (Node.js와 동일) -->
<delete id="deleteCascadingMutualExclusion" parameterType="map">
DELETE FROM CASCADING_MUTUAL_EXCLUSION
WHERE EXCLUSION_ID = #{id}
<include refid="common.companyCodeFilter"/>
</delete>
</mapper>
@@ -1,160 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cascadingRelation">
<sql id="cascadingRelationColumns">
RELATION_ID
, RELATION_CODE
, RELATION_NAME
, DESCRIPTION
, PARENT_TABLE
, PARENT_VALUE_COLUMN
, PARENT_LABEL_COLUMN
, CHILD_TABLE
, CHILD_FILTER_COLUMN
, CHILD_VALUE_COLUMN
, CHILD_LABEL_COLUMN
, CHILD_ORDER_COLUMN
, CHILD_ORDER_DIRECTION
, EMPTY_PARENT_MESSAGE
, NO_OPTIONS_MESSAGE
, LOADING_MESSAGE
, CLEAR_ON_PARENT_CHANGE
, COMPANY_CODE
, IS_ACTIVE
, CREATED_BY
, CREATED_DATE
, UPDATED_BY
, UPDATED_DATE
</sql>
<sql id="cascadingRelationSearchCondition">
<if test="is_active != null and is_active != ''">
AND IS_ACTIVE = #{is_active}
</if>
<if test="keyword != null and keyword != ''">
AND (RELATION_NAME ILIKE '%' || #{keyword} || '%'
OR RELATION_CODE ILIKE '%' || #{keyword} || '%')
</if>
</sql>
<select id="getCascadingRelationList" parameterType="map" resultType="map">
SELECT <include refid="cascadingRelationColumns"/>
FROM CASCADING_RELATION
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<include refid="cascadingRelationSearchCondition"/>
ORDER BY CREATED_DATE DESC
<include refid="common.pagination"/>
</select>
<select id="getCascadingRelationListCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM CASCADING_RELATION
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<include refid="cascadingRelationSearchCondition"/>
</select>
<select id="getCascadingRelationInfo" parameterType="map" resultType="map">
SELECT <include refid="cascadingRelationColumns"/>
FROM CASCADING_RELATION
WHERE RELATION_ID = #{id}
<include refid="common.companyCodeFilter"/>
</select>
<!-- 코드로 단건 조회 (is_active = 'Y' 조건 포함) -->
<select id="getCascadingRelationByCode" parameterType="map" resultType="map">
SELECT <include refid="cascadingRelationColumns"/>
FROM CASCADING_RELATION
WHERE RELATION_CODE = #{code}
AND IS_ACTIVE = 'Y'
<include refid="common.companyCodeFilter"/>
LIMIT 1
</select>
<insert id="insertCascadingRelation" parameterType="map"
useGeneratedKeys="true" keyProperty="relationId" keyColumn="relation_id">
INSERT INTO CASCADING_RELATION (
RELATION_CODE
, RELATION_NAME
, DESCRIPTION
, PARENT_TABLE
, PARENT_VALUE_COLUMN
, PARENT_LABEL_COLUMN
, CHILD_TABLE
, CHILD_FILTER_COLUMN
, CHILD_VALUE_COLUMN
, CHILD_LABEL_COLUMN
, CHILD_ORDER_COLUMN
, CHILD_ORDER_DIRECTION
, EMPTY_PARENT_MESSAGE
, NO_OPTIONS_MESSAGE
, LOADING_MESSAGE
, CLEAR_ON_PARENT_CHANGE
, COMPANY_CODE
, IS_ACTIVE
, CREATED_BY
, CREATED_DATE
) VALUES (
#{relation_code, jdbcType=VARCHAR}
, #{relation_name, jdbcType=VARCHAR}
, #{description, jdbcType=VARCHAR}
, #{parent_table, jdbcType=VARCHAR}
, #{parent_value_column, jdbcType=VARCHAR}
, #{parent_label_column, jdbcType=VARCHAR}
, #{child_table, jdbcType=VARCHAR}
, #{child_filter_column, jdbcType=VARCHAR}
, #{child_value_column, jdbcType=VARCHAR}
, #{child_label_column, jdbcType=VARCHAR}
, #{child_order_column, jdbcType=VARCHAR}
, #{child_order_direction, jdbcType=VARCHAR}
, #{empty_parent_message, jdbcType=VARCHAR}
, #{no_options_message, jdbcType=VARCHAR}
, #{loading_message, jdbcType=VARCHAR}
, #{clear_on_parent_change, jdbcType=VARCHAR}
, #{company_code}
, #{is_active, jdbcType=VARCHAR}
, #{user_id, jdbcType=VARCHAR}
, CURRENT_TIMESTAMP
)
</insert>
<update id="updateCascadingRelation" parameterType="map">
UPDATE CASCADING_RELATION
SET
RELATION_NAME = COALESCE(#{relation_name, jdbcType=VARCHAR}, RELATION_NAME)
, DESCRIPTION = COALESCE(#{description, jdbcType=VARCHAR}, DESCRIPTION)
, PARENT_TABLE = COALESCE(#{parent_table, jdbcType=VARCHAR}, PARENT_TABLE)
, PARENT_VALUE_COLUMN = COALESCE(#{parent_value_column, jdbcType=VARCHAR}, PARENT_VALUE_COLUMN)
, PARENT_LABEL_COLUMN = COALESCE(#{parent_label_column, jdbcType=VARCHAR}, PARENT_LABEL_COLUMN)
, CHILD_TABLE = COALESCE(#{child_table, jdbcType=VARCHAR}, CHILD_TABLE)
, CHILD_FILTER_COLUMN = COALESCE(#{child_filter_column, jdbcType=VARCHAR}, CHILD_FILTER_COLUMN)
, CHILD_VALUE_COLUMN = COALESCE(#{child_value_column, jdbcType=VARCHAR}, CHILD_VALUE_COLUMN)
, CHILD_LABEL_COLUMN = COALESCE(#{child_label_column, jdbcType=VARCHAR}, CHILD_LABEL_COLUMN)
, CHILD_ORDER_COLUMN = COALESCE(#{child_order_column, jdbcType=VARCHAR}, CHILD_ORDER_COLUMN)
, CHILD_ORDER_DIRECTION = COALESCE(#{child_order_direction, jdbcType=VARCHAR}, CHILD_ORDER_DIRECTION)
, EMPTY_PARENT_MESSAGE = COALESCE(#{empty_parent_message, jdbcType=VARCHAR}, EMPTY_PARENT_MESSAGE)
, NO_OPTIONS_MESSAGE = COALESCE(#{no_options_message, jdbcType=VARCHAR}, NO_OPTIONS_MESSAGE)
, LOADING_MESSAGE = COALESCE(#{loading_message, jdbcType=VARCHAR}, LOADING_MESSAGE)
, CLEAR_ON_PARENT_CHANGE = COALESCE(#{clear_on_parent_change, jdbcType=VARCHAR}, CLEAR_ON_PARENT_CHANGE)
, IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE)
, UPDATED_BY = #{user_id, jdbcType=VARCHAR}
, UPDATED_DATE = CURRENT_TIMESTAMP
WHERE RELATION_ID = #{id}
<include refid="common.companyCodeFilter"/>
</update>
<!-- 소프트 삭제: is_active = 'N' -->
<update id="deleteCascadingRelation" parameterType="map">
UPDATE CASCADING_RELATION
SET
IS_ACTIVE = 'N'
, UPDATED_BY = #{user_id, jdbcType=VARCHAR}
, UPDATED_DATE = CURRENT_TIMESTAMP
WHERE RELATION_ID = #{id}
<include refid="common.companyCodeFilter"/>
</update>
</mapper>
@@ -1,182 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="categoryTree">
<!-- 공통 컬럼 -->
<sql id="categoryValueColumns">
value_id, table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, path, description, color, icon,
is_active, is_default, company_code,
CREATED_DATE, UPDATED_DATE, created_by, updated_by
</sql>
<!-- 카테고리 플랫 리스트 조회 (트리/플랫 모두 사용) -->
<select id="getCategoryTreeList" parameterType="map" resultType="map">
SELECT
<include refid="categoryValueColumns"/>
FROM category_values
WHERE (company_code = #{company_code} OR company_code = '*')
AND table_name = #{table_name}
AND column_name = #{column_name}
ORDER BY depth ASC, value_order ASC, value_label ASC
</select>
<!-- 카테고리 값 단건 조회 -->
<select id="getCategoryTreeInfo" parameterType="map" resultType="map">
SELECT
<include refid="categoryValueColumns"/>
FROM category_values
WHERE (company_code = #{company_code} OR company_code = '*')
AND value_id = #{value_id}
</select>
<!-- 카테고리 값 생성 -->
<insert id="insertCategoryTree" parameterType="map"
useGeneratedKeys="true" keyProperty="valueId" keyColumn="value_id">
INSERT INTO category_values (
table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, path, description, color, icon,
is_active, is_default, company_code, created_by, updated_by
) VALUES (
#{table_name}, #{column_name}, #{value_code}, #{value_label}, #{value_order},
#{parent_value_id}, #{depth}, #{path}, #{description}, #{color}, #{icon},
#{is_active}, #{is_default}, #{company_code}, #{created_by}, #{created_by}
)
</insert>
<!-- 카테고리 값 수정 (COALESCE로 부분 업데이트) -->
<update id="updateCategoryTree" parameterType="map">
UPDATE category_values
SET
value_code = COALESCE(#{value_code}, value_code),
value_label = COALESCE(#{value_label}, value_label),
value_order = COALESCE(#{value_order}, value_order),
parent_value_id = #{parent_value_id},
depth = #{depth},
path = #{path},
description = COALESCE(#{description}, description),
color = COALESCE(#{color}, color),
icon = COALESCE(#{icon}, icon),
is_active = COALESCE(#{is_active}, is_active),
is_default = COALESCE(#{is_default}, is_default),
UPDATED_DATE = NOW(),
updated_by = #{updated_by}
WHERE (company_code = #{company_code} OR company_code = '*')
AND value_id = #{value_id}
</update>
<!-- 카테고리 값 삭제 -->
<delete id="deleteCategoryTree" parameterType="map">
DELETE FROM category_values
WHERE (company_code = #{company_code} OR company_code = '*')
AND value_id = #{value_id}
</delete>
<!-- 자식 카테고리 수 조회 -->
<select id="getCategoryTreeChildrenCnt" parameterType="map" resultType="int">
SELECT COUNT(*) FROM category_values
WHERE parent_value_id = #{value_id}
AND (company_code = #{company_code} OR company_code = '*')
</select>
<!-- 테이블 존재 여부 확인 (0 또는 1 반환) -->
<select id="checkTableExists" parameterType="map" resultType="int">
SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = #{table_name}
</select>
<!-- 컬럼 존재 여부 확인 (0 또는 1 반환) -->
<select id="checkColumnExists" parameterType="map" resultType="int">
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = #{table_name}
AND column_name = #{column_name}
</select>
<!-- 카테고리 값 사용 건수 조회 (company_code 필터 포함) -->
<select id="countCategoryUsageWithCompany" parameterType="map" resultType="int">
<![CDATA[
SELECT COUNT(*) FROM "${tableName}"
WHERE (company_code = #{company_code} OR company_code = '*')
AND (#{value_code} = ANY(string_to_array("${columnName}"::text, ','))
OR "${columnName}"::text = #{value_code})
]]>
</select>
<!-- 카테고리 값 사용 건수 조회 (company_code 필터 없음) -->
<select id="countCategoryUsage" parameterType="map" resultType="int">
<![CDATA[
SELECT COUNT(*) FROM "${tableName}"
WHERE #{value_code} = ANY(string_to_array("${columnName}"::text, ','))
OR "${columnName}"::text = #{value_code}
]]>
</select>
<!-- 직계 자식 목록 조회 (path 업데이트용) -->
<select id="getCategoryTreeChildrenList" parameterType="map" resultType="map">
SELECT value_id, value_label
FROM category_values
WHERE (company_code = #{company_code} OR company_code = '*')
AND parent_value_id = #{parent_value_id}
</select>
<!-- 자식 path 단건 업데이트 -->
<update id="updateCategoryTreeChildPath" parameterType="map">
UPDATE category_values
SET path = #{path}, UPDATED_DATE = NOW()
WHERE value_id = #{value_id}
</update>
<!-- 테이블의 카테고리 컬럼 목록 조회 -->
<select id="getCategoryTreeColumnList" parameterType="map" resultType="map">
SELECT DISTINCT column_name, column_label
FROM table_type_columns
WHERE table_name = #{table_name}
AND input_type = 'category'
AND (company_code = #{company_code} OR company_code = '*')
ORDER BY column_name
</select>
<!-- 전체 카테고리 키 목록 조회 (table_name + column_name 조합) -->
<select id="getCategoryTreeKeyList" parameterType="map" resultType="map">
SELECT DISTINCT
cv.table_name,
cv.column_name,
COALESCE(tl.table_label, cv.table_name) AS table_label,
COALESCE(ttc.column_label, cv.column_name) AS column_label
FROM category_values cv
LEFT JOIN table_labels tl ON tl.table_name = cv.table_name
LEFT JOIN table_type_columns ttc ON ttc.table_name = cv.table_name
AND ttc.column_name = cv.column_name
AND ttc.company_code = '*'
WHERE (cv.company_code = #{company_code} OR cv.company_code = '*')
OR cv.company_code = '*'
ORDER BY cv.table_name, cv.column_name
</select>
</mapper>
@@ -1,179 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="categoryValueCascading">
<sql id="groupSearchCondition">
<if test="keyword != null and keyword != ''">
AND (RELATION_NAME ILIKE '%' || #{keyword} || '%' OR RELATION_CODE ILIKE '%' || #{keyword} || '%')
</if>
<if test="is_active != null and is_active != ''">
AND IS_ACTIVE = #{is_active}
</if>
</sql>
<select id="getCategoryValueCascadingGroupList" parameterType="map" resultType="map">
SELECT
*
FROM CATEGORY_VALUE_CASCADING_GROUP
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<include refid="groupSearchCondition"/>
<choose>
<when test="sort_column != null and sort_column != ''">
ORDER BY ${sortColumn}
<if test="sort_direction != null and sort_direction != ''">
${sortDirection}
</if>
</when>
<otherwise>
ORDER BY RELATION_NAME ASC
</otherwise>
</choose>
<include refid="common.pagination"/>
</select>
<select id="getCategoryValueCascadingGroupListCnt" parameterType="map" resultType="int">
SELECT
COUNT(*)
FROM CATEGORY_VALUE_CASCADING_GROUP
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<include refid="groupSearchCondition"/>
</select>
<select id="getCategoryValueCascadingGroupInfo" parameterType="map" resultType="map">
SELECT
*
FROM CATEGORY_VALUE_CASCADING_GROUP
WHERE GROUP_ID = #{group_id}
<include refid="common.companyCodeFilter"/>
</select>
<select id="getCategoryValueCascadingGroupByCode" parameterType="map" resultType="map">
SELECT
*
FROM CATEGORY_VALUE_CASCADING_GROUP
WHERE RELATION_CODE = #{code}
AND IS_ACTIVE = 'Y'
<include refid="common.companyCodeFilter"/>
LIMIT 1
</select>
<insert id="insertCategoryValueCascadingGroup" parameterType="map" useGeneratedKeys="true" keyProperty="groupId">
INSERT INTO CATEGORY_VALUE_CASCADING_GROUP (
RELATION_CODE
, RELATION_NAME
, DESCRIPTION
, PARENT_TABLE_NAME
, PARENT_COLUMN_NAME
, PARENT_MENU_OBJID
, CHILD_TABLE_NAME
, CHILD_COLUMN_NAME
, CHILD_MENU_OBJID
, CLEAR_ON_PARENT_CHANGE
, SHOW_GROUP_LABEL
, EMPTY_PARENT_MESSAGE
, NO_OPTIONS_MESSAGE
, COMPANY_CODE
, IS_ACTIVE
, CREATED_BY
, CREATED_DATE
) VALUES (
#{relation_code}
, #{relation_name}
, #{description}
, #{parent_table_name}
, #{parent_column_name}
, #{parent_menu_objid}
, #{child_table_name}
, #{child_column_name}
, #{child_menu_objid}
, COALESCE(#{clear_on_parent_change}, 'Y')
, COALESCE(#{show_group_label}, 'Y')
, COALESCE(#{empty_parent_message}, '상위 항목을 먼저 선택하세요')
, COALESCE(#{no_options_message}, '선택 가능한 항목이 없습니다')
, #{company_code}
, 'Y'
, #{created_by}
, NOW()
)
</insert>
<update id="updateCategoryValueCascadingGroup" parameterType="map">
UPDATE CATEGORY_VALUE_CASCADING_GROUP
SET
RELATION_NAME = COALESCE(#{relation_name}, RELATION_NAME)
, DESCRIPTION = COALESCE(#{description}, DESCRIPTION)
, PARENT_TABLE_NAME = COALESCE(#{parent_table_name}, PARENT_TABLE_NAME)
, PARENT_COLUMN_NAME = COALESCE(#{parent_column_name}, PARENT_COLUMN_NAME)
, PARENT_MENU_OBJID = COALESCE(#{parent_menu_objid}, PARENT_MENU_OBJID)
, CHILD_TABLE_NAME = COALESCE(#{child_table_name}, CHILD_TABLE_NAME)
, CHILD_COLUMN_NAME = COALESCE(#{child_column_name}, CHILD_COLUMN_NAME)
, CHILD_MENU_OBJID = COALESCE(#{child_menu_objid}, CHILD_MENU_OBJID)
, CLEAR_ON_PARENT_CHANGE = COALESCE(#{clear_on_parent_change}, CLEAR_ON_PARENT_CHANGE)
, SHOW_GROUP_LABEL = COALESCE(#{show_group_label}, SHOW_GROUP_LABEL)
, EMPTY_PARENT_MESSAGE = COALESCE(#{empty_parent_message}, EMPTY_PARENT_MESSAGE)
, NO_OPTIONS_MESSAGE = COALESCE(#{no_options_message}, NO_OPTIONS_MESSAGE)
, IS_ACTIVE = COALESCE(#{is_active}, IS_ACTIVE)
, UPDATED_BY = #{updated_by}
, UPDATED_DATE = NOW()
WHERE GROUP_ID = #{group_id}
<include refid="common.companyCodeFilter"/>
</update>
<update id="deleteCategoryValueCascadingGroup" parameterType="map">
UPDATE CATEGORY_VALUE_CASCADING_GROUP
SET
IS_ACTIVE = 'N'
, UPDATED_BY = #{updated_by}
, UPDATED_DATE = NOW()
WHERE GROUP_ID = #{group_id}
<include refid="common.companyCodeFilter"/>
</update>
<select id="getCategoryValueCascadingMappingsByGroupId" parameterType="map" resultType="map">
SELECT
MAPPING_ID
, PARENT_VALUE_CODE
, PARENT_VALUE_LABEL
, CHILD_VALUE_CODE
, CHILD_VALUE_LABEL
, DISPLAY_ORDER
, IS_ACTIVE
FROM CATEGORY_VALUE_CASCADING_MAPPING
WHERE GROUP_ID = #{group_id}
AND IS_ACTIVE = 'Y'
ORDER BY PARENT_VALUE_CODE, DISPLAY_ORDER, CHILD_VALUE_LABEL
</select>
<delete id="deleteCategoryValueCascadingMappingsByGroupId" parameterType="map">
DELETE FROM CATEGORY_VALUE_CASCADING_MAPPING
WHERE GROUP_ID = #{group_id}
</delete>
<insert id="insertCategoryValueCascadingMapping" parameterType="map" useGeneratedKeys="true" keyProperty="mappingId">
INSERT INTO CATEGORY_VALUE_CASCADING_MAPPING (
GROUP_ID
, PARENT_VALUE_CODE
, PARENT_VALUE_LABEL
, CHILD_VALUE_CODE
, CHILD_VALUE_LABEL
, DISPLAY_ORDER
, COMPANY_CODE
, IS_ACTIVE
, CREATED_DATE
) VALUES (
#{group_id}
, #{parent_value_code}
, #{parent_value_label}
, #{child_value_code}
, #{child_value_label}
, COALESCE(#{display_order}, 0)
, #{company_code}
, 'Y'
, NOW()
)
</insert>
</mapper>
@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="codeMerge">
<!--
columnName 컬럼과 company_code 컬럼을 함께 가진 public BASE TABLE 목록 조회
테이블명은 information_schema 검증값이므로 동적 SQL 사용 시 안전
-->
<select id="getTablesWithColumn" parameterType="map" resultType="map">
SELECT DISTINCT t.table_name
FROM information_schema.columns c
JOIN information_schema.tables t
ON c.table_name = t.table_name
WHERE c.column_name = #{column_name}
AND t.table_schema = 'public'
AND t.table_type = 'BASE TABLE'
AND EXISTS (
SELECT 1 FROM information_schema.columns c2
WHERE c2.table_name = t.table_name
AND c2.column_name = 'company_code'
)
ORDER BY t.table_name
</select>
</mapper>
@@ -4,455 +4,436 @@
<mapper namespace="commonCode">
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- code_category -->
<!-- CODE_INFO — 1레벨 그룹 마스터 -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<select id="getCommonCodeCategoryList" parameterType="map" resultType="map">
<select id="getCodeInfoList" parameterType="map" resultType="map">
SELECT
category_code,
category_name,
category_name_eng,
description,
sort_order,
is_active,
menu_objid,
company_code,
created_by,
updated_by,
created_date,
updated_date
CODE_INFO
, CODE_NAME
, CODE_NAME_ENG
, DESCRIPTION
, SORT_ORDER
, IS_ACTIVE
, MENU_OBJID
, COMPANY_CODE
, CREATED_BY
, UPDATED_BY
, CREATED_DATE
, UPDATED_DATE
FROM code_category
FROM CODE_INFO
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<if test="search != null and search != ''">
AND (
LOWER(category_code) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(category_name) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(COALESCE(category_name_eng, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
LOWER(CODE_INFO) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(CODE_NAME) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(COALESCE(CODE_NAME_ENG, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
)
</if>
<if test="is_active != null">
AND is_active = #{is_active}
</if>
<if test="menu_objid != null">
AND menu_objid = #{menu_objid}
AND IS_ACTIVE = #{is_active}
</if>
ORDER BY sort_order ASC, category_code ASC
ORDER BY SORT_ORDER ASC, CODE_INFO ASC
<include refid="common.pagination"/>
</select>
<select id="getCommonCodeCategoryListCnt" parameterType="map" resultType="int">
<select id="getCodeInfoListCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM code_category
FROM CODE_INFO
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<if test="search != null and search != ''">
AND (
LOWER(category_code) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(category_name) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(COALESCE(category_name_eng, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
LOWER(CODE_INFO) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(CODE_NAME) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(COALESCE(CODE_NAME_ENG, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
)
</if>
<if test="is_active != null">
AND is_active = #{is_active}
</if>
<if test="menu_objid != null">
AND menu_objid = #{menu_objid}
AND IS_ACTIVE = #{is_active}
</if>
</select>
<select id="getCommonCodeCategoryInfo" parameterType="map" resultType="map">
<select id="getCodeInfoInfo" parameterType="map" resultType="map">
SELECT
category_code,
category_name,
category_name_eng,
description,
sort_order,
is_active,
menu_objid,
company_code,
created_by,
updated_by,
created_date,
updated_date
CODE_INFO
, CODE_NAME
, CODE_NAME_ENG
, DESCRIPTION
, SORT_ORDER
, IS_ACTIVE
, MENU_OBJID
, COMPANY_CODE
, CREATED_BY
, UPDATED_BY
, CREATED_DATE
, UPDATED_DATE
FROM code_category
FROM CODE_INFO
WHERE category_code = #{category_code}
WHERE CODE_INFO = #{code_info}
<include refid="common.companyCodeFilter"/>
</select>
<insert id="insertCommonCodeCategory" parameterType="map">
INSERT INTO code_category (
category_code,
category_name,
category_name_eng,
description,
sort_order,
is_active,
menu_objid,
company_code,
created_by,
updated_by,
created_date,
updated_date
<insert id="insertCodeInfo" parameterType="map">
INSERT INTO CODE_INFO (
CODE_INFO
, CODE_NAME
, CODE_NAME_ENG
, DESCRIPTION
, SORT_ORDER
, IS_ACTIVE
, MENU_OBJID
, COMPANY_CODE
, CREATED_BY
, UPDATED_BY
, CREATED_DATE
, UPDATED_DATE
) VALUES (
#{category_code},
#{category_name},
#{category_name_eng},
#{description},
#{sort_order},
#{is_active},
#{menu_objid},
#{company_code},
#{created_by},
#{updated_by},
NOW(),
NOW()
#{code_info}
, #{code_name}
, #{code_name_eng}
, #{description}
, #{sort_order}
, #{is_active}
, #{menu_objid}
, #{company_code}
, #{created_by}
, #{updated_by}
, NOW()
, NOW()
)
</insert>
<update id="updateCommonCodeCategory" parameterType="map">
UPDATE code_category
<update id="updateCodeInfo" parameterType="map">
UPDATE CODE_INFO
<set>
<if test="category_name != null">category_name = #{category_name},</if>
<if test="category_name_eng != null">category_name_eng = #{category_name_eng},</if>
<if test="description != null">description = #{description},</if>
<if test="sort_order != null">sort_order = #{sort_order},</if>
<if test="is_active != null">is_active = #{is_active},</if>
updated_by = #{updated_by},
updated_date = NOW()
<if test="code_name != null">CODE_NAME = #{code_name},</if>
<if test="code_name_eng != null">CODE_NAME_ENG = #{code_name_eng},</if>
<if test="description != null">DESCRIPTION = #{description},</if>
<if test="sort_order != null">SORT_ORDER = #{sort_order},</if>
<if test="is_active != null">IS_ACTIVE = #{is_active},</if>
<if test="menu_objid != null">MENU_OBJID = #{menu_objid},</if>
UPDATED_BY = #{updated_by},
UPDATED_DATE = NOW()
</set>
WHERE category_code = #{category_code}
WHERE CODE_INFO = #{code_info}
<include refid="common.companyCodeFilter"/>
</update>
<delete id="deleteCommonCodeCategory" parameterType="map">
DELETE FROM code_category
<delete id="deleteCodeInfo" parameterType="map">
DELETE FROM CODE_INFO
WHERE category_code = #{category_code}
WHERE CODE_INFO = #{code_info}
<include refid="common.companyCodeFilter"/>
</delete>
<select id="getCommonCodeCategoryDuplicateCnt" parameterType="map" resultType="int">
<select id="getCodeInfoDuplicateCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM code_category
FROM CODE_INFO
WHERE category_code = #{category_code}
WHERE CODE_INFO = #{code_info}
<include refid="common.companyCodeFilter"/>
</select>
<select id="getCommonCodeCategoryDuplicateByField" parameterType="map" resultType="int">
<select id="getCodeInfoDuplicateByField" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM code_category
FROM CODE_INFO
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<choose>
<when test="field == 'categoryCode'">AND category_code = #{value}</when>
<when test="field == 'categoryName'">AND category_name = #{value}</when>
<when test="field == 'categoryNameEng'">AND category_name_eng = #{value}</when>
<otherwise>AND category_code = #{value}</otherwise>
<when test='field == "code_info"'>AND CODE_INFO = #{value}</when>
<when test='field == "code_name"'>AND CODE_NAME = #{value}</when>
<when test='field == "code_name_eng"'>AND CODE_NAME_ENG = #{value}</when>
<otherwise>AND CODE_INFO = #{value}</otherwise>
</choose>
<if test="exclude_code != null and exclude_code != ''">
AND category_code != #{exclude_code}
AND CODE_INFO != #{exclude_code}
</if>
</select>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- code_info -->
<!-- CODE_DETAIL — 2레벨 ~ 무한대 트리 -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<select id="getCommonCodeList" parameterType="map" resultType="map">
<select id="getCodeDetailList" parameterType="map" resultType="map">
SELECT
code_category,
code_value,
code_name,
code_name_eng,
description,
sort_order,
is_active,
menu_objid,
company_code,
parent_code_value,
depth,
created_by,
updated_by,
created_date,
updated_date
CODE_DETAIL_ID
, CODE_INFO
, PARENT_DETAIL_ID
, CODE_VALUE
, CODE_NAME
, CODE_NAME_ENG
, DESCRIPTION
, DEPTH
, SORT_ORDER
, IS_ACTIVE
, COMPANY_CODE
, CREATED_BY
, UPDATED_BY
, CREATED_DATE
, UPDATED_DATE
FROM code_info
FROM CODE_DETAIL
WHERE code_category = #{category_code}
WHERE CODE_INFO = #{code_info}
<include refid="common.companyCodeFilter"/>
<choose>
<when test="parent_detail_id != null">
AND PARENT_DETAIL_ID = #{parent_detail_id}
</when>
<when test="only_roots != null and only_roots == true">
AND PARENT_DETAIL_ID IS NULL
</when>
</choose>
<if test="search != null and search != ''">
AND (
LOWER(code_value) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(code_name) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(COALESCE(code_name_eng, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
LOWER(CODE_VALUE) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(CODE_NAME) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(COALESCE(CODE_NAME_ENG, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
)
</if>
<if test="is_active != null">
AND is_active = #{is_active}
AND IS_ACTIVE = #{is_active}
</if>
ORDER BY sort_order ASC, code_value ASC
ORDER BY DEPTH ASC, SORT_ORDER ASC, CODE_VALUE ASC
<include refid="common.pagination"/>
</select>
<select id="getCommonCodeListCnt" parameterType="map" resultType="int">
<select id="getCodeDetailListCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM code_info
FROM CODE_DETAIL
WHERE code_category = #{category_code}
WHERE CODE_INFO = #{code_info}
<include refid="common.companyCodeFilter"/>
<choose>
<when test="parent_detail_id != null">
AND PARENT_DETAIL_ID = #{parent_detail_id}
</when>
<when test="only_roots != null and only_roots == true">
AND PARENT_DETAIL_ID IS NULL
</when>
</choose>
<if test="search != null and search != ''">
AND (
LOWER(code_value) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(code_name) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(COALESCE(code_name_eng, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
LOWER(CODE_VALUE) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(CODE_NAME) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(COALESCE(CODE_NAME_ENG, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
)
</if>
<if test="is_active != null">
AND is_active = #{is_active}
AND IS_ACTIVE = #{is_active}
</if>
</select>
<select id="getCommonCodeInfo" parameterType="map" resultType="map">
<select id="getCodeDetailInfo" parameterType="map" resultType="map">
SELECT
code_category,
code_value,
code_name,
code_name_eng,
description,
sort_order,
is_active,
menu_objid,
company_code,
parent_code_value,
depth,
created_by,
updated_by,
created_date,
updated_date
CODE_DETAIL_ID
, CODE_INFO
, PARENT_DETAIL_ID
, CODE_VALUE
, CODE_NAME
, CODE_NAME_ENG
, DESCRIPTION
, DEPTH
, SORT_ORDER
, IS_ACTIVE
, COMPANY_CODE
, CREATED_BY
, UPDATED_BY
, CREATED_DATE
, UPDATED_DATE
FROM code_info
FROM CODE_DETAIL
WHERE code_category = #{category_code}
AND code_value = #{code_value}
WHERE CODE_DETAIL_ID = #{code_detail_id}
<include refid="common.companyCodeFilter"/>
</select>
<insert id="insertCommonCode" parameterType="map">
INSERT INTO code_info (
code_category,
code_value,
code_name,
code_name_eng,
description,
sort_order,
is_active,
menu_objid,
company_code,
parent_code_value,
depth,
created_by,
updated_by,
created_date,
updated_date
<!--
그룹 전체 트리 — 재귀 CTE 로 평탄화.
depth 오름차순 → sort_order 오름차순 → code_value 오름차순.
-->
<select id="getCodeDetailTree" parameterType="map" resultType="map">
WITH RECURSIVE TREE AS (
SELECT
CODE_DETAIL_ID
, CODE_INFO
, PARENT_DETAIL_ID
, CODE_VALUE
, CODE_NAME
, CODE_NAME_ENG
, DESCRIPTION
, DEPTH
, SORT_ORDER
, IS_ACTIVE
, COMPANY_CODE
, CREATED_BY
, UPDATED_BY
, CREATED_DATE
, UPDATED_DATE
, ARRAY[SORT_ORDER, CODE_DETAIL_ID] AS PATH
FROM CODE_DETAIL
WHERE CODE_INFO = #{code_info}
AND PARENT_DETAIL_ID IS NULL
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
UNION ALL
SELECT
C.CODE_DETAIL_ID
, C.CODE_INFO
, C.PARENT_DETAIL_ID
, C.CODE_VALUE
, C.CODE_NAME
, C.CODE_NAME_ENG
, C.DESCRIPTION
, C.DEPTH
, C.SORT_ORDER
, C.IS_ACTIVE
, C.COMPANY_CODE
, C.CREATED_BY
, C.UPDATED_BY
, C.CREATED_DATE
, C.UPDATED_DATE
, TREE.PATH || ARRAY[C.SORT_ORDER, C.CODE_DETAIL_ID]
FROM CODE_DETAIL C
INNER JOIN TREE ON C.PARENT_DETAIL_ID = TREE.CODE_DETAIL_ID
WHERE C.CODE_INFO = #{code_info}
<if test='company_code != null and company_code != "*"'>
AND (C.COMPANY_CODE = #{company_code} OR C.COMPANY_CODE = '*')
</if>
)
SELECT
CODE_DETAIL_ID
, CODE_INFO
, PARENT_DETAIL_ID
, CODE_VALUE
, CODE_NAME
, CODE_NAME_ENG
, DESCRIPTION
, DEPTH
, SORT_ORDER
, IS_ACTIVE
, COMPANY_CODE
, CREATED_BY
, UPDATED_BY
, CREATED_DATE
, UPDATED_DATE
FROM TREE
ORDER BY PATH
</select>
<insert id="insertCodeDetail" parameterType="map" useGeneratedKeys="true" keyProperty="code_detail_id" keyColumn="code_detail_id">
INSERT INTO CODE_DETAIL (
CODE_INFO
, PARENT_DETAIL_ID
, CODE_VALUE
, CODE_NAME
, CODE_NAME_ENG
, DESCRIPTION
, DEPTH
, SORT_ORDER
, IS_ACTIVE
, COMPANY_CODE
, CREATED_BY
, UPDATED_BY
, CREATED_DATE
, UPDATED_DATE
) VALUES (
#{category_code},
#{code_value},
#{code_name},
#{code_name_eng},
#{description},
#{sort_order},
#{is_active},
#{menu_objid},
#{company_code},
#{parent_code_value},
#{depth},
#{created_by},
#{updated_by},
NOW(),
NOW()
#{code_info}
, #{parent_detail_id}
, #{code_value}
, #{code_name}
, #{code_name_eng}
, #{description}
, #{depth}
, #{sort_order}
, #{is_active}
, #{company_code}
, #{created_by}
, #{updated_by}
, NOW()
, NOW()
)
</insert>
<update id="updateCommonCode" parameterType="map">
UPDATE code_info
<update id="updateCodeDetail" parameterType="map">
UPDATE CODE_DETAIL
<set>
<if test="code_name != null">code_name = #{code_name},</if>
<if test="code_name_eng != null">code_name_eng = #{code_name_eng},</if>
<if test="description != null">description = #{description},</if>
<if test="sort_order != null">sort_order = #{sort_order},</if>
<if test="is_active != null">is_active = #{is_active},</if>
<if test="parent_code_value != null">parent_code_value = #{parent_code_value},</if>
<if test="depth != null">depth = #{depth},</if>
updated_by = #{updated_by},
updated_date = NOW()
<if test="code_value != null">CODE_VALUE = #{code_value},</if>
<if test="code_name != null">CODE_NAME = #{code_name},</if>
<if test="code_name_eng != null">CODE_NAME_ENG = #{code_name_eng},</if>
<if test="description != null">DESCRIPTION = #{description},</if>
<if test="sort_order != null">SORT_ORDER = #{sort_order},</if>
<if test="is_active != null">IS_ACTIVE = #{is_active},</if>
<if test="reparent != null and reparent == true">
PARENT_DETAIL_ID = #{parent_detail_id},
DEPTH = #{depth},
</if>
UPDATED_BY = #{updated_by},
UPDATED_DATE = NOW()
</set>
WHERE code_category = #{category_code}
AND code_value = #{code_value}
WHERE CODE_DETAIL_ID = #{code_detail_id}
<include refid="common.companyCodeFilter"/>
</update>
<delete id="deleteCommonCode" parameterType="map">
DELETE FROM code_info
<delete id="deleteCodeDetail" parameterType="map">
DELETE FROM CODE_DETAIL
WHERE code_category = #{category_code}
AND code_value = #{code_value}
WHERE CODE_DETAIL_ID = #{code_detail_id}
<include refid="common.companyCodeFilter"/>
</delete>
<update id="updateCommonCodeSortOrder" parameterType="map">
UPDATE code_info
SET sort_order = #{sort_order},
updated_date = NOW()
WHERE code_category = #{category_code}
AND code_value = #{code_value}
<include refid="common.companyCodeFilter"/>
</update>
<select id="getCommonCodeDuplicateCnt" parameterType="map" resultType="int">
<select id="getCodeDetailDuplicateCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM code_info
FROM CODE_DETAIL
WHERE code_category = #{category_code}
AND code_value = #{code_value}
WHERE CODE_INFO = #{code_info}
AND CODE_VALUE = #{code_value}
<include refid="common.companyCodeFilter"/>
<if test="exclude_id != null">
AND CODE_DETAIL_ID != #{exclude_id}
</if>
</select>
<select id="getCommonCodeDuplicateByField" parameterType="map" resultType="int">
<select id="getCodeDetailChildrenCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM code_info
FROM CODE_DETAIL
WHERE code_category = #{category_code}
<include refid="common.companyCodeFilter"/>
<choose>
<when test="field == 'codeValue'">AND code_value = #{value}</when>
<when test="field == 'codeName'">AND code_name = #{value}</when>
<when test="field == 'codeNameEng'">AND code_name_eng = #{value}</when>
<otherwise>AND code_value = #{value}</otherwise>
</choose>
<if test="exclude_code != null and exclude_code != ''">
AND code_value != #{exclude_code}
</if>
</select>
<select id="getCommonCodeParentDepth" parameterType="map" resultType="int">
SELECT COALESCE(depth, 0)
FROM code_info
WHERE code_category = #{category_code}
AND code_value = #{code_value}
WHERE PARENT_DETAIL_ID = #{code_detail_id}
<include refid="common.companyCodeFilter"/>
</select>
<select id="getCommonCodeChildrenCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
<select id="getCodeDetailParentDepth" parameterType="map" resultType="int">
SELECT COALESCE(DEPTH, 1)
FROM code_info
FROM CODE_DETAIL
WHERE code_category = #{category_code}
AND parent_code_value = #{code_value}
WHERE CODE_DETAIL_ID = #{code_detail_id}
<include refid="common.companyCodeFilter"/>
</select>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- 계층 / 트리 / 옵션 -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<select id="getCommonCodeHierarchicalList" parameterType="map" resultType="map">
SELECT
code_category,
code_value,
code_name,
code_name_eng,
description,
sort_order,
is_active,
menu_objid,
company_code,
parent_code_value,
depth,
created_by,
updated_by,
created_date,
updated_date
FROM code_info
WHERE code_category = #{category_code}
<include refid="common.companyCodeFilter"/>
<if test="is_active != null">
AND is_active = #{is_active}
</if>
<if test="parent_code_value != null">
AND parent_code_value = #{parent_code_value}
</if>
<if test="depth != null">
AND depth = #{depth}
</if>
ORDER BY depth ASC, sort_order ASC, code_value ASC
</select>
<select id="getCommonCodeTreeList" parameterType="map" resultType="map">
SELECT
code_category,
code_value,
code_name,
code_name_eng,
description,
sort_order,
is_active,
menu_objid,
company_code,
parent_code_value,
depth,
created_by,
updated_by,
created_date,
updated_date
FROM code_info
WHERE code_category = #{category_code}
<include refid="common.companyCodeFilter"/>
ORDER BY depth ASC, sort_order ASC, code_value ASC
</select>
<select id="getCommonCodeOptionList" parameterType="map" resultType="map">
SELECT
code_value,
code_name,
code_name_eng
FROM code_info
WHERE code_category = #{category_code}
<include refid="common.companyCodeFilter"/>
<if test="is_active != null">
AND is_active = #{is_active}
</if>
ORDER BY sort_order ASC, code_value ASC
</select>
</mapper>
@@ -23,13 +23,23 @@
D.SORT_ORDER,
D.STATUS,
D.DELETED_AT,
COUNT(DISTINCT UD.USER_ID) AS MEMBER_COUNT
COUNT(DISTINCT UD.USER_ID) AS MEMBER_COUNT,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
FROM DEPT_INFO D
LEFT JOIN USER_DEPT UD ON D.DEPT_CODE = UD.DEPT_CODE
WHERE (D.COMPANY_CODE = #{company_code} OR D.COMPANY_CODE = '*')
<if test="include_deleted == null or include_deleted == false">
AND D.DELETED_AT IS NULL
</if>
<if test="base_date != null and base_date != ''">
AND (D.START_DATE IS NULL OR D.START_DATE &lt;= #{base_date}::date)
AND (D.END_DATE IS NULL OR D.END_DATE &gt;= #{base_date}::date)
</if>
GROUP BY
D.DEPT_CODE, D.DEPT_NAME, D.COMPANY_CODE, D.PARENT_DEPT_CODE,
D.SHORT_NAME, D.DEPT_TYPE, D.ORG_SYSTEM, D.APPROVAL_MANAGER, D.DEPT_MANAGER,
@@ -57,7 +67,13 @@
END_DATE,
SORT_ORDER,
STATUS,
DELETED_AT
DELETED_AT,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
FROM DEPT_INFO
WHERE DEPT_CODE = #{dept_code}
AND DELETED_AT IS NULL
@@ -82,7 +98,13 @@
END_DATE,
SORT_ORDER,
STATUS,
DELETED_AT
DELETED_AT,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
FROM DEPT_INFO
WHERE DEPT_CODE = #{dept_code}
</select>
@@ -302,4 +324,27 @@
AND DEPT_CODE = #{dept_code}
</update>
<!-- 부서별 관리자 매핑 (role 단위 sync 용) — 전체 삭제 -->
<delete id="deleteDeptManagersByDeptAndRole" parameterType="map">
DELETE FROM DEPT_MANAGERS
WHERE DEPT_CODE = #{dept_code}
AND ROLE = #{role}
</delete>
<!-- 부서별 관리자 매핑 — bulk insert. parameterType=map, list 와 role 전달. -->
<insert id="insertDeptManagers" parameterType="map">
INSERT INTO DEPT_MANAGERS (DEPT_CODE, USER_ID, ROLE, SORT_ORDER) VALUES
<foreach collection="user_ids" item="uid" index="idx" separator=",">
(#{dept_code}, #{uid}, #{role}, #{idx} + 1)
</foreach>
</insert>
<!-- 사용자 ID 들이 같은 회사(또는 글로벌 *) 에 실존하는지 검증 — cross-tenant injection 방지 -->
<select id="selectValidUserIds" parameterType="map" resultType="string">
SELECT USER_ID FROM USER_INFO
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
AND USER_ID IN
<foreach collection="user_ids" item="u" open="(" separator="," close=")">#{u}</foreach>
</select>
</mapper>
@@ -70,7 +70,7 @@
FROM code_info
WHERE code_category = #{code_category}
WHERE code_info = #{code_info}
AND is_active = 'Y'
<if test='company_code != null and company_code != "*"'>
AND (company_code = #{company_code} OR company_code = '*')
@@ -38,17 +38,17 @@
</select>
<!-- ================================================================
정적 쿼리: table_type_columns의 code_category 조회
정적 쿼리: table_type_columns의 code_info 조회
================================================================ -->
<select id="getCodeCategoryInfo" parameterType="map" resultType="map">
SELECT code_category
SELECT code_info
FROM table_type_columns
WHERE table_name = #{table_name}
AND column_name = #{column_name}
AND code_category IS NOT NULL
AND code_info IS NOT NULL
LIMIT 1
</select>
@@ -62,7 +62,7 @@
FROM code_info
WHERE code_category = #{code_category}
WHERE code_info = #{code_info}
AND code_value IN
<foreach collection="rawValues" item="v" open="(" separator="," close=")">
#{v}
@@ -67,7 +67,7 @@
, REFERENCE_TABLE
, REFERENCE_COLUMN
, DISPLAY_COLUMN
, CODE_CATEGORY
, CODE_INFO
, CODE_VALUE
, COMPANY_CODE
FROM TABLE_TYPE_COLUMNS
@@ -16,8 +16,8 @@
category_column AS category_column,
category_value_id AS category_value_id,
created_by AS created_by,
CREATED_DATE AS CREATED_DATE,
UPDATED_DATE AS UPDATED_DATE
created_at AS created_at,
updated_at AS updated_at
</sql>
<sql id="partColumns">
@@ -42,7 +42,7 @@
<otherwise>AND (company_code = #{company_code} OR company_code = '*')</otherwise>
</choose>
ORDER BY CREATED_DATE DESC
ORDER BY created_at DESC
</select>
<select id="getRuleById" parameterType="map" resultType="map">
@@ -61,19 +61,19 @@
INSERT INTO numbering_rules (
rule_id, rule_name, description, separator, reset_period,
current_sequence, table_name, column_name, company_code,
category_column, category_value_id, created_by, CREATED_DATE, UPDATED_DATE
category_column, category_value_id, created_by, created_at, updated_at
) VALUES (
#{rule_id},
#{rule_name},
#{description, jdbcType=VARCHAR},
#{separator, jdbcType=VARCHAR},
#{reset_period, jdbcType=VARCHAR},
#{current_sequence, jdbcType=INTEGER},
#{current_sequence, jdbcType=VARCHAR},
#{table_name, jdbcType=VARCHAR},
#{column_name, jdbcType=VARCHAR},
#{company_code},
#{category_column, jdbcType=VARCHAR},
#{category_value_id, jdbcType=INTEGER},
#{category_value_id, jdbcType=VARCHAR},
#{created_by, jdbcType=VARCHAR},
NOW(), NOW()
)
@@ -89,8 +89,8 @@
table_name = COALESCE(#{table_name, jdbcType=VARCHAR}, table_name),
column_name = COALESCE(#{column_name, jdbcType=VARCHAR}, column_name),
category_column = COALESCE(#{category_column, jdbcType=VARCHAR}, category_column),
category_value_id = COALESCE(#{category_value_id, jdbcType=INTEGER}, category_value_id),
UPDATED_DATE = NOW()
category_value_id = COALESCE(#{category_value_id, jdbcType=VARCHAR}, category_value_id),
updated_at = NOW()
WHERE rule_id = #{rule_id}
AND (company_code = #{company_code} OR company_code = '*')
@@ -122,7 +122,7 @@
<insert id="insertRulePart" parameterType="map">
INSERT INTO numbering_rule_parts (
rule_id, part_order, part_type, generation_method,
auto_config, manual_config, company_code, CREATED_DATE
auto_config, manual_config, company_code, created_at
) VALUES (
#{rule_id},
#{order},
@@ -164,7 +164,17 @@
<update id="updateCurrentSequenceInRule" parameterType="map">
UPDATE numbering_rules
SET current_sequence = GREATEST(COALESCE(current_sequence, '0'), #{current_sequence}),
UPDATED_DATE = NOW()
updated_at = NOW()
WHERE rule_id = #{rule_id}
AND (company_code = #{company_code} OR company_code = '*')
</update>
<!-- admin 전용: GREATEST 없이 직접 SET. 임의 값 (0 포함) 으로 내릴 수 있음 -->
<update id="setCurrentSequenceInRule" parameterType="map">
UPDATE numbering_rules
SET current_sequence = #{current_sequence},
updated_at = NOW()
WHERE rule_id = #{rule_id}
AND (company_code = #{company_code} OR company_code = '*')
@@ -183,7 +193,7 @@
<otherwise>AND (company_code = #{company_code} OR company_code = '*')</otherwise>
</choose>
ORDER BY CREATED_DATE DESC
ORDER BY created_at DESC
</select>
<select id="getAvailableRulesForScreen" parameterType="map" resultType="map">
@@ -200,7 +210,7 @@
AND table_name = #{table_name}
</if>
ORDER BY CREATED_DATE DESC
ORDER BY created_at DESC
</select>
<select id="getRuleByColumn" parameterType="map" resultType="map">
@@ -218,8 +228,8 @@
r.category_value_id AS category_value_id,
cv.value_label AS category_value_label,
r.created_by AS created_by,
r.CREATED_DATE AS CREATED_DATE,
r.UPDATED_DATE AS UPDATED_DATE
r.created_at AS created_at,
r.updated_at AS updated_at
FROM numbering_rules r
@@ -247,8 +257,8 @@
r.category_value_id AS category_value_id,
cv.value_label AS category_value_label,
r.created_by AS created_by,
r.CREATED_DATE AS CREATED_DATE,
r.UPDATED_DATE AS UPDATED_DATE
r.created_at AS created_at,
r.updated_at AS updated_at
FROM numbering_rules r
@@ -259,7 +269,7 @@
AND (r.column_name IS NULL OR r.column_name = '')
AND r.category_value_id IS NULL
ORDER BY r.UPDATED_DATE DESC
ORDER BY r.updated_at DESC
LIMIT 1
</select>
@@ -280,7 +290,7 @@
WHERE (company_code = #{company_code} OR company_code = '*')
ORDER BY CREATED_DATE
ORDER BY created_at
</select>
<select id="getRulePartsForCopy" parameterType="map" resultType="map">
@@ -1091,12 +1091,12 @@
<select id="selectCodeCategoryForCopy" parameterType="map" resultType="map">
SELECT
*
FROM CODE_CATEGORY
FROM CODE_INFO
WHERE (COMPANY_CODE = #{source_company_code} OR COMPANY_CODE = '*')
</select>
<insert id="upsertCodeCategory" parameterType="map">
INSERT INTO CODE_CATEGORY (
INSERT INTO CODE_INFO (
CATEGORY_CODE
, CATEGORY_NAME
, COMPANY_CODE
@@ -1117,26 +1117,26 @@
*
FROM CODE_INFO
WHERE (COMPANY_CODE = #{source_company_code} OR COMPANY_CODE = '*')
AND CODE_CATEGORY = #{code_category}
AND CODE_INFO = #{code_info}
</select>
<insert id="upsertCodeInfo" parameterType="map">
INSERT INTO CODE_INFO (
CODE_CATEGORY
CODE_INFO
, CODE_VALUE
, CODE_NAME
, COMPANY_CODE
, SORT_ORDER
, IS_ACTIVE
) VALUES (
#{code_category}
#{code_info}
, #{code_value}
, #{code_name}
, #{target_company_code}
, #{sort_order}
, #{is_active}
)
ON CONFLICT (CODE_CATEGORY, CODE_VALUE, COMPANY_CODE) DO UPDATE SET
ON CONFLICT (CODE_INFO, CODE_VALUE, COMPANY_CODE) DO UPDATE SET
CODE_NAME = EXCLUDED.CODE_NAME
, SORT_ORDER = EXCLUDED.SORT_ORDER
, IS_ACTIVE = EXCLUDED.IS_ACTIVE
@@ -1359,7 +1359,7 @@
COLUMN_NAME
, INPUT_TYPE
, COLUMN_LABEL
, CODE_CATEGORY
, CODE_INFO
, REFERENCE_TABLE
, REFERENCE_COLUMN
, DISPLAY_COLUMN
@@ -1,470 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="tableCategoryValue">
<!-- ══════════════════════════════════════════════════════════════
Category Columns
══════════════════════════════════════════════════════════════ -->
<select id="getCategoryColumnList" parameterType="map" resultType="map">
SELECT
TC.TABLE_NAME
, TC.COLUMN_NAME
, TC.COLUMN_NAME AS column_label
, COUNT(CV.VALUE_ID) AS value_count
FROM TABLE_TYPE_COLUMNS TC
LEFT JOIN CATEGORY_VALUES CV
ON TC.TABLE_NAME = CV.TABLE_NAME
AND TC.COLUMN_NAME = CV.COLUMN_NAME
AND CV.IS_ACTIVE = TRUE
<if test='company_code != null and company_code != "*"'>
AND (CV.COMPANY_CODE = #{company_code} OR CV.COMPANY_CODE = '*')
</if>
WHERE TC.TABLE_NAME = #{table_name}
AND TC.INPUT_TYPE = 'category'
GROUP BY TC.TABLE_NAME, TC.COLUMN_NAME, TC.DISPLAY_ORDER
ORDER BY TC.DISPLAY_ORDER, TC.COLUMN_NAME
</select>
<select id="getAllCategoryColumnList" parameterType="map" resultType="map">
SELECT
TC.TABLE_NAME
, TC.COLUMN_NAME
, TC.COLUMN_NAME AS column_label
, COALESCE(CV_COUNT.cnt, 0) AS value_count
FROM (
SELECT DISTINCT TABLE_NAME, COLUMN_NAME, MIN(DISPLAY_ORDER) AS display_order
FROM TABLE_TYPE_COLUMNS
WHERE INPUT_TYPE = 'category'
GROUP BY TABLE_NAME, COLUMN_NAME
) TC
LEFT JOIN (
SELECT TABLE_NAME, COLUMN_NAME, COUNT(*) AS cnt
FROM CATEGORY_VALUES
WHERE IS_ACTIVE = TRUE
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
GROUP BY TABLE_NAME, COLUMN_NAME
) CV_COUNT
ON TC.TABLE_NAME = CV_COUNT.TABLE_NAME
AND TC.COLUMN_NAME = CV_COUNT.COLUMN_NAME
ORDER BY TC.TABLE_NAME, TC.DISPLAY_ORDER, TC.COLUMN_NAME
</select>
<!-- ══════════════════════════════════════════════════════════════
Category Values — Read
══════════════════════════════════════════════════════════════ -->
<select id="getCategoryValueList" parameterType="map" resultType="map">
SELECT
VALUE_ID
, TABLE_NAME
, COLUMN_NAME
, VALUE_CODE
, VALUE_LABEL
, VALUE_ORDER
, PARENT_VALUE_ID
, DEPTH
, DESCRIPTION
, COLOR
, ICON
, IS_ACTIVE
, IS_DEFAULT
, COMPANY_CODE
, MENU_OBJID
, CREATED_DATE
, UPDATED_DATE
, CREATED_BY
, UPDATED_BY
FROM CATEGORY_VALUES
WHERE TABLE_NAME = #{table_name}
AND COLUMN_NAME = #{column_name}
<choose>
<when test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</when>
<otherwise>
AND COMPANY_CODE = '*'
</otherwise>
</choose>
<if test="include_inactive == null or include_inactive == false">
AND IS_ACTIVE = TRUE
</if>
ORDER BY VALUE_ORDER, VALUE_LABEL
</select>
<select id="getCategoryValueInfo" parameterType="map" resultType="map">
SELECT
VALUE_ID
, TABLE_NAME
, COLUMN_NAME
, VALUE_CODE
, VALUE_LABEL
, VALUE_ORDER
, PARENT_VALUE_ID
, DEPTH
, DESCRIPTION
, COLOR
, ICON
, IS_ACTIVE
, IS_DEFAULT
, COMPANY_CODE
, MENU_OBJID
, CREATED_DATE
, UPDATED_DATE
, CREATED_BY
, UPDATED_BY
FROM CATEGORY_VALUES
WHERE VALUE_ID = #{value_id}
</select>
<!-- 사용 여부 확인용: table_name, column_name, value_code, value_label 반환 -->
<select id="getCategoryValueUsageInfo" parameterType="map" resultType="map">
SELECT
TABLE_NAME
, COLUMN_NAME
, VALUE_CODE
, VALUE_LABEL
FROM CATEGORY_VALUES
WHERE VALUE_ID = #{value_id}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</select>
<!-- 수정 시 라벨 중복 체크용: table_name, column_name, company_code 반환 -->
<select id="getCategoryValueLabelInfo" parameterType="map" resultType="map">
SELECT
TABLE_NAME
, COLUMN_NAME
, COMPANY_CODE
FROM CATEGORY_VALUES
WHERE VALUE_ID = #{value_id}
</select>
<!-- ══════════════════════════════════════════════════════════════
Category Values — Write
══════════════════════════════════════════════════════════════ -->
<select id="countDuplicateCode" parameterType="map" resultType="int">
SELECT COUNT(*) FROM CATEGORY_VALUES
WHERE TABLE_NAME = #{table_name}
AND COLUMN_NAME = #{column_name}
AND VALUE_CODE = #{value_code}
AND MENU_OBJID = #{menu_objid}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</select>
<select id="countDuplicateLabel" parameterType="map" resultType="int">
SELECT COUNT(*) FROM CATEGORY_VALUES
WHERE TABLE_NAME = #{table_name}
AND COLUMN_NAME = #{column_name}
AND VALUE_LABEL = #{value_label}
AND IS_ACTIVE = TRUE
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</select>
<!-- 수정 시 자기 자신 제외 라벨 중복 체크 (항상 company_code 필터) -->
<select id="countDuplicateLabelExcludeSelf" parameterType="map" resultType="int">
SELECT COUNT(*) FROM CATEGORY_VALUES
WHERE TABLE_NAME = #{table_name}
AND COLUMN_NAME = #{column_name}
AND VALUE_LABEL = #{value_label}
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
AND IS_ACTIVE = TRUE
AND VALUE_ID != #{value_id}
</select>
<insert id="insertCategoryValue" parameterType="map"
useGeneratedKeys="true" keyProperty="valueId" keyColumn="value_id">
INSERT INTO CATEGORY_VALUES (
TABLE_NAME, COLUMN_NAME, VALUE_CODE, VALUE_LABEL, VALUE_ORDER,
PARENT_VALUE_ID, DEPTH, DESCRIPTION, COLOR, ICON,
IS_ACTIVE, IS_DEFAULT, COMPANY_CODE, MENU_OBJID, CREATED_BY
) VALUES (
#{table_name}, #{column_name}, #{value_code}, #{value_label}, #{value_order},
#{parent_value_id}, #{depth}, #{description}, #{color}, #{icon},
#{is_active}, #{is_default}, #{company_code}, #{menu_objid}, #{user_id}
)
</insert>
<update id="updateCategoryValue" parameterType="map">
UPDATE CATEGORY_VALUES
<set>
<if test="value_label != null">VALUE_LABEL = #{value_label},</if>
<if test="value_order != null">VALUE_ORDER = #{value_order},</if>
<if test="description != null">DESCRIPTION = #{description},</if>
<if test="color != null">COLOR = #{color},</if>
<if test="icon != null">ICON = #{icon},</if>
<if test="is_active != null">IS_ACTIVE = #{is_active},</if>
<if test="is_default != null">IS_DEFAULT = #{is_default},</if>
UPDATED_DATE = NOW(),
UPDATED_BY = #{user_id}
</set>
WHERE VALUE_ID = #{value_id}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</update>
<!-- ══════════════════════════════════════════════════════════════
Category Values — Delete
══════════════════════════════════════════════════════════════ -->
<select id="checkTableExistsForUsage" parameterType="map" resultType="int">
SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = #{table_name}
</select>
<!--
동적 테이블 쿼리: safeTableName, safeColumnName 은 서비스에서
[a-zA-Z0-9_] 로 sanitize 후 전달. ${} 는 리터럴 치환.
-->
<select id="countValueUsageInTable" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM ${safeTableName}
WHERE ${safeColumnName} = #{value_code}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</select>
<select id="getMenuListUsingTable" parameterType="map" resultType="map">
SELECT DISTINCT
MI.OBJID AS menu_objid
, MI.MENU_NAME_KOR AS menu_name
, MI.MENU_URL
FROM MENU_INFO MI
INNER JOIN SCREEN_MENU_ASSIGNMENTS SMA ON SMA.MENU_OBJID = MI.OBJID
INNER JOIN SCREEN_DEFINITIONS SD ON SD.SCREEN_ID = SMA.SCREEN_ID
WHERE SD.TABLE_NAME = #{table_name}
AND (MI.COMPANY_CODE = #{company_code} OR MI.COMPANY_CODE = '*')
ORDER BY MI.MENU_NAME_KOR
</select>
<!-- 재귀 CTE 로 모든 하위 value_id 수집 -->
<select id="getChildValueIdList" parameterType="map" resultType="map">
WITH RECURSIVE category_tree AS (
SELECT VALUE_ID
FROM CATEGORY_VALUES
WHERE PARENT_VALUE_ID = #{value_id}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
UNION ALL
SELECT CV.VALUE_ID
FROM CATEGORY_VALUES CV
INNER JOIN category_tree CT ON CV.PARENT_VALUE_ID = CT.VALUE_ID
<if test='company_code != null and company_code != "*"'>
WHERE (CV.COMPANY_CODE = #{company_code} OR CV.COMPANY_CODE = '*')
</if>
)
SELECT VALUE_ID FROM category_tree
</select>
<delete id="deleteValueById" parameterType="map">
DELETE FROM CATEGORY_VALUES
WHERE VALUE_ID = #{value_id}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</delete>
<!-- ══════════════════════════════════════════════════════════════
Bulk / Reorder
══════════════════════════════════════════════════════════════ -->
<update id="bulkSoftDeleteValues" parameterType="map">
UPDATE CATEGORY_VALUES
SET IS_ACTIVE = FALSE,
UPDATED_DATE = NOW(),
UPDATED_BY = #{user_id}
WHERE VALUE_ID IN
<foreach collection="valueIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</update>
<update id="updateValueOrder" parameterType="map">
UPDATE CATEGORY_VALUES
SET VALUE_ORDER = #{value_order},
UPDATED_DATE = NOW()
WHERE VALUE_ID = #{value_id}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</update>
<!-- ══════════════════════════════════════════════════════════════
Column Mapping
══════════════════════════════════════════════════════════════ -->
<select id="getColumnMappingList" parameterType="map" resultType="map">
SELECT
LOGICAL_COLUMN_NAME
, PHYSICAL_COLUMN_NAME
FROM CATEGORY_COLUMN_MAPPING
WHERE TABLE_NAME = #{table_name}
AND MENU_OBJID = #{menu_objid}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</select>
<select id="getLogicalColumnList" parameterType="map" resultType="map">
SELECT
MAPPING_ID
, LOGICAL_COLUMN_NAME
, PHYSICAL_COLUMN_NAME
, DESCRIPTION
FROM CATEGORY_COLUMN_MAPPING
WHERE TABLE_NAME = #{table_name}
AND MENU_OBJID = #{menu_objid}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
ORDER BY LOGICAL_COLUMN_NAME
</select>
<select id="checkPhysicalColumnExists" parameterType="map" resultType="int">
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = #{table_name}
AND column_name = #{physical_column_name}
</select>
<!-- UPSERT: ON CONFLICT (table_name, logical_column_name, menu_objid, company_code) -->
<insert id="upsertColumnMapping" parameterType="map">
INSERT INTO CATEGORY_COLUMN_MAPPING (
TABLE_NAME, LOGICAL_COLUMN_NAME, PHYSICAL_COLUMN_NAME,
MENU_OBJID, COMPANY_CODE, DESCRIPTION, CREATED_BY, UPDATED_BY
) VALUES (
#{table_name}, #{logical_column_name}, #{physical_column_name},
#{menu_objid}, #{company_code}, #{description}, #{user_id}, #{user_id}
)
ON CONFLICT (table_name, logical_column_name, menu_objid, company_code)
DO UPDATE SET
PHYSICAL_COLUMN_NAME = EXCLUDED.PHYSICAL_COLUMN_NAME,
DESCRIPTION = EXCLUDED.DESCRIPTION,
UPDATED_DATE = NOW(),
UPDATED_BY = EXCLUDED.UPDATED_BY
</insert>
<select id="getColumnMappingInfo" parameterType="map" resultType="map">
SELECT *
FROM CATEGORY_COLUMN_MAPPING
WHERE TABLE_NAME = #{table_name}
AND LOGICAL_COLUMN_NAME = #{logical_column_name}
AND MENU_OBJID = #{menu_objid}
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</select>
<delete id="deleteColumnMappingById" parameterType="map">
DELETE FROM CATEGORY_COLUMN_MAPPING
WHERE MAPPING_ID = #{mapping_id}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</delete>
<delete id="deleteColumnMappingsByColumn" parameterType="map">
DELETE FROM CATEGORY_COLUMN_MAPPING
WHERE TABLE_NAME = #{table_name}
AND LOGICAL_COLUMN_NAME = #{column_name}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</delete>
<!-- ══════════════════════════════════════════════════════════════
Labels by Codes
══════════════════════════════════════════════════════════════ -->
<select id="getLabelListByCodes" parameterType="map" resultType="map">
SELECT DISTINCT
VALUE_CODE
, VALUE_LABEL
FROM CATEGORY_VALUES
WHERE VALUE_CODE IN
<foreach collection="valueCodes" item="code" open="(" separator="," close=")">
#{code}
</foreach>
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</select>
<!-- ══════════════════════════════════════════════════════════════
Second-Level Menus
══════════════════════════════════════════════════════════════ -->
<select id="checkMenuInfoHasCompanyCode" resultType="int">
SELECT COUNT(*) FROM information_schema.columns
WHERE table_name = 'menu_info'
AND column_name = 'company_code'
</select>
<select id="getSecondLevelMenuList" parameterType="map" resultType="map">
SELECT
M1.OBJID AS menu_objid
, M1.MENU_NAME_KOR AS menu_name
, M0.MENU_NAME_KOR AS parent_menu_name
, M1.SCREEN_CODE AS screen_code
FROM MENU_INFO M1
INNER JOIN MENU_INFO M0 ON M1.PARENT_OBJ_ID = M0.OBJID
WHERE M1.MENU_TYPE = '1'
AND M1.STATUS = 'active'
AND M0.PARENT_OBJ_ID = '0'
<if test='has_company_code and company_code != null and company_code != "*"'>
AND (M1.COMPANY_CODE = #{company_code} OR M1.COMPANY_CODE = '*')
</if>
ORDER BY M0.SEQ, M1.SEQ
</select>
</mapper>
@@ -57,7 +57,7 @@
, C.CHARACTER_MAXIMUM_LENGTH AS MAX_LENGTH
, C.NUMERIC_PRECISION
, C.NUMERIC_SCALE
, CL.CODE_CATEGORY
, CL.CODE_INFO
, CL.CODE_VALUE
, CL.REFERENCE_TABLE
, CL.REFERENCE_COLUMN
@@ -110,7 +110,7 @@
, C.CHARACTER_MAXIMUM_LENGTH AS MAX_LENGTH
, C.NUMERIC_PRECISION
, C.NUMERIC_SCALE
, COALESCE(TTC.CODE_CATEGORY, CL.CODE_CATEGORY) AS CODE_CATEGORY
, COALESCE(TTC.CODE_INFO, CL.CODE_INFO) AS CODE_INFO
, COALESCE(TTC.CODE_VALUE, CL.CODE_VALUE) AS CODE_VALUE
, COALESCE(TTC.REFERENCE_TABLE, CL.REFERENCE_TABLE) AS REFERENCE_TABLE
, COALESCE(TTC.REFERENCE_COLUMN, CL.REFERENCE_COLUMN) AS REFERENCE_COLUMN
@@ -253,7 +253,7 @@
, DESCRIPTION
, DISPLAY_ORDER
, IS_VISIBLE
, CODE_CATEGORY
, CODE_INFO
, CODE_VALUE
, REFERENCE_TABLE
, REFERENCE_COLUMN
@@ -275,7 +275,7 @@
, COLUMN_LABEL
, INPUT_TYPE
, DETAIL_SETTINGS
, CODE_CATEGORY
, CODE_INFO
, CODE_VALUE
, REFERENCE_TABLE
, REFERENCE_COLUMN
@@ -293,7 +293,7 @@
, #{column_label}
, #{input_type}
, #{detail_settings}::JSONB
, #{code_category}
, #{code_info}
, #{code_value}
, #{reference_table}
, #{reference_column}
@@ -311,7 +311,7 @@
COLUMN_LABEL = COALESCE(EXCLUDED.COLUMN_LABEL, TABLE_TYPE_COLUMNS.COLUMN_LABEL)
, INPUT_TYPE = COALESCE(EXCLUDED.INPUT_TYPE, TABLE_TYPE_COLUMNS.INPUT_TYPE)
, DETAIL_SETTINGS = COALESCE(EXCLUDED.DETAIL_SETTINGS, TABLE_TYPE_COLUMNS.DETAIL_SETTINGS)
, CODE_CATEGORY = EXCLUDED.CODE_CATEGORY
, CODE_INFO = EXCLUDED.CODE_INFO
, CODE_VALUE = EXCLUDED.CODE_VALUE
, REFERENCE_TABLE = EXCLUDED.REFERENCE_TABLE
, REFERENCE_COLUMN = EXCLUDED.REFERENCE_COLUMN
@@ -354,7 +354,7 @@
, REFERENCE_TABLE = CASE WHEN #{clear_entity} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.REFERENCE_TABLE END
, REFERENCE_COLUMN= CASE WHEN #{clear_entity} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.REFERENCE_COLUMN END
, DISPLAY_COLUMN = CASE WHEN #{clear_entity} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.DISPLAY_COLUMN END
, CODE_CATEGORY = CASE WHEN #{clear_code} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.CODE_CATEGORY END
, CODE_INFO = CASE WHEN #{clear_code} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.CODE_INFO END
, CODE_VALUE = CASE WHEN #{clear_code} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.CODE_VALUE END
, CATEGORY_REF = CASE WHEN #{clear_category} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.CATEGORY_REF END
, UPDATED_DATE = NOW()
@@ -389,7 +389,7 @@
<select id="getTablePrimaryKeyList" parameterType="map" resultType="map">
SELECT
TC.CONNAME AS constraint_name
, ARRAY_AGG(A.ATTNAME ORDER BY X.N) AS columns
, ARRAY_AGG(A.ATTNAME ORDER BY X.N)::text AS columns
FROM PG_CONSTRAINT TC
JOIN PG_CLASS C
ON TC.CONRELID = C.OID
@@ -411,7 +411,7 @@
SELECT
I.RELNAME AS index_name
, IX.INDISUNIQUE AS is_unique
, ARRAY_AGG(A.ATTNAME ORDER BY X.N) AS columns
, ARRAY_AGG(A.ATTNAME ORDER BY X.N)::text AS columns
FROM PG_INDEX IX
JOIN PG_CLASS T
ON IX.INDRELID = T.OID
@@ -667,15 +667,15 @@
SET
PROPERTIES = JSONB_SET(
JSONB_SET(
SL.PROPERTIES,
SL.PROPERTIES::JSONB,
'{widgetType}', TO_JSONB(#{component_id}::TEXT)
),
'{componentType}', TO_JSONB(#{component_id}::TEXT)
)
)::TEXT
FROM SCREEN_DEFINITIONS SD
WHERE SL.SCREEN_ID = SD.SCREEN_ID
AND SL.PROPERTIES->>'tableName' = #{table_name}
AND SL.PROPERTIES->>'columnName' = #{column_name}
AND SL.PROPERTIES::JSONB->>'tableName' = #{table_name}
AND SL.PROPERTIES::JSONB->>'columnName' = #{column_name}
AND ((SD.COMPANY_CODE = #{company_code} OR SD.COMPANY_CODE = '*') OR #{company_code} = '*')
</update>
+133
View File
@@ -0,0 +1,133 @@
# 088 마이그레이션 — DEPT_MANAGERS 테이블 추가 (다중 관리자 + 조직장)
작성일: 2026-05-14
작성자: johngreen
관련: RPS 더존 ERP UJA1040 레퍼런스 대비 누락 기능 (A 단계 — 다중 관리자 + 조직장)
## 목적
부서별로 결재 관리자 / 부서 관리자 / 조직장을 각각 **다중 등록 (최대 10명)** 할 수 있도록 매핑 테이블 신설.
- 기존 `DEPT_INFO.APPROVAL_MANAGER` / `DEPT_INFO.DEPT_MANAGER` 컬럼은 단일 `user_id` 만 저장 가능
- 신규 `DEPT_MANAGERS` 매핑 테이블이 SoT(source of truth). `ROLE` 컬럼으로 3 종류 구분
- `approval` = 결재 관리자 (자동 결재라인 등록 시 호출)
- `dept` = 부서 관리자 (행정 책임자)
- `org_leader` = 조직장 (본인 부서 + 하위 부서의 경비/근태 조회·승인 권한)
- 기존 단일 컬럼은 **호환 위해 일단 유지**. 향후 cleanup PR 에서 제거 예정
## 스키마
### DEPT_MANAGERS (신규)
| 컬럼 | 타입 | 제약 | 설명 |
|---|---|---|---|
| `DEPT_CODE` | VARCHAR(1024) | NOT NULL, FK → DEPT_INFO ON DELETE CASCADE | 부서 코드 |
| `USER_ID` | VARCHAR(50) | NOT NULL | 사용자 ID |
| `ROLE` | VARCHAR(20) | NOT NULL, CHECK | `approval` \| `dept` \| `org_leader` |
| `SORT_ORDER` | INTEGER | NOT NULL DEFAULT 1 | 표시 순서 |
| `CREATED_AT` | TIMESTAMP | NOT NULL DEFAULT NOW() | 등록 시각 |
PK: `(DEPT_CODE, USER_ID, ROLE)` — 같은 사용자가 같은 부서에 같은 role 로 중복 등록 차단.
인덱스: `(DEPT_CODE, ROLE, SORT_ORDER)` — 부서별 role 조회 + 정렬 가속.
## SQL
```sql
-- =================================================================
-- 088: DEPT_MANAGERS 테이블 (idempotent)
-- =================================================================
CREATE TABLE IF NOT EXISTS DEPT_MANAGERS (
DEPT_CODE VARCHAR(1024) NOT NULL,
USER_ID VARCHAR(50) NOT NULL,
ROLE VARCHAR(20) NOT NULL,
SORT_ORDER INTEGER NOT NULL DEFAULT 1,
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (DEPT_CODE, USER_ID, ROLE),
CONSTRAINT chk_dept_managers_role
CHECK (ROLE IN ('approval', 'dept', 'org_leader')),
CONSTRAINT fk_dept_managers_dept
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_dept_managers_role
ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER);
```
부팅 시 `StartupSchemaMigrator` 가 메타 DB + 모든 활성 테넌트 DB 에 동일 DDL 을 `IF NOT EXISTS` 로 적용하므로 일반적으로는 별도 수동 실행이 필요 없음.
## 사전 점검
```sql
-- A. 테이블 사전 상태
SELECT table_name FROM information_schema.tables WHERE table_name = 'dept_managers';
-- 빈 결과여야 정상. 이미 있으면 CREATE 의 IF NOT EXISTS 가 안전.
-- B. DEPT_INFO 행수 (FK 영향 범위)
SELECT COUNT(*) FROM DEPT_INFO;
```
## 사후 검증
```sql
-- C. 테이블 추가 확인
SELECT column_name, data_type, character_maximum_length
FROM information_schema.columns
WHERE table_name = 'dept_managers'
ORDER BY ordinal_position;
-- 기대: 5 행 (DEPT_CODE/USER_ID/ROLE/SORT_ORDER/CREATED_AT)
-- D. CHECK 제약 확인
SELECT constraint_name, check_clause FROM information_schema.check_constraints
WHERE constraint_name = 'chk_dept_managers_role';
-- 기대: ROLE IN ('approval', 'dept', 'org_leader')
-- E. FK 동작 확인 (테스트)
BEGIN;
INSERT INTO DEPT_MANAGERS (DEPT_CODE, USER_ID, ROLE)
VALUES ('NON_EXISTENT_DEPT', 'tester', 'approval');
-- 기대: FK 위반 에러 (foreign key constraint "fk_dept_managers_dept")
ROLLBACK;
```
## 실행
```bash
# 1) 메타 DB
psql -h <host> -U postgres -d invyone -f RUN_088.sql
# 2) 각 테넌트 DB (StartupSchemaMigrator 가 부팅 시 자동 적용하므로 통상 생략 가능)
for db in $(psql -tA -d invyone -c "SELECT db_name FROM company_mng WHERE db_status='active'"); do
echo "=== $db ==="
psql -h <host> -U postgres -d "$db" -f RUN_088.sql
done
```
## 롤백
```sql
-- DEPT_MANAGERS 테이블 제거 (저장된 다중 관리자 매핑 함께 삭제됨)
DROP INDEX IF EXISTS idx_dept_managers_role;
DROP TABLE IF EXISTS DEPT_MANAGERS;
```
롤백 후엔 백엔드/프론트가 단일 `APPROVAL_MANAGER` / `DEPT_MANAGER` 컬럼만 사용하는 이전 동작으로 자연스럽게 복귀 (호환 컬럼 유지하기 때문).
## 적용 환경 체크리스트
- [ ] 로컬 docker `naengangi-pg` (관련 없음 — invyone DB 는 wace/운영에만 존재)
- [ ] wace 개발서버 PostgreSQL
- [ ] 운영 메타 DB (`invyone`)
- [ ] 운영 각 테넌트 DB (loop or 부팅 시 자동)
## 관련 코드
- Flyway: `backend-spring/src/main/resources/db/migration/V022__create_dept_managers.sql`
- StartupSchemaMigrator: `backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java` (마지막 항목으로 추가)
- Mapper: `backend-spring/src/main/resources/mapper/department.xml`
- `selectDepartments` / `selectDepartmentByCode` 의 SELECT 절에 `APPROVAL_MANAGERS`/`DEPT_MANAGERS`/`ORG_LEADERS` json_agg 컬럼 추가
- 신규 query: `insertDeptManagers`, `deleteDeptManagersByDept`
- Service: `DepartmentService.java`
- `createDepartment` / `updateDepartment` 가 body 의 `approval_managers[]`/`dept_managers[]`/`org_leaders[]` 배열을 `DEPT_MANAGERS` 에 sync (트랜잭션, 최대 10명 검증)
- Frontend: `frontend/app/(main)/admin/userMng/deptMngList/page.tsx`
- BasicInfoForm 에 다중 chip UI + ManagerPicker 모달
+143
View File
@@ -0,0 +1,143 @@
# 089 마이그레이션 — IS_SOLUTION_ONLY 메뉴 플래그 + TABLE_TYPE_COLUMNS.CODE_CATEGORY rename
작성일: 2026-05-15
작성자: johngreen
관련:
- (V023) 멀티테넌시 메뉴 격리 — 5/15 fix (commit c530a67c)
- (V024) common-code 마스터-디테일 재설계 — 5/15 refactor (commit 2348800e)
## 목적
V023 과 V024 두 건의 누락된 운영 문서를 합본 처리.
앱 부팅 시 `StartupSchemaMigrator` 가 idempotent 로 메타 DB + 활성 테넌트 DB 전부에 자동 적용한다.
### V023 — MENU_INFO.IS_SOLUTION_ONLY 컬럼 (회상)
테넌트 사이트(`*.invyone.com`)에서 솔루션 전용 관리자 메뉴(회사관리/회사 프로비저닝/감사로그)를 숨기기 위한 플래그.
- 메뉴 mapper SQL(`selectAdminMenuList`, `selectUserMenuList`)이 `is_management_host` 파라미터를 보고 `IS_SOLUTION_ONLY=TRUE` 행을 제외.
- 이미 부팅 마이그레이션으로는 적용 중이지만 RUN_*.md 운영 문서가 빠져있어 이번 089 에 합본.
### V024 — TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO (★ 신규, 본 PR 의 핵심)
5/15 의 commonCode 마스터-디테일 재설계(commit `2348800e`)가 mapper SQL 6 군데에서
`CL.CODE_CATEGORY``CL.CODE_INFO` 로 컬럼 참조명을 바꿨지만, **DB 컬럼 rename SQL 을 빠뜨린 채 머지**됨.
그 결과 모든 테넌트 DB 의 `테이블 타입관리 > 테이블 클릭 > 컬럼 목록` API
(`GET /api/table-management/tables/{name}/columns`) 가 **500** 반환:
```
ERROR: column cl.code_info does not exist
```
본 089 마이그레이션이 `CODE_CATEGORY``CODE_INFO` 로 컬럼명을 안전하게 변경한다.
## 스키마
### MENU_INFO (V023)
| 컬럼 | 타입 | 제약 | 설명 |
|---|---|---|---|
| `IS_SOLUTION_ONLY` | BOOLEAN | NOT NULL DEFAULT FALSE | TRUE 인 메뉴는 솔루션 관리 호스트에서만 노출 |
### TABLE_TYPE_COLUMNS (V024)
| 변경 | 설명 |
|---|---|
| `CODE_CATEGORY``CODE_INFO` | 컬럼 RENAME (값/타입/제약 그대로) |
## SQL
```sql
-- =================================================================
-- 089-V023: MENU_INFO.IS_SOLUTION_ONLY (idempotent)
-- =================================================================
ALTER TABLE MENU_INFO
ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL;
UPDATE MENU_INFO
SET IS_SOLUTION_ONLY = TRUE
WHERE IS_SOLUTION_ONLY = FALSE
AND MENU_URL IN (
'/admin/sysMng/subdomainList',
'/admin/userMng/companyList',
'/admin/audit-log'
);
-- =================================================================
-- 089-V024: TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO (idempotent)
-- =================================================================
-- PostgreSQL 은 RENAME COLUMN 에 IF EXISTS 가 없으므로 DO 블록으로
-- 멱등성 보장 (이미 CODE_INFO 면 no-op, CODE_CATEGORY 만 존재할 때만 rename).
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'table_type_columns'
AND column_name = 'code_category'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'table_type_columns'
AND column_name = 'code_info'
) THEN
ALTER TABLE TABLE_TYPE_COLUMNS
RENAME COLUMN CODE_CATEGORY TO CODE_INFO;
END IF;
END $$;
```
## 멱등성
- V023: `ADD COLUMN IF NOT EXISTS` + UPDATE `WHERE IS_SOLUTION_ONLY = FALSE` 로 중복 실행 안전.
- V024: DO 블록 안에서 information_schema 로 현재 상태 확인 후 분기.
- 신규 테넌트 DB (이미 CODE_INFO 면): no-op
- 기존 테넌트 DB (CODE_CATEGORY 만 있으면): rename 수행
- 둘 다 있거나 둘 다 없으면: no-op (방어적)
## 적용 방법
부팅 시 자동 적용 — 별도 작업 불필요.
`backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java` 의 MIGRATIONS 리스트에
위 SQL 이 등록되어 있어서 앱이 시작할 때 모든 활성 테넌트 DB 에 idempotent 로 실행된다.
수동 적용이 필요한 경우 (예: 새 환경 부트스트랩 전):
```bash
psql -h <host> -U <user> -d <tenant_db> -f - <<'SQL'
-- 위 SQL 본문 붙여넣기
SQL
```
## 검증
```sql
-- V023
SELECT COLUMN_NAME FROM information_schema.columns
WHERE TABLE_NAME = 'menu_info' AND COLUMN_NAME = 'is_solution_only';
-- → 1 row
SELECT MENU_URL, IS_SOLUTION_ONLY FROM MENU_INFO
WHERE MENU_URL IN ('/admin/sysMng/subdomainList', '/admin/userMng/companyList', '/admin/audit-log');
-- → 모두 IS_SOLUTION_ONLY = TRUE
-- V024
SELECT COLUMN_NAME FROM information_schema.columns
WHERE TABLE_NAME = 'table_type_columns' AND COLUMN_NAME IN ('code_category', 'code_info');
-- → 1 row: code_info (code_category 는 존재하면 안 됨)
```
## 영향 범위
- 테이블 타입관리 페이지 컬럼 조회 500 에러 해소.
- common-code 재설계 후속 (mapper/Service/Frontend 는 이미 5/15 에 머지됨).
- 부팅 시점 1회 실행 — 런타임 트래픽에는 영향 없음.
## 롤백
V024 rename 을 되돌리려면 mapper SQL 도 같이 되돌려야 하므로 일반적으로 권장하지 않음.
만약 필요하면:
```sql
ALTER TABLE TABLE_TYPE_COLUMNS RENAME COLUMN CODE_INFO TO CODE_CATEGORY;
```
+ `mapper/tableManagement.xml`, `commonCode.xml`, FE `commonCode.ts` 등 5/15 변경분 revert.
+109
View File
@@ -0,0 +1,109 @@
# 090 마이그레이션 — TABLE_TYPE_COLUMNS 중복 정리 + ON CONFLICT 용 UNIQUE INDEX
작성일: 2026-05-15
작성자: johngreen
관련 버그: 테이블 타입관리에서 모든 쓰기 API (UNIQUE 토글 / NOT NULL 토글 / 컬럼 설정 저장) 가 500 반환.
## 증상
```
PSQLException: ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification
mapper: tableManagement.upsertColumnSettings / upsertNullable / upsertUnique / upsertColumnInputType
```
## 원인
`TABLE_TYPE_COLUMNS` 의 PK 는 `id` 단일(varchar). 운영 DB 어디에도
`(TABLE_NAME, COLUMN_NAME, COMPANY_CODE)` UNIQUE 제약/인덱스가 없음.
mapper 의 `INSERT … ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) DO UPDATE …`
구문이 매칭할 unique constraint 를 찾지 못해 즉시 BadSqlGrammar 로 500.
RUN_044 가 company_code 컬럼을 추가했지만 함께 도입했어야 할 unique index 가
빠진 채로 운영에 들어간 것으로 보이며, 그 후 mapper 가 ON CONFLICT 패턴으로 작성되면서
실제로는 한 번도 정상 동작하지 못한 채로 잠복했던 정황 (운영 메타 DB 의 35,316 행 중
중복 키 그룹 2개 = 추가 4 row 가 그 흔적).
## 조치
### (1) 중복 행 정리
`(TABLE_NAME, COLUMN_NAME, COMPANY_CODE)` 그룹에서
`updated_date DESC NULLS LAST, id::bigint DESC` 로 정렬해 첫 행만 유지, 나머지 DELETE.
```sql
DELETE FROM TABLE_TYPE_COLUMNS
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE
ORDER BY UPDATED_DATE DESC NULLS LAST,
id::bigint DESC
) AS rn
FROM TABLE_TYPE_COLUMNS
) r
WHERE r.rn > 1
);
```
실측(2026-05-15) 중복:
| DB | 중복 그룹 | 삭제될 row |
|---|---|---|
| meta `invyone` | 2 (`sales_order_mng.incoterms@COMPANY_16`, `sales_order_mng.payment_term@COMPANY_16`) | 2 |
| `siflex_invyone` | 0 | 0 |
| `test01_invyone` | 0 | 0 |
| `test02_invyone` | 0 | 0 |
남는 행은 가장 최근에 갱신된 동일 키 row (column_label/input_type 모두 동일 — 옛 NULL updated_date row 가 제거 대상).
### (2) UNIQUE INDEX 추가
```sql
CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC
ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE);
```
PostgreSQL 은 ON CONFLICT 가 인덱스도 인식하므로 mapper 의 모든 upsert SQL 이
즉시 정상 동작. `IF NOT EXISTS` 로 멱등.
## 적용 방법
부팅 시 자동 적용 — 별도 작업 불필요. `StartupSchemaMigrator.MIGRATIONS` 리스트에
V025 / RUN_090 (1) (2) 항목으로 등록되어 있어서 앱이 시작할 때 메타 DB + 모든 활성
테넌트 DB 에 차례로 실행된다.
## 검증
```sql
-- 중복 없음
SELECT COUNT(*) FROM (
SELECT 1 FROM TABLE_TYPE_COLUMNS
GROUP BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE HAVING COUNT(*) > 1
) d;
-- → 0
-- 인덱스 존재
SELECT indexname FROM pg_indexes
WHERE tablename = 'table_type_columns' AND indexname = 'ux_table_type_columns_tcc';
-- → 1 row
```
브라우저 검증:
1. 솔루션 또는 테넌트 사이트 > 시스템 관리 > 테이블 타입관리 > 거래처 클릭
2. 어느 컬럼이든 `UQ` / `NN` 토글 클릭 → 200, 토스트 "UNIQUE/NOT NULL 제약이 설정되었습니다"
3. "컬럼 설정 저장" 버튼 클릭 → 200, 토스트 "모든 컬럼 설정을 성공적으로 저장했습니다"
## 영향 범위
- 테이블 타입관리 페이지 쓰기 API 4종 (`unique`, `nullable`, `columns/settings`, `columns/{c}/input-type`) 정상화.
- 멱등 — 재실행 시 DELETE 0건, CREATE INDEX 도 IF NOT EXISTS 라 skip.
- 부팅 시점 1회 실행, 런타임 트래픽에는 영향 없음.
## 롤백
```sql
DROP INDEX IF EXISTS UX_TABLE_TYPE_COLUMNS_TCC;
```
DELETE 된 중복 row 는 정보 손실 없음 (남은 row 와 column_label/input_type 동일) 이라
복구가 의미 없음. 그래도 굳이 되돌리려면 사전 백업 필요.
+81
View File
@@ -0,0 +1,81 @@
# 091 마이그레이션 — TABLE_TYPE_COLUMNS.INPUT_TYPE legacy → 표준 8종 정리
작성일: 2026-05-16
작성자: johngreen
관련: 5/15 common-code 재설계 (commit `2348800e`) 후속 데이터 마이그레이션.
## 배경
5/15 PR 이 `InputTypeConstants.USER_SELECTABLE_INPUT_TYPES` 화이트리스트를
표준 8종(`text/number/date/code/entity/numbering/file/image`) 으로 좁혔지만,
운영 DB 에 잔존하는 옛 input_type 값들을 정리하는 데이터 마이그레이션이 빠지고
프론트엔드도 옛 값을 그대로 echo 했기 때문에 컬럼 설정 저장 batch 가 400 으로 거부됐다.
긴급 회복은 `90787d83` 에서 화이트리스트에 legacy 7종을 다시 인정하는 방식으로
끝냈고, 본 091 마이그레이션은 그 뒤로 **데이터를 표준으로 통합**하는 후속 정리.
## 매핑
| Legacy | → | Standard | 사유 |
|---|---|---|---|
| `category` | → | `code` | commonCode 통합 의도와 일치 |
| `select` | → | `code` | 미리 정의된 코드 선택 = code 와 동등 |
| `radio` | → | `code` | enum 선택 |
| `checkbox` | → | `code` | enum/boolean → code 매핑 (표준에 boolean 없음) |
| `boolean` | → | `code` | 표준에 boolean 없음 — code 가 가장 근접 |
| `textarea` | → | `text` | single/multi line 구분 UI 손실 (가벼움) |
| `datetime` | → | `date` | 표준에 datetime 분리 없음 |
## 영향 범위 (실측 2026-05-16)
| DB | 갱신 row |
|---|---|
| meta `invyone` | 1,207 (category 886 + select 149 + textarea 102 + checkbox 55 + radio 12 + datetime 2 + boolean 1) |
| `siflex_invyone` | 0 (테이블 비어있음) |
| `test01_invyone` | 0 |
| `test02_invyone` | 0 |
## SQL
```sql
UPDATE TABLE_TYPE_COLUMNS
SET INPUT_TYPE = CASE INPUT_TYPE
WHEN 'category' THEN 'code'
WHEN 'select' THEN 'code'
WHEN 'radio' THEN 'code'
WHEN 'checkbox' THEN 'code'
WHEN 'boolean' THEN 'code'
WHEN 'textarea' THEN 'text'
WHEN 'datetime' THEN 'date'
END,
UPDATED_DATE = NOW()
WHERE INPUT_TYPE IN ('category','select','radio','checkbox','boolean','textarea','datetime');
```
## 멱등성
`WHERE INPUT_TYPE IN (...)` 으로 두 번째 실행 시 매칭 row 0 → no-op.
## 적용 방법
부팅 시 자동 적용. `StartupSchemaMigrator.MIGRATIONS` 리스트에 V026 / RUN_091 항목으로
등록되어 있어서 backend 시작 시 메타 DB + 활성 테넌트 DB 전부에 idempotent 로 실행된다.
## 검증
```sql
-- 화이트리스트 밖 row 0 이어야 함
SELECT input_type, COUNT(*) FROM table_type_columns
WHERE input_type NOT IN ('text','number','date','code','entity','numbering','file','image')
GROUP BY 1;
-- → 0 rows
```
## 후속 cleanup (별도 PR 거리)
본 마이그레이션이 모든 환경에 한 번 적용된 다음에는:
1. `InputTypeConstants.USER_SELECTABLE_INPUT_TYPES` 에서 legacy 7종 다시 제거.
2. 프론트엔드 input type 선택 UI 에서 legacy 옵션 제거 (이미 있을 수도).
3. mapper/Service 에서 legacy 값 참조 흔적 grep + 정리.
이번 PR 은 데이터 정리만. 화이트리스트 축소는 운영 안정 확인 후.
+15 -1
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -64,6 +65,7 @@ import {
import { getCompanyList } from "@/lib/api/company";
import { useAuth } from "@/hooks/useAuth";
import { Company } from "@/types/company";
import { isManagementHost } from "@/lib/tenant/subdomain";
const RESOURCE_TYPE_CONFIG: Record<
string,
@@ -78,7 +80,7 @@ const RESOURCE_TYPE_CONFIG: Record<
USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" },
ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" },
CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
CODE_INFO: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
CODE: { label: "코드", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
DATA: { label: "데이터", icon: Database, color: "bg-muted text-foreground" },
TABLE: { label: "테이블", icon: Database, color: "bg-muted text-foreground" },
@@ -290,6 +292,16 @@ function groupByDate(entries: AuditLogEntry[]): Map<string, AuditLogEntry[]> {
}
export default function AuditLogPage() {
const router = useRouter();
const [hostBlocked, setHostBlocked] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
if (!isManagementHost(window.location.hostname)) {
setHostBlocked(true);
router.replace("/main");
}
}, [router]);
const { user } = useAuth();
const isSuperAdmin = user?.company_code === "*";
@@ -393,6 +405,8 @@ export default function AuditLogPage() {
setDetailOpen(true);
};
if (hostBlocked) return null;
return (
<div className="flex h-full flex-col gap-4 p-4 md:p-6">
<div className="flex items-center justify-between">
@@ -1,21 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
/**
*
*/
export default function AutoFillRedirect() {
const router = useRouter();
useEffect(() => {
router.replace("/admin/cascading-management?tab=autofill");
}, [router]);
return (
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-sm"> ...</div>
</div>
);
}
+3 -3
View File
@@ -3,10 +3,10 @@
// INVYONE 스튜디오 진입 페이지 (templates 테이블 기반)
// - 템플릿 목록 + 새 템플릿 생성 → templates 테이블 CRUD
// - URL ?id=<template_id> 로 바로 진입
// - ScreenDesigner 는 template_id 를 통해 templates API 로 저장/로드
// - InvyoneStudio 는 template_id 를 통해 templates API 로 저장/로드
import { Suspense, useState, useEffect, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import ScreenDesigner from "@/components/screen/ScreenDesigner";
import InvyoneStudio from "@/components/screen/InvyoneStudio";
import type { ScreenDefinition } from "@/types/screen";
import { getTemplateList, deleteTemplate } from "@/lib/api/template";
import { createTemplate } from "@/lib/utils/templateAdapter";
@@ -442,7 +442,7 @@ function BuilderInner() {
return (
<div className="ide-builder h-[calc(100vh-4rem)] w-full overflow-hidden bg-background">
<ScreenDesigner
<InvyoneStudio
selectedScreen={selectedScreen}
onBackToList={handleBackToList}
onScreenUpdate={(updatedFields) => {
@@ -1,115 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Link2, Layers, Filter, FormInput, Ban, Tags, Columns } from "lucide-react";
// 탭별 컴포넌트
import CascadingRelationsTab from "./tabs/CascadingRelationsTab";
import AutoFillTab from "./tabs/AutoFillTab";
import HierarchyTab from "./tabs/HierarchyTab";
import ConditionTab from "./tabs/ConditionTab";
import MutualExclusionTab from "./tabs/MutualExclusionTab";
import CategoryValueCascadingTab from "./tabs/CategoryValueCascadingTab";
import HierarchyColumnTab from "./tabs/HierarchyColumnTab";
export default function CascadingManagementPage() {
const searchParams = useSearchParams();
const router = useRouter();
const [activeTab, setActiveTab] = useState("relations");
// URL 쿼리 파라미터에서 탭 설정
useEffect(() => {
const tab = searchParams.get("tab");
if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion", "category-value", "hierarchy-column"].includes(tab)) {
setActiveTab(tab);
}
}, [searchParams]);
// 탭 변경 시 URL 업데이트
const handleTabChange = (value: string) => {
setActiveTab(value);
const url = new URL(window.location.href);
url.searchParams.set("tab", value);
router.replace(url.pathname + url.search);
};
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground">
, , , .
</p>
</div>
{/* 탭 네비게이션 */}
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="grid w-full grid-cols-6">
<TabsTrigger value="relations" className="gap-2">
<Link2 className="h-4 w-4" />
<span className="hidden sm:inline">2 </span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger value="hierarchy" className="gap-2">
<Layers className="h-4 w-4" />
<span className="hidden sm:inline"> </span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger value="condition" className="gap-2">
<Filter className="h-4 w-4" />
<span className="hidden sm:inline"> </span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger value="autofill" className="gap-2">
<FormInput className="h-4 w-4" />
<span className="hidden sm:inline"> </span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger value="exclusion" className="gap-2">
<Ban className="h-4 w-4" />
<span className="hidden sm:inline"> </span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger value="category-value" className="gap-2">
<Tags className="h-4 w-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</TabsTrigger>
</TabsList>
{/* 탭 컨텐츠 */}
<div className="mt-6">
<TabsContent value="relations">
<CascadingRelationsTab />
</TabsContent>
<TabsContent value="hierarchy">
<HierarchyTab />
</TabsContent>
<TabsContent value="condition">
<ConditionTab />
</TabsContent>
<TabsContent value="autofill">
<AutoFillTab />
</TabsContent>
<TabsContent value="exclusion">
<MutualExclusionTab />
</TabsContent>
<TabsContent value="category-value">
<CategoryValueCascadingTab />
</TabsContent>
</div>
</Tabs>
</div>
</div>
);
}
@@ -1,687 +0,0 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import {
Check,
ChevronsUpDown,
Plus,
Pencil,
Trash2,
Search,
RefreshCw,
ArrowRight,
X,
GripVertical,
} from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { cn } from "@/lib/utils";
import { cascadingAutoFillApi, AutoFillGroup, AutoFillMapping } from "@/lib/api/cascadingAutoFill";
import { tableManagementApi } from "@/lib/api/tableManagement";
interface TableColumn {
columnName: string;
columnLabel?: string;
dataType?: string;
}
export default function AutoFillTab() {
// 목록 상태
const [groups, setGroups] = useState<AutoFillGroup[]>([]);
const [loading, setLoading] = useState(true);
const [searchText, setSearchText] = useState("");
// 모달 상태
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [editingGroup, setEditingGroup] = useState<AutoFillGroup | null>(null);
const [deletingGroupCode, setDeletingGroupCode] = useState<string | null>(null);
// 테이블/컬럼 목록
const [tableList, setTableList] = useState<Array<{ tableName: string; displayName?: string }>>([]);
const [masterColumns, setMasterColumns] = useState<TableColumn[]>([]);
// 폼 데이터
const [formData, setFormData] = useState({
groupName: "",
description: "",
masterTable: "",
masterValueColumn: "",
masterLabelColumn: "",
isActive: "Y",
});
// 매핑 데이터
const [mappings, setMappings] = useState<AutoFillMapping[]>([]);
// 테이블 Combobox 상태
const [tableComboOpen, setTableComboOpen] = useState(false);
// 목록 로드
const loadGroups = useCallback(async () => {
setLoading(true);
try {
const response = await cascadingAutoFillApi.getGroups();
if (response.success && response.data) {
setGroups(response.data);
}
} catch (error) {
console.error("그룹 목록 로드 실패:", error);
showErrorToast("그룹 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
} finally {
setLoading(false);
}
}, []);
// 테이블 목록 로드
const loadTableList = useCallback(async () => {
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setTableList(response.data);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
}
}, []);
// 테이블 컬럼 로드
const loadColumns = useCallback(async (tableName: string) => {
if (!tableName) {
setMasterColumns([]);
return;
}
try {
const response = await tableManagementApi.getColumnList(tableName);
if (response.success && response.data?.columns) {
setMasterColumns(
response.data.columns.map((col: any) => ({
columnName: col.columnName || col.column_name,
columnLabel: col.columnLabel || col.column_label || col.columnName,
dataType: col.dataType || col.data_type,
})),
);
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
setMasterColumns([]);
}
}, []);
useEffect(() => {
loadGroups();
loadTableList();
}, [loadGroups, loadTableList]);
// 테이블 변경 시 컬럼 로드
useEffect(() => {
if (formData.masterTable) {
loadColumns(formData.masterTable);
}
}, [formData.masterTable, loadColumns]);
// 필터된 목록
const filteredGroups = groups.filter(
(g) =>
g.groupCode.toLowerCase().includes(searchText.toLowerCase()) ||
g.groupName.toLowerCase().includes(searchText.toLowerCase()) ||
g.masterTable?.toLowerCase().includes(searchText.toLowerCase()),
);
// 모달 열기 (생성)
const handleOpenCreate = () => {
setEditingGroup(null);
setFormData({
groupName: "",
description: "",
masterTable: "",
masterValueColumn: "",
masterLabelColumn: "",
isActive: "Y",
});
setMappings([]);
setMasterColumns([]);
setIsModalOpen(true);
};
// 모달 열기 (수정)
const handleOpenEdit = async (group: AutoFillGroup) => {
setEditingGroup(group);
// 상세 정보 로드
const detailResponse = await cascadingAutoFillApi.getGroupDetail(group.groupCode);
if (detailResponse.success && detailResponse.data) {
const detail = detailResponse.data;
// 컬럼 먼저 로드
if (detail.masterTable) {
await loadColumns(detail.masterTable);
}
setFormData({
groupCode: detail.groupCode,
groupName: detail.groupName,
description: detail.description || "",
masterTable: detail.masterTable,
masterValueColumn: detail.masterValueColumn,
masterLabelColumn: detail.masterLabelColumn || "",
isActive: detail.isActive || "Y",
});
// 매핑 데이터 변환 (snake_case → camelCase)
const convertedMappings = (detail.mappings || []).map((m: any) => ({
sourceColumn: m.source_column || m.sourceColumn,
targetField: m.target_field || m.targetField,
targetLabel: m.target_label || m.targetLabel || "",
isEditable: m.is_editable || m.isEditable || "Y",
isRequired: m.is_required || m.isRequired || "N",
defaultValue: m.default_value || m.defaultValue || "",
sortOrder: m.sort_order || m.sortOrder || 0,
}));
setMappings(convertedMappings);
}
setIsModalOpen(true);
};
// 삭제 확인
const handleDeleteConfirm = (groupCode: string) => {
setDeletingGroupCode(groupCode);
setIsDeleteDialogOpen(true);
};
// 삭제 실행
const handleDelete = async () => {
if (!deletingGroupCode) return;
try {
const response = await cascadingAutoFillApi.deleteGroup(deletingGroupCode);
if (response.success) {
toast.success("자동 입력 그룹이 삭제되었습니다.");
loadGroups();
} else {
toast.error(response.error || "삭제에 실패했습니다.");
}
} catch (error) {
toast.error("삭제 중 오류가 발생했습니다.");
} finally {
setIsDeleteDialogOpen(false);
setDeletingGroupCode(null);
}
};
// 저장
const handleSave = async () => {
// 유효성 검사
if (!formData.groupName || !formData.masterTable || !formData.masterValueColumn) {
toast.error("필수 항목을 모두 입력해주세요.");
return;
}
try {
const saveData = {
...formData,
mappings,
};
let response;
if (editingGroup) {
response = await cascadingAutoFillApi.updateGroup(editingGroup.groupCode!, saveData);
} else {
response = await cascadingAutoFillApi.createGroup(saveData);
}
if (response.success) {
toast.success(editingGroup ? "수정되었습니다." : "생성되었습니다.");
setIsModalOpen(false);
loadGroups();
} else {
toast.error(response.error || "저장에 실패했습니다.");
}
} catch (error) {
showErrorToast("자동입력 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
}
};
// 매핑 추가
const handleAddMapping = () => {
setMappings([
...mappings,
{
sourceColumn: "",
targetField: "",
targetLabel: "",
isEditable: "Y",
isRequired: "N",
defaultValue: "",
sortOrder: mappings.length + 1,
},
]);
};
// 매핑 삭제
const handleRemoveMapping = (index: number) => {
setMappings(mappings.filter((_, i) => i !== index));
};
// 매핑 수정
const handleMappingChange = (index: number, field: keyof AutoFillMapping, value: any) => {
const updated = [...mappings];
updated[index] = { ...updated[index], [field]: value };
setMappings(updated);
};
return (
<div className="space-y-6">
{/* 검색 및 액션 */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="relative flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="그룹 코드, 이름, 테이블명으로 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="pl-10"
/>
</div>
<Button variant="outline" onClick={loadGroups}>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 목록 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle> </CardTitle>
<CardDescription>
. ( {filteredGroups.length})
</CardDescription>
</div>
<Button onClick={handleOpenCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-6 w-6 animate-spin" />
<span className="ml-2"> ...</span>
</div>
) : filteredGroups.length === 0 ? (
<div className="text-muted-foreground py-8 text-center">
{searchText ? "검색 결과가 없습니다." : "등록된 자동 입력 그룹이 없습니다."}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredGroups.map((group) => (
<TableRow key={group.groupCode}>
<TableCell className="font-mono text-sm">{group.groupCode}</TableCell>
<TableCell className="font-medium">{group.groupName}</TableCell>
<TableCell className="text-muted-foreground">{group.masterTable}</TableCell>
<TableCell>
<Badge variant="secondary">{group.mappingCount || 0}</Badge>
</TableCell>
<TableCell>
<Badge variant={group.isActive === "Y" ? "default" : "outline"}>
{group.isActive === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(group)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(group.groupCode)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* 생성/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingGroup ? "자동 입력 그룹 수정" : "자동 입력 그룹 생성"}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* 기본 정보 */}
<div className="space-y-4">
<h3 className="text-sm font-semibold"> </h3>
<div className="space-y-2">
<Label> *</Label>
<Input
value={formData.groupName}
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
placeholder="예: 고객사 정보 자동입력"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="이 자동 입력 그룹에 대한 설명"
rows={2}
/>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={formData.isActive === "Y"}
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked ? "Y" : "N" })}
/>
<Label></Label>
</div>
</div>
<Separator />
{/* 마스터 테이블 설정 */}
<div className="space-y-4">
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-xs">
.
</p>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label> *</Label>
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboOpen}
className="h-10 w-full justify-between text-sm"
>
{formData.masterTable
? tableList.find((t) => t.tableName === formData.masterTable)?.displayName ||
formData.masterTable
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
<CommandEmpty className="text-sm"> .</CommandEmpty>
<CommandGroup>
{tableList.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName || ""}`}
onSelect={() => {
setFormData({
...formData,
masterTable: table.tableName,
masterValueColumn: "",
masterLabelColumn: "",
});
setTableComboOpen(false);
}}
className="text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.masterTable === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.displayName || table.tableName}</span>
{table.displayName && table.displayName !== table.tableName && (
<span className="text-muted-foreground text-xs">{table.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label> *</Label>
<Select
value={formData.masterValueColumn}
onValueChange={(value) => setFormData({ ...formData, masterValueColumn: value })}
disabled={!formData.masterTable}
>
<SelectTrigger>
<SelectValue placeholder="값 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{masterColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> </Label>
<Select
value={formData.masterLabelColumn}
onValueChange={(value) => setFormData({ ...formData, masterLabelColumn: value })}
disabled={!formData.masterTable}
>
<SelectTrigger>
<SelectValue placeholder="라벨 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{masterColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<Separator />
{/* 필드 매핑 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-xs">
.
</p>
</div>
<Button variant="outline" size="sm" onClick={handleAddMapping}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{mappings.length === 0 ? (
<div className="text-muted-foreground rounded-lg border border-dashed py-8 text-center text-sm">
. "매핑 추가" .
</div>
) : (
<div className="space-y-3">
{mappings.map((mapping, index) => (
<div key={index} className="bg-muted/30 flex items-center gap-3 rounded-lg border p-3">
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
{/* 소스 컬럼 */}
<div className="w-40">
<Select
value={mapping.sourceColumn}
onValueChange={(value) => handleMappingChange(index, "sourceColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="소스 컬럼" />
</SelectTrigger>
<SelectContent>
{masterColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<ArrowRight className="text-muted-foreground h-4 w-4" />
{/* 타겟 필드 */}
<div className="flex-1">
<Input
value={mapping.targetField}
onChange={(e) => handleMappingChange(index, "targetField", e.target.value)}
placeholder="타겟 필드명 (예: contact_name)"
className="h-8 text-xs"
/>
</div>
{/* 타겟 라벨 */}
<div className="w-28">
<Input
value={mapping.targetLabel || ""}
onChange={(e) => handleMappingChange(index, "targetLabel", e.target.value)}
placeholder="라벨"
className="h-8 text-xs"
/>
</div>
{/* 옵션 */}
<div className="flex items-center gap-2">
<div className="flex items-center space-x-1">
<Checkbox
id={`editable-${index}`}
checked={mapping.isEditable === "Y"}
onCheckedChange={(checked) => handleMappingChange(index, "isEditable", checked ? "Y" : "N")}
/>
<Label htmlFor={`editable-${index}`} className="text-xs">
</Label>
</div>
<div className="flex items-center space-x-1">
<Checkbox
id={`required-${index}`}
checked={mapping.isRequired === "Y"}
onCheckedChange={(checked) => handleMappingChange(index, "isRequired", checked ? "Y" : "N")}
/>
<Label htmlFor={`required-${index}`} className="text-xs">
</Label>
</div>
</div>
{/* 삭제 버튼 */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleRemoveMapping(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
</Button>
<Button onClick={handleSave}>{editingGroup ? "수정" : "생성"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -1,899 +0,0 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
import {
Check,
ChevronsUpDown,
Plus,
Pencil,
Trash2,
Link2,
RefreshCw,
Search,
ChevronRight,
Loader2,
} from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { cn } from "@/lib/utils";
import { cascadingRelationApi, CascadingRelation, CascadingRelationCreateInput } from "@/lib/api/cascadingRelation";
import { tableManagementApi } from "@/lib/api/tableManagement";
interface TableInfo {
tableName: string;
tableLabel?: string;
}
interface ColumnInfo {
columnName: string;
columnLabel?: string;
}
export default function CascadingRelationsTab() {
// 목록 상태
const [relations, setRelations] = useState<CascadingRelation[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
// 모달 상태
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingRelation, setEditingRelation] = useState<CascadingRelation | null>(null);
const [saving, setSaving] = useState(false);
// 테이블/컬럼 목록
const [tableList, setTableList] = useState<TableInfo[]>([]);
const [parentColumns, setParentColumns] = useState<ColumnInfo[]>([]);
const [childColumns, setChildColumns] = useState<ColumnInfo[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [loadingParentColumns, setLoadingParentColumns] = useState(false);
const [loadingChildColumns, setLoadingChildColumns] = useState(false);
// 폼 상태
const [formData, setFormData] = useState<CascadingRelationCreateInput>({
relationCode: "",
relationName: "",
description: "",
parentTable: "",
parentValueColumn: "",
parentLabelColumn: "",
childTable: "",
childFilterColumn: "",
childValueColumn: "",
childLabelColumn: "",
childOrderColumn: "",
childOrderDirection: "ASC",
emptyParentMessage: "상위 항목을 먼저 선택하세요",
noOptionsMessage: "선택 가능한 항목이 없습니다",
loadingMessage: "로딩 중...",
clearOnParentChange: true,
});
// 고급 설정 토글
const [showAdvanced, setShowAdvanced] = useState(false);
// 테이블 Combobox 상태
const [parentTableComboOpen, setParentTableComboOpen] = useState(false);
const [childTableComboOpen, setChildTableComboOpen] = useState(false);
// 목록 조회
const loadRelations = useCallback(async () => {
setLoading(true);
try {
const response = await cascadingRelationApi.getList("Y");
if (response.success && response.data) {
setRelations(response.data);
}
} catch (error) {
showErrorToast("연쇄 관계 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
} finally {
setLoading(false);
}
}, []);
// 테이블 목록 조회
const loadTableList = useCallback(async () => {
setLoadingTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setTableList(
response.data.map((t: any) => ({
tableName: t.tableName || t.name,
tableLabel: t.tableLabel || t.displayName || t.tableName || t.name,
})),
);
}
} catch (error) {
console.error("테이블 목록 조회 실패:", error);
} finally {
setLoadingTables(false);
}
}, []);
// 컬럼 목록 조회 (수정됨)
const loadColumns = useCallback(async (tableName: string, type: "parent" | "child") => {
if (!tableName) return;
if (type === "parent") {
setLoadingParentColumns(true);
setParentColumns([]);
} else {
setLoadingChildColumns(true);
setChildColumns([]);
}
try {
// getColumnList 사용 (getTableColumns가 아님)
const response = await tableManagementApi.getColumnList(tableName);
console.log(`컬럼 목록 조회 (${tableName}):`, response);
if (response.success && response.data) {
// 응답 구조: { data: { columns: [...] } }
const columnList = response.data.columns || response.data;
const columns = (Array.isArray(columnList) ? columnList : []).map((c: any) => ({
columnName: c.columnName || c.name,
columnLabel: c.columnLabel || c.label || c.columnName || c.name,
}));
if (type === "parent") {
setParentColumns(columns);
// 자동 추천: id, code, _id, _code로 끝나는 컬럼
autoSelectColumn(columns, "parentValueColumn", ["id", "code", "_id", "_code"]);
} else {
setChildColumns(columns);
// 자동 추천
autoSelectColumn(columns, "childValueColumn", ["id", "code", "_id", "_code"]);
autoSelectColumn(columns, "childLabelColumn", ["name", "label", "_name", "description"]);
}
}
} catch (error) {
console.error("컬럼 목록 조회 실패:", error);
toast.error(`${tableName} 테이블의 컬럼을 불러오지 못했습니다.`);
} finally {
if (type === "parent") {
setLoadingParentColumns(false);
} else {
setLoadingChildColumns(false);
}
}
}, []);
// 수정 모드용 컬럼 로드 (자동 선택 없음)
const loadColumnsForEdit = async (tableName: string, type: "parent" | "child") => {
if (!tableName) return;
if (type === "parent") {
setLoadingParentColumns(true);
} else {
setLoadingChildColumns(true);
}
try {
const response = await tableManagementApi.getColumnList(tableName);
if (response.success && response.data) {
const columnList = response.data.columns || response.data;
const columns = (Array.isArray(columnList) ? columnList : []).map((c: any) => ({
columnName: c.columnName || c.name,
columnLabel: c.columnLabel || c.label || c.columnName || c.name,
}));
if (type === "parent") {
setParentColumns(columns);
} else {
setChildColumns(columns);
}
}
} catch (error) {
console.error("컬럼 목록 조회 실패:", error);
} finally {
if (type === "parent") {
setLoadingParentColumns(false);
} else {
setLoadingChildColumns(false);
}
}
};
// 자동 컬럼 선택 (패턴 매칭)
const autoSelectColumn = (columns: ColumnInfo[], field: keyof CascadingRelationCreateInput, patterns: string[]) => {
// 이미 값이 있으면 스킵
if (formData[field]) return;
for (const pattern of patterns) {
const found = columns.find((c) => c.columnName.toLowerCase().endsWith(pattern.toLowerCase()));
if (found) {
setFormData((prev) => ({ ...prev, [field]: found.columnName }));
return;
}
}
};
useEffect(() => {
loadRelations();
loadTableList();
}, [loadRelations, loadTableList]);
// 부모 테이블 변경 시 컬럼 로드 (수정 모드가 아닐 때만)
useEffect(() => {
// 수정 모드에서는 handleOpenEdit에서 직접 로드하므로 스킵
if (editingRelation) return;
if (formData.parentTable) {
loadColumns(formData.parentTable, "parent");
} else {
setParentColumns([]);
}
}, [formData.parentTable, editingRelation]);
// 자식 테이블 변경 시 컬럼 로드 (수정 모드가 아닐 때만)
useEffect(() => {
// 수정 모드에서는 handleOpenEdit에서 직접 로드하므로 스킵
if (editingRelation) return;
if (formData.childTable) {
loadColumns(formData.childTable, "child");
} else {
setChildColumns([]);
}
}, [formData.childTable, editingRelation]);
// 관계 코드 자동 생성
const generateRelationCode = (parentTable: string, childTable: string) => {
if (!parentTable || !childTable) return "";
const parent = parentTable.replace(/_mng$|_info$|_master$/i, "").toUpperCase();
const child = childTable.replace(/_mng$|_info$|_master$/i, "").toUpperCase();
return `${parent}_${child}`;
};
// 관계명 자동 생성
const generateRelationName = (parentTable: string, childTable: string) => {
if (!parentTable || !childTable) return "";
const parentInfo = tableList.find((t) => t.tableName === parentTable);
const childInfo = tableList.find((t) => t.tableName === childTable);
const parentName = parentInfo?.tableLabel || parentTable;
const childName = childInfo?.tableLabel || childTable;
return `${parentName}-${childName}`;
};
// 모달 열기 (신규)
const handleOpenCreate = () => {
setEditingRelation(null);
setFormData({
relationCode: "",
relationName: "",
description: "",
parentTable: "",
parentValueColumn: "",
parentLabelColumn: "",
childTable: "",
childFilterColumn: "",
childValueColumn: "",
childLabelColumn: "",
childOrderColumn: "",
childOrderDirection: "ASC",
emptyParentMessage: "상위 항목을 먼저 선택하세요",
noOptionsMessage: "선택 가능한 항목이 없습니다",
loadingMessage: "로딩 중...",
clearOnParentChange: true,
});
setParentColumns([]);
setChildColumns([]);
setShowAdvanced(false);
setIsModalOpen(true);
};
// 모달 열기 (수정)
const handleOpenEdit = async (relation: CascadingRelation) => {
setEditingRelation(relation);
setShowAdvanced(false);
// 먼저 컬럼 목록을 로드 (모달 열기 전)
const loadPromises: Promise<void>[] = [];
if (relation.parent_table) {
loadPromises.push(loadColumnsForEdit(relation.parent_table, "parent"));
}
if (relation.child_table) {
loadPromises.push(loadColumnsForEdit(relation.child_table, "child"));
}
// 컬럼 로드 완료 대기
await Promise.all(loadPromises);
// 컬럼 로드 후 formData 설정 (이렇게 해야 Select에서 값이 제대로 표시됨)
setFormData({
relationCode: relation.relation_code,
relationName: relation.relation_name,
description: relation.description || "",
parentTable: relation.parent_table,
parentValueColumn: relation.parent_value_column,
parentLabelColumn: relation.parent_label_column || "",
childTable: relation.child_table,
childFilterColumn: relation.child_filter_column,
childValueColumn: relation.child_value_column,
childLabelColumn: relation.child_label_column,
childOrderColumn: relation.child_order_column || "",
childOrderDirection: relation.child_order_direction || "ASC",
emptyParentMessage: relation.empty_parent_message || "상위 항목을 먼저 선택하세요",
noOptionsMessage: relation.no_options_message || "선택 가능한 항목이 없습니다",
loadingMessage: relation.loading_message || "로딩 중...",
clearOnParentChange: relation.clear_on_parent_change === "Y",
});
setIsModalOpen(true);
};
// 부모 테이블 선택 시 자동 설정
const handleParentTableChange = async (value: string) => {
// 테이블이 변경되면 컬럼 초기화 (같은 테이블이면 유지)
const shouldClearColumns = value !== formData.parentTable;
setFormData((prev) => ({
...prev,
parentTable: value,
parentValueColumn: shouldClearColumns ? "" : prev.parentValueColumn,
parentLabelColumn: shouldClearColumns ? "" : prev.parentLabelColumn,
}));
// 수정 모드에서 테이블 변경 시 컬럼 로드
if (editingRelation && value) {
await loadColumnsForEdit(value, "parent");
}
};
// 자식 테이블 선택 시 자동 설정
const handleChildTableChange = async (value: string) => {
// 테이블이 변경되면 컬럼 초기화 (같은 테이블이면 유지)
const shouldClearColumns = value !== formData.childTable;
const newFormData = {
...formData,
childTable: value,
childFilterColumn: shouldClearColumns ? "" : formData.childFilterColumn,
childValueColumn: shouldClearColumns ? "" : formData.childValueColumn,
childLabelColumn: shouldClearColumns ? "" : formData.childLabelColumn,
childOrderColumn: shouldClearColumns ? "" : formData.childOrderColumn,
};
// 관계 코드/이름 자동 생성 (신규 모드에서만)
if (!editingRelation) {
newFormData.relationCode = generateRelationCode(formData.parentTable, value);
newFormData.relationName = generateRelationName(formData.parentTable, value);
}
setFormData(newFormData);
// 수정 모드에서 테이블 변경 시 컬럼 로드
if (editingRelation && value) {
await loadColumnsForEdit(value, "child");
}
};
// 저장
const handleSave = async () => {
// 필수 필드 검증
if (!formData.parentTable || !formData.parentValueColumn) {
toast.error("부모 테이블과 값 컬럼을 선택해주세요.");
return;
}
if (
!formData.childTable ||
!formData.childFilterColumn ||
!formData.childValueColumn ||
!formData.childLabelColumn
) {
toast.error("자식 테이블 설정을 완료해주세요.");
return;
}
// 관계 코드/이름 자동 생성 (비어있으면)
const finalData = { ...formData };
if (!finalData.relationCode) {
finalData.relationCode = generateRelationCode(formData.parentTable, formData.childTable);
}
if (!finalData.relationName) {
finalData.relationName = generateRelationName(formData.parentTable, formData.childTable);
}
setSaving(true);
try {
let response;
if (editingRelation) {
response = await cascadingRelationApi.update(editingRelation.relation_id, finalData);
} else {
response = await cascadingRelationApi.create(finalData);
}
if (response.success) {
toast.success(editingRelation ? "연쇄 관계가 수정되었습니다." : "연쇄 관계가 생성되었습니다.");
setIsModalOpen(false);
loadRelations();
} else {
toast.error(response.message || "저장에 실패했습니다.");
}
} catch (error) {
showErrorToast("연쇄 관계 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} finally {
setSaving(false);
}
};
// 삭제
const handleDelete = async (relation: CascadingRelation) => {
if (!confirm(`"${relation.relation_name}" 관계를 삭제하시겠습니까?`)) {
return;
}
try {
const response = await cascadingRelationApi.delete(relation.relation_id);
if (response.success) {
toast.success("연쇄 관계가 삭제되었습니다.");
loadRelations();
} else {
toast.error(response.message || "삭제에 실패했습니다.");
}
} catch (error) {
showErrorToast("연쇄 관계 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
}
};
// 필터링된 목록
const filteredRelations = relations.filter(
(r) =>
r.relation_code.toLowerCase().includes(searchTerm.toLowerCase()) ||
r.relation_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
r.parent_table.toLowerCase().includes(searchTerm.toLowerCase()) ||
r.child_table.toLowerCase().includes(searchTerm.toLowerCase()),
);
// 컬럼 셀렉트 렌더링 헬퍼
const renderColumnSelect = (
value: string,
onChange: (v: string) => void,
columns: ColumnInfo[],
loading: boolean,
placeholder: string,
disabled?: boolean,
) => (
<Select value={value} onValueChange={onChange} disabled={disabled || loading}>
<SelectTrigger className="h-9">
{loading ? (
<div className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="text-xs"> ...</span>
</div>
) : (
<SelectValue placeholder={placeholder} />
)}
</SelectTrigger>
<SelectContent>
{columns.length === 0 ? (
<div className="text-muted-foreground p-2 text-center text-xs"> </div>
) : (
columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
<div className="flex items-center gap-2">
<span>{col.columnLabel}</span>
{col.columnLabel !== col.columnName && (
<span className="text-muted-foreground text-xs">({col.columnName})</span>
)}
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
);
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Link2 className="h-5 w-5" />
2
</CardTitle>
<CardDescription>- . (: 창고 )</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={loadRelations} disabled={loading}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
<Button onClick={handleOpenCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{/* 검색 */}
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="관계 코드, 관계명, 테이블명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
{/* 테이블 */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground py-8 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
</TableCell>
</TableRow>
) : filteredRelations.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground py-8 text-center">
{searchTerm ? "검색 결과가 없습니다." : "등록된 연쇄 관계가 없습니다."}
</TableCell>
</TableRow>
) : (
filteredRelations.map((relation) => (
<TableRow key={relation.relation_id}>
<TableCell>
<div>
<div className="font-medium">{relation.relation_name}</div>
<div className="text-muted-foreground font-mono text-xs">{relation.relation_code}</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2 text-sm">
<span className="rounded bg-primary/10 px-2 py-0.5 text-primary">{relation.parent_table}</span>
<ChevronRight className="text-muted-foreground h-4 w-4" />
<span className="rounded bg-emerald-100 px-2 py-0.5 text-emerald-700">
{relation.child_table}
</span>
</div>
</TableCell>
<TableCell>
<Badge variant={relation.is_active === "Y" ? "default" : "secondary"}>
{relation.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(relation)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDelete(relation)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* 생성/수정 모달 - 간소화된 UI */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editingRelation ? "연쇄 관계 수정" : "새 연쇄 관계"}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Step 1: 부모 테이블 */}
<div className="rounded-lg border p-4">
<h4 className="mb-3 text-sm font-semibold text-primary">1. ( )</h4>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Popover open={parentTableComboOpen} onOpenChange={setParentTableComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={parentTableComboOpen}
className="h-9 w-full justify-between text-sm"
>
{loadingTables
? "로딩 중..."
: formData.parentTable
? tableList.find((t) => t.tableName === formData.parentTable)?.tableLabel ||
formData.parentTable
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
<CommandEmpty className="text-sm"> .</CommandEmpty>
<CommandGroup>
{tableList.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.tableLabel || ""}`}
onSelect={() => {
handleParentTableChange(table.tableName);
setParentTableComboOpen(false);
}}
className="text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.parentTable === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.tableLabel || table.tableName}</span>
{table.tableLabel && table.tableLabel !== table.tableName && (
<span className="text-muted-foreground text-xs">{table.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> ( )</Label>
{renderColumnSelect(
formData.parentValueColumn,
(v) => setFormData({ ...formData, parentValueColumn: v }),
parentColumns,
loadingParentColumns,
"컬럼 선택",
!formData.parentTable,
)}
</div>
</div>
</div>
{/* Step 2: 자식 테이블 */}
<div className="rounded-lg border p-4">
<h4 className="mb-3 text-sm font-semibold text-emerald-600">2. ( )</h4>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Popover open={childTableComboOpen} onOpenChange={setChildTableComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={childTableComboOpen}
className="h-9 w-full justify-between text-sm"
disabled={!formData.parentTable}
>
{formData.childTable
? tableList.find((t) => t.tableName === formData.childTable)?.tableLabel ||
formData.childTable
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
<CommandEmpty className="text-sm"> .</CommandEmpty>
<CommandGroup>
{tableList.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.tableLabel || ""}`}
onSelect={() => {
handleChildTableChange(table.tableName);
setChildTableComboOpen(false);
}}
className="text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.childTable === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.tableLabel || table.tableName}</span>
{table.tableLabel && table.tableLabel !== table.tableName && (
<span className="text-muted-foreground text-xs">{table.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> ( )</Label>
{renderColumnSelect(
formData.childFilterColumn,
(v) => setFormData({ ...formData, childFilterColumn: v }),
childColumns,
loadingChildColumns,
"컬럼 선택",
!formData.childTable,
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs"> ( )</Label>
{renderColumnSelect(
formData.childValueColumn,
(v) => setFormData({ ...formData, childValueColumn: v }),
childColumns,
loadingChildColumns,
"컬럼 선택",
!formData.childTable,
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs"> ( )</Label>
{renderColumnSelect(
formData.childLabelColumn,
(v) => setFormData({ ...formData, childLabelColumn: v }),
childColumns,
loadingChildColumns,
"컬럼 선택",
!formData.childTable,
)}
</div>
</div>
</div>
{/* 관계 정보 (자동 생성) */}
{formData.parentTable && formData.childTable && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Input
value={formData.relationCode || generateRelationCode(formData.parentTable, formData.childTable)}
onChange={(e) => setFormData({ ...formData, relationCode: e.target.value.toUpperCase() })}
placeholder="자동 생성"
className="h-8 text-xs"
disabled={!!editingRelation}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
value={formData.relationName || generateRelationName(formData.parentTable, formData.childTable)}
onChange={(e) => setFormData({ ...formData, relationName: e.target.value })}
placeholder="자동 생성"
className="h-8 text-xs"
/>
</div>
</div>
</div>
)}
{/* 고급 설정 토글 */}
<div className="border-t pt-3">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-muted-foreground hover:text-foreground flex w-full items-center justify-between text-xs"
>
<span> </span>
<ChevronRight className={`h-4 w-4 transition-transform ${showAdvanced ? "rotate-90" : ""}`} />
</button>
{showAdvanced && (
<div className="mt-3 space-y-3">
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="이 관계에 대한 설명..."
rows={2}
className="text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Input
value={formData.emptyParentMessage}
onChange={(e) => setFormData({ ...formData, emptyParentMessage: e.target.value })}
className="h-8 text-xs"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Input
value={formData.noOptionsMessage}
onChange={(e) => setFormData({ ...formData, noOptionsMessage: e.target.value })}
className="h-8 text-xs"
/>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-xs"> </Label>
<p className="text-muted-foreground text-xs"> </p>
</div>
<Switch
checked={formData.clearOnParentChange}
onCheckedChange={(checked) => setFormData({ ...formData, clearOnParentChange: checked })}
/>
</div>
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : editingRelation ? (
"수정"
) : (
"생성"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1,502 +0,0 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Filter, Plus, RefreshCw, Search, Pencil, Trash2 } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import {
cascadingConditionApi,
CascadingCondition,
CONDITION_OPERATORS,
} from "@/lib/api/cascadingCondition";
import { cascadingRelationApi } from "@/lib/api/cascadingRelation";
export default function ConditionTab() {
// 목록 상태
const [conditions, setConditions] = useState<CascadingCondition[]>([]);
const [relations, setRelations] = useState<Array<{ relation_code: string; relation_name: string }>>([]);
const [loading, setLoading] = useState(true);
const [searchText, setSearchText] = useState("");
// 모달 상태
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [editingCondition, setEditingCondition] = useState<CascadingCondition | null>(null);
const [deletingConditionId, setDeletingConditionId] = useState<number | null>(null);
// 폼 데이터
const [formData, setFormData] = useState<Omit<CascadingCondition, "conditionId">>({
relationType: "RELATION",
relationCode: "",
conditionName: "",
conditionField: "",
conditionOperator: "EQ",
conditionValue: "",
filterColumn: "",
filterValues: "",
priority: 0,
});
// 목록 로드
const loadConditions = useCallback(async () => {
setLoading(true);
try {
const response = await cascadingConditionApi.getList();
if (response.success && response.data) {
setConditions(response.data);
}
} catch (error) {
console.error("조건 목록 로드 실패:", error);
toast.error("조건 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, []);
// 연쇄 관계 목록 로드
const loadRelations = useCallback(async () => {
try {
const response = await cascadingRelationApi.getList("Y");
if (response.success && response.data) {
setRelations(response.data);
}
} catch (error) {
console.error("연쇄 관계 목록 로드 실패:", error);
}
}, []);
useEffect(() => {
loadConditions();
loadRelations();
}, [loadConditions, loadRelations]);
// 필터된 목록
const filteredConditions = conditions.filter(
(c) =>
c.conditionName?.toLowerCase().includes(searchText.toLowerCase()) ||
c.relationCode?.toLowerCase().includes(searchText.toLowerCase()) ||
c.conditionField?.toLowerCase().includes(searchText.toLowerCase())
);
// 모달 열기 (생성)
const handleOpenCreate = () => {
setEditingCondition(null);
setFormData({
relationType: "RELATION",
relationCode: "",
conditionName: "",
conditionField: "",
conditionOperator: "EQ",
conditionValue: "",
filterColumn: "",
filterValues: "",
priority: 0,
});
setIsModalOpen(true);
};
// 모달 열기 (수정)
const handleOpenEdit = (condition: CascadingCondition) => {
setEditingCondition(condition);
setFormData({
relationType: condition.relationType || "RELATION",
relationCode: condition.relationCode,
conditionName: condition.conditionName,
conditionField: condition.conditionField,
conditionOperator: condition.conditionOperator,
conditionValue: condition.conditionValue,
filterColumn: condition.filterColumn,
filterValues: condition.filterValues,
priority: condition.priority || 0,
});
setIsModalOpen(true);
};
// 삭제 확인
const handleDeleteConfirm = (conditionId: number) => {
setDeletingConditionId(conditionId);
setIsDeleteDialogOpen(true);
};
// 삭제 실행
const handleDelete = async () => {
if (!deletingConditionId) return;
try {
const response = await cascadingConditionApi.delete(deletingConditionId);
if (response.success) {
toast.success("조건부 규칙이 삭제되었습니다.");
loadConditions();
} else {
toast.error(response.error || "삭제에 실패했습니다.");
}
} catch (error) {
showErrorToast("조건 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} finally {
setIsDeleteDialogOpen(false);
setDeletingConditionId(null);
}
};
// 저장
const handleSave = async () => {
// 유효성 검사
if (!formData.relationCode || !formData.conditionName || !formData.conditionField) {
toast.error("필수 항목을 모두 입력해주세요.");
return;
}
if (!formData.conditionValue || !formData.filterColumn || !formData.filterValues) {
toast.error("조건 값, 필터 컬럼, 필터 값을 모두 입력해주세요.");
return;
}
try {
let response;
if (editingCondition) {
response = await cascadingConditionApi.update(editingCondition.conditionId!, formData);
} else {
response = await cascadingConditionApi.create(formData);
}
if (response.success) {
toast.success(editingCondition ? "수정되었습니다." : "생성되었습니다.");
setIsModalOpen(false);
loadConditions();
} else {
toast.error(response.error || "저장에 실패했습니다.");
}
} catch (error) {
showErrorToast("조건 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
}
};
// 연산자 라벨 찾기
const getOperatorLabel = (operator: string) => {
return CONDITION_OPERATORS.find((op) => op.value === operator)?.label || operator;
};
return (
<div className="space-y-6">
{/* 검색 */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="relative flex-1">
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="조건명, 관계 코드, 조건 필드로 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="pl-10"
/>
</div>
<Button variant="outline" onClick={loadConditions}>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 목록 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Filter className="h-5 w-5" />
</CardTitle>
<CardDescription>
. ( {filteredConditions.length})
</CardDescription>
</div>
<Button onClick={handleOpenCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-6 w-6 animate-spin" />
<span className="ml-2"> ...</span>
</div>
) : filteredConditions.length === 0 ? (
<div className="text-muted-foreground space-y-4 py-8 text-center">
<div className="text-sm">
{searchText ? "검색 결과가 없습니다." : "등록된 조건부 필터 규칙이 없습니다."}
</div>
<div className="mx-auto max-w-md space-y-3 text-left">
<div className="rounded-lg border p-4">
<div className="text-foreground mb-2 text-sm font-medium">예시: 상태별 </div>
<div className="text-muted-foreground text-xs">
"상태" "활성" "품목"
</div>
</div>
<div className="rounded-lg border p-4">
<div className="text-foreground mb-2 text-sm font-medium">예시: 유형별 </div>
<div className="text-muted-foreground text-xs">
"유형" "입고" "창고"
</div>
</div>
</div>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredConditions.map((condition) => (
<TableRow key={condition.conditionId}>
<TableCell className="font-mono text-sm">{condition.relationCode}</TableCell>
<TableCell className="font-medium">{condition.conditionName}</TableCell>
<TableCell>
<div className="text-sm">
<span className="text-muted-foreground">{condition.conditionField}</span>
<span className="mx-1 text-primary">{getOperatorLabel(condition.conditionOperator)}</span>
<span className="font-medium">{condition.conditionValue}</span>
</div>
</TableCell>
<TableCell>
<div className="text-sm">
<span className="text-muted-foreground">{condition.filterColumn}</span>
<span className="mx-1">=</span>
<span className="font-mono text-xs">{condition.filterValues}</span>
</div>
</TableCell>
<TableCell>
<Badge variant={condition.isActive === "Y" ? "default" : "secondary"}>
{condition.isActive === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(condition)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteConfirm(condition.conditionId!)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* 생성/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editingCondition ? "조건부 규칙 수정" : "조건부 규칙 생성"}</DialogTitle>
<DialogDescription>
.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 연쇄 관계 선택 */}
<div className="space-y-2">
<Label> *</Label>
<Select
value={formData.relationCode}
onValueChange={(value) => setFormData({ ...formData, relationCode: value })}
>
<SelectTrigger>
<SelectValue placeholder="연쇄 관계 선택" />
</SelectTrigger>
<SelectContent>
{relations.map((rel) => (
<SelectItem key={rel.relation_code} value={rel.relation_code}>
{rel.relation_name} ({rel.relation_code})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 조건명 */}
<div className="space-y-2">
<Label> *</Label>
<Input
value={formData.conditionName}
onChange={(e) => setFormData({ ...formData, conditionName: e.target.value })}
placeholder="예: 활성 품목만 표시"
/>
</div>
{/* 조건 설정 */}
<div className="rounded-lg border p-4">
<h4 className="mb-3 text-sm font-semibold"> </h4>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label className="text-xs"> *</Label>
<Input
value={formData.conditionField}
onChange={(e) => setFormData({ ...formData, conditionField: e.target.value })}
placeholder="예: status"
className="h-9 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> *</Label>
<Select
value={formData.conditionOperator}
onValueChange={(value) => setFormData({ ...formData, conditionOperator: value })}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONDITION_OPERATORS.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> *</Label>
<Input
value={formData.conditionValue}
onChange={(e) => setFormData({ ...formData, conditionValue: e.target.value })}
placeholder="예: active"
className="h-9 text-sm"
/>
</div>
</div>
<p className="text-muted-foreground mt-2 text-xs">
"{formData.conditionField || ""}" "{formData.conditionValue || ""}"
</p>
</div>
{/* 필터 설정 */}
<div className="rounded-lg border p-4">
<h4 className="mb-3 text-sm font-semibold"> </h4>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs"> *</Label>
<Input
value={formData.filterColumn}
onChange={(e) => setFormData({ ...formData, filterColumn: e.target.value })}
placeholder="예: status"
className="h-9 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> *</Label>
<Input
value={formData.filterValues}
onChange={(e) => setFormData({ ...formData, filterValues: e.target.value })}
placeholder="예: active,pending"
className="h-9 text-sm"
/>
</div>
</div>
<p className="text-muted-foreground mt-2 text-xs">
"{formData.filterColumn || ""}" "{formData.filterValues || ""}"
</p>
</div>
{/* 우선순위 */}
<div className="space-y-2">
<Label></Label>
<Input
type="number"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: Number(e.target.value) })}
placeholder="높을수록 먼저 적용"
className="w-32"
/>
<p className="text-muted-foreground text-xs">
.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
</Button>
<Button onClick={handleSave}>{editingCondition ? "수정" : "생성"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -1,627 +0,0 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Plus, Pencil, Trash2, Database, RefreshCw, Layers } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import {
hierarchyColumnApi,
HierarchyColumnGroup,
CreateHierarchyGroupRequest,
} from "@/lib/api/hierarchyColumn";
import { commonCodeApi } from "@/lib/api/commonCode";
import apiClient from "@/lib/api/client";
interface TableInfo {
tableName: string;
displayName?: string;
}
interface ColumnInfo {
columnName: string;
displayName?: string;
dataType?: string;
}
interface CategoryInfo {
categoryCode: string;
categoryName: string;
}
export default function HierarchyColumnTab() {
// 상태
const [groups, setGroups] = useState<HierarchyColumnGroup[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [selectedGroup, setSelectedGroup] = useState<HierarchyColumnGroup | null>(null);
const [isEditing, setIsEditing] = useState(false);
// 폼 상태
const [formData, setFormData] = useState({
groupCode: "",
groupName: "",
description: "",
codeCategory: "",
tableName: "",
maxDepth: 3,
mappings: [
{ depth: 1, levelLabel: "대분류", columnName: "", placeholder: "대분류 선택", isRequired: true },
{ depth: 2, levelLabel: "중분류", columnName: "", placeholder: "중분류 선택", isRequired: false },
{ depth: 3, levelLabel: "소분류", columnName: "", placeholder: "소분류 선택", isRequired: false },
],
});
// 참조 데이터
const [tables, setTables] = useState<TableInfo[]>([]);
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [categories, setCategories] = useState<CategoryInfo[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [loadingColumns, setLoadingColumns] = useState(false);
const [loadingCategories, setLoadingCategories] = useState(false);
// 그룹 목록 로드
const loadGroups = useCallback(async () => {
setLoading(true);
try {
const response = await hierarchyColumnApi.getAll();
if (response.success && response.data) {
setGroups(response.data);
} else {
toast.error(response.error || "계층구조 그룹 로드 실패");
}
} catch (error) {
console.error("계층구조 그룹 로드 에러:", error);
toast.error("계층구조 그룹을 로드하는 중 오류가 발생했습니다.");
} finally {
setLoading(false);
}
}, []);
// 테이블 목록 로드
const loadTables = useCallback(async () => {
setLoadingTables(true);
try {
const response = await apiClient.get("/table-management/tables");
if (response.data?.success && response.data?.data) {
setTables(response.data.data);
}
} catch (error) {
console.error("테이블 로드 에러:", error);
} finally {
setLoadingTables(false);
}
}, []);
// 카테고리 목록 로드
const loadCategories = useCallback(async () => {
setLoadingCategories(true);
try {
const response = await commonCodeApi.categories.getList();
if (response.success && response.data) {
setCategories(
response.data.map((cat: any) => ({
categoryCode: cat.categoryCode || cat.category_code,
categoryName: cat.categoryName || cat.category_name,
}))
);
}
} catch (error) {
console.error("카테고리 로드 에러:", error);
} finally {
setLoadingCategories(false);
}
}, []);
// 테이블 선택 시 컬럼 로드
const loadColumns = useCallback(async (tableName: string) => {
if (!tableName) {
setColumns([]);
return;
}
setLoadingColumns(true);
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
if (response.data?.success && response.data?.data) {
setColumns(response.data.data);
}
} catch (error) {
console.error("컬럼 로드 에러:", error);
} finally {
setLoadingColumns(false);
}
}, []);
// 초기 로드
useEffect(() => {
loadGroups();
loadTables();
loadCategories();
}, [loadGroups, loadTables, loadCategories]);
// 테이블 선택 변경 시 컬럼 로드
useEffect(() => {
if (formData.tableName) {
loadColumns(formData.tableName);
}
}, [formData.tableName, loadColumns]);
// 폼 초기화
const resetForm = () => {
setFormData({
groupCode: "",
groupName: "",
description: "",
codeCategory: "",
tableName: "",
maxDepth: 3,
mappings: [
{ depth: 1, levelLabel: "대분류", columnName: "", placeholder: "대분류 선택", isRequired: true },
{ depth: 2, levelLabel: "중분류", columnName: "", placeholder: "중분류 선택", isRequired: false },
{ depth: 3, levelLabel: "소분류", columnName: "", placeholder: "소분류 선택", isRequired: false },
],
});
setSelectedGroup(null);
setIsEditing(false);
};
// 모달 열기 (신규)
const openCreateModal = () => {
resetForm();
setModalOpen(true);
};
// 모달 열기 (수정)
const openEditModal = (group: HierarchyColumnGroup) => {
setSelectedGroup(group);
setIsEditing(true);
// 매핑 데이터 변환
const mappings = [1, 2, 3].map((depth) => {
const existing = group.mappings?.find((m) => m.depth === depth);
return {
depth,
levelLabel: existing?.level_label || (depth === 1 ? "대분류" : depth === 2 ? "중분류" : "소분류"),
columnName: existing?.column_name || "",
placeholder: existing?.placeholder || `${depth === 1 ? "대분류" : depth === 2 ? "중분류" : "소분류"} 선택`,
isRequired: existing?.is_required === "Y",
};
});
setFormData({
groupCode: group.group_code,
groupName: group.group_name,
description: group.description || "",
codeCategory: group.code_category,
tableName: group.table_name,
maxDepth: group.max_depth,
mappings,
});
// 컬럼 로드
loadColumns(group.table_name);
setModalOpen(true);
};
// 삭제 확인 열기
const openDeleteDialog = (group: HierarchyColumnGroup) => {
setSelectedGroup(group);
setDeleteDialogOpen(true);
};
// 저장
const handleSave = async () => {
// 필수 필드 검증
if (!formData.groupCode || !formData.groupName || !formData.codeCategory || !formData.tableName) {
toast.error("필수 필드를 모두 입력해주세요.");
return;
}
// 최소 1개 컬럼 매핑 검증
const validMappings = formData.mappings
.filter((m) => m.depth <= formData.maxDepth && m.columnName)
.map((m) => ({
depth: m.depth,
levelLabel: m.levelLabel,
columnName: m.columnName,
placeholder: m.placeholder,
isRequired: m.isRequired,
}));
if (validMappings.length === 0) {
toast.error("최소 하나의 컬럼 매핑이 필요합니다.");
return;
}
try {
if (isEditing && selectedGroup) {
// 수정
const response = await hierarchyColumnApi.update(selectedGroup.group_id, {
groupName: formData.groupName,
description: formData.description,
maxDepth: formData.maxDepth,
mappings: validMappings,
});
if (response.success) {
toast.success("계층구조 그룹이 수정되었습니다.");
setModalOpen(false);
loadGroups();
} else {
toast.error(response.error || "수정 실패");
}
} else {
// 생성
const request: CreateHierarchyGroupRequest = {
groupCode: formData.groupCode,
groupName: formData.groupName,
description: formData.description,
codeCategory: formData.codeCategory,
tableName: formData.tableName,
maxDepth: formData.maxDepth,
mappings: validMappings,
};
const response = await hierarchyColumnApi.create(request);
if (response.success) {
toast.success("계층구조 그룹이 생성되었습니다.");
setModalOpen(false);
loadGroups();
} else {
toast.error(response.error || "생성 실패");
}
}
} catch (error) {
console.error("저장 에러:", error);
showErrorToast("계층구조 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
}
};
// 삭제
const handleDelete = async () => {
if (!selectedGroup) return;
try {
const response = await hierarchyColumnApi.delete(selectedGroup.group_id);
if (response.success) {
toast.success("계층구조 그룹이 삭제되었습니다.");
setDeleteDialogOpen(false);
loadGroups();
} else {
toast.error(response.error || "삭제 실패");
}
} catch (error) {
console.error("삭제 에러:", error);
showErrorToast("계층구조 설정 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
}
};
// 매핑 컬럼 변경
const handleMappingChange = (depth: number, field: string, value: any) => {
setFormData((prev) => ({
...prev,
mappings: prev.mappings.map((m) =>
m.depth === depth ? { ...m, [field]: value } : m
),
}));
};
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold"> </h2>
<p className="text-sm text-muted-foreground">
// .
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={loadGroups} disabled={loading}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
<Button size="sm" onClick={openCreateModal}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
{/* 그룹 목록 */}
{loading ? (
<div className="flex items-center justify-center py-12">
<LoadingSpinner />
<span className="ml-2 text-muted-foreground"> ...</span>
</div>
) : groups.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Layers className="h-12 w-12 text-muted-foreground" />
<p className="mt-4 text-muted-foreground"> .</p>
<Button className="mt-4" onClick={openCreateModal}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{groups.map((group) => (
<Card key={group.group_id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-base">{group.group_name}</CardTitle>
<CardDescription className="text-xs">{group.group_code}</CardDescription>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEditModal(group)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => openDeleteDialog(group)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2 text-sm">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{group.table_name}</span>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{group.code_category}</Badge>
<Badge variant="secondary">{group.max_depth}</Badge>
</div>
{group.mappings && group.mappings.length > 0 && (
<div className="space-y-1">
{group.mappings.map((mapping) => (
<div key={mapping.depth} className="flex items-center gap-2 text-xs">
<Badge variant="outline" className="w-14 justify-center">
{mapping.level_label}
</Badge>
<span className="font-mono text-muted-foreground">{mapping.column_name}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
{/* 생성/수정 모달 */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-[600px]">
<DialogHeader>
<DialogTitle>{isEditing ? "계층구조 그룹 수정" : "계층구조 그룹 생성"}</DialogTitle>
<DialogDescription>
.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 기본 정보 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label> *</Label>
<Input
value={formData.groupCode}
onChange={(e) => setFormData({ ...formData, groupCode: e.target.value.toUpperCase() })}
placeholder="예: ITEM_CAT_HIERARCHY"
disabled={isEditing}
/>
</div>
<div className="space-y-2">
<Label> *</Label>
<Input
value={formData.groupName}
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
placeholder="예: 품목분류 계층"
/>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="계층구조에 대한 설명"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label> *</Label>
<Select
value={formData.codeCategory}
onValueChange={(value) => setFormData({ ...formData, codeCategory: value })}
disabled={isEditing}
>
<SelectTrigger>
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{loadingCategories ? (
<SelectItem value="_loading" disabled> ...</SelectItem>
) : (
categories.map((cat) => (
<SelectItem key={cat.categoryCode} value={cat.categoryCode}>
{cat.categoryName} ({cat.categoryCode})
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> *</Label>
<Select
value={formData.tableName}
onValueChange={(value) => setFormData({ ...formData, tableName: value })}
disabled={isEditing}
>
<SelectTrigger>
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{loadingTables ? (
<SelectItem value="_loading" disabled> ...</SelectItem>
) : (
tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label> </Label>
<Select
value={String(formData.maxDepth)}
onValueChange={(value) => setFormData({ ...formData, maxDepth: Number(value) })}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 ()</SelectItem>
<SelectItem value="2">2 (/)</SelectItem>
<SelectItem value="3">3 (//)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 컬럼 매핑 */}
<div className="space-y-3 border-t pt-4">
<Label className="text-base font-medium"> </Label>
<p className="text-xs text-muted-foreground">
.
</p>
{formData.mappings
.filter((m) => m.depth <= formData.maxDepth)
.map((mapping) => (
<div key={mapping.depth} className="grid grid-cols-4 gap-2 items-center">
<div className="flex items-center gap-2">
<Badge variant={mapping.depth === 1 ? "default" : "outline"}>
{mapping.depth}
</Badge>
<Input
value={mapping.levelLabel}
onChange={(e) => handleMappingChange(mapping.depth, "levelLabel", e.target.value)}
className="h-8 text-xs"
placeholder="라벨"
/>
</div>
<Select
value={mapping.columnName || "_none"}
onValueChange={(value) => handleMappingChange(mapping.depth, "columnName", value === "_none" ? "" : value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none"> </SelectItem>
{loadingColumns ? (
<SelectItem value="_loading" disabled> ...</SelectItem>
) : (
columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))
)}
</SelectContent>
</Select>
<Input
value={mapping.placeholder}
onChange={(e) => handleMappingChange(mapping.depth, "placeholder", e.target.value)}
className="h-8 text-xs"
placeholder="플레이스홀더"
/>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={mapping.isRequired}
onChange={(e) => handleMappingChange(mapping.depth, "isRequired", e.target.checked)}
className="h-4 w-4"
/>
<span className="text-xs text-muted-foreground"></span>
</div>
</div>
))}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setModalOpen(false)}>
</Button>
<Button onClick={handleSave}>
{isEditing ? "수정" : "생성"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 다이얼로그 */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
"{selectedGroup?.group_name}" ?
<br />
.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
</Button>
<Button variant="destructive" onClick={handleDelete}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1,847 +0,0 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Check,
ChevronsUpDown,
Layers,
Plus,
RefreshCw,
Search,
Pencil,
Trash2,
ChevronRight,
ChevronDown,
} from "lucide-react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { hierarchyApi, HierarchyGroup, HierarchyLevel, HIERARCHY_TYPES } from "@/lib/api/cascadingHierarchy";
import { tableManagementApi } from "@/lib/api/tableManagement";
export default function HierarchyTab() {
// 목록 상태
const [groups, setGroups] = useState<HierarchyGroup[]>([]);
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [loading, setLoading] = useState(true);
const [searchText, setSearchText] = useState("");
// 확장된 그룹 (레벨 표시)
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [groupLevels, setGroupLevels] = useState<Record<string, HierarchyLevel[]>>({});
// 모달 상태
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [editingGroup, setEditingGroup] = useState<HierarchyGroup | null>(null);
const [deletingGroupCode, setDeletingGroupCode] = useState<string | null>(null);
// 레벨 모달
const [isLevelModalOpen, setIsLevelModalOpen] = useState(false);
const [editingLevel, setEditingLevel] = useState<HierarchyLevel | null>(null);
const [currentGroupCode, setCurrentGroupCode] = useState<string>("");
const [levelColumns, setLevelColumns] = useState<Array<{ columnName: string; displayName: string }>>([]);
// 폼 데이터
const [formData, setFormData] = useState<Partial<HierarchyGroup>>({
groupName: "",
description: "",
hierarchyType: "MULTI_TABLE",
maxLevels: undefined,
isFixedLevels: "Y",
emptyMessage: "선택해주세요",
noOptionsMessage: "옵션이 없습니다",
loadingMessage: "로딩 중...",
});
// 레벨 폼 데이터
const [levelFormData, setLevelFormData] = useState<Partial<HierarchyLevel>>({
levelOrder: 1,
levelName: "",
levelCode: "",
tableName: "",
valueColumn: "",
labelColumn: "",
parentKeyColumn: "",
orderColumn: "",
orderDirection: "ASC",
placeholder: "",
isRequired: "Y",
isSearchable: "N",
});
// 테이블 Combobox 상태
const [tableComboOpen, setTableComboOpen] = useState(false);
// snake_case를 camelCase로 변환하는 함수
const transformGroup = (g: any): HierarchyGroup => ({
groupId: g.group_id,
groupCode: g.group_code,
groupName: g.group_name,
description: g.description,
hierarchyType: g.hierarchy_type,
maxLevels: g.max_levels,
isFixedLevels: g.is_fixed_levels,
selfRefTable: g.self_ref_table,
selfRefIdColumn: g.self_ref_id_column,
selfRefParentColumn: g.self_ref_parent_column,
selfRefValueColumn: g.self_ref_value_column,
selfRefLabelColumn: g.self_ref_label_column,
selfRefLevelColumn: g.self_ref_level_column,
selfRefOrderColumn: g.self_ref_order_column,
bomTable: g.bom_table,
bomParentColumn: g.bom_parent_column,
bomChildColumn: g.bom_child_column,
bomItemTable: g.bom_item_table,
bomItemIdColumn: g.bom_item_id_column,
bomItemLabelColumn: g.bom_item_label_column,
bomQtyColumn: g.bom_qty_column,
bomLevelColumn: g.bom_level_column,
emptyMessage: g.empty_message,
noOptionsMessage: g.no_options_message,
loadingMessage: g.loading_message,
companyCode: g.company_code,
isActive: g.is_active,
createdBy: g.created_by,
createdDate: g.created_date,
updatedBy: g.updated_by,
updatedDate: g.updated_date,
levelCount: g.level_count || 0,
levels: g.levels,
});
// 목록 로드
const loadGroups = useCallback(async () => {
setLoading(true);
try {
const response = await hierarchyApi.getGroups();
if (response.success && response.data) {
// snake_case를 camelCase로 변환
const transformedData = response.data.map(transformGroup);
setGroups(transformedData);
}
} catch (error) {
console.error("계층 그룹 목록 로드 실패:", error);
toast.error("목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, []);
// 테이블 목록 로드
const loadTables = useCallback(async () => {
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setTables(response.data);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
}
}, []);
useEffect(() => {
loadGroups();
loadTables();
}, [loadGroups, loadTables]);
// 그룹 레벨 로드
const loadGroupLevels = async (groupCode: string) => {
try {
const response = await hierarchyApi.getDetail(groupCode);
if (response.success && response.data?.levels) {
setGroupLevels((prev) => ({
...prev,
[groupCode]: response.data!.levels || [],
}));
}
} catch (error) {
console.error("레벨 로드 실패:", error);
}
};
// 그룹 확장 토글
const toggleGroupExpand = async (groupCode: string) => {
const newExpanded = new Set(expandedGroups);
if (newExpanded.has(groupCode)) {
newExpanded.delete(groupCode);
} else {
newExpanded.add(groupCode);
if (!groupLevels[groupCode]) {
await loadGroupLevels(groupCode);
}
}
setExpandedGroups(newExpanded);
};
// 컬럼 로드 (레벨 폼용)
const loadLevelColumns = async (tableName: string) => {
if (!tableName) {
setLevelColumns([]);
return;
}
try {
const response = await tableManagementApi.getColumnList(tableName);
if (response.success && response.data?.columns) {
setLevelColumns(response.data.columns);
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
}
};
// 필터된 목록
const filteredGroups = groups.filter(
(g) =>
g.groupName?.toLowerCase().includes(searchText.toLowerCase()) ||
g.groupCode?.toLowerCase().includes(searchText.toLowerCase()),
);
// 모달 열기 (생성)
const handleOpenCreate = () => {
setEditingGroup(null);
setFormData({
groupName: "",
description: "",
hierarchyType: "MULTI_TABLE",
maxLevels: undefined,
isFixedLevels: "Y",
emptyMessage: "선택해주세요",
noOptionsMessage: "옵션이 없습니다",
loadingMessage: "로딩 중...",
});
setIsModalOpen(true);
};
// 모달 열기 (수정)
const handleOpenEdit = (group: HierarchyGroup) => {
setEditingGroup(group);
setFormData({
groupCode: group.groupCode,
groupName: group.groupName,
description: group.description || "",
hierarchyType: group.hierarchyType,
maxLevels: group.maxLevels,
isFixedLevels: group.isFixedLevels || "Y",
emptyMessage: group.emptyMessage || "선택해주세요",
noOptionsMessage: group.noOptionsMessage || "옵션이 없습니다",
loadingMessage: group.loadingMessage || "로딩 중...",
});
setIsModalOpen(true);
};
// 삭제 확인
const handleDeleteConfirm = (groupCode: string) => {
setDeletingGroupCode(groupCode);
setIsDeleteDialogOpen(true);
};
// 삭제 실행
const handleDelete = async () => {
if (!deletingGroupCode) return;
try {
const response = await hierarchyApi.deleteGroup(deletingGroupCode);
if (response.success) {
toast.success("계층 그룹이 삭제되었습니다.");
loadGroups();
} else {
toast.error(response.error || "삭제에 실패했습니다.");
}
} catch (error) {
toast.error("삭제 중 오류가 발생했습니다.");
} finally {
setIsDeleteDialogOpen(false);
setDeletingGroupCode(null);
}
};
// 저장
const handleSave = async () => {
if (!formData.groupName || !formData.hierarchyType) {
toast.error("필수 항목을 모두 입력해주세요.");
return;
}
try {
let response;
if (editingGroup) {
response = await hierarchyApi.updateGroup(editingGroup.groupCode!, formData);
} else {
response = await hierarchyApi.createGroup(formData as HierarchyGroup);
}
if (response.success) {
toast.success(editingGroup ? "수정되었습니다." : "생성되었습니다.");
setIsModalOpen(false);
loadGroups();
} else {
toast.error(response.error || "저장에 실패했습니다.");
}
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
}
};
// 레벨 모달 열기 (생성)
const handleOpenCreateLevel = (groupCode: string) => {
setCurrentGroupCode(groupCode);
setEditingLevel(null);
const existingLevels = groupLevels[groupCode] || [];
setLevelFormData({
levelOrder: existingLevels.length + 1,
levelName: "",
levelCode: "",
tableName: "",
valueColumn: "",
labelColumn: "",
parentKeyColumn: "",
orderColumn: "",
orderDirection: "ASC",
placeholder: "",
isRequired: "Y",
isSearchable: "N",
});
setLevelColumns([]);
setIsLevelModalOpen(true);
};
// 레벨 모달 열기 (수정)
const handleOpenEditLevel = async (level: HierarchyLevel) => {
setCurrentGroupCode(level.groupCode);
setEditingLevel(level);
setLevelFormData({
levelOrder: level.levelOrder,
levelName: level.levelName,
levelCode: level.levelCode || "",
tableName: level.tableName,
valueColumn: level.valueColumn,
labelColumn: level.labelColumn,
parentKeyColumn: level.parentKeyColumn || "",
orderColumn: level.orderColumn || "",
orderDirection: level.orderDirection || "ASC",
placeholder: level.placeholder || "",
isRequired: level.isRequired || "Y",
isSearchable: level.isSearchable || "N",
});
await loadLevelColumns(level.tableName);
setIsLevelModalOpen(true);
};
// 레벨 저장
const handleSaveLevel = async () => {
if (
!levelFormData.levelName ||
!levelFormData.tableName ||
!levelFormData.valueColumn ||
!levelFormData.labelColumn
) {
toast.error("필수 항목을 모두 입력해주세요.");
return;
}
try {
let response;
if (editingLevel) {
response = await hierarchyApi.updateLevel(editingLevel.levelId!, levelFormData);
} else {
response = await hierarchyApi.addLevel(currentGroupCode, levelFormData);
}
if (response.success) {
toast.success(editingLevel ? "레벨이 수정되었습니다." : "레벨이 추가되었습니다.");
setIsLevelModalOpen(false);
await loadGroupLevels(currentGroupCode);
} else {
toast.error(response.error || "저장에 실패했습니다.");
}
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
}
};
// 레벨 삭제
const handleDeleteLevel = async (levelId: number, groupCode: string) => {
if (!confirm("이 레벨을 삭제하시겠습니까?")) return;
try {
const response = await hierarchyApi.deleteLevel(levelId);
if (response.success) {
toast.success("레벨이 삭제되었습니다.");
await loadGroupLevels(groupCode);
} else {
toast.error(response.error || "삭제에 실패했습니다.");
}
} catch (error) {
toast.error("삭제 중 오류가 발생했습니다.");
}
};
// 계층 타입 라벨
const getHierarchyTypeLabel = (type: string) => {
return HIERARCHY_TYPES.find((t) => t.value === type)?.label || type;
};
return (
<div className="space-y-6">
{/* 검색 */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="relative flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="그룹 코드, 이름으로 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="pl-10"
/>
</div>
<Button variant="outline" onClick={loadGroups}>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 목록 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Layers className="h-5 w-5" />
</CardTitle>
<CardDescription>
&gt; &gt; . ( {filteredGroups.length})
</CardDescription>
</div>
<Button onClick={handleOpenCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-6 w-6 animate-spin" />
<span className="ml-2"> ...</span>
</div>
) : filteredGroups.length === 0 ? (
<div className="text-muted-foreground space-y-4 py-8 text-center">
<div className="text-sm">{searchText ? "검색 결과가 없습니다." : "등록된 계층 그룹이 없습니다."}</div>
<div className="mx-auto max-w-md space-y-3 text-left">
<div className="rounded-lg border p-4">
<div className="text-foreground mb-2 text-sm font-medium">예시: 지역 </div>
<div className="text-muted-foreground text-xs"> &gt; / &gt; // &gt; //</div>
</div>
<div className="rounded-lg border p-4">
<div className="text-foreground mb-2 text-sm font-medium">예시: 조직 </div>
<div className="text-muted-foreground text-xs"> &gt; &gt; ( )</div>
</div>
</div>
</div>
) : (
<div className="space-y-2">
{filteredGroups.map((group) => (
<div key={group.groupCode} className="rounded-lg border">
<div
className="hover:bg-muted/50 flex cursor-pointer items-center justify-between p-4"
onClick={() => toggleGroupExpand(group.groupCode)}
>
<div className="flex items-center gap-3">
{expandedGroups.has(group.groupCode) ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<div>
<div className="font-medium">{group.groupName}</div>
<div className="text-muted-foreground text-xs">{group.groupCode}</div>
</div>
</div>
<div className="flex items-center gap-4">
<Badge variant="outline">{getHierarchyTypeLabel(group.hierarchyType)}</Badge>
<Badge variant="secondary">{group.levelCount || 0} </Badge>
<Badge variant={group.isActive === "Y" ? "default" : "secondary"}>
{group.isActive === "Y" ? "활성" : "비활성"}
</Badge>
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(group)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(group.groupCode)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</div>
{/* 레벨 목록 */}
{expandedGroups.has(group.groupCode) && (
<div className="bg-muted/20 border-t p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium"> </span>
<Button size="sm" variant="outline" onClick={() => handleOpenCreateLevel(group.groupCode)}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(groupLevels[group.groupCode] || []).length === 0 ? (
<div className="text-muted-foreground py-4 text-center text-sm"> .</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(groupLevels[group.groupCode] || []).map((level) => (
<TableRow key={level.levelId}>
<TableCell>{level.levelOrder}</TableCell>
<TableCell className="font-medium">{level.levelName}</TableCell>
<TableCell className="font-mono text-xs">{level.tableName}</TableCell>
<TableCell className="font-mono text-xs">{level.valueColumn}</TableCell>
<TableCell className="font-mono text-xs">{level.parentKeyColumn || "-"}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" onClick={() => handleOpenEditLevel(level)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteLevel(level.levelId!, group.groupCode)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 그룹 생성/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editingGroup ? "계층 그룹 수정" : "계층 그룹 생성"}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label> *</Label>
<Input
value={formData.groupName}
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
placeholder="예: 지역 계층"
/>
</div>
<div className="space-y-2">
<Label> *</Label>
<Select
value={formData.hierarchyType}
onValueChange={(v: any) => setFormData({ ...formData, hierarchyType: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{HIERARCHY_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="계층 구조에 대한 설명"
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label> </Label>
<Input
type="number"
value={formData.maxLevels || ""}
onChange={(e) =>
setFormData({ ...formData, maxLevels: e.target.value ? Number(e.target.value) : undefined })
}
placeholder="예: 4"
/>
</div>
<div className="space-y-2">
<Label> </Label>
<Select
value={formData.isFixedLevels}
onValueChange={(v) => setFormData({ ...formData, isFixedLevels: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
</Button>
<Button onClick={handleSave}>{editingGroup ? "수정" : "생성"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 레벨 생성/수정 모달 */}
<Dialog open={isLevelModalOpen} onOpenChange={setIsLevelModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editingLevel ? "레벨 수정" : "레벨 추가"}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label> *</Label>
<Input
type="number"
value={levelFormData.levelOrder}
onChange={(e) => setLevelFormData({ ...levelFormData, levelOrder: Number(e.target.value) })}
min={1}
/>
</div>
<div className="space-y-2">
<Label> *</Label>
<Input
value={levelFormData.levelName}
onChange={(e) => setLevelFormData({ ...levelFormData, levelName: e.target.value })}
placeholder="예: 시/도"
/>
</div>
</div>
<div className="space-y-2">
<Label> *</Label>
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboOpen}
className="h-10 w-full justify-between text-sm"
>
{levelFormData.tableName
? tables.find((t) => t.tableName === levelFormData.tableName)?.displayName ||
levelFormData.tableName
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
<CommandEmpty className="text-sm"> .</CommandEmpty>
<CommandGroup>
{tables.map((t) => (
<CommandItem
key={t.tableName}
value={`${t.tableName} ${t.displayName || ""}`}
onSelect={async () => {
setLevelFormData({
...levelFormData,
tableName: t.tableName,
valueColumn: "",
labelColumn: "",
parentKeyColumn: "",
});
await loadLevelColumns(t.tableName);
setTableComboOpen(false);
}}
className="text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
levelFormData.tableName === t.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{t.displayName || t.tableName}</span>
{t.displayName && t.displayName !== t.tableName && (
<span className="text-muted-foreground text-xs">{t.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label> *</Label>
<Select
value={levelFormData.valueColumn}
onValueChange={(v) => setLevelFormData({ ...levelFormData, valueColumn: v })}
disabled={!levelFormData.tableName}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{levelColumns.map((c) => (
<SelectItem key={c.columnName} value={c.columnName}>
{c.displayName || c.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> *</Label>
<Select
value={levelFormData.labelColumn}
onValueChange={(v) => setLevelFormData({ ...levelFormData, labelColumn: v })}
disabled={!levelFormData.tableName}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{levelColumns.map((c) => (
<SelectItem key={c.columnName} value={c.columnName}>
{c.displayName || c.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label> ( 2 )</Label>
<Select
value={levelFormData.parentKeyColumn || "__none__"}
onValueChange={(v) =>
setLevelFormData({ ...levelFormData, parentKeyColumn: v === "__none__" ? "" : v })
}
disabled={!levelFormData.tableName}
>
<SelectTrigger>
<SelectValue placeholder="선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{levelColumns.map((c) => (
<SelectItem key={c.columnName} value={c.columnName}>
{c.displayName || c.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={levelFormData.placeholder}
onChange={(e) => setLevelFormData({ ...levelFormData, placeholder: e.target.value })}
placeholder={`${levelFormData.levelName || "레벨"} 선택`}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsLevelModalOpen(false)}>
</Button>
<Button onClick={handleSaveLevel}>{editingLevel ? "수정" : "추가"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -1,582 +0,0 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Ban, Check, ChevronsUpDown, Plus, RefreshCw, Search, Pencil, Trash2 } from "lucide-react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { mutualExclusionApi, MutualExclusion, EXCLUSION_TYPES } from "@/lib/api/cascadingMutualExclusion";
import { tableManagementApi } from "@/lib/api/tableManagement";
export default function MutualExclusionTab() {
// 목록 상태
const [exclusions, setExclusions] = useState<MutualExclusion[]>([]);
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [columns, setColumns] = useState<Array<{ columnName: string; displayName: string }>>([]);
const [loading, setLoading] = useState(true);
const [searchText, setSearchText] = useState("");
// 테이블 Combobox 상태
const [tableComboOpen, setTableComboOpen] = useState(false);
// 모달 상태
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [editingExclusion, setEditingExclusion] = useState<MutualExclusion | null>(null);
const [deletingExclusionId, setDeletingExclusionId] = useState<number | null>(null);
// 폼 데이터
const [formData, setFormData] = useState<Omit<MutualExclusion, "exclusionId" | "exclusionCode">>({
exclusionName: "",
fieldNames: "",
sourceTable: "",
valueColumn: "",
labelColumn: "",
exclusionType: "SAME_VALUE",
errorMessage: "동일한 값을 선택할 수 없습니다",
});
// 필드 목록 (동적 추가)
const [fieldList, setFieldList] = useState<string[]>(["", ""]);
// 목록 로드
const loadExclusions = useCallback(async () => {
setLoading(true);
try {
const response = await mutualExclusionApi.getList();
if (response.success && response.data) {
setExclusions(response.data);
}
} catch (error) {
console.error("상호 배제 목록 로드 실패:", error);
toast.error("목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, []);
// 테이블 목록 로드
const loadTables = useCallback(async () => {
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setTables(response.data);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
}
}, []);
useEffect(() => {
loadExclusions();
loadTables();
}, [loadExclusions, loadTables]);
// 테이블 선택 시 컬럼 로드
const loadColumns = async (tableName: string) => {
if (!tableName) {
setColumns([]);
return;
}
try {
const response = await tableManagementApi.getColumnList(tableName);
if (response.success && response.data?.columns) {
setColumns(response.data.columns);
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
}
};
// 필터된 목록
const filteredExclusions = exclusions.filter(
(e) =>
e.exclusionName?.toLowerCase().includes(searchText.toLowerCase()) ||
e.exclusionCode?.toLowerCase().includes(searchText.toLowerCase()),
);
// 모달 열기 (생성)
const handleOpenCreate = () => {
setEditingExclusion(null);
setFormData({
exclusionName: "",
fieldNames: "",
sourceTable: "",
valueColumn: "",
labelColumn: "",
exclusionType: "SAME_VALUE",
errorMessage: "동일한 값을 선택할 수 없습니다",
});
setFieldList(["", ""]);
setColumns([]);
setIsModalOpen(true);
};
// 모달 열기 (수정)
const handleOpenEdit = async (exclusion: MutualExclusion) => {
setEditingExclusion(exclusion);
setFormData({
exclusionCode: exclusion.exclusionCode,
exclusionName: exclusion.exclusionName,
fieldNames: exclusion.fieldNames,
sourceTable: exclusion.sourceTable,
valueColumn: exclusion.valueColumn,
labelColumn: exclusion.labelColumn || "",
exclusionType: exclusion.exclusionType || "SAME_VALUE",
errorMessage: exclusion.errorMessage || "동일한 값을 선택할 수 없습니다",
});
setFieldList(exclusion.fieldNames.split(",").map((f) => f.trim()));
await loadColumns(exclusion.sourceTable);
setIsModalOpen(true);
};
// 삭제 확인
const handleDeleteConfirm = (exclusionId: number) => {
setDeletingExclusionId(exclusionId);
setIsDeleteDialogOpen(true);
};
// 삭제 실행
const handleDelete = async () => {
if (!deletingExclusionId) return;
try {
const response = await mutualExclusionApi.delete(deletingExclusionId);
if (response.success) {
toast.success("상호 배제 규칙이 삭제되었습니다.");
loadExclusions();
} else {
toast.error(response.error || "삭제에 실패했습니다.");
}
} catch (error) {
toast.error("삭제 중 오류가 발생했습니다.");
} finally {
setIsDeleteDialogOpen(false);
setDeletingExclusionId(null);
}
};
// 필드 추가
const addField = () => {
setFieldList([...fieldList, ""]);
};
// 필드 제거
const removeField = (index: number) => {
if (fieldList.length <= 2) {
toast.error("최소 2개의 필드가 필요합니다.");
return;
}
setFieldList(fieldList.filter((_, i) => i !== index));
};
// 필드 값 변경
const updateField = (index: number, value: string) => {
const newFields = [...fieldList];
newFields[index] = value;
setFieldList(newFields);
};
// 저장
const handleSave = async () => {
// 필드 목록 합치기
const cleanedFields = fieldList.filter((f) => f.trim());
if (cleanedFields.length < 2) {
toast.error("최소 2개의 필드를 입력해주세요.");
return;
}
// 유효성 검사
if (!formData.exclusionName || !formData.sourceTable || !formData.valueColumn) {
toast.error("필수 항목을 모두 입력해주세요.");
return;
}
const dataToSave = {
...formData,
fieldNames: cleanedFields.join(","),
};
try {
let response;
if (editingExclusion) {
response = await mutualExclusionApi.update(editingExclusion.exclusionId!, dataToSave);
} else {
response = await mutualExclusionApi.create(dataToSave);
}
if (response.success) {
toast.success(editingExclusion ? "수정되었습니다." : "생성되었습니다.");
setIsModalOpen(false);
loadExclusions();
} else {
toast.error(response.error || "저장에 실패했습니다.");
}
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
}
};
// 테이블 선택 핸들러
const handleTableChange = async (tableName: string) => {
setFormData({ ...formData, sourceTable: tableName, valueColumn: "", labelColumn: "" });
await loadColumns(tableName);
};
return (
<div className="space-y-6">
{/* 검색 */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="relative flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="배제 코드, 이름으로 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="pl-10"
/>
</div>
<Button variant="outline" onClick={loadExclusions}>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 목록 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Ban className="h-5 w-5" />
</CardTitle>
<CardDescription>
. ( {filteredExclusions.length})
</CardDescription>
</div>
<Button onClick={handleOpenCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-6 w-6 animate-spin" />
<span className="ml-2"> ...</span>
</div>
) : filteredExclusions.length === 0 ? (
<div className="text-muted-foreground space-y-4 py-8 text-center">
<div className="text-sm">
{searchText ? "검색 결과가 없습니다." : "등록된 상호 배제 규칙이 없습니다."}
</div>
<div className="mx-auto max-w-md space-y-3 text-left">
<div className="rounded-lg border p-4">
<div className="text-foreground mb-2 text-sm font-medium">예시: 창고 </div>
<div className="text-muted-foreground text-xs">
"출발 창고" "도착 창고"
</div>
</div>
<div className="rounded-lg border p-4">
<div className="text-foreground mb-2 text-sm font-medium">예시: 부서 </div>
<div className="text-muted-foreground text-xs">
"현재 부서" "이동 부서"
</div>
</div>
</div>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredExclusions.map((exclusion) => (
<TableRow key={exclusion.exclusionId}>
<TableCell className="font-mono text-sm">{exclusion.exclusionCode}</TableCell>
<TableCell className="font-medium">{exclusion.exclusionName}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{exclusion.fieldNames.split(",").map((field, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
{field.trim()}
</Badge>
))}
</div>
</TableCell>
<TableCell className="font-mono text-xs">{exclusion.sourceTable}</TableCell>
<TableCell>
<Badge variant={exclusion.isActive === "Y" ? "default" : "secondary"}>
{exclusion.isActive === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(exclusion)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(exclusion.exclusionId!)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* 생성/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editingExclusion ? "상호 배제 규칙 수정" : "상호 배제 규칙 생성"}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 배제명 */}
<div className="space-y-2">
<Label> *</Label>
<Input
value={formData.exclusionName}
onChange={(e) => setFormData({ ...formData, exclusionName: e.target.value })}
placeholder="예: 창고 이동 제한"
/>
</div>
{/* 대상 필드 */}
<div className="rounded-lg border p-4">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold"> ( 2)</h4>
<Button variant="outline" size="sm" onClick={addField}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="space-y-2">
{fieldList.map((field, index) => (
<div key={index} className="flex items-center gap-2">
<Input
value={field}
onChange={(e) => updateField(index, e.target.value)}
placeholder={`필드 ${index + 1} (예: source_warehouse)`}
className="flex-1"
/>
{fieldList.length > 2 && (
<Button variant="ghost" size="icon" onClick={() => removeField(index)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
))}
</div>
<p className="text-muted-foreground mt-2 text-xs"> .</p>
</div>
{/* 소스 테이블 및 컬럼 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label> *</Label>
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboOpen}
className="h-10 w-full justify-between text-sm"
>
{formData.sourceTable
? tables.find((t) => t.tableName === formData.sourceTable)?.displayName || formData.sourceTable
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
<CommandEmpty className="text-sm"> .</CommandEmpty>
<CommandGroup>
{tables
.filter((t) => t.tableName)
.map((t) => (
<CommandItem
key={t.tableName}
value={`${t.tableName} ${t.displayName || ""}`}
onSelect={async () => {
setFormData({
...formData,
sourceTable: t.tableName,
valueColumn: "",
labelColumn: "",
});
await loadColumns(t.tableName);
setTableComboOpen(false);
}}
className="text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.sourceTable === t.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{t.displayName || t.tableName}</span>
{t.displayName && t.displayName !== t.tableName && (
<span className="text-muted-foreground text-xs">{t.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label> *</Label>
<Select
value={formData.valueColumn}
onValueChange={(v) => setFormData({ ...formData, valueColumn: v })}
disabled={!formData.sourceTable}
>
<SelectTrigger>
<SelectValue placeholder="값 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns
.filter((c) => c.columnName)
.map((c) => (
<SelectItem key={c.columnName} value={c.columnName}>
{c.displayName || c.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label> </Label>
<Select
value={formData.labelColumn}
onValueChange={(v) => setFormData({ ...formData, labelColumn: v })}
disabled={!formData.sourceTable}
>
<SelectTrigger>
<SelectValue placeholder="라벨 컬럼 선택 (선택)" />
</SelectTrigger>
<SelectContent>
{columns
.filter((c) => c.columnName)
.map((c) => (
<SelectItem key={c.columnName} value={c.columnName}>
{c.displayName || c.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> </Label>
<Select
value={formData.exclusionType}
onValueChange={(v) => setFormData({ ...formData, exclusionType: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXCLUSION_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 에러 메시지 */}
<div className="space-y-2">
<Label> </Label>
<Input
value={formData.errorMessage}
onChange={(e) => setFormData({ ...formData, errorMessage: e.target.value })}
placeholder="동일한 값을 선택할 수 없습니다"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
</Button>
<Button onClick={handleSave}>{editingExclusion ? "수정" : "생성"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -1,21 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
/**
*
*/
export default function CascadingRelationsRedirect() {
const router = useRouter();
useEffect(() => {
router.replace("/admin/cascading-management");
}, [router]);
return (
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-sm"> ...</div>
</div>
);
}
@@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Plus, RefreshCw, Search, X, LayoutGrid, LayoutList, TestTube2, Database, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import ScreenDesigner from "@/components/screen/ScreenDesigner";
import InvyoneStudio from "@/components/screen/InvyoneStudio";
import TemplateManager from "@/components/screen/TemplateManager";
import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView";
import { ScreenRelationFlow } from "@/components/screen/ScreenRelationFlow";
@@ -138,7 +138,7 @@ export default function ScreenManagementPage() {
if (isDesignMode) {
return (
<div className="fixed inset-0 z-50 bg-background">
<ScreenDesigner
<InvyoneStudio
selectedScreen={selectedScreen}
onBackToList={() => goToStep("list")}
onScreenUpdate={(updatedFields) => {
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { FileText, Download, Plus, Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
import { getCompaniesStats } from "@/lib/api/provisioning";
@@ -9,6 +10,7 @@ import CompanyAccordionRow from "@/components/admin/provisioning/CompanyAccordio
import Wizard from "@/components/admin/provisioning/wizard/Wizard";
import AuditLogDrawer from "@/components/admin/provisioning/AuditLogDrawer";
import { toCsvString, downloadCsv } from "@/lib/csvExport";
import { isManagementHost } from "@/lib/tenant/subdomain";
const PAGE_SIZE = 10;
@@ -18,8 +20,22 @@ const PAGE_SIZE = 10;
*
* /admin/userMng/companyList ( CRUD) .
* "테넌트 DB 생성 + 서브도메인 라우팅 + 회사 라이프사이클" .
*
* 격리: 솔루션/ (solution.invyone.com, localhost ) .
* (qnc.invyone.com ) URL /main .
* SuperAdminGuard API .
*/
export default function SubdomainListPage() {
const router = useRouter();
const [hostBlocked, setHostBlocked] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
if (!isManagementHost(window.location.hostname)) {
setHostBlocked(true);
router.replace("/main");
}
}, [router]);
const [openKey, setOpenKey] = useState<string | null>(null);
const [q, setQ] = useState("");
const [filter, setFilter] = useState<"all" | "active" | "provisioning" | "inactive" | "failed">("all");
@@ -51,6 +67,7 @@ export default function SubdomainListPage() {
const { data: rows = [], isLoading, refetch, dataUpdatedAt } = useQuery({
queryKey: ["companies-stats"],
queryFn: getCompaniesStats,
enabled: !hostBlocked, // 테넌트 사이트에서는 API 도 안 부르고 곧장 redirect
refetchInterval: (query) => {
// provisioning 중인 회사 있으면 3초 폴링, 없으면 30초
const hasProvisioning = Array.isArray(query.state.data)
@@ -95,6 +112,12 @@ export default function SubdomainListPage() {
const provisCount = rows.filter((r) => r.db_status === "provisioning").length;
const inactCount = rows.filter((r) => r.db_status === "inactive" || r.status === "inactive").length;
// 호스트 격리 — 테넌트 사이트에서 진입한 경우 redirect 대기 중 빈 화면.
// 데이터/UI 가 잠깐이라도 노출되지 않도록 본 render 보다 먼저 차단.
if (hostBlocked) {
return null;
}
return (
<div
style={{
@@ -1,12 +1,12 @@
"use client";
import { CodeCategoryPanel } from "@/components/admin/CodeCategoryPanel";
import { CodeInfoPanel } from "@/components/admin/CodeInfoPanel";
import { CodeDetailPanel } from "@/components/admin/CodeDetailPanel";
import { useSelectedCategory } from "@/hooks/useSelectedCategory";
import { useSelectedCodeInfo } from "@/hooks/useSelectedCodeInfo";
import { ScrollToTop } from "@/components/common/ScrollToTop";
export default function CommonCodeManagementPage() {
const { selectedCategoryCode, selectCategory } = useSelectedCategory();
const { selectedCodeInfo, selectCodeInfo } = useSelectedCodeInfo();
return (
<div className="flex min-h-screen flex-col bg-background">
@@ -14,35 +14,41 @@ export default function CommonCodeManagementPage() {
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-sm text-muted-foreground">
(code_info) (code_detail)
</p>
</div>
{/* 메인 콘텐츠 - 좌우 레이아웃 */}
<div className="flex flex-col gap-6 lg:flex-row lg:gap-6">
{/* 좌측: 카테고리 패널 */}
{/* 좌측: 그룹 패널 */}
<div className="w-full lg:w-80 lg:border-r lg:pr-6">
<div className="space-y-4">
<h2 className="text-lg font-semibold"> </h2>
<CodeCategoryPanel selectedCategoryCode={selectedCategoryCode} onSelectCategory={selectCategory} />
<h2 className="text-lg font-semibold"> </h2>
<CodeInfoPanel
selectedCodeInfo={selectedCodeInfo}
onSelectCodeInfo={selectCodeInfo}
/>
</div>
</div>
{/* 우측: 코드 상세 패널 */}
{/* 우측: 디테일 트리 패널 */}
<div className="min-w-0 flex-1 lg:pl-0">
<div className="space-y-4">
<h2 className="text-lg font-semibold">
{selectedCategoryCode && (
<span className="ml-2 text-sm font-normal text-muted-foreground">({selectedCategoryCode})</span>
{selectedCodeInfo && (
<span className="ml-2 text-sm font-normal text-muted-foreground">
({selectedCodeInfo})
</span>
)}
</h2>
<CodeDetailPanel categoryCode={selectedCategoryCode} />
<CodeDetailPanel codeInfo={selectedCodeInfo} />
</div>
</div>
</div>
</div>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
);
File diff suppressed because it is too large Load Diff
@@ -20,6 +20,7 @@ import {
Check,
ChevronsUpDown,
Loader2,
Pencil,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
@@ -30,10 +31,13 @@ import { useAuth } from "@/hooks/useAuth";
import { TABLE_MANAGEMENT_KEYS } from "@/constants/tableManagement";
import { INPUT_TYPE_OPTIONS, USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types";
import { apiClient } from "@/lib/api/client";
import { commonCodeApi } from "@/lib/api/commonCode";
import { getCodeInfoList } from "@/lib/api/commonCode";
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
import { ddlApi } from "@/lib/api/ddl";
import { getSecondLevelMenus, createColumnMapping, deleteColumnMappingsByColumn } from "@/lib/api/tableCategoryValue";
// getSecondLevelMenus / createColumnMapping / deleteColumnMappingsByColumn (카테고리 모듈 폐기)
const getSecondLevelMenus = async (): Promise<{ success: boolean; data?: any[] }> => ({ success: true, data: [] });
const createColumnMapping = async (_params: Record<string, any>): Promise<{ success: boolean }> => ({ success: true });
const deleteColumnMappingsByColumn = async (_table: string, _column: string): Promise<{ success: boolean }> => ({ success: true });
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
import { CreateTableModal } from "@/components/admin/CreateTableModal";
import { AddColumnModal } from "@/components/admin/AddColumnModal";
@@ -73,6 +77,9 @@ export default function TableManagementPage() {
// 테이블 라벨 상태
const [tableLabel, setTableLabel] = useState("");
const [tableDescription, setTableDescription] = useState("");
// 헤더 인라인 편집 상태 (Google Docs / Notion 패턴)
const [editingHeaderField, setEditingHeaderField] = useState<"label" | "description" | null>(null);
const [editingHeaderValue, setEditingHeaderValue] = useState("");
// 🎯 Entity 조인 관련 상태
const [referenceTableColumns, setReferenceTableColumns] = useState<Record<string, ReferenceTableColumn[]>>({});
@@ -239,19 +246,13 @@ export default function TableManagementPage() {
// 공통코드 카테고리 목록 로드
const loadCommonCodeCategories = async () => {
try {
const response = await commonCodeApi.categories.getList({ isActive: true });
// console.log("🔍 공통코드 카테고리 API 응답:", response);
const response = await getCodeInfoList({ is_active: true });
if (response.success && response.data) {
// console.log("📋 공통코드 카테고리 데이터:", response.data);
const categories = response.data.map((category) => {
// console.log("🏷️ 카테고리 항목:", category);
return {
value: category.category_code,
label: category.category_name || category.category_code,
};
});
const categories = response.data.map((row: Record<string, any>) => ({
value: row.code_info,
label: row.code_name || row.code_info,
}));
// console.log("✅ 매핑된 카테고리 옵션:", categories);
setCommonCodeCategories(categories);
@@ -381,7 +382,7 @@ export default function TableManagementPage() {
if (response.data.success) {
const data = response.data.data;
setConstraints({
primaryKey: data.primaryKey ?? { name: "", columns: [] },
primaryKey: data.primary_key ?? { name: "", columns: [] },
indexes: data.indexes ?? [],
});
}
@@ -433,7 +434,7 @@ export default function TableManagementPage() {
// 코드가 아닌 타입으로 변경 시 코드 설정 초기화
if (newInputType !== "code") {
updated.code_category = undefined;
updated.code_info = undefined;
updated.code_value = undefined;
updated.hierarchy_role = undefined;
}
@@ -459,7 +460,7 @@ export default function TableManagementPage() {
prev.map((col) => {
if (col.column_name === columnName) {
let newDetailSettings = col.detail_settings;
let codeCategory = col.code_category;
let codeInfo = col.code_info;
let codeValue = col.code_value;
let referenceTable = col.reference_table;
let referenceColumn = col.reference_column;
@@ -469,17 +470,17 @@ export default function TableManagementPage() {
if (settingType === "code") {
if (value === "none") {
newDetailSettings = "";
codeCategory = undefined;
codeInfo = undefined;
codeValue = undefined;
hierarchyRole = undefined; // 코드 선택 해제 시 계층 역할도 초기화
} else {
// 기존 hierarchyRole 유지하면서 JSON 형식으로 저장
const existingHierarchyRole = hierarchyRole;
newDetailSettings = JSON.stringify({
code_category: value,
code_info: value,
hierarchy_role: existingHierarchyRole,
});
codeCategory = value;
codeInfo = value;
codeValue = value;
}
} else if (settingType === "hierarchy_role") {
@@ -529,7 +530,7 @@ export default function TableManagementPage() {
return {
...col,
detail_settings: newDetailSettings,
code_category: codeCategory,
code_info: codeInfo,
code_value: codeValue,
reference_table: referenceTable,
reference_column: referenceColumn,
@@ -632,7 +633,7 @@ export default function TableManagementPage() {
column_label: column.display_name,
input_type: column.input_type || "text",
detail_settings: finalDetailSettings,
code_category: column.code_category || "",
code_info: column.code_info || "",
code_value: column.code_value || "",
reference_table: column.reference_table || "",
reference_column: column.reference_column || "",
@@ -745,26 +746,49 @@ export default function TableManagementPage() {
}
};
// 전체 저장 (테이블 라벨 + 모든 컬럼 설정)
// 헤더 표시명/설명 인라인 저장 (PUT /label) — Google Docs 식 blur/Enter 커밋
const commitHeaderEdit = async () => {
if (!editingHeaderField || !selectedTable) {
setEditingHeaderField(null);
return;
}
const next = editingHeaderValue.trim();
const current = editingHeaderField === "label" ? tableLabel : tableDescription;
if (next === current) {
setEditingHeaderField(null);
return;
}
const newLabel = editingHeaderField === "label" ? next : tableLabel;
const newDescription = editingHeaderField === "description" ? next : tableDescription;
if (editingHeaderField === "label" && !newLabel) {
toast.error("표시명은 비울 수 없습니다.");
setEditingHeaderField(null);
return;
}
if (editingHeaderField === "label") setTableLabel(newLabel);
else setTableDescription(newDescription);
setEditingHeaderField(null);
try {
await apiClient.put(`/table-management/tables/${selectedTable}/label`, {
display_name: newLabel,
description: newDescription,
});
toast.success(editingHeaderField === "label" ? "표시명이 저장되었습니다." : "설명이 저장되었습니다.");
} catch (error: any) {
showErrorToast("저장에 실패했습니다", error, {
guidance: "잠시 후 다시 시도해 주세요.",
});
}
};
// 컬럼 설정만 일괄 저장 (헤더 라벨/설명은 inline 편집으로 즉시 저장됨)
const saveAllSettings = async () => {
if (!selectedTable) return;
if (isSaving) return; // 저장 중 중복 실행 방지
setIsSaving(true);
try {
// 1. 테이블 라벨 저장 (변경된 경우에만)
if (tableLabel !== selectedTable || tableDescription) {
try {
await apiClient.put(`/table-management/tables/${selectedTable}/label`, {
displayName: tableLabel,
description: tableDescription,
});
} catch (error) {
// console.warn("테이블 라벨 저장 실패 (API 미구현 가능):", error);
}
}
// 2. 모든 컬럼 설정 저장
// 모든 컬럼 설정 저장
if (columns.length > 0) {
const columnSettings = columns.map((column) => {
// detailSettings 계산
@@ -812,7 +836,7 @@ export default function TableManagementPage() {
input_type: column.input_type || "text",
detail_settings: finalDetailSettings,
description: column.description || "",
code_category: column.code_category || "",
code_info: column.code_info || "",
code_value: column.code_value || "",
reference_table: column.reference_table || "",
reference_column: column.reference_column || "",
@@ -1055,8 +1079,8 @@ export default function TableManagementPage() {
const action = checked ? "create" : "drop";
try {
const response = await apiClient.post(`/table-management/tables/${selectedTable}/indexes`, {
columnName,
indexType,
column_name: columnName,
index_type: indexType,
action,
});
if (response.data.success) {
@@ -1362,8 +1386,8 @@ export default function TableManagementPage() {
</div>
</div>
{/* 3패널 메인 */}
<div className="flex flex-1 overflow-hidden">
{/* 메인 (우측 패널은 overlay 라 2패널 layout) */}
<div className="relative flex flex-1 overflow-hidden">
{/* 좌측: 테이블 목록 (240px) */}
<div className="bg-card flex w-[280px] min-w-[280px] flex-shrink-0 flex-col border-r">
{/* 검색 */}
@@ -1378,7 +1402,7 @@ export default function TableManagementPage() {
/>
</div>
{isSuperAdmin && (
<div className="mt-2 flex items-center justify-between border-b pb-2">
<div className="mt-2 flex min-h-9 items-center justify-between border-b pb-2">
<div className="flex items-center gap-1.5">
<Checkbox
checked={
@@ -1435,7 +1459,7 @@ export default function TableManagementPage() {
)}
<div
className={cn(
"group relative flex items-center gap-2 rounded-md px-2.5 py-[7px] transition-colors",
"group relative flex items-center gap-2 rounded-md px-2.5 py-1.5 transition-colors",
isActive
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50",
@@ -1465,13 +1489,13 @@ export default function TableManagementPage() {
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1">
<span className={cn(
"truncate text-[16px] leading-tight",
"truncate text-[13px] leading-tight",
isActive ? "font-bold" : "font-medium",
)}>
{table.display_name || table.table_name}
</span>
</div>
<div className="text-muted-foreground truncate font-mono text-[12px] leading-tight tracking-tight">
<div className="text-muted-foreground truncate font-mono text-[10.5px] leading-tight tracking-tight">
{table.table_name}
</div>
</div>
@@ -1507,42 +1531,103 @@ export default function TableManagementPage() {
</div>
) : (
<>
{/* 중앙 헤더: 테이블명 + 라벨 입력 + 저장 */}
<div className="bg-card flex flex-shrink-0 items-center gap-3 border-b px-5 py-3">
<div className="min-w-0 flex-shrink-0">
<div className="text-[15px] font-bold tracking-tight">
{tableLabel || selectedTable}
</div>
<div className="text-muted-foreground font-mono text-[11px] tracking-tight">
{/* 중앙 헤더: inline click-to-edit (Google Docs / Notion 패턴) */}
<div className="bg-card flex flex-shrink-0 items-start gap-3 border-b px-5 py-3">
<div className="min-w-0 flex-1">
{/* 표시명 (display_name) — 클릭하면 그 자리에서 편집 */}
{editingHeaderField === "label" ? (
<Input
autoFocus
value={editingHeaderValue}
onChange={(e) => setEditingHeaderValue(e.target.value)}
onBlur={commitHeaderEdit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
} else if (e.key === "Escape") {
setEditingHeaderField(null);
}
}}
className="h-7 -mx-2 px-2 text-[15px] font-bold tracking-tight"
/>
) : (
<div className="group flex items-center gap-1.5">
<span className="text-[15px] font-bold tracking-tight">
{tableLabel || (
<span className="text-muted-foreground/60">{selectedTable}</span>
)}
</span>
<button
type="button"
onClick={() => {
setEditingHeaderValue(tableLabel);
setEditingHeaderField("label");
}}
className="text-muted-foreground/50 hover:text-foreground transition-colors"
title="표시명 편집"
aria-label="표시명 편집"
>
<Pencil className="h-3 w-3" />
</button>
</div>
)}
{/* table_name (코드, 편집 불가) */}
<div className="-mx-2 px-2 text-muted-foreground font-mono text-[11px] tracking-tight">
{selectedTable}
</div>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2">
<Input
value={tableLabel}
onChange={(e) => setTableLabel(e.target.value)}
placeholder="표시명"
className="h-8 max-w-[160px] text-xs"
/>
<Input
value={tableDescription}
onChange={(e) => setTableDescription(e.target.value)}
placeholder="설명"
className="h-8 max-w-[200px] text-xs"
/>
{/* 설명 (description) — 클릭하면 그 자리에서 편집 */}
{editingHeaderField === "description" ? (
<Input
autoFocus
value={editingHeaderValue}
onChange={(e) => setEditingHeaderValue(e.target.value)}
onBlur={commitHeaderEdit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
} else if (e.key === "Escape") {
setEditingHeaderField(null);
}
}}
placeholder="이 테이블에 대한 짧은 설명"
className="mt-1 h-7 -mx-2 px-2 text-xs"
/>
) : (
<div className="group mt-0.5 flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">
{tableDescription || (
<span className="text-muted-foreground/50">+ </span>
)}
</span>
<button
type="button"
onClick={() => {
setEditingHeaderValue(tableDescription);
setEditingHeaderField("description");
}}
className="text-muted-foreground/50 hover:text-foreground transition-colors"
title="설명 편집"
aria-label="설명 편집"
>
<Pencil className="h-2.5 w-2.5" />
</button>
</div>
)}
</div>
<Button
onClick={saveAllSettings}
disabled={!selectedTable || columns.length === 0 || isSaving}
size="sm"
className="h-8 gap-1.5 text-xs"
className="h-8 flex-shrink-0 gap-1.5 text-xs"
>
{isSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{isSaving ? "저장 중..." : "전체 설정 저장"}
{isSaving ? "저장 중..." : "컬럼 설정 저장"}
</Button>
</div>
@@ -1567,7 +1652,7 @@ export default function TableManagementPage() {
<ColumnGrid
columns={columns}
selectedColumn={selectedColumn}
onSelectColumn={setSelectedColumn}
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
onColumnChange={(columnName, field, value) => {
if (field === "is_unique") {
const currentColumn = columns.find((c) => c.column_name === columnName);
@@ -1602,10 +1687,14 @@ export default function TableManagementPage() {
)}
</div>
{/* 우측: 상세 패널 (selectedColumn 있을 때만) */}
{selectedColumn && (
<div className="w-[380px] min-w-[380px] flex-shrink-0 overflow-hidden">
<ColumnDetailPanel
{/* 우측: 상세 패널 (overlay slide-in/out — 가운데 본문 위에 부드럽게 등장) */}
<div
className={cn(
"bg-card absolute top-0 right-0 bottom-0 z-20 flex w-[380px] flex-col overflow-hidden border-l shadow-2xl transition-transform duration-300 ease-out",
selectedColumn ? "translate-x-0" : "pointer-events-none translate-x-full",
)}
>
<ColumnDetailPanel
column={columns.find((c) => c.column_name === selectedColumn) ?? null}
tables={tables}
referenceTableColumns={referenceTableColumns}
@@ -1628,11 +1717,10 @@ export default function TableManagementPage() {
}}
onClose={() => setSelectedColumn(null)}
onLoadReferenceColumns={loadReferenceTableColumns}
codeCategoryOptions={commonCodeOptions}
codeInfoOptions={commonCodeOptions}
referenceTableOptions={referenceTableOptions}
/>
</div>
)}
</div>
</div>
{/* DDL 모달 컴포넌트들 */}
@@ -1,5 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { isManagementHost } from "@/lib/tenant/subdomain";
import { useCompanyManagement } from "@/hooks/useCompanyManagement";
import { CompanyToolbar } from "@/components/admin/CompanyToolbar";
import { CompanyTable } from "@/components/admin/CompanyTable";
@@ -13,6 +16,16 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
*
*/
export default function CompanyPage() {
const router = useRouter();
const [hostBlocked, setHostBlocked] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
if (!isManagementHost(window.location.hostname)) {
setHostBlocked(true);
router.replace("/main");
}
}, [router]);
const {
// 데이터
companies,
@@ -51,6 +64,8 @@ export default function CompanyPage() {
clearError,
} = useCompanyManagement();
if (hostBlocked) return null;
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
@@ -1,10 +1,12 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import * as XLSX from "xlsx";
import {
ArrowDownToLine,
ArrowUpToLine,
Building2,
CheckCircle2,
ChevronDown,
ChevronRight,
ChevronUp,
@@ -12,6 +14,7 @@ import {
ChevronsUpDown,
Eye,
EyeOff,
FileDown,
Folder,
FolderOpen,
FolderTree,
@@ -28,6 +31,7 @@ import {
Upload,
Users,
X,
XCircle,
} from "lucide-react";
import {
DropdownMenu,
@@ -42,7 +46,9 @@ import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/hooks/useAuth";
import { cn } from "@/lib/utils";
@@ -93,6 +99,10 @@ interface DeptDetailDraft {
start_date: string;
end_date: string;
sort_order: number;
// 다중 관리자 (chip UI 용)
approval_managers: string[];
dept_managers: string[];
org_leaders: string[];
}
const emptyDraft = (companyCode = ""): DeptDetailDraft => ({
@@ -113,6 +123,9 @@ const emptyDraft = (companyCode = ""): DeptDetailDraft => ({
start_date: "",
end_date: "",
sort_order: 10,
approval_managers: [],
dept_managers: [],
org_leaders: [],
});
export default function DeptMngListPage() {
@@ -145,11 +158,15 @@ export default function DeptMngListPage() {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [pendingDeleteDept, setPendingDeleteDept] = useState<{ code: string; name: string } | null>(null);
// ── 일괄등록 / 변경이력 모달 ─────────────────────────
// ── 일괄등록 / 일괄업데이트 모달 ─────────────────────
const [bulkOpen, setBulkOpen] = useState(false);
const [bulkText, setBulkText] = useState("");
const [bulkUploading, setBulkUploading] = useState(false);
const [bulkFailures, setBulkFailures] = useState<{ line: number; deptName: string; reason: string }[]>([]);
const [bulkTab, setBulkTab] = useState<"create" | "update">("create");
const [bulkUpdateMode, setBulkUpdateMode] = useState<"department" | "manager">("department");
const [bulkRows, setBulkRows] = useState<Record<string, any>[]>([]);
const [bulkPreviewRows, setBulkPreviewRows] = useState<departmentAPI.BulkPreviewRow[]>([]);
const [bulkSelected, setBulkSelected] = useState<Set<number>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
const [bulkFileName, setBulkFileName] = useState<string>("");
// ── 트리 ⋮ 메뉴: 이동/삭제 대상 ───────────────────────
const [moveTargetDept, setMoveTargetDept] = useState<Department | null>(null);
@@ -201,7 +218,10 @@ export default function DeptMngListPage() {
if (!selectedCompanyCode) return;
setIsTreeLoading(true);
try {
const res = await departmentAPI.getDepartments(selectedCompanyCode, { includeDeleted: showDeleted });
const res = await departmentAPI.getDepartments(selectedCompanyCode, {
includeDeleted: showDeleted,
baseDate: periodMode === "date" ? baseDate : undefined,
});
if (res.success && (res as any).data) {
setDepartments((res as any).data);
} else {
@@ -210,7 +230,7 @@ export default function DeptMngListPage() {
} finally {
setIsTreeLoading(false);
}
}, [selectedCompanyCode, showDeleted]);
}, [selectedCompanyCode, showDeleted, periodMode, baseDate]);
useEffect(() => {
loadDepartments();
@@ -303,6 +323,18 @@ export default function DeptMngListPage() {
end_date: (dept.end_date ?? "").slice(0, 10),
sort_order: dept.sort_order ?? 10,
status: (dept.status as "active" | "inactive") ?? "active",
approval_managers: (() => {
const arr = ((dept as any).approval_managers || []).map((m: any) => m.user_id).filter(Boolean);
// 신규 매핑이 비어있고 옛날 단일 컬럼에 값 있으면 seed (PR #19 이전 데이터 호환)
if (arr.length === 0 && dept.approval_manager) return [dept.approval_manager];
return arr;
})(),
dept_managers: (() => {
const arr = ((dept as any).dept_managers || []).map((m: any) => m.user_id).filter(Boolean);
if (arr.length === 0 && dept.dept_manager) return [dept.dept_manager];
return arr;
})(),
org_leaders: ((dept as any).org_leaders || []).map((m: any) => m.user_id).filter(Boolean),
};
setDraft(loaded);
setOriginalDraft(loaded);
@@ -342,6 +374,10 @@ export default function DeptMngListPage() {
sort_order: d.sort_order ?? 10,
status: d.status ?? "active",
location: d.location ?? "",
// 다중 관리자 보존 (서버 응답 형식 그대로 다시 전달)
approval_managers: (d.approval_managers || []).map((m: any) => ({ user_id: typeof m === 'string' ? m : m.user_id })),
dept_managers: (d.dept_managers || []).map((m: any) => ({ user_id: typeof m === 'string' ? m : m.user_id })),
org_leaders: (d.org_leaders || []).map((m: any) => ({ user_id: typeof m === 'string' ? m : m.user_id })),
...overrides,
}), []);
@@ -472,6 +508,14 @@ export default function DeptMngListPage() {
toast({ title: "회사를 선택해주세요", variant: "destructive" });
return;
}
// 시작일/종료일 정합성 검증
if (draft.start_date && draft.end_date && draft.start_date > draft.end_date) {
toast({
title: "시작일은 종료일보다 빠르거나 같아야 합니다",
variant: "destructive",
});
return;
}
// 기본정보 탭 전체 필드를 payload 로 전달 — dept_info 스키마와 1:1 (V019 정리 후)
const payload = {
@@ -491,6 +535,10 @@ export default function DeptMngListPage() {
status: draft.status,
// dept_info 추가 필드 (location 코드만 유지)
location: draft.location,
// 다중 관리자 — backend 가 {user_id} 객체 배열 받음
approval_managers: draft.approval_managers.map((uid) => ({ user_id: uid })),
dept_managers: draft.dept_managers.map((uid) => ({ user_id: uid })),
org_leaders: draft.org_leaders.map((uid) => ({ user_id: uid })),
} as any;
try {
@@ -573,6 +621,251 @@ export default function DeptMngListPage() {
}
};
// ─────────────────────────────────────────────────────
// 일괄등록 / 일괄업데이트 helpers
// ─────────────────────────────────────────────────────
const BULK_HEADERS_CREATE: Record<string, string> = {
"부서명": "dept_name",
"상위부서코드": "parent_dept_code",
"부서유형": "dept_type",
"약칭": "short_name",
"조직체계": "org_system",
"정렬순서": "sort_order",
"사용여부": "status",
"시작일": "start_date",
"종료일": "end_date",
"결재관리자": "approval_managers",
"부서관리자": "dept_managers",
"조직장": "org_leaders",
};
const BULK_HEADERS_UPDATE_DEPT: Record<string, string> = {
"부서코드": "dept_code",
"부서명": "dept_name",
"상위부서코드": "parent_dept_code",
"부서유형": "dept_type",
"약칭": "short_name",
"조직체계": "org_system",
"정렬순서": "sort_order",
"사용여부": "status",
"시작일": "start_date",
"종료일": "end_date",
};
const BULK_HEADERS_UPDATE_MGR: Record<string, string> = {
"부서코드": "dept_code",
"결재관리자": "approval_managers",
"부서관리자": "dept_managers",
"조직장": "org_leaders",
};
const MANAGER_KEYS = new Set(["approval_managers", "dept_managers", "org_leaders"]);
const currentHeaderMap = () =>
bulkTab === "create"
? BULK_HEADERS_CREATE
: bulkUpdateMode === "department"
? BULK_HEADERS_UPDATE_DEPT
: BULK_HEADERS_UPDATE_MGR;
const currentBulkAction = (): departmentAPI.BulkAction =>
bulkTab === "create"
? "create"
: bulkUpdateMode === "department"
? "update_department"
: "update_manager";
const resetBulkData = useCallback(() => {
setBulkRows([]);
setBulkPreviewRows([]);
setBulkSelected(new Set());
setBulkFileName("");
}, []);
const openBulkModal = () => {
if (!selectedCompanyCode) {
toast({ title: "회사를 먼저 선택하세요", variant: "destructive" });
return;
}
setBulkTab("create");
setBulkUpdateMode("department");
resetBulkData();
setBulkOpen(true);
};
/** 엑셀 템플릿 다운로드 — action 별 컬럼 다름. 예시 row 1개 포함 */
const downloadBulkTemplate = () => {
const action = currentBulkAction();
const headerMap =
action === "create"
? BULK_HEADERS_CREATE
: action === "update_department"
? BULK_HEADERS_UPDATE_DEPT
: BULK_HEADERS_UPDATE_MGR;
const columns = Object.keys(headerMap);
const example: Record<string, any> = {};
columns.forEach((c) => {
const snake = headerMap[c];
if (snake === "dept_name") example[c] = "경영지원본부";
else if (snake === "dept_code") example[c] = "DEPT_1";
else if (snake === "dept_type") example[c] = "dept";
else if (snake === "status") example[c] = "active";
else if (snake === "sort_order") example[c] = 10;
else if (MANAGER_KEYS.has(snake)) example[c] = action === "update_manager" ? "user001,user002" : "";
else example[c] = "";
});
const ws = XLSX.utils.json_to_sheet([example], { header: columns });
ws["!cols"] = columns.map(() => ({ wch: 16 }));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "부서");
const fileName =
action === "create"
? "부서_일괄등록_템플릿.xlsx"
: action === "update_department"
? "부서정보_일괄업데이트_템플릿.xlsx"
: "부서관리자_일괄업데이트_템플릿.xlsx";
XLSX.writeFile(wb, fileName);
};
/** 업로드된 xlsx → 한글 헤더를 snake_case 로 매핑 + 매니저 필드는 CSV 분해 */
const handleBulkFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setBulkFileName(file.name);
try {
const buf = await file.arrayBuffer();
const wb = XLSX.read(buf, { type: "array" });
const ws = wb.Sheets[wb.SheetNames[0]];
const raw = XLSX.utils.sheet_to_json<Record<string, any>>(ws, { defval: "" });
const headerMap = currentHeaderMap();
const rows = raw
.map((row) => {
const out: Record<string, any> = {};
for (const [korean, snake] of Object.entries(headerMap)) {
const v = row[korean];
if (v === undefined || v === null || v === "") continue;
if (MANAGER_KEYS.has(snake)) {
const ids = String(v).split(/[,;]/).map((s) => s.trim()).filter(Boolean);
if (ids.length > 0) out[snake] = ids;
} else if (snake === "sort_order") {
const n = Number(v);
if (!Number.isNaN(n)) out[snake] = n;
} else {
out[snake] = String(v).trim();
}
}
return out;
})
.filter((r) => Object.keys(r).length > 0);
setBulkRows(rows);
setBulkPreviewRows([]);
setBulkSelected(new Set());
toast({ title: `${rows.length}건 로드됨`, description: "[미리보기] 를 눌러 검증하세요." });
} catch (err: any) {
toast({ title: "파일 읽기 실패", description: err.message || String(err), variant: "destructive" });
} finally {
// 동일 파일 재선택 가능하도록
e.target.value = "";
}
};
const handleBulkPreview = async () => {
if (bulkRows.length === 0) return;
setBulkBusy(true);
try {
const res = await departmentAPI.bulkPreviewDepartments(selectedCompanyCode, currentBulkAction(), bulkRows);
if (res.success && (res as any).data) {
const rows: departmentAPI.BulkPreviewRow[] = (res as any).data.rows;
setBulkPreviewRows(rows);
// 기본: ok 인 row 만 선택
setBulkSelected(new Set(rows.filter((r) => r.result === "ok").map((r) => r.row_index)));
} else {
toast({
title: "미리보기 실패",
description: (res as any).error || (res as any).message || "오류",
variant: "destructive",
});
}
} finally {
setBulkBusy(false);
}
};
const handleBulkApply = async () => {
const okSelected = bulkPreviewRows.filter(
(r) => bulkSelected.has(r.row_index) && r.result === "ok",
);
if (okSelected.length === 0) {
toast({ title: "반영할 정상 행이 없습니다", variant: "destructive" });
return;
}
const payload = okSelected.map((r) => {
const { row_index, result, error_detail, ...rest } = r as any;
return rest as Record<string, any>;
});
setBulkBusy(true);
try {
const res =
bulkTab === "create"
? await departmentAPI.bulkCreateDepartments(selectedCompanyCode, payload)
: await departmentAPI.bulkUpdateDepartments(selectedCompanyCode, bulkUpdateMode, payload);
if (res.success) {
const count =
(res as any).data?.inserted ?? (res as any).data?.updated ?? payload.length;
toast({
title: bulkTab === "create" ? "일괄등록 완료" : "일괄업데이트 완료",
description: `${count}건 처리됨`,
});
setBulkOpen(false);
resetBulkData();
await loadDepartments();
} else {
toast({
title: bulkTab === "create" ? "일괄등록 실패" : "일괄업데이트 실패",
description: (res as any).error || (res as any).message || "오류",
variant: "destructive",
});
}
} finally {
setBulkBusy(false);
}
};
const previewColumns = useMemo(() => {
if (bulkTab === "create") {
return [
{ key: "dept_name", label: "부서명" },
{ key: "parent_dept_code", label: "상위부서코드" },
{ key: "dept_type", label: "유형" },
{ key: "sort_order", label: "순서" },
{ key: "approval_managers", label: "결재관리자", manager: true },
{ key: "dept_managers", label: "부서관리자", manager: true },
{ key: "org_leaders", label: "조직장", manager: true },
];
}
if (bulkUpdateMode === "department") {
return [
{ key: "dept_code", label: "부서코드" },
{ key: "dept_name", label: "부서명" },
{ key: "parent_dept_code", label: "상위부서코드" },
{ key: "dept_type", label: "유형" },
{ key: "sort_order", label: "순서" },
];
}
return [
{ key: "dept_code", label: "부서코드" },
{ key: "approval_managers", label: "결재관리자", manager: true },
{ key: "dept_managers", label: "부서관리자", manager: true },
{ key: "org_leaders", label: "조직장", manager: true },
];
}, [bulkTab, bulkUpdateMode]);
const bulkOkCount = bulkPreviewRows.filter((r) => r.result === "ok").length;
const bulkErrCount = bulkPreviewRows.length - bulkOkCount;
const allOkSelected =
bulkOkCount > 0 &&
bulkPreviewRows
.filter((r) => r.result === "ok")
.every((r) => bulkSelected.has(r.row_index));
const isDirty = originalDraft
? JSON.stringify(originalDraft) !== JSON.stringify(draft)
: isNewMode && (draft.dept_name.trim() !== "" || draft.parent_dept_code !== null);
@@ -598,14 +891,7 @@ export default function DeptMngListPage() {
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={() => {
if (!selectedCompanyCode) {
toast({ title: "회사를 먼저 선택하세요", variant: "destructive" });
return;
}
setBulkText("");
setBulkOpen(true);
}}
onClick={openBulkModal}
>
<Upload className="h-3.5 w-3.5" />
@@ -645,33 +931,30 @@ export default function DeptMngListPage() {
<aside className="flex w-[340px] shrink-0 flex-col border-r">
{/* 기준일 / 회사 / 검색 */}
<div className="space-y-3 border-b p-3">
{/* TODO V2: 사용기간 필터 — backend 미구현, V1 hidden */}
{false && (
<div className="flex items-center gap-3">
<Label className="w-[60px] shrink-0 text-xs font-semibold"></Label>
<RadioGroup
value={periodMode}
onValueChange={(v) => setPeriodMode(v as "all" | "date")}
className="flex items-center gap-3"
>
<div className="flex items-center gap-1">
<RadioGroupItem value="all" id="period-all" className="h-3.5 w-3.5" />
<Label htmlFor="period-all" className="text-xs"></Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem value="date" id="period-date" className="h-3.5 w-3.5" />
<Label htmlFor="period-date" className="text-xs"></Label>
</div>
</RadioGroup>
<Input
type="date"
value={baseDate}
onChange={(e) => setBaseDate(e.target.value)}
disabled={periodMode !== "date"}
className="h-7 flex-1 text-xs"
/>
</div>
)}
<div className="flex items-center gap-3">
<Label className="w-[60px] shrink-0 text-xs font-semibold"></Label>
<RadioGroup
value={periodMode}
onValueChange={(v) => setPeriodMode(v as "all" | "date")}
className="flex items-center gap-3"
>
<div className="flex items-center gap-1">
<RadioGroupItem value="all" id="period-all" className="h-3.5 w-3.5" />
<Label htmlFor="period-all" className="text-xs"></Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem value="date" id="period-date" className="h-3.5 w-3.5" />
<Label htmlFor="period-date" className="text-xs"></Label>
</div>
</RadioGroup>
<Input
type="date"
value={baseDate}
onChange={(e) => setBaseDate(e.target.value)}
disabled={periodMode !== "date"}
className="h-7 flex-1 text-xs"
/>
</div>
<Select
value={selectedCompanyCode}
@@ -978,106 +1261,229 @@ export default function DeptMngListPage() {
title={moveTargetDept ? `"${moveTargetDept.dept_name}" — 새 상위 부서 선택` : "부서 선택"}
/>
{/* 일괄등록 */}
{/* 일괄등록 / 일괄업데이트 */}
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
<DialogContent className="max-w-[640px]">
<DialogContent className="flex max-h-[88vh] max-w-[1040px] flex-col">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogTitle> / </DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
<p className="mb-1.5 font-semibold">CSV </p>
<p className="text-muted-foreground">
: <code className="rounded bg-background px-1 py-0.5 font-mono">,,(dept|team|temp)</code>
</p>
<p className="mt-1 text-muted-foreground"> (DEPT_n).</p>
<p className="mt-1 text-muted-foreground">: <code className="rounded bg-background px-1 py-0.5 font-mono">,,dept</code></p>
</div>
<textarea
value={bulkText}
onChange={(e) => setBulkText(e.target.value)}
placeholder={"경영지원본부,,dept\n인사팀,DEPT_1,team"}
className="h-48 w-full resize-none rounded-md border bg-background p-2 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkOpen(false)}></Button>
<Button
disabled={bulkUploading || !bulkText.trim()}
onClick={async () => {
const lines = bulkText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
if (lines.length === 0) return;
setBulkUploading(true);
const failures: { line: number; deptName: string; reason: string }[] = [];
let success = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const cols = line.split(",").map((c) => c.trim());
const [dept_name, parent, dept_type] = cols;
if (!dept_name) {
failures.push({ line: i + 1, deptName: "(빈 줄)", reason: "부서명 필수" });
continue;
}
try {
const res = await departmentAPI.createDepartment(selectedCompanyCode, {
dept_name,
parent_dept_code: parent || null,
dept_type: (dept_type || "dept") as any,
} as any);
if (res.success) success++;
else failures.push({ line: i + 1, deptName: dept_name, reason: (res as any).error || "알 수 없는 오류" });
} catch (e: any) {
failures.push({ line: i + 1, deptName: dept_name, reason: e?.message || "예외 발생" });
}
}
setBulkUploading(false);
toast({
title: `일괄등록 완료`,
description: `성공 ${success}건 / 실패 ${failures.length}`,
variant: failures.length > 0 ? "destructive" : "default",
});
if (failures.length > 0) {
setBulkFailures(failures);
} else {
setBulkOpen(false);
}
await loadDepartments();
}}
>
{bulkUploading ? "등록 중..." : "등록"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 일괄등록 실패 결과 */}
<Dialog open={bulkFailures.length > 0} onOpenChange={(o) => !o && setBulkFailures([])}>
<DialogContent className="max-w-[640px]">
<DialogHeader>
<DialogTitle> ({bulkFailures.length})</DialogTitle>
</DialogHeader>
<div className="max-h-[480px] overflow-y-auto rounded-md border bg-muted/30">
<table className="w-full text-xs">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-semibold w-16"></th>
<th className="px-3 py-2 text-left font-semibold"></th>
<th className="px-3 py-2 text-left font-semibold"></th>
</tr>
</thead>
<tbody className="divide-y">
{bulkFailures.map((f, idx) => (
<tr key={idx}>
<td className="px-3 py-1.5 font-mono">{f.line}</td>
<td className="px-3 py-1.5">{f.deptName}</td>
<td className="px-3 py-1.5 text-destructive">{f.reason}</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setBulkFailures([]); setBulkOpen(false); }}></Button>
<Tabs
value={bulkTab}
onValueChange={(v) => {
setBulkTab(v as "create" | "update");
resetBulkData();
}}
className="flex min-h-0 flex-1 flex-col"
>
<TabsList className="mb-2">
<TabsTrigger value="create"></TabsTrigger>
<TabsTrigger value="update"></TabsTrigger>
</TabsList>
<TabsContent value="create" className="m-0 space-y-2">
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
<p className="mb-1 font-semibold"> </p>
<ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
<li>[ 릿] .</li>
<li> [] [].</li>
<li> (DEPT_n).</li>
<li> user_id (,) . 10/role.</li>
<li> 1000 .</li>
</ul>
</div>
</TabsContent>
<TabsContent value="update" className="m-0 space-y-2">
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
<p className="mb-1 font-semibold"> </p>
<ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
<li> <code className="rounded bg-background px-1 font-mono">(dept_code)</code> .</li>
<li><b> </b>: /// . .</li>
<li><b> </b>: // . role .</li>
</ul>
</div>
<div className="flex items-center gap-3 px-1">
<Label className="text-xs font-semibold"> </Label>
<RadioGroup
value={bulkUpdateMode}
onValueChange={(v) => {
setBulkUpdateMode(v as "department" | "manager");
resetBulkData();
}}
className="flex items-center gap-4"
>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="department" id="bulk-mode-dept" className="h-3.5 w-3.5" />
<Label htmlFor="bulk-mode-dept" className="cursor-pointer text-xs"> </Label>
</div>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="manager" id="bulk-mode-mgr" className="h-3.5 w-3.5" />
<Label htmlFor="bulk-mode-mgr" className="cursor-pointer text-xs"> </Label>
</div>
</RadioGroup>
</div>
</TabsContent>
{/* 회사 + 파일 선택 (탭 공통) */}
<div className="mt-2 space-y-2 rounded-md border p-3">
<div className="grid grid-cols-[100px_1fr_auto] items-center gap-3">
<Label className="text-xs font-semibold"> </Label>
<div className="text-xs">
<span className="font-mono">{selectedCompanyCode}</span>
{selectedCompany?.company_name && (
<span className="ml-2 text-muted-foreground">{selectedCompany.company_name}</span>
)}
</div>
<Button variant="outline" size="sm" className="h-7 gap-1.5 text-xs" onClick={downloadBulkTemplate}>
<FileDown className="h-3.5 w-3.5" />
릿
</Button>
</div>
<div className="grid grid-cols-[100px_1fr] items-center gap-3">
<Label className="text-xs font-semibold"> </Label>
<div className="flex items-center gap-2">
<Input
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleBulkFile}
className="h-8 cursor-pointer text-xs file:mr-2 file:rounded file:border-0 file:bg-muted file:px-2 file:py-1 file:text-xs"
/>
{bulkFileName && (
<span className="shrink-0 text-xs text-muted-foreground">
{bulkRows.length}
</span>
)}
</div>
</div>
</div>
{/* 미리보기 테이블 */}
<div className="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border">
<div className="flex items-center justify-between bg-muted/40 px-3 py-1.5 text-xs">
<span className="font-semibold">
({bulkSelected.size}/{bulkPreviewRows.length})
</span>
{bulkPreviewRows.length > 0 && (
<span className="text-muted-foreground">
<span className="text-emerald-600 dark:text-emerald-400"> {bulkOkCount}</span>
{" / "}
<span className="text-destructive"> {bulkErrCount}</span>
</span>
)}
</div>
<div className="min-h-[200px] flex-1 overflow-auto">
{bulkPreviewRows.length === 0 ? (
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-2 text-xs text-muted-foreground">
<FileDown className="h-6 w-6 opacity-30" />
<p>{bulkRows.length === 0 ? "엑셀 파일을 업로드하세요" : "[미리보기] 버튼을 눌러 검증하세요"}</p>
</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 z-10 bg-muted/60">
<tr>
<th className="w-9 px-2 py-1.5">
<Checkbox
checked={allOkSelected}
onCheckedChange={(c) => {
if (c) {
setBulkSelected(
new Set(
bulkPreviewRows.filter((r) => r.result === "ok").map((r) => r.row_index),
),
);
} else {
setBulkSelected(new Set());
}
}}
/>
</th>
{previewColumns.map((c) => (
<th key={c.key} className="px-2 py-1.5 text-left font-semibold">{c.label}</th>
))}
<th className="w-16 px-2 py-1.5 text-left font-semibold"></th>
<th className="px-2 py-1.5 text-left font-semibold"></th>
</tr>
</thead>
<tbody className="divide-y">
{bulkPreviewRows.map((r) => {
const isErr = r.result === "error";
return (
<tr
key={r.row_index}
className={cn("hover:bg-muted/30", isErr && "bg-destructive/5")}
>
<td className="px-2 py-1.5">
<Checkbox
disabled={isErr}
checked={bulkSelected.has(r.row_index)}
onCheckedChange={(c) => {
setBulkSelected((prev) => {
const next = new Set(prev);
if (c) next.add(r.row_index);
else next.delete(r.row_index);
return next;
});
}}
/>
</td>
{previewColumns.map((c) => {
const v = (r as any)[c.key];
const display = (c as any).manager
? Array.isArray(v) && v.length > 0 ? v.join(", ") : "-"
: v != null && v !== "" ? String(v) : "-";
return (
<td key={c.key} className="max-w-[180px] truncate px-2 py-1.5" title={display}>
{display}
</td>
);
})}
<td className="px-2 py-1.5">
{isErr ? (
<Badge variant="destructive" className="gap-1 text-[10px]">
<XCircle className="h-3 w-3" />
</Badge>
) : (
<Badge className="gap-1 border-emerald-500/30 bg-emerald-500/15 text-[10px] text-emerald-700 hover:bg-emerald-500/20 dark:text-emerald-300">
<CheckCircle2 className="h-3 w-3" />
</Badge>
)}
</td>
<td
className="max-w-[300px] truncate px-2 py-1.5 text-destructive"
title={r.error_detail || ""}
>
{r.error_detail || ""}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
</Tabs>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setBulkOpen(false)} disabled={bulkBusy}>
</Button>
<Button
variant="outline"
onClick={handleBulkPreview}
disabled={bulkBusy || bulkRows.length === 0}
>
{bulkBusy ? "검증 중..." : "미리보기"}
</Button>
<Button
onClick={handleBulkApply}
disabled={bulkBusy || bulkSelected.size === 0}
>
{bulkBusy ? "처리 중..." : `반영 (${bulkSelected.size}건)`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1366,17 +1772,27 @@ function BasicInfoForm({
</Row>
<Row label="결재 관리자" hint>
<PickerField
value={draft.approval_manager}
onChange={(v) => update("approval_manager", v)}
placeholder="사용자 이름을 입력해주세요."
<ManagerChipsField
userIds={draft.approval_managers}
onChange={(ids) => update("approval_managers", ids)}
companyCode={draft.company_code}
max={10}
/>
</Row>
<Row label="부서 관리자">
<PickerField
value={draft.dept_manager}
onChange={(v) => update("dept_manager", v)}
placeholder="사용자 이름을 입력해주세요."
<ManagerChipsField
userIds={draft.dept_managers}
onChange={(ids) => update("dept_managers", ids)}
companyCode={draft.company_code}
max={10}
/>
</Row>
<Row label="조직장" hint>
<ManagerChipsField
userIds={draft.org_leaders}
onChange={(ids) => update("org_leaders", ids)}
companyCode={draft.company_code}
max={10}
/>
</Row>
@@ -1423,28 +1839,25 @@ function BasicInfoForm({
</RadioGroup>
</Row>
{/* TODO V2: 사용기간 (시작일/종료일) — 필터 도입 시 사용. 컬럼은 DEPT_INFO.START_DATE/END_DATE 유지 */}
{false && (
<Row label="시작일">
<div className="grid grid-cols-2 gap-3">
<Row label="시작일">
<div className="grid grid-cols-2 gap-3">
<Input
type="date"
value={draft.start_date}
onChange={(e) => update("start_date", e.target.value)}
className="h-8 text-sm"
/>
<div className="flex items-center gap-2">
<Label className="w-[50px] text-xs"></Label>
<Input
type="date"
value={draft.start_date}
onChange={(e) => update("start_date", e.target.value)}
className="h-8 text-sm"
value={draft.end_date}
onChange={(e) => update("end_date", e.target.value)}
className="h-8 flex-1 text-sm"
/>
<div className="flex items-center gap-2">
<Label className="w-[50px] text-xs"></Label>
<Input
type="date"
value={draft.end_date}
onChange={(e) => update("end_date", e.target.value)}
className="h-8 flex-1 text-sm"
/>
</div>
</div>
</Row>
)}
</div>
</Row>
<Row label="정렬">
<Input
@@ -1518,6 +1931,99 @@ function PickerField({
);
}
function ManagerChipsField({
userIds,
onChange,
companyCode,
max,
}: {
userIds: string[];
onChange: (ids: string[]) => void;
companyCode: string;
max: number;
}) {
const [pickerOpen, setPickerOpen] = useState(false);
const [resolvedNames, setResolvedNames] = useState<Record<string, string>>({});
useEffect(() => {
const unknown = userIds.filter((id) => !resolvedNames[id]);
if (unknown.length === 0 || !companyCode) return;
let cancelled = false;
(async () => {
const updates: Record<string, string> = {};
for (const id of unknown) {
try {
const res = await departmentAPI.searchUsers(companyCode, id);
if (res.success && Array.isArray((res as any).data)) {
const found = (res as any).data.find((u: any) => u.user_id === id);
if (found) updates[id] = found.user_name || id;
}
} catch { /* ignore */ }
}
if (!cancelled && Object.keys(updates).length > 0) {
setResolvedNames((prev) => ({ ...prev, ...updates }));
}
})();
return () => { cancelled = true; };
}, [userIds, companyCode]);
const handleRemove = (id: string) => onChange(userIds.filter((x) => x !== id));
const handleAdd = (id: string) => {
if (userIds.includes(id)) return;
if (userIds.length >= max) return;
onChange([...userIds, id]);
};
return (
<>
<div className="flex flex-wrap items-center gap-1.5">
{userIds.map((id) => (
<div
key={id}
className="flex items-center gap-1 rounded-md border bg-muted/50 px-2 py-0.5 text-xs"
>
<span className="font-medium">{resolvedNames[id] || id}</span>
<span className="text-muted-foreground text-[10px]">({id})</span>
<button
type="button"
onClick={() => handleRemove(id)}
className="text-muted-foreground hover:text-destructive ml-0.5"
title="제거"
>
<X className="h-3 w-3" />
</button>
</div>
))}
{userIds.length < max && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() => setPickerOpen(true)}
disabled={!companyCode}
>
<Plus className="h-3 w-3" />
</Button>
)}
{userIds.length >= max && (
<span className="text-muted-foreground text-[10px]"> {max}</span>
)}
</div>
<UserSearchModal
open={pickerOpen}
companyCode={companyCode}
existingMemberIds={new Set(userIds)}
onAdd={async (userId) => {
handleAdd(userId);
}}
onClose={() => setPickerOpen(false)}
/>
</>
);
}
// ───────────────────────────────────────────────────────
// 사용자 검색 모달
// ───────────────────────────────────────────────────────
@@ -442,11 +442,8 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
const isInputType =
compType?.includes("input") ||
compType?.includes("select") ||
compType?.includes("textarea") ||
compType?.includes("v2-input") ||
compType?.includes("v2-select") ||
compType?.includes("v2-media") ||
compType?.includes("file-upload");
compType?.includes("textarea");
// (옛 v2-media / file-upload 매칭은 Phase D.5 에서 제거 — canonical input 의 includes("input") 으로 포함)
const hasColumnName = !!(comp as any).columnName;
return isInputType && hasColumnName;
});
@@ -1,389 +0,0 @@
"use client";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { ValidationMessage } from "@/components/common/ValidationMessage";
import { useCreateCategory, useUpdateCategory } from "@/hooks/queries/useCategories";
import type { CodeCategory } from "@/types/commonCode";
import { useCheckCategoryDuplicate } from "@/hooks/queries/useValidation";
import { useState } from "react";
import {
createCategorySchema,
updateCategorySchema,
type CreateCategoryData,
type UpdateCategoryData,
} from "@/lib/schemas/commonCode";
interface CodeCategoryFormModalProps {
isOpen: boolean;
onClose: () => void;
editingCategoryCode?: string;
categories: CodeCategory[];
}
export function CodeCategoryFormModal({
isOpen,
onClose,
editingCategoryCode,
categories,
}: CodeCategoryFormModalProps) {
const createCategoryMutation = useCreateCategory();
const updateCategoryMutation = useUpdateCategory();
const isEditing = !!editingCategoryCode;
const editingCategory = categories.find((c) => c.category_code === editingCategoryCode);
// 생성과 수정을 위한 별도 폼 설정
const createForm = useForm<CreateCategoryData>({
resolver: zodResolver(createCategorySchema),
mode: "onChange",
defaultValues: {
categoryCode: "",
categoryName: "",
categoryNameEng: "",
description: "",
sortOrder: 1,
},
});
const updateForm = useForm<UpdateCategoryData>({
resolver: zodResolver(updateCategorySchema),
mode: "onChange",
defaultValues: {
categoryName: "",
categoryNameEng: "",
description: "",
sortOrder: 1,
isActive: "Y",
},
});
// 필드 검증 상태 관리
const [validatedFields, setValidatedFields] = useState<Set<string>>(new Set());
// 필드 검증 처리 함수
const handleFieldBlur = (fieldName: string) => {
setValidatedFields((prev) => new Set(prev).add(fieldName));
};
// 중복 검사 훅들
const categoryCodeCheck = useCheckCategoryDuplicate(
"categoryCode",
isEditing ? "" : createForm.watch("categoryCode"),
isEditing ? editingCategoryCode : undefined,
validatedFields.has("categoryCode"),
);
const categoryNameCheck = useCheckCategoryDuplicate(
"categoryName",
isEditing ? updateForm.watch("categoryName") : createForm.watch("categoryName"),
isEditing ? editingCategoryCode : undefined,
validatedFields.has("categoryName"),
);
const categoryNameEngCheck = useCheckCategoryDuplicate(
"categoryNameEng",
isEditing ? updateForm.watch("categoryNameEng") : createForm.watch("categoryNameEng"),
isEditing ? editingCategoryCode : undefined,
validatedFields.has("categoryNameEng"),
);
// 중복 검사 결과 확인 (수정 시에는 카테고리 코드 검사 제외)
const hasDuplicateErrors =
(!isEditing && categoryCodeCheck.data?.isDuplicate && validatedFields.has("categoryCode")) ||
(categoryNameCheck.data?.isDuplicate && validatedFields.has("categoryName")) ||
(categoryNameEngCheck.data?.isDuplicate && validatedFields.has("categoryNameEng"));
// 중복 검사 로딩 중인지 확인 (수정 시에는 카테고리 코드 검사 제외)
const isDuplicateChecking =
(!isEditing && categoryCodeCheck.isLoading) || categoryNameCheck.isLoading || categoryNameEngCheck.isLoading;
// 폼은 조건부로 직접 사용
// 편집 모드일 때 기존 데이터 로드
useEffect(() => {
if (isOpen) {
// 검증 상태 초기화
setValidatedFields(new Set());
if (isEditing && editingCategory) {
// 수정 모드: 기존 데이터 로드
updateForm.reset({
categoryName: editingCategory.category_name,
categoryNameEng: editingCategory.category_name_eng || "",
description: editingCategory.description || "",
sortOrder: editingCategory.sort_order,
isActive: editingCategory.is_active as "Y" | "N", // 타입 안전한 캐스팅
});
} else {
// 새 카테고리 모드: 자동 순서 계산
const maxSortOrder = categories.length > 0 ? Math.max(...categories.map((c) => c.sort_order)) : 0;
createForm.reset({
categoryCode: "",
categoryName: "",
categoryNameEng: "",
description: "",
sortOrder: maxSortOrder + 1,
});
}
}
}, [isOpen, isEditing, editingCategory, categories, createForm, updateForm]);
const handleSubmit = isEditing
? updateForm.handleSubmit(async (data) => {
try {
await updateCategoryMutation.mutateAsync({
categoryCode: editingCategoryCode!,
data: data as UpdateCategoryData,
});
onClose();
updateForm.reset();
} catch (error) {
console.error("카테고리 수정 실패:", error);
}
})
: createForm.handleSubmit(async (data) => {
try {
await createCategoryMutation.mutateAsync(data as CreateCategoryData);
onClose();
createForm.reset();
} catch (error) {
console.error("카테고리 생성 실패:", error);
}
});
const isLoading = createCategoryMutation.isPending || updateCategoryMutation.isPending;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">{isEditing ? "카테고리 수정" : "새 카테고리"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
{/* 카테고리 코드 */}
{!isEditing && (
<div className="space-y-2">
<Label htmlFor="categoryCode" className="text-xs sm:text-sm"> *</Label>
<Input
id="categoryCode"
{...createForm.register("categoryCode")}
disabled={isLoading}
placeholder="카테고리 코드를 입력하세요"
className={createForm.formState.errors.categoryCode ? "h-8 text-xs sm:h-10 sm:text-sm border-destructive" : "h-8 text-xs sm:h-10 sm:text-sm"}
onBlur={() => handleFieldBlur("categoryCode")}
/>
{createForm.formState.errors.categoryCode && (
<p className="text-[10px] sm:text-xs text-destructive">{createForm.formState.errors.categoryCode.message}</p>
)}
{!createForm.formState.errors.categoryCode && (
<ValidationMessage
message={categoryCodeCheck.data?.message}
isValid={!categoryCodeCheck.data?.isDuplicate}
isLoading={categoryCodeCheck.isLoading}
/>
)}
</div>
)}
{/* 카테고리 코드 표시 (수정 시) */}
{isEditing && editingCategory && (
<div className="space-y-2">
<Label htmlFor="categoryCodeDisplay" className="text-xs sm:text-sm"> </Label>
<Input id="categoryCodeDisplay" value={editingCategory.category_code} disabled className="h-8 text-xs sm:h-10 sm:text-sm bg-muted cursor-not-allowed" />
<p className="text-[10px] sm:text-xs text-muted-foreground"> .</p>
</div>
)}
{/* 카테고리명 */}
<div className="space-y-2">
<Label htmlFor="categoryName"> *</Label>
<Input
id="categoryName"
{...(isEditing ? updateForm.register("categoryName") : createForm.register("categoryName"))}
disabled={isLoading}
placeholder="카테고리명을 입력하세요"
className={
isEditing
? updateForm.formState.errors.categoryName
? "border-destructive"
: ""
: createForm.formState.errors.categoryName
? "border-destructive"
: ""
}
onBlur={() => handleFieldBlur("categoryName")}
/>
{isEditing
? updateForm.formState.errors.categoryName && (
<p className="text-sm text-destructive">{updateForm.formState.errors.categoryName.message}</p>
)
: createForm.formState.errors.categoryName && (
<p className="text-sm text-destructive">{createForm.formState.errors.categoryName.message}</p>
)}
{!(isEditing ? updateForm.formState.errors.categoryName : createForm.formState.errors.categoryName) && (
<ValidationMessage
message={categoryNameCheck.data?.message}
isValid={!categoryNameCheck.data?.isDuplicate}
isLoading={categoryNameCheck.isLoading}
/>
)}
</div>
{/* 영문명 */}
<div className="space-y-2">
<Label htmlFor="categoryNameEng"> *</Label>
<Input
id="categoryNameEng"
{...(isEditing ? updateForm.register("categoryNameEng") : createForm.register("categoryNameEng"))}
disabled={isLoading}
placeholder="카테고리 영문명을 입력하세요"
className={
isEditing
? updateForm.formState.errors.categoryNameEng
? "border-destructive"
: ""
: createForm.formState.errors.categoryNameEng
? "border-destructive"
: ""
}
onBlur={() => handleFieldBlur("categoryNameEng")}
/>
{isEditing
? updateForm.formState.errors.categoryNameEng && (
<p className="text-sm text-destructive">{updateForm.formState.errors.categoryNameEng.message}</p>
)
: createForm.formState.errors.categoryNameEng && (
<p className="text-sm text-destructive">{createForm.formState.errors.categoryNameEng.message}</p>
)}
{!(isEditing
? updateForm.formState.errors.categoryNameEng
: createForm.formState.errors.categoryNameEng) && (
<ValidationMessage
message={categoryNameEngCheck.data?.message}
isValid={!categoryNameEngCheck.data?.isDuplicate}
isLoading={categoryNameEngCheck.isLoading}
/>
)}
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description"> *</Label>
<Textarea
id="description"
{...(isEditing ? updateForm.register("description") : createForm.register("description"))}
disabled={isLoading}
placeholder="설명을 입력하세요"
rows={3}
className={
isEditing
? updateForm.formState.errors.description
? "border-destructive"
: ""
: createForm.formState.errors.description
? "border-destructive"
: ""
}
onBlur={() => handleFieldBlur("description")}
/>
{isEditing
? updateForm.formState.errors.description && (
<p className="text-sm text-destructive">{updateForm.formState.errors.description.message}</p>
)
: createForm.formState.errors.description && (
<p className="text-sm text-destructive">{createForm.formState.errors.description.message}</p>
)}
</div>
{/* 정렬 순서 */}
<div className="space-y-2">
<Label htmlFor="sortOrder"> </Label>
<Input
id="sortOrder"
type="number"
{...(isEditing
? updateForm.register("sortOrder", { valueAsNumber: true })
: createForm.register("sortOrder", { valueAsNumber: true }))}
disabled={isLoading}
min={1}
className={
isEditing
? updateForm.formState.errors.sortOrder
? "border-destructive"
: ""
: createForm.formState.errors.sortOrder
? "border-destructive"
: ""
}
/>
{isEditing
? updateForm.formState.errors.sortOrder && (
<p className="text-sm text-destructive">{updateForm.formState.errors.sortOrder.message}</p>
)
: createForm.formState.errors.sortOrder && (
<p className="text-sm text-destructive">{createForm.formState.errors.sortOrder.message}</p>
)}
</div>
{/* 활성 상태 (수정 시에만) */}
{isEditing && (
<div className="flex items-center space-x-2">
<Switch
id="isActive"
checked={updateForm.watch("isActive") === "Y"}
onCheckedChange={(checked) => updateForm.setValue("isActive", checked ? "Y" : "N")}
disabled={isLoading}
/>
<Label htmlFor="isActive">{updateForm.watch("isActive") === "Y" ? "활성" : "비활성"}</Label>
</div>
)}
{/* 버튼 */}
<div className="flex gap-2 pt-4 sm:justify-end sm:gap-0">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isLoading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
type="submit"
disabled={
isLoading ||
!(isEditing ? updateForm.formState.isValid : createForm.formState.isValid) ||
hasDuplicateErrors ||
isDuplicateChecking
}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isLoading ? (
<>
<LoadingSpinner size="sm" className="mr-2" />
{isEditing ? "수정 중..." : "저장 중..."}
</>
) : isEditing ? (
"카테고리 수정"
) : (
"카테고리 저장"
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
+190 -271
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -9,226 +9,167 @@ import { CodeFormModal } from "./CodeFormModal";
import { SortableCodeItem } from "./SortableCodeItem";
import { AlertModal } from "@/components/common/AlertModal";
import { Search, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { useDeleteCode, useReorderCodes } from "@/hooks/queries/useCodes";
import { useCodesInfinite } from "@/hooks/queries/useCodesInfinite";
import type { CodeInfo } from "@/types/commonCode";
// Drag and Drop
import { useDeleteCodeDetail, useUpdateCodeDetail, useCodeDetailTree } from "@/hooks/queries/useCodeDetail";
import type { CodeDetail } from "@/types/commonCode";
import { DndContext, DragOverlay } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { SortableContext, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
import { useDragAndDrop } from "@/hooks/useDragAndDrop";
import { useSearchAndFilter } from "@/hooks/useSearchAndFilter";
interface CodeDetailPanelProps {
categoryCode: string;
codeInfo: string;
}
export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
// 검색 및 필터 상태 (먼저 선언)
/**
* .
*
* BE `/api/common-codes/detail?code_info=X` depth+sort_order .
* FE parent_detail_id children indent + collapse .
* sort_order reorder update PUT .
*/
export function CodeDetailPanel({ codeInfo }: CodeDetailPanelProps) {
const [searchTerm, setSearchTerm] = useState("");
const [showActiveOnly, setShowActiveOnly] = useState(false);
// React Query로 코드 데이터 관리 (무한 스크롤)
const {
data: codes = [],
isLoading,
error,
handleScroll,
isFetchingNextPage,
hasNextPage,
} = useCodesInfinite(categoryCode, {
const treeQuery = useCodeDetailTree(codeInfo || undefined, {
search: searchTerm || undefined,
active: showActiveOnly || undefined,
});
const deleteCodeMutation = useDeleteCode();
const reorderCodesMutation = useReorderCodes();
const rows: CodeDetail[] = useMemo(() => treeQuery.data || [], [treeQuery.data]);
const isLoading = treeQuery.isLoading;
const error = treeQuery.error;
// 드래그앤드롭을 위해 필터링된 코드 목록 사용
const { filteredItems: filteredCodesRaw } = useSearchAndFilter(codes, {
searchFields: ["code_name", "code_value"],
});
const deleteMutation = useDeleteCodeDetail();
const updateMutation = useUpdateCodeDetail();
// 계층 구조로 정렬 (부모 → 자식 순서)
const filteredCodes = useMemo(() => {
if (!filteredCodesRaw || filteredCodesRaw.length === 0) return [];
// 코드를 계층 순서로 정렬하는 함수
const sortHierarchically = (codes: CodeInfo[]): CodeInfo[] => {
const result: CodeInfo[] = [];
const codeMap = new Map<string, CodeInfo>();
const childrenMap = new Map<string, CodeInfo[]>();
// 코드 맵 생성
codes.forEach((code) => {
const codeValue = code.code_value || "";
const parentValue = code.parent_code_value;
codeMap.set(codeValue, code);
if (parentValue) {
if (!childrenMap.has(parentValue)) {
childrenMap.set(parentValue, []);
}
childrenMap.get(parentValue)!.push(code);
}
});
// 재귀적으로 트리 구조 순회
const traverse = (parentValue: string | null, depth: number) => {
const children = parentValue
? childrenMap.get(parentValue) || []
: codes.filter((c) => !c.parent_code_value);
// 정렬 순서로 정렬
children
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0))
.forEach((code) => {
result.push(code);
const codeValue = code.code_value || "";
traverse(codeValue, depth + 1);
});
};
traverse(null, 1);
// 트리에 포함되지 않은 코드들도 추가 (orphan 코드)
codes.forEach((code) => {
if (!result.includes(code)) {
result.push(code);
}
});
return result;
};
return sortHierarchically(filteredCodesRaw);
}, [filteredCodesRaw]);
// 모달 상태
const [showFormModal, setShowFormModal] = useState(false);
const [editingCode, setEditingCode] = useState<CodeInfo | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deletingCode, setDeletingCode] = useState<CodeInfo | null>(null);
const [defaultParentCode, setDefaultParentCode] = useState<string | undefined>(undefined);
// 트리 접기/펼치기 상태 (코드값 Set)
const [collapsedCodes, setCollapsedCodes] = useState<Set<string>>(new Set());
// 자식 정보 계산
/** parent_detail_id → children */
const childrenMap = useMemo(() => {
const map = new Map<string, CodeInfo[]>();
codes.forEach((code) => {
const parentValue = code.parent_code_value;
if (parentValue) {
if (!map.has(parentValue)) {
map.set(parentValue, []);
}
map.get(parentValue)!.push(code);
}
});
const map = new Map<string, CodeDetail[]>();
for (const row of rows) {
const key = row.parent_detail_id == null ? "ROOT" : String(row.parent_detail_id);
const list = map.get(key) || [];
list.push(row);
map.set(key, list);
}
return map;
}, [codes]);
}, [rows]);
// 접기/펼치기 토글
const toggleExpand = (codeValue: string) => {
setCollapsedCodes((prev) => {
const newSet = new Set(prev);
if (newSet.has(codeValue)) {
newSet.delete(codeValue);
} else {
newSet.add(codeValue);
}
return newSet;
// 접기/펼치기 (code_detail_id Set)
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const toggleExpand = (id: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
// 특정 코드가 표시되어야 하는지 확인 (부모가 접혀있으면 숨김)
const isCodeVisible = (code: CodeInfo): boolean => {
const parentValue = code.parent_code_value;
if (!parentValue) return true; // 최상위 코드는 항상 표시
// 부모가 접혀있으면 숨김
if (collapsedCodes.has(parentValue)) return false;
// 부모의 부모도 확인 (재귀적으로)
const parentCode = codes.find((c) => c.code_value === parentValue);
if (parentCode) {
return isCodeVisible(parentCode);
// 부모 행 중 하나라도 접혀있으면 자식 숨김
const isRowVisible = (row: CodeDetail): boolean => {
let parentId = row.parent_detail_id;
while (parentId != null) {
if (collapsed.has(String(parentId))) return false;
const parentRow = rows.find((r) => String(r.code_detail_id) === String(parentId));
parentId = parentRow ? parentRow.parent_detail_id : null;
}
return true;
};
// 표시할 코드 목록 (접힌 상태 반영)
const visibleCodes = useMemo(() => {
return filteredCodes.filter(isCodeVisible);
}, [filteredCodes, collapsedCodes, codes]);
const visibleRows = useMemo(() => rows.filter(isRowVisible), [rows, collapsed]);
// 드래그 앤 드롭 훅 사용
const dragAndDrop = useDragAndDrop<CodeInfo>({
items: filteredCodes,
onReorder: async (reorderedItems) => {
await reorderCodesMutation.mutateAsync({
categoryCode,
codes: reorderedItems.map((item) => ({
codeValue: item.id,
sortOrder: item.sortOrder,
})),
});
// 모달 상태
const [showFormModal, setShowFormModal] = useState(false);
const [editingRow, setEditingRow] = useState<CodeDetail | null>(null);
const [defaultParentDetailId, setDefaultParentDetailId] = useState<number | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deletingRow, setDeletingRow] = useState<CodeDetail | null>(null);
// sort_order 재할당 (형제 단위) — 드래그 결과를 PUT 으로 직렬 호출
const dragAndDrop = useDragAndDrop<CodeDetail>({
items: visibleRows,
getItemId: (row) => String(row.code_detail_id),
onReorder: async (reordered) => {
// visibleRows 기준으로 재정렬. 형제만 reorder 의미가 있으므로
// parent_detail_id 가 같은 그룹별로 처리.
const idMap = new Map<string, number>();
reordered.forEach(({ id, sortOrder }) => idMap.set(id, sortOrder));
// 같은 부모 안의 형제만 묶어서 정렬
const byParent = new Map<string, CodeDetail[]>();
for (const row of visibleRows) {
const parentKey =
row.parent_detail_id == null ? "ROOT" : String(row.parent_detail_id);
const list = byParent.get(parentKey) || [];
list.push(row);
byParent.set(parentKey, list);
}
for (const list of byParent.values()) {
const sorted = [...list].sort((a, b) => {
const ao = idMap.get(String(a.code_detail_id)) ?? a.sort_order ?? 0;
const bo = idMap.get(String(b.code_detail_id)) ?? b.sort_order ?? 0;
return ao - bo;
});
for (let i = 0; i < sorted.length; i++) {
const row = sorted[i];
const nextOrder = (i + 1) * 10;
if ((row.sort_order ?? 0) === nextOrder) continue;
await updateMutation.mutateAsync({
codeDetailId: row.code_detail_id,
data: {
code_info: row.code_info,
parent_detail_id: row.parent_detail_id ?? null,
code_value: row.code_value,
code_name: row.code_name,
code_name_eng: row.code_name_eng || "",
description: row.description || "",
sort_order: nextOrder,
is_active: row.is_active || "Y",
},
});
}
}
},
getItemId: (code: CodeInfo) => code.code_value || "",
});
// 새 코드 생성
const handleNewCode = () => {
setEditingCode(null);
setDefaultParentCode(undefined);
const handleNew = () => {
setEditingRow(null);
setDefaultParentDetailId(null);
setShowFormModal(true);
};
// 코드 수정
const handleEditCode = (code: CodeInfo) => {
setEditingCode(code);
setDefaultParentCode(undefined);
const handleEdit = (row: CodeDetail) => {
setEditingRow(row);
setDefaultParentDetailId(null);
setShowFormModal(true);
};
// 하위 코드 추가
const handleAddChild = (parentCode: CodeInfo) => {
setEditingCode(null);
setDefaultParentCode(parentCode.code_value || "");
const handleAddChild = (parent: CodeDetail) => {
setEditingRow(null);
setDefaultParentDetailId(Number(parent.code_detail_id));
setShowFormModal(true);
};
// 코드 삭제 확인
const handleDeleteCode = (code: CodeInfo) => {
setDeletingCode(code);
const handleDelete = (row: CodeDetail) => {
setDeletingRow(row);
setShowDeleteModal(true);
};
// 코드 삭제 실행
const handleConfirmDelete = async () => {
if (!deletingCode) return;
if (!deletingRow) return;
try {
await deleteCodeMutation.mutateAsync({
categoryCode,
codeValue: deletingCode.code_value || "",
});
await deleteMutation.mutateAsync(deletingRow.code_detail_id);
setShowDeleteModal(false);
setDeletingCode(null);
setDeletingRow(null);
} catch (error) {
console.error("코드 삭제 실패:", error);
console.error("디테일 삭제 실패:", error);
}
};
// 드래그 앤 드롭 로직은 useDragAndDrop 훅에서 처리
if (!categoryCode) {
if (!codeInfo) {
return (
<div className="flex h-96 items-center justify-center">
<p className="text-muted-foreground text-sm"> </p>
<p className="text-sm text-muted-foreground"> </p>
</div>
);
}
@@ -237,8 +178,14 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<p className="text-destructive text-sm font-semibold"> .</p>
<Button variant="outline" onClick={() => window.location.reload()} className="mt-4 h-10 text-sm font-medium">
<p className="text-sm font-semibold text-destructive">
.
</p>
<Button
variant="outline"
onClick={() => window.location.reload()}
className="mt-4 h-10 text-sm font-medium"
>
</Button>
</div>
@@ -248,156 +195,125 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
return (
<div className="flex h-full flex-col space-y-4">
{/* 검색 및 액션 */}
<div className="space-y-3">
{/* 검색 + 버튼 */}
<div className="flex items-center gap-2">
<div className="relative w-full sm:w-[300px]">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="코드 검색..."
placeholder="디테일 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
<Button onClick={handleNewCode} className="h-10 gap-2 text-sm font-medium">
<Button onClick={handleNew} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 활성 필터 */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="activeOnlyCodes"
id="activeOnlyDetail"
checked={showActiveOnly}
onChange={(e) => setShowActiveOnly(e.target.checked)}
className="border-input h-4 w-4 rounded"
className="h-4 w-4 rounded border-input"
/>
<label htmlFor="activeOnlyCodes" className="text-muted-foreground text-sm">
<label htmlFor="activeOnlyDetail" className="text-sm text-muted-foreground">
</label>
</div>
</div>
{/* 코드 목록 (무한 스크롤) */}
<div className="space-y-3" onScroll={handleScroll}>
<div className="space-y-3">
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<LoadingSpinner />
</div>
) : visibleCodes.length === 0 ? (
) : visibleRows.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-muted-foreground text-sm">
{codes.length === 0 ? "코드가 없습니다." : "검색 결과가 없습니다."}
<p className="text-sm text-muted-foreground">
{rows.length === 0 ? "디테일이 없습니다." : "검색 결과가 없습니다."}
</p>
</div>
) : (
<>
<DndContext {...dragAndDrop.dndContextProps}>
<SortableContext
items={visibleCodes.map((code) => code.code_value || "")}
strategy={verticalListSortingStrategy}
>
{visibleCodes.map((code, index) => {
const codeValue = code.code_value || "";
const children = childrenMap.get(codeValue) || [];
const hasChildren = children.length > 0;
const isExpanded = !collapsedCodes.has(codeValue);
<DndContext {...dragAndDrop.dndContextProps}>
<SortableContext
items={visibleRows.map((r) => String(r.code_detail_id))}
strategy={verticalListSortingStrategy}
>
{visibleRows.map((row) => {
const idStr = String(row.code_detail_id);
const children = childrenMap.get(idStr) || [];
const hasChildren = children.length > 0;
const isExpanded = !collapsed.has(idStr);
return (
<SortableCodeItem
key={`${codeValue}-${index}`}
code={code}
categoryCode={categoryCode}
onEdit={() => handleEditCode(code)}
onDelete={() => handleDeleteCode(code)}
onAddChild={() => handleAddChild(code)}
hasChildren={hasChildren}
childCount={children.length}
isExpanded={isExpanded}
onToggleExpand={() => toggleExpand(codeValue)}
/>
);
})}
</SortableContext>
return (
<SortableCodeItem
key={idStr}
row={row}
onEdit={() => handleEdit(row)}
onDelete={() => handleDelete(row)}
onAddChild={() => handleAddChild(row)}
hasChildren={hasChildren}
childCount={children.length}
isExpanded={isExpanded}
onToggleExpand={() => toggleExpand(idStr)}
/>
);
})}
</SortableContext>
<DragOverlay dropAnimation={null}>
{dragAndDrop.activeItem ? (
<div className="bg-card cursor-grabbing rounded-lg border p-4 shadow-lg">
{(() => {
const activeCode = dragAndDrop.activeItem;
if (!activeCode) return null;
return (
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold">{activeCode.code_name}</h4>
<Badge
variant={
activeCode.is_active === "Y" ? "default" : "secondary"
}
>
{activeCode.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{activeCode.code_value}
</p>
{activeCode.description && (
<p className="text-muted-foreground mt-1 text-xs">{activeCode.description}</p>
)}
</div>
</div>
);
})()}
<DragOverlay dropAnimation={null}>
{dragAndDrop.activeItem ? (
<div className="cursor-grabbing rounded-lg border bg-card p-4 shadow-lg">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold">
{dragAndDrop.activeItem.code_name}
</h4>
<Badge
variant={dragAndDrop.activeItem.is_active === "Y" ? "default" : "secondary"}
>
{dragAndDrop.activeItem.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{dragAndDrop.activeItem.code_value}
</p>
</div>
</div>
) : null}
</DragOverlay>
</DndContext>
{/* 무한 스크롤 로딩 인디케이터 */}
{isFetchingNextPage && (
<div className="flex items-center justify-center py-4">
<LoadingSpinner size="sm" />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div>
)}
{/* 모든 코드 로드 완료 메시지 */}
{!hasNextPage && codes.length > 0 && (
<div className="text-muted-foreground py-4 text-center text-sm"> .</div>
)}
</>
</div>
) : null}
</DragOverlay>
</DndContext>
)}
</div>
{/* 코드 폼 모달 */}
{showFormModal && (
<CodeFormModal
isOpen={showFormModal}
onClose={() => {
setShowFormModal(false);
setEditingCode(null);
setDefaultParentCode(undefined);
setEditingRow(null);
setDefaultParentDetailId(null);
}}
categoryCode={categoryCode}
editingCode={editingCode}
codes={codes}
defaultParentCode={defaultParentCode}
codeInfo={codeInfo}
editingRow={editingRow}
allRows={rows}
defaultParentDetailId={defaultParentDetailId}
/>
)}
{/* 삭제 확인 모달 */}
{showDeleteModal && (
<AlertModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
type="error"
title="코드 삭제"
message="정말로 이 코드를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
title="디테일 삭제"
message="정말로 이 코드를 삭제하시겠습니까? 하위 코드도 모두 함께 삭제됩니다 (CASCADE)."
confirmText="삭제"
onConfirm={handleConfirmDelete}
/>
@@ -405,3 +321,6 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
</div>
);
}
// `arrayMove` import 유지 위해 keep no-op (트리 reorder 로직에서 향후 활용 가능)
void arrayMove;
+226 -182
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@@ -9,32 +9,31 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { ValidationMessage } from "@/components/common/ValidationMessage";
import { useCreateCode, useUpdateCode } from "@/hooks/queries/useCodes";
import { useCheckCodeDuplicate } from "@/hooks/queries/useValidation";
import { createCodeSchema, updateCodeSchema, type CreateCodeData, type UpdateCodeData } from "@/lib/schemas/commonCode";
import type { CodeInfo } from "@/types/commonCode";
import type { FieldError } from "react-hook-form";
import { useCreateCodeDetail, useUpdateCodeDetail } from "@/hooks/queries/useCodeDetail";
import { useCheckCodeDetailDuplicate } from "@/hooks/queries/useValidation";
import {
createCodeDetailSchema,
updateCodeDetailSchema,
type CreateCodeDetailData,
type UpdateCodeDetailData,
} from "@/lib/schemas/commonCode";
import type { CodeDetail } from "@/types/commonCode";
interface CodeFormModalProps {
isOpen: boolean;
onClose: () => void;
categoryCode: string;
editingCode?: CodeInfo | null;
codes: CodeInfo[];
defaultParentCode?: string; // 하위 코드 추가 시 기본 부모 코드
codeInfo: string;
editingRow?: CodeDetail | null;
allRows: CodeDetail[];
defaultParentDetailId?: number | null;
}
// 에러 메시지를 안전하게 문자열로 변환하는 헬퍼 함수
const getErrorMessage = (error: FieldError | undefined): string => {
if (!error) return "";
if (typeof error === "string") return error;
return error.message || "";
};
const PARENT_NONE = "__NONE__";
// 코드값 자동 생성 함수 (UUID 기반 짧은 코드)
/** code_value 자동 생성 (timestamp+random) */
const generateCodeValue = (): string => {
const timestamp = Date.now().toString(36).toUpperCase();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
@@ -44,194 +43,257 @@ const generateCodeValue = (): string => {
export function CodeFormModal({
isOpen,
onClose,
categoryCode,
editingCode,
codes,
defaultParentCode,
codeInfo,
editingRow,
allRows,
defaultParentDetailId,
}: CodeFormModalProps) {
const createCodeMutation = useCreateCode();
const updateCodeMutation = useUpdateCode();
const createMutation = useCreateCodeDetail();
const updateMutation = useUpdateCodeDetail();
const isEditing = !!editingCode;
// 검증 상태 관리 (코드명만 중복 검사)
const [validationStates, setValidationStates] = useState({
codeName: { enabled: false, value: "" },
});
// 코드명 중복 검사
const codeNameCheck = useCheckCodeDuplicate(
categoryCode,
"codeName",
validationStates.codeName.value,
isEditing ? editingCode?.code_value : undefined,
validationStates.codeName.enabled,
);
// 중복 검사 결과 확인
const hasDuplicateErrors = codeNameCheck.data?.isDuplicate && validationStates.codeName.enabled;
// 중복 검사 로딩 중인지 확인
const isDuplicateChecking = codeNameCheck.isLoading;
// 폼 스키마 선택 (생성/수정에 따라)
const schema = isEditing ? updateCodeSchema : createCodeSchema;
const isEditing = !!editingRow;
const schema = isEditing ? updateCodeDetailSchema : createCodeDetailSchema;
const form = useForm({
resolver: zodResolver(schema),
mode: "onChange", // 실시간 검증 활성화
mode: "onChange",
defaultValues: {
codeValue: "",
codeName: "",
codeNameEng: "",
code_info: codeInfo,
parent_detail_id: null as number | null,
code_value: "",
code_name: "",
code_name_eng: "",
description: "",
sortOrder: 1,
parentCodeValue: "" as string | undefined,
...(isEditing && { isActive: "Y" as const }),
sort_order: 10,
...(isEditing && { is_active: "Y" as const }),
},
});
// 편집 모드일 때 기존 데이터 로드
useEffect(() => {
if (isOpen) {
if (isEditing && editingCode) {
// 수정 모드: 기존 데이터 로드 (codeValue는 표시용으로만 설정)
const parentValue = editingCode.parent_code_value || "";
// 부모 후보: 자기 자신 + 자기 자손은 제외
const parentOptions = useMemo(() => {
if (!isEditing) return allRows;
if (!editingRow) return allRows;
const selfId = String(editingRow.code_detail_id);
form.reset({
codeName: editingCode.code_name,
codeNameEng: editingCode.code_name_eng || "",
description: editingCode.description || "",
sortOrder: editingCode.sort_order,
isActive: editingCode.is_active as "Y" | "N",
parentCodeValue: parentValue,
});
// codeValue는 별도로 설정 (표시용)
form.setValue("codeValue" as any, editingCode.code_value);
} else {
// 새 코드 모드: 자동 순서 계산
const maxSortOrder = codes.length > 0 ? Math.max(...codes.map((c) => c.sort_order || 0)) : 0;
// 기본 부모 코드가 있으면 설정 (하위 코드 추가 시)
const parentValue = defaultParentCode || "";
// 코드값 자동 생성
const autoCodeValue = generateCodeValue();
form.reset({
codeValue: autoCodeValue,
codeName: "",
codeNameEng: "",
description: "",
sortOrder: maxSortOrder + 1,
parentCodeValue: parentValue,
});
const descendants = new Set<string>([selfId]);
let changed = true;
while (changed) {
changed = false;
for (const row of allRows) {
const parentKey =
row.parent_detail_id == null ? "" : String(row.parent_detail_id);
if (
descendants.has(parentKey) &&
!descendants.has(String(row.code_detail_id))
) {
descendants.add(String(row.code_detail_id));
changed = true;
}
}
}
}, [isOpen, isEditing, editingCode, codes, defaultParentCode]);
return allRows.filter((r) => !descendants.has(String(r.code_detail_id)));
}, [allRows, editingRow, isEditing]);
// 중복 검사 (code_name)
const [duplicateState, setDuplicateState] = useState<{ enabled: boolean; value: string }>({
enabled: false,
value: "",
});
const nameCheck = useCheckCodeDetailDuplicate(
codeInfo,
"code_name",
duplicateState.value,
isEditing ? editingRow?.code_detail_id : undefined,
duplicateState.enabled,
);
const hasDuplicateErrors = !!nameCheck.data?.isDuplicate && duplicateState.enabled;
const isDuplicateChecking = nameCheck.isLoading;
useEffect(() => {
if (!isOpen) return;
if (isEditing && editingRow) {
form.reset({
code_info: editingRow.code_info || codeInfo,
parent_detail_id: editingRow.parent_detail_id ?? null,
code_value: editingRow.code_value || "",
code_name: editingRow.code_name || "",
code_name_eng: editingRow.code_name_eng || "",
description: editingRow.description || "",
sort_order: editingRow.sort_order ?? 10,
is_active: ((editingRow.is_active as "Y" | "N") || "Y") as any,
} as any);
} else {
// 형제 중 최대 sort_order + 10
const siblings = allRows.filter((r) => {
const pid = r.parent_detail_id ?? null;
return pid === (defaultParentDetailId ?? null);
});
const maxSort =
siblings.length > 0
? Math.max(...siblings.map((r) => r.sort_order ?? 0))
: 0;
form.reset({
code_info: codeInfo,
parent_detail_id: defaultParentDetailId ?? null,
code_value: generateCodeValue(),
code_name: "",
code_name_eng: "",
description: "",
sort_order: maxSort + 10,
});
}
setDuplicateState({ enabled: false, value: "" });
}, [isOpen, isEditing, editingRow, codeInfo, defaultParentDetailId]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSubmit = form.handleSubmit(async (data) => {
try {
if (isEditing && editingCode) {
// 수정
await updateCodeMutation.mutateAsync({
categoryCode,
codeValue: editingCode.code_value || "",
data: data as UpdateCodeData,
if (isEditing && editingRow) {
await updateMutation.mutateAsync({
codeDetailId: editingRow.code_detail_id,
data: data as UpdateCodeDetailData,
});
} else {
// 생성
await createCodeMutation.mutateAsync({
categoryCode,
data: data as CreateCodeData,
});
await createMutation.mutateAsync(data as CreateCodeDetailData);
}
onClose();
form.reset();
} catch (error) {
console.error("코드 저장 실패:", error);
console.error("디테일 저장 실패:", error);
}
});
const isLoading = createCodeMutation.isPending || updateCodeMutation.isPending;
const isLoading = createMutation.isPending || updateMutation.isPending;
const watchedParent = form.watch("parent_detail_id");
const parentSelectValue =
watchedParent == null || watchedParent === undefined
? PARENT_NONE
: String(watchedParent);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{isEditing ? "코드 수정" : defaultParentCode ? "하위 코드 추가" : "새 코드"}
{isEditing
? "디테일 수정"
: defaultParentDetailId != null
? "하위 코드 추가"
: "새 코드"}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
{/* 코드값 (자동 생성, 수정 시에만 표시) */}
{isEditing && (
<div className="space-y-2">
<Label className="text-xs sm:text-sm"></Label>
<div className="bg-muted h-8 rounded-md border px-3 py-1.5 text-xs sm:h-10 sm:py-2 sm:text-sm">
{form.watch("codeValue")}
</div>
<p className="text-muted-foreground text-[10px] sm:text-xs"> </p>
</div>
)}
{/* code_value */}
<div className="space-y-2">
<Label htmlFor="code_value" className="text-xs sm:text-sm">
*
</Label>
<Input
id="code_value"
{...form.register("code_value")}
disabled={isLoading || isEditing}
placeholder="코드값"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
{isEditing && (
<p className="text-[10px] sm:text-xs text-muted-foreground">
</p>
)}
{form.formState.errors.code_value && (
<p className="text-[10px] sm:text-xs text-destructive">
{form.formState.errors.code_value.message as string}
</p>
)}
</div>
{/* 부모 선택 */}
<div className="space-y-2">
<Label htmlFor="parent_detail_id" className="text-xs sm:text-sm">
</Label>
<Select
value={parentSelectValue}
onValueChange={(v) =>
form.setValue("parent_detail_id", v === PARENT_NONE ? null : Number(v), {
shouldValidate: true,
shouldDirty: true,
})
}
disabled={isLoading}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="상위 코드를 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value={PARENT_NONE}> (2 )</SelectItem>
{parentOptions.map((opt) => (
<SelectItem
key={String(opt.code_detail_id)}
value={String(opt.code_detail_id)}
>
{" ".repeat(Math.max(0, (opt.depth ?? 2) - 2))}
{opt.code_name || opt.code_value}{" "}
<span className="ml-1 text-muted-foreground">({opt.code_value})</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] sm:text-xs text-muted-foreground">
(2 )
</p>
</div>
{/* 코드명 */}
<div className="space-y-2">
<Label htmlFor="codeName" className="text-xs sm:text-sm">
<Label htmlFor="code_name" className="text-xs sm:text-sm">
*
</Label>
<Input
id="codeName"
{...form.register("codeName")}
id="code_name"
{...form.register("code_name")}
disabled={isLoading}
placeholder="코드명을 입력하세요"
placeholder="코드명"
className={
form.formState.errors.codeName
? "border-destructive h-8 text-xs sm:h-10 sm:text-sm"
form.formState.errors.code_name
? "h-8 border-destructive text-xs sm:h-10 sm:text-sm"
: "h-8 text-xs sm:h-10 sm:text-sm"
}
onBlur={(e) => {
const value = e.target.value.trim();
if (value) {
setValidationStates((prev) => ({
...prev,
codeName: { enabled: true, value },
}));
}
if (value) setDuplicateState({ enabled: true, value });
}}
/>
{form.formState.errors.codeName && (
<p className="text-destructive text-[10px] sm:text-xs">
{getErrorMessage(form.formState.errors.codeName)}
{form.formState.errors.code_name && (
<p className="text-[10px] sm:text-xs text-destructive">
{form.formState.errors.code_name.message as string}
</p>
)}
{!form.formState.errors.codeName && (
{!form.formState.errors.code_name && (
<ValidationMessage
message={codeNameCheck.data?.message}
isValid={!codeNameCheck.data?.isDuplicate}
isLoading={codeNameCheck.isLoading}
message={nameCheck.data?.message}
isValid={!nameCheck.data?.isDuplicate}
isLoading={nameCheck.isLoading}
/>
)}
</div>
{/* 영문명 (선택) */}
{/* 영문명 */}
<div className="space-y-2">
<Label htmlFor="codeNameEng" className="text-xs sm:text-sm">
<Label htmlFor="code_name_eng" className="text-xs sm:text-sm">
</Label>
<Input
id="codeNameEng"
{...form.register("codeNameEng")}
id="code_name_eng"
{...form.register("code_name_eng")}
disabled={isLoading}
placeholder="코드 영문명을 입력하세요 (선택사항)"
placeholder="영문명 (선택)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 설명 (선택) */}
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description" className="text-xs sm:text-sm">
@@ -240,64 +302,41 @@ export function CodeFormModal({
id="description"
{...form.register("description")}
disabled={isLoading}
placeholder="설명을 입력하세요 (선택사항)"
placeholder="설명 (선택)"
rows={2}
className="text-xs sm:text-sm"
/>
</div>
{/* 부모 코드 표시 (하위 코드 추가 시에만 표시, 읽기 전용) */}
{defaultParentCode && (
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<div className="bg-muted h-8 rounded-md border px-3 py-1.5 text-xs sm:h-10 sm:py-2 sm:text-sm">
{(() => {
const parentCode = codes.find((c) => c.code_value === defaultParentCode);
return parentCode
? `${parentCode.code_name} (${defaultParentCode})`
: defaultParentCode;
})()}
</div>
<p className="text-muted-foreground text-[10px] sm:text-xs"> </p>
</div>
)}
{/* 정렬 순서 */}
{/* 정렬 */}
<div className="space-y-2">
<Label htmlFor="sortOrder" className="text-xs sm:text-sm">
<Label htmlFor="sort_order" className="text-xs sm:text-sm">
</Label>
<Input
id="sortOrder"
id="sort_order"
type="number"
{...form.register("sortOrder", { valueAsNumber: true })}
{...form.register("sort_order", { valueAsNumber: true })}
disabled={isLoading}
min={1}
className={
form.formState.errors.sortOrder
? "border-destructive h-8 text-xs sm:h-10 sm:text-sm"
: "h-8 text-xs sm:h-10 sm:text-sm"
}
min={0}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
{form.formState.errors.sortOrder && (
<p className="text-destructive text-[10px] sm:text-xs">
{getErrorMessage(form.formState.errors.sortOrder)}
</p>
)}
</div>
{/* 활성 상태 (수정 시에만) */}
{/* 활성 (수정) */}
{isEditing && (
<div className="flex items-center gap-2">
<Switch
id="isActive"
checked={form.watch("isActive") === "Y"}
onCheckedChange={(checked) => form.setValue("isActive", checked ? "Y" : "N")}
id="is_active"
checked={form.watch("is_active" as any) === "Y"}
onCheckedChange={(checked) =>
form.setValue("is_active" as any, checked ? "Y" : "N")
}
disabled={isLoading}
aria-label="활성 상태"
/>
<Label htmlFor="isActive" className="text-xs sm:text-sm">
{form.watch("isActive") === "Y" ? "활성" : "비활성"}
<Label htmlFor="is_active" className="text-xs sm:text-sm">
{form.watch("is_active" as any) === "Y" ? "활성" : "비활성"}
</Label>
</div>
)}
@@ -315,7 +354,12 @@ export function CodeFormModal({
</Button>
<Button
type="submit"
disabled={isLoading || !form.formState.isValid || hasDuplicateErrors || isDuplicateChecking}
disabled={
isLoading ||
!form.formState.isValid ||
hasDuplicateErrors ||
isDuplicateChecking
}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isLoading ? (
@@ -324,9 +368,9 @@ export function CodeFormModal({
{isEditing ? "수정 중..." : "저장 중..."}
</>
) : isEditing ? (
"코드 수정"
"디테일 수정"
) : (
"코드 저장"
"디테일 저장"
)}
</Button>
</div>
@@ -0,0 +1,357 @@
"use client";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { ValidationMessage } from "@/components/common/ValidationMessage";
import { useCreateCodeInfo, useUpdateCodeInfo } from "@/hooks/queries/useCodeInfo";
import { useCheckCodeInfoDuplicate } from "@/hooks/queries/useValidation";
import type { CodeInfo } from "@/types/commonCode";
import {
createCodeInfoSchema,
updateCodeInfoSchema,
type CreateCodeInfoData,
type UpdateCodeInfoData,
} from "@/lib/schemas/commonCode";
interface CodeInfoFormModalProps {
isOpen: boolean;
onClose: () => void;
editingCodeInfo?: string;
existingRows: CodeInfo[];
}
export function CodeInfoFormModal({
isOpen,
onClose,
editingCodeInfo,
existingRows,
}: CodeInfoFormModalProps) {
const createMutation = useCreateCodeInfo();
const updateMutation = useUpdateCodeInfo();
const isEditing = !!editingCodeInfo;
const editingRow = existingRows.find((r) => r.code_info === editingCodeInfo);
const createForm = useForm<CreateCodeInfoData>({
resolver: zodResolver(createCodeInfoSchema),
mode: "onChange",
defaultValues: {
code_info: "",
code_name: "",
code_name_eng: "",
description: "",
sort_order: 0,
},
});
const updateForm = useForm<UpdateCodeInfoData>({
resolver: zodResolver(updateCodeInfoSchema),
mode: "onChange",
defaultValues: {
code_name: "",
code_name_eng: "",
description: "",
sort_order: 0,
is_active: "Y",
},
});
const [validatedFields, setValidatedFields] = useState<Set<string>>(new Set());
const handleFieldBlur = (name: string) => {
setValidatedFields((prev) => new Set(prev).add(name));
};
const codeCheck = useCheckCodeInfoDuplicate(
"code_info",
isEditing ? "" : createForm.watch("code_info"),
isEditing ? editingCodeInfo : undefined,
validatedFields.has("code_info"),
);
const nameCheck = useCheckCodeInfoDuplicate(
"code_name",
isEditing ? updateForm.watch("code_name") : createForm.watch("code_name"),
isEditing ? editingCodeInfo : undefined,
validatedFields.has("code_name"),
);
const nameEngCheck = useCheckCodeInfoDuplicate(
"code_name_eng",
isEditing
? updateForm.watch("code_name_eng") || ""
: createForm.watch("code_name_eng") || "",
isEditing ? editingCodeInfo : undefined,
validatedFields.has("code_name_eng"),
);
const hasDuplicateErrors =
(!isEditing && codeCheck.data?.isDuplicate && validatedFields.has("code_info")) ||
(nameCheck.data?.isDuplicate && validatedFields.has("code_name")) ||
(nameEngCheck.data?.isDuplicate && validatedFields.has("code_name_eng"));
const isDuplicateChecking =
(!isEditing && codeCheck.isLoading) || nameCheck.isLoading || nameEngCheck.isLoading;
useEffect(() => {
if (!isOpen) return;
setValidatedFields(new Set());
if (isEditing && editingRow) {
updateForm.reset({
code_name: editingRow.code_name,
code_name_eng: editingRow.code_name_eng || "",
description: editingRow.description || "",
sort_order: editingRow.sort_order ?? 0,
is_active: (editingRow.is_active as "Y" | "N") || "Y",
});
} else {
const maxSort =
existingRows.length > 0
? Math.max(...existingRows.map((r) => r.sort_order ?? 0))
: 0;
createForm.reset({
code_info: "",
code_name: "",
code_name_eng: "",
description: "",
sort_order: maxSort + 1,
});
}
}, [isOpen, isEditing, editingRow, existingRows]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSubmit = isEditing
? updateForm.handleSubmit(async (data) => {
try {
await updateMutation.mutateAsync({ codeInfo: editingCodeInfo!, data });
onClose();
updateForm.reset();
} catch (error) {
console.error("그룹 수정 실패:", error);
}
})
: createForm.handleSubmit(async (data) => {
try {
await createMutation.mutateAsync(data);
onClose();
createForm.reset();
} catch (error) {
console.error("그룹 생성 실패:", error);
}
});
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{isEditing ? "그룹 수정" : "새 그룹"}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
{/* 그룹 코드 (code_info) */}
{!isEditing && (
<div className="space-y-2">
<Label htmlFor="code_info" className="text-xs sm:text-sm">
*
</Label>
<Input
id="code_info"
{...createForm.register("code_info")}
disabled={isLoading}
placeholder="예: STATUS, USER_ROLE"
className={
createForm.formState.errors.code_info
? "h-8 border-destructive text-xs sm:h-10 sm:text-sm"
: "h-8 text-xs sm:h-10 sm:text-sm"
}
onBlur={() => handleFieldBlur("code_info")}
/>
{createForm.formState.errors.code_info && (
<p className="text-[10px] sm:text-xs text-destructive">
{createForm.formState.errors.code_info.message}
</p>
)}
{!createForm.formState.errors.code_info && (
<ValidationMessage
message={codeCheck.data?.message}
isValid={!codeCheck.data?.isDuplicate}
isLoading={codeCheck.isLoading}
/>
)}
</div>
)}
{isEditing && editingRow && (
<div className="space-y-2">
<Label htmlFor="code_info_display" className="text-xs sm:text-sm">
</Label>
<Input
id="code_info_display"
value={editingRow.code_info}
disabled
className="h-8 cursor-not-allowed bg-muted text-xs sm:h-10 sm:text-sm"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
.
</p>
</div>
)}
{/* 그룹명 */}
<div className="space-y-2">
<Label htmlFor="code_name"> *</Label>
<Input
id="code_name"
{...(isEditing
? updateForm.register("code_name")
: createForm.register("code_name"))}
disabled={isLoading}
placeholder="그룹명을 입력하세요"
className={
(isEditing
? updateForm.formState.errors.code_name
: createForm.formState.errors.code_name)
? "border-destructive"
: ""
}
onBlur={() => handleFieldBlur("code_name")}
/>
{(isEditing
? updateForm.formState.errors.code_name
: createForm.formState.errors.code_name) && (
<p className="text-sm text-destructive">
{
(isEditing
? updateForm.formState.errors.code_name
: createForm.formState.errors.code_name
)?.message
}
</p>
)}
{!(isEditing
? updateForm.formState.errors.code_name
: createForm.formState.errors.code_name) && (
<ValidationMessage
message={nameCheck.data?.message}
isValid={!nameCheck.data?.isDuplicate}
isLoading={nameCheck.isLoading}
/>
)}
</div>
{/* 영문명 */}
<div className="space-y-2">
<Label htmlFor="code_name_eng"> </Label>
<Input
id="code_name_eng"
{...(isEditing
? updateForm.register("code_name_eng")
: createForm.register("code_name_eng"))}
disabled={isLoading}
placeholder="영문명을 입력하세요 (선택)"
onBlur={() => handleFieldBlur("code_name_eng")}
/>
{!(isEditing
? updateForm.formState.errors.code_name_eng
: createForm.formState.errors.code_name_eng) && (
<ValidationMessage
message={nameEngCheck.data?.message}
isValid={!nameEngCheck.data?.isDuplicate}
isLoading={nameEngCheck.isLoading}
/>
)}
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
{...(isEditing
? updateForm.register("description")
: createForm.register("description"))}
disabled={isLoading}
placeholder="설명을 입력하세요 (선택)"
rows={3}
/>
</div>
{/* 정렬 순서 */}
<div className="space-y-2">
<Label htmlFor="sort_order"> </Label>
<Input
id="sort_order"
type="number"
{...(isEditing
? updateForm.register("sort_order", { valueAsNumber: true })
: createForm.register("sort_order", { valueAsNumber: true }))}
disabled={isLoading}
min={0}
/>
</div>
{/* 활성 상태 (수정 시) */}
{isEditing && (
<div className="flex items-center space-x-2">
<Switch
id="is_active"
checked={updateForm.watch("is_active") === "Y"}
onCheckedChange={(checked) =>
updateForm.setValue("is_active", checked ? "Y" : "N")
}
disabled={isLoading}
/>
<Label htmlFor="is_active">
{updateForm.watch("is_active") === "Y" ? "활성" : "비활성"}
</Label>
</div>
)}
{/* 버튼 */}
<div className="flex gap-2 pt-4 sm:justify-end sm:gap-0">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isLoading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
type="submit"
disabled={
isLoading ||
!(isEditing ? updateForm.formState.isValid : createForm.formState.isValid) ||
hasDuplicateErrors ||
isDuplicateChecking
}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isLoading ? (
<>
<LoadingSpinner size="sm" className="mr-2" />
{isEditing ? "수정 중..." : "저장 중..."}
</>
) : isEditing ? (
"그룹 수정"
) : (
"그룹 저장"
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
@@ -5,35 +5,34 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Edit, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useUpdateCategory } from "@/hooks/queries/useCategories";
import type { CategoryInfo } from "@/types/commonCode";
import { useUpdateCodeInfo } from "@/hooks/queries/useCodeInfo";
import type { CodeInfo } from "@/types/commonCode";
interface CategoryItemProps {
category: CategoryInfo;
interface CodeInfoItemProps {
row: CodeInfo;
isSelected: boolean;
onSelect: () => void;
onEdit: () => void;
onDelete: () => void;
}
export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete }: CategoryItemProps) {
const updateCategoryMutation = useUpdateCategory();
export function CodeInfoItem({ row, isSelected, onSelect, onEdit, onDelete }: CodeInfoItemProps) {
const updateMutation = useUpdateCodeInfo();
// 활성/비활성 토글 핸들러
const handleToggleActive = async (checked: boolean) => {
const handleToggleActive = async (next: "Y" | "N") => {
try {
await updateCategoryMutation.mutateAsync({
categoryCode: category.category_code,
await updateMutation.mutateAsync({
codeInfo: row.code_info,
data: {
categoryName: category.category_name,
categoryNameEng: category.category_name_eng || "",
description: category.description || "",
sortOrder: category.sort_order,
isActive: checked ? "Y" : "N",
code_name: row.code_name,
code_name_eng: row.code_name_eng || "",
description: row.description || "",
sort_order: row.sort_order ?? 0,
is_active: next,
},
});
} catch (error) {
console.error("카테고리 활성 상태 변경 실패:", error);
console.error("그룹 활성 상태 변경 실패:", error);
}
};
@@ -41,40 +40,39 @@ export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete
<div
className={cn(
"cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all",
isSelected
? "shadow-md"
: "hover:shadow-md",
isSelected ? "border-primary shadow-md" : "hover:shadow-md",
)}
onClick={onSelect}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold">{category.category_name}</h4>
<h4 className="text-sm font-semibold">{row.code_name}</h4>
<Badge
variant={category.is_active === "Y" ? "default" : "secondary"}
variant={row.is_active === "Y" ? "default" : "secondary"}
className={cn(
"cursor-pointer text-xs transition-colors",
updateCategoryMutation.isPending && "cursor-not-allowed opacity-50",
updateMutation.isPending && "cursor-not-allowed opacity-50",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!updateCategoryMutation.isPending) {
handleToggleActive(category.is_active !== "Y");
if (!updateMutation.isPending) {
handleToggleActive(row.is_active === "Y" ? "N" : "Y");
}
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{category.is_active === "Y" ? "활성" : "비활성"}
{row.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">{category.category_code}</p>
{category.description && <p className="mt-1 text-xs text-muted-foreground">{category.description}</p>}
<p className="mt-1 text-xs text-muted-foreground">{row.code_info}</p>
{row.description && (
<p className="mt-1 text-xs text-muted-foreground">{row.description}</p>
)}
</div>
{/* 액션 버튼 */}
{isSelected && (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="sm" onClick={onEdit}>
@@ -4,77 +4,66 @@ import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { CodeCategoryFormModal } from "./CodeCategoryFormModal";
import { CategoryItem } from "./CategoryItem";
import { CodeInfoFormModal } from "./CodeInfoFormModal";
import { CodeInfoItem } from "./CodeInfoItem";
import { AlertModal } from "@/components/common/AlertModal";
import { Search, Plus } from "lucide-react";
import { useDeleteCategory } from "@/hooks/queries/useCategories";
import { useCategoriesInfinite } from "@/hooks/queries/useCategoriesInfinite";
import { useDeleteCodeInfo } from "@/hooks/queries/useCodeInfo";
import { useCodeInfoInfinite } from "@/hooks/queries/useCodeInfoInfinite";
interface CodeCategoryPanelProps {
selectedCategoryCode: string;
onSelectCategory: (categoryCode: string) => void;
interface CodeInfoPanelProps {
selectedCodeInfo: string;
onSelectCodeInfo: (codeInfo: string) => void;
}
export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: CodeCategoryPanelProps) {
// 검색 및 필터 상태 (먼저 선언)
export function CodeInfoPanel({ selectedCodeInfo, onSelectCodeInfo }: CodeInfoPanelProps) {
const [searchTerm, setSearchTerm] = useState("");
const [showActiveOnly, setShowActiveOnly] = useState(false);
// React Query로 카테고리 데이터 관리 (무한 스크롤)
const {
data: categories = [],
data: rows = [],
isLoading,
error,
handleScroll,
isFetchingNextPage,
hasNextPage,
} = useCategoriesInfinite({
} = useCodeInfoInfinite({
search: searchTerm || undefined,
active: showActiveOnly || undefined, // isActive -> active로 수정
active: showActiveOnly || undefined,
});
const deleteCategoryMutation = useDeleteCategory();
const deleteMutation = useDeleteCodeInfo();
// 모달 상태
const [showFormModal, setShowFormModal] = useState(false);
const [editingCategory, setEditingCategory] = useState<string>("");
const [editingCode, setEditingCode] = useState<string>("");
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deletingCategory, setDeletingCategory] = useState<string>("");
const [deletingCode, setDeletingCode] = useState<string>("");
// 새 카테고리 생성
const handleNewCategory = () => {
setEditingCategory("");
const handleNew = () => {
setEditingCode("");
setShowFormModal(true);
};
// 카테고리 수정
const handleEditCategory = (categoryCode: string) => {
setEditingCategory(categoryCode);
const handleEdit = (code: string) => {
setEditingCode(code);
setShowFormModal(true);
};
// 카테고리 삭제 확인
const handleDeleteCategory = (categoryCode: string) => {
setDeletingCategory(categoryCode);
const handleDelete = (code: string) => {
setDeletingCode(code);
setShowDeleteModal(true);
};
// 카테고리 삭제 실행
const handleConfirmDelete = async () => {
if (!deletingCategory) return;
if (!deletingCode) return;
try {
await deleteCategoryMutation.mutateAsync(deletingCategory);
// 삭제된 카테고리가 선택된 상태라면 선택 해제
if (selectedCategoryCode === deletingCategory) {
onSelectCategory("");
await deleteMutation.mutateAsync(deletingCode);
if (selectedCodeInfo === deletingCode) {
onSelectCodeInfo("");
}
setShowDeleteModal(false);
setDeletingCategory("");
setDeletingCode("");
} catch (error) {
console.error("카테고리 삭제 실패:", error);
console.error("그룹 삭제 실패:", error);
}
};
@@ -82,7 +71,7 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-destructive"> .</p>
<p className="text-destructive"> .</p>
<Button variant="outline" onClick={() => window.location.reload()} className="mt-2">
</Button>
@@ -93,66 +82,63 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
return (
<div className="flex h-full flex-col space-y-4">
{/* 검색 및 액션 */}
{/* 검색 + 등록 */}
<div className="space-y-3">
{/* 검색 + 버튼 */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="카테고리 검색..."
placeholder="그룹 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
<Button onClick={handleNewCategory} className="h-10 gap-2 text-sm font-medium">
<Button onClick={handleNew} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 활성 필터 */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="activeOnly"
id="activeOnlyInfo"
checked={showActiveOnly}
onChange={(e) => setShowActiveOnly(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
<label htmlFor="activeOnly" className="text-sm text-muted-foreground">
<label htmlFor="activeOnlyInfo" className="text-sm text-muted-foreground">
</label>
</div>
</div>
{/* 카테고리 목록 (무한 스크롤) */}
{/* 목록 */}
<div className="space-y-3" onScroll={handleScroll}>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<LoadingSpinner />
</div>
) : categories.length === 0 ? (
) : rows.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-muted-foreground">
{searchTerm ? "검색 결과가 없습니다." : "카테고리가 없습니다."}
{searchTerm ? "검색 결과가 없습니다." : "그룹이 없습니다."}
</p>
</div>
) : (
<>
{categories.map((category, index) => (
<CategoryItem
key={`${category.category_code}-${index}`}
category={category}
isSelected={selectedCategoryCode === category.category_code}
onSelect={() => onSelectCategory(category.category_code)}
onEdit={() => handleEditCategory(category.category_code)}
onDelete={() => handleDeleteCategory(category.category_code)}
{rows.map((row, index) => (
<CodeInfoItem
key={`${row.code_info}-${index}`}
row={row}
isSelected={selectedCodeInfo === row.code_info}
onSelect={() => onSelectCodeInfo(row.code_info)}
onEdit={() => handleEdit(row.code_info)}
onDelete={() => handleDelete(row.code_info)}
/>
))}
{/* 추가 로딩 표시 */}
{isFetchingNextPage && (
<div className="flex items-center justify-center py-4">
<LoadingSpinner size="sm" />
@@ -160,32 +146,31 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
</div>
)}
{/* 더 이상 데이터가 없을 때 */}
{!hasNextPage && categories.length > 0 && (
<div className="py-4 text-center text-sm text-muted-foreground"> .</div>
{!hasNextPage && rows.length > 0 && (
<div className="py-4 text-center text-sm text-muted-foreground">
.
</div>
)}
</>
)}
</div>
{/* 카테고리 폼 모달 */}
{showFormModal && (
<CodeCategoryFormModal
<CodeInfoFormModal
isOpen={showFormModal}
onClose={() => setShowFormModal(false)}
editingCategoryCode={editingCategory}
categories={categories}
editingCodeInfo={editingCode}
existingRows={rows}
/>
)}
{/* 삭제 확인 모달 */}
{showDeleteModal && (
<AlertModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
type="error"
title="카테고리 삭제"
message="정말로 이 카테고리를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
title="그룹 삭제"
message="정말로 이 그룹을 삭제하시겠습니까? 그룹의 모든 디테일 코드도 함께 삭제됩니다 (CASCADE)."
confirmText="삭제"
onConfirm={handleConfirmDelete}
/>
+7 -7
View File
@@ -58,7 +58,7 @@ export function MenuCopyDialog({
const [addPrefix, setAddPrefix] = useState("");
// 카테고리/코드 복사 옵션
const [copyCodeCategory, setCopyCodeCategory] = useState(false);
const [copyCodeInfo, setCopyCodeInfo] = useState(false);
const [copyNumberingRules, setCopyNumberingRules] = useState(false);
const [copyCategoryMapping, setCopyCategoryMapping] = useState(false);
const [copyTableTypeColumns, setCopyTableTypeColumns] = useState(false);
@@ -74,7 +74,7 @@ export function MenuCopyDialog({
setUseBulkRename(false);
setRemoveText("");
setAddPrefix("");
setCopyCodeCategory(false);
setCopyCodeInfo(false);
setCopyNumberingRules(false);
setCopyCategoryMapping(false);
setCopyTableTypeColumns(false);
@@ -127,7 +127,7 @@ export function MenuCopyDialog({
// 추가 복사 옵션
const additionalCopyOptions = {
copyCodeCategory,
copyCodeInfo,
copyNumberingRules,
copyCategoryMapping,
copyTableTypeColumns,
@@ -294,13 +294,13 @@ export function MenuCopyDialog({
<div className="space-y-2 pl-2 border-l-2">
<div className="flex items-center gap-2">
<Checkbox
id="copyCodeCategory"
checked={copyCodeCategory}
onCheckedChange={(checked) => setCopyCodeCategory(checked as boolean)}
id="copyCodeInfo"
checked={copyCodeInfo}
onCheckedChange={(checked) => setCopyCodeInfo(checked as boolean)}
disabled={copying}
/>
<Label
htmlFor="copyCodeCategory"
htmlFor="copyCodeInfo"
className="text-xs cursor-pointer"
>
+
+65 -98
View File
@@ -7,85 +7,76 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Edit, Trash2, CornerDownRight, Plus, ChevronRight, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { useUpdateCode } from "@/hooks/queries/useCodes";
import type { CodeInfo } from "@/types/commonCode";
import { useUpdateCodeDetail } from "@/hooks/queries/useCodeDetail";
import type { CodeDetail } from "@/types/commonCode";
interface SortableCodeItemProps {
code: CodeInfo;
categoryCode: string;
row: CodeDetail;
onEdit: () => void;
onDelete: () => void;
onAddChild: () => void; // 하위 코드 추가
onAddChild: () => void;
isDragOverlay?: boolean;
maxDepth?: number; // 최대 깊이 (기본값 3)
hasChildren?: boolean; // 자식이 있는지 여부
childCount?: number; // 자식 개수
isExpanded?: boolean; // 펼쳐진 상태
onToggleExpand?: () => void; // 접기/펼치기 토글
hasChildren?: boolean;
childCount?: number;
isExpanded?: boolean;
onToggleExpand?: () => void;
}
export function SortableCodeItem({
code,
categoryCode,
row,
onEdit,
onDelete,
onAddChild,
isDragOverlay = false,
maxDepth = 3,
hasChildren = false,
childCount = 0,
isExpanded = true,
onToggleExpand,
}: SortableCodeItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: code.code_value || "",
id: String(row.code_detail_id),
disabled: isDragOverlay,
});
const updateCodeMutation = useUpdateCode();
const updateMutation = useUpdateCodeDetail();
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
// 활성/비활성 토글 핸들러
const handleToggleActive = async (checked: boolean) => {
const handleToggleActive = async (next: "Y" | "N") => {
try {
const codeValue = code.code_value;
if (!codeValue) {
return;
}
await updateCodeMutation.mutateAsync({
categoryCode,
codeValue: codeValue,
await updateMutation.mutateAsync({
codeDetailId: row.code_detail_id,
data: {
codeName: code.code_name,
codeNameEng: code.code_name_eng || "",
description: code.description || "",
sortOrder: code.sort_order,
isActive: checked ? "Y" : "N",
code_info: row.code_info,
parent_detail_id: row.parent_detail_id ?? null,
code_value: row.code_value,
code_name: row.code_name,
code_name_eng: row.code_name_eng || "",
description: row.description || "",
sort_order: row.sort_order ?? 10,
is_active: next,
},
});
} catch (error) {
console.error("코드 활성 상태 변경 실패:", error);
console.error("디테일 활성 상태 변경 실패:", error);
}
};
// 계층구조 깊이에 따른 들여쓰기
const depth = code.depth || 1;
const indentLevel = (depth - 1) * 28; // 28px per level
const hasParent = !!code.parent_code_value;
// depth 는 2 부터. (그룹 직속 = 2)
const depth: number = row.depth ?? 2;
const indentLevel = Math.max(0, depth - 2) * 28;
const hasParent = row.parent_detail_id != null;
return (
<div className="flex items-stretch">
{/* 계층구조 들여쓰기 영역 */}
{depth > 1 && (
{indentLevel > 0 && (
<div
className="flex items-center justify-end pr-2"
style={{ width: `${indentLevel}px`, minWidth: `${indentLevel}px` }}
>
<CornerDownRight className="text-muted-foreground/50 h-4 w-4" />
<CornerDownRight className="h-4 w-4 text-muted-foreground/50" />
</div>
)}
@@ -95,17 +86,17 @@ export function SortableCodeItem({
{...attributes}
{...listeners}
className={cn(
"group bg-card flex-1 cursor-grab rounded-lg border p-4 shadow-sm transition-all hover:shadow-md",
"group flex-1 cursor-grab rounded-lg border bg-card p-4 shadow-sm transition-all hover:shadow-md",
isDragging && "cursor-grabbing opacity-50",
depth === 1 && "border-l-primary border-l-4",
depth === 2 && "border-l-4 border-l-blue-400",
depth === 3 && "border-l-4 border-l-green-400",
depth === 2 && "border-l-4 border-l-primary",
depth === 3 && "border-l-4 border-l-blue-400",
depth === 4 && "border-l-4 border-l-green-400",
depth >= 5 && "border-l-4 border-l-muted-foreground/40",
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
{/* 접기/펼치기 버튼 (자식이 있을 때만 표시) */}
{hasChildren && onToggleExpand && (
<button
type="button"
@@ -116,91 +107,67 @@ export function SortableCodeItem({
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="text-muted-foreground hover:text-foreground -ml-1 flex h-5 w-5 items-center justify-center rounded transition-colors hover:bg-muted"
className="-ml-1 flex h-5 w-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={isExpanded ? "접기" : "펼치기"}
>
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
)}
<h4 className="text-sm font-semibold">{code.code_name}</h4>
{/* 접힌 상태에서 자식 개수 표시 */}
{hasChildren && !isExpanded && <span className="text-muted-foreground text-[10px]">({childCount})</span>}
{/* 깊이 표시 배지 */}
{depth === 1 && (
<Badge
variant="outline"
className="border-primary/30 bg-primary/10 text-primary px-1.5 py-0 text-[10px]"
>
</Badge>
)}
{depth === 2 && (
<Badge variant="outline" className="bg-primary/10 px-1.5 py-0 text-[10px] text-primary">
</Badge>
)}
{depth === 3 && (
<Badge variant="outline" className="bg-emerald-50 px-1.5 py-0 text-[10px] text-emerald-600">
</Badge>
)}
{depth > 3 && (
<Badge variant="outline" className="bg-muted px-1.5 py-0 text-[10px]">
{depth}
</Badge>
<h4 className="text-sm font-semibold">{row.code_name}</h4>
{hasChildren && !isExpanded && (
<span className="text-[10px] text-muted-foreground">({childCount})</span>
)}
<Badge variant="outline" className="bg-muted/40 px-1.5 py-0 text-[10px]">
depth {depth}
</Badge>
<Badge
variant={code.is_active === "Y" ? "default" : "secondary"}
variant={row.is_active === "Y" ? "default" : "secondary"}
className={cn(
"cursor-pointer text-xs transition-colors",
updateCodeMutation.isPending && "cursor-not-allowed opacity-50",
updateMutation.isPending && "cursor-not-allowed opacity-50",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!updateCodeMutation.isPending) {
const isActive = code.is_active === "Y";
handleToggleActive(!isActive);
if (!updateMutation.isPending) {
handleToggleActive(row.is_active === "Y" ? "N" : "Y");
}
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{code.is_active === "Y" ? "활성" : "비활성"}
{row.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="text-muted-foreground mt-1 text-xs">{code.code_value}</p>
{/* 부모 코드 표시 */}
<p className="mt-1 text-xs text-muted-foreground">{row.code_value}</p>
{hasParent && (
<p className="text-muted-foreground mt-0.5 text-[10px]">
: {code.parent_code_value}
<p className="mt-0.5 text-[10px] text-muted-foreground">
상위: detail#{row.parent_detail_id}
</p>
)}
{code.description && <p className="text-muted-foreground mt-1 text-xs">{code.description}</p>}
{row.description && (
<p className="mt-1 text-xs text-muted-foreground">{row.description}</p>
)}
</div>
{/* 액션 버튼 */}
<div
className="flex items-center gap-1"
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* 하위 코드 추가 버튼 (최대 깊이 미만일 때만 표시) */}
{depth < maxDepth && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onAddChild();
}}
title="하위 코드 추가"
className="text-primary hover:bg-primary/10 hover:text-primary"
>
<Plus className="h-3 w-3" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onAddChild();
}}
title="하위 코드 추가"
className="text-primary hover:bg-primary/10 hover:text-primary"
>
<Plus className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
@@ -35,7 +35,7 @@ export interface ColumnDetailPanelProps {
onClose: () => void;
onLoadReferenceColumns?: (tableName: string) => void;
/** 코드 카테고리 옵션 (value, label) */
codeCategoryOptions?: Array<{ value: string; label: string }>;
codeInfoOptions?: Array<{ value: string; label: string }>;
/** 참조 테이블 옵션 (value, label) */
referenceTableOptions?: Array<{ value: string; label: string }>;
}
@@ -48,7 +48,7 @@ export function ColumnDetailPanel({
onColumnChange,
onClose,
onLoadReferenceColumns,
codeCategoryOptions = [],
codeInfoOptions = [],
referenceTableOptions = [],
}: ColumnDetailPanelProps) {
const [advancedOpen, setAdvancedOpen] = React.useState(false);
@@ -75,11 +75,9 @@ export function ColumnDetailPanel({
return n;
}, [column]);
if (!column) return null;
const refTableOpts = useMemo(() => {
const hasKorean = (s: string) => /[가-힣]/.test(s);
const raw = referenceTableOptions.length
const rawSource = referenceTableOptions.length
? [...referenceTableOptions]
: [
{ value: "none", label: "없음" },
@@ -92,6 +90,14 @@ export function ColumnDetailPanel({
})),
];
// value 기준 dedupe — referenceTableOptions/tables 어디서든 중복 들어오면 React key 충돌
const seen = new Set<string>();
const raw = rawSource.filter((o) => {
if (seen.has(o.value)) return false;
seen.add(o.value);
return true;
});
const noneOpt = raw.find((o) => o.value === "none");
const rest = raw.filter((o) => o.value !== "none");
@@ -106,6 +112,10 @@ export function ColumnDetailPanel({
return noneOpt ? [noneOpt, ...rest] : rest;
}, [referenceTableOptions, tables]);
// early return 은 반드시 모든 hook 호출 뒤에 (Rules of Hooks).
// overlay 패턴으로 항상 마운트되므로 column null 케이스가 정상적으로 들어옴.
if (!column) return null;
return (
<div className="flex h-full w-full flex-col border-l bg-card">
{/* 헤더 */}
@@ -365,14 +375,17 @@ export function ColumnDetailPanel({
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"> </Label>
<Select
value={column.code_category ?? "none"}
onValueChange={(v) => onColumnChange("code_category", v === "none" ? undefined : v)}
value={column.code_info ?? "none"}
onValueChange={(v) => onColumnChange("code_info", v === "none" ? undefined : v)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="코드 선택" />
</SelectTrigger>
<SelectContent>
{[{ value: "none", label: "선택 안함" }, ...codeCategoryOptions].map((opt) => (
{[
{ value: "none", label: "선택 안함" },
...codeInfoOptions.filter((opt) => opt.value !== "none"),
].map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
@@ -380,7 +393,7 @@ export function ColumnDetailPanel({
</SelectContent>
</Select>
</div>
{column.code_category && column.code_category !== "none" && (
{column.code_info && column.code_info !== "none" && (
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"> </Label>
<Select
@@ -144,7 +144,7 @@ export function ColumnGrid({
{/* 라벨 + 컬럼명 (한글라벨 (영어명) 동시 표시) */}
<div className="min-w-0">
<div className="truncate text-sm font-medium">
<div className="truncate text-xs font-medium">
{column.display_name && column.display_name !== column.column_name
? `${column.display_name} (${column.column_name})`
: column.column_name}
@@ -193,7 +193,7 @@ export function ColumnGrid({
)}
{column.input_type === "code" && (
<span className="text-muted-foreground truncate text-xs">
{column.code_category ?? "—"} · {column.default_value ?? ""}
{column.code_info ?? "—"} · {column.default_value ?? ""}
</span>
)}
{column.input_type === "numbering" && column.numbering_rule_id && (
@@ -22,11 +22,24 @@ function countByInputType(columns: ColumnTypeInfo[]): Record<string, number> {
return counts;
}
/** 도넛 차트용 비율 (0~1) 배열 및 라벨 순서 (8개 사용자 선택 가능 타입 한정) */
function getDonutSegments(counts: Record<string, number>, total: number): Array<{ type: string; ratio: number }> {
return (USER_SELECTABLE_INPUT_TYPE_ORDER as readonly string[])
/** 도넛 차트용 segment (8개 base + legacy 그룹) */
function getDonutSegments(
counts: Record<string, number>,
total: number,
): Array<{ type: string; ratio: number; isLegacy: boolean }> {
const baseSegments = (USER_SELECTABLE_INPUT_TYPE_ORDER as readonly string[])
.filter((type) => (counts[type] || 0) > 0)
.map((type) => ({ type, ratio: (counts[type] || 0) / total }));
.map((type) => ({ type, ratio: (counts[type] || 0) / total, isLegacy: false }));
const legacyCount = Object.entries(counts)
.filter(([type]) => !(USER_SELECTABLE_INPUT_TYPE_ORDER as readonly string[]).includes(type))
.reduce((sum, [, count]) => sum + count, 0);
if (legacyCount > 0) {
baseSegments.push({ type: "__legacy__", ratio: legacyCount / total, isLegacy: true });
}
return baseSegments;
}
export function TypeOverviewStrip({
@@ -44,16 +57,25 @@ export function TypeOverviewStrip({
/** stroke-dasharray: 비율만큼 둘레에 할당 (둘레 100 기준) */
const circumference = 100;
let offset = 0;
const segmentPaths = segments.map(({ type, ratio }) => {
const LEGACY_CONF = {
color: "text-amber-600",
bgColor: "bg-amber-50",
barColor: "bg-amber-400",
label: "Legacy",
desc: "구버전 타입",
iconChar: "?",
};
const segmentPaths = segments.map(({ type, ratio, isLegacy }) => {
const length = ratio * circumference;
const dashArray = `${length} ${circumference - length}`;
const dashOffset = -offset;
offset += length;
const conf = INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted" };
const conf = isLegacy ? LEGACY_CONF : (INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted" });
return {
type,
dashArray,
dashOffset,
isLegacy,
...conf,
};
});
@@ -84,7 +106,7 @@ export function TypeOverviewStrip({
</svg>
</div>
{/* 타입 칩 목록 (8개 사용자 선택 가능 타입 한정, 클릭 시 필터 토글) */}
{/* 타입 칩 목록 (8개 base + legacy 그룹, 클릭 시 필터 토글) */}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{(USER_SELECTABLE_INPUT_TYPE_ORDER as readonly string[])
.filter((type) => (counts[type] || 0) > 0)
@@ -109,6 +131,29 @@ export function TypeOverviewStrip({
</button>
);
})}
{/* Legacy 칩 — 8개 외 구버전 타입 합산 */}
{(() => {
const legacyCount = Object.entries(counts)
.filter(([type]) => !(USER_SELECTABLE_INPUT_TYPE_ORDER as readonly string[]).includes(type))
.reduce((sum, [, count]) => sum + count, 0);
if (legacyCount === 0) return null;
const isActive = activeFilter === null || activeFilter === "__legacy__";
return (
<button
key="__legacy__"
type="button"
onClick={() => onFilterChange?.(activeFilter === "__legacy__" ? null : "__legacy__")}
className={cn(
"rounded-md border px-2 py-1 text-xs font-medium transition-colors",
"bg-amber-50 text-amber-600",
"border-amber-200",
isActive ? "ring-1 ring-ring" : "opacity-70 hover:opacity-100",
)}
>
Legacy {legacyCount}
</button>
);
})()}
</div>
</div>
);

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