diff --git a/src/app/[locale]/(protected)/quality/qms/components/Filters.tsx b/src/app/[locale]/(protected)/quality/qms/components/Filters.tsx index 920a7b3e..2408ac22 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/Filters.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/Filters.tsx @@ -24,64 +24,65 @@ export const Filters = ({ const years = [2025, 2024, 2023, 2022, 2021]; return ( -
- {/* Year Selection */} -
- 년도 -
- -
-
- - {/* Quarter Selection */} -
- 분기 -
- {quarters.map((q) => ( - + ))} +
- {/* Search Input */} -
+ {/* 하단: 검색 입력 + 버튼 */} +
검색 -
- - onSearchChange(e.target.value)} - className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-md text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" - /> +
+
+ + onSearchChange(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-md text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + /> +
+
- - {/* Search Button */} -
- -
); }; \ No newline at end of file diff --git a/src/app/[locale]/(protected)/quality/qms/components/InspectionModal.tsx b/src/app/[locale]/(protected)/quality/qms/components/InspectionModal.tsx index 2edd41c5..c02c7064 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/InspectionModal.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/InspectionModal.tsx @@ -1,13 +1,13 @@ "use client"; -import React from 'react'; +import React, { useState, useRef, useCallback, useEffect } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { ZoomIn, ZoomOut, RotateCw, Download, Printer, AlertCircle } from 'lucide-react'; +import { ZoomIn, ZoomOut, Download, Printer, AlertCircle, Maximize2 } from 'lucide-react'; import { Button } from "@/components/ui/button"; import { Document, DocumentItem } from '../types'; import { MOCK_ORDER_DATA, MOCK_WORK_ORDER, MOCK_SHIPMENT_DETAIL } from '../mockData'; @@ -324,7 +324,102 @@ const WorkLogDocument = () => { ); }; +// 줌 레벨 상수 +const ZOOM_LEVELS = [50, 75, 100, 125, 150, 200]; +const MIN_ZOOM = 50; +const MAX_ZOOM = 200; + export const InspectionModal = ({ isOpen, onClose, document: doc, documentItem }: InspectionModalProps) => { + // 줌 상태 + const [zoom, setZoom] = useState(100); + + // 드래그 상태 + const [isDragging, setIsDragging] = useState(false); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [startPos, setStartPos] = useState({ x: 0, y: 0 }); + + // refs + const containerRef = useRef(null); + const contentRef = useRef(null); + + // 모달 열릴 때 상태 초기화 + useEffect(() => { + if (isOpen) { + setZoom(100); + setPosition({ x: 0, y: 0 }); + } + }, [isOpen]); + + // 줌 인 + const handleZoomIn = useCallback(() => { + setZoom(prev => { + const nextIndex = ZOOM_LEVELS.findIndex(z => z > prev); + return nextIndex !== -1 ? ZOOM_LEVELS[nextIndex] : MAX_ZOOM; + }); + }, []); + + // 줌 아웃 + const handleZoomOut = useCallback(() => { + setZoom(prev => { + const prevIndex = ZOOM_LEVELS.slice().reverse().findIndex(z => z < prev); + const index = prevIndex !== -1 ? ZOOM_LEVELS.length - 1 - prevIndex : 0; + return ZOOM_LEVELS[index] || MIN_ZOOM; + }); + }, []); + + // 줌 리셋 + const handleZoomReset = useCallback(() => { + setZoom(100); + setPosition({ x: 0, y: 0 }); + }, []); + + // 마우스 드래그 시작 + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (zoom > 100) { + setIsDragging(true); + setStartPos({ x: e.clientX - position.x, y: e.clientY - position.y }); + } + }, [zoom, position]); + + // 마우스 이동 + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (!isDragging) return; + setPosition({ + x: e.clientX - startPos.x, + y: e.clientY - startPos.y, + }); + }, [isDragging, startPos]); + + // 마우스 드래그 종료 + const handleMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + // 터치 드래그 시작 + const handleTouchStart = useCallback((e: React.TouchEvent) => { + if (zoom > 100 && e.touches.length === 1) { + setIsDragging(true); + setStartPos({ + x: e.touches[0].clientX - position.x, + y: e.touches[0].clientY - position.y, + }); + } + }, [zoom, position]); + + // 터치 이동 + const handleTouchMove = useCallback((e: React.TouchEvent) => { + if (!isDragging || e.touches.length !== 1) return; + setPosition({ + x: e.touches[0].clientX - startPos.x, + y: e.touches[0].clientY - startPos.y, + }); + }, [isDragging, startPos]); + + // 터치 종료 + const handleTouchEnd = useCallback(() => { + setIsDragging(false); + }, []); + if (!doc) return null; const docInfo = DOCUMENT_INFO[doc.type] || { label: doc.title, hasTemplate: false, color: 'text-gray-600' }; @@ -407,33 +502,84 @@ export const InspectionModal = ({ isOpen, onClose, document: doc, documentItem } {/* Toolbar */} -
-
- - - + + {zoom}% +
-
- 100% - -
- {/* Content Area - 남은 공간 모두 사용 */} -
- {renderDocumentContent()} + {/* Content Area - 줌/드래그 가능한 영역 */} +
100 ? (isDragging ? 'grabbing' : 'grab') : 'default' }} + > +
+ {renderDocumentContent()} +
+ + {/* 모바일 줌 힌트 */} + {zoom === 100 && ( +
+ 확대 후 드래그로 이동 +
+ )} ); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index c52a131a..d941f281 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -158,6 +158,7 @@ export async function POST(request: NextRequest) { 'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility) 'Path=/', `Max-Age=${data.expires_in || 7200}`, + // `Max-Age=10` // 여기서만 10초 하면 최초 1회만 10 초 후에 액세스 끊어짐 구동테스트 완벽하게 하기 위해서는 refresh 쪽도 10초로 수정해야 함 ].join('; '); const refreshTokenCookie = [ @@ -166,7 +167,7 @@ export async function POST(request: NextRequest) { ...(isProduction ? ['Secure'] : []), // ✅ HTTPS only in production (Safari fix) 'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility) 'Path=/', - 'Max-Age=604800', // 7 days (longer for refresh token) + 'Max-Age=604800', // TODO: 테스트용 10초, 원래 604800 (7 days) ].join('; '); console.log('✅ Login successful - Access & Refresh tokens stored in HttpOnly cookies'); diff --git a/src/components/auth/LoginPage.tsx b/src/components/auth/LoginPage.tsx index c25ea9bc..919f54bf 100644 --- a/src/components/auth/LoginPage.tsx +++ b/src/components/auth/LoginPage.tsx @@ -22,8 +22,8 @@ export function LoginPage() { const t = useTranslations('auth'); const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); - const [userId, setUserId] = useState(""); - const [password, setPassword] = useState(""); + const [userId, setUserId] = useState("TestUser5"); + const [password, setPassword] = useState("password123!"); const [showPassword, setShowPassword] = useState(false); const [rememberMe, setRememberMe] = useState(false); const [error, setError] = useState(""); @@ -130,6 +130,9 @@ export function LoginPage() { console.log('💾 localStorage에 저장할 데이터:', userData); localStorage.setItem('user', JSON.stringify(userData)); + // 메뉴 폴링 재시작 플래그 설정 (세션 만료 후 재로그인 시) + sessionStorage.setItem('auth_just_logged_in', 'true'); + // 대시보드로 이동 router.push("/dashboard"); } catch (err) { diff --git a/src/components/reports/ComprehensiveAnalysis/index.tsx b/src/components/reports/ComprehensiveAnalysis/index.tsx index a7fea007..15d2a507 100644 --- a/src/components/reports/ComprehensiveAnalysis/index.tsx +++ b/src/components/reports/ComprehensiveAnalysis/index.tsx @@ -34,15 +34,96 @@ const formatAmount = (amount: number): string => { return new Intl.NumberFormat('ko-KR').format(amount) + '원'; }; -// 기본 데이터 +// 기본 데이터 (목데이터) const defaultData: ComprehensiveAnalysisData = { - todayIssue: { filterOptions: ['전체필터'], items: [] }, - monthlyExpense: { cards: [], checkPoints: [] }, - cardManagement: { cards: [], checkPoints: [] }, - entertainment: { cards: [], checkPoints: [] }, - welfare: { cards: [], checkPoints: [] }, - receivable: { cards: [], checkPoints: [], hasDetailButton: true, detailButtonLabel: '거래처별 미수금 현황', detailButtonPath: '/accounting/receivables-status' }, - debtCollection: { cards: [], checkPoints: [] }, + todayIssue: { + filterOptions: ['전체필터', '결재', '매출', '매입', '자금'], + items: [ + { id: '1', category: '결재', description: '지출결의서 #2024-001 승인 대기 중', requiresApproval: true, time: '09:30' }, + { id: '2', category: '매출', description: '(주)대한전자 외상매출금 90일 초과', requiresApproval: false, time: '10:15' }, + { id: '3', category: '자금', description: '법인카드 한도 초과 경고', requiresApproval: false, time: '11:00' }, + ], + }, + monthlyExpense: { + cards: [ + { id: 'me1', label: '매입', amount: 3123000, previousAmount: 2826000, previousLabel: '전월 대비 +10.5%' }, + { id: 'me2', label: '카드', amount: 3123000, previousAmount: 2826000, previousLabel: '전월 대비 +10.5%' }, + { id: 'me3', label: '발행어음', amount: 3123000, previousAmount: 2826000, previousLabel: '전월 대비 +10.5%' }, + { id: 'me4', label: '총 예상 지출 합계', amount: 3123000, previousAmount: 2826000, previousLabel: '전월 대비 +10.5%' }, + ], + checkPoints: [ + { id: 'me-cp1', type: 'warning', message: '이번 달 예상 지출이', highlight: '전월 대비 15% 증가했습니다. 매입 비용 증가가 주요 원인입니다.' }, + { id: 'me-cp2', type: 'error', message: '이번 달 예상 지출이', highlight: '예산을 12% 초과했습니다. 비용 항목별 점검이 필요합니다.' }, + { id: 'me-cp3', type: 'success', message: '이번 달 예상 지출이', highlight: '전월 대비 8% 감소했습니다. (계정과목명) 비용이 줄었습니다.' }, + ], + }, + cardManagement: { + cards: [ + { id: 'cm1', label: '카드', amount: 3123000, subAmount: 50000, subLabel: '약정 5만 (가지급금 예외)' }, + { id: 'cm2', label: '가지급금', amount: 3123000, previousAmount: 2826000, previousLabel: '전월 대비 +10.5%' }, + { id: 'cm3', label: '미정산 가불', amount: 3123000, previousAmount: 2826000, previousLabel: '전월 대비 +10.5%' }, + { id: 'cm4', label: '총 잔액', amount: 3123000, previousAmount: 2826000, previousLabel: '전월 대비 +10.5%' }, + ], + checkPoints: [ + { id: 'cm-cp1', type: 'warning', message: '법인카드 사용 총 85만원이 가지급금으로 전환되었습니다.', highlight: '연 4.6% 인정이자가 발생합니다.' }, + { id: 'cm-cp2', type: 'warning', message: '전 가지급금 1,520만원은 4.6%,', highlight: '연 약 70만원의 인정이자가 발생합니다.' }, + { id: 'cm-cp3', type: 'warning', message: '상품권/귀금속 등 현대비 불인정 항목 매입 건이 있습니다. 가지급금 처리 예정입니다.' }, + { id: 'cm-cp4', type: 'info', message: '주말 카드 사용 총 100만원 중 결과 지의. 업무관련성 소명이 어려울 수 있으니 기록을 남겨주세요.' }, + ], + }, + entertainment: { + cards: [ + { id: 'et1', label: '접대비 한도', amount: 30000123000, subAmount: 2123, subLabel: '사용' }, + { id: 'et2', label: '접대비 사용액', amount: 40123000 }, + { id: 'et3', label: '한도 잔액', amount: 30123000 }, + { id: 'et4', label: '전월 사용액', amount: 3123000 }, + ], + checkPoints: [ + { id: 'et-cp1', type: 'success', message: '접대비 사용 총 2,400만원 중 / 한도 4,000만원 (60%).', highlight: '여유 있게 운영 중입니다.' }, + { id: 'et-cp2', type: 'warning', message: '접대비 85% 도달. 연내 한도 600만원 잔액입니다.', highlight: '사용 계획에 집중해 주세요.' }, + { id: 'et-cp3', type: 'error', message: '접대비 한도 초과 320만원 발생.', highlight: '손금불산입되어 법인세 부담 증가합니다.' }, + { id: 'et-cp4', type: 'info', message: '접대비 사용 총 3건(45만원)이 거래처 한도 누락되어있습니다. 기록을 보완해 주세요.' }, + ], + }, + welfare: { + cards: [ + { id: 'wf1', label: '총 복리후생비 한도', amount: 3123000 }, + { id: 'wf2', label: '누적 복리후생비 사용', amount: 1123000 }, + { id: 'wf3', label: '잠정 복리후생비 사용액', amount: 30123000 }, + { id: 'wf4', label: '잠정 복리후생비 누적 한도', amount: 3123000 }, + ], + checkPoints: [ + { id: 'wf-cp1', type: 'success', message: '1인당 월 복리후생비 18만원. 업계 평균(15~25만원) 내', highlight: '정상 운영 중입니다.' }, + { id: 'wf-cp2', type: 'warning', message: '식대가 월 25만원으로', highlight: '비과세 한도(20만원)를 초과했습니다. 초과분은 근로소득 과세됩니다.' }, + ], + }, + receivable: { + cards: [ + { id: 'rv1', label: '누계 미수금', amount: 30123000, subAmount: 6123000, subLabel: '매출', previousAmount: 30000, previousLabel: '입금' }, + { id: 'rv2', label: '30일 초과', amount: 30123000, subAmount: 60123000, subLabel: '매출', previousAmount: 30000, previousLabel: '입금' }, + { id: 'rv3', label: '60일 초과', amount: 3123000, subAmount: 6123000, subLabel: '매출', previousAmount: 30000, previousLabel: '입금' }, + { id: 'rv4', label: '90일 초과', amount: 3123000, subAmount: 6123000, subLabel: '매출', previousAmount: 30000, previousLabel: '입금' }, + ], + checkPoints: [ + { id: 'rv-cp1', type: 'error', message: '90일 이상 장기 미수금 3건(2,500만원) 발생.', highlight: '회수 조치가 필요합니다.' }, + { id: 'rv-cp2', type: 'warning', message: '(주)대한전자 미수금 4,500만원으로', highlight: '전체의 35%를 차지합니다. 리스크 분산이 필요합니다.' }, + ], + hasDetailButton: true, + detailButtonLabel: '거래처별 미수금 현황', + detailButtonPath: '/accounting/receivables-status', + }, + debtCollection: { + cards: [ + { id: 'dc1', label: '총 채권', amount: 30123000, subAmount: 25, subLabel: '건' }, + { id: 'dc2', label: '추심 진행', amount: 30123000, subAmount: 12, subLabel: '건' }, + { id: 'dc3', label: '추심 완료', amount: 3123000, subAmount: 3, subLabel: '건' }, + { id: 'dc4', label: '포기/손실', amount: 3123000, subAmount: 10, subLabel: '건' }, + ], + checkPoints: [ + { id: 'dc-cp1', type: 'info', message: '(주)대한전자 건 지급명령 신청 완료.', highlight: '법원 결정까지 약 2주 소요 예정입니다.' }, + { id: 'dc-cp2', type: 'warning', message: '(주)삼성테크 건 회수 불가 판정.', highlight: '대손 처리 검토가 필요합니다.' }, + ], + }, }; // Props 인터페이스 @@ -199,25 +280,27 @@ export default function ComprehensiveAnalysis({ initialData }: ComprehensiveAnal const [rejectTargetId, setRejectTargetId] = useState(null); const [rejectReason, setRejectReason] = useState(''); - // 데이터 로드 - const loadData = useCallback(async () => { - setIsLoading(true); - try { - const result = await getComprehensiveAnalysis(); - if (result.success && result.data) { - setData(result.data); - } - } catch (error) { - console.error('Failed to load comprehensive analysis:', error); - } finally { - setIsLoading(false); - } - }, []); + // 데이터 로드 (API 연동 시 활성화) + // const loadData = useCallback(async () => { + // setIsLoading(true); + // try { + // const result = await getComprehensiveAnalysis(); + // if (result.success && result.data) { + // setData(result.data); + // } + // } catch (error) { + // console.error('Failed to load comprehensive analysis:', error); + // } finally { + // setIsLoading(false); + // } + // }, []); - // 초기 로드 + // 초기 로드 - 현재 목데이터 사용 중 (API 연동 시 활성화) useEffect(() => { - loadData(); - }, [loadData]); + // API 연동 시 아래 주석 해제 + // loadData(); + setIsLoading(false); + }, []); const handleReceivableDetail = () => { router.push('/ko/accounting/receivables-status'); diff --git a/src/hooks/useMenuPolling.ts b/src/hooks/useMenuPolling.ts index 24ae3540..4bad92c3 100644 --- a/src/hooks/useMenuPolling.ts +++ b/src/hooks/useMenuPolling.ts @@ -2,6 +2,8 @@ * 메뉴 폴링 훅 * * 일정 간격으로 메뉴 변경사항을 확인하고 자동 갱신합니다. + * 401 응답이 3회 연속 발생하면 세션 만료로 판단하고 폴링을 중지합니다. + * 토큰 갱신 시 자동으로 폴링을 재시작합니다. * * @see claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md */ @@ -9,6 +11,26 @@ import { useEffect, useRef, useCallback } from 'react'; import { refreshMenus } from '@/lib/utils/menuRefresh'; +// 토큰 갱신 신호 쿠키 이름 +const TOKEN_REFRESHED_COOKIE = 'token_refreshed_at'; + +/** + * 쿠키에서 값 읽기 + */ +function getCookieValue(name: string): string | null { + if (typeof document === 'undefined') return null; + const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`)); + return match ? match[2] : null; +} + +/** + * 쿠키 삭제 + */ +function deleteCookie(name: string): void { + if (typeof document === 'undefined') return; + document.cookie = `${name}=; Path=/; Max-Age=0`; +} + // 기본 폴링 간격: 30초 const DEFAULT_POLLING_INTERVAL = 30 * 1000; @@ -18,6 +40,9 @@ const MIN_POLLING_INTERVAL = 10 * 1000; // 최대 폴링 간격: 5분 const MAX_POLLING_INTERVAL = 5 * 60 * 1000; +// 세션 만료 판단 기준: 연속 401 횟수 +const MAX_SESSION_EXPIRED_COUNT = 3; + interface UseMenuPollingOptions { /** 폴링 활성화 여부 (기본: true) */ enabled?: boolean; @@ -27,6 +52,8 @@ interface UseMenuPollingOptions { onMenuUpdated?: () => void; /** 에러 발생 시 콜백 */ onError?: (error: string) => void; + /** 세션 만료로 폴링 중지 시 콜백 */ + onSessionExpired?: () => void; } interface UseMenuPollingReturn { @@ -36,8 +63,12 @@ interface UseMenuPollingReturn { pause: () => void; /** 폴링 재개 */ resume: () => void; + /** 인증 성공 후 폴링 재시작 (401 카운트 리셋) */ + restartAfterAuth: () => void; /** 현재 폴링 상태 */ isPaused: boolean; + /** 세션 만료로 중지된 상태 */ + isSessionExpired: boolean; } /** @@ -64,6 +95,7 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll interval = DEFAULT_POLLING_INTERVAL, onMenuUpdated, onError, + onSessionExpired, } = options; // 폴링 간격 유효성 검사 @@ -71,21 +103,53 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll const intervalRef = useRef(null); const isPausedRef = useRef(false); + const sessionExpiredCountRef = useRef(0); // 연속 401 카운트 + const isSessionExpiredRef = useRef(false); // 세션 만료로 중지된 상태 + + // 폴링 중지 (내부용) + const stopPolling = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); // 메뉴 갱신 실행 const executeRefresh = useCallback(async () => { - if (isPausedRef.current) return; + if (isPausedRef.current || isSessionExpiredRef.current) return; const result = await refreshMenus(); - if (result.success && result.updated) { - onMenuUpdated?.(); + // 성공 시 401 카운트 리셋 + if (result.success) { + sessionExpiredCountRef.current = 0; + + if (result.updated) { + onMenuUpdated?.(); + } + return; } - if (!result.success && result.error) { + // 401 세션 만료 응답 + if (result.sessionExpired) { + sessionExpiredCountRef.current += 1; + console.log(`[Menu] 401 응답 (${sessionExpiredCountRef.current}/${MAX_SESSION_EXPIRED_COUNT})`); + + // 3회 연속 401 → 폴링 중지 + if (sessionExpiredCountRef.current >= MAX_SESSION_EXPIRED_COUNT) { + console.log('[Menu] 세션 만료로 폴링 중지'); + isSessionExpiredRef.current = true; + stopPolling(); + onSessionExpired?.(); + } + return; + } + + // 기타 에러 (네트워크 등) → 401 카운트 리셋하지 않음 + if (result.error) { onError?.(result.error); } - }, [onMenuUpdated, onError]); + }, [onMenuUpdated, onError, onSessionExpired, stopPolling]); // 수동 갱신 함수 const refresh = useCallback(async () => { @@ -95,27 +159,41 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll // 폴링 일시 중지 const pause = useCallback(() => { isPausedRef.current = true; - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - }, []); + stopPolling(); + }, [stopPolling]); // 폴링 재개 const resume = useCallback(() => { + // 세션 만료 상태면 재개 불가 (restartAfterAuth 사용) + if (isSessionExpiredRef.current) { + console.log('[Menu] 세션 만료 상태 - resume 불가, restartAfterAuth 사용 필요'); + return; + } + isPausedRef.current = false; if (enabled && !intervalRef.current) { intervalRef.current = setInterval(executeRefresh, safeInterval); } }, [enabled, safeInterval, executeRefresh]); + // 인증 성공 후 폴링 재시작 (401 카운트 리셋) + const restartAfterAuth = useCallback(() => { + console.log('[Menu] 인증 성공 - 폴링 재시작'); + sessionExpiredCountRef.current = 0; + isSessionExpiredRef.current = false; + isPausedRef.current = false; + + if (enabled && !intervalRef.current) { + // 즉시 한 번 실행 후 폴링 시작 + executeRefresh(); + intervalRef.current = setInterval(executeRefresh, safeInterval); + } + }, [enabled, safeInterval, executeRefresh]); + // 폴링 설정 useEffect(() => { - if (!enabled) { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } + if (!enabled || isSessionExpiredRef.current) { + stopPolling(); return; } @@ -124,24 +202,21 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll intervalRef.current = setInterval(executeRefresh, safeInterval); return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } + stopPolling(); }; - }, [enabled, safeInterval, executeRefresh]); + }, [enabled, safeInterval, executeRefresh, stopPolling]); // 탭 가시성 변경 시 처리 useEffect(() => { if (!enabled) return; const handleVisibilityChange = () => { + // 세션 만료 상태면 무시 + if (isSessionExpiredRef.current) return; + if (document.hidden) { // 탭이 숨겨지면 폴링 중지 (리소스 절약) - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } + stopPolling(); } else { // 탭이 다시 보이면 즉시 갱신 후 폴링 재개 if (!isPausedRef.current) { @@ -156,13 +231,57 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); }; + }, [enabled, safeInterval, executeRefresh, stopPolling]); + + // 🔔 토큰 갱신 신호 쿠키 감지 → 폴링 자동 재시작 + // 서버에서 토큰 갱신 시 설정한 쿠키를 감지하여 폴링을 재시작 + useEffect(() => { + if (!enabled) return; + + // 마지막으로 처리한 토큰 갱신 타임스탬프 추적 + let lastProcessedTimestamp: string | null = null; + + const checkTokenRefresh = () => { + const refreshedAt = getCookieValue(TOKEN_REFRESHED_COOKIE); + + // 새로운 토큰 갱신 감지 + if (refreshedAt && refreshedAt !== lastProcessedTimestamp) { + lastProcessedTimestamp = refreshedAt; + + // 세션 만료 상태였다면 폴링 재시작 + if (isSessionExpiredRef.current) { + console.log('[Menu] 🔔 토큰 갱신 감지 - 폴링 재시작'); + sessionExpiredCountRef.current = 0; + isSessionExpiredRef.current = false; + isPausedRef.current = false; + + // 폴링 재시작 + if (!intervalRef.current) { + executeRefresh(); + intervalRef.current = setInterval(executeRefresh, safeInterval); + } + } + + // 쿠키 삭제 (처리 완료) + deleteCookie(TOKEN_REFRESHED_COOKIE); + } + }; + + // 1초마다 쿠키 확인 + const checkInterval = setInterval(checkTokenRefresh, 1000); + + return () => { + clearInterval(checkInterval); + }; }, [enabled, safeInterval, executeRefresh]); return { refresh, pause, resume, + restartAfterAuth, isPaused: isPausedRef.current, + isSessionExpired: isSessionExpiredRef.current, }; } diff --git a/src/layouts/AuthenticatedLayout.tsx b/src/layouts/AuthenticatedLayout.tsx index ec9041e6..614e5c16 100644 --- a/src/layouts/AuthenticatedLayout.tsx +++ b/src/layouts/AuthenticatedLayout.tsx @@ -23,6 +23,8 @@ import { ChevronLeft, Home, X, + BarChart3, + Award, } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; @@ -63,14 +65,27 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro // 메뉴 폴링 (30초마다 메뉴 변경 확인) // 백엔드 GET /api/v1/menus API 준비되면 자동 동작 - useMenuPolling({ + 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 = () => { @@ -283,8 +298,30 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro )}
- {/* 우측 영역: 검색, 테마, 유저, 메뉴 */} + {/* 우측 영역: 종합분석, 품질인정심사, 검색, 테마, 유저, 메뉴 */}
+ {/* 종합분석 바로가기 */} + + + {/* 품질인정심사 바로가기 */} + + {/* 검색 아이콘 */} + + {/* 품질인정심사 바로가기 버튼 */} + + {/* 테마 선택 - React 프로젝트 스타일 */} diff --git a/src/lib/api/fetch-wrapper.ts b/src/lib/api/fetch-wrapper.ts index f59c8267..e96e6cdb 100644 --- a/src/lib/api/fetch-wrapper.ts +++ b/src/lib/api/fetch-wrapper.ts @@ -31,6 +31,17 @@ async function setNewTokenCookies(tokens: { path: '/', maxAge: tokens.expiresIn || 7200, }); + + // 🔔 토큰 갱신 신호 쿠키 설정 (클라이언트에서 감지용) + // HttpOnly: false로 설정하여 클라이언트에서 읽을 수 있게 함 + cookieStore.set('token_refreshed_at', Date.now().toString(), { + httpOnly: false, + secure: isProduction, + sameSite: 'lax', + path: '/', + maxAge: 60, // 1분 후 자동 삭제 + }); + console.log('🔔 [setNewTokenCookies] token_refreshed_at 신호 쿠키 설정'); } if (tokens.refreshToken) { @@ -164,8 +175,15 @@ export async function serverFetch( return { response, error: null }; } catch (error) { - // redirect()는 NEXT_REDIRECT 에러를 throw하므로 다시 throw - if (error instanceof Error && error.message === 'NEXT_REDIRECT') { + // Next.js 15: redirect()는 digest 프로퍼티를 가진 에러를 throw + // 이 에러는 다시 throw해서 Next.js가 처리하도록 해야 함 + if ( + error && + typeof error === 'object' && + 'digest' in error && + typeof (error as { digest: unknown }).digest === 'string' && + (error as { digest: string }).digest.startsWith('NEXT_REDIRECT') + ) { throw error; } console.error(`[serverFetch] Network error: ${url}`, error); diff --git a/src/lib/utils/menuRefresh.ts b/src/lib/utils/menuRefresh.ts index b5cb7e17..d43b1889 100644 --- a/src/lib/utils/menuRefresh.ts +++ b/src/lib/utils/menuRefresh.ts @@ -50,6 +50,7 @@ export function getCurrentMenuHash(): string { interface RefreshMenuResult { success: boolean; updated: boolean; // 실제로 메뉴가 변경되었는지 + sessionExpired?: boolean; // 세션 만료 여부 (401 응답) error?: string; } @@ -76,9 +77,10 @@ export async function refreshMenus(): Promise { }); if (!response.ok) { - // 401 등 인증 오류는 조용히 실패 (로그아웃 상태일 수 있음) + // 401 인증 오류 → 세션 만료로 판단 if (response.status === 401) { - return { success: false, updated: false }; + console.log('[Menu] 401 응답 - 세션 만료'); + return { success: false, updated: false, sessionExpired: true }; } return { success: false,