Files
sam-docs/sam/docs/projects/org-chart/README.md
김보곤 5798058125 docs: [projects] 조직도 관리 시스템 기술문서 추가
- projects/org-chart/README.md: 아키텍처, API, DB, 프론트엔드 상세
- index_projects.md: 조직도 프로젝트 등록
- INDEX.md: 조직도 문서 링크 추가
2026-03-06 20:34:06 +09:00

318 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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