## 단가관리 (Pricing Management) - 단가 목록 페이지 (IntegratedListTemplateV2 공통 템플릿 적용) - 단가 등록/수정 폼 (원가/마진 자동 계산) - 이력 조회, 수정 이력, 최종 확정 다이얼로그 - 판매관리 > 단가관리 네비게이션 메뉴 추가 ## HR 관리 (Human Resources) - 사원관리 (목록, 등록, 수정, 상세, CSV 업로드) - 부서관리 (트리 구조) - 근태관리 (기본 구조) ## 품목관리 개선 - Radix UI Select controlled mode 버그 수정 (key prop 적용) - DynamicItemForm 파일 업로드 지원 - 수정 페이지 데이터 로딩 개선 ## 문서화 - 단가관리 마이그레이션 체크리스트 - HR 관리 구현 체크리스트 - Radix UI Select 버그 수정 가이드 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
337 lines
13 KiB
TypeScript
337 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { useMenuStore } from '@/store/menuStore';
|
|
import type { SerializableMenuItem } from '@/store/menuStore';
|
|
import type { MenuItem } from '@/store/menuStore';
|
|
import { useRouter, usePathname } from 'next/navigation';
|
|
import { useEffect, useState } from 'react';
|
|
import {
|
|
Menu,
|
|
Search,
|
|
User,
|
|
LogOut,
|
|
LayoutDashboard,
|
|
Sun,
|
|
Moon,
|
|
Accessibility,
|
|
ShoppingCart,
|
|
Building2,
|
|
Receipt,
|
|
Package,
|
|
Settings,
|
|
DollarSign,
|
|
} from 'lucide-react';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import Sidebar from '@/components/layout/Sidebar';
|
|
import { useTheme } from '@/contexts/ThemeContext';
|
|
import { deserializeMenuItems } from '@/lib/utils/menuTransform';
|
|
|
|
interface DashboardLayoutProps {
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
|
const { menuItems, activeMenu, setActiveMenu, setMenuItems, sidebarCollapsed, toggleSidebar, _hasHydrated } = useMenuStore();
|
|
const { theme, setTheme } = useTheme();
|
|
const router = useRouter();
|
|
const pathname = usePathname(); // 현재 경로 추적
|
|
|
|
// 확장된 서브메뉴 관리 (기본적으로 sales, master-data 확장)
|
|
const [expandedMenus, setExpandedMenus] = useState<string[]>(['sales', 'master-data']);
|
|
|
|
// 모바일 상태 관리
|
|
const [isMobile, setIsMobile] = useState(false);
|
|
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
|
|
|
// 사용자 정보 상태
|
|
const [userName, setUserName] = useState<string>("사용자");
|
|
const [userPosition, setUserPosition] = useState<string>("직책");
|
|
|
|
// 모바일 감지
|
|
useEffect(() => {
|
|
const checkScreenSize = () => {
|
|
setIsMobile(window.innerWidth < 768);
|
|
};
|
|
checkScreenSize();
|
|
window.addEventListener('resize', checkScreenSize);
|
|
return () => window.removeEventListener('resize', checkScreenSize);
|
|
}, []);
|
|
|
|
// 서버에서 받은 사용자 정보로 초기화
|
|
useEffect(() => {
|
|
// ⚠️ Allow rendering even before hydration (Zustand persist rehydration can be slow)
|
|
// Commenting out the hydration check prevents infinite loading spinner
|
|
// if (!_hasHydrated) return;
|
|
|
|
// localStorage에서 사용자 정보 가져오기
|
|
const userDataStr = localStorage.getItem("user");
|
|
if (userDataStr) {
|
|
const userData = JSON.parse(userDataStr);
|
|
|
|
// 사용자 이름과 직책 설정
|
|
setUserName(userData.name || "사용자");
|
|
setUserPosition(userData.position || "직책");
|
|
|
|
// 서버에서 받은 메뉴 배열이 있으면 사용, 없으면 기본 메뉴 사용
|
|
if (userData.menu && Array.isArray(userData.menu) && userData.menu.length > 0) {
|
|
// SerializableMenuItem (iconName string)을 MenuItem (icon component)로 변환
|
|
const deserializedMenus = deserializeMenuItems(userData.menu as SerializableMenuItem[]);
|
|
setMenuItems(deserializedMenus);
|
|
} else {
|
|
// API가 준비될 때까지 임시 기본 메뉴
|
|
const defaultMenu: MenuItem[] = [
|
|
{ id: "dashboard", label: "대시보드", icon: LayoutDashboard, path: "/dashboard" },
|
|
{
|
|
id: "sales",
|
|
label: "판매관리",
|
|
icon: ShoppingCart,
|
|
path: "#",
|
|
children: [
|
|
{ id: "customer-management", label: "거래처관리", icon: Building2, path: "/sales/client-management-sales-admin" },
|
|
{ id: "quote-management", label: "견적관리", icon: Receipt, path: "/sales/quote-management" },
|
|
{ id: "pricing-management", label: "단가관리", icon: DollarSign, path: "/sales/pricing-management" },
|
|
],
|
|
},
|
|
{
|
|
id: "master-data",
|
|
label: "기준정보",
|
|
icon: Settings,
|
|
path: "#",
|
|
children: [
|
|
{ id: "item-master", label: "품목기준관리", icon: Package, path: "/master-data/item-master-data-management" },
|
|
],
|
|
},
|
|
];
|
|
setMenuItems(defaultMenu);
|
|
}
|
|
}
|
|
}, [_hasHydrated, setMenuItems]);
|
|
|
|
// 현재 경로에 맞는 메뉴 자동 활성화 (URL 직접 입력, 뒤로가기 대응)
|
|
useEffect(() => {
|
|
if (!pathname || menuItems.length === 0) return;
|
|
|
|
// 경로 정규화 (로케일 제거)
|
|
const normalizedPath = pathname.replace(/^\/(ko|en|ja)/, '');
|
|
|
|
// 메뉴 탐색 함수: 메인 메뉴와 서브메뉴 모두 탐색
|
|
const findActiveMenu = (items: MenuItem[]): { menuId: string; parentId?: string } | null => {
|
|
for (const item of items) {
|
|
// 서브메뉴가 있으면 먼저 확인 (더 구체적인 경로 우선)
|
|
if (item.children && item.children.length > 0) {
|
|
for (const child of item.children) {
|
|
if (child.path && normalizedPath.startsWith(child.path)) {
|
|
return { menuId: child.id, parentId: item.id };
|
|
}
|
|
}
|
|
}
|
|
|
|
// 서브메뉴에서 매칭되지 않으면 현재 메뉴 확인
|
|
if (item.path && normalizedPath.startsWith(item.path)) {
|
|
return { menuId: item.id };
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const result = findActiveMenu(menuItems);
|
|
|
|
if (result) {
|
|
// 활성 메뉴 설정
|
|
setActiveMenu(result.menuId);
|
|
|
|
// 부모 메뉴가 있으면 자동으로 확장
|
|
if (result.parentId && !expandedMenus.includes(result.parentId)) {
|
|
setExpandedMenus(prev => [...prev, result.parentId!]);
|
|
}
|
|
}
|
|
}, [pathname, menuItems, setActiveMenu, expandedMenus]);
|
|
|
|
const handleMenuClick = (menuId: string, path: string) => {
|
|
setActiveMenu(menuId);
|
|
router.push(path);
|
|
};
|
|
|
|
// 서브메뉴 토글 함수
|
|
const toggleSubmenu = (menuId: string) => {
|
|
setExpandedMenus(prev =>
|
|
prev.includes(menuId)
|
|
? prev.filter(id => id !== menuId)
|
|
: [...prev, menuId]
|
|
);
|
|
};
|
|
|
|
const handleLogout = async () => {
|
|
try {
|
|
// HttpOnly Cookie 방식: Next.js API Route로 프록시
|
|
await fetch('/api/auth/logout', {
|
|
method: 'POST',
|
|
});
|
|
|
|
// localStorage 정리
|
|
localStorage.removeItem('user');
|
|
|
|
// 로그인 페이지로 리다이렉트
|
|
router.push('/login');
|
|
} catch (error) {
|
|
console.error('로그아웃 처리 중 오류:', error);
|
|
// 에러가 나도 로그인 페이지로 이동
|
|
localStorage.removeItem('user');
|
|
router.push('/login');
|
|
}
|
|
};
|
|
|
|
// ⚠️ FIXED: Removed hydration check to prevent infinite loading spinner
|
|
// The hydration check was causing the dashboard to show a loading spinner indefinitely
|
|
// because Zustand persist rehydration was taking too long or not completing properly.
|
|
// By removing this check, we allow the component to render immediately with default values
|
|
// and update once hydration completes through the useEffect above.
|
|
|
|
return (
|
|
<div className="min-h-screen flex flex-col w-full">
|
|
{/* 헤더 - 전체 너비 상단 고정 */}
|
|
<header className="clean-glass px-8 py-5 mx-3 mt-3 mb-0 rounded-2xl clean-shadow relative overflow-hidden flex-shrink-0 sticky top-3 z-50 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
|
<div className="flex items-center justify-between relative z-10">
|
|
<div className="flex items-center space-x-6">
|
|
{/* SAM 로고 섹션 */}
|
|
<div className="flex items-center space-x-4 pr-6 border-r border-border/30">
|
|
<div className="w-12 h-12 rounded-xl flex items-center justify-center shadow-md relative overflow-hidden bg-gradient-to-br from-blue-500 to-blue-600 flex-shrink-0">
|
|
<div className="text-white font-bold text-xl">S</div>
|
|
</div>
|
|
<div>
|
|
<h1 className="text-xl font-bold text-gray-900">SAM</h1>
|
|
<p className="text-xs text-gray-500 font-medium">Smart Automation Management</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Menu 버튼 - 모바일: Sheet 열기, 데스크톱: 사이드바 토글 */}
|
|
{isMobile ? (
|
|
<Sheet open={isMobileSidebarOpen} onOpenChange={setIsMobileSidebarOpen}>
|
|
<SheetTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="rounded-xl transition-all duration-200 hover:bg-accent p-3 md:hidden">
|
|
<Menu className="h-5 w-5" />
|
|
</Button>
|
|
</SheetTrigger>
|
|
<SheetContent side="left" className="w-64 p-4 bg-sidebar">
|
|
<Sidebar
|
|
menuItems={menuItems}
|
|
activeMenu={activeMenu}
|
|
expandedMenus={expandedMenus}
|
|
sidebarCollapsed={false}
|
|
isMobile={true}
|
|
onMenuClick={handleMenuClick}
|
|
onToggleSubmenu={toggleSubmenu}
|
|
onCloseMobileSidebar={() => setIsMobileSidebarOpen(false)}
|
|
/>
|
|
</SheetContent>
|
|
</Sheet>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={toggleSidebar}
|
|
className="rounded-xl transition-all duration-200 hover:bg-accent p-3 hidden md:block"
|
|
>
|
|
<Menu className="h-5 w-5" />
|
|
</Button>
|
|
)}
|
|
|
|
{/* 검색바 */}
|
|
<div className="relative hidden lg:block">
|
|
<Search className="absolute left-5 top-1/2 transform -translate-y-1/2 text-muted-foreground h-5 w-5" />
|
|
<Input
|
|
placeholder="통합 검색..."
|
|
className="pl-14 w-96 clean-input border-0 bg-input-background/60 text-base"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-3">
|
|
{/* 테마 선택 - React 프로젝트 스타일 */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="p-3 rounded-xl hover:bg-accent transition-all duration-200">
|
|
{theme === 'light' && <Sun className="h-5 w-5" />}
|
|
{theme === 'dark' && <Moon className="h-5 w-5" />}
|
|
{theme === 'senior' && <Accessibility className="h-5 w-5" />}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => setTheme('light')}>
|
|
<Sun className="mr-2 h-4 w-4" />
|
|
일반모드
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
|
<Moon className="mr-2 h-4 w-4" />
|
|
다크모드
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setTheme('senior')}>
|
|
<Accessibility className="mr-2 h-4 w-4" />
|
|
시니어모드
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* 유저 프로필 */}
|
|
<div className="hidden lg:flex items-center space-x-3 pl-3 border-l border-border/30">
|
|
<div className="w-11 h-11 bg-primary/10 rounded-xl flex items-center justify-center clean-shadow-sm">
|
|
<User className="h-5 w-5 text-primary" />
|
|
</div>
|
|
<div className="text-sm text-left">
|
|
<p className="font-bold text-foreground text-base">{userName}</p>
|
|
<p className="text-muted-foreground text-sm">{userPosition}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 로그아웃 버튼 - 아이콘 형태 */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleLogout}
|
|
className="p-3 rounded-xl hover:bg-accent transition-all duration-200"
|
|
title="로그아웃"
|
|
>
|
|
<LogOut className="h-5 w-5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Subtle gradient overlay */}
|
|
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-primary/30 to-transparent"></div>
|
|
</header>
|
|
|
|
{/* 사이드바 + 메인 콘텐츠 영역 */}
|
|
<div className="flex flex-1 gap-3 px-3 pb-3">
|
|
{/* 데스크톱 사이드바 (모바일에서 숨김) */}
|
|
<div
|
|
className={`sticky top-[106px] self-start h-[calc(100vh-118px)] mt-3 border-none bg-transparent hidden md:block transition-all duration-300 flex-shrink-0 ${
|
|
sidebarCollapsed ? 'w-24' : 'w-64'
|
|
}`}
|
|
>
|
|
<Sidebar
|
|
menuItems={menuItems}
|
|
activeMenu={activeMenu}
|
|
expandedMenus={expandedMenus}
|
|
sidebarCollapsed={sidebarCollapsed}
|
|
isMobile={false}
|
|
onMenuClick={handleMenuClick}
|
|
onToggleSubmenu={toggleSubmenu}
|
|
/>
|
|
</div>
|
|
|
|
{/* 메인 콘텐츠 */}
|
|
<main className="flex-1 overflow-auto">
|
|
{children}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |