feat: Global Menu 계층 이동 기능 추가 및 Role 삭제 오류 수정

Global Menu indent/outdent 기능:
- GlobalMenuController에 move() 메서드 추가
- MenuService에 moveGlobalMenu(), isGlobalDescendant(), reorderGlobalSiblings(), compactGlobalSiblings() 추가
- global-index.blade.php에 드래그 계층 이동 JavaScript 추가
- routes/api.php에 POST /move 라우트 추가

Role 삭제 500 에러 수정:
- config/auth.php에 api guard 추가 (Spatie Permission getModelForGuard 오류 해결)
- RoleService에서 불필요한 users()->detach() 제거 (FK CASCADE 처리)
- RoleController에서 HTMX 요청 시 View 직접 반환 (JSON 파싱 에러 해결)
- index.blade.php에서 불필요한 afterSwap 핸들러 제거

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-20 22:43:48 +09:00
parent 6525bfd715
commit 00a4920b7a
8 changed files with 416 additions and 27 deletions

View File

@@ -277,6 +277,37 @@ public function toggleHidden(Request $request, int $id): JsonResponse
}
}
/**
* 글로벌 메뉴 계층 이동 (인덴트/아웃덴트)
*/
public function move(Request $request): JsonResponse
{
$validated = $request->validate([
'menu_id' => 'required|integer',
'new_parent_id' => 'nullable|integer',
'sort_order' => 'required|integer|min:1',
]);
try {
$result = $this->menuService->moveGlobalMenu(
$validated['menu_id'],
$validated['new_parent_id'],
$validated['sort_order']
);
if (! $result['success']) {
return response()->json($result, 400);
}
return response()->json($result);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '글로벌 메뉴 이동에 실패했습니다: '.$e->getMessage(),
], 500);
}
}
/**
* 글로벌 메뉴 순서 변경
*/

View File

