'use client'; import { useMenuStore } from '@/stores/menuStore'; import type { SerializableMenuItem } from '@/stores/menuStore'; import type { MenuItem } from '@/stores/menuStore'; import { useRouter, usePathname } from 'next/navigation'; import Image from 'next/image'; import { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { Menu, Search, User, LogOut, LayoutDashboard, Sun, Moon, Accessibility, ShoppingCart, Building2, Receipt, Package, Settings, DollarSign, ChevronLeft, Home, X, Bell, Clock, Minus, Plus, Type, } 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 HeaderFavoritesBar from '@/components/layout/HeaderFavoritesBar'; import CommandMenuSearch, { type CommandMenuSearchRef } from '@/components/layout/CommandMenuSearch'; import { useTheme } from '@/contexts/ThemeContext'; import { useAuth } from '@/contexts/AuthContext'; import { deserializeMenuItems } from '@/lib/utils/menuTransform'; import { stripLocalePrefix } from '@/lib/utils/locale'; import { safeJsonParse } from '@/lib/utils'; 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; // 뱃지 색상 매핑 (TodayIssueSection과 동기화) const BADGE_COLORS: Record = { '수주등록': 'bg-blue-100 text-blue-700', '추심이슈': 'bg-purple-100 text-purple-700', '안전재고': 'bg-orange-100 text-orange-700', '지출승인': 'bg-green-100 text-green-700', '세금신고': 'bg-red-100 text-red-700', '결재요청': 'bg-yellow-100 text-yellow-700', '신규업체': 'bg-emerald-100 text-emerald-700', '입금': 'bg-cyan-100 text-cyan-700', '출금': 'bg-pink-100 text-pink-700', '기타': 'bg-gray-100 text-gray-700', }; 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(); // 현재 경로 추적 // 폰트 크기 조절 (12~20px, 기본 16px) const FONT_SIZES = [12, 13, 14, 15, 16, 17, 18, 19, 20] as const; const [fontSize, setFontSize] = useState(16); useEffect(() => { // 초기값: localStorage 또는 CSS 변수에서 읽기 const saved = localStorage.getItem('sam-font-size'); if (saved) { const size = parseInt(saved, 10); if (size >= 12 && size <= 20) { setFontSize(size); document.documentElement.style.setProperty('--font-size', `${size}px`); } } }, []); const handleFontSizeChange = useCallback((size: number) => { const clamped = Math.max(12, Math.min(20, size)); setFontSize(clamped); document.documentElement.style.setProperty('--font-size', `${clamped}px`); localStorage.setItem('sam-font-size', String(clamped)); }, []); // 확장된 서브메뉴 관리 (기본적으로 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"); // Command Menu Search ref const commandMenuRef = useRef(null); // 알림 관련 상태 const [notificationEnabled, setNotificationEnabled] = useState(true); const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); const [isPollingPaused, setIsPollingPaused] = useState(false); // 토큰 갱신 중 폴링 일시 중지 // 알림 벨 애니메이션 (알림 켜져 있고 읽지 않은 알림이 있을 때만) const bellAnimating = useMemo(() => notificationEnabled && unreadCount > 0, [notificationEnabled, unreadCount]); // 🔧 클라이언트 마운트 상태 (새로고침 시 스피너 표시용) const [isMounted, setIsMounted] = useState(false); // 🔧 클라이언트 마운트 확인 (새로고침 시 스피너 표시) useEffect(() => { setIsMounted(true); }, []); // 🔧 스크롤바 자동 숨김 - 스크롤 시에만 스크롤바 thumb 표시 useEffect(() => { const scrollTimers = new WeakMap>(); const handleScroll = (e: Event) => { const target = e.target === document ? document.documentElement : e.target as Element; if (!target || !(target instanceof Element)) return; target.classList.add('is-scrolling'); const existing = scrollTimers.get(target); if (existing) clearTimeout(existing); const timer = setTimeout(() => { target.classList.remove('is-scrolling'); scrollTimers.delete(target); }, 1500); scrollTimers.set(target, timer); }; document.addEventListener('scroll', handleScroll, { capture: true, passive: true }); return () => { document.removeEventListener('scroll', handleScroll, { capture: true }); }; }, []); // 토큰 갱신 함수 const refreshToken = useCallback(async (): Promise => { try { const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include', }); if (response.ok) { return true; } else { console.warn('[Notification] 토큰 갱신 실패:', response.status); return false; } } catch (error) { console.error('[Notification] 토큰 갱신 오류:', error); return false; } }, []); // 알림 데이터 가져오기 함수 const fetchNotifications = useCallback(async (isRetry: boolean = false) => { // 폴링이 일시 중지 상태면 건너뛰기 if (isPollingPaused && !isRetry) { return; } try { const response = await getUnreadTodayIssues(10); // 인증 에러인 경우 if (response.authError) { console.warn('[Notification] 인증 에러 감지 - 토큰 갱신 시도'); // 이미 재시도 중이면 무한 루프 방지 if (isRetry) { console.error('[Notification] 토큰 갱신 후에도 인증 에러 - 폴링 중지'); setIsPollingPaused(true); return; } // 폴링 일시 중지 setIsPollingPaused(true); // 토큰 갱신 시도 const refreshSuccess = await refreshToken(); if (refreshSuccess) { // 토큰 갱신 성공 - 폴링 재개 및 재시도 setIsPollingPaused(false); await fetchNotifications(true); // 재시도 플래그로 호출 } else { // 토큰 갱신 실패 - 폴링 유지 중지 (로그인 필요) console.error('[Notification] 토큰 갱신 실패 - 폴링 중지, 재로그인 필요'); // 여기서 로그아웃하거나 알림을 보여줄 수 있음 } return; } // 정상 응답 if (response.success && response.data) { setNotifications(response.data.items); setUnreadCount(response.data.total); // 혹시 이전에 폴링이 중지되어 있었다면 재개 if (isPollingPaused) { setIsPollingPaused(false); } } } catch (error) { console.error('[Notification] 알림 조회 실패:', error); } }, [isPollingPaused, refreshToken]); // 알림 폴링 (30초마다) useEffect(() => { if (!isMounted) return; // 초기 로드 fetchNotifications(); // 폴링 시작 (폴링이 중지되지 않은 경우에만) const intervalId = setInterval(() => { if (!isPollingPaused) { fetchNotifications(); } }, NOTIFICATION_POLLING_INTERVAL); return () => { clearInterval(intervalId); }; }, [isMounted, fetchNotifications, isPollingPaused]); // 알림 클릭 핸들러 (읽음 처리 + 페이지 이동) 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: () => { }, onSessionExpired: () => { }, }); // 로그인 성공 후 메뉴 폴링 재시작 useEffect(() => { const justLoggedIn = sessionStorage.getItem('auth_just_logged_in'); if (justLoggedIn === 'true') { 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 = safeJsonParse | null>(userDataStr, null); if (!userData) return; // 사용자 이름, 직책, 이메일, 회사 설정 setUserName((userData.name as string) || "사용자"); setUserPosition((userData.position as string) || "직책"); setUserEmail((userData.email as string) || ""); setUserCompany((userData.company as string) || (userData.company_name as string) || ""); // 서버에서 받은 메뉴 배열이 있으면 사용, 없으면 기본 메뉴 사용 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); } // 즐겨찾기는 사용자가 사이드바에서 별표로 직접 추가 // useFavoritesStore.getState().initializeIfEmpty(DEFAULT_FAVORITES); } }, [_hasHydrated, setMenuItems]); // 현재 경로에 맞는 메뉴 자동 활성화 (URL 직접 입력, 뒤로가기 대응) // 3depth 이상 메뉴 구조 지원 useEffect(() => { if (!pathname || menuItems.length === 0) return; // 경로 정규화 (로케일 제거) const normalizedPath = stripLocalePrefix(pathname); // 메뉴 탐색 함수: 메인 메뉴와 서브메뉴 모두 탐색 // 경로 매칭: 정확히 일치하거나 하위 경로인 경우만 매칭 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 toggleAllMenus = useCallback(() => { if (expandedMenus.length > 0) { // 하나라도 열려있으면 전부 닫기 setExpandedMenus([]); } else { // 전부 닫혀있으면 children 있는 메뉴 전부 열기 const menusWithChildren = menuItems .filter(item => item.children && item.children.length > 0) .map(item => item.id); setExpandedMenus(menusWithChildren); } }, [expandedMenus, menuItems]); 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 ( <> {/* 모바일 스켈레톤 (md 미만) */}
{/* 모바일 헤더 스켈레톤 */}
{/* 왼쪽: 햄버거 + 로고 */}
{/* 오른쪽: 버튼들 */}
{/* 모바일 콘텐츠 스켈레톤 */}
{[1, 2, 3, 4].map((i) => (
))}
{/* 데스크톱 스켈레톤 (md 이상) */}
{/* 헤더 스켈레톤 - 상세 버전 */}
{/* 왼쪽: 로고 + 메뉴버튼 + 검색바 */}
{/* 로고 영역 */}
{/* 메뉴 버튼 */}
{/* 검색바 */}
{/* 오른쪽: 버튼들 */}
{/* 사이드바 + 콘텐츠 스켈레톤 */}
{/* 사이드바 스켈레톤 - 상세 버전 */}
{/* 메뉴 아이콘 스켈레톤 (13개) */} {[...Array(13)].map((_, i) => (
))}
{/* 콘텐츠 스켈레톤 */}
{/* 페이지 헤더 */}
{/* 콘텐츠 카드 */}
{[1, 2, 3, 4, 5, 6].map((i) => (
))}
); } // 현재 페이지가 대시보드인지 확인 const isDashboard = pathname?.includes('/dashboard') || activeMenu === 'dashboard'; // 이전 페이지로 이동 const handleGoBack = () => { router.back(); }; // 홈(대시보드)으로 이동 const handleGoHome = () => { router.push('/dashboard'); }; // 모바일 레이아웃 if (isMobile) { return (
{/* 모바일 헤더 - sam-design 스타일 */}
{/* 좌측 영역: 햄버거 메뉴 + (대시보드일 때는 로고, 다른 페이지일 때는 이전/홈 버튼) */}
{/* 햄버거 메뉴 - 좌측 맨 앞 */} 메뉴 setIsMobileSidebarOpen(false)} /> {isDashboard ? ( // 대시보드: 로고만 표시
SAM

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' && } {/* 구분선 */}
{/* 글자 크기 조절 */}
글자 크기
{fontSize}px
{/* 구분선 */}
{/* 로그아웃 */} 로그아웃
{/* 모바일 콘텐츠 */}
{children}
{/* 메뉴 검색 Command Palette (Ctrl+K / Cmd+K) */}
); } // 데스크톱 레이아웃 return (
{/* 헤더 - 전체 너비 상단 고정 */}
{/* SAM 로고 섹션 - 클릭 시 대시보드로 이동 */}
SAM

SAM

Smart Automation Management

{/* Menu 버튼 - 사이드바 토글 */} {/* 검색바 - 클릭 시 Command Palette 열기 */}
commandMenuRef.current?.open()} >
메뉴 검색... K
{/* 즐겨찾기 바로가기 */} {/* 알림 버튼 - 드롭다운 */} {/* 헤더: 알림 토글 + 모두 읽음 */}
알림 {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' && } {/* 구분선 */}
{/* 글자 크기 조절 */}
글자 크기
{fontSize}px
{/* 구분선 */}
{/* 로그아웃 */} 로그아웃
{/* Subtle gradient overlay */}
{/* 사이드바 + 메인 콘텐츠 영역 */}
{/* 데스크톱 사이드바 */}
{/* 메인 콘텐츠 */}
{children}
{/* 메뉴 검색 Command Palette (Ctrl+K / Cmd+K) */}
); }