feat: 메뉴 계층 이동 기능 추가

- MenuService.moveMenu() 메서드 추가 (부모 변경 + 하위 메뉴 유지)
- POST /api/admin/menus/move API 엔드포인트 추가
- 순환 참조 방지 로직 구현
- Shift+드래그로 위 메뉴의 하위로 이동 가능
- 사용법 안내 UI 추가
This commit is contained in:
2025-12-01 15:35:49 +09:00
parent 302b9d73aa
commit d8bae36efd
4 changed files with 227 additions and 27 deletions

View File

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

View File

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