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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계층 구조로 정렬된 전체 메뉴 트리 조회
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user