- layouts/app.blade.php에 SweetAlert2 CDN 및 전역 헬퍼 함수 추가 - showToast(): 토스트 알림 (success, error, warning, info) - showConfirm(): 확인 대화상자 - showDeleteConfirm(): 삭제 확인 (경고 아이콘) - showPermanentDeleteConfirm(): 영구 삭제 확인 (빨간색 경고) - showSuccess(), showError(): 성공/에러 알림 - 변환된 파일 목록 (48개 Blade 파일): - menus/* (6개), boards/* (2개), posts/* (3개) - daily-logs/* (3개), project-management/* (6개) - dev-tools/flow-tester/* (6개) - quote-formulas/* (4개), permission-analyze/* (1개) - archived-records/* (1개), profile/* (1개) - roles/* (3개), permissions/* (3개) - departments/* (3개), tenants/* (3개), users/* (3개) - 주요 개선사항: - Tailwind CSS 테마와 일관된 디자인 - 비동기 콜백 패턴으로 리팩토링 - 삭제/복원/영구삭제 각각 다른 스타일 적용
786 lines
32 KiB
PHP
786 lines
32 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" id="modeDescription">
|
|
드래그: 순서 변경 | <span class="font-medium text-blue-600">→ 오른쪽</span>: 하위로 이동 | <span class="font-medium text-orange-600">← 왼쪽</span>: 상위로 이동
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
@if(session('selected_tenant_id'))
|
|
<!-- 모드 전환 버튼 -->
|
|
<div class="flex items-center bg-gray-200 rounded-lg p-1">
|
|
<button onclick="switchMode('normal')"
|
|
id="normalModeBtn"
|
|
class="mode-btn px-4 py-2 text-sm font-medium rounded-md transition bg-white text-gray-900 shadow-sm">
|
|
내 메뉴
|
|
</button>
|
|
<button onclick="switchMode('import')"
|
|
id="importModeBtn"
|
|
class="mode-btn px-4 py-2 text-sm font-medium rounded-md transition bg-gray-200 text-gray-500 hover:text-gray-700 hover:bg-gray-300">
|
|
기본에서 가져오기
|
|
</button>
|
|
</div>
|
|
<!-- 가져오기 버튼 (import 모드에서만 표시) -->
|
|
<button onclick="importSelectedMenus()"
|
|
id="importBtn"
|
|
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed hidden"
|
|
disabled>
|
|
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
</svg>
|
|
선택 가져오기 (<span id="selectedCount">0</span>)
|
|
</button>
|
|
@endif
|
|
<a href="{{ route('menus.create') }}" id="newMenuBtn" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition">
|
|
+ 새 메뉴
|
|
</a>
|
|
@if(auth()->user()?->is_super_admin)
|
|
<a href="{{ route('menus.global.index') }}" class="bg-purple-600 hover:bg-purple-700 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="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
기본 메뉴 관리
|
|
</a>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 필터 영역 -->
|
|
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
|
<form id="filterForm" class="flex gap-4">
|
|
<!-- 모드 (hidden) -->
|
|
<input type="hidden" name="mode" id="modeInput" value="">
|
|
|
|
<!-- 검색 -->
|
|
<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-blue-500">
|
|
</div>
|
|
|
|
<!-- 활성 상태 필터 (일반 모드) -->
|
|
<div class="w-48" id="activeFilter">
|
|
<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-blue-500">
|
|
<option value="">전체 상태</option>
|
|
<option value="1">활성</option>
|
|
<option value="0">비활성</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- 가져오기 상태 필터 (가져오기 모드) -->
|
|
<div class="w-48 hidden" id="importFilter">
|
|
<select name="import_status" id="importStatusSelect" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500" onchange="filterImportedMenus()">
|
|
<option value="all">전체 메뉴</option>
|
|
<option value="available">가져올 수 있는 메뉴</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- 검색 버튼 -->
|
|
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition">
|
|
검색
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- 테이블 영역 (HTMX로 로드) -->
|
|
<div id="menu-table"
|
|
hx-get="/api/admin/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-blue-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);
|
|
transition: background-color 0.15s, transform 0.1s;
|
|
}
|
|
.drag-indicator.indent {
|
|
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
|
color: white;
|
|
}
|
|
.drag-indicator.outdent {
|
|
background: linear-gradient(135deg, #f97316, #ea580c);
|
|
color: white;
|
|
}
|
|
.drag-indicator.reorder {
|
|
background: linear-gradient(135deg, #6b7280, #4b5563);
|
|
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); }
|
|
}
|
|
|
|
/* 인덴트 프리뷰 라인 */
|
|
.indent-preview-line {
|
|
position: absolute;
|
|
left: 0;
|
|
height: 3px;
|
|
background: #3b82f6;
|
|
border-radius: 2px;
|
|
pointer-events: none;
|
|
z-index: 100;
|
|
transition: width 0.15s, left 0.15s;
|
|
}
|
|
|
|
/* SortableJS fallback 모드 스타일 (forceFallback: true) */
|
|
.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') {
|
|
const response = JSON.parse(event.detail.xhr.response);
|
|
if (response.html) {
|
|
event.detail.target.innerHTML = response.html;
|
|
// 테이블 로드 후 SortableJS 초기화
|
|
initMenuSortable();
|
|
}
|
|
}
|
|
});
|
|
|
|
// 드래그 상태 관리
|
|
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(새 부모가 될 행) 하이라이트
|
|
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 initMenuSortable() {
|
|
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 초기화 - 노션 스타일 인덴트
|
|
// forceFallback: true → 네이티브 드래그 대신 자체 구현 사용 (mousemove 이벤트 정상 발생)
|
|
tbody.sortableInstance = new Sortable(tbody, {
|
|
handle: '.drag-handle',
|
|
animation: 150,
|
|
forceFallback: true, // 브라우저 네이티브 드래그 비활성화 → mousemove 정상 작동
|
|
fallbackClass: 'sortable-fallback',
|
|
fallbackOnBody: true,
|
|
ghostClass: 'bg-blue-50',
|
|
chosenClass: 'bg-blue-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);
|
|
|
|
console.log('=== Drag Start ===');
|
|
console.log('dragStartX:', dragStartX);
|
|
},
|
|
|
|
// 드래그 종료 시 처리
|
|
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 currentDepth = parseInt(movedItem.dataset.depth) || 0;
|
|
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 oldIndex = evt.oldIndex;
|
|
|
|
// X 이동량 계산
|
|
const dragEndX = evt.originalEvent?.clientX || evt.originalEvent?.changedTouches?.[0]?.clientX || dragStartX;
|
|
const deltaX = dragEndX - dragStartX;
|
|
|
|
console.log('=== Menu Drag Debug (Indent Style) ===');
|
|
console.log('menuId:', menuId, 'currentDepth:', currentDepth);
|
|
console.log('oldIndex:', oldIndex, '→ newIndex:', newIndex);
|
|
console.log('dragStartX:', dragStartX, 'dragEndX:', dragEndX, 'deltaX:', deltaX);
|
|
console.log('oldParentId:', oldPid);
|
|
|
|
let newParentId = oldPid;
|
|
let sortOrder = 1;
|
|
let action = 'reorder'; // 'reorder', 'indent', 'outdent'
|
|
|
|
// 오른쪽으로 이동 → 인덴트 (위 항목의 자식으로)
|
|
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';
|
|
console.log('→ INDENT: 위 항목의 자식으로 이동, newParentId:', newParentId);
|
|
}
|
|
}
|
|
}
|
|
// 왼쪽으로 이동 → 아웃덴트 (부모의 형제로)
|
|
else if (deltaX < -INDENT_THRESHOLD && oldPid !== null) {
|
|
// 현재 부모를 찾아서 그 부모의 parent_id를 가져옴
|
|
const parentRow = rows.find(r => parseInt(r.dataset.menuId) === oldPid);
|
|
if (parentRow) {
|
|
const grandParentIdRaw = parentRow.dataset.parentId;
|
|
newParentId = grandParentIdRaw === '' ? null : (grandParentIdRaw ? parseInt(grandParentIdRaw) : null);
|
|
action = 'outdent';
|
|
console.log('← OUTDENT: 부모의 형제로 이동, newParentId:', newParentId);
|
|
} else {
|
|
// 부모를 찾을 수 없으면 최상위로
|
|
newParentId = null;
|
|
action = 'outdent';
|
|
console.log('← OUTDENT: 최상위로 이동');
|
|
}
|
|
}
|
|
// 수평 이동 없음 → 같은 레벨에서 순서만 변경
|
|
else {
|
|
// 원래 부모 유지 (순서만 변경, 계층 변경 안함)
|
|
newParentId = oldPid;
|
|
action = 'reorder';
|
|
console.log('↔ REORDER: 같은 부모 유지, parentId:', newParentId);
|
|
}
|
|
|
|
const parentChanged = oldPid !== newParentId;
|
|
console.log('oldPid:', oldPid, 'newPid:', newParentId, 'parentChanged:', parentChanged);
|
|
console.log('action:', action);
|
|
|
|
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;
|
|
});
|
|
|
|
// DOM에서 movedItem보다 앞에 있는 형제의 수 = 새 순서
|
|
let siblingsBeforeMe = 0;
|
|
for (const sibling of existingSiblings) {
|
|
if (rows.indexOf(sibling) < newIndex) {
|
|
siblingsBeforeMe++;
|
|
}
|
|
}
|
|
sortOrder = siblingsBeforeMe + 1;
|
|
|
|
console.log('existingSiblings count:', existingSiblings.length);
|
|
console.log('siblingsBeforeMe:', siblingsBeforeMe, 'sortOrder:', sortOrder);
|
|
|
|
// 계층 변경 API 호출
|
|
moveMenu(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
|
|
}));
|
|
|
|
console.log('Same level reorder:', items);
|
|
saveMenuOrder(items);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 메뉴 이동 API 호출 (계층 변경)
|
|
function moveMenu(menuId, newParentId, sortOrder) {
|
|
const payload = {
|
|
menu_id: menuId,
|
|
new_parent_id: newParentId,
|
|
sort_order: sortOrder
|
|
};
|
|
console.log('moveMenu API Request:', payload);
|
|
|
|
fetch('/api/admin/menus/move', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
'Accept': 'application/json'
|
|
},
|
|
body: JSON.stringify(payload)
|
|
})
|
|
.then(response => {
|
|
console.log('moveMenu API Response status:', response.status);
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
console.log('moveMenu API Response data:', data);
|
|
if (data.success) {
|
|
console.log('메뉴 이동 성공!');
|
|
htmx.trigger('#menu-table', 'filterSubmit');
|
|
} else {
|
|
console.error('메뉴 이동 실패:', data.message);
|
|
showToast('메뉴 이동 실패: ' + (data.message || ''), 'error');
|
|
htmx.trigger('#menu-table', 'filterSubmit');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('moveMenu API Error:', error);
|
|
showToast('메뉴 이동 중 오류 발생', 'error');
|
|
htmx.trigger('#menu-table', 'filterSubmit');
|
|
});
|
|
}
|
|
|
|
// 메뉴 순서 저장 API 호출 (같은 레벨)
|
|
function saveMenuOrder(items) {
|
|
fetch('/api/admin/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/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/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, () => {
|
|
htmx.ajax('DELETE', `/api/admin/menus/${id}/force`, {
|
|
target: '#menu-table',
|
|
swap: 'none',
|
|
headers: {
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
}
|
|
}).then(() => {
|
|
htmx.trigger('#menu-table', 'filterSubmit');
|
|
});
|
|
});
|
|
};
|
|
|
|
// 활성 토글
|
|
window.toggleActive = function(id) {
|
|
htmx.ajax('POST', `/api/admin/menus/${id}/toggle-active`, {
|
|
target: '#menu-table',
|
|
swap: 'none',
|
|
headers: {
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
}
|
|
}).then(() => {
|
|
htmx.trigger('#menu-table', 'filterSubmit');
|
|
});
|
|
};
|
|
|
|
// 숨김 토글
|
|
window.toggleHidden = function(id) {
|
|
htmx.ajax('POST', `/api/admin/menus/${id}/toggle-hidden`, {
|
|
target: '#menu-table',
|
|
swap: 'none',
|
|
headers: {
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
}
|
|
}).then(() => {
|
|
htmx.trigger('#menu-table', 'filterSubmit');
|
|
});
|
|
};
|
|
|
|
// 현재 모드 상태
|
|
let currentMode = 'normal';
|
|
|
|
// 모드 전환
|
|
window.switchMode = function(mode) {
|
|
currentMode = mode;
|
|
|
|
const normalBtn = document.getElementById('normalModeBtn');
|
|
const importBtn = document.getElementById('importModeBtn');
|
|
const importActionBtn = document.getElementById('importBtn');
|
|
const newMenuBtn = document.getElementById('newMenuBtn');
|
|
const modeDescription = document.getElementById('modeDescription');
|
|
const modeInput = document.getElementById('modeInput');
|
|
const activeFilter = document.getElementById('activeFilter');
|
|
const importFilter = document.getElementById('importFilter');
|
|
|
|
// hidden input 값 업데이트
|
|
modeInput.value = mode === 'import' ? 'import' : '';
|
|
|
|
// 버튼 스타일 업데이트
|
|
if (mode === 'import') {
|
|
// 내 메뉴 버튼 비활성화 스타일
|
|
normalBtn.classList.remove('bg-white', 'text-gray-900', 'shadow-sm');
|
|
normalBtn.classList.add('bg-gray-200', 'text-gray-500', 'hover:text-gray-700', 'hover:bg-gray-300');
|
|
// 기본에서 가져오기 버튼 활성화 스타일
|
|
importBtn.classList.add('bg-white', 'text-gray-900', 'shadow-sm');
|
|
importBtn.classList.remove('bg-gray-200', 'text-gray-500', 'hover:text-gray-700', 'hover:bg-gray-300');
|
|
|
|
// 가져오기 버튼 표시, 새 메뉴 버튼 숨김
|
|
importActionBtn.classList.remove('hidden');
|
|
importActionBtn.classList.add('flex');
|
|
newMenuBtn.classList.add('hidden');
|
|
|
|
// 필터 전환: 활성 상태 → 가져오기 상태
|
|
activeFilter.classList.add('hidden');
|
|
importFilter.classList.remove('hidden');
|
|
|
|
// 설명 변경
|
|
modeDescription.innerHTML = '기본 메뉴에서 가져올 항목을 선택하세요. 체크박스로 선택 후 <span class="font-medium text-green-600">선택 가져오기</span> 버튼을 클릭하세요.';
|
|
} else {
|
|
// 기본에서 가져오기 버튼 비활성화 스타일
|
|
importBtn.classList.remove('bg-white', 'text-gray-900', 'shadow-sm');
|
|
importBtn.classList.add('bg-gray-200', 'text-gray-500', 'hover:text-gray-700', 'hover:bg-gray-300');
|
|
// 내 메뉴 버튼 활성화 스타일
|
|
normalBtn.classList.add('bg-white', 'text-gray-900', 'shadow-sm');
|
|
normalBtn.classList.remove('bg-gray-200', 'text-gray-500', 'hover:text-gray-700', 'hover:bg-gray-300');
|
|
|
|
// 가져오기 버튼 숨김, 새 메뉴 버튼 표시
|
|
importActionBtn.classList.add('hidden');
|
|
importActionBtn.classList.remove('flex');
|
|
newMenuBtn.classList.remove('hidden');
|
|
|
|
// 필터 전환: 가져오기 상태 → 활성 상태
|
|
activeFilter.classList.remove('hidden');
|
|
importFilter.classList.add('hidden');
|
|
|
|
// 설명 복원
|
|
modeDescription.innerHTML = '드래그: 순서 변경 | <span class="font-medium text-blue-600">→ 오른쪽</span>: 하위로 이동 | <span class="font-medium text-orange-600">← 왼쪽</span>: 상위로 이동';
|
|
}
|
|
|
|
// 테이블 새로고침
|
|
htmx.trigger('#menu-table', 'filterSubmit');
|
|
};
|
|
|
|
// 가져오기 버튼 상태 업데이트 (활성화된 체크박스만 카운트)
|
|
window.updateImportButtonState = function() {
|
|
// disabled가 아닌 체크박스만 선택
|
|
const checkedBoxes = document.querySelectorAll('#menu-sortable .import-checkbox:checked:not(:disabled)');
|
|
const importBtn = document.getElementById('importBtn');
|
|
const selectedCount = document.getElementById('selectedCount');
|
|
|
|
if (importBtn && selectedCount) {
|
|
selectedCount.textContent = checkedBoxes.length;
|
|
importBtn.disabled = checkedBoxes.length === 0;
|
|
}
|
|
};
|
|
|
|
// 전체 선택/해제 (가져오기용 - 가져올 수 있는 것만)
|
|
window.toggleSelectAllImport = function(headerCheckbox) {
|
|
// disabled가 아닌 체크박스만 선택 대상
|
|
const enabledCheckboxes = document.querySelectorAll('#menu-sortable .import-checkbox:not(:disabled)');
|
|
enabledCheckboxes.forEach(cb => cb.checked = headerCheckbox.checked);
|
|
updateImportButtonState();
|
|
};
|
|
|
|
// 가져오기 상태 필터링 (클라이언트 사이드)
|
|
window.filterImportedMenus = function() {
|
|
const filterValue = document.getElementById('importStatusSelect').value;
|
|
const rows = document.querySelectorAll('#menu-sortable .menu-row');
|
|
|
|
rows.forEach(row => {
|
|
const checkbox = row.querySelector('.import-checkbox');
|
|
if (!checkbox) return;
|
|
|
|
const isImported = checkbox.disabled; // disabled면 이미 가져온 것
|
|
|
|
if (filterValue === 'available') {
|
|
// 가져올 수 있는 메뉴만 표시
|
|
row.style.display = isImported ? 'none' : '';
|
|
} else {
|
|
// 전체 표시
|
|
row.style.display = '';
|
|
}
|
|
});
|
|
|
|
// 전체 선택 체크박스 해제
|
|
const selectAllCheckbox = document.getElementById('selectAllImport');
|
|
if (selectAllCheckbox) {
|
|
selectAllCheckbox.checked = false;
|
|
}
|
|
updateImportButtonState();
|
|
};
|
|
|
|
// 선택된 기본 메뉴 가져오기 (가져올 수 있는 것만)
|
|
window.importSelectedMenus = function() {
|
|
const checkboxes = document.querySelectorAll('#menu-sortable .import-checkbox:checked:not(:disabled)');
|
|
const menuIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
|
|
|
|
if (menuIds.length === 0) {
|
|
showToast('가져올 메뉴를 선택해주세요.', 'warning');
|
|
return;
|
|
}
|
|
|
|
showConfirm(`선택한 ${menuIds.length}개 메뉴를 가져오시겠습니까?`, () => {
|
|
fetch('/api/admin/menus/copy-from-global', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
'Accept': 'application/json'
|
|
},
|
|
body: JSON.stringify({ menu_ids: menuIds })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
showToast(`${data.copied}개 메뉴가 복사되었습니다.`, 'success');
|
|
htmx.trigger('#menu-table', 'filterSubmit');
|
|
} else {
|
|
showToast('가져오기 실패: ' + (data.message || ''), 'error');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
showToast('가져오기 중 오류 발생', 'error');
|
|
});
|
|
}, { title: '메뉴 가져오기', icon: 'question' });
|
|
};
|
|
|
|
</script>
|
|
<script src="{{ asset('js/menu-tree.js') }}"></script>
|
|
@endpush
|