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);
}; };
const handleRoleChange = (role: string) => {
// 역할 변경 시 localStorage 업데이트
if (userData) {
userData.role = role;
localStorage.setItem("user", JSON.stringify(userData));
// 페이지 새로고침하여 역할별 대시보드 표시
window.location.reload();
}
};
if (menuItems.length === 0) {
return <div className="flex items-center justify-center min-h-screen">Loading...</div>;
}
return ( return (
<SidebarProvider defaultOpen={!sidebarCollapsed}>
<div className="flex min-h-screen w-full"> <div className="flex min-h-screen w-full">
<Sidebar> <div className="w-64 border-r bg-gray-50 p-4">
<SidebarHeader> <div className="mb-8">
<div className="flex items-center gap-2 px-4 py-2"> <h1 className="text-2xl font-bold">SAM</h1>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<span className="text-lg font-bold">S</span>
</div> </div>
<span className="text-lg font-semibold">SAM</span> <nav>
</div> <ul className="space-y-2">
</SidebarHeader> {menuItems.map((item) => {
<SidebarContent> const IconComponent = item.icon;
<SidebarMenu> return (
{menuItems.map((item) => ( <li key={item.id}>
<SidebarMenuItem key={item.id}> <button
<SidebarMenuButton
onClick={() => handleMenuClick(item.id, item.path)} onClick={() => handleMenuClick(item.id, item.path)}
isActive={activeMenu === item.id} 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'
}`}
> >
<item.icon className="h-5 w-5" /> {IconComponent && <IconComponent className="h-5 w-5 flex-shrink-0" />}
<span>{item.label}</span> <span>{item.label}</span>
</SidebarMenuButton> </button>
</SidebarMenuItem> </li>
))} );
</SidebarMenu> })}
</SidebarContent> </ul>
</Sidebar> </nav>
</div>
<main className="flex-1"> <main className="flex-1">
<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"> <header className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-white px-4 shadow-sm">
<SidebarTrigger /> <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="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> </header>
<div className="p-6"> <div className="p-6">
<Outlet /> <Outlet />
</div> </div>
</main> </main>
</div> </div>
</SidebarProvider>
); );
} }

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에서 리스닝) clientPort: 80, // HTTP 사용 (nginx가 port 80에서 리스닝)
protocol: 'ws', // HTTP 환경에서는 WebSocket (ws) 사용 protocol: 'ws', // HTTP 환경에서는 WebSocket (ws) 사용
host: 'dev.sam.kr', // HMR 연결 시 사용할 호스트 host: 'dev.sam.kr', // HMR 연결 시 사용할 호스트
}, }
: true, // 로컬 환경에서는 기본 HMR 설정 사용
// 파일 감시 설정 (Docker 환경에서 필수) // 파일 감시 설정 (Docker 환경에서 필수)
watch: { watch: {
usePolling: true, usePolling: true,