feat: [menus] 메뉴 트리 상태 유지 및 활성 상태 연쇄 토글
- localStorage로 접힌 메뉴 상태 저장, HTMX 리로드 후 복원 - 상위 메뉴 활성/비활성 시 하위 메뉴 연쇄 적용 (백엔드+프론트) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -455,7 +455,7 @@ public function forceDeleteMenu(int $id): array
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 활성 상태 토글
|
||||
* 메뉴 활성 상태 토글 (하위 메뉴 포함)
|
||||
*/
|
||||
public function toggleActive(int $id): bool
|
||||
{
|
||||
@@ -464,10 +464,34 @@ public function toggleActive(int $id): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
$menu->is_active = ! $menu->is_active;
|
||||
$newState = ! $menu->is_active;
|
||||
$menu->is_active = $newState;
|
||||
$menu->updated_by = auth()->id();
|
||||
$menu->save();
|
||||
|
||||
return $menu->save();
|
||||
// 하위 메뉴도 동일한 상태로 변경
|
||||
$this->setChildrenActiveState($menu, $newState);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 메뉴의 활성 상태를 재귀적으로 변경
|
||||
*/
|
||||
private function setChildrenActiveState($menu, bool $isActive): void
|
||||
{
|
||||
$children = $menu->children()->get();
|
||||
if ($children->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = auth()->id();
|
||||
foreach ($children as $child) {
|
||||
$child->is_active = $isActive;
|
||||
$child->updated_by = $userId;
|
||||
$child->save();
|
||||
$this->setChildrenActiveState($child, $isActive);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,8 +3,32 @@
|
||||
* - 부서 권한 관리 (department-permissions) - 테이블 기반
|
||||
* - 개인 권한 관리 (user-permissions) - 테이블 기반
|
||||
* - 권한 분석 (permission-analyze) - div 기반
|
||||
* - 메뉴 관리 (menus) - 테이블 기반
|
||||
*
|
||||
* localStorage 키: 'menu-tree-collapsed' (접힌 메뉴 ID 배열)
|
||||
*/
|
||||
|
||||
const MENU_TREE_STORAGE_KEY = 'menu-tree-collapsed';
|
||||
|
||||
// localStorage에서 접힌 메뉴 ID Set 로드
|
||||
function getCollapsedMenuIds() {
|
||||
try {
|
||||
const data = localStorage.getItem(MENU_TREE_STORAGE_KEY);
|
||||
return data ? new Set(JSON.parse(data)) : new Set();
|
||||
} catch (e) {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
// localStorage에 접힌 메뉴 ID Set 저장
|
||||
function saveCollapsedMenuIds(collapsedSet) {
|
||||
try {
|
||||
localStorage.setItem(MENU_TREE_STORAGE_KEY, JSON.stringify([...collapsedSet]));
|
||||
} catch (e) {
|
||||
// storage full 등 무시
|
||||
}
|
||||
}
|
||||
|
||||
// 자식 메뉴 접기/펼치기
|
||||
window.toggleChildren = function(menuId) {
|
||||
const button = document.querySelector(`.toggle-btn[data-menu-id="${menuId}"]`);
|
||||
@@ -13,17 +37,22 @@ window.toggleChildren = function(menuId) {
|
||||
const chevron = button.querySelector('.chevron-icon');
|
||||
if (!chevron) return;
|
||||
|
||||
const collapsedSet = getCollapsedMenuIds();
|
||||
const isCollapsed = chevron.classList.contains('rotate-[-90deg]');
|
||||
|
||||
if (isCollapsed) {
|
||||
// 펼치기
|
||||
chevron.classList.remove('rotate-[-90deg]');
|
||||
showChildren(menuId);
|
||||
collapsedSet.delete(String(menuId));
|
||||
} else {
|
||||
// 접기
|
||||
chevron.classList.add('rotate-[-90deg]');
|
||||
hideChildren(menuId);
|
||||
collapsedSet.add(String(menuId));
|
||||
}
|
||||
|
||||
saveCollapsedMenuIds(collapsedSet);
|
||||
};
|
||||
|
||||
// 자식 요소 선택자 (테이블: tr.menu-row, div: .menu-item)
|
||||
@@ -49,6 +78,8 @@ function hideChildren(parentId) {
|
||||
// 전체 접기/펼치기
|
||||
window.toggleAllChildren = function(collapse) {
|
||||
const buttons = document.querySelectorAll('.toggle-btn');
|
||||
const collapsedSet = collapse ? new Set() : new Set();
|
||||
|
||||
buttons.forEach(btn => {
|
||||
const menuId = btn.getAttribute('data-menu-id');
|
||||
const chevron = btn.querySelector('.chevron-icon');
|
||||
@@ -57,11 +88,14 @@ window.toggleAllChildren = function(collapse) {
|
||||
if (collapse) {
|
||||
chevron.classList.add('rotate-[-90deg]');
|
||||
hideChildren(menuId);
|
||||
collapsedSet.add(String(menuId));
|
||||
} else {
|
||||
chevron.classList.remove('rotate-[-90deg]');
|
||||
showChildren(menuId);
|
||||
}
|
||||
});
|
||||
|
||||
saveCollapsedMenuIds(collapsedSet);
|
||||
};
|
||||
|
||||
// 재귀적으로 직계 자식만 표시
|
||||
@@ -78,4 +112,21 @@ function showChildren(parentId) {
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// HTMX 리로드 후 접힌 상태 복원
|
||||
window.restoreMenuTreeState = function() {
|
||||
const collapsedSet = getCollapsedMenuIds();
|
||||
if (collapsedSet.size === 0) return;
|
||||
|
||||
collapsedSet.forEach(menuId => {
|
||||
const button = document.querySelector(`.toggle-btn[data-menu-id="${menuId}"]`);
|
||||
if (!button) return;
|
||||
|
||||
const chevron = button.querySelector('.chevron-icon');
|
||||
if (chevron) {
|
||||
chevron.classList.add('rotate-[-90deg]');
|
||||
hideChildren(menuId);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -366,13 +366,17 @@ function initFilterForm() {
|
||||
document.addEventListener('DOMContentLoaded', initFilterForm);
|
||||
})();
|
||||
|
||||
// HTMX 응답 처리 + SortableJS 초기화 (menu-table 내부 갱신용)
|
||||
// HTMX 응답 처리 + SortableJS 초기화 + 메뉴 트리 상태 복원
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.id === 'menu-table') {
|
||||
// 테이블 로드 후 SortableJS 초기화 (전역 함수 사용)
|
||||
if (typeof initMenuSortable === 'function') {
|
||||
initMenuSortable();
|
||||
}
|
||||
// 접힌 메뉴 상태 복원
|
||||
if (typeof restoreMenuTreeState === 'function') {
|
||||
restoreMenuTreeState();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -433,7 +437,28 @@ function initFilterForm() {
|
||||
});
|
||||
};
|
||||
|
||||
// 활성 토글 (새로고침 없이 UI만 업데이트)
|
||||
// 활성 토글 UI 업데이트 헬퍼
|
||||
function setActiveUI(btn, active) {
|
||||
const thumb = btn.querySelector('span');
|
||||
if (!thumb) return;
|
||||
btn.classList.toggle('bg-blue-500', active);
|
||||
btn.classList.toggle('bg-gray-400', !active);
|
||||
thumb.classList.toggle('translate-x-3.5', active);
|
||||
thumb.classList.toggle('translate-x-0.5', !active);
|
||||
}
|
||||
|
||||
// 하위 메뉴의 활성 토글 버튼 UI를 재귀적으로 업데이트
|
||||
function setChildrenActiveUI(parentId, active) {
|
||||
const childRows = document.querySelectorAll(`tr.menu-row[data-parent-id="${parentId}"]`);
|
||||
childRows.forEach(row => {
|
||||
const childBtn = row.querySelector('button[onclick*="toggleActive"]');
|
||||
if (childBtn) setActiveUI(childBtn, active);
|
||||
const childId = row.getAttribute('data-menu-id');
|
||||
setChildrenActiveUI(childId, active);
|
||||
});
|
||||
}
|
||||
|
||||
// 활성 토글 (새로고침 없이 UI만 업데이트, 하위 메뉴 포함)
|
||||
window.toggleActive = function(id, buttonEl) {
|
||||
// 버튼 요소 찾기
|
||||
const btn = buttonEl || document.querySelector(`tr[data-menu-id="${id}"] button[onclick*="toggleActive"]`);
|
||||
@@ -441,13 +466,11 @@ function initFilterForm() {
|
||||
|
||||
// 현재 상태 확인 (파란색이면 활성)
|
||||
const isCurrentlyActive = btn.classList.contains('bg-blue-500');
|
||||
const thumb = btn.querySelector('span');
|
||||
const newState = !isCurrentlyActive;
|
||||
|
||||
// 즉시 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);
|
||||
// 즉시 UI 토글 (낙관적 업데이트) - 본인 + 하위 메뉴
|
||||
setActiveUI(btn, newState);
|
||||
setChildrenActiveUI(String(id), newState);
|
||||
|
||||
// 백엔드 요청
|
||||
fetch(`/api/admin/menus/${id}/toggle-active`, {
|
||||
@@ -460,20 +483,16 @@ function initFilterForm() {
|
||||
.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);
|
||||
// 실패 시 롤백 - 본인 + 하위 메뉴
|
||||
setActiveUI(btn, isCurrentlyActive);
|
||||
setChildrenActiveUI(String(id), 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);
|
||||
// 에러 시 롤백 - 본인 + 하위 메뉴
|
||||
setActiveUI(btn, isCurrentlyActive);
|
||||
setChildrenActiveUI(String(id), isCurrentlyActive);
|
||||
showToast('상태 변경 중 오류가 발생했습니다.', 'error');
|
||||
console.error('Toggle active error:', error);
|
||||
});
|
||||
@@ -904,5 +923,5 @@ function setChildren(parentId) {
|
||||
};
|
||||
|
||||
</script>
|
||||
<script src="{{ asset('js/menu-tree.js') }}"></script>
|
||||
<script src="{{ asset('js/menu-tree.js') }}?v={{ filemtime(public_path('js/menu-tree.js')) }}"></script>
|
||||
@endpush
|
||||
|
||||
Reference in New Issue
Block a user