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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user