# 조직도 관리 시스템

> **작성일**: 2026-03-06
> **상태**: 🟢 v1.0 구현 완료
> **프로젝트**: MNG 전용 (Blade + Alpine.js + SortableJS)

---

## 1. 개요

### 1.1 목적

테넌트별 조직 구조를 시각적으로 관리하는 트리형 조직도 시스템.
부서 계층 구조와 직원 배치를 드래그 앤 드롭으로 관리한다.

### 1.2 주요 기능

| 기능 | 설명 |
|------|------|
| 트리형 조직도 | 회사 → 부서 → 하위부서 (무한 depth) 계층 표시 |
| 직원 배치 | 드래그 앤 드롭으로 직원을 부서에 배치/해제 |
| 부서 순서 변경 | 같은 레벨 내 부서 순서 드래그로 변경 |
| 부서 계층 이동 | 부서를 다른 부서 아래로 드래그하여 parent 변경 |
| 부서 숨기기 | 더블클릭 → 숨기기 버튼 → DB 저장 (영구) |
| 임원 필터링 | 대표이사/사장 등은 미배치 목록에서 제외 |

---

## 2. 기술 스택

| 구분 | 기술 |
|------|------|
| 백엔드 | Laravel (MNG 프로젝트) |
| 프론트엔드 | Alpine.js + 수동 DOM 렌더링 |
| 드래그 앤 드롭 | SortableJS |
| 스타일 | Tailwind CSS + inline style |
| 데이터 저장 | MySQL `departments`, `employees` 테이블 |

---

## 3. 아키텍처

### 3.1 렌더링 방식

> **핵심**: Alpine.js `x-for` 대신 수동 `innerHTML` 렌더링을 사용한다.

SortableJS와 Alpine.js `x-for` 템플릿이 동시에 DOM을 조작하면 **이중 업데이트 버그**가 발생한다.
이를 해결하기 위해 부서 트리는 JavaScript로 HTML 문자열을 생성하고 `innerHTML`로 삽입한다.

```
Alpine.js 데이터 변경
    ↓
renderTree() 호출
    ↓
기존 SortableJS 인스턴스 destroy
    ↓
buildChildrenHtml(null, 0) → 재귀적 HTML 생성
    ↓
$refs.deptTree.innerHTML = html
    ↓
$nextTick → initDeptSortables() + initEmpSortables()
```

### 3.2 이벤트 처리

수동 렌더링된 HTML에는 Alpine 디렉티브가 없으므로 **이벤트 위임(Event Delegation)** 패턴을 사용한다.

```
루트 div @click="handleClick($event)"
         @dblclick="handleDblClick($event)"
    ↓
e.target.closest('[data-action]') 으로 액션 식별
    ↓
data-action 값에 따라 분기:
  - "unassign"    → 직원 미배치
  - "hide-dept"   → 부서 숨기기
  - "restore-dept" → 부서 복원
  - "dept-dblclick" → 더블클릭 시 숨기기 버튼 토글
```

### 3.3 순환 참조 방지

부서를 자신의 하위로 드래그하면 무한 루프가 발생한다.
`isDescendant(ancestorId, targetId)` 재귀 함수로 이를 차단한다.

```javascript
// 드래그 대상(dragId)의 자손인 곳으로는 이동 불가
onMove: (evt) => {
    const dragId = parseInt(evt.dragged.dataset.deptId);
    const toPid = evt.to.dataset.parentId ? parseInt(evt.to.dataset.parentId) : null;
    if (toPid === dragId || this.isDescendant(dragId, toPid)) return false;
}
```

---

## 4. 파일 구조

### 4.1 MNG 프로젝트

| 파일 | 역할 |
|------|------|
| `app/Http/Controllers/RdController.php` | 컨트롤러 (7개 메서드) |
| `app/Models/Tenants/Department.php` | 부서 모델 (`options` JSON cast) |
| `resources/views/rd/org-chart.blade.php` | 뷰 (Alpine.js + SortableJS) |
| `routes/web.php` | 라우트 (6개 엔드포인트) |

### 4.2 API 프로젝트

| 파일 | 역할 |
|------|------|
| `database/migrations/2026_03_06_201500_add_options_to_departments_table.php` | `options` JSON 컬럼 추가 |

---

## 5. API 엔드포인트

