Files
sam-manage/resources/views/menus/global-index.blade.php
권혁성 f03330a3f5 fix:메뉴 관리 토글 클릭 시 새로고침 없이 UI만 업데이트
- toggleActive, toggleHidden 함수를 낙관적 업데이트 방식으로 변경
- 토글 클릭 시 즉시 UI 상태 변경 후 백엔드 비동기 요청
- 실패 시에만 원래 상태로 롤백 및 에러 토스트 표시
- 일반 메뉴, 글로벌 메뉴 페이지 모두 적용

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 16:51:33 +09:00

596 lines
23 KiB
PHP

@extends('layouts.app')
@section('title', '기본 메뉴 관리')
@section('content')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-800">기본 메뉴 관리</h1>
<p class="text-sm text-gray-500 mt-1">
시스템 전체에서 사용되는 기본 메뉴를 관리합니다. 테넌트는 메뉴를 복사하여 사용합니다.
</p>
</div>
<div class="flex items-center gap-3">
<a href="{{ route('menus.index') }}" class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
메뉴 관리로 돌아가기
</a>
<a href="{{ route('menus.global.create') }}" class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg transition">
+ 기본 메뉴
</a>
</div>
</div>
<!-- 필터 영역 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<form id="filterForm" class="flex gap-4">
<!-- 검색 -->
<div class="flex-1">
<input type="text"
name="search"
placeholder="메뉴명, URL로 검색..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
</div>
<!-- 활성 상태 필터 -->
<div class="w-48">
<select name="is_active" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
<option value="">전체 상태</option>
<option value="1">활성</option>
<option value="0">비활성</option>
</select>
</div>
<!-- 검색 버튼 -->
<button type="submit" class="bg-purple-600 hover:bg-purple-700 text-white px-6 py-2 rounded-lg transition">
검색
</button>
</form>
</div>
<!-- 테이블 영역 (HTMX로 로드) -->
<div id="menu-table"
hx-get="/api/admin/global-menus"
hx-trigger="load, filterSubmit from:body"
hx-include="#filterForm"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="bg-white rounded-lg shadow-sm overflow-hidden">
<!-- 로딩 스피너 -->
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600"></div>
</div>
</div>
@endsection
@push('styles')
<style>
/* 드래그 인디케이터 스타일 */
.drag-indicator {
position: fixed;
pointer-events: none;
z-index: 9999;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.drag-indicator.reorder {
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;
background: white !important;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
border-radius: 4px;
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
@push('scripts')
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
<script>
// 폼 제출 시 HTMX 이벤트 트리거
document.getElementById('filterForm').addEventListener('submit', function(e) {
e.preventDefault();
htmx.trigger('#menu-table', 'filterSubmit');
});
// HTMX 응답 처리 + SortableJS 초기화
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'menu-table') {
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');
if (!tbody) return;
if (tbody.sortableInstance) {
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,
forceFallback: true,
fallbackClass: 'sortable-fallback',
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) {
document.removeEventListener('mousemove', onDragMove);
document.removeEventListener('touchmove', onDragMove);
removeDragIndicator();
currentDragItem = null;
document.body.classList.remove('is-dragging');
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 호출 (계층 변경)
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',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
},
body: JSON.stringify({ items: items })
})
.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');
});
}
// 삭제 확인
window.confirmDelete = function(id, name) {
showDeleteConfirm(name, () => {
htmx.ajax('DELETE', `/api/admin/global-menus/${id}`, {
target: '#menu-table',
swap: 'none',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
}).then(() => {
htmx.trigger('#menu-table', 'filterSubmit');
});
});
};
// 복원 확인
window.confirmRestore = function(id, name) {
showConfirm(`"${name}" 기본 메뉴를 복원하시겠습니까?`, () => {
htmx.ajax('POST', `/api/admin/global-menus/${id}/restore`, {
target: '#menu-table',
swap: 'none',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
}).then(() => {
htmx.trigger('#menu-table', 'filterSubmit');
});
}, { title: '복원 확인', icon: 'question' });
};
// 영구삭제 확인
window.confirmForceDelete = function(id, name) {
showPermanentDeleteConfirm(name, () => {
fetch(`/api/admin/global-menus/${id}/force`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'Content-Type': 'application/json'
}
})
.then(response => response.json().then(data => ({ ok: response.ok, data })))
.then(({ ok, data }) => {
if (ok && data.success) {
showToast(data.message || '글로벌 메뉴가 영구 삭제되었습니다.', 'success');
htmx.trigger('#menu-table', 'filterSubmit');
} else {
showToast(data.message || '삭제에 실패했습니다.', 'error');
}
})
.catch(error => {
showToast('삭제 중 오류가 발생했습니다.', 'error');
console.error('Force delete error:', error);
});
});
};
// 활성 토글 (새로고침 없이 UI만 업데이트)
window.toggleActive = function(id, buttonEl) {
// 버튼 요소 찾기
const btn = buttonEl || document.querySelector(`tr[data-menu-id="${id}"] button[onclick*="toggleActive"]`);
if (!btn) return;
// 현재 상태 확인 (보라색이면 활성)
const isCurrentlyActive = btn.classList.contains('bg-purple-500');
const thumb = btn.querySelector('span');
// 즉시 UI 토글 (낙관적 업데이트)
btn.classList.toggle('bg-purple-500', !isCurrentlyActive);
btn.classList.toggle('bg-gray-400', isCurrentlyActive);
thumb.classList.toggle('translate-x-3.5', !isCurrentlyActive);
thumb.classList.toggle('translate-x-0.5', isCurrentlyActive);
// 백엔드 요청
fetch(`/api/admin/global-menus/${id}/toggle-active`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (!data.success) {
// 실패 시 롤백
btn.classList.toggle('bg-purple-500', isCurrentlyActive);
btn.classList.toggle('bg-gray-400', !isCurrentlyActive);
thumb.classList.toggle('translate-x-3.5', isCurrentlyActive);
thumb.classList.toggle('translate-x-0.5', !isCurrentlyActive);
showToast(data.message || '상태 변경에 실패했습니다.', 'error');
}
})
.catch(error => {
// 에러 시 롤백
btn.classList.toggle('bg-purple-500', isCurrentlyActive);
btn.classList.toggle('bg-gray-400', !isCurrentlyActive);
thumb.classList.toggle('translate-x-3.5', isCurrentlyActive);
thumb.classList.toggle('translate-x-0.5', !isCurrentlyActive);
showToast('상태 변경 중 오류가 발생했습니다.', 'error');
console.error('Toggle active error:', error);
});
};
// 숨김 토글 (새로고침 없이 UI만 업데이트)
window.toggleHidden = function(id, buttonEl) {
// 버튼 요소 찾기
const btn = buttonEl || document.querySelector(`tr[data-menu-id="${id}"] button[onclick*="toggleHidden"]`);
if (!btn) return;
// 현재 상태 확인 (주황색이면 숨김)
const isCurrentlyHidden = btn.classList.contains('bg-amber-500');
const thumb = btn.querySelector('span');
// 즉시 UI 토글 (낙관적 업데이트)
btn.classList.toggle('bg-amber-500', !isCurrentlyHidden);
btn.classList.toggle('bg-gray-400', isCurrentlyHidden);
thumb.classList.toggle('translate-x-3.5', !isCurrentlyHidden);
thumb.classList.toggle('translate-x-0.5', isCurrentlyHidden);
// 백엔드 요청
fetch(`/api/admin/global-menus/${id}/toggle-hidden`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (!data.success) {
// 실패 시 롤백
btn.classList.toggle('bg-amber-500', isCurrentlyHidden);
btn.classList.toggle('bg-gray-400', !isCurrentlyHidden);
thumb.classList.toggle('translate-x-3.5', isCurrentlyHidden);
thumb.classList.toggle('translate-x-0.5', !isCurrentlyHidden);
showToast(data.message || '상태 변경에 실패했습니다.', 'error');
}
})
.catch(error => {
// 에러 시 롤백
btn.classList.toggle('bg-amber-500', isCurrentlyHidden);
btn.classList.toggle('bg-gray-400', !isCurrentlyHidden);
thumb.classList.toggle('translate-x-3.5', isCurrentlyHidden);
thumb.classList.toggle('translate-x-0.5', !isCurrentlyHidden);
showToast('상태 변경 중 오류가 발생했습니다.', 'error');
console.error('Toggle hidden error:', error);
});
};
</script>
<script src="{{ asset('js/menu-tree.js') }}"></script>
@endpush