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:
2025-12-20 22:43:48 +09:00
parent 6525bfd715
commit 00a4920b7a
8 changed files with 416 additions and 27 deletions

View File

@@ -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',

View File

@@ -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, () => {