feat: [menus] 메뉴 트리 상태 유지 및 활성 상태 연쇄 토글

- localStorage로 접힌 메뉴 상태 저장, HTMX 리로드 후 복원
- 상위 메뉴 활성/비활성 시 하위 메뉴 연쇄 적용 (백엔드+프론트)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 23:05:02 +09:00
parent 6d2720edf3
commit afa8cd8293
3 changed files with 117 additions and 23 deletions

View File

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

View File

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

View File

@@ -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