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)) {
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 {
Map<String, Object> updated = departmentService.updateDepartment(deptCode, body);
@@ -120,6 +124,8 @@ public class DepartmentController {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(updated, "부서가 수정되었습니다."));
} catch (DepartmentService.DuplicateDeptNameException e) {
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
@@ -149,6 +155,10 @@ public class DepartmentController {
if (!canAccessDept(existing, userCompanyCode)) {
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 {
int result = departmentService.deleteDepartment(deptCode);
@@ -187,8 +197,17 @@ public class DepartmentController {
if (!canAccessDept(existing, userCompanyCode)) {
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) {
case OK:
Map<String, Object> data = new java.util.HashMap<>();
@@ -234,8 +253,17 @@ public class DepartmentController {
@GetMapping("/companies/{companyCode}/users/search")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> searchUsers(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestAttribute("role") String role,
@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()) {
return ResponseEntity.status(400).body(ApiResponse.error("검색어를 입력해주세요."));
}
@@ -266,10 +294,13 @@ public class DepartmentController {
if (!canAccessDept(existing, userCompanyCode)) {
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 호환)
Object userIdObj = body.get("user_id");
if (userIdObj == null) userIdObj = body.get("user_id");
Object userIdObj = body.get("user_id") != null ? body.get("user_id") : body.get("userId");
if (userIdObj == null || userIdObj.toString().isBlank()) {
return ResponseEntity.status(400).body(ApiResponse.error("사용자 ID를 입력해주세요."));
}
@@ -307,6 +338,10 @@ public class DepartmentController {
if (!canAccessDept(existing, userCompanyCode)) {
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);
if (!removed) {
@@ -337,9 +372,17 @@ public class DepartmentController {
if (!canAccessDept(existing, userCompanyCode)) {
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);
return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다."));
try {
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")
: companyCode;
// 부서 코드 결정 — body 에 dept_code 가 있고 형식 OK 이면 override, 없으면 자동생성
String requestedCode = trimString(bodyParam(body, "dept_code", "dept_code"));
String deptCode;
if (requestedCode != null && requestedCode.matches("^[A-Za-z0-9_]+$")) {
// 중복 체크
Map<String, Object> existing = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted",
Map.of("dept_code", requestedCode));
if (existing != null) {
throw new IllegalArgumentException("부서 코드 \"" + requestedCode + "\" 가 이미 존재합니다.");
}
deptCode = requestedCode;
} else {
// parent_dept_code cross-tenant / 존재 / 삭제 검증
Object parentObj = nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code"));
String parentCode = parentObj != null ? parentObj.toString() : null;
validateParent(parentCode, companyCode);
// 부서 코드 자동 생성 — 사용자 입력 받지 않음 (정책 변경 2026-05-08)
// 재시도 로직 (race condition 대비, 최대 3회)
String deptCode = null;
for (int attempt = 0; attempt < 3; attempt++) {
Map<String, Object> codeResult = sqlSession.selectOne("department.selectNextDeptNumber", null);
long nextNumber = codeResult != null ? ((Number) codeResult.get("next_number")).longValue() : 1L;
deptCode = "DEPT_" + nextNumber;
long nextNumber = codeResult != null && codeResult.get("next_number") != null
? ((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("company_code", companyCode);
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("dept_type", bodyParam(body, "dept_type", "dept_type"));
insertParams.put("org_system", nullIfBlank(bodyParam(body, "org_system", "org_system")));
@@ -135,9 +143,34 @@ public class DepartmentService extends BaseService {
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"));
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<>();
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<>();
params.put("dept_code", deptCode);
int restored = sqlSession.update("department.restoreDepartment", params);
@@ -242,6 +288,28 @@ public class DepartmentService extends BaseService {
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 변경 시 사이클 검증.
* deptCode 의 새 부모로 newParent 를 지정하려고 할 때, newParent 또는 그 ancestor
@@ -355,6 +423,15 @@ public class DepartmentService extends BaseService {
@Transactional
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<>();
clearParams.put("user_id", userId);
@@ -385,10 +462,13 @@ public class DepartmentService extends BaseService {
return val != null ? val : body.get(camelCase);
}
/** 빈 문자열을 null 로 치환 — DATE 컬럼에 '' 바인딩 시 pg cast 에러 나는 걸 방지 */
/** 빈 문자열 또는 공백만 있는 문자열을 null 로 치환. 그 외엔 trim 한 값을 반환 */
private Object nullIfBlank(Object value) {
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;
}
@@ -176,6 +176,7 @@
STATUS = #{status},
LOCATION = #{location}
WHERE DEPT_CODE = #{dept_code}
AND DELETED_AT IS NULL
</update>
<!-- 하위 부서 수 조회 (기본 active 자식만, include_deleted=true 시 deleted 자식도 카운트) -->
@@ -188,12 +189,6 @@
</if>
</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 보존 — 복구 시 멤버 그대로 살아남 -->
<update id="deleteDepartment" parameterType="map">
UPDATE DEPT_INFO
@@ -223,7 +218,7 @@
D.DEPT_NAME,
UD.IS_PRIMARY
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
WHERE UD.DEPT_CODE = #{dept_code}
ORDER BY UD.IS_PRIMARY DESC, U.USER_NAME
@@ -240,8 +235,8 @@
FROM USER_INFO
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
AND (
USER_ID ILIKE #{search}
OR USER_NAME ILIKE #{search}
USER_ID ILIKE #{search} ESCAPE '\'
OR USER_NAME ILIKE #{search} ESCAPE '\'
)
ORDER BY USER_NAME
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: "",
address1: "",
address2: "",
start_date: new Date().toISOString().slice(0, 10),
start_date: "",
end_date: "",
sort_order: 10,
});
@@ -143,12 +143,13 @@ export default function DeptMngListPage() {
const [activeTab, setActiveTab] = useState<"info" | "members">("info");
const [members, setMembers] = useState<DepartmentMember[]>([]);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [pendingDeleteDept, setPendingDeleteDept] = useState<{ code: string; name: string } | null>(null);
// ── 일괄등록 / 변경이력 모달 ─────────────────────────
const [bulkOpen, setBulkOpen] = useState(false);
const [bulkText, setBulkText] = useState("");
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);
@@ -221,21 +222,40 @@ export default function DeptMngListPage() {
setMembers([]);
return;
}
let cancelled = false;
(async () => {
const res = await departmentAPI.getDepartmentMembers(selectedCode);
if (cancelled) return;
if (res.success && (res as any).data) setMembers((res as any).data);
})();
return () => {
cancelled = true;
};
}, [activeTab, selectedCode, isNewMode]);
// ── 트리 구성 ────────────────────────────────────────
const filteredDepts = useMemo(() => {
if (!searchKeyword.trim()) return departments;
const kw = searchKeyword.toLowerCase();
return departments.filter(
(d) =>
d.dept_name?.toLowerCase().includes(kw) ||
d.dept_code?.toLowerCase().includes(kw),
// 1차: 직접 매칭된 부서들
const directMatches = new Set(
departments
.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]);
const childrenOf = useCallback(
@@ -247,7 +267,7 @@ export default function DeptMngListPage() {
);
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());
@@ -414,6 +434,7 @@ export default function DeptMngListPage() {
await loadDepartments();
} catch (err: any) {
toast({ title: "정렬 변경 실패", description: err?.message, variant: "destructive" });
await loadDepartments(); // 부분 업데이트 가능성 있어 DB 상태로 동기화
}
};
@@ -501,15 +522,16 @@ export default function DeptMngListPage() {
// ── 삭제 ─────────────────────────────────────────────
const handleDelete = async () => {
if (!selectedCode) return;
const target = pendingDeleteDept;
if (!target) return;
try {
const res = await departmentAPI.deleteDepartment(selectedCode);
const res = await departmentAPI.deleteDepartment(target.code);
if (res.success) {
const softDeleted = (res as any).data?.soft_deleted;
toast({
title: softDeleted ? "부서 삭제됨 (복구 가능)" : "부서가 삭제되었습니다",
description: softDeleted
? `"${draft.dept_name}" 부서가 휴지통으로 이동했습니다. 상단 '삭제 보기' 토글에서 복구할 수 있습니다.`
? `"${target.name}" 부서가 휴지통으로 이동했습니다. 상단 '삭제 보기' 토글에서 복구할 수 있습니다.`
: undefined,
});
await loadDepartments();
@@ -519,6 +541,7 @@ export default function DeptMngListPage() {
}
} finally {
setDeleteConfirmOpen(false);
setPendingDeleteDept(null);
}
};
@@ -594,14 +617,9 @@ export default function DeptMngListPage() {
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={() => {
if (!selectedCode) {
toast({ title: "부서를 먼저 선택하세요", variant: "destructive" });
return;
}
setHistoryOpen(true);
}}
className="h-8 gap-1.5 text-xs opacity-60"
disabled
title="변경이력 기능은 준비 중입니다"
>
<History className="h-3.5 w-3.5" />
@@ -655,7 +673,16 @@ export default function DeptMngListPage() {
</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">
<SelectValue placeholder="회사 선택" />
</SelectTrigger>
@@ -799,7 +826,12 @@ export default function DeptMngListPage() {
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => setDeleteConfirmOpen(true)}
onClick={() => {
if (selectedCode) {
setPendingDeleteDept({ code: selectedCode, name: draft.dept_name });
setDeleteConfirmOpen(true);
}
}}
disabled={isNewMode}
>
@@ -808,7 +840,10 @@ export default function DeptMngListPage() {
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={handleClearDetail}
onClick={() => {
if (isDirty && !window.confirm("저장되지 않은 변경사항이 있습니다. 폐기하시겠습니까?")) return;
handleClearDetail();
}}
title="상세 닫기"
>
<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>
</>
@@ -878,17 +924,23 @@ export default function DeptMngListPage() {
</div>
{/* 삭제 확인 */}
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<Dialog
open={deleteConfirmOpen}
onOpenChange={(o) => {
setDeleteConfirmOpen(o);
if (!o) setPendingDeleteDept(null);
}}
>
<DialogContent className="max-w-[420px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<p className="text-sm">
<span className="font-semibold">{draft.dept_name}</span> ?
<span className="font-semibold">{pendingDeleteDept?.name ?? draft.dept_name}</span> ?
</p>
<p className="text-xs text-muted-foreground"> . ( '삭제 보기' ) .</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirmOpen(false)}></Button>
<Button variant="outline" onClick={() => { setDeleteConfirmOpen(false); setPendingDeleteDept(null); }}></Button>
<Button variant="destructive" onClick={handleDelete}></Button>
</DialogFooter>
</DialogContent>
@@ -915,6 +967,7 @@ export default function DeptMngListPage() {
<DepartmentPicker
companyCode={moveTargetDept?.company_code ?? selectedCompanyCode}
mode="single"
allowRoot
value={moveTargetDept?.parent_dept_code || ""}
open={!!moveTargetDept}
onSelect={(code) =>
@@ -935,14 +988,15 @@ export default function DeptMngListPage() {
<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="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 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>
<textarea
value={bulkText}
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"
/>
</div>
@@ -954,36 +1008,39 @@ export default function DeptMngListPage() {
const lines = bulkText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
if (lines.length === 0) return;
setBulkUploading(true);
const failures: { line: number; deptName: string; reason: string }[] = [];
let success = 0;
let failed = 0;
for (const line of lines) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const cols = line.split(",").map((c) => c.trim());
const [dept_code, dept_name, parent, dept_type] = cols;
if (!dept_code || !dept_name) {
failed++;
const [dept_name, parent, dept_type] = cols;
if (!dept_name) {
failures.push({ line: i + 1, deptName: "(빈 줄)", reason: "부서명 필수" });
continue;
}
try {
const res = await departmentAPI.createDepartment(selectedCompanyCode, {
...emptyDraft(selectedCompanyCode),
dept_code,
dept_name,
parent_dept_code: parent || null,
dept_type: (dept_type || "dept") as any,
} as any);
if (res.success) success++;
else failed++;
} catch {
failed++;
else failures.push({ line: i + 1, deptName: dept_name, reason: (res as any).error || "알 수 없는 오류" });
} catch (e: any) {
failures.push({ line: i + 1, deptName: dept_name, reason: e?.message || "예외 발생" });
}
}
setBulkUploading(false);
toast({
title: `일괄등록 완료`,
description: `성공 ${success}건 / 실패 ${failed}`,
variant: failed > 0 ? "destructive" : "default",
description: `성공 ${success}건 / 실패 ${failures.length}`,
variant: failures.length > 0 ? "destructive" : "default",
});
setBulkOpen(false);
if (failures.length > 0) {
setBulkFailures(failures);
} else {
setBulkOpen(false);
}
await loadDepartments();
}}
>
@@ -993,38 +1050,38 @@ export default function DeptMngListPage() {
</DialogContent>
</Dialog>
{/* 변경이력 */}
<Dialog open={historyOpen} onOpenChange={setHistoryOpen}>
<DialogContent className="max-w-[720px]">
{/* 일괄등록 실패 결과 */}
<Dialog open={bulkFailures.length > 0} onOpenChange={(o) => !o && setBulkFailures([])}>
<DialogContent className="max-w-[640px]">
<DialogHeader>
<DialogTitle>
{selectedCode && <span className="text-muted-foreground text-sm font-normal">- {draft.dept_name} ({selectedCode})</span>}
</DialogTitle>
<DialogTitle> ({bulkFailures.length})</DialogTitle>
</DialogHeader>
<div className="max-h-[480px] overflow-y-auto rounded-md border bg-muted/30">
<table className="w-full text-xs">
<thead className="bg-muted/50 sticky top-0">
<tr>
<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 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>
</tr>
</thead>
<tbody className="divide-y">
<tr>
<td colSpan={4} className="px-3 py-12 text-center text-muted-foreground">
, .
</td>
</tr>
{bulkFailures.map((f, idx) => (
<tr key={idx}>
<td className="px-3 py-1.5 font-mono">{f.line}</td>
<td className="px-3 py-1.5">{f.deptName}</td>
<td className="px-3 py-1.5 text-destructive">{f.reason}</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setHistoryOpen(false)}></Button>
<Button variant="outline" onClick={() => { setBulkFailures([]); setBulkOpen(false); }}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1254,13 +1311,11 @@ function BasicInfoForm({
</Row>
<Row label="부서코드">
<Input
value={draft.dept_code}
onChange={(e) => update("dept_code", e.target.value)}
placeholder="저장 시 자동 부여 (DEPT_n)"
className="h-8 text-sm"
readOnly={!!draft.dept_code}
/>
{draft.dept_code ? (
<span className="font-mono text-sm">{draft.dept_code}</span>
) : (
<span className="text-muted-foreground text-sm"> (DEPT_n)</span>
)}
</Row>
<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 (
<div className="mx-auto max-w-4xl">
<div className="mb-3 flex items-center justify-between">
<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 className="divide-y rounded-md border bg-card">
{members.length === 0 ? (
@@ -1494,10 +1730,41 @@ function MembersPanel({ members }: { members: DepartmentMember[] }) {
{m.phone && <span>{m.phone}</span>}
</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>
<UserSearchModal
open={addModalOpen}
companyCode={companyCode}
existingMemberIds={existingMemberIds}
onAdd={handleAdd}
onClose={() => setAddModalOpen(false)}
/>
</div>
);
}
@@ -50,6 +50,8 @@ export interface DepartmentPickerProps {
includeDeleted?: boolean;
/** 모달 헤더 타이틀 (default: "부서 선택") */
title?: string;
/** single 모드에서 "최상위로" (부모 없음) 옵션 표시 (default false) */
allowRoot?: boolean;
}
export function DepartmentPicker({
@@ -62,6 +64,7 @@ export function DepartmentPicker({
excludeCodes,
includeDeleted = false,
title = "부서 선택",
allowRoot = false,
}: DepartmentPickerProps) {
const [departments, setDepartments] = useState<Department[]>([]);
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">
{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 ? (
<div className="text-muted-foreground py-8 text-center text-sm"> ...</div>
) : !hasAny ? (