# 부서관리 UX Edge Case / 버그 헌팅 (2026-05-08)

대상 파일:
- `frontend/app/(main)/admin/userMng/deptMngList/page.tsx`
- `frontend/components/departments/DepartmentPicker.tsx`
- `frontend/lib/api/department.ts`

---

## 1. 휴지통 모드 — 삭제된 부서 클릭 / 컨텍스트 메뉴 ✅

- `page.tsx:1081` onClick 가드 `!isDeleted && handlers.onSelect(dept)` — 클릭 차단 OK.
- `page.tsx:1105–1121` 삭제됨 노드는 ⋮ 메뉴 대신 복구 버튼만 렌더. 조건 분기 `isDeleted ? <복구> : <DropdownMenu>` 명확.
- isNewMode 중 showDeleted 토글해도 우측 폼은 그대로 유지 — 의도된 동작이므로 문제 없음.

**추가 시나리오**: 삭제된 부서를 복구 버튼 클릭 → 부모 부서도 deleted 상태면 백엔드가 400 반환 (`restoreDepartment` 주석 참조). `page.tsx:538–549` toast 처리 있음. OK.

---

## 2. collectAllDescendants — deleted 자손 포함 여부 ⚠️

- `page.tsx:329–341` `collectAllDescendants`는 `departments` 배열 전체를 순회.
- `departments`는 `showDeleted=true`이면 deleted 자손 포함, `false`면 미포함.
- **문제**: `showDeleted=false`(기본)일 때 deleted 자손은 `departments`에 없으므로 `excludeCodes`에 들어가지 않음. picker에서 이미 soft-delete된 자손 코드를 새 부모로 선택 가능 — 백엔드 cycle 가드가 없으면 실제로는 무해하지만, deleted 부서를 새 부모로 지정하는 비정합 데이터 생성 가능성.
- `DepartmentPicker.tsx:63` `includeDeleted` prop이 있으나 `page.tsx:924` picker 호출 시 `includeDeleted` 미전달(기본 false) — picker 자체는 deleted 부서를 보여주지 않으므로 선택 불가. 결과적으로 deleted 자손 선택 자체는 차단됨.
- **실질 위험**: `showDeleted=true`이면서 picker도 `includeDeleted`를 받았을 경우에만 deleted 부서를 새 부모로 지정 가능. 현재 코드에선 picker에 `includeDeleted` 미전달이므로 실질 위험 없음. 단, 향후 includeDeleted 전달 시 cycle 검증 미비로 이어질 수 있음.
- 백엔드 cycle 가드: 코드 내 확인 불가 (백엔드 영역).

---

## 3. picker "최상위로" 선택 — 빈 문자열 vs null ⚠️

- `page.tsx:920–922`:
  ```ts
  onSelect={(code) =>
    handleConfirmMoveTo(typeof code === "string" && code ? code : null)
  }
  ```
- `DepartmentPicker.tsx:179–184` single 모드에서 `onSelect(d.dept_code)` 호출 — 항상 실제 부서코드 문자열 전달. "최상위로" 선택 버튼이 picker에 **없음**.
- **문제**: picker에 "최상위로 이동 (부모 없음)" 선택지가 없어 root 레벨로 이동하는 UX가 불가능. `handleConfirmMoveTo(null)` 경로 자체는 구현되어 있으나 트리거할 방법이 없음.
- 빈 문자열 처리 로직은 방어코드로만 존재 — 실제로는 도달 불가.

---

## 4. 부서원 탭 — 신규 부서 ✅

- `page.tsx:839–845` `disabled={isNewMode}` — 탭 버튼 클릭 차단. OK.
- `page.tsx:220–228` useEffect에서 `isNewMode`이면 `setMembers([])` 즉시 반환. OK.

---

## 5. selectedCompanyCode 변경 시 selectedCode/draft 잔존 ❌

- `page.tsx:658` 회사 Select `onValueChange={setSelectedCompanyCode}` — `selectedCode`, `draft`, `isNewMode` 초기화 없음.
- **문제**: 회사 A의 부서를 선택 → 회사 B로 전환 → 우측 상세에 회사 A 부서 정보가 그대로 표시됨. breadcrumb도 회사 A 부서명. 회사 B 트리를 클릭하기 전까지 stale 데이터 노출.
- `loadDepartments`(line 199)는 `selectedCompanyCode` 의존성으로 재호출되어 트리는 갱신되지만 우측 패널은 그대로.
- `file:line` — `page.tsx:658` `onValueChange={setSelectedCompanyCode}` 에서 추가로 `handleClearDetail()` 호출 필요.

