'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, useMemo, useCallback } from 'react'; import { Menu, Search, User, LogOut, LayoutDashboard, Sun, Moon, Accessibility, ShoppingCart, Building2, Receipt, Package, Settings, DollarSign, ChevronLeft, Home, X, BarChart3, Award, Bell, Clock, } 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'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; // TodayIssue 타입 및 API import type { TodayIssueUnreadItem } from '@/types/today-issue'; import { getUnreadTodayIssues, markTodayIssueAsRead, markAllTodayIssuesAsRead, } from '@/lib/api/today-issue'; // 목업 회사 데이터 const MOCK_COMPANIES = [ { id: 'all', name: '전체' }, { id: 'company1', name: '(주)삼성건설' }, { id: 'company2', name: '현대건설(주)' }, { id: 'company3', name: '대우건설(주)' }, { id: 'company4', name: 'GS건설(주)' }, ]; // 알림 폴링 간격 (30초) const NOTIFICATION_POLLING_INTERVAL = 30000; 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(['sales', 'master-data']); // 모바일 상태 관리 const [isMobile, setIsMobile] = useState(false); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); // 사용자 정보 상태 const [userName, setUserName] = useState("사용자"); const [userPosition, setUserPosition] = useState("직책"); const [userEmail, setUserEmail] = useState(""); const [userCompany, setUserCompany] = useState(""); // 회사 선택 상태 (목업) const [selectedCompany, setSelectedCompany] = useState("all"); // 알림 관련 상태 const [notificationEnabled, setNotificationEnabled] = useState(true); const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); // 알림 벨 애니메이션 (알림 켜져 있고 읽지 않은 알림이 있을 때만) const bellAnimating = useMemo(() => notificationEnabled && unreadCount > 0, [notificationEnabled, unreadCount]); // 🔧 클라이언트 마운트 상태 (새로고침 시 스피너 표시용) const [isMounted, setIsMounted] = useState(false); // 🔧 클라이언트 마운트 확인 (새로고침 시 스피너 표시) useEffect(() => { setIsMounted(true); }, []); // 알림 데이터 가져오기 함수 const fetchNotifications = useCallback(async () => { try { const response = await getUnreadTodayIssues(10); if (response.success && response.data) { setNotifications(response.data.items); setUnreadCount(response.data.total); } } catch (error) { console.error('[Notification] 알림 조회 실패:', error); } }, []); // 알림 폴링 (30초마다) useEffect(() => { if (!isMounted) return; // 초기 로드 fetchNotifications(); // 폴링 시작 const intervalId = setInterval(fetchNotifications, NOTIFICATION_POLLING_INTERVAL); return () => { clearInterval(intervalId); }; }, [isMounted, fetchNotifications]); // 알림 클릭 핸들러 (읽음 처리 + 페이지 이동) const handleNotificationClick = useCallback(async (notification: TodayIssueUnreadItem) => { try { // 읽음 처리 await markTodayIssueAsRead(notification.id); // 로컬 상태 업데이트 (해당 알림 제거) setNotifications(prev => prev.filter(n => n.id !== notification.id)); setUnreadCount(prev => Math.max(0, prev - 1)); // 페이지 이동 (path가 있으면) if (notification.path) { router.push(notification.path); } } catch (error) { console.error('[Notification] 읽음 처리 실패:', error); } }, [router]); // 모두 읽음 처리 핸들러 const handleMarkAllAsRead = useCallback(async () => { try { const response = await markAllTodayIssuesAsRead(); if (response.success) { // 로컬 상태 초기화 setNotifications([]); setUnreadCount(0); } } catch (error) { console.error('[Notification] 모두 읽음 처리 실패:', error); } }, []); // 메뉴 폴링 (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]); // 모바일 감지 + 폴더블 기기 대응 (Galaxy Fold 등) // 🔧 Debounce 적용: resize 시 API 중복 호출 방지 // - isMobile 상태 변경 시 전체 자식 컴포넌트가 리마운트됨 // - debounce 없이 빠른 resize 시 API가 여러 번 호출되는 문제 해결 useEffect(() => { let debounceTimer: ReturnType | null = null; const updateViewport = () => { // visualViewport API 우선 사용 (폴더블 기기에서 더 정확) const width = window.visualViewport?.width ?? window.innerWidth; const height = window.visualViewport?.height ?? window.innerHeight; // CSS 변수는 즉시 업데이트 (레이아웃 깨짐 방지) document.documentElement.style.setProperty('--app-width', `${width}px`); document.documentElement.style.setProperty('--app-height', `${height}px`); // isMobile 상태 변경은 debounce 적용 (컴포넌트 리마운트 최소화) if (debounceTimer) { clearTimeout(debounceTimer); } debounceTimer = setTimeout(() => { setIsMobile(width < 768); }, 300); // 300ms debounce - 데이터 로딩 안정성 개선 }; // 초기 값 설정 (debounce 없이 즉시) const width = window.visualViewport?.width ?? window.innerWidth; const height = window.visualViewport?.height ?? window.innerHeight; setIsMobile(width < 768); document.documentElement.style.setProperty('--app-width', `${width}px`); document.documentElement.style.setProperty('--app-height', `${height}px`); // resize 이벤트 window.addEventListener('resize', updateViewport); // visualViewport resize 이벤트 (폴더블 기기 화면 전환 감지) window.visualViewport?.addEventListener('resize', updateViewport); return () => { if (debounceTimer) { clearTimeout(debounceTimer); } window.removeEventListener('resize', updateViewport); window.visualViewport?.removeEventListener('resize', updateViewport); }; }, []); // 서버에서 받은 사용자 정보로 초기화 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 || "직책"); setUserEmail(userData.email || ""); setUserCompany(userData.company || userData.company_name || ""); // 서버에서 받은 메뉴 배열이 있으면 사용, 없으면 기본 메뉴 사용 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 직접 입력, 뒤로가기 대응) // 3depth 이상 메뉴 구조 지원 useEffect(() => { if (!pathname || menuItems.length === 0) return; // 경로 정규화 (로케일 제거) const normalizedPath = pathname.replace(/^\/(ko|en|ja)/, ''); // 메뉴 탐색 함수: 메인 메뉴와 서브메뉴 모두 탐색 // 경로 매칭: 정확히 일치하거나 하위 경로인 경우만 매칭 const isPathMatch = (menuPath: string, currentPath: string): boolean => { if (currentPath === menuPath) return true; return currentPath.startsWith(menuPath + '/'); }; // 재귀적으로 모든 depth의 메뉴를 탐색 (3depth 이상 지원) type MenuMatch = { menuId: string; ancestorIds: string[]; pathLength: number }; const findMenuRecursive = ( items: MenuItem[], currentPath: string, ancestors: string[] = [] ): MenuMatch[] => { const matches: MenuMatch[] = []; for (const item of items) { // 현재 메뉴의 경로 매칭 확인 if (item.path && item.path !== '#' && isPathMatch(item.path, currentPath)) { matches.push({ menuId: item.id, ancestorIds: ancestors, pathLength: item.path.length, }); } // 자식 메뉴가 있으면 재귀적으로 탐색 if (item.children && item.children.length > 0) { const childMatches = findMenuRecursive( item.children, currentPath, [...ancestors, item.id] // 현재 메뉴를 조상 목록에 추가 ); matches.push(...childMatches); } } return matches; }; const matches = findMenuRecursive(menuItems, normalizedPath); if (matches.length > 0) { // 가장 긴 경로(가장 구체적인 매칭) 선택 matches.sort((a, b) => b.pathLength - a.pathLength); const result = matches[0]; // 활성 메뉴 설정 setActiveMenu(result.menuId); // 모든 조상 메뉴를 확장 (3depth 이상 지원) if (result.ancestorIds.length > 0) { setExpandedMenus(prev => { const newExpanded = [...prev]; result.ancestorIds.forEach(id => { if (!newExpanded.includes(id)) { newExpanded.push(id); } }); return newExpanded; }); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [pathname, menuItems, setActiveMenu]); const handleMenuClick = (menuId: string, path: string) => { // 네비게이션 우선 실행 - setActiveMenu는 pathname 기반 useEffect가 처리 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. // 🔧 새로고침 시 스피너 표시 (hydration 전) if (!isMounted) { return (

