diff --git a/app/Services/MenuService.php b/app/Services/MenuService.php index a842259f..8cbe2e66 100644 --- a/app/Services/MenuService.php +++ b/app/Services/MenuService.php @@ -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); + } } /** diff --git a/public/js/menu-tree.js b/public/js/menu-tree.js index 81d0c9df..8dbe70d3 100644 --- a/public/js/menu-tree.js +++ b/public/js/menu-tree.js @@ -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) { } } }); -} \ No newline at end of file +} + +// 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); + } + }); +}; \ No newline at end of file diff --git a/resources/views/menus/index.blade.php b/resources/views/menus/index.blade.php index ff0208eb..ea206eb3 100644 --- a/resources/views/menus/index.blade.php +++ b/resources/views/menus/index.blade.php @@ -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) { }; - + @endpush