---

## 6. 부서원 fetch race condition ❌

- `page.tsx:219–228` useEffect:
  ```ts
  useEffect(() => {
    if (activeTab !== "members" || !selectedCode || isNewMode) { setMembers([]); return; }
    (async () => {
      const res = await departmentAPI.getDepartmentMembers(selectedCode);
      if (res.success && (res as any).data) setMembers((res as any).data);
    })();
  }, [activeTab, selectedCode, isNewMode]);
  ```
- **문제**: cleanup 함수(cancellation flag)가 없음. fetch 도중 다른 부서 클릭 → 이전 fetch가 완료되면 `setMembers`가 구형 데이터로 상태 덮어씀. stale closure 문제.
- `DepartmentPicker.tsx:92–107` 에는 `cancelled` flag 패턴이 올바르게 적용되어 있음 — 동일 패턴이 members fetch에 없음.
- 재현: 네트워크 느린 환경에서 빠르게 부서 A → B 클릭 시 B의 멤버 대신 A의 멤버가 표시될 수 있음.

---

## 7. 신규 부서 작성 중 회사 변경 — company_code mismatch ❌

- `page.tsx:291–297` `handleAddNew`:
  ```ts
  setDraft({ ...emptyDraft(selectedCompanyCode), parent_dept_code: parentCode });
  ```
  `emptyDraft(selectedCompanyCode)` 시점에 `company_code`가 고정됨.
- 이후 회사 변경 → `selectedCompanyCode` 갱신되지만 `draft.company_code`는 이전 회사 코드 유지.
- `page.tsx:477` `createDepartment(selectedCompanyCode, payload)` — URL 파라미터는 새 `selectedCompanyCode` 사용, payload 내 `draft`의 `company_code`는 구 회사 코드 → 불일치.
- 백엔드가 URL 파라미터 `companyCode`를 우선하면 실질 저장은 새 회사로 되지만, payload에 잘못된 `company_code`가 포함되어 백엔드 유효성 검증에서 실패할 수도 있음.
- **5번 버그와 동일한 근본 원인**: 회사 변경 시 `handleClearDetail()` 미호출.

---

## 8. 동시 두 삭제 다이얼로그 ⚠️

- `page.tsx:881` `deleteConfirmOpen` 다이얼로그 (메인 패널 "삭제" 버튼).
- `page.tsx:898` `contextDeleteDept` 다이얼로그 (트리 ⋮ 메뉴 "삭제").
- **문제**: 두 다이얼로그 열기 조건이 독립적. 사용자가 ⋮ → 삭제를 클릭해 `contextDeleteDept` 다이얼로그가 열린 상태에서, 뒤에 있는 메인 패널의 "삭제" 버튼(`page.tsx:800`)도 클릭 가능 여부는 Dialog의 z-index/모달 동작에 따라 다름.
- 실제로 shadcn Dialog는 포커스 트랩을 걸므로 동시 두 개 열리기는 어렵지만, 빠른 클릭 시퀀스로 두 state가 모두 true가 될 수는 있음. React 렌더링 사이클 타이밍에 따라 두 Dialog가 동시에 `open={true}`인 상태 가능.
- 치명적 버그는 아니지만 UI 혼란 가능.

---

## 9. handleDelete race — selectedCode 변경 후 삭제 ❌

- `page.tsx:503–523` `handleDelete`:
  ```ts
  const handleDelete = async () => {
    if (!selectedCode) return;
    const res = await departmentAPI.deleteDepartment(selectedCode); // (A) selectedCode 캡처
    ...
  };
  ```
- `page.tsx:881–895` 삭제 확인 다이얼로그 `open={deleteConfirmOpen}`.
- **문제**: 삭제 다이얼로그가 열린 상태에서 사용자가 배경 트리(Dialog가 포커스 트랩 없는 경우)를 클릭해 `selectedCode`가 변경될 경우, "삭제" 버튼 클릭 시 `handleDelete`는 **최신 `selectedCode`** 를 사용. 즉, 다이얼로그를 열 때 의도한 부서가 아닌 다른 부서가 삭제될 수 있음.
- shadcn Dialog는 기본 포커스 트랩 제공으로 일반 사용 시 배경 클릭은 차단되지만, `onOpenChange`로 dismiss도 가능하고, 로직상 `selectedCode`가 mutable 상태인 것 자체가 위험.
- `handleDelete` 시 클로저로 코드를 고정하지 않는 구조 — 컨텍스트 삭제의 `handleConfirmDeleteContext`(line 421)는 `contextDeleteDept` state를 사용해 이 문제가 없음. 메인 패널 삭제만 취약.

