feat: 메뉴 드래그 앤 드롭 정렬 기능 추가

- MenuService.reorderMenus() 메서드 추가
- MenuController.reorder() API 엔드포인트 추가
- POST /api/admin/menus/reorder 라우트 추가
- SortableJS 기반 드래그 앤 드롭 UI 구현
- 같은 부모 메뉴 내에서만 순서 변경 가능 (계층 구조 유지)
This commit is contained in:
2025-12-01 15:24:49 +09:00
parent c8ddbfd130
commit 302b9d73aa
5 changed files with 150 additions and 13 deletions

View File

@@ -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);
}
}
}

View File

@@ -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;
});
}
}

View File

@@ -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}" 메뉴를 삭제하시겠습니까?`)) {

View File

@@ -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>

View File

@@ -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');