Files
invyone/backend-spring/src/main/java/com/erp/controller/DepartmentController.java
T
johngreen 0e895a90fa 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>
2026-05-08 08:34:23 +09:00

369 lines
17 KiB
Java

package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.DepartmentService;
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;
@RestController
@RequestMapping("/api/departments")
@RequiredArgsConstructor
@Slf4j
public class DepartmentController {
private final DepartmentService departmentService;
/**
* 부서 목록 조회 (회사별).
* 기본은 active 부서만. ?include_deleted=true 시 soft-delete 된 부서도 포함.
* GET /api/departments/companies/{companyCode}/departments[?include_deleted=true]
*/
@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) {
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403)
.body(ApiResponse.error("해당 회사의 부서를 조회할 권한이 없습니다."));
}
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted);
return ResponseEntity.ok(ApiResponse.success(departments, "부서 목록 조회 성공"));
}
/**
* 부서 상세 조회.
* - 기본: active 부서만 (DELETED_AT IS NULL)
* - ?include_deleted=true: soft-delete 된 부서도 조회 가능 (복구·이력 화면용)
* - 회사 격리: 본인 회사 부서만, SUPER_ADMIN 은 전체
* GET /api/departments/{deptCode}[?include_deleted=true]
*/
@GetMapping("/{deptCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getDepartment(
@PathVariable String deptCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted) {
Map<String, Object> 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, "부서 조회 성공"));
}
/**
* 부서 생성
* POST /api/departments/companies/{companyCode}/departments
*/
@PostMapping("/companies/{companyCode}/departments")
public ResponseEntity<ApiResponse<Map<String, Object>>> createDepartment(
@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("해당 회사의 부서를 생성할 권한이 없습니다."));
}
try {
Map<String, Object> created = departmentService.createDepartment(companyCode, body);
return ResponseEntity.status(201).body(ApiResponse.success(created, "부서가 생성되었습니다."));
} 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()));
}
}
/**
* 부서 수정
* PUT /api/departments/{deptCode}
*/
@PutMapping("/{deptCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateDepartment(
@PathVariable String deptCode,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode,
@RequestBody Map<String, Object> body) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> 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<String, Object> updated = departmentService.updateDepartment(deptCode, body);
if (updated == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(updated, "부서가 수정되었습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/**
* 부서 삭제 (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<ApiResponse<Map<String, Object>>> deleteDepartment(
@PathVariable String deptCode,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> 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 result = departmentService.deleteDepartment(deptCode);
if (result == -1) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
}
Map<String, Object> 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<ApiResponse<Map<String, Object>>> restoreDepartment(
@PathVariable String deptCode,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> 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<String, Object> 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<ApiResponse<List<Map<String, Object>>>> getDeptMembers(
@PathVariable String deptCode,
@RequestAttribute("company_code") String userCompanyCode) {
Map<String, Object> 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<Map<String, Object>> members = departmentService.getDeptMembers(deptCode);
return ResponseEntity.ok(ApiResponse.success(members, "부서원 목록 조회 성공"));
}
/**
* 사용자 검색 (부서원 추가용)
* GET /api/departments/companies/{companyCode}/users/search
*/
@GetMapping("/companies/{companyCode}/users/search")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> searchUsers(
@PathVariable String companyCode,
@RequestParam(required = false) String search) {
if (search == null || search.isBlank()) {
return ResponseEntity.status(400).body(ApiResponse.error("검색어를 입력해주세요."));
}
List<Map<String, Object>> users = departmentService.searchUsers(companyCode, search);
return ResponseEntity.ok(ApiResponse.success(users, "사용자 검색 성공"));
}
/**
* 부서원 추가
* POST /api/departments/{deptCode}/members
*/
@PostMapping("/{deptCode}/members")
public ResponseEntity<ApiResponse<Void>> addDeptMember(
@PathVariable String deptCode,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode,
@RequestBody Map<String, Object> body) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> 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");
if (userIdObj == null || userIdObj.toString().isBlank()) {
return ResponseEntity.status(400).body(ApiResponse.error("사용자 ID를 입력해주세요."));
}
String userId = userIdObj.toString();
try {
departmentService.addDeptMember(deptCode, userId);
return ResponseEntity.status(201).body(ApiResponse.success(null, "부서원이 추가되었습니다."));
} catch (DepartmentService.DuplicateMemberException e) {
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(404).body(ApiResponse.error(e.getMessage()));
}
}
/**
* 부서원 제거
* DELETE /api/departments/{deptCode}/members/{userId}
*/
@DeleteMapping("/{deptCode}/members/{userId}")
public ResponseEntity<ApiResponse<Void>> removeDeptMember(
@PathVariable String deptCode,
@PathVariable String userId,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> 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("해당 부서원을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(null, "부서원이 제거되었습니다."));
}
/**
* 주 부서 설정
* PUT /api/departments/{deptCode}/members/{userId}/primary
*/
@PutMapping("/{deptCode}/members/{userId}/primary")
public ResponseEntity<ApiResponse<Void>> setPrimaryDept(
@PathVariable String deptCode,
@PathVariable String userId,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> 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, "주 부서가 설정되었습니다."));
}
// ──────────────────────────────────────────────────
// 내부 유틸
// ──────────────────────────────────────────────────
private boolean isAdmin(String role) {
return isSuperAdmin(role) || "COMPANY_ADMIN".equals(role);
}
private boolean isSuperAdmin(String companyCodeOrRole) {
return "*".equals(companyCodeOrRole) || "SUPER_ADMIN".equals(companyCodeOrRole);
}
/**
* 회사 격리 검증. SUPER_ADMIN ('*') 은 모든 회사 접근 가능.
* 일반 ADMIN/USER 는 자기 회사 + 글로벌 ('*') 부서만.
*/
private boolean canAccessDept(Map<String, Object> 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);
}
}