From 3f34e84bfca9832030e613d3356fbad9ef7a53c8 Mon Sep 17 00:00:00 2001 From: kent Date: Mon, 22 Dec 2025 10:51:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(menus):=20=EB=A9=94=EB=89=B4=20=EC=9D=BC?= =?UTF-8?q?=EA=B4=84=20=EC=84=A0=ED=83=9D=20=EC=82=AD=EC=A0=9C/=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC/=EC=98=81=EA=B5=AC=EC=82=AD=EC=A0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GlobalMenu 모델에 getSection(), getMeta() 메서드 추가 (import 모드 500 에러 해결) - table.blade.php: normal 모드에서 체크박스 + 드래그 핸들 분리 - index.blade.php: 선택 삭제/복구/영구삭제 버튼 및 JS 함수 추가 - MenuController: bulkDelete, bulkRestore, bulkForceDelete API 추가 - routes/api.php: bulk 엔드포인트 3개 등록 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../Controllers/Api/Admin/MenuController.php | 105 +++++++++ app/Models/Commons/GlobalMenu.php | 16 ++ resources/views/menus/index.blade.php | 215 ++++++++++++++++++ .../views/menus/partials/table.blade.php | 23 +- routes/api.php | 5 + 5 files changed, 361 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/Api/Admin/MenuController.php b/app/Http/Controllers/Api/Admin/MenuController.php index 07b5850e..d3d03fac 100644 --- a/app/Http/Controllers/Api/Admin/MenuController.php +++ b/app/Http/Controllers/Api/Admin/MenuController.php @@ -450,4 +450,109 @@ public function copyFromGlobal(Request $request): JsonResponse ], 500); } } + + /** + * 선택 삭제 (일괄 soft delete) + */ + public function bulkDelete(Request $request): JsonResponse + { + $validated = $request->validate([ + 'menu_ids' => 'required|array|min:1', + 'menu_ids.*' => 'required|integer', + ]); + + try { + $deleted = 0; + foreach ($validated['menu_ids'] as $menuId) { + if ($this->menuService->deleteMenu($menuId)) { + $deleted++; + } + } + + return response()->json([ + 'success' => true, + 'message' => "{$deleted}개 메뉴가 삭제되었습니다.", + 'deleted' => $deleted, + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => '메뉴 삭제에 실패했습니다: '.$e->getMessage(), + ], 500); + } + } + + /** + * 선택 복원 (일괄 restore) + */ + public function bulkRestore(Request $request): JsonResponse + { + $validated = $request->validate([ + 'menu_ids' => 'required|array|min:1', + 'menu_ids.*' => 'required|integer', + ]); + + try { + $restored = 0; + foreach ($validated['menu_ids'] as $menuId) { + $this->menuService->restoreMenu($menuId); + $restored++; + } + + return response()->json([ + 'success' => true, + 'message' => "{$restored}개 메뉴가 복원되었습니다.", + 'restored' => $restored, + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => '메뉴 복원에 실패했습니다: '.$e->getMessage(), + ], 500); + } + } + + /** + * 선택 영구삭제 (일괄 force delete, 슈퍼관리자 전용) + */ + public function bulkForceDelete(Request $request): JsonResponse + { + // 슈퍼관리자 권한 체크 + if (! auth()->user()?->is_super_admin) { + return response()->json([ + 'success' => false, + 'message' => '권한이 없습니다.', + ], 403); + } + + $validated = $request->validate([ + 'menu_ids' => 'required|array|min:1', + 'menu_ids.*' => 'required|integer', + ]); + + try { + $deleted = 0; + $totalPermissions = 0; + + foreach ($validated['menu_ids'] as $menuId) { + $result = $this->menuService->forceDeleteMenu($menuId); + if ($result['success']) { + $deleted++; + $totalPermissions += count($result['deleted_permissions'] ?? []); + } + } + + return response()->json([ + 'success' => true, + 'message' => "{$deleted}개 메뉴가 영구 삭제되었습니다. (연관 권한 {$totalPermissions}개 삭제)", + 'deleted' => $deleted, + 'deleted_permissions' => $totalPermissions, + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => '메뉴 영구삭제에 실패했습니다: '.$e->getMessage(), + ], 500); + } + } } diff --git a/app/Models/Commons/GlobalMenu.php b/app/Models/Commons/GlobalMenu.php index 05cdc675..79ecd148 100644 --- a/app/Models/Commons/GlobalMenu.php +++ b/app/Models/Commons/GlobalMenu.php @@ -132,6 +132,22 @@ public function getLevel(): int return 3; } + /** + * 메뉴 섹션 조회 (GlobalMenu는 기본값 'main' 반환) + */ + public function getSection(): string + { + return 'main'; + } + + /** + * meta 데이터 조회 (GlobalMenu는 빈 배열 반환) + */ + public function getMeta(?string $key = null, mixed $default = null): mixed + { + return $key === null ? [] : $default; + } + /** * 계층 구조로 정렬된 전체 메뉴 트리 조회 */ diff --git a/resources/views/menus/index.blade.php b/resources/views/menus/index.blade.php index effced17..a6a9582f 100644 --- a/resources/views/menus/index.blade.php +++ b/resources/views/menus/index.blade.php @@ -36,6 +36,38 @@ class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transitio 선택 가져오기 (0) + + + + + @if(auth()->user()?->is_super_admin) + + + @endif @endif + 새 메뉴 @@ -651,6 +683,9 @@ function saveMenuOrder(items) { 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'); @@ -674,6 +709,11 @@ function saveMenuOrder(items) { 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'); @@ -693,6 +733,20 @@ function saveMenuOrder(items) { 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'); @@ -815,6 +869,167 @@ function saveMenuOrder(items) { } }); + // ===== 일괄 작업 (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'); + }); + }); + }; + @endpush diff --git a/resources/views/menus/partials/table.blade.php b/resources/views/menus/partials/table.blade.php index 5f1403b0..0bec7371 100644 --- a/resources/views/menus/partials/table.blade.php +++ b/resources/views/menus/partials/table.blade.php @@ -3,7 +3,7 @@ - {{-- 체크박스 (가져오기 모드일 때만 표시) --}} + {{-- 체크박스 --}} @if($importMode ?? false) @else + + @endif + {{-- 드래그 핸들 (일반 모드만) --}} + @if(!($importMode ?? false)) @endif @@ -33,7 +42,7 @@ class="w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-green-500"> data-parent-id="{{ $menu->parent_id ?? '' }}" data-sort-order="{{ $menu->sort_order ?? 0 }}" data-depth="{{ $menu->depth ?? 0 }}"> - {{-- 체크박스 또는 드래그 핸들 --}} + {{-- 체크박스 --}} @if($importMode ?? false) @else + + {{-- 드래그 핸들 --}} @empty - diff --git a/routes/api.php b/routes/api.php index 140be070..faaecd8f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -124,6 +124,11 @@ Route::get('/available-global', [MenuController::class, 'availableGlobal'])->name('availableGlobal'); Route::post('/copy-from-global', [MenuController::class, 'copyFromGlobal'])->name('copyFromGlobal'); + // 일괄 작업 (bulk actions) + Route::post('/bulk-delete', [MenuController::class, 'bulkDelete'])->name('bulkDelete'); + Route::post('/bulk-restore', [MenuController::class, 'bulkRestore'])->name('bulkRestore'); + Route::middleware('super.admin')->post('/bulk-force-delete', [MenuController::class, 'bulkForceDelete'])->name('bulkForceDelete'); + // 동적 경로는 나중에 정의 Route::get('/', [MenuController::class, 'index'])->name('index'); Route::post('/', [MenuController::class, 'store'])->name('store');
+ + No. @if($menu->is_imported ?? false) @@ -49,6 +58,14 @@ class="import-checkbox w-4 h-4 rounded border-gray-300 text-green-600 focus:ring @endif + + @if(!$menu->deleted_at) @@ -239,7 +256,7 @@ class="inline-flex items-center px-2 py-1 text-xs font-medium rounded bg-red-100
+ 메뉴가 없습니다.