feat: [rd] 조직도 클래식 하향식 트리 형태로 개편

- 회사(대표이사) → 1단계 부서 → 2단계 → 3단계 하향식 트리 구조
- 부서 간 수직/수평 연결선으로 계층 시각화
- 미배치 직원 패널을 상단 접이식으로 변경
- 부서 카드 클릭 시 하위 부서 펼침/접기
- drag & drop 배치 기능 유지
This commit is contained in:
김보곤
2026-03-06 19:42:21 +09:00
parent 399813a16f
commit df72d241fb
2 changed files with 222 additions and 155 deletions

View File

@@ -5,6 +5,7 @@
use App\Models\HR\Employee;
use App\Models\Rd\AiQuotation;
use App\Models\Tenants\Department;
use App\Models\Tenants\Tenant;
use App\Services\Rd\AiQuotationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -68,7 +69,12 @@ public function orgChart(Request $request): View|\Illuminate\Http\Response
];
})->values();
return view('rd.org-chart', compact('departments', 'employees'));
// 회사 정보 (조직도 최상단)
$tenant = Tenant::find($tenantId);
$companyName = $tenant->company_name ?? 'SAM';
$ceoName = $tenant->ceo_name ?? '';
return view('rd.org-chart', compact('departments', 'employees', 'companyName', 'ceoName'));
}
/**

View File

@@ -24,166 +24,217 @@
<span>직원 카드를 드래그하여 부서에 배치하거나, 미배치 영역으로 이동할 있습니다. 변경은 즉시 저장됩니다.</span>
</div>
<div class="flex gap-6" style="min-height: calc(100vh - 240px);">
<!-- 좌측: 미배치 직원 패널 -->
<div class="shrink-0" style="width: 280px;">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 sticky" style="top: 80px;">
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50 rounded-t-lg">
<h2 class="text-sm font-semibold text-gray-700 flex items-center gap-2">
<i class="ri-user-unfollow-line text-orange-500"></i>
미배치 직원
<span class="ml-auto bg-orange-100 text-orange-600 text-xs font-bold px-2 py-0.5 rounded-full" x-text="unassignedCount"></span>
</h2>
</div>
<!-- 검색 -->
<div class="px-3 py-2 border-b border-gray-100">
<!-- 미배치 직원 패널 (상단 접이식) -->
<div x-data="{ panelOpen: unassignedCount > 0 }" 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>
<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="이름 검색..."
class="w-full px-3 py-1.5 border border-gray-200 rounded text-sm focus:outline-none focus:ring-1 focus:ring-purple-400">
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 id="unassigned-zone" class="p-2 overflow-y-auto" style="max-height: calc(100vh - 380px); min-height: 100px;"
data-department-id="">
</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 mb-2 cursor-grab hover:shadow-md hover:border-purple-300 transition flex items-center gap-2"
<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 truncate" x-text="emp.display_name"></p>
<p class="text-xs text-gray-400 truncate" x-text="emp.position_label || ''"></p>
<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="text-center text-gray-400 text-xs py-6">
<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>
</div>
</div>
<!-- 우측: 조직도 트리 -->
<div class="flex-1 min-w-0">
<!-- 최상위 부서 카드들 -->
<template x-for="dept in rootDepartments" :key="dept.id">
<div class="mb-4">
<div x-data="{ open: true }">
<!-- 부서 헤더 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div class="flex items-center gap-2 px-4 py-3 bg-gradient-to-r from-purple-50 to-indigo-50 border-b border-gray-200 cursor-pointer select-none"
@click="open = !open">
<i class="ri-arrow-down-s-line transition-transform text-gray-400" :class="!open && '-rotate-90'"></i>
<i class="ri-building-2-line text-purple-600"></i>
<span class="font-semibold text-gray-800" x-text="dept.name"></span>
<span class="text-xs text-gray-400 ml-1" x-text="dept.code ? `(${dept.code})` : ''"></span>
<span class="ml-auto bg-purple-100 text-purple-700 text-xs font-bold px-2 py-0.5 rounded-full"
x-text="getDeptEmployeeCount(dept.id) + '명'"></span>
</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 bg-gradient-to-br from-purple-600 to-indigo-700 text-white rounded-xl px-6 py-4 shadow-lg text-center">
<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-purple-200 text-sm" x-text="ceoName ? `대표이사 ${ceoName}` : ''"></p>
</div>
</div>
</div>
<!-- 부서 직원 드롭존 -->
<div x-show="open" x-collapse>
<div class="dept-drop-zone p-3 min-h-[60px]" :data-department-id="dept.id">
<template x-for="emp in getDeptEmployees(dept.id)" :key="emp.id">
<div class="employee-card inline-flex items-center gap-2 bg-white border border-gray-200 rounded-lg px-3 py-2 mr-2 mb-2 cursor-grab hover:shadow-md hover:border-purple-300 transition"
: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>
<button class="ml-1 text-gray-300 hover:text-red-500 transition shrink-0" title="미배치로 이동"
@click.stop="unassignEmployee(emp.id)">
<i class="ri-close-line"></i>
</button>
<!-- 수직 연결선 (회사 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 class="org-level flex justify-center gap-0">
<template x-for="dept in rootDepartments" :key="dept.id">
<div class="flex flex-col items-center" style="min-width: 240px;">
<!-- 수직선 (수평선 부서 카드) -->
<div class="w-px bg-gray-300" style="height: 32px;" x-show="rootDepartments.length > 1"></div>
<!-- 부서 카드 -->
<div class="org-node bg-white border-2 border-purple-300 rounded-xl shadow-sm text-center cursor-pointer select-none hover:shadow-md transition"
style="width: 200px;"
@click="toggleDept(dept.id)">
<div class="bg-purple-50 px-4 py-2.5 rounded-t-xl border-b border-purple-100">
<div class="flex items-center justify-center gap-1.5">
<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>
</template>
<div x-show="getDeptEmployees(dept.id).length === 0" class="text-center text-gray-300 text-xs py-4">
직원을 여기로 드래그하세요
<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>
<!-- 하위 부서 -->
<template x-for="child in getChildDepartments(dept.id)" :key="child.id">
<div class="border-t border-gray-100">
<div x-data="{ childOpen: true }">
<div class="flex items-center gap-2 px-6 py-2.5 bg-gray-50 border-b border-gray-100 cursor-pointer select-none"
@click="childOpen = !childOpen">
<i class="ri-arrow-down-s-line transition-transform text-gray-400 text-sm" :class="!childOpen && '-rotate-90'"></i>
<i class="ri-git-branch-line text-indigo-400 text-sm"></i>
<span class="text-sm font-medium text-gray-700" x-text="child.name"></span>
<span class="text-xs text-gray-400" x-text="child.code ? `(${child.code})` : ''"></span>
<span class="ml-auto bg-indigo-50 text-indigo-600 text-xs font-bold px-2 py-0.5 rounded-full"
x-text="getDeptEmployeeCount(child.id) + '명'"></span>
</div>
<div x-show="childOpen" x-collapse>
<div class="dept-drop-zone px-6 py-3 min-h-[50px]" :data-department-id="child.id">
<template x-for="emp in getDeptEmployees(child.id)" :key="emp.id">
<div class="employee-card inline-flex items-center gap-2 bg-white border border-gray-200 rounded-lg px-3 py-2 mr-2 mb-2 cursor-grab hover:shadow-md hover:border-purple-300 transition"
: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>
<button class="ml-1 text-gray-300 hover:text-red-500 transition shrink-0" title="미배치로 이동"
@click.stop="unassignEmployee(emp.id)">
<i class="ri-close-line"></i>
</button>
</div>
</template>
<div x-show="getDeptEmployees(child.id).length === 0" class="text-center text-gray-300 text-xs py-3">
직원을 여기로 드래그하세요
</div>
</div>
<!-- 하위 부서 연결 -->
<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>
<!-- 3단계 하위 부서 -->
<template x-for="grandchild in getChildDepartments(child.id)" :key="grandchild.id">
<div class="border-t border-gray-50">
<div class="flex items-center gap-2 px-10 py-2 bg-gray-50/50 border-b border-gray-50">
<i class="ri-corner-down-right-line text-gray-300 text-sm"></i>
<span class="text-sm text-gray-600" x-text="grandchild.name"></span>
<span class="ml-auto bg-gray-100 text-gray-500 text-xs font-bold px-2 py-0.5 rounded-full"
x-text="getDeptEmployeeCount(grandchild.id) + '명'"></span>
<!-- 수평선 (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="flex justify-center gap-0">
<template x-for="child in getChildDepartments(dept.id)" :key="child.id">
<div class="flex flex-col items-center" style="min-width: 200px;">
<!-- 수직선 -->
<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="bg-indigo-50 px-3 py-2 rounded-t-lg border-b border-indigo-100">
<div class="flex items-center justify-center gap-1">
<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-10 py-2 min-h-[40px]" :data-department-id="grandchild.id">
<template x-for="emp in getDeptEmployees(grandchild.id)" :key="emp.id">
<div class="employee-card inline-flex items-center gap-2 bg-white border border-gray-200 rounded-lg px-3 py-1.5 mr-2 mb-2 cursor-grab hover:shadow-md hover:border-purple-300 transition"
<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-6 h-6 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0"
<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-sm text-gray-800 whitespace-nowrap" x-text="emp.display_name"></span>
<button class="text-gray-300 hover:text-red-500 transition shrink-0" title="미배치로 이동"
<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 text-sm"></i>
<i class="ri-close-line" style="font-size: 10px;"></i>
</button>
</div>
</template>
<div x-show="getDeptEmployees(grandchild.id).length === 0" class="text-center text-gray-300 text-xs py-2">
드래그하여 배치
<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>
</template>
</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>
</div>
</template>
</div>
</div>
</template>
<div x-show="rootDepartments.length === 0" class="bg-white rounded-lg shadow-sm p-12 text-center text-gray-400">
<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>
<p class="text-sm mt-1">먼저 부서를 등록해주세요.</p>
<p>등록된 부서가 없습니다. 먼저 부서를 등록해주세요.</p>
</div>
</div>
</div>
@@ -194,6 +245,13 @@ class="fixed bottom-6 right-6 bg-gray-800 text-white px-4 py-2 rounded-lg shadow
<i class="ri-loader-4-line animate-spin"></i> 저장 ...
</div>
</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; }
</style>
@endsection
@push('scripts')
@@ -202,9 +260,12 @@ function orgChart() {
return {
departments: @json($departments),
employees: @json($employees),
companyName: @json($companyName),
ceoName: @json($ceoName),
searchUnassigned: '',
saving: false,
sortables: [],
expandedDepts: [],
get totalEmployees() { return this.employees.length; },
get assignedCount() { return this.employees.filter(e => e.department_id).length; },
@@ -227,16 +288,26 @@ function orgChart() {
return this.employees.filter(e => e.department_id === deptId);
},
getDeptEmployeeCount(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.getDeptEmployeeCount(child.id);
count += this.getDeptTotalCount(child.id);
}
return count;
},
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());
},
getAvatarColor(name) {
if (!name) return '#9CA3AF';
const colors = ['#6366F1','#8B5CF6','#EC4899','#F59E0B','#10B981','#3B82F6','#EF4444','#14B8A6','#F97316','#6D28D9'];
@@ -246,48 +317,43 @@ function orgChart() {
},
init() {
this.$nextTick(() => this.initSortable());
// 하위 부서가 있는 1단계 부서는 기본 펼침
this.expandedDepts = this.rootDepartments
.filter(d => this.getChildDepartments(d.id).length > 0)
.map(d => d.id);
this.$nextTick(() => {
this.initSortable();
this.initDropZones();
});
},
initSortable() {
// 미배치 영역
const unassignedEl = document.getElementById('unassigned-zone');
if (unassignedEl) {
if (unassignedEl && !unassignedEl._sortableInitialized) {
unassignedEl._sortableInitialized = true;
this.sortables.push(new Sortable(unassignedEl, {
group: 'org-chart',
animation: 150,
ghostClass: 'opacity-40',
dragClass: 'shadow-lg',
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
onAdd: (evt) => this.handleDrop(evt),
}));
}
// 부서 드롭존 - MutationObserver로 동적 생성 대응
this.observeAndInitDropZones();
},
observeAndInitDropZones() {
const initZones = () => {
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: 'opacity-40',
dragClass: 'shadow-lg',
onAdd: (evt) => this.handleDrop(evt),
}));
});
};
initZones();
// Alpine이 DOM을 업데이트할 때 다시 초기화
const observer = new MutationObserver(() => {
setTimeout(initZones, 100);
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),
}));
});
observer.observe(document.querySelector('[x-data]'), { childList: true, subtree: true });
},
handleDrop(evt) {
@@ -295,13 +361,9 @@ function orgChart() {
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;
}
if (emp) emp.department_id = newDeptId;
// 서버에 저장
this.saveAssignment(employeeId, newDeptId);
},
@@ -311,7 +373,6 @@ function orgChart() {
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 };