feat: [menus] 최상위 그룹 상단/하단 이동 버튼 추가

- depth=0 메뉴에만 이동 버튼(↕) 표시
- 클릭 시 드롭다운으로 상단/하단 이동 선택
- 기존 reorder API 재사용하여 sort_order 일괄 변경
This commit is contained in:
김보곤
2026-02-28 08:24:36 +09:00
parent 8ba619d659
commit 83f10552df
3 changed files with 138 additions and 6 deletions

View File

@@ -415,6 +415,97 @@ window.moveMenuGroup = function(groupMenuIds, leadId, newParentId, sortOrder, cs
});
};
// ===== 최상위 그룹 상단/하단 이동 =====
// 이동 드롭다운 표시
window.showMoveGroupDropdown = function(menuId, buttonEl) {
// 기존 드롭다운 제거
const existing = document.getElementById('move-group-dropdown');
if (existing) {
existing.remove();
// 같은 버튼 클릭 시 토글 (닫기)
if (existing.dataset.menuId === String(menuId)) return;
}
const dropdown = document.createElement('div');
dropdown.id = 'move-group-dropdown';
dropdown.dataset.menuId = menuId;
dropdown.className = 'move-group-dropdown';
dropdown.innerHTML = `
<button type="button" onclick="moveGroupToPosition(${menuId}, 'top')" class="move-group-option">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
상단으로 이동
</button>
<button type="button" onclick="moveGroupToPosition(${menuId}, 'bottom')" class="move-group-option">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
하단으로 이동
</button>
`;
// 버튼 위치 기준으로 드롭다운 배치
const rect = buttonEl.getBoundingClientRect();
dropdown.style.position = 'fixed';
dropdown.style.left = rect.left + 'px';
dropdown.style.top = (rect.bottom + 4) + 'px';
dropdown.style.zIndex = '9999';
document.body.appendChild(dropdown);
// 외부 클릭 시 닫기
function closeDropdown(e) {
if (!dropdown.contains(e.target) && e.target !== buttonEl && !buttonEl.contains(e.target)) {
dropdown.remove();
document.removeEventListener('click', closeDropdown, true);
}
}
// 다음 이벤트 루프에서 리스너 등록 (현재 클릭 무시)
setTimeout(() => document.addEventListener('click', closeDropdown, true), 0);
};
// 최상위 그룹을 상단/하단으로 이동
window.moveGroupToPosition = function(menuId, position) {
// 드롭다운 닫기
const dropdown = document.getElementById('move-group-dropdown');
if (dropdown) dropdown.remove();
// DOM에서 최상위 그룹(depth=0, parent_id="") 수집
const allRows = document.querySelectorAll('#menu-sortable tr.menu-row[data-depth="0"][data-parent-id=""]');
if (allRows.length === 0) return;
const groupIds = Array.from(allRows).map(row => parseInt(row.dataset.menuId));
const targetIndex = groupIds.indexOf(menuId);
if (targetIndex === -1) return;
// 이미 해당 위치에 있으면 무시
if (position === 'top' && targetIndex === 0) return;
if (position === 'bottom' && targetIndex === groupIds.length - 1) return;
// 대상을 배열에서 제거 후 처음/끝에 삽입
groupIds.splice(targetIndex, 1);
if (position === 'top') {
groupIds.unshift(menuId);
} else {
groupIds.push(menuId);
}
// sort_order 계산 (1부터 순차)
const items = groupIds.map((id, index) => ({
id: id,
sort_order: index + 1
}));
// CSRF 토큰
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
// 기존 saveMenuOrder API 호출
window.saveMenuOrder(items, csrfToken);
};
// 메뉴 순서 저장 API 호출 (같은 레벨)
window.saveMenuOrder = function(items, csrfToken) {
fetch('/api/admin/menus/reorder', {

View File

@@ -314,6 +314,35 @@ class="bg-white rounded-lg shadow-sm overflow-hidden">
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* 최상위 그룹 이동 드롭다운 */
.move-group-dropdown {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 4px;
min-width: 150px;
}
.move-group-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
font-size: 13px;
font-weight: 500;
color: #374151;
border: none;
background: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.15s;
}
.move-group-option:hover {
background-color: #eff6ff;
color: #2563eb;
}
</style>
@endpush

View File

@@ -65,14 +65,26 @@ class="import-checkbox w-4 h-4 rounded border-gray-300 text-green-600 focus:ring
onchange="toggleMenuChildren(this); updateBulkButtonState()"
class="menu-checkbox w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
</td>
{{-- 드래그 핸들 --}}
{{-- 드래그 핸들 + 이동 버튼 --}}
<td class="px-2 py-2 whitespace-nowrap text-center">
@if(!$menu->deleted_at)
<span class="drag-handle cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600">
<svg class="w-4 h-4 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16" />
</svg>
</span>
<div class="inline-flex items-center gap-0.5">
<span class="drag-handle cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600">
<svg class="w-4 h-4 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16" />
</svg>
</span>
@if(($menu->depth ?? 0) === 0)
<button type="button"
onclick="showMoveGroupDropdown({{ $menu->id }}, this)"
class="move-group-btn text-gray-400 hover:text-blue-600 transition p-0.5 rounded hover:bg-blue-50"
title="상단/하단으로 이동">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
</button>
@endif
</div>
@endif
</td>
@endif