자재관리: - 입고관리 재고조정 다이얼로그 신규 추가, 상세/목록 기능 확장 - 재고현황 컴포넌트 리팩토링 출고관리: - 출하관리 생성/수정/목록/상세 개선 - 차량배차관리 상세/수정/목록 기능 보강 생산관리: - 작업지시서 WIP 생산 모달 신규 추가 - 벤딩WIP/슬랫조인트바 검사 콘텐츠 신규 추가 - 작업자화면 기능 대폭 확장 (카드/목록 개선) - 검사성적서 모달 개선 품질관리: - 실적보고서 관리 페이지 신규 추가 - 검사관리 문서/타입/목데이터 개선 단가관리: - 단가배포 페이지 및 컴포넌트 신규 추가 - 단가표 관리 페이지 및 컴포넌트 신규 추가 공통: - 권한 시스템 추가 개선 (PermissionContext, usePermission, PermissionGuard) - 메뉴 폴링 훅 개선, 레이아웃 수정 - 모바일 줌/패닝 CSS 수정 - locale 유틸 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
301 lines
8.8 KiB
TypeScript
301 lines
8.8 KiB
TypeScript
/**
|
|
* 메뉴 폴링 훅
|
|
*
|
|
* 일정 간격으로 메뉴 변경사항을 확인하고 자동 갱신합니다.
|
|
* 401 응답이 3회 연속 발생하면 세션 만료로 판단하고 폴링을 중지합니다.
|
|
* 토큰 갱신 시 자동으로 폴링을 재시작합니다.
|
|
*
|
|
* @see claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md
|
|
*/
|
|
|
|
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;
|
|
|
|
// 최소 폴링 간격: 10초 (서버 부하 방지)
|
|
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;
|
|
/** 폴링 간격 (ms, 기본: 30초) */
|
|
interval?: number;
|
|
/** 메뉴 갱신 시 콜백 */
|
|
onMenuUpdated?: () => void;
|
|
/** 에러 발생 시 콜백 */
|
|
onError?: (error: string) => void;
|
|
/** 세션 만료로 폴링 중지 시 콜백 */
|
|
onSessionExpired?: () => void;
|
|
}
|
|
|
|
interface UseMenuPollingReturn {
|
|
/** 수동으로 메뉴 갱신 실행 */
|
|
refresh: () => Promise<void>;
|
|
/** 폴링 일시 중지 */
|
|
pause: () => void;
|
|
/** 폴링 재개 */
|
|
resume: () => void;
|
|
/** 인증 성공 후 폴링 재시작 (401 카운트 리셋) */
|
|
restartAfterAuth: () => void;
|
|
/** 현재 폴링 상태 */
|
|
isPaused: boolean;
|
|
/** 세션 만료로 중지된 상태 */
|
|
isSessionExpired: boolean;
|
|
}
|
|
|
|
/**
|
|
* 메뉴 폴링 훅
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* // 기본 사용
|
|
* useMenuPolling();
|
|
*
|
|
* // 옵션과 함께 사용
|
|
* const { refresh, pause, resume } = useMenuPolling({
|
|
* interval: 60000, // 1분마다
|
|
* onMenuUpdated: () => console.log('메뉴 업데이트됨!'),
|
|
* });
|
|
*
|
|
* // 수동 갱신
|
|
* await refresh();
|
|
* ```
|
|
*/
|
|
export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPollingReturn {
|
|
const {
|
|
enabled = true,
|
|
interval = DEFAULT_POLLING_INTERVAL,
|
|
onMenuUpdated,
|
|
onError,
|
|
onSessionExpired,
|
|
} = options;
|
|
|
|
// 폴링 간격 유효성 검사
|
|
const safeInterval = Math.max(MIN_POLLING_INTERVAL, Math.min(MAX_POLLING_INTERVAL, interval));
|
|
|
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
const isPausedRef = useRef(false);
|
|
const sessionExpiredCountRef = useRef(0); // 연속 401 카운트
|
|
const isSessionExpiredRef = useRef(false); // 세션 만료로 중지된 상태
|
|
|
|
// 콜백을 ref로 저장하여 인터벌 리셋 방지
|
|
// (인라인 콜백이 매 렌더마다 새 참조를 생성해도 인터벌에 영향 없음)
|
|
const onMenuUpdatedRef = useRef(onMenuUpdated);
|
|
const onErrorRef = useRef(onError);
|
|
const onSessionExpiredRef = useRef(onSessionExpired);
|
|
|
|
// 콜백 ref를 최신 값으로 동기화
|
|
useEffect(() => {
|
|
onMenuUpdatedRef.current = onMenuUpdated;
|
|
onErrorRef.current = onError;
|
|
onSessionExpiredRef.current = onSessionExpired;
|
|
});
|
|
|
|
// 폴링 중지 (내부용)
|
|
const stopPolling = useCallback(() => {
|
|
if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
intervalRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
// 메뉴 갱신 실행 (의존성: stopPolling만 — 안정적)
|
|
const executeRefresh = useCallback(async () => {
|
|
if (isPausedRef.current || isSessionExpiredRef.current) return;
|
|
|
|
const result = await refreshMenus();
|
|
|
|
// 성공 시 401 카운트 리셋
|
|
if (result.success) {
|
|
sessionExpiredCountRef.current = 0;
|
|
|
|
if (result.updated) {
|
|
onMenuUpdatedRef.current?.();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 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();
|
|
onSessionExpiredRef.current?.();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 기타 에러 (네트워크 등) → 401 카운트 리셋하지 않음
|
|
if (result.error) {
|
|
onErrorRef.current?.(result.error);
|
|
}
|
|
}, [stopPolling]);
|
|
|
|
// 수동 갱신 함수
|
|
const refresh = useCallback(async () => {
|
|
await executeRefresh();
|
|
}, [executeRefresh]);
|
|
|
|
// 폴링 일시 중지
|
|
const pause = useCallback(() => {
|
|
isPausedRef.current = true;
|
|
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 || isSessionExpiredRef.current) {
|
|
stopPolling();
|
|
return;
|
|
}
|
|
|
|
// 페이지 로드 시 즉시 실행하지 않음 (로그인 시 이미 받아옴)
|
|
// 폴링 시작
|
|
intervalRef.current = setInterval(executeRefresh, safeInterval);
|
|
|
|
return () => {
|
|
stopPolling();
|
|
};
|
|
}, [enabled, safeInterval, executeRefresh, stopPolling]);
|
|
|
|
// 탭 가시성 변경 시 처리
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
|
|
const handleVisibilityChange = () => {
|
|
// 세션 만료 상태면 무시
|
|
if (isSessionExpiredRef.current) return;
|
|
|
|
if (document.hidden) {
|
|
// 탭이 숨겨지면 폴링 중지 (리소스 절약)
|
|
stopPolling();
|
|
} else {
|
|
// 탭이 다시 보이면 즉시 갱신 후 폴링 재개
|
|
if (!isPausedRef.current) {
|
|
executeRefresh();
|
|
intervalRef.current = setInterval(executeRefresh, safeInterval);
|
|
}
|
|
}
|
|
};
|
|
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
export default useMenuPolling; |