feat: Global Menu 계층 이동 기능 추가 및 Role 삭제 오류 수정
Global Menu indent/outdent 기능: - GlobalMenuController에 move() 메서드 추가 - MenuService에 moveGlobalMenu(), isGlobalDescendant(), reorderGlobalSiblings(), compactGlobalSiblings() 추가 - global-index.blade.php에 드래그 계층 이동 JavaScript 추가 - routes/api.php에 POST /move 라우트 추가 Role 삭제 500 에러 수정: - config/auth.php에 api guard 추가 (Spatie Permission getModelForGuard 오류 해결) - RoleService에서 불필요한 users()->detach() 제거 (FK CASCADE 처리) - RoleController에서 HTMX 요청 시 View 직접 반환 (JSON 파싱 에러 해결) - index.blade.php에서 불필요한 afterSwap 핸들러 제거 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
</style>
|
||||
@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 = `→ <b>${targetName}</b>의 하위로`;
|
||||
} 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',
|
||||
|
||||
@@ -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, () => {
|
||||
|
||||
Reference in New Issue
Block a user