feat: [org-chart] 조직도 최상단 노드 색상 수정 및 부서 드래그 정렬 기능 추가

- 최상단 회사 노드: Tailwind gradient → inline style로 변경 (글씨 안보이는 문제 수정)
- 부서 카드 드래그 앤 드롭 정렬: SortableJS handle 기반
- 1단계/2단계 부서 모두 드래그 정렬 가능
- sort_order 변경 즉시 서버 저장 (reorder-depts API)
- 부서 헤더에 드래그 아이콘 추가
This commit is contained in:
김보곤
2026-03-06 19:50:36 +09:00
parent 11d5fb57a7
commit 8111910d6c
3 changed files with 137 additions and 18 deletions

View File

@@ -151,6 +151,32 @@ public function orgChartReorder(Request $request): JsonResponse
return response()->json(['success' => true]);
}
/**
* 조직도 - 부서 순서 변경 (드래그 앤 드롭)
*/
public function orgChartReorderDepts(Request $request): JsonResponse
{
$request->validate([
'orders' => 'required|array',
'orders.*.id' => 'required|integer',
'orders.*.parent_id' => 'nullable|integer',
'orders.*.sort_order' => 'required|integer',
]);
$tenantId = session('selected_tenant_id');
foreach ($request->orders as $order) {
Department::where('tenant_id', $tenantId)
->where('id', $order['id'])
->update([
'parent_id' => $order['parent_id'],
'sort_order' => $order['sort_order'],
]);
}
return response()->json(['success' => true]);
}
/**
* 중대재해처벌법 실무 점검
*/

View File

@@ -21,7 +21,7 @@
<!-- 안내 -->
<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>
<!-- 미배치 직원 패널 (상단 접이식) -->
@@ -68,12 +68,13 @@ class="px-3 py-1 border border-gray-200 rounded text-xs focus:outline-none focus
<!-- 최상단: 회사 -->
<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="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-purple-200 text-sm" x-text="ceoName ? `대표이사 ${ceoName}` : ''"></p>
<p class="text-sm" style="color: #E0D4FC;" x-text="ceoName ? `대표이사 ${ceoName}` : ''"></p>
</div>
</div>
</div>
@@ -92,19 +93,20 @@ class="px-3 py-1 border border-gray-200 rounded text-xs focus:outline-none focus
:style="`width: calc(${(rootDepartments.length - 1)} * 240px)`"></div>
</div>
<!-- 1단계 부서들 -->
<div class="org-level flex justify-center gap-0">
<!-- 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="flex flex-col items-center" style="min-width: 240px;">
<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 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="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>
@@ -132,6 +134,14 @@ class="px-3 py-1 border border-gray-200 rounded text-xs focus:outline-none focus
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">
@@ -144,18 +154,20 @@ class="px-3 py-1 border border-gray-200 rounded text-xs focus:outline-none focus
:style="`width: calc(${(getChildDepartments(dept.id).length - 1)} * 200px)`"></div>
</div>
<!-- 2단계 부서들 -->
<div class="flex justify-center gap-0">
<!-- 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="flex flex-col items-center" style="min-width: 200px;">
<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="bg-indigo-50 px-3 py-2 rounded-t-lg border-b border-indigo-100">
<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>
@@ -251,6 +263,9 @@ class="fixed bottom-6 right-6 bg-gray-800 text-white px-4 py-2 rounded-lg shadow
.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; }
</style>
@endsection
@@ -265,6 +280,7 @@ function orgChart() {
searchUnassigned: '',
saving: false,
sortables: [],
deptSortables: [],
expandedDepts: [],
get totalEmployees() { return this.employees.length; },
@@ -277,11 +293,15 @@ function orgChart() {
return this.unassignedEmployees.filter(e => e.display_name?.toLowerCase().includes(s));
},
get rootDepartments() {
return this.departments.filter(d => !d.parent_id);
return this.departments
.filter(d => !d.parent_id)
.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);
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) {
@@ -304,8 +324,10 @@ function orgChart() {
} else {
this.expandedDepts.push(deptId);
}
// 하위 부서 드롭존 재초기화
this.$nextTick(() => this.initDropZones());
this.$nextTick(() => {
this.initDropZones();
this.initDeptSortables();
});
},
getAvatarColor(name) {
@@ -325,6 +347,7 @@ function orgChart() {
this.$nextTick(() => {
this.initSortable();
this.initDropZones();
this.initDeptSortables();
});
},
@@ -356,6 +379,37 @@ function orgChart() {
});
},
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),
}));
}
// 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),
}));
});
},
handleDrop(evt) {
const employeeId = parseInt(evt.item.dataset.employeeId);
const targetDeptId = evt.to.dataset.departmentId;
@@ -367,6 +421,24 @@ function orgChart() {
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.saveDeptOrder(orders);
},
async saveAssignment(employeeId, departmentId) {
this.saving = true;
try {
@@ -393,6 +465,26 @@ function orgChart() {
}
},
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 }),
});
if (typeof showToast === 'function') showToast('부서 순서가 저장되었습니다.', 'success');
} catch (e) {
console.error('부서 순서 저장 실패:', e);
} finally {
this.saving = false;
}
},
async unassignEmployee(empId) {
const emp = this.employees.find(e => e.id === empId);
if (emp) {

View File

@@ -392,6 +392,7 @@
Route::post('/org-chart/assign', [RdController::class, 'orgChartAssign'])->name('org-chart.assign');
Route::post('/org-chart/unassign', [RdController::class, 'orgChartUnassign'])->name('org-chart.unassign');
Route::post('/org-chart/reorder', [RdController::class, 'orgChartReorder'])->name('org-chart.reorder');
Route::post('/org-chart/reorder-depts', [RdController::class, 'orgChartReorderDepts'])->name('org-chart.reorder-depts');
// 중대재해처벌법 실무 점검
Route::get('/safety-audit', [RdController::class, 'safetyAudit'])->name('safety-audit');