@@ -18,7 +18,7 @@ public function __construct(
/**
* 역할 목록 조회
*/
public function index(Request $request): JsonResponse
public function index(Request $request): JsonResponse|\Illuminate\Contracts\View\View
{
$roles = $this->roleService->getRoles(
$request->all(),
@@ -27,11 +27,7 @@ public function index(Request $request): JsonResponse
// HTMX 요청 시 HTML 반환
if ($request->header('HX-Request')) {
$html = view('roles.partials.table', compact('roles'))->render();
return response()->json([
'html' => $html,
]);
return view('roles.partials.table', compact('roles'));
}
// 일반 요청 시 JSON 반환

View File

@@ -836,6 +836,105 @@ public function reorderGlobalMenus(array $items): bool
});
}
/**
* 글로벌 메뉴 이동 (계층 구조 변경 - 인덴트/아웃덴트)
* - 다른 부모 아래로 이동 가능
* - 하위 메뉴는 자동으로 따라감
* - 순환 참조 방지
*/
public function moveGlobalMenu(int $menuId, ?int $newParentId, int $sortOrder): array
{
$menu = GlobalMenu::find($menuId);
if (! $menu) {
return ['success' => false, 'message' => '글로벌 메뉴를 찾을 수 없습니다.'];
}
// 순환 참조 방지: 자신의 하위 메뉴로 이동 불가
if ($newParentId !== null && $this->isGlobalDescendant($menuId, $newParentId)) {
return ['success' => false, 'message' => '자신의 하위 메뉴로 이동할 수 없습니다.'];
}
// 자기 자신을 부모로 설정 방지
if ($newParentId === $menuId) {
return ['success' => false, 'message' => '자기 자신을 부모로 설정할 수 없습니다.'];
}
return \DB::transaction(function () use ($menu, $newParentId, $sortOrder) {
$oldParentId = $menu->parent_id;
// 부모 변경
$menu->parent_id = $newParentId;
$menu->sort_order = $sortOrder;
$menu->save();
// 같은 부모의 다른 메뉴들 순서 재정렬
$this->reorderGlobalSiblings($newParentId, $menu->id, $sortOrder);
// 이전 부모의 메뉴들도 순서 재정렬 (빈 자리 채우기)
if ($oldParentId !== $newParentId) {
$this->compactGlobalSiblings($oldParentId);
}
return ['success' => true, 'message' => '글로벌 메뉴가 이동되었습니다.'];
});
}
/**
* 특정 글로벌 메뉴가 다른 메뉴의 하위인지 확인 (순환 참조 방지)
*/
private function isGlobalDescendant(int $ancestorId, int $menuId): bool
{
$menu = GlobalMenu::find($menuId);
while ($menu) {
if ($menu->id === $ancestorId) {
return true;
}
$menu = $menu->parent;
}
return false;
}
/**
* 같은 부모의 글로벌 형제 메뉴들 순서 재정렬
*/
private function reorderGlobalSiblings(?int $parentId, int $excludeId, int $insertAt): void
{
$siblings = GlobalMenu::where('parent_id', $parentId)
->where('id', '!=', $excludeId)
->orderBy('sort_order')
->get();
$order = 1;
foreach ($siblings as $sibling) {
if ($order === $insertAt) {
$order++; // 삽입 위치 건너뛰기
}
if ($sibling->sort_order !== $order) {
$sibling->update(['sort_order' => $order]);
}
$order++;
}
}
/**
* 글로벌 형제 메뉴들 순서 압축 (빈 자리 채우기)
*/
private function compactGlobalSiblings(?int $parentId): void
{
$siblings = GlobalMenu::where('parent_id', $parentId)
->orderBy('sort_order')
->get();
$order = 1;
foreach ($siblings as $sibling) {
if ($sibling->sort_order !== $order) {
$sibling->update(['sort_order' => $order]);
}
$order++;
}
}
/**
* 글로벌 메뉴 목록 조회 (가져오기 상태 포함)
* - 모든 글로벌 메뉴 반환

View File

@@ -183,8 +183,7 @@ public function deleteRole(int $id): bool
// 권한 연결 해제
$role->permissions()->detach();
// 사용자 연결 해제
$role->users()->detach();
// 사용자 연결은 FK CASCADE가 자동 처리 (model_has_roles.role_id → ON DELETE CASCADE)
return $role->delete();
}

View File

@@ -40,6 +40,11 @@
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'sanctum',
'provider' => 'users',
],
],
/*

View File

@@ -83,6 +83,40 @@ class="bg-white rounded-lg shadow-sm overflow-hidden">
background: linear-gradient(135deg, #9333ea, #7c3aed);
color: white;
}
.drag-indicator.indent {
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
}
.drag-indicator.outdent {
background: linear-gradient(135deg, #f97316, #ea580c);
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); }
}
.sortable-fallback {
opacity: 0.9;
@@ -92,10 +126,27 @@ class="bg-white rounded-lg shadow-sm overflow-hidden">
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;
}
</style>
@endpush
@@ -110,14 +161,80 @@ class="bg-white rounded-lg shadow-sm overflow-hidden">
});
// HTMX 응답 처리 + SortableJS 초기화
// 서버가 HTML을 직접 반환하므로 HTMX가 자동으로 swap 처리
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'menu-table') {
// 테이블 로드 후 SortableJS 초기화
initGlobalMenuSortable();
}
});
// 드래그 상태 관리
let dragStartX = 0;
let dragIndicator = null;
let currentDragItem = null;
let lastHighlightedRow = null;
const INDENT_THRESHOLD = 40; // px - 인덴트 임계값
// 드래그 인디케이터 생성
function createDragIndicator() {
const indicator = document.createElement('div');
indicator.className = 'drag-indicator reorder';
indicator.innerHTML = '↕️ 순서 변경';
indicator.style.display = 'none';
document.body.appendChild(indicator);
return indicator;
}
// 드래그 인디케이터 업데이트
function updateDragIndicator(deltaX, mouseX, mouseY, canIndent, canOutdent, targetName) {
if (!dragIndicator) return;
dragIndicator.style.display = 'block';
dragIndicator.style.left = (mouseX + 15) + 'px';
dragIndicator.style.top = (mouseY - 15) + 'px';
dragIndicator.classList.remove('indent', 'outdent', 'reorder');
if (deltaX > INDENT_THRESHOLD && canIndent) {
dragIndicator.classList.add('indent');
dragIndicator.innerHTML = `→ <b>${targetName}</b>의 하위로`;
} else if (deltaX < -INDENT_THRESHOLD && canOutdent) {
dragIndicator.classList.add('outdent');
dragIndicator.innerHTML = '← 상위 레벨로';
} else {
dragIndicator.classList.add('reorder');
dragIndicator.innerHTML = '↕️ 순서 변경';
}
}
// 행 하이라이트 업데이트
function updateRowHighlight(prevRow, draggedRow, deltaX, canIndent, canOutdent) {
if (lastHighlightedRow) {
lastHighlightedRow.classList.remove('drag-target-indent', 'drag-target-outdent');
}
if (deltaX > INDENT_THRESHOLD && canIndent && prevRow) {
prevRow.classList.add('drag-target-indent');
lastHighlightedRow = prevRow;
} else if (deltaX < -INDENT_THRESHOLD && canOutdent && draggedRow) {
draggedRow.classList.add('drag-target-outdent');
lastHighlightedRow = draggedRow;
} else {
lastHighlightedRow = null;
}
}
// 드래그 인디케이터 제거
function removeDragIndicator() {
if (dragIndicator) {
dragIndicator.remove();
dragIndicator = null;
}
if (lastHighlightedRow) {
lastHighlightedRow.classList.remove('drag-target-indent', 'drag-target-outdent');
lastHighlightedRow = null;
}
}
// SortableJS 초기화 함수
function initGlobalMenuSortable() {
const tbody = document.getElementById('menu-sortable');
@@ -127,6 +244,42 @@ function initGlobalMenuSortable() {
tbody.sortableInstance.destroy();
}
// 드래그 중 마우스 이동 핸들러
function onDragMove(e) {
if (!currentDragItem) return;
const mouseX = e.clientX || e.touches?.[0]?.clientX || 0;
const mouseY = e.clientY || e.touches?.[0]?.clientY || 0;
const deltaX = mouseX - dragStartX;
const rows = Array.from(tbody.querySelectorAll('tr.menu-row'));
const draggedIndex = rows.indexOf(currentDragItem);
// 위 행 찾기
let prevRow = null;
for (let i = draggedIndex - 1; i >= 0; i--) {
if (rows[i] !== currentDragItem && !rows[i].classList.contains('sortable-ghost')) {
prevRow = rows[i];
break;
}
}
const oldParentIdRaw = currentDragItem.dataset.parentId;
const oldPid = oldParentIdRaw === '' ? null : (oldParentIdRaw ? parseInt(oldParentIdRaw) : null);
const canIndent = prevRow !== null;
const canOutdent = oldPid !== null;
let targetName = '';
if (prevRow && canIndent) {
const nameCell = prevRow.querySelector('td:nth-child(3) span');
targetName = nameCell?.textContent?.trim() || '위 항목';
}
updateDragIndicator(deltaX, mouseX, mouseY, canIndent, canOutdent, targetName);
updateRowHighlight(prevRow, currentDragItem, deltaX, canIndent, canOutdent);
}
// SortableJS 초기화 - 노션 스타일 인덴트
tbody.sortableInstance = new Sortable(tbody, {
handle: '.drag-handle',
animation: 150,
@@ -135,20 +288,135 @@ function initGlobalMenuSortable() {
fallbackOnBody: true,
ghostClass: 'bg-purple-50',
chosenClass: 'bg-purple-100',
dragClass: 'shadow-lg',
onStart: function(evt) {
dragStartX = evt.originalEvent?.clientX || evt.originalEvent?.touches?.[0]?.clientX || 0;
currentDragItem = evt.item;
dragIndicator = createDragIndicator();
document.body.classList.add('is-dragging');
document.addEventListener('mousemove', onDragMove);
document.addEventListener('touchmove', onDragMove);
},
onEnd: function(evt) {
const rows = Array.from(tbody.querySelectorAll('tr.menu-row'));
const items = rows.map((row, index) => ({
id: parseInt(row.dataset.menuId),
sort_order: index + 1
}));
document.removeEventListener('mousemove', onDragMove);
document.removeEventListener('touchmove', onDragMove);
removeDragIndicator();
currentDragItem = null;
document.body.classList.remove('is-dragging');
saveGlobalMenuOrder(items);
const movedItem = evt.item;
const menuId = parseInt(movedItem.dataset.menuId);
const oldParentIdRaw = movedItem.dataset.parentId;
const oldPid = oldParentIdRaw === '' ? null : (oldParentIdRaw ? parseInt(oldParentIdRaw) : null);
const rows = Array.from(tbody.querySelectorAll('tr.menu-row'));
const newIndex = rows.indexOf(movedItem);
const dragEndX = evt.originalEvent?.clientX || evt.originalEvent?.changedTouches?.[0]?.clientX || dragStartX;
const deltaX = dragEndX - dragStartX;
let newParentId = oldPid;
let sortOrder = 1;
let action = 'reorder';
// 오른쪽으로 이동 → 인덴트 (위 항목의 자식으로)
if (deltaX > INDENT_THRESHOLD && newIndex > 0) {
const prevRow = rows[newIndex - 1];
if (prevRow) {
const prevId = parseInt(prevRow.dataset.menuId);
if (prevId !== menuId) {
newParentId = prevId;
action = 'indent';
}
}
}
// 왼쪽으로 이동 → 아웃덴트 (부모의 형제로)
else if (deltaX < -INDENT_THRESHOLD && oldPid !== null) {
const parentRow = rows.find(r => parseInt(r.dataset.menuId) === oldPid);
if (parentRow) {
const grandParentIdRaw = parentRow.dataset.parentId;
newParentId = grandParentIdRaw === '' ? null : (grandParentIdRaw ? parseInt(grandParentIdRaw) : null);
} else {
newParentId = null;
}
action = 'outdent';
}
const parentChanged = oldPid !== newParentId;
if (parentChanged) {
// 새 부모 하위에서의 순서 계산
const existingSiblings = rows.filter(row => {
if (row === movedItem) return false;
const rowParentId = row.dataset.parentId || '';
const rowPid = rowParentId === '' ? null : parseInt(rowParentId);
return rowPid === newParentId;
});
let siblingsBeforeMe = 0;
for (const sibling of existingSiblings) {
if (rows.indexOf(sibling) < newIndex) {
siblingsBeforeMe++;
}
}
sortOrder = siblingsBeforeMe + 1;
moveGlobalMenu(menuId, newParentId, sortOrder);
} else {
// 같은 레벨 내 순서 변경
const siblingRows = rows.filter(row => {
const rowParentId = row.dataset.parentId || '';
const rowPid = rowParentId === '' ? null : parseInt(rowParentId);
return rowPid === oldPid;
});
const items = siblingRows.map((row, index) => ({
id: parseInt(row.dataset.menuId),
sort_order: index + 1
}));
saveGlobalMenuOrder(items);
}
}
});
}
// 메뉴 순서 저장 API 호출
// 글로벌 메뉴 이동 API 호출 (계층 변경)
function moveGlobalMenu(menuId, newParentId, sortOrder) {
const payload = {
menu_id: menuId,
new_parent_id: newParentId,
sort_order: sortOrder
};
fetch('/api/admin/global-menus/move', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
},
body: JSON.stringify(payload)
})
.then(response => response.json())
.then(data => {
if (data.success) {
htmx.trigger('#menu-table', 'filterSubmit');
} else {
showToast('메뉴 이동 실패: ' + (data.message || ''), 'error');
htmx.trigger('#menu-table', 'filterSubmit');
}
})
.catch(error => {
console.error('Error:', error);
showToast('메뉴 이동 중 오류 발생', 'error');
htmx.trigger('#menu-table', 'filterSubmit');
});
}
// 글로벌 메뉴 순서 저장 API 호출
function saveGlobalMenuOrder(items) {
fetch('/api/admin/global-menus/reorder', {
method: 'POST',

View File

@@ -63,16 +63,6 @@ class="bg-white rounded-lg shadow-sm overflow-hidden">
htmx.trigger('#role-table', 'filterSubmit');
});
// HTMX 응답 처리
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'role-table') {
const response = JSON.parse(event.detail.xhr.response);
if (response.html) {
event.detail.target.innerHTML = response.html;
}
}
});
// 삭제 확인
window.confirmDelete = function(id, name) {
showDeleteConfirm(name, () => {

View File

@@ -148,6 +148,7 @@
Route::middleware('super.admin')->prefix('global-menus')->name('global-menus.')->group(function () {
// 고정 경로
Route::post('/reorder', [GlobalMenuController::class, 'reorder'])->name('reorder');
Route::post('/move', [GlobalMenuController::class, 'move'])->name('move');
// 기본 CRUD
Route::get('/', [GlobalMenuController::class, 'index'])->name('index');