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
@@ -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<ApiResponse<List<Map<String, Object>>>> 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<Map<String, Object>> departments = departmentService.getDepartments(companyCode);
List<Map<String, Object>> 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<ApiResponse<Map<String, Object>>> getDepartment(
@PathVariable String deptCode) {
@PathVariable String deptCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted) {
Map<String, Object> department = departmentService.getDepartment(deptCode);
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, "부서 조회 성공"));
}
@@ -87,12 +99,21 @@ public class DepartmentController {
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) {
@@ -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<ApiResponse<Void>> deleteDepartment(
public ResponseEntity<ApiResponse<Map<String, Object>>> 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<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 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<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) {
@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, "부서원 목록 조회 성공"));
@@ -168,12 +252,21 @@ public class DepartmentController {
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");
@@ -200,12 +293,21 @@ public class DepartmentController {
public ResponseEntity<ApiResponse<Void>> 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<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("해당 부서원을 찾을 수 없습니다."));
@@ -221,12 +323,21 @@ public class DepartmentController {
public ResponseEntity<ApiResponse<Void>> 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<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, "주 부서가 설정되었습니다."));
}
@@ -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<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);
}
}
@@ -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)
@@ -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;
}
@@ -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'
@@ -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;
@@ -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;
@@ -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 @@
</if>
<if test="search != null and search != ''">
AND (DEPT_NAME ILIKE '%' || #{search} || '%'
OR DEPT_CODE ILIKE '%' || #{search} || '%'
OR LOCATION_NAME ILIKE '%' || #{search} || '%')
OR DEPT_CODE ILIKE '%' || #{search} || '%')
</if>
ORDER BY PARENT_DEPT_CODE ASC NULLS FIRST, DEPT_NAME ASC
</select>
@@ -3,7 +3,7 @@
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="department">
<!-- 부서 목록 조회 (회사별, 부서원 수 포함) -->
<!-- 부서 목록 조회 (회사별, 부서원 수 포함). soft-delete: 기본 active 만, include_deleted=true 시 deleted 포함 -->
<select id="selectDepartments" parameterType="map" resultType="map">
SELECT
D.DEPT_CODE,
@@ -15,29 +15,30 @@
D.ORG_SYSTEM,
D.APPROVAL_MANAGER,
D.DEPT_MANAGER,
D.ORG_HEAD,
D.ZIPCODE,
D.ADDRESS1,
D.ADDRESS2,
D.START_DATE,
D.END_DATE,
D.ERP_MANAGED,
D.SHOW_IN_CHART,
D.SORT_ORDER,
D.STATUS,
D.DELETED_AT,
COUNT(DISTINCT UD.USER_ID) AS MEMBER_COUNT
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>
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, D.ORG_HEAD,
D.SHORT_NAME, D.DEPT_TYPE, D.ORG_SYSTEM, D.APPROVAL_MANAGER, D.DEPT_MANAGER,
D.ZIPCODE, D.ADDRESS1, D.ADDRESS2, D.START_DATE, D.END_DATE,
D.ERP_MANAGED, D.SHOW_IN_CHART, D.SORT_ORDER, D.STATUS
D.SORT_ORDER, D.STATUS, D.DELETED_AT
ORDER BY COALESCE(D.SORT_ORDER, 9999), D.DEPT_NAME
</select>
<!-- 부서 단건 조회 -->
<!-- 부서 단건 조회 (active 만) -->
<select id="selectDepartmentByCode" parameterType="map" resultType="map">
SELECT
DEPT_CODE,
@@ -49,33 +50,58 @@
ORG_SYSTEM,
APPROVAL_MANAGER,
DEPT_MANAGER,
ORG_HEAD,
ZIPCODE,
ADDRESS1,
ADDRESS2,
START_DATE,
END_DATE,
ERP_MANAGED,
SHOW_IN_CHART,
SORT_ORDER,
STATUS
STATUS,
DELETED_AT
FROM DEPT_INFO
WHERE DEPT_CODE = #{dept_code}
AND DELETED_AT IS NULL
</select>
<!-- 부서 단건 조회 (deleted 포함) — 복구 검증·복구 처리용 -->
<select id="selectDepartmentByCodeIncludingDeleted" parameterType="map" resultType="map">
SELECT
DEPT_CODE,
DEPT_NAME,
COMPANY_CODE,
PARENT_DEPT_CODE,
SHORT_NAME,
DEPT_TYPE,
ORG_SYSTEM,
APPROVAL_MANAGER,
DEPT_MANAGER,
ZIPCODE,
ADDRESS1,
ADDRESS2,
START_DATE,
END_DATE,
SORT_ORDER,
STATUS,
DELETED_AT
FROM DEPT_INFO
WHERE DEPT_CODE = #{dept_code}
</select>
<!-- 중복 부서명 확인 -->
<!-- 중복 부서명 확인 (per-tenant, 활성 부서만, 공백/대소문자 무관) -->
<select id="selectDuplicateDeptName" parameterType="map" resultType="map">
SELECT DEPT_CODE, DEPT_NAME
FROM DEPT_INFO
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
AND DEPT_NAME = #{dept_name}
WHERE COMPANY_CODE = #{company_code}
AND DELETED_AT IS NULL
AND TRIM(LOWER(DEPT_NAME)) = TRIM(LOWER(#{dept_name}))
</select>
<!-- 회사명 조회 -->
<!-- 회사명 조회 (정확 매칭, '*' 글로벌 fallback 제거 — selectOne 에서 다중 row 충돌 방지) -->
<select id="selectCompanyName" parameterType="map" resultType="map">
SELECT COMPANY_NAME
FROM COMPANY_MNG
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
WHERE COMPANY_CODE = #{company_code}
LIMIT 1
</select>
<!-- 다음 부서 코드 번호 조회 (전역 카운트) -->
@@ -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()
)
</insert>
@@ -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}
</update>
<!-- 하위 부서 수 조회 -->
<!-- 하위 부서 수 조회 (기본 active 자식만, include_deleted=true 시 deleted 자식도 카운트) -->
<select id="selectChildDeptCount" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM DEPT_INFO
WHERE PARENT_DEPT_CODE = #{dept_code}
<if test="include_deleted == null or include_deleted == false">
AND DELETED_AT IS NULL
</if>
</select>
<!-- 부서 삭제 전 user_dept 삭제 -->
<!-- 부서 삭제 전 user_dept 삭제 (※ Slice 2.1 이후 deleteDepartment 에서는 사용 안 함 — 멤버 보존) -->
<delete id="deleteUserDeptByDeptCode" parameterType="map">
DELETE FROM USER_DEPT
WHERE DEPT_CODE = #{dept_code}
</delete>
<!-- 부서 삭제 -->
<delete id="deleteDepartment" parameterType="map">
DELETE FROM DEPT_INFO
<!-- 부서 삭제 (soft-delete: DELETED_AT = NOW()). USER_DEPT 보존 — 복구 시 멤버 그대로 살아남 -->
<update id="deleteDepartment" parameterType="map">
UPDATE DEPT_INFO
SET DELETED_AT = NOW()
WHERE DEPT_CODE = #{dept_code}
</delete>
AND DELETED_AT IS NULL
</update>
<!-- 부서 복구 (DELETED_AT = NULL). 호출 전에 부모 deleted 여부 service 에서 검증 -->
<update id="restoreDepartment" parameterType="map">
UPDATE DEPT_INFO
SET DELETED_AT = NULL
WHERE DEPT_CODE = #{dept_code}
AND DELETED_AT IS NOT NULL
</update>
<!-- 부서원 목록 조회 -->
<select id="selectDeptMembers" parameterType="map" resultType="map">
@@ -239,14 +254,23 @@
WHERE USER_ID = #{user_id}
</select>
<!-- 기존 부서원 확인 -->
<!-- 기존 부서원 확인 (IS_PRIMARY 포함 — 제거 시 자동 승격 판단용) -->
<select id="selectExistingMember" parameterType="map" resultType="map">
SELECT USER_ID, DEPT_CODE
SELECT USER_ID, DEPT_CODE, IS_PRIMARY
FROM USER_DEPT
WHERE USER_ID = #{user_id}
AND DEPT_CODE = #{dept_code}
</select>
<!-- 사용자의 USER_DEPT row 중 첫 번째 (primary 자동 승격용) -->
<select id="selectFirstUserDept" parameterType="map" resultType="map">
SELECT USER_ID, DEPT_CODE
FROM USER_DEPT
WHERE USER_ID = #{user_id}
ORDER BY CREATED_DATE ASC
LIMIT 1
</select>
<!-- 사용자의 주 부서 확인 -->
<select id="selectUserPrimaryDept" parameterType="map" resultType="map">
SELECT USER_ID, DEPT_CODE
@@ -1,115 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ArrowLeft } from "lucide-react";
import { DepartmentStructure } from "@/components/admin/department/DepartmentStructure";
import { DepartmentMembers } from "@/components/admin/department/DepartmentMembers";
import type { Department } from "@/types/department";
import { getCompanyList } from "@/lib/api/company";
/**
* 부서 관리 메인 페이지
* 좌측: 부서 구조, 우측: 부서 인원
*/
export default function DepartmentManagementPage() {
const params = useParams();
const router = useRouter();
const { companyCode } = params as { companyCode: string };
const [selectedDepartment, setSelectedDepartment] = useState<Department | null>(null);
const [activeTab, setActiveTab] = useState<string>("structure");
const [companyName, setCompanyName] = useState<string>("");
const [refreshTrigger, setRefreshTrigger] = useState(0);
// 부서원 변경 시 부서 구조 새로고침
const handleMemberChange = () => {
setRefreshTrigger((prev) => prev + 1);
};
// 회사 정보 로드
useEffect(() => {
const loadCompanyInfo = async () => {
const response = await getCompanyList();
if (response.success && response.data) {
const company = response.data.find((c) => c.company_code === companyCode);
if (company) {
setCompanyName(company.company_name);
}
}
};
loadCompanyInfo();
}, [companyCode]);
const handleBackToList = () => {
router.push("/admin/userMng/companyList");
};
return (
<div className="space-y-4">
{/* 상단 헤더: 회사 정보 + 뒤로가기 */}
<div className="flex items-center justify-between border-b pb-4">
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={handleBackToList} className="h-9 gap-2">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="bg-border h-6 w-px" />
<div>
<h2 className="text-xl font-semibold">{companyName || companyCode}</h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
</div>
{/* 탭 네비게이션 (모바일용) */}
<div className="lg:hidden">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="structure"> </TabsTrigger>
<TabsTrigger value="members"> </TabsTrigger>
</TabsList>
<TabsContent value="structure" className="mt-4">
<DepartmentStructure
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onSelectDepartment={setSelectedDepartment}
refreshTrigger={refreshTrigger}
/>
</TabsContent>
<TabsContent value="members" className="mt-4">
<DepartmentMembers
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onMemberChange={handleMemberChange}
/>
</TabsContent>
</Tabs>
</div>
{/* 좌우 레이아웃 (데스크톱) */}
<div className="hidden h-full gap-6 lg:flex">
{/* 좌측: 부서 구조 (20%) */}
<div className="w-[20%] border-r pr-6">
<DepartmentStructure
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onSelectDepartment={setSelectedDepartment}
refreshTrigger={refreshTrigger}
/>
</div>
{/* 우측: 부서 인원 (80%) */}
<div className="w-[80%] pl-0">
<DepartmentMembers
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onMemberChange={handleMemberChange}
/>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -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`);
};
// 디스크 사용량 포맷팅 함수
@@ -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<Set<string>>(new Set());
const [isLoading, setIsLoading] = useState(false);
// V1: soft-delete 된 부서 표시 토글
const [showDeleted, setShowDeleted] = useState(false);
// 부서 추가 모달
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [parentDeptForAdd, setParentDeptForAdd] = useState<string | null>(null);
@@ -42,15 +45,15 @@ export function DepartmentStructure({
const [deptToDelete, setDeptToDelete] = useState<{ code: string; name: string } | null>(null);
const [deleteErrorMessage, setDeleteErrorMessage] = useState<string | null>(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 (
<div key={dept.dept_code}>
{/* 부서 항목 */}
{/* 부서 항목 — soft-delete 시 회색+취소선 */}
<div
className={`hover:bg-muted flex cursor-pointer items-center justify-between rounded-lg p-2 text-sm transition-colors ${
isSelected ? "bg-primary/10 text-primary" : ""
}`}
} ${isDeleted ? "bg-muted/40 text-muted-foreground line-through opacity-60" : ""}`}
style={{ marginLeft: `${level * 16}px` }}
>
<div className="flex flex-1 items-center gap-2" onClick={() => onSelectDepartment(dept)}>
<div className="flex flex-1 items-center gap-2" onClick={() => !isDeleted && onSelectDepartment(dept)}>
{/* 확장/축소 아이콘 */}
{hasChildren ? (
<button
@@ -218,32 +253,54 @@ export function DepartmentStructure({
<Users className="h-3 w-3" />
<span>{dept.member_count || 0}</span>
</div>
{/* deleted 배지 */}
{isDeleted && <span className="text-muted-foreground text-[10px] uppercase tracking-wider"></span>}
</div>
{/* 액션 버튼 */}
{/* 액션 버튼 — deleted 면 복구 버튼만, 아니면 추가/삭제 버튼 */}
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleAddDepartment(dept.dept_code);
}}
>
<Plus className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
{isDeleted ? (
<Button
variant="ghost"
size="icon"
className="text-primary h-6 w-6"
title="복구"
onClick={(e) => {
e.stopPropagation();
handleRestoreDepartment(dept.dept_code, dept.dept_name);
}}
>
<Undo2 className="h-3 w-3" />
</Button>
) : (
<>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title="하위 부서 추가"
onClick={(e) => {
e.stopPropagation();
handleAddDepartment(dept.dept_code);
}}
>
<Plus className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive h-6 w-6"
title="삭제"
onClick={(e) => {
e.stopPropagation();
handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</>
)}
</div>
</div>
@@ -259,10 +316,23 @@ export function DepartmentStructure({
{/* 헤더 */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold"> </h3>
<Button size="sm" className="h-9 gap-2 text-sm" onClick={() => handleAddDepartment(null)}>
<Plus className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
{/* V1: soft-delete 부서 표시 토글 */}
<Button
variant={showDeleted ? "secondary" : "ghost"}
size="sm"
className="h-9 gap-2 text-sm"
onClick={() => setShowDeleted((v) => !v)}
title={showDeleted ? "삭제된 부서 숨기기" : "삭제된 부서 보기"}
>
{showDeleted ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
{showDeleted ? "삭제 부서 숨기기" : "삭제 부서 보기"}
</Button>
<Button size="sm" className="h-9 gap-2 text-sm" onClick={() => handleAddDepartment(null)}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{/* 부서 트리 */}
@@ -338,7 +408,9 @@ export function DepartmentStructure({
<p className="text-sm">
<span className="font-semibold">{deptToDelete?.name}</span> ?
</p>
<p className="text-muted-foreground mt-2 text-xs"> .</p>
<p className="text-muted-foreground mt-2 text-xs">
. ( '삭제 부서 보기' ) .
</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
@@ -71,8 +71,19 @@ export default function Step1Basic({
const r: any = await checkAvailability(payload);
if (payload.subdomain === state.subdomain) {
const sub = r?.subdomain;
// 우선순위: reserved > valid_format > available
// 백엔드 isValidSubdomain 이 reserved 도 false 로 잡아내므로 reserved 를 먼저 검사해야
// "예약어" 케이스가 "형식 오류" 로 묻히지 않는다.
setSubStatus(
!sub ? "idle" : !sub.valid_format || sub.reserved ? "invalid" : sub.available ? "available" : "taken",
!sub
? "idle"
: sub.reserved
? "reserved"
: !sub.valid_format
? "invalid"
: sub.available
? "available"
: "taken",
);
}
if (payload.dbPrefix === state.db_prefix) {
@@ -9,7 +9,7 @@ import { Loader2, CheckCircle2, XCircle } from "lucide-react";
* - CheckAvailBadge: subdomain/db_prefix 실시간 검증 인디케이터
*/
export type AvailStatus = "idle" | "checking" | "available" | "taken" | "invalid";
export type AvailStatus = "idle" | "checking" | "available" | "taken" | "reserved" | "invalid";
export function Field({
label,
@@ -228,6 +228,21 @@ export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?
<XCircle size={13} />
</span>
);
if (status === "reserved")
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
fontSize: "0.72rem",
color: "var(--v5-red)",
fontWeight: 600,
}}
>
<XCircle size={13} /> ( )
</span>
);
return (
<span
style={{
@@ -247,7 +262,7 @@ export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?
/** TextInput 의 status prop 과 매핑 */
export function availToInputStatus(a: AvailStatus): TextInputStatus | undefined {
if (a === "available") return "ok";
if (a === "taken" || a === "invalid") return "err";
if (a === "taken" || a === "reserved" || a === "invalid") return "err";
return undefined;
}
@@ -0,0 +1,337 @@
"use client";
/**
* DepartmentPicker — 부서 선택 재사용 컴포넌트 (V1 신규).
*
* 다른 화면에서 부서를 선택해야 할 때 사용. 단일 / 다중 선택 모드 지원.
*
* 사용 예:
* <DepartmentPicker
* companyCode="INVYONE"
* mode="single"
* value={parentDeptCode}
* open={isOpen}
* onSelect={(code) => setParentDeptCode(code as string)}
* onClose={() => setIsOpen(false)}
* excludeCodes={[currentDeptCode]}
* />
*
* 동작:
* - shadcn Dialog 안에 검색박스 + 트리뷰
* - 부모 클릭 시 자식 cascade 펼침
* - 클라이언트측 검색 (debounce 200ms, 이름/코드 부분일치)
* - single: 클릭 즉시 onSelect → close
* - multi: 체크박스 + 부모 체크 시 자식 자동 cascade + 확인 버튼으로 onSelect
* - excludeCodes 에 포함된 dept 는 disabled
* - 사이클 데이터 (잘못된 PARENT_DEPT_CODE) 는 visited Set 으로 차단
*
* V1 한계: 검색은 클라이언트측 필터. 1000+ 부서는 V2 에서 backend search 도입 예정.
*/
import { useEffect, useMemo, useState } from "react";
import { Check, ChevronDown, ChevronRight, Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import type { Department } from "@/types/department";
import { getDepartments } from "@/lib/api/department";
export interface DepartmentPickerProps {
companyCode: string;
mode: "single" | "multi";
/** 현재 선택값. single 이면 string, multi 면 string[] */
value?: string | string[];
open: boolean;
onSelect: (code: string | string[]) => void;
onClose: () => void;
/** 선택 불가로 disable 처리할 dept_code 들 (자기 자신 부모 등록 방지 등) */
excludeCodes?: string[];
/** soft-delete 된 부서도 보여줄지 (default false) */
includeDeleted?: boolean;
/** 모달 헤더 타이틀 (default: "부서 선택") */
title?: string;
}
export function DepartmentPicker({
companyCode,
mode,
value,
open,
onSelect,
onClose,
excludeCodes,
includeDeleted = false,
title = "부서 선택",
}: DepartmentPickerProps) {
const [departments, setDepartments] = useState<Department[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [searchInput, setSearchInput] = useState("");
const [searchTerm, setSearchTerm] = useState(""); // debounced
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<Set<string>>(new Set());
// value -> selected 동기화
useEffect(() => {
if (!open) return;
if (mode === "single") {
setSelected(new Set(typeof value === "string" && value ? [value] : []));
} else {
setSelected(new Set(Array.isArray(value) ? value : []));
}
}, [value, mode, open]);
// 검색어 debounce 200ms
useEffect(() => {
const t = setTimeout(() => setSearchTerm(searchInput.trim().toLowerCase()), 200);
return () => clearTimeout(t);
}, [searchInput]);
// 부서 목록 로드
useEffect(() => {
if (!open) return;
let cancelled = false;
setIsLoading(true);
getDepartments(companyCode, { includeDeleted })
.then((res: any) => {
if (cancelled) return;
if (res?.success && Array.isArray(res?.data)) {
setDepartments(res.data);
} else {
setDepartments([]);
}
})
.catch(() => !cancelled && setDepartments([]))
.finally(() => !cancelled && setIsLoading(false));
return () => {
cancelled = true;
};
}, [open, companyCode, includeDeleted]);
// 코드 -> 부서 맵 (검색·자식 조회용)
const byCode = useMemo(() => {
const m = new Map<string, Department>();
for (const d of departments) m.set(d.dept_code, d);
return m;
}, [departments]);
// 검색 매칭 (이름·코드 부분일치)
const isMatch = (d: Department): boolean => {
if (!searchTerm) return true;
return (
d.dept_name.toLowerCase().includes(searchTerm) ||
d.dept_code.toLowerCase().includes(searchTerm)
);
};
// 검색 매칭 부서 + 그 조상들도 포함해서 트리에서 visible
const visibleCodes = useMemo(() => {
if (!searchTerm) return null; // null = 전체 visible
const visible = new Set<string>();
for (const d of departments) {
if (isMatch(d)) {
visible.add(d.dept_code);
// 조상 visible (부모-부모-...) — 사이클 차단
const visited = new Set<string>([d.dept_code]);
let parentCode = d.parent_dept_code;
while (parentCode && !visited.has(parentCode)) {
visited.add(parentCode);
visible.add(parentCode);
parentCode = byCode.get(parentCode)?.parent_dept_code ?? null;
}
}
}
return visible;
}, [searchTerm, departments, byCode]);
// 부모 코드 → 자식 정렬 리스트
const childrenOf = (parentCode: string | null): Department[] => {
return departments
.filter((d) => (d.parent_dept_code ?? null) === parentCode)
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
};
// 부모 + 자식 (재귀) cascade 코드들
const collectDescendants = (rootCode: string): string[] => {
const result: string[] = [];
const visited = new Set<string>();
const dfs = (code: string) => {
if (visited.has(code)) return;
visited.add(code);
result.push(code);
for (const child of childrenOf(code)) {
dfs(child.dept_code);
}
};
dfs(rootCode);
return result;
};
const toggleExpand = (code: string) => {
const next = new Set(expanded);
if (next.has(code)) next.delete(code);
else next.add(code);
setExpanded(next);
};
const isExcluded = (code: string) => Boolean(excludeCodes?.includes(code));
const isDeleted = (d: Department) => Boolean((d as any).deleted_at);
const handleNodeClick = (d: Department) => {
if (isExcluded(d.dept_code)) return;
if (mode === "single") {
onSelect(d.dept_code);
onClose();
return;
}
// multi: 자기 + 자손 모두 토글
const next = new Set(selected);
const codes = collectDescendants(d.dept_code).filter((c) => !isExcluded(c));
const allSelected = codes.every((c) => next.has(c));
if (allSelected) {
for (const c of codes) next.delete(c);
} else {
for (const c of codes) next.add(c);
}
setSelected(next);
};
const handleConfirmMulti = () => {
onSelect(Array.from(selected));
onClose();
};
// 트리 렌더 (재귀, visited Set 사이클 차단)
const renderTree = (parentCode: string | null, level: number, visited: Set<string>): React.ReactNode => {
const list = childrenOf(parentCode);
return list.map((d) => {
if (visited.has(d.dept_code)) return null; // 사이클 차단
const nextVisited = new Set(visited);
nextVisited.add(d.dept_code);
const hasChildren = childrenOf(d.dept_code).length > 0;
const isOpen = expanded.has(d.dept_code) || (searchTerm.length > 0 && hasChildren);
const isSel = selected.has(d.dept_code);
const excluded = isExcluded(d.dept_code);
const deleted = isDeleted(d);
// 검색 시: visible 아닌 노드는 숨김
if (visibleCodes && !visibleCodes.has(d.dept_code)) return null;
return (
<div key={d.dept_code}>
<div
className={`flex items-center gap-2 rounded p-1.5 text-sm transition-colors ${
excluded
? "cursor-not-allowed opacity-40"
: "hover:bg-muted cursor-pointer"
} ${isSel ? "bg-primary/10 text-primary" : ""} ${deleted ? "text-muted-foreground line-through" : ""}`}
style={{ paddingLeft: `${level * 16 + 6}px` }}
onClick={() => handleNodeClick(d)}
>
{/* expand/collapse */}
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation();
toggleExpand(d.dept_code);
}}
className="flex h-4 w-4 items-center justify-center"
>
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : (
<div className="h-4 w-4" />
)}
{/* multi: 체크 표시 */}
{mode === "multi" && (
<div
className={`flex h-4 w-4 items-center justify-center rounded border ${
isSel ? "bg-primary border-primary text-primary-foreground" : "border-input"
}`}
>
{isSel && <Check className="h-3 w-3" />}
</div>
)}
{/* 부서명 + 코드 */}
<div className="flex flex-1 flex-col leading-tight">
<span className="font-medium">{d.dept_name}</span>
<span className="text-muted-foreground text-[10px] uppercase">{d.dept_code}</span>
</div>
{deleted && (
<span className="text-muted-foreground text-[10px] uppercase tracking-wider"></span>
)}
</div>
{hasChildren && isOpen && renderTree(d.dept_code, level + 1, nextVisited)}
</div>
);
});
};
// 루트 — 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 (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-[95vw] sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
{/* 검색 */}
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="부서명 또는 코드로 검색"
className="pl-8 pr-8"
autoFocus
/>
{searchInput && (
<button
onClick={() => setSearchInput("")}
className="text-muted-foreground absolute right-2 top-1/2 -translate-y-1/2"
title="검색어 지우기"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* 트리 */}
<div className="bg-card max-h-[50vh] overflow-y-auto rounded border p-2">
{isLoading ? (
<div className="text-muted-foreground py-8 text-center text-sm"> ...</div>
) : !hasAny ? (
<div className="text-muted-foreground py-8 text-center text-sm"> .</div>
) : noResults ? (
<div className="text-muted-foreground py-8 text-center text-sm"> </div>
) : (
renderTree(null, 0, new Set())
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
</Button>
{mode === "multi" && (
<Button onClick={handleConfirmMulti} disabled={selected.size === 0}>
({selected.size})
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default DepartmentPicker;
@@ -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"),
+37 -5
View File
@@ -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,
};
}
}
/**
* 부서원 목록 조회
*/
+5 -20
View File
@@ -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; // 일괄등록용 (자동 부여 시 미전달)
}