From 0e895a90faadfc7ba63922ca242ffaf74a8afeee Mon Sep 17 00:00:00 2001 From: johngreen Date: Fri, 8 May 2026 08:34:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EB=B6=80=EC=84=9C=EA=B4=80=EB=A6=AC):=20V?= =?UTF-8?q?1=20=EC=8A=AC=EB=A6=BC=20=EC=8A=A4=EC=BD=94=ED=94=84=20+=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=20=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20UX=20=EB=A6=AC=EB=94=94=EC=9E=90=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백엔드: - V018 soft-delete (deleted_at 컬럼) + 휴지통/복구 흐름 - V019 미사용 컬럼 cleanup (V1 슬림 스코프) - DepartmentService.updateDepartment 에 parent_dept_code 사이클 가드 (자기 자신/자손을 부모로 지정 시도 차단) - DepartmentController, mapper 갱신 프론트: - 부서관리 페이지(deptMngList) UX 리디자인 - 트리 노드 ⋮ 컨텍스트 메뉴 (하위 추가, 다른 부서 아래로 이동, 정렬 4단계, 삭제) - 헤더 breadcrumb 으로 부서 위치 상시 표시 - 폼의 상위부서 row 제거 (트리 ⋮ 로 진입점 일원화) - 빈 상태 placeholder + X 닫기 동작 - 토글 버튼 토스 스타일 (아이콘 + 툴팁, 일정한 위치) - 부서유형 row 좁은 화면 가로 오버플로 fix - DepartmentPicker 신규 재사용 컴포넌트 (자손 자동 exclude, 사이클 차단) - 회사관리/프로비저닝 폼 개선 (Step1Basic, fields, CompanyTable, AdminPageRenderer) - companyList/[companyCode]/departments 구버전 페이지 삭제 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../erp/controller/DepartmentController.java | 165 ++- .../erp/migration/StartupSchemaMigrator.java | 36 +- .../com/erp/service/DepartmentService.java | 188 +++- ...V017__reorder_company_mgmt_admin_menus.sql | 16 +- .../V018__dept_mgmt_v1_softdelete.sql | 18 + ...9__dept_mgmt_v1_cleanup_unused_columns.sql | 22 + .../src/main/resources/mapper/admin.xml | 8 +- .../src/main/resources/mapper/department.xml | 122 ++- .../[companyCode]/departments/page.tsx | 115 --- .../(main)/admin/userMng/deptMngList/page.tsx | 960 ++++++++++++------ frontend/components/admin/CompanyTable.tsx | 6 +- .../admin/department/DepartmentStructure.tsx | 148 ++- .../admin/provisioning/wizard/Step1Basic.tsx | 13 +- .../admin/provisioning/wizard/fields.tsx | 19 +- .../departments/DepartmentPicker.tsx | 337 ++++++ .../components/layout/AdminPageRenderer.tsx | 5 - frontend/lib/api/department.ts | 42 +- frontend/types/department.ts | 25 +- 18 files changed, 1592 insertions(+), 653 deletions(-) create mode 100644 backend-spring/src/main/resources/db/migration/V018__dept_mgmt_v1_softdelete.sql create mode 100644 backend-spring/src/main/resources/db/migration/V019__dept_mgmt_v1_cleanup_unused_columns.sql delete mode 100644 frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx create mode 100644 frontend/components/departments/DepartmentPicker.tsx diff --git a/backend-spring/src/main/java/com/erp/controller/DepartmentController.java b/backend-spring/src/main/java/com/erp/controller/DepartmentController.java index 55d1fa9c..fe1acefb 100644 --- a/backend-spring/src/main/java/com/erp/controller/DepartmentController.java +++ b/backend-spring/src/main/java/com/erp/controller/DepartmentController.java @@ -19,35 +19,47 @@ public class DepartmentController { private final DepartmentService departmentService; /** - * 부서 목록 조회 (회사별) - * GET /api/departments/companies/{companyCode}/departments + * 부서 목록 조회 (회사별). + * 기본은 active 부서만. ?include_deleted=true 시 soft-delete 된 부서도 포함. + * GET /api/departments/companies/{companyCode}/departments[?include_deleted=true] */ @GetMapping("/companies/{companyCode}/departments") public ResponseEntity>>> getDepartments( @PathVariable String companyCode, - @RequestAttribute("company_code") String userCompanyCode) { + @RequestAttribute("company_code") String userCompanyCode, + @RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted) { if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) { return ResponseEntity.status(403) .body(ApiResponse.error("해당 회사의 부서를 조회할 권한이 없습니다.")); } - List> departments = departmentService.getDepartments(companyCode); + List> departments = departmentService.getDepartments(companyCode, includeDeleted); return ResponseEntity.ok(ApiResponse.success(departments, "부서 목록 조회 성공")); } /** - * 부서 상세 조회 - * GET /api/departments/{deptCode} + * 부서 상세 조회. + * - 기본: active 부서만 (DELETED_AT IS NULL) + * - ?include_deleted=true: soft-delete 된 부서도 조회 가능 (복구·이력 화면용) + * - 회사 격리: 본인 회사 부서만, SUPER_ADMIN 은 전체 + * GET /api/departments/{deptCode}[?include_deleted=true] */ @GetMapping("/{deptCode}") public ResponseEntity>> getDepartment( - @PathVariable String deptCode) { + @PathVariable String deptCode, + @RequestAttribute("company_code") String userCompanyCode, + @RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted) { - Map department = departmentService.getDepartment(deptCode); + Map department = includeDeleted + ? departmentService.getDepartmentIncludingDeleted(deptCode) + : departmentService.getDepartment(deptCode); if (department == null) { return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다.")); } + if (!canAccessDept(department, userCompanyCode)) { + return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다.")); + } return ResponseEntity.ok(ApiResponse.success(department, "부서 조회 성공")); } @@ -87,12 +99,21 @@ public class DepartmentController { public ResponseEntity>> updateDepartment( @PathVariable String deptCode, @RequestAttribute("role") String role, + @RequestAttribute("company_code") String userCompanyCode, @RequestBody Map body) { if (!isAdmin(role)) { return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다.")); } + Map existing = departmentService.getDepartmentIncludingDeleted(deptCode); + if (existing == null) { + return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다.")); + } + if (!canAccessDept(existing, userCompanyCode)) { + return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다.")); + } + try { Map updated = departmentService.updateDepartment(deptCode, body); if (updated == null) { @@ -105,39 +126,102 @@ public class DepartmentController { } /** - * 부서 삭제 + * 부서 삭제 (soft-delete, V1 slim scope). + * - 기존 hard-delete → DELETED_AT = NOW() 마킹으로 변경 + * - 응답 호환: 기존 { success, message } 에 data.soft_deleted=true 필드 추가 + * - USER_DEPT 행은 보존되어 복구 시 멤버 그대로 살아남 * DELETE /api/departments/{deptCode} */ @DeleteMapping("/{deptCode}") - public ResponseEntity> deleteDepartment( + public ResponseEntity>> deleteDepartment( @PathVariable String deptCode, - @RequestAttribute("role") String role) { + @RequestAttribute("role") String role, + @RequestAttribute("company_code") String userCompanyCode) { if (!isAdmin(role)) { return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다.")); } + Map existing = departmentService.getDepartmentIncludingDeleted(deptCode); + if (existing == null) { + return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다.")); + } + if (!canAccessDept(existing, userCompanyCode)) { + return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다.")); + } + try { - int memberCount = departmentService.deleteDepartment(deptCode); - if (memberCount == -1) { - return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다.")); + int result = departmentService.deleteDepartment(deptCode); + if (result == -1) { + return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다.")); } - String message = memberCount > 0 - ? "부서가 삭제되었습니다. (부서원 " + memberCount + "명 제외됨)" - : "부서가 삭제되었습니다."; - return ResponseEntity.ok(ApiResponse.success(null, message)); + Map data = new java.util.HashMap<>(); + data.put("soft_deleted", true); + data.put("dept_code", deptCode); + return ResponseEntity.ok(ApiResponse.success(data, "부서가 삭제되었습니다. (복구 가능)")); } catch (IllegalStateException e) { return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage())); } } + /** + * 부서 복구 (V1 slim scope). + * - DELETED_AT = NULL 로 되돌림 + * - 부모도 deleted 상태면 차단 + * POST /api/departments/{deptCode}/restore + */ + @PostMapping("/{deptCode}/restore") + public ResponseEntity>> restoreDepartment( + @PathVariable String deptCode, + @RequestAttribute("role") String role, + @RequestAttribute("company_code") String userCompanyCode) { + + if (!isAdmin(role)) { + return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다.")); + } + + Map existing = departmentService.getDepartmentIncludingDeleted(deptCode); + if (existing == null) { + return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다.")); + } + if (!canAccessDept(existing, userCompanyCode)) { + return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다.")); + } + + DepartmentService.RestoreResult result = departmentService.restoreDepartment(deptCode); + switch (result) { + case OK: + Map data = new java.util.HashMap<>(); + data.put("dept_code", deptCode); + data.put("restored", true); + return ResponseEntity.ok(ApiResponse.success(data, "부서가 복구되었습니다.")); + case NOT_FOUND: + return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다.")); + case NOT_DELETED: + return ResponseEntity.status(400).body(ApiResponse.error("이미 활성 상태인 부서입니다.")); + case PARENT_DELETED: + return ResponseEntity.status(400).body(ApiResponse.error("상위 부서가 삭제 상태입니다. 상위 부서를 먼저 복구해주세요.")); + default: + return ResponseEntity.status(500).body(ApiResponse.error("복구 처리 중 오류")); + } + } + /** * 부서원 목록 조회 * GET /api/departments/{deptCode}/members */ @GetMapping("/{deptCode}/members") public ResponseEntity>>> getDeptMembers( - @PathVariable String deptCode) { + @PathVariable String deptCode, + @RequestAttribute("company_code") String userCompanyCode) { + + Map existing = departmentService.getDepartmentIncludingDeleted(deptCode); + if (existing == null) { + return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다.")); + } + if (!canAccessDept(existing, userCompanyCode)) { + return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다.")); + } List> members = departmentService.getDeptMembers(deptCode); return ResponseEntity.ok(ApiResponse.success(members, "부서원 목록 조회 성공")); @@ -168,12 +252,21 @@ public class DepartmentController { public ResponseEntity> addDeptMember( @PathVariable String deptCode, @RequestAttribute("role") String role, + @RequestAttribute("company_code") String userCompanyCode, @RequestBody Map body) { if (!isAdmin(role)) { return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다.")); } + Map existing = departmentService.getDepartmentIncludingDeleted(deptCode); + if (existing == null) { + return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다.")); + } + if (!canAccessDept(existing, userCompanyCode)) { + return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다.")); + } + // 프론트엔드는 snake_case(user_id)로 전송 (Node.js 호환) Object userIdObj = body.get("user_id"); if (userIdObj == null) userIdObj = body.get("user_id"); @@ -200,12 +293,21 @@ public class DepartmentController { public ResponseEntity> removeDeptMember( @PathVariable String deptCode, @PathVariable String userId, - @RequestAttribute("role") String role) { + @RequestAttribute("role") String role, + @RequestAttribute("company_code") String userCompanyCode) { if (!isAdmin(role)) { return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다.")); } + Map existing = departmentService.getDepartmentIncludingDeleted(deptCode); + if (existing == null) { + return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다.")); + } + if (!canAccessDept(existing, userCompanyCode)) { + return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다.")); + } + boolean removed = departmentService.removeDeptMember(deptCode, userId); if (!removed) { return ResponseEntity.status(404).body(ApiResponse.error("해당 부서원을 찾을 수 없습니다.")); @@ -221,12 +323,21 @@ public class DepartmentController { public ResponseEntity> setPrimaryDept( @PathVariable String deptCode, @PathVariable String userId, - @RequestAttribute("role") String role) { + @RequestAttribute("role") String role, + @RequestAttribute("company_code") String userCompanyCode) { if (!isAdmin(role)) { return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다.")); } + Map existing = departmentService.getDepartmentIncludingDeleted(deptCode); + if (existing == null) { + return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다.")); + } + if (!canAccessDept(existing, userCompanyCode)) { + return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다.")); + } + departmentService.setPrimaryDept(deptCode, userId); return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다.")); } @@ -242,4 +353,16 @@ public class DepartmentController { private boolean isSuperAdmin(String companyCodeOrRole) { return "*".equals(companyCodeOrRole) || "SUPER_ADMIN".equals(companyCodeOrRole); } + + /** + * 회사 격리 검증. SUPER_ADMIN ('*') 은 모든 회사 접근 가능. + * 일반 ADMIN/USER 는 자기 회사 + 글로벌 ('*') 부서만. + */ + private boolean canAccessDept(Map dept, String userCompanyCode) { + if (dept == null) return false; + if (isSuperAdmin(userCompanyCode)) return true; + String deptCompanyCode = dept.get("company_code") != null ? dept.get("company_code").toString() : null; + if (deptCompanyCode == null) return false; + return userCompanyCode.equals(deptCompanyCode) || "*".equals(deptCompanyCode); + } } diff --git a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java index dd522eb4..44ec7040 100644 --- a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java +++ b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java @@ -48,15 +48,17 @@ public class StartupSchemaMigrator { // 메타 DB 는 Flyway V017 로도 적용되지만 프로비저닝된 테넌트 DB 는 // 회사 생성 시점 스냅샷이 박혀있으므로 부팅 때 모든 활성 DB 에 동기화. // SEQ 만 갱신 → 멱등. + // 타입 주의: SEQ 가 varchar 이므로 THEN 값도 문자열 리터럴로 줄 것 + // (정수 리터럴이면 ELSE SEQ 와 CASE 타입 불일치 42804 발생). """ UPDATE MENU_INFO SET SEQ = CASE MENU_NAME_KOR - WHEN '회사관리' THEN 100 - WHEN '부서관리' THEN 200 - WHEN '사용자관리' THEN 300 - WHEN '메뉴관리' THEN 400 - WHEN '권한관리' THEN 500 - WHEN '권한 그룹관리' THEN 600 + WHEN '회사관리' THEN '100' + WHEN '부서관리' THEN '200' + WHEN '사용자관리' THEN '300' + WHEN '메뉴관리' THEN '400' + WHEN '권한관리' THEN '500' + WHEN '권한 그룹관리' THEN '600' ELSE SEQ END WHERE MENU_TYPE = '0' @@ -67,7 +69,27 @@ public class StartupSchemaMigrator { '회사관리', '부서관리', '사용자관리', '메뉴관리', '권한관리', '권한 그룹관리' ) - """ + """, + + // V018 (1) 부서관리 V1 - DEPT_INFO 소프트삭제 컬럼. + // DELETE 동작이 hard 가 아니라 DELETED_AT = NOW() 로 전환됨. + // 메타 DB 는 Flyway V018 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화. + "ALTER TABLE DEPT_INFO ADD COLUMN IF NOT EXISTS DELETED_AT TIMESTAMP NULL", + + // V018 (2) DEPT_INFO 활성 부서 부분 인덱스 (DELETED_AT IS NULL 쿼리 가속) + "CREATE INDEX IF NOT EXISTS IDX_DEPT_INFO_ACTIVE ON DEPT_INFO (COMPANY_CODE, PARENT_DEPT_CODE) WHERE DELETED_AT IS NULL", + + // V019: 부서관리 V1 - DEPT_INFO 미사용/중복 컬럼 정리. + // 메타 DB 는 Flyway V019 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화. + // DROP IF EXISTS 로 멱등성 보장. + "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_SABUN", + "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_USER_ID", + "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ORG_HEAD", + "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS LOCATION_NAME", + "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SALES_YN", + "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SHOW_IN_CHART", + "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ERP_MANAGED", + "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS DATA_TYPE" ); @EventListener(ApplicationReadyEvent.class) diff --git a/backend-spring/src/main/java/com/erp/service/DepartmentService.java b/backend-spring/src/main/java/com/erp/service/DepartmentService.java index a2a75e79..f30dfb82 100644 --- a/backend-spring/src/main/java/com/erp/service/DepartmentService.java +++ b/backend-spring/src/main/java/com/erp/service/DepartmentService.java @@ -6,8 +6,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; @Service @Slf4j @@ -18,8 +20,14 @@ public class DepartmentService extends BaseService { // ────────────────────────────────────────────────── public List> getDepartments(String companyCode) { + return getDepartments(companyCode, false); + } + + /** soft-delete 대응 — includeDeleted=true 면 DELETED_AT 부서도 포함 */ + public List> getDepartments(String companyCode, boolean includeDeleted) { Map params = new HashMap<>(); params.put("company_code", companyCode); + params.put("include_deleted", includeDeleted); List> departments = sqlSession.selectList("department.selectDepartments", params); // member_count를 int로 변환 @@ -34,12 +42,20 @@ public class DepartmentService extends BaseService { return departments; } + /** active 부서만 반환. deleted 면 null. 복구 흐름은 getDepartmentIncludingDeleted 사용 */ public Map getDepartment(String deptCode) { Map params = new HashMap<>(); params.put("dept_code", deptCode); return sqlSession.selectOne("department.selectDepartmentByCode", params); } + /** deleted 부서까지 포함 — 복구 검증 / 부모 deleted 체크 등 internal 흐름용 */ + public Map getDepartmentIncludingDeleted(String deptCode) { + Map params = new HashMap<>(); + params.put("dept_code", deptCode); + return sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", params); + } + @Transactional public Map createDepartment(String companyCode, Map body) { // 프론트엔드는 snake_case로 전송 (Node.js 호환) @@ -65,10 +81,22 @@ public class DepartmentService extends BaseService { ? (String) company.get("company_name") : companyCode; - // 부서 코드 생성 - Map codeResult = sqlSession.selectOne("department.selectNextDeptNumber", null); - long nextNumber = codeResult != null ? ((Number) codeResult.get("next_number")).longValue() : 1L; - String deptCode = "DEPT_" + nextNumber; + // 부서 코드 결정 — body 에 dept_code 가 있고 형식 OK 이면 override, 없으면 자동생성 + String requestedCode = trimString(bodyParam(body, "dept_code", "dept_code")); + String deptCode; + if (requestedCode != null && requestedCode.matches("^[A-Za-z0-9_]+$")) { + // 중복 체크 + Map existing = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", + Map.of("dept_code", requestedCode)); + if (existing != null) { + throw new IllegalArgumentException("부서 코드 \"" + requestedCode + "\" 가 이미 존재합니다."); + } + deptCode = requestedCode; + } else { + Map codeResult = sqlSession.selectOne("department.selectNextDeptNumber", null); + long nextNumber = codeResult != null ? ((Number) codeResult.get("next_number")).longValue() : 1L; + deptCode = "DEPT_" + nextNumber; + } // 부서 생성 (전체 필드) Map insertParams = new HashMap<>(); @@ -82,23 +110,15 @@ public class DepartmentService extends BaseService { insertParams.put("org_system", nullIfBlank(bodyParam(body, "org_system", "org_system"))); insertParams.put("approval_manager", nullIfBlank(bodyParam(body, "approval_manager", "approval_manager"))); insertParams.put("dept_manager", nullIfBlank(bodyParam(body, "dept_manager", "dept_manager"))); - insertParams.put("org_head", nullIfBlank(bodyParam(body, "org_head", "org_head"))); insertParams.put("zipcode", nullIfBlank(bodyParam(body, "zipcode", "zipcode"))); insertParams.put("address1", nullIfBlank(bodyParam(body, "address1", "address1"))); insertParams.put("address2", nullIfBlank(bodyParam(body, "address2", "address2"))); insertParams.put("start_date", nullIfBlank(bodyParam(body, "start_date", "start_date"))); insertParams.put("end_date", nullIfBlank(bodyParam(body, "end_date", "end_date"))); - insertParams.put("erp_managed", bodyParam(body, "erp_managed", "erp_managed")); - insertParams.put("show_in_chart", bodyParam(body, "show_in_chart", "show_in_chart")); insertParams.put("sort_order", bodyParam(body, "sort_order", "sort_order")); insertParams.put("status", bodyParam(body, "status", "status")); - // dept_info 추가 필드 (master_*, location_*, data_type, sales_yn) - insertParams.put("master_sabun", nullIfBlank(bodyParam(body, "master_sabun", "master_sabun"))); - insertParams.put("master_user_id", nullIfBlank(bodyParam(body, "master_user_id", "master_user_id"))); + // dept_info 추가 필드 (location 코드만 유지 — V019 정리 후) insertParams.put("location", nullIfBlank(bodyParam(body, "location", "location"))); - insertParams.put("location_name", nullIfBlank(bodyParam(body, "location_name", "location_name"))); - insertParams.put("data_type", bodyParam(body, "data_type", "data_type")); - insertParams.put("sales_yn", bodyParam(body, "sales_yn", "sales_yn")); sqlSession.insert("department.insertDepartment", insertParams); log.info("부서 생성 성공: deptCode={}, deptName={}", deptCode, deptName); @@ -115,32 +135,28 @@ public class DepartmentService extends BaseService { throw new IllegalArgumentException("부서명을 입력해주세요."); } + // 사이클 가드 — 자기 자신/자손을 부모로 지정하려는 시도 차단 + Object newParent = nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code")); + verifyParentCycle(deptCode, newParent != null ? newParent.toString() : null); + Map params = new HashMap<>(); params.put("dept_code", deptCode); params.put("dept_name", deptName); - params.put("parent_dept_code", nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code"))); + params.put("parent_dept_code", newParent); params.put("short_name", nullIfBlank(bodyParam(body, "short_name", "short_name"))); params.put("dept_type", bodyParam(body, "dept_type", "dept_type")); params.put("org_system", nullIfBlank(bodyParam(body, "org_system", "org_system"))); params.put("approval_manager", nullIfBlank(bodyParam(body, "approval_manager", "approval_manager"))); params.put("dept_manager", nullIfBlank(bodyParam(body, "dept_manager", "dept_manager"))); - params.put("org_head", nullIfBlank(bodyParam(body, "org_head", "org_head"))); params.put("zipcode", nullIfBlank(bodyParam(body, "zipcode", "zipcode"))); params.put("address1", nullIfBlank(bodyParam(body, "address1", "address1"))); params.put("address2", nullIfBlank(bodyParam(body, "address2", "address2"))); params.put("start_date", nullIfBlank(bodyParam(body, "start_date", "start_date"))); params.put("end_date", nullIfBlank(bodyParam(body, "end_date", "end_date"))); - params.put("erp_managed", bodyParam(body, "erp_managed", "erp_managed")); - params.put("show_in_chart", bodyParam(body, "show_in_chart", "show_in_chart")); params.put("sort_order", bodyParam(body, "sort_order", "sort_order")); params.put("status", bodyParam(body, "status", "status")); - // dept_info 추가 필드 - params.put("master_sabun", nullIfBlank(bodyParam(body, "master_sabun", "master_sabun"))); - params.put("master_user_id", nullIfBlank(bodyParam(body, "master_user_id", "master_user_id"))); + // dept_info 추가 필드 (location 코드만 유지 — V019 정리 후) params.put("location", nullIfBlank(bodyParam(body, "location", "location"))); - params.put("location_name", nullIfBlank(bodyParam(body, "location_name", "location_name"))); - params.put("data_type", bodyParam(body, "data_type", "data_type")); - params.put("sales_yn", bodyParam(body, "sales_yn", "sales_yn")); int updated = sqlSession.update("department.updateDepartment", params); if (updated == 0) { @@ -153,32 +169,105 @@ public class DepartmentService extends BaseService { return sqlSession.selectOne("department.selectDepartmentByCode", findParams); } + /** + * 부서 soft-delete (V1 slim scope). + * - hard delete 가 아니라 DELETED_AT = NOW() 로 마킹 + * - USER_DEPT 행은 보존 → 복구 시 멤버 그대로 살아남 + * - 활성 자식 부서가 있으면 차단 (deleted 자식은 무시) + * - 반환: 0 = soft-delete 성공 (보존된 부서원 수는 복구 시점에 재조회) + * -1 = not found / already deleted + */ @Transactional public int deleteDepartment(String deptCode) { - // 하위 부서 확인 + // 활성 하위 부서 확인 (deleted 자식은 자식 카운트에서 제외) Map childParams = new HashMap<>(); childParams.put("dept_code", deptCode); + childParams.put("include_deleted", false); Number childCountNum = sqlSession.selectOne("department.selectChildDeptCount", childParams); int childCount = childCountNum != null ? childCountNum.intValue() : 0; if (childCount > 0) { throw new IllegalStateException("하위 부서가 있는 부서는 삭제할 수 없습니다. 먼저 하위 부서를 삭제해주세요."); } - // 부서원 삭제 - Map memberParams = new HashMap<>(); - memberParams.put("dept_code", deptCode); - int memberCount = sqlSession.delete("department.deleteUserDeptByDeptCode", memberParams); - - // 부서 삭제 + // soft-delete: DELETED_AT = NOW(). USER_DEPT 보존 Map deptParams = new HashMap<>(); deptParams.put("dept_code", deptCode); - int deleted = sqlSession.delete("department.deleteDepartment", deptParams); - if (deleted == 0) { - return -1; // not found + int updated = sqlSession.update("department.deleteDepartment", deptParams); + if (updated == 0) { + return -1; // not found 또는 이미 deleted } - log.info("부서 삭제 성공: deptCode={}, 제외된 부서원 수={}", deptCode, memberCount); - return memberCount; + log.info("부서 soft-delete 성공: deptCode={} (USER_DEPT 행 보존)", deptCode); + return 0; + } + + /** + * 부서 복구 (V1 slim scope). + * - DELETED_AT = NULL 로 되돌림 + * - 부모가 있고 부모도 deleted 상태면 차단 (orphan 방지) + * - USER_DEPT 행은 soft-delete 시점부터 보존되어왔으므로 자동 복원됨 + */ + @Transactional + public RestoreResult restoreDepartment(String deptCode) { + Map dept = getDepartmentIncludingDeleted(deptCode); + if (dept == null) { + return RestoreResult.NOT_FOUND; + } + if (dept.get("deleted_at") == null) { + return RestoreResult.NOT_DELETED; + } + + // 부모 deleted 검증 + Object parentObj = dept.get("parent_dept_code"); + if (parentObj != null && !parentObj.toString().isBlank()) { + String parentCode = parentObj.toString(); + Map parent = getDepartmentIncludingDeleted(parentCode); + if (parent != null && parent.get("deleted_at") != null) { + return RestoreResult.PARENT_DELETED; + } + } + + Map params = new HashMap<>(); + params.put("dept_code", deptCode); + int restored = sqlSession.update("department.restoreDepartment", params); + if (restored == 0) { + return RestoreResult.NOT_DELETED; // race: 동시 복구 + } + + log.info("부서 복구 성공: deptCode={}", deptCode); + return RestoreResult.OK; + } + + public enum RestoreResult { + OK, NOT_FOUND, NOT_DELETED, PARENT_DELETED + } + + /** + * parent_dept_code 변경 시 사이클 검증. + * deptCode 의 새 부모로 newParent 를 지정하려고 할 때, newParent 또는 그 ancestor + * 체인에 deptCode 자체가 들어있다면 사이클이 생기므로 차단. + * (newParent == null 은 최상위로 만들기 — 항상 안전) + */ + private void verifyParentCycle(String deptCode, String newParent) { + if (newParent == null) return; + if (newParent.equals(deptCode)) { + throw new IllegalArgumentException("자기 자신을 상위 부서로 지정할 수 없습니다."); + } + Set visited = new HashSet<>(); + String cur = newParent; + while (cur != null && !visited.contains(cur)) { + if (deptCode.equals(cur)) { + throw new IllegalArgumentException("선택한 부서는 현재 부서의 하위 부서이므로 상위 부서로 지정할 수 없습니다."); + } + visited.add(cur); + Map p = sqlSession.selectOne( + "department.selectDepartmentByCodeIncludingDeleted", + Map.of("dept_code", cur) + ); + if (p == null) break; + Object parent = p.get("parent_dept_code"); + cur = parent != null ? parent.toString() : null; + } } // ────────────────────────────────────────────────── @@ -234,14 +323,33 @@ public class DepartmentService extends BaseService { @Transactional public boolean removeDeptMember(String deptCode, String userId) { - Map params = new HashMap<>(); - params.put("user_id", userId); - params.put("dept_code", deptCode); - int deleted = sqlSession.delete("department.deleteDeptMember", params); + // 1. 제거 전 — 이 row 가 primary 였는지 확인 + Map existParams = new HashMap<>(); + existParams.put("user_id", userId); + existParams.put("dept_code", deptCode); + Map existing = sqlSession.selectOne("department.selectExistingMember", existParams); + boolean wasPrimary = existing != null && Boolean.TRUE.equals(existing.get("is_primary")); + + // 2. 제거 + int deleted = sqlSession.delete("department.deleteDeptMember", existParams); if (deleted == 0) { return false; } - log.info("부서원 제거 성공: userId={}, deptCode={}", userId, deptCode); + + // 3. primary 였으면 다른 USER_DEPT row 중 하나 promote + if (wasPrimary) { + Map remaining = sqlSession.selectOne("department.selectFirstUserDept", + Map.of("user_id", userId)); + if (remaining != null && remaining.get("dept_code") != null) { + Map promote = new HashMap<>(); + promote.put("user_id", userId); + promote.put("dept_code", remaining.get("dept_code").toString()); + sqlSession.update("department.setUserPrimaryDept", promote); + log.info("주 부서 자동 승격: userId={}, newPrimaryDept={}", userId, remaining.get("dept_code")); + } + } + + log.info("부서원 제거 성공: userId={}, deptCode={}, wasPrimary={}", userId, deptCode, wasPrimary); return true; } diff --git a/backend-spring/src/main/resources/db/migration/V017__reorder_company_mgmt_admin_menus.sql b/backend-spring/src/main/resources/db/migration/V017__reorder_company_mgmt_admin_menus.sql index 2b6eb216..05951e74 100644 --- a/backend-spring/src/main/resources/db/migration/V017__reorder_company_mgmt_admin_menus.sql +++ b/backend-spring/src/main/resources/db/migration/V017__reorder_company_mgmt_admin_menus.sql @@ -14,15 +14,19 @@ -- -- 멱등성: SEQ 만 갱신하므로 중복 실행 안전. MENU_NAME_KOR + MENU_TYPE + COMPANY_CODE -- 로 식별하여 다른 회사/사용자 메뉴는 영향 없음. +-- +-- 타입 주의: MENU_INFO.SEQ 컬럼은 character varying 이라 정수 리터럴을 그대로 +-- 쓰면 CASE 가 ELSE SEQ(varchar) 와 타입 불일치(42804) 로 실패한다. +-- → THEN 값은 반드시 문자열 리터럴로 줄 것. UPDATE MENU_INFO SET SEQ = CASE MENU_NAME_KOR - WHEN '회사관리' THEN 100 - WHEN '부서관리' THEN 200 - WHEN '사용자관리' THEN 300 - WHEN '메뉴관리' THEN 400 - WHEN '권한관리' THEN 500 - WHEN '권한 그룹관리' THEN 600 + WHEN '회사관리' THEN '100' + WHEN '부서관리' THEN '200' + WHEN '사용자관리' THEN '300' + WHEN '메뉴관리' THEN '400' + WHEN '권한관리' THEN '500' + WHEN '권한 그룹관리' THEN '600' ELSE SEQ END WHERE MENU_TYPE = '0' diff --git a/backend-spring/src/main/resources/db/migration/V018__dept_mgmt_v1_softdelete.sql b/backend-spring/src/main/resources/db/migration/V018__dept_mgmt_v1_softdelete.sql new file mode 100644 index 00000000..3c1212cf --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V018__dept_mgmt_v1_softdelete.sql @@ -0,0 +1,18 @@ +-- V018: invyone 부서관리 V1 - soft-delete +-- 부서 삭제를 hard-delete → soft-delete 로 전환하기 위한 schema 변경. +-- Additive only: 기존 22 컬럼 / 11 endpoint 무변경. +-- 멱등: IF NOT EXISTS 가드. 중복 실행 안전. +-- +-- 멀티테넌트: 메타 DB 는 본 Flyway 가, 활성 테넌트는 StartupSchemaMigrator 가 +-- 동일 statement 를 부팅 시점에 적용. +-- +-- 후속 작업 (Slice 2.1): mapper/department.xml 의 deleteDepartment 를 UPDATE 로 교체, +-- restoreDepartment 신규, list/byCode 는 DELETED_AT IS NULL 옵션 처리. + +-- (1) DEPT_INFO 소프트삭제 컬럼 +ALTER TABLE DEPT_INFO ADD COLUMN IF NOT EXISTS DELETED_AT TIMESTAMP NULL; + +-- (2) DEPT_INFO 활성 부서 부분 인덱스 (DELETED_AT IS NULL 쿼리 가속) +CREATE INDEX IF NOT EXISTS IDX_DEPT_INFO_ACTIVE + ON DEPT_INFO (COMPANY_CODE, PARENT_DEPT_CODE) + WHERE DELETED_AT IS NULL; diff --git a/backend-spring/src/main/resources/db/migration/V019__dept_mgmt_v1_cleanup_unused_columns.sql b/backend-spring/src/main/resources/db/migration/V019__dept_mgmt_v1_cleanup_unused_columns.sql new file mode 100644 index 00000000..d5c72f18 --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V019__dept_mgmt_v1_cleanup_unused_columns.sql @@ -0,0 +1,22 @@ +-- V019: 부서관리 미사용/중복 컬럼 정리 +-- 기준: 부서관리 모듈 내부에서만 사용 + 사용처 0 + 다른 컬럼과 중복. +-- DROP IF EXISTS 로 멱등성 보장. +-- +-- 대상 컬럼 (8개): +-- MASTER_SABUN - 부서장 사번 (DEPT_MANAGER 와 중복) +-- MASTER_USER_ID - 부서장 user_id (DEPT_MANAGER 와 중복, UI 미노출) +-- ORG_HEAD - 조직장 (DEPT_MANAGER 와 중복, 한국 SaaS 표준은 부서장 1명) +-- LOCATION_NAME - 위치명 (LOCATION 코드만 유지) +-- SALES_YN - 영업조직 Y/N (ORG_SYSTEM='sales' 와 중복) +-- SHOW_IN_CHART - 조직도 표시 (V2 까지 dead 로직) +-- ERP_MANAGED - ERP 관리 (분기 로직 없음) +-- DATA_TYPE - real/temp (DEPT_TYPE='temp' 와 충돌) + +ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_SABUN; +ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_USER_ID; +ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ORG_HEAD; +ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS LOCATION_NAME; +ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SALES_YN; +ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SHOW_IN_CHART; +ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ERP_MANAGED; +ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS DATA_TYPE; diff --git a/backend-spring/src/main/resources/mapper/admin.xml b/backend-spring/src/main/resources/mapper/admin.xml index d4fbdd8a..1211310b 100644 --- a/backend-spring/src/main/resources/mapper/admin.xml +++ b/backend-spring/src/main/resources/mapper/admin.xml @@ -728,14 +728,9 @@ DEPT_CODE , PARENT_DEPT_CODE , DEPT_NAME - , MASTER_SABUN - , MASTER_USER_ID , LOCATION - , LOCATION_NAME , CASE WHEN CREATED_DATE IS NOT NULL THEN TO_CHAR(CREATED_DATE, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') ELSE NULL END AS CREATED_DATE - , DATA_TYPE , STATUS - , SALES_YN , COMPANY_CODE , COMPANY_NAME FROM DEPT_INFO @@ -746,8 +741,7 @@ AND (DEPT_NAME ILIKE '%' || #{search} || '%' - OR DEPT_CODE ILIKE '%' || #{search} || '%' - OR LOCATION_NAME ILIKE '%' || #{search} || '%') + OR DEPT_CODE ILIKE '%' || #{search} || '%') ORDER BY PARENT_DEPT_CODE ASC NULLS FIRST, DEPT_NAME ASC diff --git a/backend-spring/src/main/resources/mapper/department.xml b/backend-spring/src/main/resources/mapper/department.xml index 5f00b785..f84c2ba4 100644 --- a/backend-spring/src/main/resources/mapper/department.xml +++ b/backend-spring/src/main/resources/mapper/department.xml @@ -3,7 +3,7 @@ "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> - + - + + + + - + - + @@ -98,22 +124,14 @@ ORG_SYSTEM, APPROVAL_MANAGER, DEPT_MANAGER, - ORG_HEAD, ZIPCODE, ADDRESS1, ADDRESS2, START_DATE, END_DATE, - ERP_MANAGED, - SHOW_IN_CHART, SORT_ORDER, STATUS, - MASTER_SABUN, - MASTER_USER_ID, LOCATION, - LOCATION_NAME, - DATA_TYPE, - SALES_YN, CREATED_DATE ) VALUES ( #{dept_code}, @@ -126,22 +144,14 @@ #{org_system}, #{approval_manager}, #{dept_manager}, - #{org_head}, #{zipcode}, #{address1}, #{address2}, #{start_date}::date, #{end_date}::date, - COALESCE(#{erp_managed}, 'Y'), - COALESCE(#{show_in_chart}, 'Y'), COALESCE(#{sort_order}, 10), COALESCE(#{status}, 'active'), - #{master_sabun}, - #{master_user_id}, #{location}, - #{location_name}, - COALESCE(#{data_type}, 'real'), - COALESCE(#{sales_yn}, 'N'), NOW() ) @@ -157,43 +167,48 @@ ORG_SYSTEM = #{org_system}, APPROVAL_MANAGER = #{approval_manager}, DEPT_MANAGER = #{dept_manager}, - ORG_HEAD = #{org_head}, ZIPCODE = #{zipcode}, ADDRESS1 = #{address1}, ADDRESS2 = #{address2}, START_DATE = #{start_date}::date, END_DATE = #{end_date}::date, - ERP_MANAGED = #{erp_managed}, - SHOW_IN_CHART = #{show_in_chart}, SORT_ORDER = #{sort_order}, STATUS = #{status}, - MASTER_SABUN = #{master_sabun}, - MASTER_USER_ID = #{master_user_id}, - LOCATION = #{location}, - LOCATION_NAME = #{location_name}, - DATA_TYPE = #{data_type}, - SALES_YN = #{sales_yn} + LOCATION = #{location} WHERE DEPT_CODE = #{dept_code} - + - + DELETE FROM USER_DEPT WHERE DEPT_CODE = #{dept_code} - - - DELETE FROM DEPT_INFO + + + UPDATE DEPT_INFO + SET DELETED_AT = NOW() WHERE DEPT_CODE = #{dept_code} - + AND DELETED_AT IS NULL + + + + + UPDATE DEPT_INFO + SET DELETED_AT = NULL + WHERE DEPT_CODE = #{dept_code} + AND DELETED_AT IS NOT NULL + - + + + + setBaseDate(e.target.value)} - disabled={periodMode !== "date"} - className="h-7 flex-1 text-xs" - /> - + {/* TODO V2: 사용기간 필터 — backend 미구현, V1 hidden */} + {false && ( +
+ + setPeriodMode(v as "all" | "date")} + className="flex items-center gap-3" + > +
+ + +
+
+ + +
+
+ setBaseDate(e.target.value)} + disabled={periodMode !== "date"} + className="h-7 flex-1 text-xs" + /> +
+ )} - - - - -
+
update("zipcode", e.target.value)} + className="h-8 w-[100px] text-sm" + /> + +
update("zipcode", e.target.value)} - className="h-8 w-[100px] text-sm" + value={draft.address1} + onChange={(e) => update("address1", e.target.value)} + className="h-8 text-sm" + /> + update("address2", e.target.value)} + className="h-8 text-sm" /> -
- update("address1", e.target.value)} - className="h-8 text-sm" - /> - update("address2", e.target.value)} - className="h-8 text-sm" - /> - -
+
+ )} - -
- update("start_date", e.target.value)} - className="h-8 text-sm" - /> -
- + {/* TODO V2: 사용기간 (시작일/종료일) — 필터 도입 시 사용. 컬럼은 DEPT_INFO.START_DATE/END_DATE 유지 */} + {false && ( + +
update("end_date", e.target.value)} - className="h-8 flex-1 text-sm" + value={draft.start_date} + onChange={(e) => update("start_date", e.target.value)} + className="h-8 text-sm" /> -
-
- - - -
- update("erp_managed", v as "Y" | "N")} - className="flex items-center gap-4" - > -
- - +
+ + update("end_date", e.target.value)} + className="h-8 flex-1 text-sm" + />
-
- - -
- -
- - update("show_in_chart", v as "Y" | "N")} - className="flex items-center gap-4" - > -
- - -
-
- - -
-
-
- + + )} - - update("master_sabun", e.target.value)} - className="h-8 text-sm" - placeholder="부서장 사번" - /> - - - - update("master_user_id", e.target.value)} - className="h-8 text-sm" - placeholder="user_id" - /> - - - -
+ {/* TODO V2: 위치 — 향후 매핑 가능성. 컬럼은 DEPT_INFO.LOCATION 유지 */} + {false && ( + update("location", e.target.value)} className="h-8 text-sm" placeholder="위치코드" /> - update("location_name", e.target.value)} - className="h-8 text-sm" - placeholder="위치명" - /> -
-
- - - update("data_type", v)} - className="flex items-center gap-4" - > -
- - -
-
- - -
-
-
- - - update("sales_yn", v as "Y" | "N")} - className="flex items-center gap-4" - > -
- - -
-
- - -
-
-
+ + )}
); diff --git a/frontend/components/admin/CompanyTable.tsx b/frontend/components/admin/CompanyTable.tsx index 86049d4b..17257247 100644 --- a/frontend/components/admin/CompanyTable.tsx +++ b/frontend/components/admin/CompanyTable.tsx @@ -17,9 +17,9 @@ interface CompanyTableProps { export function CompanyTable({ companies, isLoading, onEdit, onDelete }: CompanyTableProps) { const router = useRouter(); - // 부서 관리 페이지로 이동 - const handleManageDepartments = (company: Company) => { - router.push(`/admin/userMng/companyList/${company.company_code}/departments`); + // 부서 관리 페이지로 이동 (legacy deptMngList 가 캐노니컬 페이지) + const handleManageDepartments = (_company: Company) => { + router.push(`/admin/userMng/deptMngList`); }; // 디스크 사용량 포맷팅 함수 diff --git a/frontend/components/admin/department/DepartmentStructure.tsx b/frontend/components/admin/department/DepartmentStructure.tsx index 6a43aa29..083d334e 100644 --- a/frontend/components/admin/department/DepartmentStructure.tsx +++ b/frontend/components/admin/department/DepartmentStructure.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react"; +import { Plus, ChevronDown, ChevronRight, Users, Trash2, Eye, EyeOff, Undo2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; @@ -31,6 +31,9 @@ export function DepartmentStructure({ const [expandedDepts, setExpandedDepts] = useState>(new Set()); const [isLoading, setIsLoading] = useState(false); + // V1: soft-delete 된 부서 표시 토글 + const [showDeleted, setShowDeleted] = useState(false); + // 부서 추가 모달 const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [parentDeptForAdd, setParentDeptForAdd] = useState(null); @@ -42,15 +45,15 @@ export function DepartmentStructure({ const [deptToDelete, setDeptToDelete] = useState<{ code: string; name: string } | null>(null); const [deleteErrorMessage, setDeleteErrorMessage] = useState(null); - // 부서 목록 로드 + // 부서 목록 로드 — showDeleted 도 의존성에 포함 useEffect(() => { loadDepartments(); - }, [companyCode, refreshTrigger]); + }, [companyCode, refreshTrigger, showDeleted]); const loadDepartments = async () => { setIsLoading(true); try { - const response = await departmentAPI.getDepartments(companyCode); + const response = await departmentAPI.getDepartments(companyCode, { includeDeleted: showDeleted }); if (response.success && (response as any).data) { setDepartments((response as any).data); } else { @@ -65,6 +68,34 @@ export function DepartmentStructure({ } }; + // V1: 부서 복구 핸들러 (soft-delete 된 부서 되살리기) + const handleRestoreDepartment = async (deptCode: string, deptName: string) => { + try { + const response = await departmentAPI.restoreDepartment(deptCode); + if (response.success) { + loadDepartments(); + toast({ + title: "부서 복구 완료", + description: `"${deptName}" 부서가 복구되었습니다.`, + variant: "default", + }); + } else { + toast({ + title: "복구 불가", + description: (response as any).error || "부서 복구에 실패했습니다.", + variant: "destructive", + }); + } + } catch (error) { + console.error("부서 복구 실패:", error); + toast({ + title: "부서 복구 실패", + description: "복구 중 오류가 발생했습니다.", + variant: "destructive", + }); + } + }; + // 부서 트리 구조 생성 const buildTree = (parentCode: string | null): Department[] => { return departments @@ -145,10 +176,13 @@ export function DepartmentStructure({ setDeptToDelete(null); loadDepartments(); - // 성공 메시지 Toast로 표시 (부서원 수 포함) + // V1 soft-delete: 복구 가능 안내 추가 + const isSoft = (response as any)?.data?.soft_deleted === true; toast({ - title: "부서 삭제 완료", - description: (response as any).message || "부서가 삭제되었습니다.", + title: isSoft ? "부서 삭제됨 (복구 가능)" : "부서 삭제 완료", + description: isSoft + ? `"${deptToDelete.name}" 부서를 휴지통으로 보냈습니다. '삭제 부서 보기' 토글로 복구할 수 있습니다.` + : (response as any).message || "부서가 삭제되었습니다.", variant: "default", }); } else { @@ -184,17 +218,18 @@ export function DepartmentStructure({ const hasChildren = departments.some((d) => d.parent_dept_code === dept.dept_code); const isExpanded = expandedDepts.has(dept.dept_code); const isSelected = selectedDepartment?.dept_code === dept.dept_code; + const isDeleted = !!(dept as any).deleted_at; return (
- {/* 부서 항목 */} + {/* 부서 항목 — soft-delete 시 회색+취소선 */}
-
onSelectDepartment(dept)}> +
!isDeleted && onSelectDepartment(dept)}> {/* 확장/축소 아이콘 */} {hasChildren ? (
+ + {/* deleted 배지 */} + {isDeleted && 삭제됨}
- {/* 액션 버튼 */} + {/* 액션 버튼 — deleted 면 복구 버튼만, 아니면 추가/삭제 버튼 */}
- - + {isDeleted ? ( + + ) : ( + <> + + + + )}
@@ -259,10 +316,23 @@ export function DepartmentStructure({ {/* 헤더 */}

부서 구조

- +
+ {/* V1: soft-delete 부서 표시 토글 */} + + +
{/* 부서 트리 */} @@ -338,7 +408,9 @@ export function DepartmentStructure({

{deptToDelete?.name} 부서를 삭제하시겠습니까?

-

이 작업은 되돌릴 수 없습니다.

+

+ 부서원은 보존됩니다. 휴지통(상단 '삭제 부서 보기' 토글)에서 복구할 수 있습니다. +

+ ) : ( +
+ )} + + {/* multi: 체크 표시 */} + {mode === "multi" && ( +
+ {isSel && } +
+ )} + + {/* 부서명 + 코드 */} +
+ {d.dept_name} + {d.dept_code} +
+ + {deleted && ( + 삭제됨 + )} +
+ + {hasChildren && isOpen && renderTree(d.dept_code, level + 1, nextVisited)} + + ); + }); + }; + + // 루트 — parent_dept_code 가 null/빈문자열인 부서들 + const rootList = useMemo(() => childrenOf(null), [departments]); + const hasAny = rootList.length > 0 || departments.some((d) => !d.parent_dept_code); + const noResults = searchTerm.length > 0 && (visibleCodes?.size ?? 0) === 0; + + return ( + !o && onClose()}> + + + {title} + + +
+ {/* 검색 */} +
+ + setSearchInput(e.target.value)} + placeholder="부서명 또는 코드로 검색" + className="pl-8 pr-8" + autoFocus + /> + {searchInput && ( + + )} +
+ + {/* 트리 */} +
+ {isLoading ? ( +
로딩 중...
+ ) : !hasAny ? ( +
부서가 없습니다.
+ ) : noResults ? ( +
검색 결과 없음
+ ) : ( + renderTree(null, 0, new Set()) + )} +
+
+ + + + {mode === "multi" && ( + + )} + +
+
+ ); +} + +export default DepartmentPicker; diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index d859fddd..1cd02ebd 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -195,11 +195,6 @@ const DYNAMIC_ADMIN_PATTERNS: Array<{ getImport: () => import("@/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page"), extractParams: (m) => ({ diagramId: m[1] }), }, - { - pattern: /^\/admin\/userMng\/companyList\/([^/]+)\/departments$/, - getImport: () => import("@/app/(main)/admin/userMng/companyList/[companyCode]/departments/page"), - extractParams: (m) => ({ companyCode: m[1] }), - }, { pattern: /^\/admin\/standards\/([^/]+)\/edit$/, getImport: () => import("@/app/(main)/admin/standards/[webType]/edit/page"), diff --git a/frontend/lib/api/department.ts b/frontend/lib/api/department.ts index 2486d11f..34be751a 100644 --- a/frontend/lib/api/department.ts +++ b/frontend/lib/api/department.ts @@ -6,12 +6,18 @@ import { apiClient } from "./client"; import { Department, DepartmentMember, DepartmentFormData } from "@/types/department"; /** - * 부서 목록 조회 (회사별) + * 부서 목록 조회 (회사별). + * options.includeDeleted=true 시 soft-delete 된 부서도 포함. */ -export async function getDepartments(companyCode: string) { +export async function getDepartments( + companyCode: string, + options?: { includeDeleted?: boolean }, +) { try { const url = `/departments/companies/${companyCode}/departments`; - const response = await apiClient.get<{ success: boolean; data: Department[] }>(url); + const response = await apiClient.get<{ success: boolean; data: Department[] }>(url, { + params: options?.includeDeleted ? { include_deleted: true } : undefined, + }); return response.data; } catch (error: any) { console.error("부서 목록 조회 실패:", error); @@ -67,11 +73,16 @@ export async function updateDepartment(deptCode: string, data: DepartmentFormDat } /** - * 부서 삭제 + * 부서 삭제 (V1: soft-delete). + * 응답 호환: 기존 { success, message } 에 data.soft_deleted=true 필드 추가. */ export async function deleteDepartment(deptCode: string) { try { - const response = await apiClient.delete<{ success: boolean }>(`/departments/${deptCode}`); + const response = await apiClient.delete<{ + success: boolean; + message?: string; + data?: { soft_deleted?: boolean; dept_code?: string }; + }>(`/departments/${deptCode}`); return response.data; } catch (error: any) { console.error("부서 삭제 실패:", error); @@ -79,6 +90,27 @@ export async function deleteDepartment(deptCode: string) { } } +/** + * 부서 복구 (V1 신규 — soft-delete 된 부서 되살리기). + * 부모가 deleted 면 차단 (400) → "상위 부서를 먼저 복구해주세요" 메시지. + */ +export async function restoreDepartment(deptCode: string) { + try { + const response = await apiClient.post<{ + success: boolean; + message?: string; + data?: { dept_code?: string; restored?: boolean }; + }>(`/departments/${deptCode}/restore`); + return response.data; + } catch (error: any) { + console.error("부서 복구 실패:", error); + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +} + /** * 부서원 목록 조회 */ diff --git a/frontend/types/department.ts b/frontend/types/department.ts index df373623..5a75eadf 100644 --- a/frontend/types/department.ts +++ b/frontend/types/department.ts @@ -2,19 +2,14 @@ * 부서 관리 관련 타입 정의 */ -// 부서 정보 (dept_info 테이블 1:1 매핑) +// 부서 정보 (dept_info 테이블 1:1 매핑) — V019 정리 후 export interface Department { dept_code: string; // 부서 코드 (PK) parent_dept_code?: string | null; // 상위 부서 코드 dept_name: string; // 부서명 - master_sabun?: string | null; // 부서장 사번 - master_user_id?: string | null; // 부서장 사용자ID - location?: string | null; // 위치코드 - location_name?: string | null; // 위치명 + location?: string | null; // 위치코드 (UI hide, V2 매핑용 컬럼만 유지) created_date?: string | null; // 생성일시 - data_type?: string | null; // 데이터 구분 (real/temp) status?: "active" | "inactive" | null; // 사용여부 - sales_yn?: "Y" | "N" | null; // 영업조직 여부 company_name?: string | null; // 회사명 company_code: string; // 회사 코드 short_name?: string | null; // 부서약칭 @@ -22,17 +17,15 @@ export interface Department { org_system?: string | null; // 조직체계 approval_manager?: string | null; // 결재관리자 user_id dept_manager?: string | null; // 부서관리자 user_id - org_head?: string | null; // 조직장 user_id zipcode?: string | null; address1?: string | null; address2?: string | null; start_date?: string | null; // YYYY-MM-DD end_date?: string | null; // YYYY-MM-DD - erp_managed?: "Y" | "N" | null; - show_in_chart?: "Y" | "N" | null; sort_order?: number | null; created_at?: string; updated_at?: string; + deleted_at?: string | null; // V1: soft-delete 시각. NULL=active, 값 있음=휴지통 // UI용 추가 필드 children?: Department[]; member_count?: number; @@ -59,7 +52,7 @@ export interface UserDepartmentMapping { created_at?: string; } -// 부서 등록/수정 폼 데이터 — dept_info 스키마 1:1 +// 부서 등록/수정 폼 데이터 — dept_info 스키마 1:1 (V019 정리 후) export interface DepartmentFormData { dept_name: string; // 부서명 (필수) parent_dept_code?: string | null; @@ -68,23 +61,15 @@ export interface DepartmentFormData { org_system?: string | null; approval_manager?: string | null; dept_manager?: string | null; - org_head?: string | null; zipcode?: string | null; address1?: string | null; address2?: string | null; start_date?: string | null; end_date?: string | null; - erp_managed?: "Y" | "N" | null; - show_in_chart?: "Y" | "N" | null; sort_order?: number | null; status?: "active" | "inactive" | null; - // dept_info 추가 필드 - master_sabun?: string | null; - master_user_id?: string | null; + // dept_info 추가 필드 (location 코드만 유지) location?: string | null; - location_name?: string | null; - data_type?: string | null; - sales_yn?: "Y" | "N" | null; dept_code?: string | null; // 일괄등록용 (자동 부여 시 미전달) }