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:
2026-05-08 17:08:03 +09:00
parent 3d5b2a4911
commit 68c1cb5b14
6 changed files with 569 additions and 97 deletions
@@ -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
+72
View File
@@ -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 ? (