fix(부서관리): 25개 버그 일괄 수정 + 데이터 무결성 강화
CRITICAL: - searchUsers 회사/role 격리 가드 추가 (멀티테넌시 침해 차단) - setPrimaryDept 멤버십 검증 추가 (주부서 데이터 손상 방지) - parent_dept_code cross-tenant 검증 (validateParent 헬퍼) HIGH: - updateDepartment SQL WHERE 에 DELETED_AT IS NULL 추가 (silent corruption 방지) - update/restore 부서명 중복 검증 추가 - 글로벌 부서 (*) write 작업 SUPER_ADMIN 전용 가드 - 부서코드 자동 생성으로 강제 (사용자 입력 받지 않음) - 회사 변경 시 상세 패널 초기화 - handleMove 부분 실패 시 화면 동기화 - 검색 시 부모 체인 자동 포함 (broken tree 수정) - start_date 기본값 today 강제 제거 MEDIUM: - 멤버 fetch cancellation flag - 삭제 다이얼로그 dept_code 클로저 캡처 - isDirty 시 X 버튼 폼 초기화 경고 - 변경이력 버튼 disabled (백엔드 API 미구현) - 일괄등록 실패 상세 모달 (라인 + 사유) - LIKE 와일드카드 ESCAPE 적용 - nullIfBlank 에 trim 통합 LOW + 새 기능: - 부서원 추가/제거 UI 신규 구현 (UserSearchModal) - selectDeptMembers LEFT JOIN 으로 변경 - DepartmentPicker allowRoot 옵션 (최상위로 이동) - expandAll 전체 departments 사용 - dead code 정리 DB: - RUN_085 마이그레이션: DEPT_INFO partial UNIQUE + USER_DEPT UNIQUE - 모든 active 테넌트 DB (siflex/test01/test02_invyone) 적용 완료 Breaking changes: - 일괄등록 CSV 4컬럼 → 3컬럼 (부서명,상위부서,유형) - 부서코드 입력란 제거 (자동 부여 DEPT_n) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -113,6 +113,10 @@ public class DepartmentController {
|
|||||||
if (!canAccessDept(existing, userCompanyCode)) {
|
if (!canAccessDept(existing, userCompanyCode)) {
|
||||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||||
}
|
}
|
||||||
|
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||||
|
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||||
|
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Map<String, Object> updated = departmentService.updateDepartment(deptCode, body);
|
Map<String, Object> updated = departmentService.updateDepartment(deptCode, body);
|
||||||
@@ -120,6 +124,8 @@ public class DepartmentController {
|
|||||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok(ApiResponse.success(updated, "부서가 수정되었습니다."));
|
return ResponseEntity.ok(ApiResponse.success(updated, "부서가 수정되었습니다."));
|
||||||
|
} catch (DepartmentService.DuplicateDeptNameException e) {
|
||||||
|
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||||
}
|
}
|
||||||
@@ -149,6 +155,10 @@ public class DepartmentController {
|
|||||||
if (!canAccessDept(existing, userCompanyCode)) {
|
if (!canAccessDept(existing, userCompanyCode)) {
|
||||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
|
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
|
||||||
}
|
}
|
||||||
|
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||||
|
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||||
|
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
int result = departmentService.deleteDepartment(deptCode);
|
int result = departmentService.deleteDepartment(deptCode);
|
||||||
@@ -187,8 +197,17 @@ public class DepartmentController {
|
|||||||
if (!canAccessDept(existing, userCompanyCode)) {
|
if (!canAccessDept(existing, userCompanyCode)) {
|
||||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||||
}
|
}
|
||||||
|
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||||
|
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||||
|
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||||
|
}
|
||||||
|
|
||||||
DepartmentService.RestoreResult result = departmentService.restoreDepartment(deptCode);
|
DepartmentService.RestoreResult result;
|
||||||
|
try {
|
||||||
|
result = departmentService.restoreDepartment(deptCode);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||||
|
}
|
||||||
switch (result) {
|
switch (result) {
|
||||||
case OK:
|
case OK:
|
||||||
Map<String, Object> data = new java.util.HashMap<>();
|
Map<String, Object> data = new java.util.HashMap<>();
|
||||||
@@ -234,8 +253,17 @@ public class DepartmentController {
|
|||||||
@GetMapping("/companies/{companyCode}/users/search")
|
@GetMapping("/companies/{companyCode}/users/search")
|
||||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> searchUsers(
|
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> searchUsers(
|
||||||
@PathVariable String companyCode,
|
@PathVariable String companyCode,
|
||||||
|
@RequestAttribute("company_code") String userCompanyCode,
|
||||||
|
@RequestAttribute("role") String role,
|
||||||
@RequestParam(required = false) String search) {
|
@RequestParam(required = false) String search) {
|
||||||
|
|
||||||
|
if (!isAdmin(role)) {
|
||||||
|
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||||
|
}
|
||||||
|
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||||
|
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 사용자를 검색할 권한이 없습니다."));
|
||||||
|
}
|
||||||
|
|
||||||
if (search == null || search.isBlank()) {
|
if (search == null || search.isBlank()) {
|
||||||
return ResponseEntity.status(400).body(ApiResponse.error("검색어를 입력해주세요."));
|
return ResponseEntity.status(400).body(ApiResponse.error("검색어를 입력해주세요."));
|
||||||
}
|
}
|
||||||
@@ -266,10 +294,13 @@ public class DepartmentController {
|
|||||||
if (!canAccessDept(existing, userCompanyCode)) {
|
if (!canAccessDept(existing, userCompanyCode)) {
|
||||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||||
}
|
}
|
||||||
|
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||||
|
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||||
|
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||||
|
}
|
||||||
|
|
||||||
// 프론트엔드는 snake_case(user_id)로 전송 (Node.js 호환)
|
// 프론트엔드는 snake_case(user_id)로 전송 (Node.js 호환)
|
||||||
Object userIdObj = body.get("user_id");
|
Object userIdObj = body.get("user_id") != null ? body.get("user_id") : body.get("userId");
|
||||||
if (userIdObj == null) userIdObj = body.get("user_id");
|
|
||||||
if (userIdObj == null || userIdObj.toString().isBlank()) {
|
if (userIdObj == null || userIdObj.toString().isBlank()) {
|
||||||
return ResponseEntity.status(400).body(ApiResponse.error("사용자 ID를 입력해주세요."));
|
return ResponseEntity.status(400).body(ApiResponse.error("사용자 ID를 입력해주세요."));
|
||||||
}
|
}
|
||||||
@@ -307,6 +338,10 @@ public class DepartmentController {
|
|||||||
if (!canAccessDept(existing, userCompanyCode)) {
|
if (!canAccessDept(existing, userCompanyCode)) {
|
||||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||||
}
|
}
|
||||||
|
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||||
|
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||||
|
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||||
|
}
|
||||||
|
|
||||||
boolean removed = departmentService.removeDeptMember(deptCode, userId);
|
boolean removed = departmentService.removeDeptMember(deptCode, userId);
|
||||||
if (!removed) {
|
if (!removed) {
|
||||||
@@ -337,9 +372,17 @@ public class DepartmentController {
|
|||||||
if (!canAccessDept(existing, userCompanyCode)) {
|
if (!canAccessDept(existing, userCompanyCode)) {
|
||||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||||
}
|
}
|
||||||
|
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||||
|
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||||
|
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||||
|
}
|
||||||
|
|
||||||
departmentService.setPrimaryDept(deptCode, userId);
|
try {
|
||||||
return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다."));
|
departmentService.setPrimaryDept(deptCode, userId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다."));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -81,21 +81,29 @@ public class DepartmentService extends BaseService {
|
|||||||
? (String) company.get("company_name")
|
? (String) company.get("company_name")
|
||||||
: companyCode;
|
: companyCode;
|
||||||
|
|
||||||
// 부서 코드 결정 — body 에 dept_code 가 있고 형식 OK 이면 override, 없으면 자동생성
|
// parent_dept_code cross-tenant / 존재 / 삭제 검증
|
||||||
String requestedCode = trimString(bodyParam(body, "dept_code", "dept_code"));
|
Object parentObj = nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code"));
|
||||||
String deptCode;
|
String parentCode = parentObj != null ? parentObj.toString() : null;
|
||||||
if (requestedCode != null && requestedCode.matches("^[A-Za-z0-9_]+$")) {
|
validateParent(parentCode, companyCode);
|
||||||
// 중복 체크
|
|
||||||
Map<String, Object> existing = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted",
|
// 부서 코드 자동 생성 — 사용자 입력 받지 않음 (정책 변경 2026-05-08)
|
||||||
Map.of("dept_code", requestedCode));
|
// 재시도 로직 (race condition 대비, 최대 3회)
|
||||||
if (existing != null) {
|
String deptCode = null;
|
||||||
throw new IllegalArgumentException("부서 코드 \"" + requestedCode + "\" 가 이미 존재합니다.");
|
for (int attempt = 0; attempt < 3; attempt++) {
|
||||||
}
|
|
||||||
deptCode = requestedCode;
|
|
||||||
} else {
|
|
||||||
Map<String, Object> codeResult = sqlSession.selectOne("department.selectNextDeptNumber", null);
|
Map<String, Object> codeResult = sqlSession.selectOne("department.selectNextDeptNumber", null);
|
||||||
long nextNumber = codeResult != null ? ((Number) codeResult.get("next_number")).longValue() : 1L;
|
long nextNumber = codeResult != null && codeResult.get("next_number") != null
|
||||||
deptCode = "DEPT_" + nextNumber;
|
? ((Number) codeResult.get("next_number")).longValue()
|
||||||
|
: 1L;
|
||||||
|
String candidate = "DEPT_" + nextNumber;
|
||||||
|
Map<String, Object> existing = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted",
|
||||||
|
Map.of("dept_code", candidate));
|
||||||
|
if (existing == null) {
|
||||||
|
deptCode = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deptCode == null) {
|
||||||
|
throw new IllegalStateException("부서 코드 생성 실패 (동시 생성 충돌). 잠시 후 다시 시도해주세요.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 부서 생성 (전체 필드)
|
// 부서 생성 (전체 필드)
|
||||||
@@ -104,7 +112,7 @@ public class DepartmentService extends BaseService {
|
|||||||
insertParams.put("dept_name", deptName);
|
insertParams.put("dept_name", deptName);
|
||||||
insertParams.put("company_code", companyCode);
|
insertParams.put("company_code", companyCode);
|
||||||
insertParams.put("company_name", companyName);
|
insertParams.put("company_name", companyName);
|
||||||
insertParams.put("parent_dept_code", nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code")));
|
insertParams.put("parent_dept_code", parentCode);
|
||||||
insertParams.put("short_name", nullIfBlank(bodyParam(body, "short_name", "short_name")));
|
insertParams.put("short_name", nullIfBlank(bodyParam(body, "short_name", "short_name")));
|
||||||
insertParams.put("dept_type", bodyParam(body, "dept_type", "dept_type"));
|
insertParams.put("dept_type", bodyParam(body, "dept_type", "dept_type"));
|
||||||
insertParams.put("org_system", nullIfBlank(bodyParam(body, "org_system", "org_system")));
|
insertParams.put("org_system", nullIfBlank(bodyParam(body, "org_system", "org_system")));
|
||||||
@@ -135,9 +143,34 @@ public class DepartmentService extends BaseService {
|
|||||||
throw new IllegalArgumentException("부서명을 입력해주세요.");
|
throw new IllegalArgumentException("부서명을 입력해주세요.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 본인 dept 의 company_code 조회 (validateParent + 중복명 검증에 사용)
|
||||||
|
Map<String, Object> existingDept = sqlSession.selectOne(
|
||||||
|
"department.selectDepartmentByCodeIncludingDeleted",
|
||||||
|
Map.of("dept_code", deptCode)
|
||||||
|
);
|
||||||
|
String deptCompanyCode = existingDept != null && existingDept.get("company_code") != null
|
||||||
|
? existingDept.get("company_code").toString()
|
||||||
|
: null;
|
||||||
|
|
||||||
// 사이클 가드 — 자기 자신/자손을 부모로 지정하려는 시도 차단
|
// 사이클 가드 — 자기 자신/자손을 부모로 지정하려는 시도 차단
|
||||||
Object newParent = nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code"));
|
Object newParent = nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code"));
|
||||||
verifyParentCycle(deptCode, newParent != null ? newParent.toString() : null);
|
String newParentCode = newParent != null ? newParent.toString() : null;
|
||||||
|
// parent_dept_code cross-tenant / 존재 / 삭제 검증
|
||||||
|
if (deptCompanyCode != null) {
|
||||||
|
validateParent(newParentCode, deptCompanyCode);
|
||||||
|
}
|
||||||
|
verifyParentCycle(deptCode, newParentCode);
|
||||||
|
|
||||||
|
// 부서명 중복 검증 — 본인 dept_code 는 제외
|
||||||
|
if (deptCompanyCode != null) {
|
||||||
|
Map<String, Object> dupParams = new HashMap<>();
|
||||||
|
dupParams.put("company_code", deptCompanyCode);
|
||||||
|
dupParams.put("dept_name", deptName);
|
||||||
|
Map<String, Object> duplicate = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
|
||||||
|
if (duplicate != null && !deptCode.equals(duplicate.get("dept_code"))) {
|
||||||
|
throw new DuplicateDeptNameException("\"" + deptName + "\" 부서가 이미 존재합니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, Object> params = new HashMap<>();
|
Map<String, Object> params = new HashMap<>();
|
||||||
params.put("dept_code", deptCode);
|
params.put("dept_code", deptCode);
|
||||||
@@ -227,6 +260,19 @@ public class DepartmentService extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 동일 이름의 active 부서 중복 검증 (복구 시점)
|
||||||
|
Object companyCodeObj = dept.get("company_code");
|
||||||
|
Object deptNameObj = dept.get("dept_name");
|
||||||
|
if (companyCodeObj != null && deptNameObj != null) {
|
||||||
|
Map<String, Object> dupParams = new HashMap<>();
|
||||||
|
dupParams.put("company_code", companyCodeObj.toString());
|
||||||
|
dupParams.put("dept_name", deptNameObj.toString());
|
||||||
|
Map<String, Object> duplicate = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
|
||||||
|
if (duplicate != null && !deptCode.equals(duplicate.get("dept_code"))) {
|
||||||
|
throw new IllegalArgumentException("동일한 이름의 활성 부서가 이미 존재합니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, Object> params = new HashMap<>();
|
Map<String, Object> params = new HashMap<>();
|
||||||
params.put("dept_code", deptCode);
|
params.put("dept_code", deptCode);
|
||||||
int restored = sqlSession.update("department.restoreDepartment", params);
|
int restored = sqlSession.update("department.restoreDepartment", params);
|
||||||
@@ -242,6 +288,28 @@ public class DepartmentService extends BaseService {
|
|||||||
OK, NOT_FOUND, NOT_DELETED, PARENT_DELETED
|
OK, NOT_FOUND, NOT_DELETED, PARENT_DELETED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* parent_dept_code 가 (a) 존재하고 (b) 같은 회사이며 (c) deleted 가 아닌지 검증.
|
||||||
|
* null/blank 면 검증 스킵 (최상위 부서).
|
||||||
|
*/
|
||||||
|
private void validateParent(String parentCode, String companyCode) {
|
||||||
|
if (parentCode == null || parentCode.isBlank()) return;
|
||||||
|
Map<String, Object> parent = sqlSession.selectOne(
|
||||||
|
"department.selectDepartmentByCodeIncludingDeleted",
|
||||||
|
Map.of("dept_code", parentCode)
|
||||||
|
);
|
||||||
|
if (parent == null) {
|
||||||
|
throw new IllegalArgumentException("상위 부서를 찾을 수 없습니다: " + parentCode);
|
||||||
|
}
|
||||||
|
if (parent.get("deleted_at") != null) {
|
||||||
|
throw new IllegalArgumentException("삭제된 부서를 상위로 지정할 수 없습니다: " + parentCode);
|
||||||
|
}
|
||||||
|
Object parentCompany = parent.get("company_code");
|
||||||
|
if (parentCompany == null || (!companyCode.equals(parentCompany.toString()) && !"*".equals(parentCompany.toString()))) {
|
||||||
|
throw new IllegalArgumentException("다른 회사의 부서를 상위로 지정할 수 없습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* parent_dept_code 변경 시 사이클 검증.
|
* parent_dept_code 변경 시 사이클 검증.
|
||||||
* deptCode 의 새 부모로 newParent 를 지정하려고 할 때, newParent 또는 그 ancestor
|
* deptCode 의 새 부모로 newParent 를 지정하려고 할 때, newParent 또는 그 ancestor
|
||||||
@@ -355,6 +423,15 @@ public class DepartmentService extends BaseService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void setPrimaryDept(String deptCode, String userId) {
|
public void setPrimaryDept(String deptCode, String userId) {
|
||||||
|
// 멤버십 검증 — 미소속 부서로 호출 시 데이터 손상 방지
|
||||||
|
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);
|
||||||
|
if (existing == null) {
|
||||||
|
throw new IllegalArgumentException("해당 부서의 부서원이 아닙니다. 먼저 부서원으로 추가해주세요.");
|
||||||
|
}
|
||||||
|
|
||||||
// 다른 부서의 주 부서 해제
|
// 다른 부서의 주 부서 해제
|
||||||
Map<String, Object> clearParams = new HashMap<>();
|
Map<String, Object> clearParams = new HashMap<>();
|
||||||
clearParams.put("user_id", userId);
|
clearParams.put("user_id", userId);
|
||||||
@@ -385,10 +462,13 @@ public class DepartmentService extends BaseService {
|
|||||||
return val != null ? val : body.get(camelCase);
|
return val != null ? val : body.get(camelCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 빈 문자열을 null 로 치환 — DATE 컬럼에 '' 바인딩 시 pg cast 에러 나는 걸 방지 */
|
/** 빈 문자열 또는 공백만 있는 문자열을 null 로 치환. 그 외엔 trim 한 값을 반환 */
|
||||||
private Object nullIfBlank(Object value) {
|
private Object nullIfBlank(Object value) {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
if (value instanceof String s && s.trim().isEmpty()) return null;
|
if (value instanceof String s) {
|
||||||
|
String trimmed = s.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,7 @@
|
|||||||
STATUS = #{status},
|
STATUS = #{status},
|
||||||
LOCATION = #{location}
|
LOCATION = #{location}
|
||||||
WHERE DEPT_CODE = #{dept_code}
|
WHERE DEPT_CODE = #{dept_code}
|
||||||
|
AND DELETED_AT IS NULL
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
<!-- 하위 부서 수 조회 (기본 active 자식만, include_deleted=true 시 deleted 자식도 카운트) -->
|
<!-- 하위 부서 수 조회 (기본 active 자식만, include_deleted=true 시 deleted 자식도 카운트) -->
|
||||||
@@ -188,12 +189,6 @@
|
|||||||
</if>
|
</if>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- 부서 삭제 전 user_dept 삭제 (※ Slice 2.1 이후 deleteDepartment 에서는 사용 안 함 — 멤버 보존) -->
|
|
||||||
<delete id="deleteUserDeptByDeptCode" parameterType="map">
|
|
||||||
DELETE FROM USER_DEPT
|
|
||||||
WHERE DEPT_CODE = #{dept_code}
|
|
||||||
</delete>
|
|
||||||
|
|
||||||
<!-- 부서 삭제 (soft-delete: DELETED_AT = NOW()). USER_DEPT 보존 — 복구 시 멤버 그대로 살아남 -->
|
<!-- 부서 삭제 (soft-delete: DELETED_AT = NOW()). USER_DEPT 보존 — 복구 시 멤버 그대로 살아남 -->
|
||||||
<update id="deleteDepartment" parameterType="map">
|
<update id="deleteDepartment" parameterType="map">
|
||||||
UPDATE DEPT_INFO
|
UPDATE DEPT_INFO
|
||||||
@@ -223,7 +218,7 @@
|
|||||||
D.DEPT_NAME,
|
D.DEPT_NAME,
|
||||||
UD.IS_PRIMARY
|
UD.IS_PRIMARY
|
||||||
FROM USER_DEPT UD
|
FROM USER_DEPT UD
|
||||||
JOIN USER_INFO U ON UD.USER_ID = U.USER_ID
|
LEFT JOIN USER_INFO U ON UD.USER_ID = U.USER_ID
|
||||||
JOIN DEPT_INFO D ON UD.DEPT_CODE = D.DEPT_CODE
|
JOIN DEPT_INFO D ON UD.DEPT_CODE = D.DEPT_CODE
|
||||||
WHERE UD.DEPT_CODE = #{dept_code}
|
WHERE UD.DEPT_CODE = #{dept_code}
|
||||||
ORDER BY UD.IS_PRIMARY DESC, U.USER_NAME
|
ORDER BY UD.IS_PRIMARY DESC, U.USER_NAME
|
||||||
@@ -240,8 +235,8 @@
|
|||||||
FROM USER_INFO
|
FROM USER_INFO
|
||||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||||
AND (
|
AND (
|
||||||
USER_ID ILIKE #{search}
|
USER_ID ILIKE #{search} ESCAPE '\'
|
||||||
OR USER_NAME ILIKE #{search}
|
OR USER_NAME ILIKE #{search} ESCAPE '\'
|
||||||
)
|
)
|
||||||
ORDER BY USER_NAME
|
ORDER BY USER_NAME
|
||||||
LIMIT 20
|
LIMIT 20
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# 085 마이그레이션 — 부서관리 데이터 무결성 제약 추가
|
||||||
|
|
||||||
|
작성일: 2026-05-08
|
||||||
|
작성자: johngreen
|
||||||
|
관련: 부서관리 페이지 버그 수정 (notes/johngreen/2026-05-08-부서관리-버그-정리.md)
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
부서명/부서원 중복을 DB 레벨에서 방어. race condition 시에도 데이터 무결성 보장.
|
||||||
|
|
||||||
|
## 추가 제약
|
||||||
|
|
||||||
|
| 제약 | 대상 | 용도 |
|
||||||
|
|---|---|---|
|
||||||
|
| `idx_dept_info_company_name_unique` | DEPT_INFO `(COMPANY_CODE, LOWER(TRIM(DEPT_NAME))) WHERE DELETED_AT IS NULL` | 회사 내 동일 이름 active 부서 중복 방지 |
|
||||||
|
| `idx_user_dept_user_dept_unique` | USER_DEPT `(USER_ID, DEPT_CODE)` | 동일 사용자가 같은 부서 중복 등록 방지 |
|
||||||
|
|
||||||
|
(USER_DEPT 에 이미 PK 또는 UNIQUE 가 있으면 IF NOT EXISTS 로 안전 적용)
|
||||||
|
|
||||||
|
## SQL
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 085: 부서관리 무결성 제약
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_dept_info_company_name_unique
|
||||||
|
ON DEPT_INFO (COMPANY_CODE, LOWER(TRIM(DEPT_NAME)))
|
||||||
|
WHERE DELETED_AT IS NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_dept_user_dept_unique
|
||||||
|
ON USER_DEPT (USER_ID, DEPT_CODE);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 실행
|
||||||
|
|
||||||
|
각 테넌트 DB 에 적용 필요. 운영 DB 와 모든 회사 DB 에 한 번씩.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 메타 DB
|
||||||
|
psql -h <host> -U postgres -d invyone -f RUN_085.sql
|
||||||
|
|
||||||
|
# 각 테넌트 DB (예시)
|
||||||
|
for db in $(psql -tA -c "SELECT db_name FROM company_mng WHERE db_status='active'"); do
|
||||||
|
psql -h <host> -U postgres -d "$db" -f RUN_085.sql
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## 사전 점검
|
||||||
|
|
||||||
|
기존 데이터에 중복이 있으면 인덱스 생성이 실패합니다. 사전 확인:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 부서명 중복 확인
|
||||||
|
SELECT COMPANY_CODE, LOWER(TRIM(DEPT_NAME)), COUNT(*)
|
||||||
|
FROM DEPT_INFO
|
||||||
|
WHERE DELETED_AT IS NULL
|
||||||
|
GROUP BY COMPANY_CODE, LOWER(TRIM(DEPT_NAME))
|
||||||
|
HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
-- USER_DEPT 중복 확인
|
||||||
|
SELECT USER_ID, DEPT_CODE, COUNT(*)
|
||||||
|
FROM USER_DEPT
|
||||||
|
GROUP BY USER_ID, DEPT_CODE
|
||||||
|
HAVING COUNT(*) > 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
중복이 있다면 수동 정리 후 인덱스 생성.
|
||||||
|
|
||||||
|
## 롤백
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DROP INDEX IF EXISTS idx_dept_info_company_name_unique;
|
||||||
|
DROP INDEX IF EXISTS idx_user_dept_user_dept_unique;
|
||||||
|
```
|
||||||
@@ -110,7 +110,7 @@ const emptyDraft = (companyCode = ""): DeptDetailDraft => ({
|
|||||||
zipcode: "",
|
zipcode: "",
|
||||||
address1: "",
|
address1: "",
|
||||||
address2: "",
|
address2: "",
|
||||||
start_date: new Date().toISOString().slice(0, 10),
|
start_date: "",
|
||||||
end_date: "",
|
end_date: "",
|
||||||
sort_order: 10,
|
sort_order: 10,
|
||||||
});
|
});
|
||||||
@@ -143,12 +143,13 @@ export default function DeptMngListPage() {
|
|||||||
const [activeTab, setActiveTab] = useState<"info" | "members">("info");
|
const [activeTab, setActiveTab] = useState<"info" | "members">("info");
|
||||||
const [members, setMembers] = useState<DepartmentMember[]>([]);
|
const [members, setMembers] = useState<DepartmentMember[]>([]);
|
||||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
|
const [pendingDeleteDept, setPendingDeleteDept] = useState<{ code: string; name: string } | null>(null);
|
||||||
|
|
||||||
// ── 일괄등록 / 변경이력 모달 ─────────────────────────
|
// ── 일괄등록 / 변경이력 모달 ─────────────────────────
|
||||||
const [bulkOpen, setBulkOpen] = useState(false);
|
const [bulkOpen, setBulkOpen] = useState(false);
|
||||||
const [bulkText, setBulkText] = useState("");
|
const [bulkText, setBulkText] = useState("");
|
||||||
const [bulkUploading, setBulkUploading] = useState(false);
|
const [bulkUploading, setBulkUploading] = useState(false);
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [bulkFailures, setBulkFailures] = useState<{ line: number; deptName: string; reason: string }[]>([]);
|
||||||
|
|
||||||
// ── 트리 ⋮ 메뉴: 이동/삭제 대상 ───────────────────────
|
// ── 트리 ⋮ 메뉴: 이동/삭제 대상 ───────────────────────
|
||||||
const [moveTargetDept, setMoveTargetDept] = useState<Department | null>(null);
|
const [moveTargetDept, setMoveTargetDept] = useState<Department | null>(null);
|
||||||
@@ -221,21 +222,40 @@ export default function DeptMngListPage() {
|
|||||||
setMembers([]);
|
setMembers([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const res = await departmentAPI.getDepartmentMembers(selectedCode);
|
const res = await departmentAPI.getDepartmentMembers(selectedCode);
|
||||||
|
if (cancelled) return;
|
||||||
if (res.success && (res as any).data) setMembers((res as any).data);
|
if (res.success && (res as any).data) setMembers((res as any).data);
|
||||||
})();
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [activeTab, selectedCode, isNewMode]);
|
}, [activeTab, selectedCode, isNewMode]);
|
||||||
|
|
||||||
// ── 트리 구성 ────────────────────────────────────────
|
// ── 트리 구성 ────────────────────────────────────────
|
||||||
const filteredDepts = useMemo(() => {
|
const filteredDepts = useMemo(() => {
|
||||||
if (!searchKeyword.trim()) return departments;
|
if (!searchKeyword.trim()) return departments;
|
||||||
const kw = searchKeyword.toLowerCase();
|
const kw = searchKeyword.toLowerCase();
|
||||||
return departments.filter(
|
// 1차: 직접 매칭된 부서들
|
||||||
(d) =>
|
const directMatches = new Set(
|
||||||
d.dept_name?.toLowerCase().includes(kw) ||
|
departments
|
||||||
d.dept_code?.toLowerCase().includes(kw),
|
.filter((d) => d.dept_name?.toLowerCase().includes(kw) || d.dept_code?.toLowerCase().includes(kw))
|
||||||
|
.map((d) => d.dept_code),
|
||||||
);
|
);
|
||||||
|
// 2차: 매칭된 노드의 모든 조상도 포함 (트리 구조 유지)
|
||||||
|
const visible = new Set<string>(directMatches);
|
||||||
|
const byCode = new Map(departments.map((d) => [d.dept_code, d]));
|
||||||
|
for (const code of directMatches) {
|
||||||
|
let cur: string | null | undefined = byCode.get(code)?.parent_dept_code ?? null;
|
||||||
|
const visited = new Set<string>([code]);
|
||||||
|
while (cur && !visited.has(cur)) {
|
||||||
|
visited.add(cur);
|
||||||
|
visible.add(cur);
|
||||||
|
cur = byCode.get(cur)?.parent_dept_code ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return departments.filter((d) => visible.has(d.dept_code));
|
||||||
}, [departments, searchKeyword]);
|
}, [departments, searchKeyword]);
|
||||||
|
|
||||||
const childrenOf = useCallback(
|
const childrenOf = useCallback(
|
||||||
@@ -247,7 +267,7 @@ export default function DeptMngListPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const expandAll = () => {
|
const expandAll = () => {
|
||||||
setExpandedSet(new Set(filteredDepts.map((d) => d.dept_code)));
|
setExpandedSet(new Set(departments.map((d) => d.dept_code)));
|
||||||
};
|
};
|
||||||
const collapseAll = () => setExpandedSet(new Set());
|
const collapseAll = () => setExpandedSet(new Set());
|
||||||
|
|
||||||
@@ -414,6 +434,7 @@ export default function DeptMngListPage() {
|
|||||||
await loadDepartments();
|
await loadDepartments();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast({ title: "정렬 변경 실패", description: err?.message, variant: "destructive" });
|
toast({ title: "정렬 변경 실패", description: err?.message, variant: "destructive" });
|
||||||
|
await loadDepartments(); // 부분 업데이트 가능성 있어 DB 상태로 동기화
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -501,15 +522,16 @@ export default function DeptMngListPage() {
|
|||||||
|
|
||||||
// ── 삭제 ─────────────────────────────────────────────
|
// ── 삭제 ─────────────────────────────────────────────
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!selectedCode) return;
|
const target = pendingDeleteDept;
|
||||||
|
if (!target) return;
|
||||||
try {
|
try {
|
||||||
const res = await departmentAPI.deleteDepartment(selectedCode);
|
const res = await departmentAPI.deleteDepartment(target.code);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
const softDeleted = (res as any).data?.soft_deleted;
|
const softDeleted = (res as any).data?.soft_deleted;
|
||||||
toast({
|
toast({
|
||||||
title: softDeleted ? "부서 삭제됨 (복구 가능)" : "부서가 삭제되었습니다",
|
title: softDeleted ? "부서 삭제됨 (복구 가능)" : "부서가 삭제되었습니다",
|
||||||
description: softDeleted
|
description: softDeleted
|
||||||
? `"${draft.dept_name}" 부서가 휴지통으로 이동했습니다. 상단 '삭제 보기' 토글에서 복구할 수 있습니다.`
|
? `"${target.name}" 부서가 휴지통으로 이동했습니다. 상단 '삭제 보기' 토글에서 복구할 수 있습니다.`
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
await loadDepartments();
|
await loadDepartments();
|
||||||
@@ -519,6 +541,7 @@ export default function DeptMngListPage() {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setDeleteConfirmOpen(false);
|
setDeleteConfirmOpen(false);
|
||||||
|
setPendingDeleteDept(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -594,14 +617,9 @@ export default function DeptMngListPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 gap-1.5 text-xs"
|
className="h-8 gap-1.5 text-xs opacity-60"
|
||||||
onClick={() => {
|
disabled
|
||||||
if (!selectedCode) {
|
title="변경이력 기능은 준비 중입니다"
|
||||||
toast({ title: "부서를 먼저 선택하세요", variant: "destructive" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setHistoryOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<History className="h-3.5 w-3.5" />
|
<History className="h-3.5 w-3.5" />
|
||||||
변경이력
|
변경이력
|
||||||
@@ -655,7 +673,16 @@ export default function DeptMngListPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Select value={selectedCompanyCode} onValueChange={setSelectedCompanyCode}>
|
<Select
|
||||||
|
value={selectedCompanyCode}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setSelectedCompanyCode(v);
|
||||||
|
setSelectedCode(null);
|
||||||
|
setIsNewMode(false);
|
||||||
|
setDraft(emptyDraft(v));
|
||||||
|
setOriginalDraft(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder="회사 선택" />
|
<SelectValue placeholder="회사 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -799,7 +826,12 @@ export default function DeptMngListPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
onClick={() => setDeleteConfirmOpen(true)}
|
onClick={() => {
|
||||||
|
if (selectedCode) {
|
||||||
|
setPendingDeleteDept({ code: selectedCode, name: draft.dept_name });
|
||||||
|
setDeleteConfirmOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
disabled={isNewMode}
|
disabled={isNewMode}
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
@@ -808,7 +840,10 @@ export default function DeptMngListPage() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleClearDetail}
|
onClick={() => {
|
||||||
|
if (isDirty && !window.confirm("저장되지 않은 변경사항이 있습니다. 폐기하시겠습니까?")) return;
|
||||||
|
handleClearDetail();
|
||||||
|
}}
|
||||||
title="상세 닫기"
|
title="상세 닫기"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
@@ -858,7 +893,18 @@ export default function DeptMngListPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MembersPanel members={members} />
|
<MembersPanel
|
||||||
|
members={members}
|
||||||
|
deptCode={selectedCode!}
|
||||||
|
companyCode={selectedCompany?.company_code ?? ""}
|
||||||
|
onChanged={async () => {
|
||||||
|
if (selectedCode) {
|
||||||
|
const res = await departmentAPI.getDepartmentMembers(selectedCode);
|
||||||
|
if (res.success && (res as any).data) setMembers((res as any).data);
|
||||||
|
}
|
||||||
|
await loadDepartments();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -878,17 +924,23 @@ export default function DeptMngListPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 삭제 확인 */}
|
{/* 삭제 확인 */}
|
||||||
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
<Dialog
|
||||||
|
open={deleteConfirmOpen}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
setDeleteConfirmOpen(o);
|
||||||
|
if (!o) setPendingDeleteDept(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogContent className="max-w-[420px]">
|
<DialogContent className="max-w-[420px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>부서 삭제</DialogTitle>
|
<DialogTitle>부서 삭제</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
<span className="font-semibold">{draft.dept_name}</span> 부서를 삭제하시겠습니까?
|
<span className="font-semibold">{pendingDeleteDept?.name ?? draft.dept_name}</span> 부서를 삭제하시겠습니까?
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">부서원은 보존됩니다. 휴지통(상단 '삭제 보기' 토글)에서 복구할 수 있습니다.</p>
|
<p className="text-xs text-muted-foreground">부서원은 보존됩니다. 휴지통(상단 '삭제 보기' 토글)에서 복구할 수 있습니다.</p>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setDeleteConfirmOpen(false)}>취소</Button>
|
<Button variant="outline" onClick={() => { setDeleteConfirmOpen(false); setPendingDeleteDept(null); }}>취소</Button>
|
||||||
<Button variant="destructive" onClick={handleDelete}>삭제</Button>
|
<Button variant="destructive" onClick={handleDelete}>삭제</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -915,6 +967,7 @@ export default function DeptMngListPage() {
|
|||||||
<DepartmentPicker
|
<DepartmentPicker
|
||||||
companyCode={moveTargetDept?.company_code ?? selectedCompanyCode}
|
companyCode={moveTargetDept?.company_code ?? selectedCompanyCode}
|
||||||
mode="single"
|
mode="single"
|
||||||
|
allowRoot
|
||||||
value={moveTargetDept?.parent_dept_code || ""}
|
value={moveTargetDept?.parent_dept_code || ""}
|
||||||
open={!!moveTargetDept}
|
open={!!moveTargetDept}
|
||||||
onSelect={(code) =>
|
onSelect={(code) =>
|
||||||
@@ -935,14 +988,15 @@ export default function DeptMngListPage() {
|
|||||||
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
|
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
|
||||||
<p className="mb-1.5 font-semibold">CSV 형식으로 한 줄에 하나씩 입력하세요</p>
|
<p className="mb-1.5 font-semibold">CSV 형식으로 한 줄에 하나씩 입력하세요</p>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
형식: <code className="rounded bg-background px-1 py-0.5 font-mono">부서코드,부서명,상위부서코드,부서유형(dept|team|temp)</code>
|
형식: <code className="rounded bg-background px-1 py-0.5 font-mono">부서명,상위부서코드,부서유형(dept|team|temp)</code>
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-muted-foreground">예시: <code className="rounded bg-background px-1 py-0.5 font-mono">D001,경영지원본부,,dept</code></p>
|
<p className="mt-1 text-muted-foreground">부서코드는 저장 시 자동 부여됩니다 (DEPT_n).</p>
|
||||||
|
<p className="mt-1 text-muted-foreground">예시: <code className="rounded bg-background px-1 py-0.5 font-mono">경영지원본부,,dept</code></p>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={bulkText}
|
value={bulkText}
|
||||||
onChange={(e) => setBulkText(e.target.value)}
|
onChange={(e) => setBulkText(e.target.value)}
|
||||||
placeholder={"D001,경영지원본부,,dept\nD002,인사팀,D001,team"}
|
placeholder={"경영지원본부,,dept\n인사팀,DEPT_1,team"}
|
||||||
className="h-48 w-full resize-none rounded-md border bg-background p-2 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
className="h-48 w-full resize-none rounded-md border bg-background p-2 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -954,36 +1008,39 @@ export default function DeptMngListPage() {
|
|||||||
const lines = bulkText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
const lines = bulkText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||||
if (lines.length === 0) return;
|
if (lines.length === 0) return;
|
||||||
setBulkUploading(true);
|
setBulkUploading(true);
|
||||||
|
const failures: { line: number; deptName: string; reason: string }[] = [];
|
||||||
let success = 0;
|
let success = 0;
|
||||||
let failed = 0;
|
for (let i = 0; i < lines.length; i++) {
|
||||||
for (const line of lines) {
|
const line = lines[i];
|
||||||
const cols = line.split(",").map((c) => c.trim());
|
const cols = line.split(",").map((c) => c.trim());
|
||||||
const [dept_code, dept_name, parent, dept_type] = cols;
|
const [dept_name, parent, dept_type] = cols;
|
||||||
if (!dept_code || !dept_name) {
|
if (!dept_name) {
|
||||||
failed++;
|
failures.push({ line: i + 1, deptName: "(빈 줄)", reason: "부서명 필수" });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await departmentAPI.createDepartment(selectedCompanyCode, {
|
const res = await departmentAPI.createDepartment(selectedCompanyCode, {
|
||||||
...emptyDraft(selectedCompanyCode),
|
|
||||||
dept_code,
|
|
||||||
dept_name,
|
dept_name,
|
||||||
parent_dept_code: parent || null,
|
parent_dept_code: parent || null,
|
||||||
dept_type: (dept_type || "dept") as any,
|
dept_type: (dept_type || "dept") as any,
|
||||||
} as any);
|
} as any);
|
||||||
if (res.success) success++;
|
if (res.success) success++;
|
||||||
else failed++;
|
else failures.push({ line: i + 1, deptName: dept_name, reason: (res as any).error || "알 수 없는 오류" });
|
||||||
} catch {
|
} catch (e: any) {
|
||||||
failed++;
|
failures.push({ line: i + 1, deptName: dept_name, reason: e?.message || "예외 발생" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setBulkUploading(false);
|
setBulkUploading(false);
|
||||||
toast({
|
toast({
|
||||||
title: `일괄등록 완료`,
|
title: `일괄등록 완료`,
|
||||||
description: `성공 ${success}건 / 실패 ${failed}건`,
|
description: `성공 ${success}건 / 실패 ${failures.length}건`,
|
||||||
variant: failed > 0 ? "destructive" : "default",
|
variant: failures.length > 0 ? "destructive" : "default",
|
||||||
});
|
});
|
||||||
setBulkOpen(false);
|
if (failures.length > 0) {
|
||||||
|
setBulkFailures(failures);
|
||||||
|
} else {
|
||||||
|
setBulkOpen(false);
|
||||||
|
}
|
||||||
await loadDepartments();
|
await loadDepartments();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -993,38 +1050,38 @@ export default function DeptMngListPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* 변경이력 */}
|
{/* 일괄등록 실패 결과 */}
|
||||||
<Dialog open={historyOpen} onOpenChange={setHistoryOpen}>
|
<Dialog open={bulkFailures.length > 0} onOpenChange={(o) => !o && setBulkFailures([])}>
|
||||||
<DialogContent className="max-w-[720px]">
|
<DialogContent className="max-w-[640px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>일괄등록 실패 항목 ({bulkFailures.length}건)</DialogTitle>
|
||||||
변경이력 {selectedCode && <span className="text-muted-foreground text-sm font-normal">- {draft.dept_name} ({selectedCode})</span>}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="max-h-[480px] overflow-y-auto rounded-md border bg-muted/30">
|
<div className="max-h-[480px] overflow-y-auto rounded-md border bg-muted/30">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead className="bg-muted/50 sticky top-0">
|
<thead className="bg-muted/50 sticky top-0">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 text-left font-semibold">변경일시</th>
|
<th className="px-3 py-2 text-left font-semibold w-16">라인</th>
|
||||||
<th className="px-3 py-2 text-left font-semibold">작업자</th>
|
<th className="px-3 py-2 text-left font-semibold">부서명</th>
|
||||||
<th className="px-3 py-2 text-left font-semibold">변경 항목</th>
|
<th className="px-3 py-2 text-left font-semibold">사유</th>
|
||||||
<th className="px-3 py-2 text-left font-semibold">이전 값 → 신규 값</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
<tr>
|
{bulkFailures.map((f, idx) => (
|
||||||
<td colSpan={4} className="px-3 py-12 text-center text-muted-foreground">
|
<tr key={idx}>
|
||||||
변경이력 데이터를 불러오는 중이거나, 등록된 이력이 없습니다.
|
<td className="px-3 py-1.5 font-mono">{f.line}</td>
|
||||||
</td>
|
<td className="px-3 py-1.5">{f.deptName}</td>
|
||||||
</tr>
|
<td className="px-3 py-1.5 text-destructive">{f.reason}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setHistoryOpen(false)}>닫기</Button>
|
<Button variant="outline" onClick={() => { setBulkFailures([]); setBulkOpen(false); }}>닫기</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1254,13 +1311,11 @@ function BasicInfoForm({
|
|||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row label="부서코드">
|
<Row label="부서코드">
|
||||||
<Input
|
{draft.dept_code ? (
|
||||||
value={draft.dept_code}
|
<span className="font-mono text-sm">{draft.dept_code}</span>
|
||||||
onChange={(e) => update("dept_code", e.target.value)}
|
) : (
|
||||||
placeholder="저장 시 자동 부여 (DEPT_n)"
|
<span className="text-muted-foreground text-sm">저장 시 자동 부여됩니다 (DEPT_n)</span>
|
||||||
className="h-8 text-sm"
|
)}
|
||||||
readOnly={!!draft.dept_code}
|
|
||||||
/>
|
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row label="부서유형">
|
<Row label="부서유형">
|
||||||
@@ -1463,14 +1518,195 @@ function PickerField({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────
|
||||||
|
// 사용자 검색 모달
|
||||||
|
// ───────────────────────────────────────────────────────
|
||||||
|
function UserSearchModal({
|
||||||
|
open,
|
||||||
|
companyCode,
|
||||||
|
existingMemberIds,
|
||||||
|
onAdd,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
companyCode: string;
|
||||||
|
existingMemberIds: Set<string>;
|
||||||
|
onAdd: (userId: string) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<Record<string, any>[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [addingId, setAddingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setQuery("");
|
||||||
|
setResults([]);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
setSearching(true);
|
||||||
|
try {
|
||||||
|
const res = await departmentAPI.searchUsers(companyCode, query.trim());
|
||||||
|
if (res.success && (res as any).data) setResults((res as any).data);
|
||||||
|
else setResults([]);
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [query, companyCode]);
|
||||||
|
|
||||||
|
const handleAdd = async (userId: string) => {
|
||||||
|
setAddingId(userId);
|
||||||
|
try {
|
||||||
|
await onAdd(userId);
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setAddingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-w-[480px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>부서원 추가</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Input
|
||||||
|
placeholder="이름 또는 아이디로 검색..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="max-h-72 overflow-y-auto rounded-md border bg-card">
|
||||||
|
{searching ? (
|
||||||
|
<div className="py-8 text-center text-xs text-muted-foreground">검색 중...</div>
|
||||||
|
) : results.length === 0 && query.trim() ? (
|
||||||
|
<div className="py-8 text-center text-xs text-muted-foreground">사용자를 찾을 수 없음</div>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-xs text-muted-foreground">검색어를 입력하세요</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{results.map((u) => {
|
||||||
|
const alreadyMember = existingMemberIds.has(u.user_id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={u.user_id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between px-4 py-2.5",
|
||||||
|
alreadyMember ? "opacity-50" : "cursor-pointer hover:bg-muted/50",
|
||||||
|
)}
|
||||||
|
onClick={() => !alreadyMember && !addingId && handleAdd(u.user_id)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="font-medium">{u.user_name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">({u.user_id})</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex gap-3 text-[11px] text-muted-foreground">
|
||||||
|
{u.position_name && <span>{u.position_name}</span>}
|
||||||
|
{u.email && <span>{u.email}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{alreadyMember ? (
|
||||||
|
<span className="text-[11px] text-muted-foreground">이미 부서원</span>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-[11px]"
|
||||||
|
disabled={addingId === u.user_id}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleAdd(u.user_id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ───────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────
|
||||||
// 부서원 패널
|
// 부서원 패널
|
||||||
// ───────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────
|
||||||
function MembersPanel({ members }: { members: DepartmentMember[] }) {
|
function MembersPanel({
|
||||||
|
members,
|
||||||
|
deptCode,
|
||||||
|
companyCode,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
members: DepartmentMember[];
|
||||||
|
deptCode: string;
|
||||||
|
companyCode: string;
|
||||||
|
onChanged: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||||
|
const existingMemberIds = useMemo(() => new Set(members.map((m) => m.user_id)), [members]);
|
||||||
|
|
||||||
|
const handleAdd = async (userId: string) => {
|
||||||
|
const res = await departmentAPI.addDepartmentMember(deptCode, userId);
|
||||||
|
if (res.success) {
|
||||||
|
toast({ title: "부서원이 추가되었습니다" });
|
||||||
|
await onChanged();
|
||||||
|
} else {
|
||||||
|
toast({ title: "추가 실패", description: (res as any).error ?? "오류가 발생했습니다", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async (userId: string, userName: string) => {
|
||||||
|
if (!window.confirm(`${userName}을(를) 부서에서 제거하시겠습니까?`)) return;
|
||||||
|
const res = await departmentAPI.removeDepartmentMember(deptCode, userId);
|
||||||
|
if (res.success) {
|
||||||
|
toast({ title: "부서원이 제거되었습니다" });
|
||||||
|
await onChanged();
|
||||||
|
} else {
|
||||||
|
toast({ title: "제거 실패", description: (res as any).error ?? "오류가 발생했습니다", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetPrimary = async (userId: string) => {
|
||||||
|
const res = await departmentAPI.setPrimaryDepartment(deptCode, userId);
|
||||||
|
if (res.success) {
|
||||||
|
toast({ title: "주부서로 설정되었습니다" });
|
||||||
|
await onChanged();
|
||||||
|
} else {
|
||||||
|
toast({ title: "주부서 설정 실패", description: (res as any).error ?? "오류가 발생했습니다", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl">
|
<div className="mx-auto max-w-4xl">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<div className="text-sm text-muted-foreground">부서원 {members.length}명</div>
|
<div className="text-sm text-muted-foreground">부서원 {members.length}명</div>
|
||||||
|
<Button size="sm" className="h-7 gap-1.5 text-xs" onClick={() => setAddModalOpen(true)}>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
부서원 추가
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y rounded-md border bg-card">
|
<div className="divide-y rounded-md border bg-card">
|
||||||
{members.length === 0 ? (
|
{members.length === 0 ? (
|
||||||
@@ -1494,10 +1730,41 @@ function MembersPanel({ members }: { members: DepartmentMember[] }) {
|
|||||||
{m.phone && <span>{m.phone}</span>}
|
{m.phone && <span>{m.phone}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{!m.is_primary && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-6 gap-1 px-2 text-[11px]"
|
||||||
|
onClick={() => handleSetPrimary(m.user_id)}
|
||||||
|
title="주부서로 설정"
|
||||||
|
>
|
||||||
|
<Star className="h-3 w-3" />
|
||||||
|
주부서로 설정
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => handleRemove(m.user_id, m.user_name)}
|
||||||
|
title="부서원 제거"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UserSearchModal
|
||||||
|
open={addModalOpen}
|
||||||
|
companyCode={companyCode}
|
||||||
|
existingMemberIds={existingMemberIds}
|
||||||
|
onAdd={handleAdd}
|
||||||
|
onClose={() => setAddModalOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export interface DepartmentPickerProps {
|
|||||||
includeDeleted?: boolean;
|
includeDeleted?: boolean;
|
||||||
/** 모달 헤더 타이틀 (default: "부서 선택") */
|
/** 모달 헤더 타이틀 (default: "부서 선택") */
|
||||||
title?: string;
|
title?: string;
|
||||||
|
/** single 모드에서 "최상위로" (부모 없음) 옵션 표시 (default false) */
|
||||||
|
allowRoot?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DepartmentPicker({
|
export function DepartmentPicker({
|
||||||
@@ -62,6 +64,7 @@ export function DepartmentPicker({
|
|||||||
excludeCodes,
|
excludeCodes,
|
||||||
includeDeleted = false,
|
includeDeleted = false,
|
||||||
title = "부서 선택",
|
title = "부서 선택",
|
||||||
|
allowRoot = false,
|
||||||
}: DepartmentPickerProps) {
|
}: DepartmentPickerProps) {
|
||||||
const [departments, setDepartments] = useState<Department[]>([]);
|
const [departments, setDepartments] = useState<Department[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -307,6 +310,18 @@ export function DepartmentPicker({
|
|||||||
|
|
||||||
{/* 트리 */}
|
{/* 트리 */}
|
||||||
<div className="bg-card max-h-[50vh] overflow-y-auto rounded border p-2">
|
<div className="bg-card max-h-[50vh] overflow-y-auto rounded border p-2">
|
||||||
|
{mode === "single" && allowRoot && !isLoading && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect("");
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="bg-muted/30 hover:bg-muted mb-1 w-full rounded p-1.5 text-left text-sm font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
📂 (최상위 — 부모 없음)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-muted-foreground py-8 text-center text-sm">로딩 중...</div>
|
<div className="text-muted-foreground py-8 text-center text-sm">로딩 중...</div>
|
||||||
) : !hasAny ? (
|
) : !hasAny ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user