Files
sam-react-prod/src/layouts/AuthenticatedLayout.tsx
byeongcheolryu 4b1a3abf05 feat(WEB): 헤더 바로가기 버튼 추가 및 종합분석 목데이터 적용
- 공용 헤더에 종합분석/품질인정심사 바로가기 버튼 추가 (데스크톱/모바일)
- 종합분석 페이지 목데이터 적용 (API 호출 비활성화)
- 로그인 페이지 기본 계정 설정
- QMS 필터/모달 컴포넌트 개선
- 메뉴 폴링 및 fetch-wrapper 유틸리티 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 18:40:50 +09:00

550 lines
22 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,
ChevronLeft,
Home,
X,
BarChart3,
Award,
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger, SheetHeader, SheetTitle } 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 { useAuth } from '@/contexts/AuthContext';
import { deserializeMenuItems } from '@/lib/utils/menuTransform';
import { useMenuPolling } from '@/hooks/useMenuPolling';
interface AuthenticatedLayoutProps {
children: React.ReactNode;
}
export default function AuthenticatedLayout({ children }: AuthenticatedLayoutProps) {
const { menuItems, activeMenu, setActiveMenu, setMenuItems, sidebarCollapsed, toggleSidebar, _hasHydrated } = useMenuStore();
const { theme, setTheme } = useTheme();
const { logout } = useAuth();
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>("직책");
// 메뉴 폴링 (30초마다 메뉴 변경 확인)
// 백엔드 GET /api/v1/menus API 준비되면 자동 동작
const { restartAfterAuth } = useMenuPolling({
enabled: true,
interval: 30000, // 30초
onMenuUpdated: () => {
console.log('[Menu] 메뉴가 업데이트되었습니다');
},
onSessionExpired: () => {
console.log('[Menu] 세션 만료로 폴링 중지됨');
},
});
// 로그인 성공 후 메뉴 폴링 재시작
useEffect(() => {
const justLoggedIn = sessionStorage.getItem('auth_just_logged_in');
if (justLoggedIn === 'true') {
console.log('[Menu] 로그인 감지 - 폴링 재시작');
sessionStorage.removeItem('auth_just_logged_in');
restartAfterAuth();
}
}, [restartAfterAuth]);
// 모바일 감지
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)/, '');
// 메뉴 탐색 함수: 메인 메뉴와 서브메뉴 모두 탐색
// 경로 매칭: 정확히 일치하거나 하위 경로인 경우만 매칭 (예: /hr/attendance는 /hr/attendance-management와 매칭되면 안됨)
const isPathMatch = (menuPath: string, currentPath: string): boolean => {
if (currentPath === menuPath) return true;
// 하위 경로 확인: /menu/path/subpath 형태만 매칭 (슬래시로 구분)
return currentPath.startsWith(menuPath + '/');
};
const findActiveMenu = (items: MenuItem[]): { menuId: string; parentId?: string } | null => {
// 모든 매칭 가능한 메뉴 수집 (가장 긴 경로가 가장 구체적)
const matches: { menuId: string; parentId?: string; pathLength: number }[] = [];
for (const item of items) {
// 서브메뉴 확인
if (item.children && item.children.length > 0) {
for (const child of item.children) {
if (child.path && isPathMatch(child.path, normalizedPath)) {
matches.push({ menuId: child.id, parentId: item.id, pathLength: child.path.length });
}
}
}
// 메인 메뉴 확인
if (item.path && item.path !== '#' && isPathMatch(item.path, normalizedPath)) {
matches.push({ menuId: item.id, pathLength: item.path.length });
}
}
// 가장 긴 경로(가장 구체적인 매칭) 반환
if (matches.length > 0) {
matches.sort((a, b) => b.pathLength - a.pathLength);
return { menuId: matches[0].menuId, parentId: matches[0].parentId };
}
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 {
// AuthContext의 logout() 호출 (완전한 캐시 정리 수행)
// - Zustand 스토어 초기화
// - sessionStorage 캐시 삭제 (page_config_*, mes-*)
// - localStorage 사용자 데이터 삭제
// - 서버 로그아웃 API 호출 (HttpOnly 쿠키 삭제)
await logout();
// 로그인 페이지로 리다이렉트
router.push('/login');
} catch (error) {
console.error('로그아웃 처리 중 오류:', error);
// 에러가 나도 로그인 페이지로 이동
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.
// 현재 페이지가 대시보드인지 확인
const isDashboard = pathname?.includes('/dashboard') || activeMenu === 'dashboard';
// 이전 페이지로 이동
const handleGoBack = () => {
router.back();
};
// 홈(대시보드)으로 이동
const handleGoHome = () => {
router.push('/dashboard');
};
// 모바일 레이아웃
if (isMobile) {
return (
<div className="min-h-screen flex flex-col">
{/* 모바일 헤더 - sam-design 스타일 */}
<header className="clean-glass sticky top-0 z-40 px-4 py-4 m-3 rounded-2xl clean-shadow">
<div className="flex items-center justify-between">
{/* 좌측 영역: 대시보드일 때는 로고, 다른 페이지일 때는 이전/홈 버튼 */}
<div className="flex items-center space-x-3">
{isDashboard ? (
// 대시보드: 로고만 표시
<div className="flex items-center space-x-3 ml-4">
<div className="w-10 h-10 min-w-10 min-h-10 flex-shrink-0 aspect-square rounded-xl flex items-center justify-center shadow-md relative overflow-hidden bg-gradient-to-br from-blue-500 to-blue-600">
<div className="text-white font-bold text-lg">S</div>
</div>
<div>
<h1 className="font-bold text-foreground text-left">SAM</h1>
</div>
</div>
) : (
// 다른 페이지: 이전/홈 버튼 표시
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
className="min-w-[44px] min-h-[44px] p-0 rounded-xl hover:bg-accent transition-all duration-200 flex items-center justify-center"
onClick={handleGoBack}
title="이전 페이지"
>
<ChevronLeft className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="sm"
className="min-w-[44px] min-h-[44px] p-0 rounded-xl hover:bg-accent transition-all duration-200 flex items-center justify-center"
onClick={handleGoHome}
title="홈으로"
>
<Home className="h-5 w-5" />
</Button>
</div>
)}
</div>
{/* 우측 영역: 종합분석, 품질인정심사, 검색, 테마, 유저, 메뉴 */}
<div className="flex items-center space-x-1">
{/* 종합분석 바로가기 */}
<Button
variant="default"
size="sm"
onClick={() => router.push('/reports/comprehensive-analysis')}
className="min-w-[44px] min-h-[44px] p-0 rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center"
title="종합분석"
>
<BarChart3 className="h-5 w-5" />
</Button>
{/* 품질인정심사 바로가기 */}
<Button
variant="default"
size="sm"
onClick={() => router.push('/quality/qms')}
className="min-w-[44px] min-h-[44px] p-0 rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white flex items-center justify-center"
title="품질인정심사"
>
<Award className="h-5 w-5" />
</Button>
{/* 검색 아이콘 */}
<Button
variant="ghost"
size="sm"
className="min-w-[44px] min-h-[44px] p-0 rounded-xl hover:bg-accent transition-all duration-200 flex items-center justify-center"
>
<Search className="h-5 w-5" />
</Button>
{/* 테마 토글 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="min-w-[44px] min-h-[44px] p-0 rounded-xl hover:bg-accent transition-all duration-200 flex items-center justify-center">
{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="min-w-[44px] min-h-[44px] w-11 h-11 bg-muted rounded-xl flex items-center justify-center clean-shadow-sm">
<User className="h-4 w-4 text-primary" />
</div>
{/* 로그아웃 버튼 */}
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
className="min-w-[44px] min-h-[44px] p-0 rounded-xl hover:bg-accent transition-all duration-200 flex items-center justify-center"
title="로그아웃"
>
<LogOut className="h-5 w-5" />
</Button>
{/* 햄버거 메뉴 - 맨 우측 */}
<Sheet open={isMobileSidebarOpen} onOpenChange={setIsMobileSidebarOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="sm" className="min-w-[44px] min-h-[44px] p-0 rounded-xl hover:bg-accent transition-all duration-200 flex items-center justify-center">
<Menu className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-80 p-1 bg-transparent border-none">
<SheetHeader className="sr-only">
<SheetTitle></SheetTitle>
</SheetHeader>
<Sidebar
menuItems={menuItems}
activeMenu={activeMenu}
expandedMenus={expandedMenus}
sidebarCollapsed={false}
isMobile={true}
onMenuClick={handleMenuClick}
onToggleSubmenu={toggleSubmenu}
onCloseMobileSidebar={() => setIsMobileSidebarOpen(false)}
/>
</SheetContent>
</Sheet>
</div>
</div>
</header>
{/* 모바일 콘텐츠 */}
<main className="flex-1 overflow-auto px-3">
{children}
</main>
</div>
);
}
// 데스크톱 레이아웃
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-foreground">SAM</h1>
<p className="text-xs text-muted-foreground font-medium">Smart Automation Management</p>
</div>
</div>
{/* Menu 버튼 - 사이드바 토글 */}
<Button
variant="ghost"
size="sm"
onClick={toggleSidebar}
className="rounded-xl transition-all duration-200 hover:bg-accent p-3"
>
<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">
{/* 종합분석 바로가기 버튼 */}
<Button
variant="default"
size="sm"
onClick={() => router.push('/reports/comprehensive-analysis')}
className="rounded-xl bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 flex items-center gap-2 transition-all duration-200"
>
<BarChart3 className="h-4 w-4" />
<span className="hidden xl:inline"></span>
</Button>
{/* 품질인정심사 바로가기 버튼 */}
<Button
variant="default"
size="sm"
onClick={() => router.push('/quality/qms')}
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 flex items-center gap-2 transition-all duration-200"
>
<Award className="h-4 w-4" />
<span className="hidden xl:inline"></span>
</Button>
{/* 테마 선택 - 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 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 p-3 md:p-6 pb-0">
{children}
</main>
</div>
</div>
);
}