로딩 중...

); } // 현재 페이지가 대시보드인지 확인 const isDashboard = pathname?.includes('/dashboard') || activeMenu === 'dashboard'; // 이전 페이지로 이동 const handleGoBack = () => { router.back(); }; // 홈(대시보드)으로 이동 const handleGoHome = () => { router.push('/dashboard'); }; // 모바일 레이아웃 if (isMobile) { return (
{/* 모바일 헤더 - sam-design 스타일 */}
{/* 좌측 영역: 햄버거 메뉴 + (대시보드일 때는 로고, 다른 페이지일 때는 이전/홈 버튼) */}
{/* 햄버거 메뉴 - 좌측 맨 앞 */} 메뉴 setIsMobileSidebarOpen(false)} /> {isDashboard ? ( // 대시보드: 로고만 표시
S

SAM

) : ( // 다른 페이지: 이전/홈 버튼 표시
)}
{/* 우측 영역: 종합분석, 품질인정심사, 유저 드롭다운, 메뉴 */}
{/* 종합분석 바로가기 */} {/* 품질인정심사 바로가기 */} {/* 알림 버튼 - 드롭다운 */} {/* 헤더: 알림 토글 + 모두 읽음 */}
알림 {unreadCount > 0 && ( ({unreadCount}) )}
{notifications.length > 0 && ( )}
{/* 알림 리스트 - 3개 초과시 스크롤 */}
{notifications.length === 0 ? (
새로운 알림이 없습니다
) : ( notifications.map((notification) => (
handleNotificationClick(notification)} className="flex items-start gap-3 p-3 border-b border-border hover:bg-accent/50 cursor-pointer" > {/* 배지 */}
{notification.badge}
{/* 내용 */}

{notification.content}

{notification.time}
{/* 승인 필요 표시 */} {notification.needs_approval && ( ! )}
)) )}
{/* 유저 프로필 드롭다운 */} {/* 사용자 정보 헤더 */}

