diff --git a/public/js/menu-sortable.js b/public/js/menu-sortable.js index 3fd6ff06..290c1370 100644 --- a/public/js/menu-sortable.js +++ b/public/js/menu-sortable.js @@ -1,6 +1,7 @@ /** * 메뉴 관리 페이지 전용 SortableJS 초기화 스크립트 * HTMX hx-boost 네비게이션에서도 동작하도록 전역으로 정의 + * - 그룹 드래그: 체크된 항목들을 함께 이동 */ // 드래그 상태 관리 @@ -8,6 +9,7 @@ let dragStartX = 0; let dragIndicator = null; let currentDragItem = null; let lastHighlightedRow = null; +let groupDragItems = []; // 그룹 드래그 대상 행들 const INDENT_THRESHOLD = 40; // px - 인덴트 임계값 // 드래그 인디케이터 생성 @@ -28,18 +30,21 @@ function updateDragIndicator(deltaX, mouseX, mouseY, canIndent, canOutdent, targ dragIndicator.style.left = (mouseX + 15) + 'px'; dragIndicator.style.top = (mouseY - 15) + 'px'; + const groupCount = groupDragItems.length; + const groupLabel = groupCount > 1 ? ` ${groupCount}개` : ''; + // 클래스 및 텍스트 업데이트 dragIndicator.classList.remove('indent', 'outdent', 'reorder'); if (deltaX > INDENT_THRESHOLD && canIndent) { dragIndicator.classList.add('indent'); - dragIndicator.innerHTML = `→ ${targetName}의 하위로`; + dragIndicator.innerHTML = `→ ${targetName}의 하위로${groupLabel}`; } else if (deltaX < -INDENT_THRESHOLD && canOutdent) { dragIndicator.classList.add('outdent'); - dragIndicator.innerHTML = '← 상위 레벨로'; + dragIndicator.innerHTML = `← 상위 레벨로${groupLabel}`; } else { dragIndicator.classList.add('reorder'); - dragIndicator.innerHTML = '↕️ 순서 변경'; + dragIndicator.innerHTML = `↕️ 순서 변경${groupLabel}`; } } @@ -52,11 +57,9 @@ function updateRowHighlight(prevRow, draggedRow, deltaX, canIndent, canOutdent) // 새 하이라이트 적용 if (deltaX > INDENT_THRESHOLD && canIndent && prevRow) { - // 인덴트: 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 { @@ -76,6 +79,40 @@ function removeDragIndicator() { } } +/** + * 체크된 항목 + 그 하위 메뉴를 재귀적으로 수집 + */ +function collectGroupItems(draggedRow) { + const draggedId = draggedRow.dataset.menuId; + const checkbox = draggedRow.querySelector('.menu-checkbox'); + + // 드래그 항목이 체크 안 되어 있으면 단독 드래그 + if (!checkbox || !checkbox.checked) return [draggedRow]; + + // 체크된 모든 항목 수집 + const checkedRows = Array.from(document.querySelectorAll('#menu-sortable .menu-checkbox:checked')) + .map(cb => cb.closest('tr.menu-row')) + .filter(Boolean); + + if (checkedRows.length <= 1) return [draggedRow]; + + return checkedRows; +} + +/** + * 특정 메뉴의 모든 하위 메뉴 ID를 재귀적으로 수집 + */ +function getDescendantIds(parentId) { + const ids = []; + const children = document.querySelectorAll(`tr.menu-row[data-parent-id="${parentId}"]`); + children.forEach(child => { + const childId = child.dataset.menuId; + ids.push(childId); + ids.push(...getDescendantIds(childId)); + }); + return ids; +} + // SortableJS 초기화 함수 (전역) window.initMenuSortable = function() { const tbody = document.getElementById('menu-sortable'); @@ -100,7 +137,7 @@ window.initMenuSortable = function() { const mouseY = e.clientY || e.touches?.[0]?.clientY || 0; const deltaX = mouseX - dragStartX; - const rows = Array.from(tbody.querySelectorAll('tr.menu-row')); + const rows = Array.from(tbody.querySelectorAll('tr.menu-row:not(.group-drag-hidden)')); const draggedIndex = rows.indexOf(currentDragItem); // 위 행 찾기 (드래그 중인 항목 제외) @@ -152,6 +189,28 @@ window.initMenuSortable = function() { // 드래그 중 텍스트 선택 방지 document.body.classList.add('is-dragging'); + // 그룹 드래그 처리 + groupDragItems = collectGroupItems(currentDragItem); + + if (groupDragItems.length > 1) { + const draggedId = currentDragItem.dataset.menuId; + + // 드래그 중인 항목 제외한 그룹 항목들 숨기기 + groupDragItems.forEach(row => { + if (row !== currentDragItem) { + row.classList.add('group-drag-hidden'); + row.style.display = 'none'; + } + }); + + // 드래그 항목에 그룹 뱃지 추가 + const badge = document.createElement('span'); + badge.className = 'group-drag-badge'; + badge.textContent = groupDragItems.length + '개 항목'; + const nameCell = currentDragItem.querySelector('td:nth-child(4)'); + if (nameCell) nameCell.appendChild(badge); + } + // 마우스 이동 이벤트 리스너 추가 document.addEventListener('mousemove', onDragMove); document.addEventListener('touchmove', onDragMove); @@ -163,11 +222,19 @@ window.initMenuSortable = function() { document.removeEventListener('mousemove', onDragMove); document.removeEventListener('touchmove', onDragMove); removeDragIndicator(); - currentDragItem = null; // 드래그 중 텍스트 선택 방지 해제 document.body.classList.remove('is-dragging'); + // 그룹 뱃지 제거 + document.querySelectorAll('.group-drag-badge').forEach(b => b.remove()); + + // 숨긴 행 복원 + document.querySelectorAll('.group-drag-hidden').forEach(row => { + row.classList.remove('group-drag-hidden'); + row.style.display = ''; + }); + const movedItem = evt.item; const menuId = parseInt(movedItem.dataset.menuId); const oldParentIdRaw = movedItem.dataset.parentId; @@ -205,6 +272,16 @@ window.initMenuSortable = function() { } const parentChanged = oldPid !== newParentId; + const isGroupDrag = groupDragItems.length > 1; + + // 그룹 드래그인 경우: 모든 체크된 항목의 ID 수집 + const groupMenuIds = isGroupDrag + ? groupDragItems.map(r => parseInt(r.dataset.menuId)) + : [menuId]; + + // 상태 초기화 + groupDragItems = []; + currentDragItem = null; if (parentChanged) { // 새 부모 하위에서의 순서 계산 @@ -223,8 +300,13 @@ window.initMenuSortable = function() { } sortOrder = siblingsBeforeMe + 1; - // 계층 변경 API 호출 - window.moveMenu(menuId, newParentId, sortOrder, csrfToken); + if (isGroupDrag) { + // 그룹 이동: 대표 항목(드래그한 항목)만 이동 후 새로고침 + // 나머지 체크된 항목도 순차적으로 이동 + window.moveMenuGroup(groupMenuIds, menuId, newParentId, sortOrder, csrfToken); + } else { + window.moveMenu(menuId, newParentId, sortOrder, csrfToken); + } } else { // 같은 레벨 내 순서 변경 const siblingRows = rows.filter(row => { @@ -275,6 +357,61 @@ window.moveMenu = function(menuId, newParentId, sortOrder, csrfToken) { }); }; +// 그룹 메뉴 이동 (체크된 항목들 순차 이동) +window.moveMenuGroup = function(groupMenuIds, leadId, newParentId, sortOrder, csrfToken) { + // 대표 항목 먼저 이동 + const movePromise = fetch('/api/admin/menus/move', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken, + 'Accept': 'application/json' + }, + body: JSON.stringify({ + menu_id: leadId, + new_parent_id: newParentId, + sort_order: sortOrder + }) + }).then(r => r.json()); + + // 나머지 항목들도 동일한 부모 아래로 이동 (대표 항목 이후 순서로) + const otherIds = groupMenuIds.filter(id => id !== leadId); + + if (otherIds.length === 0) { + movePromise.then(() => htmx.trigger('#menu-table', 'filterSubmit')); + return; + } + + movePromise.then(() => { + // 나머지 항목을 순차적으로 이동 + const promises = otherIds.map((id, idx) => + fetch('/api/admin/menus/move', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken, + 'Accept': 'application/json' + }, + body: JSON.stringify({ + menu_id: id, + new_parent_id: newParentId, + sort_order: sortOrder + idx + 1 + }) + }).then(r => r.json()) + ); + + return Promise.all(promises); + }) + .then(() => { + htmx.trigger('#menu-table', 'filterSubmit'); + }) + .catch(error => { + console.error('moveMenuGroup error:', error); + showToast('그룹 이동 중 오류 발생', 'error'); + htmx.trigger('#menu-table', 'filterSubmit'); + }); +}; + // 메뉴 순서 저장 API 호출 (같은 레벨) window.saveMenuOrder = function(items, csrfToken) { fetch('/api/admin/menus/reorder', { @@ -300,4 +437,4 @@ window.saveMenuOrder = function(items, csrfToken) { showToast('순서 변경 중 오류 발생', 'error'); htmx.trigger('#menu-table', 'filterSubmit'); }); -}; \ No newline at end of file +}; diff --git a/resources/views/menus/index.blade.php b/resources/views/menus/index.blade.php index 22a76857..4e60e15a 100644 --- a/resources/views/menus/index.blade.php +++ b/resources/views/menus/index.blade.php @@ -270,6 +270,32 @@ class="bg-white rounded-lg shadow-sm overflow-hidden"> user-select: none !important; -webkit-user-select: none !important; } + + /* 그룹 드래그: 숨김 상태 (드래그 중 나머지 체크된 항목) */ + .group-drag-hidden { + display: none !important; + } + + /* 그룹 드래그: 항목 수 뱃지 */ + .group-drag-badge { + display: inline-flex; + align-items: center; + background: linear-gradient(135deg, #3b82f6, #2563eb); + color: white; + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + margin-left: 8px; + white-space: nowrap; + animation: badge-pulse 1s ease-in-out infinite; + vertical-align: middle; + } + + @keyframes badge-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } + } @endpush