diff --git a/app/Http/Controllers/Api/Admin/MenuController.php b/app/Http/Controllers/Api/Admin/MenuController.php index 52182479..9148857b 100644 --- a/app/Http/Controllers/Api/Admin/MenuController.php +++ b/app/Http/Controllers/Api/Admin/MenuController.php @@ -319,4 +319,35 @@ public function reorder(Request $request): JsonResponse ], 500); } } + + /** + * 메뉴 이동 (계층 구조 변경) + */ + public function move(Request $request): JsonResponse + { + $validated = $request->validate([ + 'menu_id' => 'required|integer', + 'new_parent_id' => 'nullable|integer', + 'sort_order' => 'required|integer|min:1', + ]); + + try { + $result = $this->menuService->moveMenu( + $validated['menu_id'], + $validated['new_parent_id'], + $validated['sort_order'] + ); + + if (! $result['success']) { + return response()->json($result, 400); + } + + return response()->json($result); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => '메뉴 이동에 실패했습니다: '.$e->getMessage(), + ], 500); + } + } } diff --git a/app/Services/MenuService.php b/app/Services/MenuService.php index fc1a063c..6b7cd164 100644 --- a/app/Services/MenuService.php +++ b/app/Services/MenuService.php @@ -340,4 +340,104 @@ public function reorderMenus(array $items): bool return true; }); } + + /** + * 메뉴 이동 (계층 구조 변경) + * - 다른 부모 아래로 이동 가능 + * - 하위 메뉴는 자동으로 따라감 + * - 순환 참조 방지 + */ + public function moveMenu(int $menuId, ?int $newParentId, int $sortOrder): array + { + $menu = Menu::find($menuId); + if (! $menu) { + return ['success' => false, 'message' => '메뉴를 찾을 수 없습니다.']; + } + + // 순환 참조 방지: 자신의 하위 메뉴로 이동 불가 + if ($newParentId !== null && $this->isDescendant($menuId, $newParentId)) { + return ['success' => false, 'message' => '자신의 하위 메뉴로 이동할 수 없습니다.']; + } + + // 자기 자신을 부모로 설정 방지 + if ($newParentId === $menuId) { + return ['success' => false, 'message' => '자기 자신을 부모로 설정할 수 없습니다.']; + } + + return \DB::transaction(function () use ($menu, $newParentId, $sortOrder) { + $oldParentId = $menu->parent_id; + + // 부모 변경 + $menu->parent_id = $newParentId; + $menu->sort_order = $sortOrder; + $menu->updated_by = auth()->id(); + $menu->save(); + + // 같은 부모의 다른 메뉴들 순서 재정렬 + $this->reorderSiblings($newParentId, $menu->id, $sortOrder); + + // 이전 부모의 메뉴들도 순서 재정렬 (빈 자리 채우기) + if ($oldParentId !== $newParentId) { + $this->compactSiblings($oldParentId); + } + + return ['success' => true, 'message' => '메뉴가 이동되었습니다.']; + }); + } + + /** + * 특정 메뉴가 다른 메뉴의 하위인지 확인 (순환 참조 방지) + */ + private function isDescendant(int $ancestorId, int $menuId): bool + { + $menu = Menu::find($menuId); + while ($menu) { + if ($menu->id === $ancestorId) { + return true; + } + $menu = $menu->parent; + } + + return false; + } + + /** + * 같은 부모의 형제 메뉴들 순서 재정렬 + */ + private function reorderSiblings(?int $parentId, int $excludeId, int $insertAt): void + { + $siblings = Menu::where('parent_id', $parentId) + ->where('id', '!=', $excludeId) + ->orderBy('sort_order') + ->get(); + + $order = 1; + foreach ($siblings as $sibling) { + if ($order === $insertAt) { + $order++; // 삽입 위치 건너뛰기 + } + if ($sibling->sort_order !== $order) { + $sibling->update(['sort_order' => $order]); + } + $order++; + } + } + + /** + * 형제 메뉴들 순서 압축 (빈 자리 채우기) + */ + private function compactSiblings(?int $parentId): void + { + $siblings = Menu::where('parent_id', $parentId) + ->orderBy('sort_order') + ->get(); + + $order = 1; + foreach ($siblings as $sibling) { + if ($sibling->sort_order !== $order) { + $sibling->update(['sort_order' => $order]); + } + $order++; + } + } } diff --git a/resources/views/menus/index.blade.php b/resources/views/menus/index.blade.php index 67a976ec..6d7b8567 100644 --- a/resources/views/menus/index.blade.php +++ b/resources/views/menus/index.blade.php @@ -5,7 +5,12 @@ @section('content')
-

메뉴 관리

+
+

메뉴 관리

+

+ 드래그: 같은 레벨 순서 변경 | Shift+드래그: 위 메뉴의 하위로 이동 +

+
+ 새 메뉴 @@ -84,44 +89,109 @@ function initMenuSortable() { tbody.sortableInstance.destroy(); } - // SortableJS 초기화 + // 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 menuId = parseInt(movedItem.dataset.menuId); + const oldParentId = movedItem.dataset.parentId || null; const rows = Array.from(tbody.querySelectorAll('tr.menu-row')); + const newIndex = rows.indexOf(movedItem); - // 같은 부모를 가진 항목들 필터링 - const siblingRows = rows.filter(row => (row.dataset.parentId || '') === parentId); + // 새 부모 결정: Shift 키로 계층 이동 + let newParentId = null; + let sortOrder = 1; - // 순서 데이터 생성 - const items = siblingRows.map((row, index) => ({ - id: parseInt(row.dataset.menuId), - sort_order: index + 1 - })); + if (newIndex > 0) { + const prevRow = rows[newIndex - 1]; + if (prevRow) { + const prevId = parseInt(prevRow.dataset.menuId); + const prevParentId = prevRow.dataset.parentId || null; - // API 호출 - saveMenuOrder(items); + // Shift+드래그: 위 행의 하위로 이동 + if (evt.originalEvent && evt.originalEvent.shiftKey) { + newParentId = prevId; + } else { + // 일반 드래그: 위 행과 같은 부모 + newParentId = prevParentId ? parseInt(prevParentId) : null; + } + } + } + + // 부모 변경 여부 확인 + const oldPid = oldParentId === '' ? null : (oldParentId ? parseInt(oldParentId) : null); + const newPid = newParentId; + const parentChanged = oldPid !== newPid; + + if (parentChanged) { + // 새 부모 하위에서의 순서 계산 + const siblingRows = rows.filter(row => { + const rowParentId = row.dataset.parentId || null; + const rowPid = rowParentId === '' ? null : (rowParentId ? parseInt(rowParentId) : null); + return rowPid === newPid; + }); + sortOrder = siblingRows.indexOf(movedItem) + 1; + if (sortOrder <= 0) sortOrder = siblingRows.length + 1; + + // 계층 변경 API 호출 + moveMenu(menuId, newPid, sortOrder); + } else { + // 같은 레벨 내 순서 변경 + const siblingRows = rows.filter(row => { + const rowParentId = row.dataset.parentId || null; + const rowPid = rowParentId === '' ? null : (rowParentId ? parseInt(rowParentId) : null); + return rowPid === oldPid; + }); + + const items = siblingRows.map((row, index) => ({ + id: parseInt(row.dataset.menuId), + sort_order: index + 1 + })); + + saveMenuOrder(items); + } } }); } - // 메뉴 순서 저장 API 호출 + // 메뉴 이동 API 호출 (계층 변경) + function moveMenu(menuId, newParentId, sortOrder) { + fetch('/api/admin/menus/move', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + menu_id: menuId, + new_parent_id: newParentId, + sort_order: sortOrder + }) + }) + .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'); + }); + } + + // 메뉴 순서 저장 API 호출 (같은 레벨) function saveMenuOrder(items) { fetch('/api/admin/menus/reorder', { method: 'POST', @@ -135,17 +205,15 @@ function saveMenuOrder(items) { .then(response => response.json()) .then(data => { if (data.success) { - // 성공 시 테이블 새로고침하여 정렬 순서 표시 갱신 htmx.trigger('#menu-table', 'filterSubmit'); } else { - alert('메뉴 순서 변경에 실패했습니다: ' + (data.message || '알 수 없는 오류')); - // 실패 시 테이블 새로고침으로 원래 상태 복구 + alert('순서 변경 실패: ' + (data.message || '')); htmx.trigger('#menu-table', 'filterSubmit'); } }) .catch(error => { console.error('Error:', error); - alert('메뉴 순서 변경 중 오류가 발생했습니다.'); + alert('순서 변경 중 오류 발생'); htmx.trigger('#menu-table', 'filterSubmit'); }); } diff --git a/routes/api.php b/routes/api.php index 37c79ee7..cfa685f4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -111,6 +111,7 @@ // 고정 경로는 먼저 정의 Route::get('/tree', [MenuController::class, 'tree'])->name('tree'); Route::post('/reorder', [MenuController::class, 'reorder'])->name('reorder'); + Route::post('/move', [MenuController::class, 'move'])->name('move'); // 동적 경로는 나중에 정의 Route::get('/', [MenuController::class, 'index'])->name('index');