feat: 대시보드 레이아웃에 사이드바 메뉴 및 역할 전환 기능 추가

- DashboardLayout에 좌측 사이드바 메뉴 추가 (아이콘 포함)
- 상단 헤더에 역할별 대시보드 전환 드롭다운 추가
- menuStore의 persist 설정에서 menuItems 제외 (icon 함수 직렬화 문제 해결)
- vite.config.ts HMR 설정을 환경별로 조건부 처리 (로컬/도커 환경 분리)
- Lucide React 아이콘 렌더링 패턴 개선 (변수 할당 후 렌더링)

변경 사항:
- vite.config.ts: VITE_DOCKER_ENV 환경변수 기반 HMR 설정
- DashboardLayout.tsx: 사이드바, 메뉴, 역할 드롭다운 UI 구현
- menuStore.ts: partialize 옵션으로 menuItems localStorage 저장 제외
This commit is contained in:
2025-10-17 16:04:34 +09:00
parent ce3ebf8c5d
commit 33d02b897b
3 changed files with 155 additions and 47 deletions

View File

@@ -1,57 +1,158 @@
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { SidebarProvider, Sidebar, SidebarHeader, SidebarContent, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarTrigger } from '@/components/ui/sidebar';
import { useMenuStore } from '@/store/menuStore'; import { useMenuStore } from '@/store/menuStore';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useEffect } from 'react';
import {
LayoutDashboard,
ShoppingCart,
Factory,
ClipboardCheck,
Package,
Database,
ChevronDown,
Menu,
} from 'lucide-react';
export default function DashboardLayout() { export default function DashboardLayout() {
const { menuItems, activeMenu, setActiveMenu, sidebarCollapsed } = useMenuStore(); const { menuItems, activeMenu, setActiveMenu, setMenuItems } = useMenuStore();
const navigate = useNavigate(); const navigate = useNavigate();
// 현재 사용자 역할 가져오기
const userDataStr = localStorage.getItem("user");
const userData = userDataStr ? JSON.parse(userDataStr) : null;
const currentRole = userData?.role || "CEO";
// 역할별 대시보드 정의
const roleDashboards = [
{ role: 'SystemAdmin', label: '시스템 관리자', icon: Database },
{ role: 'CEO', label: 'CEO', icon: LayoutDashboard },
{ role: 'ProductionManager', label: '생산 관리자', icon: Factory },
];
// 초기 메뉴 설정
useEffect(() => {
if (menuItems.length === 0) {
setMenuItems([
{
id: 'dashboard',
label: '대시보드',
icon: LayoutDashboard,
path: '/dashboard'
},
{
id: 'sales-leads',
label: '영업 대시보드',
icon: ShoppingCart,
path: '/dashboard/sales-leads'
},
{
id: 'production',
label: '생산관리',
icon: Factory,
path: '/dashboard/production'
},
{
id: 'quality',
label: '품질관리',
icon: ClipboardCheck,
path: '/dashboard/quality'
},
{
id: 'materials',
label: '자재관리',
icon: Package,
path: '/dashboard/materials'
},
]);
}
}, [menuItems.length, setMenuItems]);
const handleMenuClick = (menuId: string, path: string) => { const handleMenuClick = (menuId: string, path: string) => {
setActiveMenu(menuId); setActiveMenu(menuId);
navigate(path); navigate(path);
}; };
return ( const handleRoleChange = (role: string) => {
<SidebarProvider defaultOpen={!sidebarCollapsed}> // 역할 변경 시 localStorage 업데이트
<div className="flex min-h-screen w-full"> if (userData) {
<Sidebar> userData.role = role;
<SidebarHeader> localStorage.setItem("user", JSON.stringify(userData));
<div className="flex items-center gap-2 px-4 py-2"> // 페이지 새로고침하여 역할별 대시보드 표시
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary text-primary-foreground"> window.location.reload();
<span className="text-lg font-bold">S</span> }
</div> };
<span className="text-lg font-semibold">SAM</span>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarMenu>
{menuItems.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
onClick={() => handleMenuClick(item.id, item.path)}
isActive={activeMenu === item.id}
>
<item.icon className="h-5 w-5" />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarContent>
</Sidebar>
<main className="flex-1"> if (menuItems.length === 0) {
<header className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-4"> return <div className="flex items-center justify-center min-h-screen">Loading...</div>;
<SidebarTrigger /> }
<div className="flex-1" />
{/* 테마 토글, 사용자 메뉴 등 추가 가능 */} return (
</header> <div className="flex min-h-screen w-full">
<div className="p-6"> <div className="w-64 border-r bg-gray-50 p-4">
<Outlet /> <div className="mb-8">
</div> <h1 className="text-2xl font-bold">SAM</h1>
</main> </div>
<nav>
<ul className="space-y-2">
{menuItems.map((item) => {
const IconComponent = item.icon;
return (
<li key={item.id}>
<button
onClick={() => handleMenuClick(item.id, item.path)}
className={`w-full text-left px-4 py-2 rounded-md flex items-center gap-3 transition-colors ${
activeMenu === item.id
? 'bg-blue-50 text-blue-900 font-medium border-l-4 border-blue-600'
: 'hover:bg-gray-100 border-l-4 border-transparent'
}`}
>
{IconComponent && <IconComponent className="h-5 w-5 flex-shrink-0" />}
<span>{item.label}</span>
</button>
</li>
);
})}
</ul>
</nav>
</div> </div>
</SidebarProvider>
<main className="flex-1">
<header className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-white px-4 shadow-sm">
<button className="p-2 hover:bg-gray-100 rounded-md transition-colors">
<Menu className="h-5 w-5" />
</button>
<div className="flex-1" />
{/* 역할별 대시보드 전환 버튼 */}
<div className="relative group">
<button className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors">
<Database className="h-4 w-4" />
<span>{roleDashboards.find(d => d.role === currentRole)?.label || 'CEO'}</span>
<ChevronDown className="h-4 w-4" />
</button>
{/* 드롭다운 메뉴 */}
<div className="absolute right-0 mt-2 w-56 bg-white border border-gray-200 rounded-md shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
{roleDashboards.map((dashboard) => {
const DashboardIcon = dashboard.icon;
return (
<button
key={dashboard.role}
onClick={() => handleRoleChange(dashboard.role)}
className="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 first:rounded-t-md last:rounded-b-md transition-colors"
>
<DashboardIcon className="h-4 w-4" />
<span>{dashboard.label}</span>
</button>
);
})}
</div>
</div>
</header>
<div className="p-6">
<Outlet />
</div>
</main>
</div>
); );
} }

View File

@@ -38,6 +38,11 @@ export const useMenuStore = create<MenuState>()(
}), }),
{ {
name: 'sam-menu', name: 'sam-menu',
// menuItems는 함수(icon)를 포함하므로 localStorage에서 제외
partialize: (state) => ({
activeMenu: state.activeMenu,
sidebarCollapsed: state.sidebarCollapsed,
}),
} }
) )
); );

