- depth=0 메뉴에만 이동 버튼(↕) 표시 - 클릭 시 드롭다운으로 상단/하단 이동 선택 - 기존 reorder API 재사용하여 sort_order 일괄 변경
909 lines
39 KiB
PHP
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
|