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