# 조직도 관리 시스템 > **작성일**: 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