> 모든 엔드포인트는 `rd.` 네임 프리픽스 하위에 위치한다.

| Method | Route | 컨트롤러 메서드 | 설명 |
|--------|-------|---------------|------|
| GET | `/rd/org-chart` | `orgChart` | 조직도 페이지 |
| POST | `/rd/org-chart/assign` | `orgChartAssign` | 직원 부서 배치 |
| POST | `/rd/org-chart/unassign` | `orgChartUnassign` | 직원 부서 해제 |
| POST | `/rd/org-chart/reorder` | `orgChartReorder` | 직원 일괄 이동 |
| POST | `/rd/org-chart/reorder-depts` | `orgChartReorderDepts` | 부서 순서/계층 변경 |
| POST | `/rd/org-chart/toggle-hide` | `orgChartToggleHide` | 부서 숨기기/표시 토글 |

### 5.1 요청/응답 형식

**부서 배치** (`POST /rd/org-chart/assign`):
```json
{ "employee_id": 1, "department_id": 5 }
→ { "success": true }
```

**부서 순서 변경** (`POST /rd/org-chart/reorder-depts`):
```json
{
  "orders": [
    { "id": 1, "parent_id": null, "sort_order": 1 },
    { "id": 2, "parent_id": null, "sort_order": 2 },
    { "id": 3, "parent_id": 1,    "sort_order": 1 }
  ]
}
→ { "success": true }
```

**부서 숨기기** (`POST /rd/org-chart/toggle-hide`):
```json
{ "department_id": 5, "hidden": true }
→ { "success": true }
```

---

## 6. DB 구조

### 6.1 departments 테이블 (관련 컬럼)

| 컬럼 | 타입 | 설명 |
|------|------|------|
| `id` | int | PK |
| `tenant_id` | int | 테넌트 FK |
| `parent_id` | int (nullable) | 상위 부서 (null = 최상위) |
| `name` | varchar | 부서명 |
| `code` | varchar | 부서 코드 |
| `is_active` | bool | 활성 여부 |
| `sort_order` | int | 정렬 순서 |
| `options` | json (nullable) | 확장 속성 |

**`options` 키**:

| 키 | 타입 | 설명 |
|----|------|------|
| `orgchart_hidden` | boolean | 조직도에서 숨김 여부 |

### 6.2 employees 테이블 (관련 컬럼)

| 컬럼 | 타입 | 설명 |
|------|------|------|
| `id` | int | PK |
| `tenant_id` | int | 테넌트 FK |
| `department_id` | int (nullable) | 소속 부서 (null = 미배치) |
| `display_name` | varchar | 표시 이름 |
| `position_label` | varchar | 직책/직급 |
| `employee_status` | enum | `active`, `leave`, `resigned` |

---

## 7. 프론트엔드 구현 상세

### 7.1 Alpine.js 컴포넌트 (`orgChart()`)

**데이터**:

| 속성 | 타입 | 설명 |
|------|------|------|
| `departments` | Array | 전체 부서 목록 (서버에서 전달) |
| `employees` | Array | 전체 직원 목록 (서버에서 전달) |
| `hiddenDepts` | Set | 숨긴 부서 ID (DB에서 초기화) |
| `dblClickDept` | int/null | 더블클릭된 부서 ID (숨기기 버튼 표시용) |
| `execTitles` | Array | 임원 직책 목록 (`['대표이사', '사장', '부사장', '회장', '부회장']`) |

**핵심 메서드**:

| 메서드 | 설명 |
|--------|------|
| `renderTree()` | SortableJS 파괴 → HTML 재생성 → SortableJS 재초기화 |
| `buildChildrenHtml(parentId, level)` | 재귀적 자식 부서 HTML 생성 |
| `buildNodeHtml(dept, level)` | 단일 부서 카드 HTML (level별 스타일 차등) |
| `buildEmpHtml(emp, isLarge)` | 직원 카드 HTML |
| `isDeptHidden(deptId)` | 부서 또는 상위 부서가 숨김인지 재귀 체크 |
| `isDescendant(ancestorId, targetId)` | 순환 참조 방지 |
| `isExecutive(emp)` | 임원 여부 판별 |

### 7.2 SortableJS 그룹

