Files
sam-manage/resources/views/partials/sidebar.blade.php
hskwon c94e1cff41 메뉴 관리 HTMX 에러 수정 및 개발도구 메뉴 동적 렌더링
- HTMX 응답 에러 수정: JSON 래핑 대신 HTML 직접 반환
  - MenuController, GlobalMenuController의 index 메소드 수정
  - index.blade.php, global-index.blade.php의 JSON 파싱 로직 제거

- 메뉴 options 필드 검증 추가
  - StoreMenuRequest, UpdateMenuRequest에 options 필드 추가
  - section 변경이 정상 저장되도록 수정

- 개발도구 메뉴 하드코딩 제거, DB 기반 동적 렌더링
  - sidebar.blade.php에서 하드코딩된 메뉴 제거
  - tools-menu.blade.php 컴포넌트 신규 생성
  - section=tools 메뉴가 하단 고정 영역에 동적 표시
2025-12-18 11:19:07 +09:00

690 lines
22 KiB
PHP

<!-- Sidebar (Dynamic Menu from DB + Static Tools/Labs) -->
<aside id="sidebar" class="sidebar bg-white shadow-lg flex-shrink-0 transition-all duration-300 ease-in-out w-64">
<div class="flex flex-col h-full">
<!-- Logo / Brand -->
<div class="flex items-center h-16 border-b border-gray-200 px-3">
<!-- 펼쳐진 상태: 햄버거 버튼 + 로고 -->
<div class="sidebar-expanded-only flex items-center gap-2 w-full">
<button
type="button"
onclick="toggleSidebar()"
class="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
title="메뉴 접기"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<span class="text-xl font-bold text-gray-900">{{ config('app.name') }}</span>
</div>
<!-- 접힌 상태: S 버튼 (클릭하면 확장) -->
<button
type="button"
onclick="toggleSidebar()"
class="sidebar-collapsed-only hidden w-full p-2 text-xl font-bold text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
title="메뉴 펼치기"
>
S
</button>
</div>
<!-- Navigation Menu -->
<nav class="flex-1 overflow-y-auto p-4 sidebar-nav">
<ul class="space-y-1">
{{-- Main Section Menus (Dynamic from DB) --}}
<x-sidebar.menu-tree :menus="$mainMenus" />
{{-- R&D Labs Section (Dynamic from DB with Tab UI) --}}
<x-sidebar.labs-menu :menus="$labsMenus" />
</ul>
</nav>
<!-- 개발 도구 (하단 고정, DB 기반) -->
@if(!empty($toolsMenus) && $toolsMenus->count() > 0)
<div class="border-t border-gray-200 p-2 bg-gray-50">
<x-sidebar.tools-menu :menus="$toolsMenus" />
</div>
@endif
</div>
</aside>
<style>
/* ========== 사이드바 기본 스타일 ========== */
.sidebar {
width: 16rem;
overflow: visible;
transition: width 0.3s ease-in-out;
}
.sidebar-collapsed {
width: 4rem !important;
}
/* 접힌 상태 기본 설정 */
.sidebar-expanded-only {
display: flex;
}
.sidebar-collapsed-only {
display: none;
}
/* 접힌 상태에서 overflow 설정 (툴팁 표시를 위해) */
html.sidebar-is-collapsed #sidebar,
.sidebar.sidebar-collapsed {
width: 4rem !important;
overflow: visible !important;
}
html.sidebar-is-collapsed #sidebar .sidebar-nav,
.sidebar.sidebar-collapsed .sidebar-nav {
overflow-y: auto !important;
overflow-x: visible !important;
}
html.sidebar-is-collapsed #sidebar > div,
.sidebar.sidebar-collapsed > div {
overflow: visible !important;
}
html.sidebar-is-collapsed #sidebar .sidebar-text,
.sidebar.sidebar-collapsed .sidebar-text {
display: none;
}
html.sidebar-is-collapsed #sidebar .sidebar-group-header,
.sidebar.sidebar-collapsed .sidebar-group-header {
display: none;
}
html.sidebar-is-collapsed #sidebar .sidebar-nav,
.sidebar.sidebar-collapsed .sidebar-nav {
padding: 0.5rem;
}
html.sidebar-is-collapsed #sidebar .sidebar-expanded-only,
.sidebar.sidebar-collapsed .sidebar-expanded-only {
display: none;
}
html.sidebar-is-collapsed #sidebar .sidebar-collapsed-only,
.sidebar.sidebar-collapsed .sidebar-collapsed-only {
display: block;
}
/* 접힌 상태에서 메뉴 아이템 중앙 정렬 */
html.sidebar-is-collapsed #sidebar .sidebar-nav a,
html.sidebar-is-collapsed #sidebar .sidebar-nav span,
.sidebar.sidebar-collapsed .sidebar-nav a,
.sidebar.sidebar-collapsed .sidebar-nav span {
justify-content: center;
padding-left: 0.75rem !important;
padding-right: 0.75rem !important;
}
/* 접힌 상태에서 개발 도구 영역 조정 */
html.sidebar-is-collapsed #sidebar .border-t.border-gray-200.p-2,
.sidebar.sidebar-collapsed .border-t.border-gray-200.p-2 {
padding: 0.5rem;
}
/* 접힌 상태에서 메뉴 아이콘 호버 툴팁 */
html.sidebar-is-collapsed #sidebar .sidebar-nav a,
html.sidebar-is-collapsed #sidebar .sidebar-nav > ul > li > ul > li > a,
html.sidebar-is-collapsed #sidebar .sidebar-nav > ul > li > ul > li > span,
.sidebar.sidebar-collapsed .sidebar-nav a,
.sidebar.sidebar-collapsed .sidebar-nav > ul > li > ul > li > a,
.sidebar.sidebar-collapsed .sidebar-nav > ul > li > ul > li > span {
position: relative;
}
html.sidebar-is-collapsed #sidebar .sidebar-nav a::after,
html.sidebar-is-collapsed #sidebar .sidebar-nav > ul > li > ul > li > span::after,
.sidebar.sidebar-collapsed .sidebar-nav a::after,
.sidebar.sidebar-collapsed .sidebar-nav > ul > li > ul > li > span::after {
content: attr(title);
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: 0.75rem;
padding: 0.5rem 0.75rem;
background-color: #1f2937;
color: white;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
border-radius: 0.375rem;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease-in-out, visibility 0.15s ease-in-out;
z-index: 1000;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
html.sidebar-is-collapsed #sidebar .sidebar-nav a:hover::after,
html.sidebar-is-collapsed #sidebar .sidebar-nav > ul > li > ul > li > span:hover::after,
.sidebar.sidebar-collapsed .sidebar-nav a:hover::after,
.sidebar.sidebar-collapsed .sidebar-nav > ul > li > ul > li > span:hover::after {
opacity: 1;
visibility: visible;
}
/* 툴팁 화살표 */
html.sidebar-is-collapsed #sidebar .sidebar-nav a::before,
html.sidebar-is-collapsed #sidebar .sidebar-nav > ul > li > ul > li > span::before,
.sidebar.sidebar-collapsed .sidebar-nav a::before,
.sidebar.sidebar-collapsed .sidebar-nav > ul > li > ul > li > span::before {
content: '';
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: 0.25rem;
border: 6px solid transparent;
border-right-color: #1f2937;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease-in-out, visibility 0.15s ease-in-out;
z-index: 1000;
}
html.sidebar-is-collapsed #sidebar .sidebar-nav a:hover::before,
html.sidebar-is-collapsed #sidebar .sidebar-nav > ul > li > ul > li > span:hover::before,
.sidebar.sidebar-collapsed .sidebar-nav a:hover::before,
.sidebar.sidebar-collapsed .sidebar-nav > ul > li > ul > li > span:hover::before {
opacity: 1;
visibility: visible;
}
/* 접힌 상태에서 서브메뉴 숨김 */
html.sidebar-is-collapsed #sidebar .sidebar-nav ul ul,
.sidebar.sidebar-collapsed .sidebar-nav ul ul {
display: none;
}
/* 접힌 상태에서 그룹 헤더 버튼 스타일 */
html.sidebar-is-collapsed #sidebar .sidebar-group-header span.flex,
.sidebar.sidebar-collapsed .sidebar-group-header span.flex {
justify-content: center;
}
/* ========== R&D Labs 탭 + 플라이아웃 스타일 ========== */
/* R&D Labs 그룹 특별 스타일 */
.lab-group-header {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border: 1px solid #f59e0b;
}
.lab-group-header:hover {
background: linear-gradient(135deg, #fde68a 0%, #fcd34d 100%);
}
/* 탭 버튼 스타일 */
.lab-tabs .lab-tab {
background: transparent;
border: none;
cursor: pointer;
}
.lab-tabs .lab-tab.active {
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.lab-tabs .lab-tab:not(.active):hover {
background: rgba(255, 255, 255, 0.5);
}
/* 확장 상태에서 축소 뷰 숨김 */
.lab-expanded-view {
display: block;
}
.lab-collapsed-view {
display: none !important;
}
/* 축소 상태에서 확장 뷰 숨김, 축소 뷰 표시 */
html.sidebar-is-collapsed #sidebar .lab-expanded-view,
.sidebar.sidebar-collapsed .lab-expanded-view {
display: none !important;
}
html.sidebar-is-collapsed #sidebar .lab-collapsed-view,
.sidebar.sidebar-collapsed .lab-collapsed-view {
display: block !important;
}
/* 플라이아웃 트리거 */
.lab-flyout-trigger {
position: relative;
}
/* 플라이아웃 표시 - JavaScript로 제어 */
.lab-flyout.show {
display: block !important;
animation: flyoutFadeIn 0.15s ease-out;
}
/* 플라이아웃 왼쪽 투명 브릿지 영역 (hover gap 연결) */
.lab-flyout::before {
content: '';
position: absolute;
left: -20px;
top: 0;
width: 20px;
height: 100%;
background: transparent;
}
@keyframes flyoutFadeIn {
from {
opacity: 0;
transform: translateX(-8px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* 플라이아웃 탭 스타일 */
.lab-flyout-tab {
background: transparent;
border: none;
cursor: pointer;
}
.lab-flyout-tab.active {
background: white;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.lab-flyout-tab:not(.active):hover {
background: rgba(255, 255, 255, 0.7);
}
/* 접힌 상태에서 R&D Labs 메뉴 아이템 스타일 리셋 */
html.sidebar-is-collapsed #sidebar .lab-panel span,
.sidebar.sidebar-collapsed .lab-panel span {
display: none;
}
/* 접힌 상태에서 탭 숨김 */
html.sidebar-is-collapsed #sidebar .lab-tabs,
.sidebar.sidebar-collapsed .lab-tabs {
display: none;
}
/* 접힌 상태에서 R&D Labs 그룹 border-t 유지 */
html.sidebar-is-collapsed #sidebar .lab-menu-container,
.sidebar.sidebar-collapsed .lab-menu-container {
border-top: 1px solid #e5e7eb;
padding-top: 1rem;
margin-top: 0.5rem;
}
</style>
<script>
// 사이드바 토글 (로컬스토리지 연동)
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const html = document.documentElement;
if (html.classList.contains('sidebar-is-collapsed')) {
html.classList.remove('sidebar-is-collapsed');
sidebar.classList.remove('sidebar-collapsed');
localStorage.setItem('sidebar-collapsed', 'false');
} else {
html.classList.add('sidebar-is-collapsed');
sidebar.classList.add('sidebar-collapsed');
localStorage.setItem('sidebar-collapsed', 'true');
}
}
// 메뉴 그룹 토글 (R&D Labs, 개발 도구 등)
function toggleGroup(groupId) {
const sidebar = document.getElementById('sidebar');
// 사이드바가 접힌 상태면 그룹 토글 무시
if (sidebar.classList.contains('sidebar-collapsed')) {
return;
}
const group = document.getElementById(groupId);
const icon = document.getElementById(groupId + '-icon');
if (group.style.display === 'none') {
group.style.display = 'block';
icon.style.transform = 'rotate(0deg)';
localStorage.setItem('sidebar-' + groupId, 'open');
} else {
group.style.display = 'none';
icon.style.transform = 'rotate(-90deg)';
localStorage.setItem('sidebar-' + groupId, 'closed');
}
}
// 동적 메뉴 그룹 토글 (DB에서 가져온 메뉴)
function toggleMenuGroup(groupId) {
const sidebar = document.getElementById('sidebar');
// 사이드바가 접힌 상태면 그룹 토글 무시
if (sidebar.classList.contains('sidebar-collapsed') || document.documentElement.classList.contains('sidebar-is-collapsed')) {
return;
}
const group = document.getElementById(groupId);
const icon = document.getElementById(groupId + '-icon');
if (!group) return;
const isHidden = group.style.display === 'none' || group.style.display === '';
if (isHidden) {
group.style.display = 'block';
if (icon) {
icon.classList.add('rotate-180');
}
localStorage.setItem('menu-group-' + groupId, 'visible');
} else {
group.style.display = 'none';
if (icon) {
icon.classList.remove('rotate-180');
}
localStorage.setItem('menu-group-' + groupId, 'hidden');
}
}
// ========== R&D Labs 사이드바 스크롤 함수 ==========
// 사이드바를 최하단으로 스크롤하고 위치 저장
function scrollSidebarToBottom() {
const sidebarNav = document.querySelector('.sidebar-nav');
if (sidebarNav) {
setTimeout(function() {
sidebarNav.scrollTo({
top: sidebarNav.scrollHeight,
behavior: 'smooth'
});
localStorage.setItem('sidebar-scroll-bottom', 'true');
}, 50);
}
}
// 저장된 스크롤 위치 복원
function restoreSidebarScroll() {
const sidebarNav = document.querySelector('.sidebar-nav');
const scrollToBottom = localStorage.getItem('sidebar-scroll-bottom');
if (sidebarNav && scrollToBottom === 'true') {
setTimeout(function() {
sidebarNav.scrollTop = sidebarNav.scrollHeight;
}, 100);
}
}
// 일반 메뉴 클릭 시 스크롤 위치 초기화
function resetSidebarScroll() {
localStorage.removeItem('sidebar-scroll-bottom');
}
// ========== R&D Labs 탭 전환 함수 ==========
// 확장 상태: 탭 전환
function switchLabTab(tabKey) {
const tabs = ['s', 'a', 'm'];
tabs.forEach(function(key) {
const tab = document.getElementById('lab-tab-' + key);
const panel = document.getElementById('lab-panel-' + key);
if (tab) {
tab.classList.remove('active');
}
if (panel) {
panel.classList.add('hidden');
}
});
const activeTab = document.getElementById('lab-tab-' + tabKey);
const activePanel = document.getElementById('lab-panel-' + tabKey);
if (activeTab) {
activeTab.classList.add('active');
}
if (activePanel) {
activePanel.classList.remove('hidden');
}
localStorage.setItem('lab-active-tab', tabKey);
if (!window._skipLabScroll) {
scrollSidebarToBottom();
}
}
// 축소 상태 (플라이아웃): 탭 전환
function switchLabFlyoutTab(tabKey) {
const tabs = ['s', 'a', 'm'];
tabs.forEach(function(key) {
const tab = document.getElementById('lab-flyout-tab-' + key);
const panel = document.getElementById('lab-flyout-panel-' + key);
if (tab) {
tab.classList.remove('active');
}
if (panel) {
panel.classList.add('hidden');
}
});
const activeTab = document.getElementById('lab-flyout-tab-' + tabKey);
const activePanel = document.getElementById('lab-flyout-panel-' + tabKey);
if (activeTab) {
activeTab.classList.add('active');
}
if (activePanel) {
activePanel.classList.remove('hidden');
}
localStorage.setItem('lab-active-tab', tabKey);
}
// 플라이아웃 위치 동적 계산 및 hover 제어
function initLabFlyoutPosition() {
const trigger = document.querySelector('.lab-flyout-trigger');
const flyout = document.querySelector('.lab-flyout');
if (!trigger || !flyout) return;
let hideTimeout = null;
const HIDE_DELAY = 150;
function showFlyout() {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
const triggerRect = trigger.getBoundingClientRect();
const sidebar = document.getElementById('sidebar');
const sidebarRect = sidebar.getBoundingClientRect();
flyout.style.left = (sidebarRect.right + 8) + 'px';
flyout.style.top = Math.max(triggerRect.top - 10, 10) + 'px';
const flyoutHeight = flyout.offsetHeight || 300;
const windowHeight = window.innerHeight;
if (triggerRect.top + flyoutHeight > windowHeight - 20) {
flyout.style.top = Math.max(windowHeight - flyoutHeight - 20, 10) + 'px';
}
flyout.classList.add('show');
}
function hideFlyout() {
hideTimeout = setTimeout(function() {
flyout.classList.remove('show');
}, HIDE_DELAY);
}
trigger.addEventListener('mouseenter', showFlyout);
trigger.addEventListener('mouseleave', hideFlyout);
flyout.addEventListener('mouseenter', function() {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
});
flyout.addEventListener('mouseleave', hideFlyout);
}
// JavaScript 기반 툴팁 (overflow 제약 우회) - 이벤트 위임 방식
function initSidebarTooltips() {
if (document.getElementById('sidebar-tooltip')) {
return;
}
const tooltip = document.createElement('div');
tooltip.id = 'sidebar-tooltip';
tooltip.className = 'fixed bg-gray-800 text-white text-sm font-medium px-3 py-2 rounded-lg shadow-lg z-[9999] pointer-events-none opacity-0 transition-opacity duration-150 whitespace-nowrap';
tooltip.style.cssText = 'display: none;';
document.body.appendChild(tooltip);
const sidebar = document.getElementById('sidebar');
let currentTarget = null;
sidebar.addEventListener('mouseover', function(e) {
if (!sidebar.classList.contains('sidebar-collapsed') && !document.documentElement.classList.contains('sidebar-is-collapsed')) {
return;
}
const target = e.target.closest('.sidebar-nav a[title], .sidebar-nav span[title], #dev-tools-group a[title]');
if (!target || target === currentTarget) return;
currentTarget = target;
const title = target.getAttribute('title');
if (!title) return;
if (!target.hasAttribute('data-tooltip')) {
target.setAttribute('data-tooltip', title);
}
target.removeAttribute('title');
tooltip.textContent = title;
tooltip.style.display = 'block';
const rect = target.getBoundingClientRect();
tooltip.style.left = (rect.right + 12) + 'px';
tooltip.style.top = (rect.top + (rect.height / 2) - (tooltip.offsetHeight / 2)) + 'px';
requestAnimationFrame(() => {
tooltip.style.opacity = '1';
});
});
sidebar.addEventListener('mouseout', function(e) {
const target = e.target.closest('.sidebar-nav a, .sidebar-nav span, #dev-tools-group a');
if (!target) return;
const relatedTarget = e.relatedTarget;
if (relatedTarget && target.contains(relatedTarget)) {
return;
}
const originalTitle = target.getAttribute('data-tooltip');
if (originalTitle) {
target.setAttribute('title', originalTitle);
}
currentTarget = null;
tooltip.style.opacity = '0';
setTimeout(() => {
if (tooltip.style.opacity === '0') {
tooltip.style.display = 'none';
}
}, 150);
});
}
// 페이지 로드 시 상태 복원
document.addEventListener('DOMContentLoaded', function() {
// 사이드바 상태 복원
const isCollapsed = localStorage.getItem('sidebar-collapsed') === 'true';
if (isCollapsed) {
document.documentElement.classList.add('sidebar-is-collapsed');
document.getElementById('sidebar')?.classList.add('sidebar-collapsed');
}
// 그룹 상태 복원
['lab-group', 'dev-tools-group'].forEach(function(groupId) {
const state = localStorage.getItem('sidebar-' + groupId);
if (state === 'closed') {
const group = document.getElementById(groupId);
const icon = document.getElementById(groupId + '-icon');
if (group) group.style.display = 'none';
if (icon) icon.style.transform = 'rotate(-90deg)';
}
});
// 메뉴 그룹 상태 복원 (동적 메뉴) - inline style로 통일
document.querySelectorAll('[id^="menu-group-"]').forEach(function(group) {
const groupId = group.id;
const savedState = localStorage.getItem('menu-group-' + groupId);
const icon = document.getElementById(groupId + '-icon');
if (savedState === 'hidden') {
group.style.display = 'none';
icon?.classList.remove('rotate-180');
} else if (savedState === 'visible') {
group.style.display = 'block';
icon?.classList.add('rotate-180');
}
// savedState가 없으면 서버에서 렌더링한 초기 상태 유지
});
// R&D Labs 탭 상태 복원
const savedTab = localStorage.getItem('lab-active-tab');
if (savedTab && ['s', 'a', 'm'].includes(savedTab)) {
window._skipLabScroll = true;
switchLabTab(savedTab);
switchLabFlyoutTab(savedTab);
window._skipLabScroll = false;
}
// 플라이아웃 위치 초기화
initLabFlyoutPosition();
// 스크롤 위치 복원
restoreSidebarScroll();
// 사이드바 툴팁 초기화
initSidebarTooltips();
// R&D Labs 메뉴 클릭 시 스크롤
const labMenuContainer = document.getElementById('lab-menu-container');
if (labMenuContainer) {
labMenuContainer.addEventListener('click', function(e) {
if (e.target.closest('a[href]')) {
scrollSidebarToBottom();
}
});
}
// 일반 메뉴 클릭 시 스크롤 위치 초기화
const sidebarNav = document.querySelector('.sidebar-nav');
if (sidebarNav) {
sidebarNav.addEventListener('click', function(e) {
const link = e.target.closest('a[href]');
if (link && !link.closest('#lab-menu-container')) {
resetSidebarScroll();
}
});
}
});
</script>