feat: [org-chart] 조직도 최상단 노드 색상 수정 및 부서 드래그 정렬 기능 추가
- 최상단 회사 노드: Tailwind gradient → inline style로 변경 (글씨 안보이는 문제 수정) - 부서 카드 드래그 앤 드롭 정렬: SortableJS handle 기반 - 1단계/2단계 부서 모두 드래그 정렬 가능 - sort_order 변경 즉시 서버 저장 (reorder-depts API) - 부서 헤더에 드래그 아이콘 추가
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 중대재해처벌법 실무 점검
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user