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

조직도 관리 시스템

작성일: 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) 재귀 함수로 이를 차단한다.

// 드래그 대상(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):

{ "employee_id": 1, "department_id": 5 }
 { "success": true }

부서 순서 변경 (POST /rd/org-chart/reorder-depts):

{
  "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):

{ "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)

관련 문서


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