Files
sam-manage/resources/views/menus/index.blade.php
김보곤 83f10552df feat: [menus] 최상위 그룹 상단/하단 이동 버튼 추가
- depth=0 메뉴에만 이동 버튼(↕) 표시
- 클릭 시 드롭다운으로 상단/하단 이동 선택
- 기존 reorder API 재사용하여 sort_order 일괄 변경
2026-02-28 08:24:36 +09:00

909 lines
39 KiB
PHP

@extends('layouts.app')
@section('title', '메뉴 관리')
@section('content')
<!-- 페이지 헤더 -->
<div class="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-800">메뉴 관리</h1>
<p class="text-sm text-gray-500 mt-1 hidden sm:block" id="modeDescription">
드래그: 순서 변경 | <span class="font-medium text-blue-600"> 오른쪽</span>: 하위로 이동 | <span class="font-medium text-orange-600"> 왼쪽</span>: 상위로 이동 | <span class="font-medium text-purple-600">상위 메뉴 드래그 하위 메뉴 자동 포함</span>
</p>
</div>
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
@if(session('selected_tenant_id'))
<!-- 모드 전환 버튼 -->
<div class="flex items-center bg-gray-200 rounded-lg p-1 w-full sm:w-auto">
<button onclick="switchMode('normal')"
id="normalModeBtn"
class="mode-btn flex-1 sm:flex-none 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 flex-1 sm:flex-none 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 w-full sm:w-auto text-center"
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>
<!-- 선택 삭제 버튼 (normal 모드에서만 표시) -->
<button onclick="bulkDelete()"
id="bulkDeleteBtn"
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto text-center"
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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
선택 삭제 (<span id="deleteCount">0</span>)
</button>
<!-- 선택 복원 버튼 (normal 모드에서만 표시) -->
<button onclick="bulkRestore()"
id="bulkRestoreBtn"
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto text-center"
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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
선택 복원 (<span id="restoreCount">0</span>)
</button>
@if(auth()->user()?->is_super_admin)
<!-- 선택 영구삭제 버튼 (슈퍼관리자만, normal 모드에서만 표시) -->
<button onclick="bulkForceDelete()"
id="bulkForceDeleteBtn"
class="bg-gray-800 hover:bg-gray-900 text-white px-4 py-2 rounded-lg transition flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto text-center"
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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
선택 영구삭제 (<span id="forceDeleteCount">0</span>)
</button>
@endif
@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 flex-1 sm:flex-none text-center">
+ 메뉴
</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 justify-center gap-2 flex-1 sm:flex-none">
<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>
<span class="hidden sm:inline">기본 메뉴 관리</span>
<span class="sm:hidden">기본 메뉴</span>
</a>
@endif
</div>
</div>
<!-- 필터 영역 -->
<x-filter-collapsible id="filterForm">
<form id="filterForm" class="flex flex-wrap gap-2 sm:gap-4">
<!-- 모드 (hidden) -->
<input type="hidden" name="mode" id="modeInput" value="">
<!-- 페이지당 항목 (쿠키에서 로드, 기본값 10) -->
<input type="hidden" name="per_page" id="perPageInput" value="10">
<input type="hidden" name="page" id="pageInput" value="1">
<!-- 검색 -->
<div class="flex-1 min-w-0 w-full sm:w-auto">
<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-full sm: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-full sm: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 w-full sm:w-auto">
검색
</button>
</form>
</x-filter-collapsible>
{{-- 쿠키에서 per_page 로드 (htmx:configRequest 이벤트로 요청 직전에 적용) --}}
<script>
(function() {
// 쿠키 값 읽기 함수
function getCookieValue(name) {
const nameEQ = name + "=";
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) == ' ') c = c.substring(1, c.length);
if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
}
return null;
}
// 즉시 hidden input 업데이트 시도
const perPageInput = document.getElementById('perPageInput');
if (perPageInput) {
const savedPerPage = getCookieValue('pagination_per_page') || '10';
perPageInput.value = savedPerPage;
}
// HTMX 요청 직전에 per_page 값을 쿠키에서 읽어서 적용 (안전장치)
document.addEventListener('htmx:configRequest', function(evt) {
// menu-table 관련 요청인 경우에만 처리
if (evt.detail.elt && evt.detail.elt.id === 'menu-table') {
const savedPerPage = getCookieValue('pagination_per_page') || '10';
// 요청 파라미터에 per_page 값 설정/덮어쓰기
evt.detail.parameters['per_page'] = savedPerPage;
}
});
})();
</script>
<!-- 전체 접기/펼치기 -->
<div class="flex items-center gap-2 mb-3">
<button type="button" onclick="toggleAllChildren(true)"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition">
<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="M20 12H4" />
</svg>
전체 접기
</button>
<button type="button" onclick="toggleAllChildren(false)"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition">
<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="M12 4v16m8-8H4" />
</svg>
전체 펼치기
</button>
</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;
}
/* 그룹 드래그: 숨김 상태 (드래그 중 나머지 체크된 항목) */
.group-drag-hidden {
display: none !important;
}
/* 그룹 드래그: 항목 수 뱃지 */
.group-drag-badge {
display: inline-flex;
align-items: center;
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
margin-left: 8px;
white-space: nowrap;
animation: badge-pulse 1s ease-in-out infinite;
vertical-align: middle;
}
@keyframes badge-pulse {
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
@push('scripts')
<script>
// 폼 제출 시 HTMX 이벤트 트리거 (DOMContentLoaded 또는 HTMX 네비게이션 후 실행)
(function() {
function initFilterForm() {
const filterForm = document.getElementById('filterForm');
if (filterForm && !filterForm._menuInitialized) {
filterForm.addEventListener('submit', function(e) {
e.preventDefault();
htmx.trigger('#menu-table', 'filterSubmit');
});
filterForm._menuInitialized = true;
}
}
// 즉시 실행 (일반 페이지 로드용)
initFilterForm();
// DOMContentLoaded 이벤트 (안전장치)
document.addEventListener('DOMContentLoaded', initFilterForm);
})();
// HTMX 응답 처리 + SortableJS 초기화 (menu-table 내부 갱신용)
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'menu-table') {
// 테이블 로드 후 SortableJS 초기화 (전역 함수 사용)
if (typeof initMenuSortable === 'function') {
initMenuSortable();
}
}
});
// 삭제 확인
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, () => {
fetch(`/api/admin/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-blue-500');
const thumb = btn.querySelector('span');
// 즉시 UI 토글 (낙관적 업데이트)
btn.classList.toggle('bg-blue-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/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-blue-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-blue-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/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);
});
};
// 현재 모드 상태
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 bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
const bulkRestoreBtn = document.getElementById('bulkRestoreBtn');
const bulkForceDeleteBtn = document.getElementById('bulkForceDeleteBtn');
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');
// bulk 버튼들 숨김
if (bulkDeleteBtn) bulkDeleteBtn.classList.add('hidden');
if (bulkRestoreBtn) bulkRestoreBtn.classList.add('hidden');
if (bulkForceDeleteBtn) bulkForceDeleteBtn.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');
// bulk 버튼들 표시 (disabled 상태로)
if (bulkDeleteBtn) {
bulkDeleteBtn.classList.remove('hidden');
bulkDeleteBtn.classList.add('flex');
}
if (bulkRestoreBtn) {
bulkRestoreBtn.classList.remove('hidden');
bulkRestoreBtn.classList.add('flex');
}
if (bulkForceDeleteBtn) {
bulkForceDeleteBtn.classList.remove('hidden');
bulkForceDeleteBtn.classList.add('flex');
}
// 필터 전환: 가져오기 상태 → 활성 상태
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>: 상위로 이동 | <span class="font-medium text-purple-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' });
};
// 옵션 팝오버 토글
window.toggleOptionsPopover = function(menuId) {
const popover = document.getElementById(`options-popover-${menuId}`);
if (!popover) return;
// 다른 팝오버 모두 닫기
document.querySelectorAll('.options-popover').forEach(p => {
if (p.id !== `options-popover-${menuId}`) {
p.classList.add('hidden');
}
});
// 현재 팝오버 토글
popover.classList.toggle('hidden');
};
// 외부 클릭 시 팝오버 닫기
document.addEventListener('click', function(e) {
if (!e.target.closest('.options-popover') && !e.target.closest('[onclick*="toggleOptionsPopover"]')) {
document.querySelectorAll('.options-popover').forEach(p => {
p.classList.add('hidden');
});
}
});
// 상위 메뉴 체크 시 하위 메뉴도 선택/해제
window.toggleMenuChildren = function(checkbox) {
const row = checkbox.closest('tr.menu-row');
if (!row) return;
const menuId = row.getAttribute('data-menu-id');
const checked = checkbox.checked;
function setChildren(parentId) {
const children = document.querySelectorAll(`tr.menu-row[data-parent-id="${parentId}"]`);
children.forEach(child => {
const cb = child.querySelector('input[type="checkbox"]:not(:disabled)');
if (cb) cb.checked = checked;
const childId = child.getAttribute('data-menu-id');
setChildren(childId);
});
}
setChildren(menuId);
};
// ===== 일괄 작업 (Bulk Actions) =====
// 전체 선택/해제 (normal 모드용)
window.toggleSelectAllMenu = function(headerCheckbox) {
const checkboxes = document.querySelectorAll('#menu-sortable .menu-checkbox');
checkboxes.forEach(cb => cb.checked = headerCheckbox.checked);
updateBulkButtonState();
};
// 선택 상태에 따라 버튼 활성화/비활성화
window.updateBulkButtonState = function() {
const checkedBoxes = document.querySelectorAll('#menu-sortable .menu-checkbox:checked');
const bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
const bulkRestoreBtn = document.getElementById('bulkRestoreBtn');
const bulkForceDeleteBtn = document.getElementById('bulkForceDeleteBtn');
// 삭제된 항목과 활성 항목 분류
let activeCount = 0;
let deletedCount = 0;
checkedBoxes.forEach(cb => {
if (cb.dataset.deleted === '1') {
deletedCount++;
} else {
activeCount++;
}
});
// 삭제 버튼: 활성 항목이 있을 때만
if (bulkDeleteBtn) {
document.getElementById('deleteCount').textContent = activeCount;
bulkDeleteBtn.disabled = activeCount === 0;
}
// 복원 버튼: 삭제된 항목이 있을 때만
if (bulkRestoreBtn) {
document.getElementById('restoreCount').textContent = deletedCount;
bulkRestoreBtn.disabled = deletedCount === 0;
}
// 영구삭제 버튼: 삭제된 항목이 있을 때만
if (bulkForceDeleteBtn) {
document.getElementById('forceDeleteCount').textContent = deletedCount;
bulkForceDeleteBtn.disabled = deletedCount === 0;
}
};
// 선택 삭제
window.bulkDelete = function() {
const checkboxes = document.querySelectorAll('#menu-sortable .menu-checkbox:checked');
const menuIds = Array.from(checkboxes)
.filter(cb => cb.dataset.deleted !== '1')
.map(cb => parseInt(cb.value));
if (menuIds.length === 0) {
showToast('삭제할 메뉴를 선택해주세요.', 'warning');
return;
}
showDeleteConfirm(`${menuIds.length}개 메뉴`, () => {
fetch('/api/admin/menus/bulk-delete', {
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.message || `${data.deleted}개 메뉴가 삭제되었습니다.`, 'success');
htmx.trigger('#menu-table', 'filterSubmit');
} else {
showToast('삭제 실패: ' + (data.message || ''), 'error');
}
})
.catch(error => {
console.error('Error:', error);
showToast('삭제 중 오류 발생', 'error');
});
});
};
// 선택 복원
window.bulkRestore = function() {
const checkboxes = document.querySelectorAll('#menu-sortable .menu-checkbox:checked');
const menuIds = Array.from(checkboxes)
.filter(cb => cb.dataset.deleted === '1')
.map(cb => parseInt(cb.value));
if (menuIds.length === 0) {
showToast('복원할 메뉴를 선택해주세요.', 'warning');
return;
}
showConfirm(`선택한 ${menuIds.length}개 메뉴를 복원하시겠습니까?`, () => {
fetch('/api/admin/menus/bulk-restore', {
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.message || `${data.restored}개 메뉴가 복원되었습니다.`, 'success');
htmx.trigger('#menu-table', 'filterSubmit');
} else {
showToast('복원 실패: ' + (data.message || ''), 'error');
}
})
.catch(error => {
console.error('Error:', error);
showToast('복원 중 오류 발생', 'error');
});
}, { title: '메뉴 복원', icon: 'question' });
};
// 선택 영구삭제
window.bulkForceDelete = function() {
const checkboxes = document.querySelectorAll('#menu-sortable .menu-checkbox:checked');
const menuIds = Array.from(checkboxes)
.filter(cb => cb.dataset.deleted === '1')
.map(cb => parseInt(cb.value));
if (menuIds.length === 0) {
showToast('영구삭제할 메뉴를 선택해주세요.', 'warning');
return;
}
showPermanentDeleteConfirm(`${menuIds.length}개 메뉴`, () => {
fetch('/api/admin/menus/bulk-force-delete', {
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.message || `${data.deleted}개 메뉴가 영구 삭제되었습니다.`, 'success');
htmx.trigger('#menu-table', 'filterSubmit');
} else {
showToast('영구삭제 실패: ' + (data.message || ''), 'error');
}
})
.catch(error => {
console.error('Error:', error);
showToast('영구삭제 중 오류 발생', 'error');
});
});
};
</script>
<script src="{{ asset('js/menu-tree.js') }}"></script>
@endpush