---

## 10. DepartmentPicker — 다른 회사 부서 선택 가능 여부 ✅

- `page.tsx:916` picker 호출:
  ```ts
  companyCode={moveTargetDept?.company_code ?? selectedCompanyCode}
  ```
- `DepartmentPicker.tsx:94` `getDepartments(companyCode, ...)` — 해당 회사 부서만 로드. 다른 회사 부서 노출 없음.
- `moveTargetDept?.company_code`를 사용하므로 이동 대상 부서의 원래 회사 기준으로 picker가 열림. 올바른 동작.

---

## 11. 변경이력 — API 호출 없음 (미구현) ❌

- `page.tsx:997–1027` 변경이력 Dialog: hardcoded "데이터를 불러오는 중이거나, 등록된 이력이 없습니다." 텍스트만 표시.
- `page.tsx:995–1002` `historyOpen` state 변경 시 fetch 트리거 없음. 실제 API 호출 코드 없음.
- `department.ts` 전체 검색 시 history/changelog 관련 API 함수 없음.
- 기능 미구현 상태. 사용자는 변경이력 버튼을 눌러도 항상 "이력 없음" 문구만 봄.

---

## 12. expandAll — 검색 필터 상태에서의 부분 적용 ⚠️

- `page.tsx:249–251`:
  ```ts
  const expandAll = () => {
    setExpandedSet(new Set(filteredDepts.map((d) => d.dept_code)));
  };
  ```
- `filteredDepts`는 검색 키워드가 있으면 필터된 부서만 포함.
- **문제**: 검색어 "인사" 입력 후 expandAll 클릭 → `expandedSet`에 "인사" 매칭 부서들만 저장 → 검색어 삭제 → 전체 트리 표시되지만 expandedSet은 이전 검색 결과에 해당하는 코드만 expanded — 트리가 부분 펼침 상태로 보임.
- 이후 collapseAll/expandAll 재클릭으로 복구 가능. 치명적이지 않으나 UX 혼란.

---

## 13. siteOpen 회사 변경 시 유지 ✅

- `siteOpen` state는 회사 변경에 리셋 로직 없음 → 기존 상태 유지.
- 기본값 `true` — 첫 로드, 회사 전환 모두 자동 펼침. 의도된 동작.

---

## 14. handleClearDetail — isDirty 경고 없음 ❌

- `page.tsx:299–304`:
  ```ts
  const handleClearDetail = () => {
    setSelectedCode(null);
    setIsNewMode(false);
    setDraft(emptyDraft(selectedCompanyCode));
    setOriginalDraft(null);
  };
  ```
- `page.tsx:553–555` `isDirty` 계산은 존재하지만 `handleClearDetail` 진입 시 확인 없음.
- `page.tsx:808–815` X 버튼 `onClick={handleClearDetail}` — 폼에 미저장 내용이 있어도 즉시 초기화.
- 또한 `handleConfirmDeleteContext`(line 435) 내부에서도 `handleClearDetail()` 직접 호출 — 삭제 성공 후 폼 클리어는 정상이지만, 만약 다른 부서가 선택된 상태에서 컨텍스트 삭제가 실행되면 해당 부서 폼도 무조건 지워짐 (line 435: `if (selectedCode === d.dept_code) handleClearDetail()` — 조건 있어서 이 케이스는 OK).

---

## 15. bulk register — 실패 부서 상세 없음 ⚠️

- `page.tsx:957–985`:
  ```ts
  toast({
    title: `일괄등록 완료`,
    description: `성공 ${success}건 / 실패 ${failed}건`,
  });
  ```
- 실패 건의 `dept_code`, `dept_name`, 실패 사유(에러 메시지)를 수집하지 않음.
- 100개 중 5개 실패 시 어떤 코드가 왜 실패했는지 사용자가 알 수 없음. 재시도 불가.
- `createDepartment` 반환의 `error`, `isDuplicate` 필드를 버리고 `failed++`만 카운트.

---

## 16. member_count — UI에서 멤버 추가/제거 불가 ⚠️

