diff --git a/app/Http/Controllers/Api/Admin/GlobalMenuController.php b/app/Http/Controllers/Api/Admin/GlobalMenuController.php index e774ad7c..538a2eef 100644 --- a/app/Http/Controllers/Api/Admin/GlobalMenuController.php +++ b/app/Http/Controllers/Api/Admin/GlobalMenuController.php @@ -277,6 +277,37 @@ public function toggleHidden(Request $request, int $id): JsonResponse } } + /** + * 글로벌 메뉴 계층 이동 (인덴트/아웃덴트) + */ + 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->moveGlobalMenu( + $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/Http/Controllers/Api/Admin/RoleController.php b/app/Http/Controllers/Api/Admin/RoleController.php index f968895e..54769600 100644 --- a/app/Http/Controllers/Api/Admin/RoleController.php +++ b/app/Http/Controllers/Api/Admin/RoleController.php @@ -18,7 +18,7 @@ public function __construct( /** * 역할 목록 조회 */ - public function index(Request $request): JsonResponse + public function index(Request $request): JsonResponse|\Illuminate\Contracts\View\View { $roles = $this->roleService->getRoles( $request->all(), @@ -27,11 +27,7 @@ public function index(Request $request): JsonResponse // HTMX 요청 시 HTML 반환 if ($request->header('HX-Request')) { - $html = view('roles.partials.table', compact('roles'))->render(); - - return response()->json([ - 'html' => $html, - ]); + return view('roles.partials.table', compact('roles')); } // 일반 요청 시 JSON 반환 diff --git a/app/Services/MenuService.php b/app/Services/MenuService.php index f2822b91..c98db6d3 100644 --- a/app/Services/MenuService.php +++ b/app/Services/MenuService.php @@ -836,6 +836,105 @@ public function reorderGlobalMenus(array $items): bool }); } + /** + * 글로벌 메뉴 이동 (계층 구조 변경 - 인덴트/아웃덴트) + * - 다른 부모 아래로 이동 가능 + * - 하위 메뉴는 자동으로 따라감 + * - 순환 참조 방지 + */ + public function moveGlobalMenu(int $menuId, ?int $newParentId, int $sortOrder): array + { + $menu = GlobalMenu::find($menuId); + if (! $menu) { + return ['success' => false, 'message' => '글로벌 메뉴를 찾을 수 없습니다.']; + } + + // 순환 참조 방지: 자신의 하위 메뉴로 이동 불가 + if ($newParentId !== null && $this->isGlobalDescendant($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->save(); + + // 같은 부모의 다른 메뉴들 순서 재정렬 + $this->reorderGlobalSiblings($newParentId, $menu->id, $sortOrder); + + // 이전 부모의 메뉴들도 순서 재정렬 (빈 자리 채우기) + if ($oldParentId !== $newParentId) { + $this->compactGlobalSiblings($oldParentId); + } + + return ['success' => true, 'message' => '글로벌 메뉴가 이동되었습니다.']; + }); + } + + /** + * 특정 글로벌 메뉴가 다른 메뉴의 하위인지 확인 (순환 참조 방지) + */ + private function isGlobalDescendant(int $ancestorId, int $menuId): bool + { + $menu = GlobalMenu::find($menuId); + while ($menu) { + if ($menu->id === $ancestorId) { + return true; + } + $menu = $menu->parent; + } + + return false; + } + + /** + * 같은 부모의 글로벌 형제 메뉴들 순서 재정렬 + */ + private function reorderGlobalSiblings(?int $parentId, int $excludeId, int $insertAt): void + { + $siblings = GlobalMenu::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 compactGlobalSiblings(?int $parentId): void + { + $siblings = GlobalMenu::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/app/Services/RoleService.php b/app/Services/RoleService.php index ca523d8b..9e5dff85 100644 --- a/app/Services/RoleService.php +++ b/app/Services/RoleService.php @@ -183,8 +183,7 @@ public function deleteRole(int $id): bool // 권한 연결 해제 $role->permissions()->detach(); - // 사용자 연결 해제 - $role->users()->detach(); + // 사용자 연결은 FK CASCADE가 자동 처리 (model_has_roles.role_id → ON DELETE CASCADE) return $role->delete(); } diff --git a/config/auth.php b/config/auth.php index 7d1eb0de..91514f09 100644 --- a/config/auth.php +++ b/config/auth.php @@ -40,6 +40,11 @@ 'driver' => 'session', 'provider' => 'users', ], + + 'api' => [ + 'driver' => 'sanctum', + 'provider' => 'users', + ], ], /* diff --git a/resources/views/menus/global-index.blade.php b/resources/views/menus/global-index.blade.php index 179c1c10..b10014b5 100644 --- a/resources/views/menus/global-index.blade.php +++ b/resources/views/menus/global-index.blade.php @@ -83,6 +83,40 @@ class="bg-white rounded-lg shadow-sm overflow-hidden"> background: linear-gradient(135deg, #9333ea, #7c3aed); color: white; } + .drag-indicator.indent { + background: linear-gradient(135deg, #3b82f6, #2563eb); + color: white; + } + .drag-indicator.outdent { + background: linear-gradient(135deg, #f97316, #ea580c); + color: white; + } + + /* 드래그 중 행 하이라이트 */ + .menu-row.drag-target-indent { + background: rgba(59, 130, 246, 0.25) !important; + border: 2px solid #3b82f6 !important; + border-left-width: 6px !important; + position: relative; + animation: pulse-indent 0.8s ease-in-out infinite; + } + .menu-row.drag-target-outdent { + background: rgba(249, 115, 22, 0.25) !important; + border: 2px solid #f97316 !important; + border-right-width: 6px !important; + position: relative; + animation: pulse-outdent 0.8s ease-in-out infinite; + } + + /* 펄스 애니메이션 */ + @keyframes pulse-indent { + 0%, 100% { background: rgba(59, 130, 246, 0.15); } + 50% { background: rgba(59, 130, 246, 0.35); } + } + @keyframes pulse-outdent { + 0%, 100% { background: rgba(249, 115, 22, 0.15); } + 50% { background: rgba(249, 115, 22, 0.35); } + } .sortable-fallback { opacity: 0.9; @@ -92,10 +126,27 @@ class="bg-white rounded-lg shadow-sm overflow-hidden"> z-index: 9998 !important; } + /* 드래그 중 텍스트 선택 방지 */ + .sortable-drag, .sortable-chosen, .sortable-ghost { + user-select: none !important; + -webkit-user-select: none !important; + } + .drag-handle { user-select: none; -webkit-user-select: none; } + + /* 드래그 중 전체 페이지 텍스트 선택 방지 */ + body.is-dragging { + user-select: none !important; + -webkit-user-select: none !important; + cursor: grabbing !important; + } + body.is-dragging * { + user-select: none !important; + -webkit-user-select: none !important; + } @endpush @@ -110,14 +161,80 @@ class="bg-white rounded-lg shadow-sm overflow-hidden"> }); // HTMX 응답 처리 + SortableJS 초기화 - // 서버가 HTML을 직접 반환하므로 HTMX가 자동으로 swap 처리 document.body.addEventListener('htmx:afterSwap', function(event) { if (event.detail.target.id === 'menu-table') { - // 테이블 로드 후 SortableJS 초기화 initGlobalMenuSortable(); } }); + // 드래그 상태 관리 + let dragStartX = 0; + let dragIndicator = null; + let currentDragItem = null; + let lastHighlightedRow = null; + const INDENT_THRESHOLD = 40; // px - 인덴트 임계값 + + // 드래그 인디케이터 생성 + function createDragIndicator() { + const indicator = document.createElement('div'); + indicator.className = 'drag-indicator reorder'; + indicator.innerHTML = '↕️ 순서 변경'; + indicator.style.display = 'none'; + document.body.appendChild(indicator); + return indicator; + } + + // 드래그 인디케이터 업데이트 + function updateDragIndicator(deltaX, mouseX, mouseY, canIndent, canOutdent, targetName) { + if (!dragIndicator) return; + + dragIndicator.style.display = 'block'; + dragIndicator.style.left = (mouseX + 15) + 'px'; + dragIndicator.style.top = (mouseY - 15) + 'px'; + + dragIndicator.classList.remove('indent', 'outdent', 'reorder'); + + if (deltaX > INDENT_THRESHOLD && canIndent) { + dragIndicator.classList.add('indent'); + dragIndicator.innerHTML = `→ ${targetName}의 하위로`; + } else if (deltaX < -INDENT_THRESHOLD && canOutdent) { + dragIndicator.classList.add('outdent'); + dragIndicator.innerHTML = '← 상위 레벨로'; + } else { + dragIndicator.classList.add('reorder'); + dragIndicator.innerHTML = '↕️ 순서 변경'; + } + } + + // 행 하이라이트 업데이트 + function updateRowHighlight(prevRow, draggedRow, deltaX, canIndent, canOutdent) { + if (lastHighlightedRow) { + lastHighlightedRow.classList.remove('drag-target-indent', 'drag-target-outdent'); + } + + if (deltaX > INDENT_THRESHOLD && canIndent && prevRow) { + prevRow.classList.add('drag-target-indent'); + lastHighlightedRow = prevRow; + } else if (deltaX < -INDENT_THRESHOLD && canOutdent && draggedRow) { + draggedRow.classList.add('drag-target-outdent'); + lastHighlightedRow = draggedRow; + } else { + lastHighlightedRow = null; + } + } + + // 드래그 인디케이터 제거 + function removeDragIndicator() { + if (dragIndicator) { + dragIndicator.remove(); + dragIndicator = null; + } + if (lastHighlightedRow) { + lastHighlightedRow.classList.remove('drag-target-indent', 'drag-target-outdent'); + lastHighlightedRow = null; + } + } + // SortableJS 초기화 함수 function initGlobalMenuSortable() { const tbody = document.getElementById('menu-sortable'); @@ -127,6 +244,42 @@ function initGlobalMenuSortable() { tbody.sortableInstance.destroy(); } + // 드래그 중 마우스 이동 핸들러 + function onDragMove(e) { + if (!currentDragItem) return; + + const mouseX = e.clientX || e.touches?.[0]?.clientX || 0; + const mouseY = e.clientY || e.touches?.[0]?.clientY || 0; + const deltaX = mouseX - dragStartX; + + const rows = Array.from(tbody.querySelectorAll('tr.menu-row')); + const draggedIndex = rows.indexOf(currentDragItem); + + // 위 행 찾기 + let prevRow = null; + for (let i = draggedIndex - 1; i >= 0; i--) { + if (rows[i] !== currentDragItem && !rows[i].classList.contains('sortable-ghost')) { + prevRow = rows[i]; + break; + } + } + + const oldParentIdRaw = currentDragItem.dataset.parentId; + const oldPid = oldParentIdRaw === '' ? null : (oldParentIdRaw ? parseInt(oldParentIdRaw) : null); + const canIndent = prevRow !== null; + const canOutdent = oldPid !== null; + + let targetName = ''; + if (prevRow && canIndent) { + const nameCell = prevRow.querySelector('td:nth-child(3) span'); + targetName = nameCell?.textContent?.trim() || '위 항목'; + } + + updateDragIndicator(deltaX, mouseX, mouseY, canIndent, canOutdent, targetName); + updateRowHighlight(prevRow, currentDragItem, deltaX, canIndent, canOutdent); + } + + // SortableJS 초기화 - 노션 스타일 인덴트 tbody.sortableInstance = new Sortable(tbody, { handle: '.drag-handle', animation: 150, @@ -135,20 +288,135 @@ function initGlobalMenuSortable() { fallbackOnBody: true, ghostClass: 'bg-purple-50', chosenClass: 'bg-purple-100', + dragClass: 'shadow-lg', + + onStart: function(evt) { + dragStartX = evt.originalEvent?.clientX || evt.originalEvent?.touches?.[0]?.clientX || 0; + currentDragItem = evt.item; + dragIndicator = createDragIndicator(); + document.body.classList.add('is-dragging'); + document.addEventListener('mousemove', onDragMove); + document.addEventListener('touchmove', onDragMove); + }, onEnd: function(evt) { - const rows = Array.from(tbody.querySelectorAll('tr.menu-row')); - const items = rows.map((row, index) => ({ - id: parseInt(row.dataset.menuId), - sort_order: index + 1 - })); + document.removeEventListener('mousemove', onDragMove); + document.removeEventListener('touchmove', onDragMove); + removeDragIndicator(); + currentDragItem = null; + document.body.classList.remove('is-dragging'); - saveGlobalMenuOrder(items); + const movedItem = evt.item; + const menuId = parseInt(movedItem.dataset.menuId); + const oldParentIdRaw = movedItem.dataset.parentId; + const oldPid = oldParentIdRaw === '' ? null : (oldParentIdRaw ? parseInt(oldParentIdRaw) : null); + + const rows = Array.from(tbody.querySelectorAll('tr.menu-row')); + const newIndex = rows.indexOf(movedItem); + + const dragEndX = evt.originalEvent?.clientX || evt.originalEvent?.changedTouches?.[0]?.clientX || dragStartX; + const deltaX = dragEndX - dragStartX; + + let newParentId = oldPid; + let sortOrder = 1; + let action = 'reorder'; + + // 오른쪽으로 이동 → 인덴트 (위 항목의 자식으로) + if (deltaX > INDENT_THRESHOLD && newIndex > 0) { + const prevRow = rows[newIndex - 1]; + if (prevRow) { + const prevId = parseInt(prevRow.dataset.menuId); + if (prevId !== menuId) { + newParentId = prevId; + action = 'indent'; + } + } + } + // 왼쪽으로 이동 → 아웃덴트 (부모의 형제로) + else if (deltaX < -INDENT_THRESHOLD && oldPid !== null) { + const parentRow = rows.find(r => parseInt(r.dataset.menuId) === oldPid); + if (parentRow) { + const grandParentIdRaw = parentRow.dataset.parentId; + newParentId = grandParentIdRaw === '' ? null : (grandParentIdRaw ? parseInt(grandParentIdRaw) : null); + } else { + newParentId = null; + } + action = 'outdent'; + } + + const parentChanged = oldPid !== newParentId; + + if (parentChanged) { + // 새 부모 하위에서의 순서 계산 + const existingSiblings = rows.filter(row => { + if (row === movedItem) return false; + const rowParentId = row.dataset.parentId || ''; + const rowPid = rowParentId === '' ? null : parseInt(rowParentId); + return rowPid === newParentId; + }); + + let siblingsBeforeMe = 0; + for (const sibling of existingSiblings) { + if (rows.indexOf(sibling) < newIndex) { + siblingsBeforeMe++; + } + } + sortOrder = siblingsBeforeMe + 1; + + moveGlobalMenu(menuId, newParentId, sortOrder); + } else { + // 같은 레벨 내 순서 변경 + const siblingRows = rows.filter(row => { + const rowParentId = row.dataset.parentId || ''; + const rowPid = rowParentId === '' ? null : parseInt(rowParentId); + return rowPid === oldPid; + }); + + const items = siblingRows.map((row, index) => ({ + id: parseInt(row.dataset.menuId), + sort_order: index + 1 + })); + + saveGlobalMenuOrder(items); + } } }); } - // 메뉴 순서 저장 API 호출 + // 글로벌 메뉴 이동 API 호출 (계층 변경) + function moveGlobalMenu(menuId, newParentId, sortOrder) { + const payload = { + menu_id: menuId, + new_parent_id: newParentId, + sort_order: sortOrder + }; + + fetch('/api/admin/global-menus/move', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + 'Accept': 'application/json' + }, + body: JSON.stringify(payload) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + htmx.trigger('#menu-table', 'filterSubmit'); + } else { + showToast('메뉴 이동 실패: ' + (data.message || ''), 'error'); + htmx.trigger('#menu-table', 'filterSubmit'); + } + }) + .catch(error => { + console.error('Error:', error); + showToast('메뉴 이동 중 오류 발생', 'error'); + htmx.trigger('#menu-table', 'filterSubmit'); + }); + } + + // 글로벌 메뉴 순서 저장 API 호출 function saveGlobalMenuOrder(items) { fetch('/api/admin/global-menus/reorder', { method: 'POST', diff --git a/resources/views/roles/index.blade.php b/resources/views/roles/index.blade.php index 15b48901..35a2019a 100644 --- a/resources/views/roles/index.blade.php +++ b/resources/views/roles/index.blade.php @@ -63,16 +63,6 @@ class="bg-white rounded-lg shadow-sm overflow-hidden"> htmx.trigger('#role-table', 'filterSubmit'); }); - // HTMX 응답 처리 - document.body.addEventListener('htmx:afterSwap', function(event) { - if (event.detail.target.id === 'role-table') { - const response = JSON.parse(event.detail.xhr.response); - if (response.html) { - event.detail.target.innerHTML = response.html; - } - } - }); - // 삭제 확인 window.confirmDelete = function(id, name) { showDeleteConfirm(name, () => { diff --git a/routes/api.php b/routes/api.php index 0965d4e8..140be070 100644 --- a/routes/api.php +++ b/routes/api.php @@ -148,6 +148,7 @@ Route::middleware('super.admin')->prefix('global-menus')->name('global-menus.')->group(function () { // 고정 경로 Route::post('/reorder', [GlobalMenuController::class, 'reorder'])->name('reorder'); + Route::post('/move', [GlobalMenuController::class, 'move'])->name('move'); // 기본 CRUD Route::get('/', [GlobalMenuController::class, 'index'])->name('index');