View File

@@ -26,12 +26,14 @@ export default defineConfig({
host: '0.0.0.0', // Docker 컨테이너 내 모든 네트워크 인터페이스에서 접근 허용 host: '0.0.0.0', // Docker 컨테이너 내 모든 네트워크 인터페이스에서 접근 허용
port: 5173, port: 5173,
strictPort: true, strictPort: true,
// Nginx 리버스 프록시를 통한 도메인 접근 허 // 환경별 HMR 설정: Docker 환경에서만 커스텀 설정 사
hmr: { hmr: process.env.VITE_DOCKER_ENV === 'true'
clientPort: 80, // HTTP 사용 (nginx가 port 80에서 리스닝) ? {
protocol: 'ws', // HTTP 환경에서는 WebSocket (ws) 사용 clientPort: 80, // HTTP 사용 (nginx가 port 80에서 리스닝)
host: 'dev.sam.kr', // HMR 연결 시 사용할 호스트 protocol: 'ws', // HTTP 환경에서는 WebSocket (ws) 사용
}, host: 'dev.sam.kr', // HMR 연결 시 사용할 호스트
}
: true, // 로컬 환경에서는 기본 HMR 설정 사용
// 파일 감시 설정 (Docker 환경에서 필수) // 파일 감시 설정 (Docker 환경에서 필수)
watch: { watch: {
usePolling: true, usePolling: true,