feat(부서관리): V1 슬림 스코프 + 트리 컨텍스트 메뉴 UX 리디자인

백엔드:
- 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 08:34:23 +09:00
parent 798fdf18b3
commit 0e895a90fa
18 changed files with 1592 additions and 653 deletions
@@ -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<Map<String, Object>> getDepartments(String companyCode) {
return getDepartments(companyCode, false);
}
/** soft-delete 대응 — includeDeleted=true 면 DELETED_AT 부서도 포함 */
public List<Map<String, Object>> getDepartments(String companyCode, boolean includeDeleted) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("include_deleted", includeDeleted);
List<Map<String, Object>> 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<String, Object> getDepartment(String deptCode) {
Map<String, Object> params = new HashMap<>();
params.put("dept_code", deptCode);
return sqlSession.selectOne("department.selectDepartmentByCode", params);
}
/** 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);
}
@Transactional
public Map<String, Object> createDepartment(String companyCode, Map<String, Object> body) {
// 프론트엔드는 snake_case로 전송 (Node.js 호환)
@@ -65,10 +81,22 @@ public class DepartmentService extends BaseService {
? (String) company.get("company_name")
: companyCode;
// 부서 코드 생성
Map<String, Object> 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<String, Object> existing = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted",
Map.of("dept_code", requestedCode));
if (existing != null) {
throw new IllegalArgumentException("부서 코드 \"" + requestedCode + "\" 가 이미 존재합니다.");
}
deptCode = requestedCode;
} else {
Map<String, Object> codeResult = sqlSession.selectOne("department.selectNextDeptNumber", null);
long nextNumber = codeResult != null ? ((Number) codeResult.get("next_number")).longValue() : 1L;
deptCode = "DEPT_" + nextNumber;
}
// 부서 생성 (전체 필드)
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> memberParams = new HashMap<>();
memberParams.put("dept_code", deptCode);
int memberCount = sqlSession.delete("department.deleteUserDeptByDeptCode", memberParams);
// 부서 삭제
// soft-delete: DELETED_AT = NOW(). USER_DEPT 보존
Map<String, Object> 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<String, Object> 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<String, Object> parent = getDepartmentIncludingDeleted(parentCode);
if (parent != null && parent.get("deleted_at") != null) {
return RestoreResult.PARENT_DELETED;
}
}
Map<String, Object> 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<String> visited = new HashSet<>();
String cur = newParent;
while (cur != null && !visited.contains(cur)) {
if (deptCode.equals(cur)) {
throw new IllegalArgumentException("선택한 부서는 현재 부서의 하위 부서이므로 상위 부서로 지정할 수 없습니다.");
}
visited.add(cur);
Map<String, Object> 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<String, Object> params = new HashMap<>();
params.put("user_id", userId);
params.put("dept_code", deptCode);
int deleted = sqlSession.delete("department.deleteDeptMember", params);
// 1. 제거 전 — 이 row 가 primary 였는지 확인
Map<String, Object> existParams = new HashMap<>();
existParams.put("user_id", userId);
existParams.put("dept_code", deptCode);
Map<String, Object> 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<String, Object> remaining = sqlSession.selectOne("department.selectFirstUserDept",
Map.of("user_id", userId));
if (remaining != null && remaining.get("dept_code") != null) {
Map<String, Object> 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;
}