- `page.tsx:1469–1502` `MembersPanel` — 멤버 목록 표시만 있고 추가/제거 버튼 없음.
- `department.ts:148–176` `addDepartmentMember`, `removeDepartmentMember` API 함수 존재하나 page.tsx에서 호출되지 않음.
- `department.ts:132–143` `searchUsers` API도 미사용.
- `page.tsx:1472` 멤버 수 표시만 있고 편집 액션 없음 → 기능 미구현.
- 트리 노드의 `member_count`(line 1125–1128)는 부서 목록 재로드 시 갱신되지만 UI에서 변경할 방법 없음.

---

## 추가 발견 시나리오

### A. 정렬 이동(handleMove) — deleted 부서 포함 siblings ⚠️

- `page.tsx:382`:
  ```ts
  const siblings = departments
    .filter((d) => (d.parent_dept_code ?? null) === (dept.parent_dept_code ?? null) && !(d as any).deleted_at)
  ```
- `deleted_at` 체크로 deleted 제외. OK.
- 단, `showDeleted=true`일 때 deleted 부서도 `departments`에 포함되어 있음 — siblings 필터가 올바르게 제외하므로 문제 없음.

### B. DepartmentPicker single 모드 — deleted 부서 클릭 가능 ⚠️

- `DepartmentPicker.tsx:179–184` `handleNodeClick`:
  ```ts
  if (isExcluded(d.dept_code)) return;
  if (mode === "single") { onSelect(d.dept_code); onClose(); return; }
  ```
- `isDeleted` 체크가 없음 — `includeDeleted=true`로 picker를 열면 deleted 부서도 선택 가능.
- 현재 `page.tsx:924` picker 호출 시 `includeDeleted` 미전달(false)이므로 실질 위험 없으나, 향후 includeDeleted 활성화 시 deleted 부서를 새 부모로 지정 가능.

### C. handleSave — isNewMode에서 draft.dept_code 중복 ⚠️

- `page.tsx:1258–1262` 부서코드 Input: `readOnly={!!draft.dept_code}` — 신규 시 빈 문자열이면 편집 가능.
- 사용자가 이미 존재하는 `dept_code`를 수동 입력 후 저장 → `createDepartment` 409 응답 → `page.tsx:484–486` toast "생성 실패"만 표시. `isDuplicate` 플래그 존재하지만 별도 메시지 없음.

### D. ancestors breadcrumb — deleted 조상 표시 ⚠️

- `page.tsx:164–178` `ancestors` useMemo: `departments` 배열에서 parent 체인 추적.
- `showDeleted=false`이면 deleted 조상은 `departments`에 없어 체인이 중간에 끊김 → breadcrumb 불완전.
- `showDeleted=true`이면 deleted 조상도 표시 — strikethrough 없이 일반 텍스트로 표시됨 (ancestors 렌더에 deleted 스타일링 없음, `page.tsx:772–777`).

---

## 요약 테이블

| # | 시나리오 | 판정 | 심각도 |
|---|---|---|---|
| 1 | 휴지통 모드 삭제 부서 클릭/메뉴 | ✅ | - |
| 2 | collectAllDescendants deleted 자손 | ⚠️ | 낮음 |
| 3 | picker "최상위로" 선택 UX 누락 | ⚠️ | 중간 |
| 4 | 부서원 탭 신규 부서 차단 | ✅ | - |
| 5 | 회사 변경 시 selectedCode/draft 잔존 | ❌ | 높음 |
| 6 | 부서원 fetch race condition | ❌ | 중간 |
| 7 | 신규 작성 중 회사 변경 company_code mismatch | ❌ | 높음 |
| 8 | 동시 두 삭제 다이얼로그 | ⚠️ | 낮음 |
| 9 | handleDelete selectedCode race | ❌ | 중간 |
| 10 | picker 다른 회사 부서 선택 차단 | ✅ | - |
| 11 | 변경이력 API 미구현 | ❌ | 중간 |
| 12 | expandAll 검색 후 부분 펼침 | ⚠️ | 낮음 |
| 13 | siteOpen 회사 변경 유지 | ✅ | - |
| 14 | handleClearDetail isDirty 경고 없음 | ❌ | 중간 |
| 15 | bulk register 실패 상세 없음 | ⚠️ | 중간 |
| 16 | 부서원 추가/제거 UI 미구현 | ❌ | 중간 |
| A | handleMove deleted siblings 제외 | ✅ | - |
| B | picker single deleted 부서 선택 가능성 | ⚠️ | 낮음 |
| C | handleSave dept_code 중복 안내 미흡 | ⚠️ | 낮음 |
| D | ancestors breadcrumb deleted 조상 스타일 누락 | ⚠️ | 낮음 |
