fix: [org-chart] 부서 드래그 정렬 버그 수정 및 계층 이동, 직책 표시 개선
- SortableJS+Alpine 충돌 해결: 수동 DOM 렌더링으로 전환
- 부서 드래그로 다른 부서 하위로 이동 가능 (parent_id 변경)
- 순환 참조 방지 (자기 자신/하위로 이동 불가)
- 재귀 렌더링으로 무제한 depth 지원
- 직책이 이름 앞에 표시 ("사원 김보곤")
- 빈 하위 드롭존: 드래그 시에만 표시
This commit is contained in:
@@ -3,8 +3,7 @@
|
||||
@section('title', '조직도 관리')
|
||||
|
||||
@section('content')
|
||||
<div x-data="orgChart()" x-init="init()">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div x-data="orgChart()" x-init="init()" @click="handleClick($event)">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<i class="ri-organization-chart text-purple-600"></i>
|
||||
@@ -18,240 +17,48 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 안내 -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg px-4 py-3 mb-6 text-sm text-blue-700 flex items-start gap-2">
|
||||
<i class="ri-information-line text-lg shrink-0 mt-0.5"></i>
|
||||
<span>직원 카드를 드래그하여 부서에 배치하거나, 미배치 영역으로 이동할 수 있습니다. 부서 카드도 드래그하여 순서를 변경할 수 있습니다. 변경은 즉시 저장됩니다.</span>
|
||||
<span>직원 카드를 드래그하여 부서에 배치합니다. 부서 헤더를 드래그하여 순서를 변경하거나 다른 부서 아래로 이동할 수 있습니다.</span>
|
||||
</div>
|
||||
|
||||
<!-- 미배치 직원 패널 (상단 접이식) -->
|
||||
<div x-data="{ panelOpen: unassignedCount > 0 }" class="mb-8">
|
||||
<!-- 미배치 직원 패널 -->
|
||||
<div class="mb-8">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="flex items-center gap-2 px-4 py-3 bg-orange-50 border-b border-orange-200 rounded-t-lg cursor-pointer select-none"
|
||||
@click="panelOpen = !panelOpen">
|
||||
<i class="ri-arrow-down-s-line transition-transform text-gray-400" :class="!panelOpen && '-rotate-90'"></i>
|
||||
@click="unassignedOpen = !unassignedOpen">
|
||||
<i class="ri-arrow-down-s-line transition-transform text-gray-400" :class="!unassignedOpen && '-rotate-90'"></i>
|
||||
<i class="ri-user-unfollow-line text-orange-500"></i>
|
||||
<span class="font-semibold text-gray-700 text-sm">미배치 직원</span>
|
||||
<span class="bg-orange-100 text-orange-600 text-xs font-bold px-2 py-0.5 rounded-full" x-text="unassignedCount"></span>
|
||||
<div class="ml-auto flex items-center gap-2" @click.stop>
|
||||
<input type="text" x-model="searchUnassigned" placeholder="이름 검색..."
|
||||
<input type="text" x-model="searchUnassigned" @input="renderUnassigned()" placeholder="이름 검색..."
|
||||
class="px-3 py-1 border border-gray-200 rounded text-xs focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
style="width: 160px;">
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="panelOpen" x-collapse>
|
||||
<div id="unassigned-zone" class="p-3 flex flex-wrap gap-2" data-department-id="" style="min-height: 50px;">
|
||||
<template x-for="emp in filteredUnassigned" :key="emp.id">
|
||||
<div class="employee-card bg-white border border-gray-200 rounded-lg px-3 py-2 cursor-grab hover:shadow-md hover:border-purple-300 transition flex items-center gap-2"
|
||||
:data-employee-id="emp.id">
|
||||
<div class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0"
|
||||
:style="`background-color: ${getAvatarColor(emp.display_name)}`"
|
||||
x-text="emp.display_name?.charAt(0) || '?'"></div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-800 whitespace-nowrap" x-text="emp.display_name"></p>
|
||||
<p class="text-xs text-gray-400 whitespace-nowrap" x-text="emp.position_label || ''"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="filteredUnassigned.length === 0" class="w-full text-center text-gray-400 text-xs py-4">
|
||||
<i class="ri-user-smile-line text-2xl block mb-1"></i>
|
||||
미배치 직원 없음
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="unassignedOpen" x-collapse>
|
||||
<div x-ref="unassignedZone" id="unassigned-zone" class="p-3 flex flex-wrap gap-2" data-department-id="" style="min-height: 50px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 클래식 조직도 (하향식 트리) -->
|
||||
<!-- 조직도 트리 -->
|
||||
<div class="overflow-x-auto pb-8">
|
||||
<div class="org-tree" style="min-width: fit-content;">
|
||||
<!-- 최상단: 회사 -->
|
||||
<div class="org-level flex justify-center">
|
||||
<div class="org-node-wrapper">
|
||||
<div class="org-node org-node-root rounded-xl px-6 py-4 shadow-lg text-center"
|
||||
style="background: linear-gradient(135deg, #7C3AED, #4338CA); color: #FFFFFF;">
|
||||
<div class="flex items-center justify-center gap-2 mb-1">
|
||||
<i class="ri-building-4-line text-lg"></i>
|
||||
<span class="font-bold text-lg" x-text="companyName"></span>
|
||||
</div>
|
||||
<p class="text-sm" style="color: #E0D4FC;" x-text="ceoName ? `대표이사 ${ceoName}` : ''"></p>
|
||||
</div>
|
||||
<div class="org-tree" style="display:flex;flex-direction:column;align-items:center;">
|
||||
<div class="rounded-xl px-6 py-4 shadow-lg text-center"
|
||||
style="background:linear-gradient(135deg,#7C3AED,#4338CA);color:#fff;">
|
||||
<div class="flex items-center justify-center gap-2 mb-1">
|
||||
<i class="ri-building-4-line text-lg"></i>
|
||||
<span class="font-bold text-lg" x-text="companyName"></span>
|
||||
</div>
|
||||
<p class="text-sm" style="color:#E0D4FC;" x-text="ceoName ? `대표이사 ${ceoName}` : ''"></p>
|
||||
</div>
|
||||
|
||||
<!-- 수직 연결선 (회사 → 1단계 부서) -->
|
||||
<div class="org-connector flex justify-center" x-show="rootDepartments.length > 0">
|
||||
<div class="w-px bg-gray-300" style="height: 32px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 수평 연결선 + 1단계 부서 -->
|
||||
<template x-if="rootDepartments.length > 0">
|
||||
<div>
|
||||
<!-- 수평선 (1단계 부서가 2개 이상일 때) -->
|
||||
<div class="flex justify-center" x-show="rootDepartments.length > 1">
|
||||
<div class="org-h-line bg-gray-300" style="height: 1px;"
|
||||
:style="`width: calc(${(rootDepartments.length - 1)} * 240px)`"></div>
|
||||
</div>
|
||||
|
||||
<!-- 1단계 부서들 (드래그 정렬 가능) -->
|
||||
<div id="root-dept-sortable" class="org-level flex justify-center gap-0">
|
||||
<template x-for="dept in rootDepartments" :key="dept.id">
|
||||
<div class="dept-sortable-item flex flex-col items-center" style="min-width: 240px;" :data-dept-id="dept.id">
|
||||
<!-- 수직선 (수평선 → 부서 카드) -->
|
||||
<div class="w-px bg-gray-300" style="height: 32px;" x-show="rootDepartments.length > 1"></div>
|
||||
|
||||
<!-- 부서 카드 -->
|
||||
<div class="org-node border-2 border-purple-300 rounded-xl shadow-sm text-center select-none hover:shadow-md transition"
|
||||
style="width: 200px; background: #FFFFFF;">
|
||||
<div class="dept-drag-handle cursor-grab active:cursor-grabbing px-4 py-2.5 rounded-t-xl border-b"
|
||||
style="background: #F5F3FF; border-color: #E9D5FF;">
|
||||
<div class="flex items-center justify-center gap-1.5">
|
||||
<i class="ri-drag-move-2-line text-purple-400 text-xs"></i>
|
||||
<i class="ri-building-2-line text-purple-600 text-sm"></i>
|
||||
<span class="font-bold text-gray-800 text-sm" x-text="dept.name"></span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400" x-text="dept.code ? `(${dept.code})` : ''"></span>
|
||||
</div>
|
||||
<div class="dept-drop-zone px-2 py-2" :data-department-id="dept.id" style="min-height: 40px;">
|
||||
<template x-for="emp in getDeptEmployees(dept.id)" :key="emp.id">
|
||||
<div class="employee-card flex items-center gap-1.5 bg-gray-50 rounded-lg px-2 py-1 mb-1 cursor-grab hover:bg-purple-50 transition text-left"
|
||||
:data-employee-id="emp.id">
|
||||
<div class="w-5 h-5 rounded-full flex items-center justify-center text-white shrink-0" style="font-size: 9px; font-weight: 700;"
|
||||
:style="`background-color: ${getAvatarColor(emp.display_name)}`"
|
||||
x-text="emp.display_name?.charAt(0) || '?'"></div>
|
||||
<span class="text-xs text-gray-700 truncate" x-text="emp.display_name" style="max-width: 110px;"></span>
|
||||
<button class="ml-auto text-gray-300 hover:text-red-500 transition shrink-0" title="미배치"
|
||||
@click.stop="unassignEmployee(emp.id)">
|
||||
<i class="ri-close-line" style="font-size: 11px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="getDeptEmployees(dept.id).length === 0" class="text-center text-gray-300 text-xs py-1">
|
||||
드래그하여 배치
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-2 py-1 rounded-b-xl border-t border-gray-100 text-xs text-gray-400"
|
||||
x-text="`${getDeptTotalCount(dept.id)}명`"></div>
|
||||
</div>
|
||||
|
||||
<!-- 하위 부서 토글 버튼 -->
|
||||
<button x-show="getChildDepartments(dept.id).length > 0"
|
||||
@click="toggleDept(dept.id)"
|
||||
class="mt-1 text-xs text-purple-400 hover:text-purple-600 transition">
|
||||
<i class="ri-arrow-down-s-line" :class="expandedDepts.includes(dept.id) ? '' : '-rotate-90'" style="transition: transform 0.2s;"></i>
|
||||
<span x-text="expandedDepts.includes(dept.id) ? '접기' : `하위 ${getChildDepartments(dept.id).length}개`"></span>
|
||||
</button>
|
||||
|
||||
<!-- 하위 부서 연결 -->
|
||||
<template x-if="getChildDepartments(dept.id).length > 0 && expandedDepts.includes(dept.id)">
|
||||
<div class="flex flex-col items-center">
|
||||
<!-- 수직 연결선 -->
|
||||
<div class="w-px bg-gray-300" style="height: 24px;"></div>
|
||||
|
||||
<!-- 수평선 (2단계 부서가 2개 이상일 때) -->
|
||||
<div x-show="getChildDepartments(dept.id).length > 1" class="flex justify-center">
|
||||
<div class="bg-gray-300" style="height: 1px;"
|
||||
:style="`width: calc(${(getChildDepartments(dept.id).length - 1)} * 200px)`"></div>
|
||||
</div>
|
||||
|
||||
<!-- 2단계 부서들 (드래그 정렬 가능) -->
|
||||
<div class="child-dept-sortable flex justify-center gap-0" :data-parent-id="dept.id">
|
||||
<template x-for="child in getChildDepartments(dept.id)" :key="child.id">
|
||||
<div class="dept-sortable-item flex flex-col items-center" style="min-width: 200px;" :data-dept-id="child.id">
|
||||
<!-- 수직선 -->
|
||||
<div class="w-px bg-gray-300" style="height: 24px;" x-show="getChildDepartments(dept.id).length > 1"></div>
|
||||
|
||||
<!-- 2단계 부서 카드 -->
|
||||
<div class="org-node bg-white border border-indigo-200 rounded-lg shadow-sm text-center hover:shadow-md transition"
|
||||
style="width: 180px;">
|
||||
<div class="dept-drag-handle cursor-grab active:cursor-grabbing px-3 py-2 rounded-t-lg border-b"
|
||||
style="background: #EEF2FF; border-color: #C7D2FE;">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<i class="ri-drag-move-2-line text-indigo-300" style="font-size: 10px;"></i>
|
||||
<i class="ri-git-branch-line text-indigo-400 text-xs"></i>
|
||||
<span class="font-semibold text-gray-700 text-xs" x-text="child.name"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dept-drop-zone px-2 py-1.5" :data-department-id="child.id" style="min-height: 32px;">
|
||||
<template x-for="emp in getDeptEmployees(child.id)" :key="emp.id">
|
||||
<div class="employee-card flex items-center gap-1 bg-gray-50 rounded px-1.5 py-0.5 mb-0.5 cursor-grab hover:bg-indigo-50 transition text-left"
|
||||
:data-employee-id="emp.id">
|
||||
<div class="w-4 h-4 rounded-full flex items-center justify-center text-white shrink-0" style="font-size: 8px; font-weight: 700;"
|
||||
:style="`background-color: ${getAvatarColor(emp.display_name)}`"
|
||||
x-text="emp.display_name?.charAt(0) || '?'"></div>
|
||||
<span class="text-xs text-gray-600 truncate" x-text="emp.display_name" style="max-width: 100px;"></span>
|
||||
<button class="ml-auto text-gray-300 hover:text-red-500 transition shrink-0" title="미배치"
|
||||
@click.stop="unassignEmployee(emp.id)">
|
||||
<i class="ri-close-line" style="font-size: 10px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="getDeptEmployees(child.id).length === 0" class="text-center text-gray-300 py-0.5" style="font-size: 10px;">
|
||||
드래그 배치
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-2 py-0.5 rounded-b-lg border-t border-gray-100 text-xs text-gray-400"
|
||||
x-text="`${getDeptTotalCount(child.id)}명`"></div>
|
||||
</div>
|
||||
|
||||
<!-- 3단계 하위 부서 -->
|
||||
<template x-if="getChildDepartments(child.id).length > 0">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-px bg-gray-300" style="height: 20px;"></div>
|
||||
<div x-show="getChildDepartments(child.id).length > 1" class="flex justify-center">
|
||||
<div class="bg-gray-300" style="height: 1px;"
|
||||
:style="`width: calc(${(getChildDepartments(child.id).length - 1)} * 170px)`"></div>
|
||||
</div>
|
||||
<div class="flex justify-center gap-0">
|
||||
<template x-for="gc in getChildDepartments(child.id)" :key="gc.id">
|
||||
<div class="flex flex-col items-center" style="min-width: 170px;">
|
||||
<div class="w-px bg-gray-300" style="height: 20px;" x-show="getChildDepartments(child.id).length > 1"></div>
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm text-center" style="width: 150px;">
|
||||
<div class="bg-gray-50 px-2 py-1.5 rounded-t-lg border-b border-gray-100">
|
||||
<span class="text-xs font-medium text-gray-600" x-text="gc.name"></span>
|
||||
</div>
|
||||
<div class="dept-drop-zone px-1.5 py-1" :data-department-id="gc.id" style="min-height: 28px;">
|
||||
<template x-for="emp in getDeptEmployees(gc.id)" :key="emp.id">
|
||||
<div class="employee-card flex items-center gap-1 bg-gray-50 rounded px-1 py-0.5 mb-0.5 cursor-grab hover:bg-gray-100 transition text-left"
|
||||
:data-employee-id="emp.id">
|
||||
<div class="w-4 h-4 rounded-full flex items-center justify-center text-white shrink-0" style="font-size: 7px; font-weight: 700;"
|
||||
:style="`background-color: ${getAvatarColor(emp.display_name)}`"
|
||||
x-text="emp.display_name?.charAt(0) || '?'"></div>
|
||||
<span class="text-xs text-gray-600 truncate" x-text="emp.display_name" style="max-width: 85px;"></span>
|
||||
<button class="ml-auto text-gray-300 hover:text-red-500 transition shrink-0" @click.stop="unassignEmployee(emp.id)">
|
||||
<i class="ri-close-line" style="font-size: 9px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="getDeptEmployees(gc.id).length === 0" class="text-center text-gray-300 py-0.5" style="font-size: 9px;">
|
||||
드래그
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div x-show="rootDepartments.length === 0" class="text-center text-gray-400 py-12">
|
||||
<i class="ri-building-2-line text-4xl block mb-2"></i>
|
||||
<p>등록된 부서가 없습니다. 먼저 부서를 등록해주세요.</p>
|
||||
</div>
|
||||
<div x-show="rootDepts.length > 0" style="width:1px;height:24px;background:#D1D5DB;"></div>
|
||||
<div x-ref="deptTree"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 저장 중 토스트 -->
|
||||
<div x-show="saving" x-transition
|
||||
class="fixed bottom-6 right-6 bg-gray-800 text-white px-4 py-2 rounded-lg shadow-lg text-sm flex items-center gap-2 z-50">
|
||||
<i class="ri-loader-4-line animate-spin"></i> 저장 중...
|
||||
@@ -259,13 +66,26 @@ class="fixed bottom-6 right-6 bg-gray-800 text-white px-4 py-2 rounded-lg shadow
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.org-tree { display: flex; flex-direction: column; align-items: center; }
|
||||
.employee-card.sortable-ghost { opacity: 0.4; }
|
||||
.employee-card.sortable-drag { box-shadow: 0 8px 25px rgba(0,0,0,0.15); z-index: 999; }
|
||||
.dept-drop-zone.sortable-drag-over { background-color: #F3E8FF; border-radius: 8px; }
|
||||
.dept-sortable-item.sortable-ghost { opacity: 0.3; }
|
||||
.dept-sortable-item.sortable-drag { box-shadow: 0 8px 25px rgba(0,0,0,0.2); z-index: 999; }
|
||||
.dept-sortable-item.sortable-chosen .dept-drag-handle { background-color: #DDD6FE !important; }
|
||||
.org-children { display:flex; justify-content:center; position:relative; }
|
||||
.org-node-wrap { display:flex; flex-direction:column; align-items:center; position:relative; padding:0 12px; }
|
||||
.org-children > .org-node-wrap:not(:first-child)::before {
|
||||
content:''; position:absolute; top:12px; left:0; right:50%; height:1px; background:#D1D5DB;
|
||||
}
|
||||
.org-children > .org-node-wrap:not(:last-child)::after {
|
||||
content:''; position:absolute; top:12px; left:50%; right:0; height:1px; background:#D1D5DB;
|
||||
}
|
||||
.org-children > .org-node-wrap:only-child::before,
|
||||
.org-children > .org-node-wrap:only-child::after { display:none; }
|
||||
.employee-card.sortable-ghost, .org-node-wrap.sortable-ghost { opacity:0.3; }
|
||||
.employee-card.sortable-drag { box-shadow:0 8px 25px rgba(0,0,0,0.15); z-index:999; }
|
||||
.org-node-wrap.sortable-drag { box-shadow:0 8px 25px rgba(0,0,0,0.2); z-index:999; }
|
||||
body.dept-dragging .org-drop-target { min-height:28px !important; max-height:40px !important; opacity:1 !important; }
|
||||
.org-drop-target {
|
||||
min-height:0; max-height:0; opacity:0; overflow:hidden;
|
||||
border:2px dashed #C4B5FD; border-radius:8px; margin-top:4px;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-size:10px; color:#A78BFA; transition:all 0.2s;
|
||||
}
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@@ -278,219 +98,280 @@ function orgChart() {
|
||||
companyName: @json($companyName),
|
||||
ceoName: @json($ceoName),
|
||||
searchUnassigned: '',
|
||||
unassignedOpen: true,
|
||||
saving: false,
|
||||
sortables: [],
|
||||
deptSortables: [],
|
||||
expandedDepts: [],
|
||||
empSortables: [],
|
||||
unassignedSortable: null,
|
||||
|
||||
get totalEmployees() { return this.employees.length; },
|
||||
get assignedCount() { return this.employees.filter(e => e.department_id).length; },
|
||||
get unassignedCount() { return this.employees.filter(e => !e.department_id).length; },
|
||||
get unassignedEmployees() { return this.employees.filter(e => !e.department_id); },
|
||||
get filteredUnassigned() {
|
||||
if (!this.searchUnassigned) return this.unassignedEmployees;
|
||||
const s = this.searchUnassigned.toLowerCase();
|
||||
return this.unassignedEmployees.filter(e => e.display_name?.toLowerCase().includes(s));
|
||||
},
|
||||
get rootDepartments() {
|
||||
get rootDepts() { return this.getChildrenSorted(null); },
|
||||
|
||||
getChildrenSorted(parentId) {
|
||||
return this.departments
|
||||
.filter(d => !d.parent_id)
|
||||
.sort((a, b) => (a.sort_order ?? 999) - (b.sort_order ?? 999) || a.name.localeCompare(b.name));
|
||||
.filter(d => (d.parent_id || null) === parentId)
|
||||
.sort((a, b) => (a.sort_order ?? 999) - (b.sort_order ?? 999) || (a.name || '').localeCompare(b.name || ''));
|
||||
},
|
||||
|
||||
getChildDepartments(parentId) {
|
||||
return this.departments
|
||||
.filter(d => d.parent_id === parentId)
|
||||
.sort((a, b) => (a.sort_order ?? 999) - (b.sort_order ?? 999) || a.name.localeCompare(b.name));
|
||||
},
|
||||
|
||||
getDeptEmployees(deptId) {
|
||||
return this.employees.filter(e => e.department_id === deptId);
|
||||
},
|
||||
|
||||
getDeptEmployees(deptId) { return this.employees.filter(e => e.department_id === deptId); },
|
||||
getDeptTotalCount(deptId) {
|
||||
let count = this.employees.filter(e => e.department_id === deptId).length;
|
||||
const children = this.departments.filter(d => d.parent_id === deptId);
|
||||
for (const child of children) {
|
||||
count += this.getDeptTotalCount(child.id);
|
||||
}
|
||||
return count;
|
||||
let c = this.getDeptEmployees(deptId).length;
|
||||
for (const ch of this.departments.filter(d => d.parent_id === deptId)) c += this.getDeptTotalCount(ch.id);
|
||||
return c;
|
||||
},
|
||||
|
||||
toggleDept(deptId) {
|
||||
const idx = this.expandedDepts.indexOf(deptId);
|
||||
if (idx >= 0) {
|
||||
this.expandedDepts.splice(idx, 1);
|
||||
} else {
|
||||
this.expandedDepts.push(deptId);
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.initDropZones();
|
||||
this.initDeptSortables();
|
||||
});
|
||||
isDescendant(ancestorId, targetId) {
|
||||
if (!targetId) return false;
|
||||
if (targetId === ancestorId) return true;
|
||||
const p = this.departments.find(d => d.id === targetId);
|
||||
return p ? this.isDescendant(ancestorId, p.parent_id) : false;
|
||||
},
|
||||
|
||||
getAvatarColor(name) {
|
||||
if (!name) return '#9CA3AF';
|
||||
const colors = ['#6366F1','#8B5CF6','#EC4899','#F59E0B','#10B981','#3B82F6','#EF4444','#14B8A6','#F97316','#6D28D9'];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
const c = ['#6366F1','#8B5CF6','#EC4899','#F59E0B','#10B981','#3B82F6','#EF4444','#14B8A6','#F97316','#6D28D9'];
|
||||
let h = 0; for (let i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h);
|
||||
return c[Math.abs(h) % c.length];
|
||||
},
|
||||
esc(s) { return s ? String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : ''; },
|
||||
|
||||
init() {
|
||||
// 하위 부서가 있는 1단계 부서는 기본 펼침
|
||||
this.expandedDepts = this.rootDepartments
|
||||
.filter(d => this.getChildDepartments(d.id).length > 0)
|
||||
.map(d => d.id);
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.initSortable();
|
||||
this.initDropZones();
|
||||
this.initDeptSortables();
|
||||
});
|
||||
this.renderTree();
|
||||
this.renderUnassigned();
|
||||
this.initUnassignedSortable();
|
||||
},
|
||||
|
||||
initSortable() {
|
||||
const unassignedEl = document.getElementById('unassigned-zone');
|
||||
if (unassignedEl && !unassignedEl._sortableInitialized) {
|
||||
unassignedEl._sortableInitialized = true;
|
||||
this.sortables.push(new Sortable(unassignedEl, {
|
||||
group: 'org-chart',
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
onAdd: (evt) => this.handleDrop(evt),
|
||||
}));
|
||||
// ===== 미배치 직원 =====
|
||||
renderUnassigned() {
|
||||
const zone = this.$refs.unassignedZone;
|
||||
if (!zone) return;
|
||||
let emps = this.employees.filter(e => !e.department_id);
|
||||
if (this.searchUnassigned) {
|
||||
const q = this.searchUnassigned.toLowerCase();
|
||||
emps = emps.filter(e => (e.display_name || '').toLowerCase().includes(q));
|
||||
}
|
||||
if (!emps.length) {
|
||||
zone.innerHTML = '<div style="width:100%;text-align:center;color:#9CA3AF;font-size:12px;padding:16px 0;"><i class="ri-user-smile-line" style="font-size:24px;display:block;margin-bottom:4px;"></i>미배치 직원 없음</div>';
|
||||
return;
|
||||
}
|
||||
zone.innerHTML = emps.map(e => this.buildEmpHtml(e, true)).join('');
|
||||
},
|
||||
|
||||
initDropZones() {
|
||||
document.querySelectorAll('.dept-drop-zone').forEach(el => {
|
||||
if (el._sortableInitialized) return;
|
||||
el._sortableInitialized = true;
|
||||
this.sortables.push(new Sortable(el, {
|
||||
group: 'org-chart',
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
onAdd: (evt) => this.handleDrop(evt),
|
||||
}));
|
||||
initUnassignedSortable() {
|
||||
const zone = document.getElementById('unassigned-zone');
|
||||
if (!zone) return;
|
||||
this.unassignedSortable = new Sortable(zone, {
|
||||
group: 'employees', animation: 150,
|
||||
ghostClass: 'sortable-ghost', dragClass: 'sortable-drag',
|
||||
onAdd: (evt) => {
|
||||
const empId = parseInt(evt.item.dataset.employeeId);
|
||||
evt.item.remove();
|
||||
const emp = this.employees.find(e => e.id === empId);
|
||||
if (emp) emp.department_id = null;
|
||||
this.renderTree();
|
||||
this.renderUnassigned();
|
||||
this.saveAssignment(empId, null);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// ===== 부서 트리 =====
|
||||
renderTree() {
|
||||
this.deptSortables.forEach(s => s.destroy()); this.deptSortables = [];
|
||||
this.empSortables.forEach(s => s.destroy()); this.empSortables = [];
|
||||
const el = this.$refs.deptTree;
|
||||
if (!el) return;
|
||||
el.innerHTML = this.buildChildrenHtml(null, 0);
|
||||
this.$nextTick(() => { this.initDeptSortables(); this.initEmpSortables(); });
|
||||
},
|
||||
|
||||
buildChildrenHtml(parentId, level) {
|
||||
const children = this.getChildrenSorted(parentId);
|
||||
if (!children.length && parentId !== null) {
|
||||
return `<div class="org-children org-drop-target" data-parent-id="${parentId}">하위 부서 드롭</div>`;
|
||||
}
|
||||
if (!children.length) return '';
|
||||
let h = `<div class="org-children" data-parent-id="${parentId ?? ''}">`;
|
||||
for (const d of children) h += this.buildNodeHtml(d, level);
|
||||
h += '</div>';
|
||||
return h;
|
||||
},
|
||||
|
||||
buildNodeHtml(dept, level) {
|
||||
const emps = this.getDeptEmployees(dept.id);
|
||||
const children = this.getChildrenSorted(dept.id);
|
||||
const total = this.getDeptTotalCount(dept.id);
|
||||
const styles = [
|
||||
{ border:'2px solid #C4B5FD', hBg:'#F5F3FF', hBdr:'#E9D5FF', icon:'ri-building-2-line', iconClr:'#7C3AED', w:'200px' },
|
||||
{ border:'1px solid #C7D2FE', hBg:'#EEF2FF', hBdr:'#C7D2FE', icon:'ri-git-branch-line', iconClr:'#6366F1', w:'180px' },
|
||||
{ border:'1px solid #E5E7EB', hBg:'#F9FAFB', hBdr:'#E5E7EB', icon:'ri-subtract-line', iconClr:'#6B7280', w:'160px' },
|
||||
];
|
||||
const s = styles[Math.min(level, styles.length - 1)];
|
||||
|
||||
let h = `<div class="org-node-wrap" data-dept-id="${dept.id}">`;
|
||||
h += `<div style="width:1px;height:24px;background:#D1D5DB;"></div>`;
|
||||
h += `<div style="width:${s.w};border:${s.border};border-radius:12px;background:#fff;text-align:center;overflow:hidden;">`;
|
||||
h += `<div class="dept-drag-handle" style="cursor:grab;padding:8px 12px;background:${s.hBg};border-bottom:1px solid ${s.hBdr};display:flex;align-items:center;justify-content:center;gap:4px;">`;
|
||||
h += `<i class="ri-drag-move-2-line" style="color:${s.iconClr};opacity:0.5;font-size:11px;"></i>`;
|
||||
h += `<i class="${s.icon}" style="color:${s.iconClr};font-size:13px;"></i>`;
|
||||
h += `<span style="font-weight:700;font-size:13px;color:#1F2937;">${this.esc(dept.name)}</span>`;
|
||||
if (dept.code) h += `<span style="font-size:11px;color:#9CA3AF;">(${this.esc(dept.code)})</span>`;
|
||||
h += '</div>';
|
||||
h += `<div class="emp-zone" data-department-id="${dept.id}" style="padding:6px;min-height:36px;">`;
|
||||
if (emps.length) {
|
||||
for (const e of emps) h += this.buildEmpHtml(e, false);
|
||||
} else {
|
||||
h += '<div style="text-align:center;color:#D1D5DB;font-size:11px;padding:4px 0;">드래그하여 배치</div>';
|
||||
}
|
||||
h += '</div>';
|
||||
h += `<div style="padding:3px 8px;background:#F9FAFB;border-top:1px solid #F3F4F6;font-size:11px;color:#9CA3AF;">${total}명</div>`;
|
||||
h += '</div>';
|
||||
if (children.length > 0) {
|
||||
h += `<div style="width:1px;height:24px;background:#D1D5DB;"></div>`;
|
||||
}
|
||||
h += this.buildChildrenHtml(dept.id, level + 1);
|
||||
h += '</div>';
|
||||
return h;
|
||||
},
|
||||
|
||||
buildEmpHtml(emp, isLarge) {
|
||||
const color = this.getAvatarColor(emp.display_name);
|
||||
const initial = (emp.display_name || '?').charAt(0);
|
||||
const pos = emp.position_label || '';
|
||||
const name = this.esc(emp.display_name || '');
|
||||
const label = pos ? `${this.esc(pos)} ${name}` : name;
|
||||
if (isLarge) {
|
||||
return `<div class="employee-card" data-employee-id="${emp.id}" style="display:flex;align-items:center;gap:8px;padding:6px 10px;border:1px solid #E5E7EB;border-radius:8px;background:#fff;cursor:grab;">
|
||||
<div style="width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-size:11px;font-weight:700;background:${color};flex-shrink:0;">${initial}</div>
|
||||
<div><p style="font-size:13px;font-weight:500;color:#1F2937;white-space:nowrap;">${label}</p></div></div>`;
|
||||
}
|
||||
return `<div class="employee-card" data-employee-id="${emp.id}" style="display:flex;align-items:center;gap:4px;padding:3px 6px;margin-bottom:2px;background:#F9FAFB;border-radius:6px;cursor:grab;">
|
||||
<div style="width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-size:8px;font-weight:700;background:${color};flex-shrink:0;">${initial}</div>
|
||||
<span style="font-size:11px;color:#374151;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:110px;">${label}</span>
|
||||
<button data-action="unassign" data-emp-id="${emp.id}" style="margin-left:auto;color:#D1D5DB;border:none;background:none;cursor:pointer;padding:0;flex-shrink:0;" title="미배치"><i class="ri-close-line" style="font-size:11px;"></i></button></div>`;
|
||||
},
|
||||
|
||||
// ===== SortableJS =====
|
||||
initDeptSortables() {
|
||||
// 1단계 부서 정렬
|
||||
const rootEl = document.getElementById('root-dept-sortable');
|
||||
if (rootEl && !rootEl._deptSortableInitialized) {
|
||||
rootEl._deptSortableInitialized = true;
|
||||
this.deptSortables.push(new Sortable(rootEl, {
|
||||
handle: '.dept-drag-handle',
|
||||
animation: 200,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
draggable: '.dept-sortable-item',
|
||||
onEnd: (evt) => this.handleDeptReorder(evt, null),
|
||||
}));
|
||||
document.querySelectorAll('.org-children').forEach(el => {
|
||||
const inst = new Sortable(el, {
|
||||
group: 'departments', handle: '.dept-drag-handle', animation: 200,
|
||||
fallbackOnBody: true, swapThreshold: 0.65, draggable: '.org-node-wrap', emptyInsertThreshold: 20,
|
||||
onStart: () => document.body.classList.add('dept-dragging'),
|
||||
onEnd: () => document.body.classList.remove('dept-dragging'),
|
||||
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;
|
||||
},
|
||||
onAdd: (evt) => this.handleDeptMove(evt),
|
||||
onUpdate: (evt) => this.handleDeptReorder(evt),
|
||||
});
|
||||
this.deptSortables.push(inst);
|
||||
});
|
||||
document.querySelectorAll('.org-drop-target').forEach(el => {
|
||||
const inst = new Sortable(el, {
|
||||
group: 'departments', animation: 200, draggable: '.org-node-wrap', emptyInsertThreshold: 30,
|
||||
onStart: () => document.body.classList.add('dept-dragging'),
|
||||
onEnd: () => document.body.classList.remove('dept-dragging'),
|
||||
onMove: (evt) => {
|
||||
const dragId = parseInt(evt.dragged.dataset.deptId);
|
||||
const toPid = parseInt(el.dataset.parentId);
|
||||
if (toPid === dragId || this.isDescendant(dragId, toPid)) return false;
|
||||
},
|
||||
onAdd: (evt) => this.handleDeptMove(evt),
|
||||
});
|
||||
this.deptSortables.push(inst);
|
||||
});
|
||||
},
|
||||
|
||||
initEmpSortables() {
|
||||
document.querySelectorAll('.emp-zone').forEach(el => {
|
||||
const inst = new Sortable(el, {
|
||||
group: 'employees', animation: 150,
|
||||
ghostClass: 'sortable-ghost', dragClass: 'sortable-drag',
|
||||
onAdd: (evt) => {
|
||||
const empId = parseInt(evt.item.dataset.employeeId);
|
||||
const deptId = parseInt(evt.to.dataset.departmentId);
|
||||
evt.item.remove();
|
||||
const emp = this.employees.find(e => e.id === empId);
|
||||
if (emp) emp.department_id = deptId;
|
||||
this.renderTree(); this.renderUnassigned();
|
||||
this.saveAssignment(empId, deptId);
|
||||
},
|
||||
});
|
||||
this.empSortables.push(inst);
|
||||
});
|
||||
},
|
||||
|
||||
handleDeptMove(evt) {
|
||||
const deptId = parseInt(evt.item.dataset.deptId);
|
||||
const newParentId = evt.to.dataset.parentId ? parseInt(evt.to.dataset.parentId) : null;
|
||||
const dept = this.departments.find(d => d.id === deptId);
|
||||
if (dept) dept.parent_id = newParentId;
|
||||
const orders = this.collectSortOrders(evt.to, newParentId);
|
||||
if (evt.from !== evt.to) {
|
||||
const oldPid = evt.from.dataset.parentId ? parseInt(evt.from.dataset.parentId) : null;
|
||||
orders.push(...this.collectSortOrders(evt.from, oldPid));
|
||||
}
|
||||
|
||||
// 2단계 부서 정렬
|
||||
document.querySelectorAll('.child-dept-sortable').forEach(el => {
|
||||
if (el._deptSortableInitialized) return;
|
||||
el._deptSortableInitialized = true;
|
||||
const parentId = parseInt(el.dataset.parentId);
|
||||
this.deptSortables.push(new Sortable(el, {
|
||||
handle: '.dept-drag-handle',
|
||||
animation: 200,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
draggable: '.dept-sortable-item',
|
||||
onEnd: (evt) => this.handleDeptReorder(evt, parentId),
|
||||
}));
|
||||
orders.forEach(o => {
|
||||
const d = this.departments.find(dd => dd.id === o.id);
|
||||
if (d) { d.parent_id = o.parent_id; d.sort_order = o.sort_order; }
|
||||
});
|
||||
},
|
||||
|
||||
handleDrop(evt) {
|
||||
const employeeId = parseInt(evt.item.dataset.employeeId);
|
||||
const targetDeptId = evt.to.dataset.departmentId;
|
||||
const newDeptId = targetDeptId ? parseInt(targetDeptId) : null;
|
||||
|
||||
const emp = this.employees.find(e => e.id === employeeId);
|
||||
if (emp) emp.department_id = newDeptId;
|
||||
|
||||
this.saveAssignment(employeeId, newDeptId);
|
||||
},
|
||||
|
||||
handleDeptReorder(evt, parentId) {
|
||||
// DOM에서 현재 순서 읽기
|
||||
const container = evt.from;
|
||||
const items = container.querySelectorAll(':scope > .dept-sortable-item');
|
||||
const orders = [];
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const deptId = parseInt(item.dataset.deptId);
|
||||
orders.push({ id: deptId, parent_id: parentId, sort_order: index + 1 });
|
||||
|
||||
// Alpine 데이터도 갱신
|
||||
const dept = this.departments.find(d => d.id === deptId);
|
||||
if (dept) dept.sort_order = index + 1;
|
||||
});
|
||||
|
||||
this.renderTree(); this.renderUnassigned();
|
||||
this.saveDeptOrder(orders);
|
||||
},
|
||||
|
||||
async saveAssignment(employeeId, departmentId) {
|
||||
handleDeptReorder(evt) {
|
||||
const parentId = evt.from.dataset.parentId ? parseInt(evt.from.dataset.parentId) : null;
|
||||
const orders = this.collectSortOrders(evt.from, parentId);
|
||||
orders.forEach(o => {
|
||||
const d = this.departments.find(dd => dd.id === o.id);
|
||||
if (d) d.sort_order = o.sort_order;
|
||||
});
|
||||
this.renderTree();
|
||||
this.saveDeptOrder(orders);
|
||||
},
|
||||
|
||||
collectSortOrders(container, parentId) {
|
||||
const items = container.querySelectorAll(':scope > .org-node-wrap');
|
||||
return [...items].map((el, idx) => ({
|
||||
id: parseInt(el.dataset.deptId), parent_id: parentId, sort_order: idx + 1,
|
||||
}));
|
||||
},
|
||||
|
||||
handleClick(e) {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
if (btn.dataset.action === 'unassign') {
|
||||
e.preventDefault();
|
||||
this.unassignEmployee(parseInt(btn.dataset.empId));
|
||||
}
|
||||
},
|
||||
|
||||
async saveAssignment(empId, deptId) {
|
||||
this.saving = true;
|
||||
try {
|
||||
const url = departmentId
|
||||
? '{{ route("rd.org-chart.assign") }}'
|
||||
: '{{ route("rd.org-chart.unassign") }}';
|
||||
const body = departmentId
|
||||
? { employee_id: employeeId, department_id: departmentId }
|
||||
: { employee_id: employeeId };
|
||||
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('조직도 저장 실패:', e);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
const url = deptId ? '{{ route("rd.org-chart.assign") }}' : '{{ route("rd.org-chart.unassign") }}';
|
||||
const body = deptId ? { employee_id: empId, department_id: deptId } : { employee_id: empId };
|
||||
await fetch(url, { method:'POST', headers:{'Content-Type':'application/json','X-CSRF-TOKEN':document.querySelector('meta[name="csrf-token"]').content,'Accept':'application/json'}, body:JSON.stringify(body) });
|
||||
} catch(e) { console.error('저장 실패:', e); }
|
||||
finally { this.saving = false; }
|
||||
},
|
||||
|
||||
async saveDeptOrder(orders) {
|
||||
this.saving = true;
|
||||
try {
|
||||
await fetch('{{ route("rd.org-chart.reorder-depts") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ orders }),
|
||||
});
|
||||
await fetch('{{ route("rd.org-chart.reorder-depts") }}', { method:'POST', headers:{'Content-Type':'application/json','X-CSRF-TOKEN':document.querySelector('meta[name="csrf-token"]').content,'Accept':'application/json'}, body:JSON.stringify({orders}) });
|
||||
if (typeof showToast === 'function') showToast('부서 순서가 저장되었습니다.', 'success');
|
||||
} catch (e) {
|
||||
console.error('부서 순서 저장 실패:', e);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
} catch(e) { console.error('부서 순서 저장 실패:', e); }
|
||||
finally { this.saving = false; }
|
||||
},
|
||||
|
||||
async unassignEmployee(empId) {
|
||||
const emp = this.employees.find(e => e.id === empId);
|
||||
if (emp) {
|
||||
emp.department_id = null;
|
||||
await this.saveAssignment(empId, null);
|
||||
}
|
||||
if (emp) emp.department_id = null;
|
||||
this.renderTree(); this.renderUnassigned();
|
||||
await this.saveAssignment(empId, null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user