feat: 메뉴 계층 이동 기능 추가
- MenuService.moveMenu() 메서드 추가 (부모 변경 + 하위 메뉴 유지) - POST /api/admin/menus/move API 엔드포인트 추가 - 순환 참조 방지 로직 구현 - Shift+드래그로 위 메뉴의 하위로 이동 가능 - 사용법 안내 UI 추가
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">메뉴 관리</h1>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">메뉴 관리</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
드래그: 같은 레벨 순서 변경 | <span class="font-medium">Shift+드래그</span>: 위 메뉴의 하위로 이동
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{ route('menus.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition">
|
||||
+ 새 메뉴
|
||||
</a>
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user