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