Files
sam-react-prod/src/hooks/useMenuPolling.ts
유병철 c1b63b850a feat(WEB): 자재/출고/생산/품질/단가 기능 대폭 개선 및 신규 페이지 추가
자재관리:
- 입고관리 재고조정 다이얼로그 신규 추가, 상세/목록 기능 확장
- 재고현황 컴포넌트 리팩토링

출고관리:
- 출하관리 생성/수정/목록/상세 개선
- 차량배차관리 상세/수정/목록 기능 보강

생산관리:
- 작업지시서 WIP 생산 모달 신규 추가
- 벤딩WIP/슬랫조인트바 검사 콘텐츠 신규 추가
- 작업자화면 기능 대폭 확장 (카드/목록 개선)
- 검사성적서 모달 개선

품질관리:
- 실적보고서 관리 페이지 신규 추가
- 검사관리 문서/타입/목데이터 개선

단가관리:
- 단가배포 페이지 및 컴포넌트 신규 추가
- 단가표 관리 페이지 및 컴포넌트 신규 추가

공통:
- 권한 시스템 추가 개선 (PermissionContext, usePermission, PermissionGuard)
- 메뉴 폴링 훅 개선, 레이아웃 수정
- 모바일 줌/패닝 CSS 수정
- locale 유틸 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 12:46:19 +09:00

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;