{userName}

{userPosition}

{/* 출근하기 버튼 */}
{/* 회사 선택 (목업) */}

회사 선택

{/* 테마 선택 */}
setTheme('light')} className="rounded-lg"> 일반모드 {theme === 'light' && } setTheme('dark')} className="rounded-lg"> 다크모드 {theme === 'dark' && } setTheme('senior')} className="rounded-lg"> 시니어모드 {theme === 'senior' && } {/* 구분선 */}
{/* 로그아웃 */} 로그아웃
{/* 모바일 콘텐츠 */}
{children}
); } // 데스크톱 레이아웃 return (
{/* 헤더 - 전체 너비 상단 고정 */}
{/* SAM 로고 섹션 - 클릭 시 대시보드로 이동 */}
S

SAM

Smart Automation Management

{/* Menu 버튼 - 사이드바 토글 */} {/* 검색바 */}
{/* 종합분석 바로가기 버튼 */} {/* 품질인정심사 바로가기 버튼 */} {/* 알림 버튼 - 드롭다운 */} {/* 헤더: 알림 토글 + 모두 읽음 */}
알림 {unreadCount > 0 && ( ({unreadCount}) )}
{notifications.length > 0 && ( )}
{/* 알림 리스트 - 3개 초과시 스크롤 */}
{notifications.length === 0 ? (
새로운 알림이 없습니다
) : ( notifications.map((notification) => (
handleNotificationClick(notification)} className="flex items-start gap-3 p-3 border-b border-border hover:bg-accent/50 cursor-pointer" > {/* 배지 */}
{notification.badge}
{/* 내용 */}

{notification.content}

{notification.time}
{/* 승인 필요 표시 */} {notification.needs_approval && ( ! )}
)) )}
{/* 유저 프로필 드롭다운 */} {/* 사용자 정보 헤더 */}

{userName}

{userPosition}

{/* 회사 선택 (목업) */}

회사 선택

{/* 테마 선택 */}
setTheme('light')} className="rounded-lg"> 일반모드 {theme === 'light' && } setTheme('dark')} className="rounded-lg"> 다크모드 {theme === 'dark' && } setTheme('senior')} className="rounded-lg"> 시니어모드 {theme === 'senior' && } {/* 구분선 */}
{/* 로그아웃 */} 로그아웃
{/* Subtle gradient overlay */}
{/* 사이드바 + 메인 콘텐츠 영역 */}
{/* 데스크톱 사이드바 */}
{/* 메인 콘텐츠 */}
{children}
); }