feat: 메뉴 드래그 앤 드롭 정렬 기능 추가
- MenuService.reorderMenus() 메서드 추가 - MenuController.reorder() API 엔드포인트 추가 - POST /api/admin/menus/reorder 라우트 추가 - SortableJS 기반 드래그 앤 드롭 UI 구현 - 같은 부모 메뉴 내에서만 순서 변경 가능 (계층 구조 유지)
This commit is contained in:
@@ -293,4 +293,30 @@ public function toggleHidden(Request $request, int $id): JsonResponse
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 순서 변경 (드래그앤드롭)
|
||||
*/
|
||||
public function reorder(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'items' => 'required|array',
|
||||
'items.*.id' => 'required|integer',
|
||||
'items.*.sort_order' => 'required|integer',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->menuService->reorderMenus($validated['items']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '메뉴 순서가 변경되었습니다.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '메뉴 순서 변경에 실패했습니다: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,4 +321,23 @@ public function toggleHidden(int $id): bool
|
||||
|
||||
return $menu->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 순서 변경 (드래그앤드롭)
|
||||
* 같은 parent_id 내에서만 순서 변경
|
||||
*/
|
||||
public function reorderMenus(array $items): bool
|
||||
{
|
||||
return \DB::transaction(function () use ($items) {
|
||||
foreach ($items as $item) {
|
||||
Menu::where('id', $item['id'])
|
||||
->update([
|
||||
'sort_order' => $item['sort_order'],
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
|
||||
<script>
|
||||
// 폼 제출 시 HTMX 이벤트 트리거
|
||||
document.getElementById('filterForm').addEventListener('submit', function(e) {
|
||||
@@ -61,16 +62,94 @@ class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
htmx.trigger('#menu-table', 'filterSubmit');
|
||||
});
|
||||
|
||||
// HTMX 응답 처리
|
||||
// HTMX 응답 처리 + SortableJS 초기화
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.id === 'menu-table') {
|
||||
const response = JSON.parse(event.detail.xhr.response);
|
||||
if (response.html) {
|
||||
event.detail.target.innerHTML = response.html;
|
||||
// 테이블 로드 후 SortableJS 초기화
|
||||
initMenuSortable();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// SortableJS 초기화 함수
|
||||
function initMenuSortable() {
|
||||
const tbody = document.getElementById('menu-sortable');
|
||||
if (!tbody) return;
|
||||
|
||||
// 기존 인스턴스 제거
|
||||
if (tbody.sortableInstance) {
|
||||
tbody.sortableInstance.destroy();
|
||||
}
|
||||
|
||||
// SortableJS 초기화
|
||||
tbody.sortableInstance = new Sortable(tbody, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
ghostClass: 'bg-blue-50',
|
||||
chosenClass: 'bg-blue-100',
|
||||
dragClass: 'shadow-lg',
|
||||
// 같은 parent_id 그룹 내에서만 정렬 (계층 구조 유지)
|
||||
onMove: function(evt) {
|
||||
const draggedParentId = evt.dragged.dataset.parentId || '';
|
||||
const relatedParentId = evt.related.dataset.parentId || '';
|
||||
// 같은 부모를 가진 항목끼리만 이동 가능
|
||||
return draggedParentId === relatedParentId;
|
||||
},
|
||||
onEnd: function(evt) {
|
||||
if (evt.oldIndex === evt.newIndex) return;
|
||||
|
||||
// 같은 parent_id를 가진 항목들만 추출하여 순서 업데이트
|
||||
const movedItem = evt.item;
|
||||
const parentId = movedItem.dataset.parentId || '';
|
||||
const rows = Array.from(tbody.querySelectorAll('tr.menu-row'));
|
||||
|
||||
// 같은 부모를 가진 항목들 필터링
|
||||
const siblingRows = rows.filter(row => (row.dataset.parentId || '') === parentId);
|
||||
|
||||
// 순서 데이터 생성
|
||||
const items = siblingRows.map((row, index) => ({
|
||||
id: parseInt(row.dataset.menuId),
|
||||
sort_order: index + 1
|
||||
}));
|
||||
|
||||
// API 호출
|
||||
saveMenuOrder(items);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 메뉴 순서 저장 API 호출
|
||||
function saveMenuOrder(items) {
|
||||
fetch('/api/admin/menus/reorder', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ items: items })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// 성공 시 테이블 새로고침하여 정렬 순서 표시 갱신
|
||||
htmx.trigger('#menu-table', 'filterSubmit');
|
||||
} else {
|
||||
alert('메뉴 순서 변경에 실패했습니다: ' + (data.message || '알 수 없는 오류'));
|
||||
// 실패 시 테이블 새로고침으로 원래 상태 복구
|
||||
htmx.trigger('#menu-table', 'filterSubmit');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('메뉴 순서 변경 중 오류가 발생했습니다.');
|
||||
htmx.trigger('#menu-table', 'filterSubmit');
|
||||
});
|
||||
}
|
||||
|
||||
// 삭제 확인
|
||||
window.confirmDelete = function(id, name) {
|
||||
if (confirm(`"${name}" 메뉴를 삭제하시겠습니까?`)) {
|
||||
|
||||
@@ -2,22 +2,34 @@
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">ID</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">메뉴명</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">URL</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">정렬</th>
|
||||
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">활성</th>
|
||||
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">숨김</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">작업</th>
|
||||
<th class="px-2 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider w-10"></th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">ID</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">메뉴명</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">URL</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider w-16">정렬</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider w-16">활성</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider w-16">숨김</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tbody id="menu-sortable" class="bg-white divide-y divide-gray-200">
|
||||
@forelse($menus as $menu)
|
||||
<tr class="hover:bg-gray-50 menu-row"
|
||||
<tr class="hover:bg-gray-50 menu-row {{ $menu->deleted_at ? 'bg-red-50' : '' }}"
|
||||
data-menu-id="{{ $menu->id }}"
|
||||
data-parent-id="{{ $menu->parent_id ?? '' }}"
|
||||
data-sort-order="{{ $menu->sort_order ?? 0 }}"
|
||||
data-depth="{{ $menu->depth ?? 0 }}">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{-- 드래그 핸들 --}}
|
||||
<td class="px-2 py-4 whitespace-nowrap text-center">
|
||||
@if(!$menu->deleted_at)
|
||||
<span class="drag-handle cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16" />
|
||||
</svg>
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ $menu->id }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@@ -119,7 +131,7 @@ class="text-red-600 hover:text-red-900">
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-4 text-center text-gray-500">
|
||||
<td colspan="8" class="px-6 py-4 text-center text-gray-500">
|
||||
메뉴가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\Admin\BoardController;
|
||||
use App\Http\Controllers\Api\Admin\DailyLogController;
|
||||
use App\Http\Controllers\Api\Admin\DepartmentController;
|
||||
use App\Http\Controllers\Api\Admin\MenuController;
|
||||
use App\Http\Controllers\Api\Admin\PermissionController;
|
||||
use App\Http\Controllers\Api\Admin\DailyLogController;
|
||||
use App\Http\Controllers\Api\Admin\ProjectManagement\ImportController as PmImportController;
|
||||
use App\Http\Controllers\Api\Admin\ProjectManagement\IssueController as PmIssueController;
|
||||
use App\Http\Controllers\Api\Admin\ProjectManagement\ProjectController as PmProjectController;
|
||||
@@ -110,6 +110,7 @@
|
||||
Route::prefix('menus')->name('menus.')->group(function () {
|
||||
// 고정 경로는 먼저 정의
|
||||
Route::get('/tree', [MenuController::class, 'tree'])->name('tree');
|
||||
Route::post('/reorder', [MenuController::class, 'reorder'])->name('reorder');
|
||||
|
||||
// 동적 경로는 나중에 정의
|
||||
Route::get('/', [MenuController::class, 'index'])->name('index');
|
||||
|
||||
Reference in New Issue
Block a user