feat(menus): 메뉴 일괄 선택 삭제/복구/영구삭제 기능 추가

- 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 <noreply@anthropic.com>
This commit is contained in:
2025-12-22 10:51:06 +09:00
parent 20d92ea51b
commit 3f34e84bfc
5 changed files with 361 additions and 3 deletions

View File

@@ -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);
}
}
}

View File

@@ -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;
}
/**
* 계층 구조로 정렬된 전체 메뉴 트리 조회
*/

View File

@@ -36,6 +36,38 @@ class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transitio
</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">
+ 메뉴
@@ -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');
});
});
};
</script>
<script src="{{ asset('js/menu-tree.js') }}"></script>
@endpush

View File

@@ -3,7 +3,7 @@
<table class="min-w-full">
<thead class="bg-gray-50 border-b">
<tr>
{{-- 체크박스 (가져오기 모드일 때만 표시) --}}
{{-- 체크박스 --}}
@if($importMode ?? false)
<th class="px-2 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider w-10">
<input type="checkbox"
@@ -12,6 +12,15 @@
class="w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-green-500">
</th>
@else
<th class="px-2 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider w-10">
<input type="checkbox"
id="selectAllMenu"
onchange="toggleSelectAllMenu(this)"
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
</th>
@endif
{{-- 드래그 핸들 (일반 모드만) --}}
@if(!($importMode ?? false))
<th class="px-2 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider w-10"></th>
@endif
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">No.</th>
@@ -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)
<td class="px-2 py-2 whitespace-nowrap text-center">
@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
</td>
@else
<td class="px-2 py-2 whitespace-nowrap text-center">
<input type="checkbox"
value="{{ $menu->id }}"
data-deleted="{{ $menu->deleted_at ? '1' : '0' }}"
onchange="updateBulkButtonState()"
class="menu-checkbox w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
</td>
{{-- 드래그 핸들 --}}
<td class="px-2 py-2 whitespace-nowrap text-center">
@if(!$menu->deleted_at)
<span class="drag-handle cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600">
@@ -239,7 +256,7 @@ class="inline-flex items-center px-2 py-1 text-xs font-medium rounded bg-red-100
</tr>
@empty
<tr>
<td colspan="11" class="px-4 py-3 text-center text-gray-500">
<td colspan="{{ ($importMode ?? false) ? '11' : '12' }}" class="px-4 py-3 text-center text-gray-500">
메뉴가 없습니다.
</td>
</tr>

View File

@@ -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');