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]);
|
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">
|
<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>
|
<i class="ri-information-line text-lg shrink-0 mt-0.5"></i>
|
||||||
<span>직원 카드를 드래그하여 부서에 배치하거나, 미배치 영역으로 이동할 수 있습니다. 변경은 즉시 저장됩니다.</span>
|
<span>직원 카드를 드래그하여 부서에 배치하거나, 미배치 영역으로 이동할 수 있습니다. 부서 카드도 드래그하여 순서를 변경할 수 있습니다. 변경은 즉시 저장됩니다.</span>
|
||||||
</div>
|
</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-level flex justify-center">
|
||||||
<div class="org-node-wrapper">
|
<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">
|
<div class="flex items-center justify-center gap-2 mb-1">
|
||||||
<i class="ri-building-4-line text-lg"></i>
|
<i class="ri-building-4-line text-lg"></i>
|
||||||
<span class="font-bold text-lg" x-text="companyName"></span>
|
<span class="font-bold text-lg" x-text="companyName"></span>
|
||||||
</div>
|
</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>
|
</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>
|
:style="`width: calc(${(rootDepartments.length - 1)} * 240px)`"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 1단계 부서들 -->
|
<!-- 1단계 부서들 (드래그 정렬 가능) -->
|
||||||
<div class="org-level flex justify-center gap-0">
|
<div id="root-dept-sortable" class="org-level flex justify-center gap-0">
|
||||||
<template x-for="dept in rootDepartments" :key="dept.id">
|
<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="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"
|
<div class="org-node border-2 border-purple-300 rounded-xl shadow-sm text-center select-none hover:shadow-md transition"
|
||||||
style="width: 200px;"
|
style="width: 200px; background: #FFFFFF;">
|
||||||
@click="toggleDept(dept.id)">
|
<div class="dept-drag-handle cursor-grab active:cursor-grabbing px-4 py-2.5 rounded-t-xl border-b"
|
||||||
<div class="bg-purple-50 px-4 py-2.5 rounded-t-xl border-b border-purple-100">
|
style="background: #F5F3FF; border-color: #E9D5FF;">
|
||||||
<div class="flex items-center justify-center gap-1.5">
|
<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>
|
<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>
|
<span class="font-bold text-gray-800 text-sm" x-text="dept.name"></span>
|
||||||
</div>
|
</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>
|
x-text="`${getDeptTotalCount(dept.id)}명`"></div>
|
||||||
</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)">
|
<template x-if="getChildDepartments(dept.id).length > 0 && expandedDepts.includes(dept.id)">
|
||||||
<div class="flex flex-col items-center">
|
<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>
|
:style="`width: calc(${(getChildDepartments(dept.id).length - 1)} * 200px)`"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 2단계 부서들 -->
|
<!-- 2단계 부서들 (드래그 정렬 가능) -->
|
||||||
<div class="flex justify-center gap-0">
|
<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">
|
<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>
|
<div class="w-px bg-gray-300" style="height: 24px;" x-show="getChildDepartments(dept.id).length > 1"></div>
|
||||||
|
|
||||||
<!-- 2단계 부서 카드 -->
|
<!-- 2단계 부서 카드 -->
|
||||||
<div class="org-node bg-white border border-indigo-200 rounded-lg shadow-sm text-center hover:shadow-md transition"
|
<div class="org-node bg-white border border-indigo-200 rounded-lg shadow-sm text-center hover:shadow-md transition"
|
||||||
style="width: 180px;">
|
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">
|
<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>
|
<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>
|
<span class="font-semibold text-gray-700 text-xs" x-text="child.name"></span>
|
||||||
</div>
|
</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-ghost { opacity: 0.4; }
|
||||||
.employee-card.sortable-drag { box-shadow: 0 8px 25px rgba(0,0,0,0.15); z-index: 999; }
|
.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-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>
|
</style>
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@@ -265,6 +280,7 @@ function orgChart() {
|
|||||||
searchUnassigned: '',
|
searchUnassigned: '',
|
||||||
saving: false,
|
saving: false,
|
||||||
sortables: [],
|
sortables: [],
|
||||||
|
deptSortables: [],
|
||||||
expandedDepts: [],
|
expandedDepts: [],
|
||||||
|
|
||||||
get totalEmployees() { return this.employees.length; },
|
get totalEmployees() { return this.employees.length; },
|
||||||
@@ -277,11 +293,15 @@ function orgChart() {
|
|||||||
return this.unassignedEmployees.filter(e => e.display_name?.toLowerCase().includes(s));
|
return this.unassignedEmployees.filter(e => e.display_name?.toLowerCase().includes(s));
|
||||||
},
|
},
|
||||||
get rootDepartments() {
|
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) {
|
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) {
|
getDeptEmployees(deptId) {
|
||||||
@@ -304,8 +324,10 @@ function orgChart() {
|
|||||||
} else {
|
} else {
|
||||||
this.expandedDepts.push(deptId);
|
this.expandedDepts.push(deptId);
|
||||||
}
|
}
|
||||||
// 하위 부서 드롭존 재초기화
|
this.$nextTick(() => {
|
||||||
this.$nextTick(() => this.initDropZones());
|
this.initDropZones();
|
||||||
|
this.initDeptSortables();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
getAvatarColor(name) {
|
getAvatarColor(name) {
|
||||||
@@ -325,6 +347,7 @@ function orgChart() {
|
|||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.initSortable();
|
this.initSortable();
|
||||||
this.initDropZones();
|
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) {
|
handleDrop(evt) {
|
||||||
const employeeId = parseInt(evt.item.dataset.employeeId);
|
const employeeId = parseInt(evt.item.dataset.employeeId);
|
||||||
const targetDeptId = evt.to.dataset.departmentId;
|
const targetDeptId = evt.to.dataset.departmentId;
|
||||||
@@ -367,6 +421,24 @@ function orgChart() {
|
|||||||
this.saveAssignment(employeeId, 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.saveDeptOrder(orders);
|
||||||
|
},
|
||||||
|
|
||||||
async saveAssignment(employeeId, departmentId) {
|
async saveAssignment(employeeId, departmentId) {
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
try {
|
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) {
|
async unassignEmployee(empId) {
|
||||||
const emp = this.employees.find(e => e.id === empId);
|
const emp = this.employees.find(e => e.id === empId);
|
||||||
if (emp) {
|
if (emp) {
|
||||||
|
|||||||
@@ -392,6 +392,7 @@
|
|||||||
Route::post('/org-chart/assign', [RdController::class, 'orgChartAssign'])->name('org-chart.assign');
|
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/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', [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');
|
Route::get('/safety-audit', [RdController::class, 'safetyAudit'])->name('safety-audit');
|
||||||
|
|||||||
Reference in New Issue
Block a user