| 그룹 | 대상 | 핸들 | 기능 |
|------|------|------|------|
| `departments` | `.org-children`, `.org-drop-target` | `.dept-drag-handle` | 부서 순서/계층 변경 |
| `employees` | `.emp-zone`, `#unassigned-zone` | (전체) | 직원 배치/해제 |

### 7.3 CSS 연결선

부서 간 연결선은 CSS `::before`/`::after` 의사 요소로 구현한다.

```
         부모 노드
            │  (vertical: div 1px × 24px)
    ┌───────┼───────┐  (horizontal: ::before + ::after)
    │       │       │  (vertical: div 1px × 24px)
  자식1   자식2   자식3
```

| 선택자 | 역할 |
|--------|------|
| `.org-node-wrap` 내부 div (1px × 24px) | 세로 연결선 |
| `.org-node-wrap:not(:first-child)::before` | 왼쪽 가로선 (left:0 ~ right:50%) |
| `.org-node-wrap:not(:last-child)::after` | 오른쪽 가로선 (left:50% ~ right:0) |
| `:only-child` | 단일 자식이면 가로선 숨김 |

### 7.4 부서 숨기기 UX 흐름

```
① 부서 헤더 더블클릭
    ↓
② dblClickDept = dept.id → renderTree()
    ↓
③ 헤더에 빨간 "숨기기" 버튼 표시
    ↓
④ "숨기기" 클릭
    ↓
⑤ hiddenDepts.add(id) → renderTree() → POST /toggle-hide (DB 저장)
    ↓
⑥ 해당 부서 + 하위 부서가 트리에서 제거
⑦ "숨겨진 부서" 패널에 표시
    ↓
⑧ 패널에서 👁 아이콘 클릭 → hiddenDepts.delete(id) → POST /toggle-hide
```

### 7.5 부서 레벨별 스타일

| Level | 색상 테마 | 너비 | 아이콘 |
|-------|---------|------|--------|
| 0 (최상위) | 보라 (`#7C3AED`) | 200px | `ri-building-2-line` |
| 1 (중간) | 인디고 (`#6366F1`) | 180px | `ri-git-branch-line` |
| 2+ (하위) | 회색 (`#6B7280`) | 160px | `ri-subtract-line` |

---

## 8. 비즈니스 규칙

### 8.1 임원 필터링

미배치 직원 목록에서 다음 조건에 해당하면 제외:

- `position_label`이 `['대표이사', '사장', '부사장', '회장', '부회장']` 중 하나
- `display_name`이 테넌트의 `ceo_name`과 일치

> 이유: 조직도 최상단에 "대표이사 OOO"이 이미 표시되므로 중복 방지

### 8.2 부서 숨기기

- `departments.options` JSON의 `orgchart_hidden` 키로 저장
- 숨긴 부서의 **하위 부서도 자동으로 숨겨짐** (`isDeptHidden` 재귀 체크)
- 숨겨진 부서 패널에는 **직접 숨긴 부서만** 표시 (자식은 부모 복원 시 같이 복원)
- 숨기기는 **조직도 표시 전용** — `is_active`와 무관하며, 부서 데이터에 영향 없음

### 8.3 직원 표시 형식

- 직책이 있으면: `{직책} {이름}` (예: "과장 전진선")
- 직책이 없으면: `{이름}` (예: "김보곤")

---

## 9. 개발 이력

| 날짜 | 커밋 | 내용 |
|------|------|------|
| 2026-03-06 | `a12ee886` | CSS 연결선 수정 + 빈 드롭 타겟 숨김 |
| 2026-03-06 | `9fd72e49` | 부서 숨기기 기능 추가 (프론트 전용) |
| 2026-03-06 | `8c8fd5f6` | 대표이사 미배치 제외 + 숨긴 부서 연결선 제거 |
| 2026-03-06 | `81157a15` | 부서 숨기기 상태 DB 저장 (`options.orgchart_hidden`) |

---

## 관련 문서

- [rules/department-tree-api.md](../../rules/department-tree-api.md) — 부서 트리 API 규칙
- [rules/employee-api.md](../../rules/employee-api.md) — 직원 API 규칙
- [system/database/hr.md](../../system/database/hr.md) — HR 테이블 스키마
- [standards/options-column-policy.md](../../standards/options-column-policy.md) — options JSON 컬럼 정책

---

**최종 업데이트**: 2026-03-06
