t
10/28/2025
import { useEffect, useState, useCallback, useRef } from 'react';
export interface SSEEvent<T = any> {
type: string;
data: T;
timestamp?: number;
}
export interface UseSSEOptions {
onMessage?: (event: SSEEvent) => void;
onError?: (error: Error) => void;
onOpen?: () => void;
onClose?: () => void;
reconnect?: boolean;
maxRetries?: number;
}
export interface UseSSEReturn {
isConnected: boolean;
lastEvent: SSEEvent | null;
error: Error | null;
connect: (url: string) => void;
disconnect: () => void;
}
/**
* 범용 SSE Hook
* @example
* const { isConnected, lastEvent, connect, disconnect } = useSSE({
* onMessage: (event) => console.log(event),
* });
*
* // 연결
* connect('/api/events/project/1');
*
* // 특정 이벤트 타입 감시
* useEffect(() => {
* if (lastEvent?.type === 'IMPORT_COMPLETED') {
* // 처리
* }
* }, [lastEvent]);
*/
export function useSSE(options: UseSSEOptions = {}): UseSSEReturn {
const {
onMessage,
onError,
onOpen,
onClose,
reconnect = true,
maxRetries = 3,
} = options;
const [isConnected, setIsConnected] = useState(false);
const [lastEvent, setLastEvent] = useState<SSEEvent | null>(null);
const [error, setError] = useState<Error | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const retryCountRef = useRef(0);
const urlRef = useRef<string | null>(null);
const connect = useCallback((url: string) => {
// 이미 연결된 경우 스킵
if (eventSourceRef.current && urlRef.current === url) {
return;
}
// 기존 연결 정리
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
urlRef.current = url;
retryCountRef.current = 0;
try {
const es = new EventSource(url);
// ✅ 연결 열림
es.onopen = () => {
setIsConnected(true);
setError(null);
retryCountRef.current = 0;
onOpen?.();
};
// ✅ 일반 메시지 (이벤트 타입이 없는 경우)
es.onmessage = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
const sseEvent: SSEEvent = {
type: 'message',
data,
timestamp: Date.now(),
};
setLastEvent(sseEvent);
onMessage?.(sseEvent);
} catch (err) {
console.error('SSE message parse error:', err);
}
};
// ✅ 커스텀 이벤트 타입 리스너
// 예: addEventListener('IMPORT_PROGRESS', ...)
const handleCustomEvent = (eventType: string) => (event: Event) => {
try {
const messageEvent = event as MessageEvent;
const data = JSON.parse(messageEvent.data);
const sseEvent: SSEEvent = {
type: eventType,
data,
timestamp: Date.now(),
};
setLastEvent(sseEvent);
onMessage?.(sseEvent);
} catch (err) {
console.error(`SSE ${eventType} parse error:`, err);
}
};
// 일반적인 이벤트 타입들
const eventTypes = [
'COMPLETED',
'FAILED',
'PROGRESS',
'START',
'ERROR',
];
eventTypes.forEach((type) => {
es.addEventListener(type, handleCustomEvent(type));
});
// ✅ 오류 처리
es.onerror = () => {
setIsConnected(false);
const err = new Error('SSE connection error');
setError(err);
onError?.(err);
// 자동 재연결 로직
if (reconnect && retryCountRef.current < maxRetries) {
retryCountRef.current += 1;
const delay = Math.min(1000 * Math.pow(2, retryCountRef.current), 30000);
console.warn(
`SSE reconnecting... (${retryCountRef.current}/${maxRetries}) in ${delay}ms`
);
setTimeout(() => connect(url), delay);
} else {
es.close();
onClose?.();
}
};
eventSourceRef.current = es;
} catch (err) {
const error = err instanceof Error ? err : new Error('SSE connection failed');
setError(error);
onError?.(error);
}
}, [onMessage, onError, onOpen, onClose, reconnect, maxRetries]);
const disconnect = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setIsConnected(false);
setLastEvent(null);
urlRef.current = null;
retryCountRef.current = 0;
onClose?.();
}, [onClose]);
// ✅ 언마운트 시 정리
useEffect(() => {
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
};
}, []);
return {
isConnected,
lastEvent,
error,
connect,
disconnect,
};
}
import { Dialog, DialogContent, Snackbar, Alert, CircularProgress, Box, Typography } from '@mui/material';
import { useLocales } from '@repo/theme';
import { useEffect, useState } from 'react';
import { useSSE, SSEEvent } from '@/hooks/useSSE';
import EmissionProjectImportConfirmStep from './EmissionProjectImportConfirmStep';
import EmissionProjectImportSelectStep from './EmissionProjectImportSelectStep';
interface ImportProgressData {
projectId: number;
status: 'IN_PROGRESS' | 'COMPLETED' | 'FAILED';
message: string;
progress?: number;
}
interface EmissionProjectImportDialogProps {
currentProjectId: number;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function EmissionProjectImportDialog({
currentProjectId,
isOpen,
setIsOpen,
}: EmissionProjectImportDialogProps) {
const { translate } = useLocales();
const { isConnected, lastEvent, error, connect, disconnect } = useSSE({
onMessage: (event) => console.log('SSE Event:', event),
onError: (err) => console.error('SSE Error:', err),
onClose: () => console.log('SSE disconnected'),
});
const [currentStep, setCurrentStep] = useState(1);
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(null);
const [showSnackbar, setShowSnackbar] = useState(false);
const [snackbarSeverity, setSnackbarSeverity] = useState<'success' | 'error' | 'info'>('info');
const [snackbarMessage, setSnackbarMessage] = useState('');
const closeDialog = () => {
setIsOpen(false);
disconnect();
};
const handleClickNext = () => {
if (currentStep < 2) {
setCurrentStep(currentStep + 1);
}
};
const handleClickPrev = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
const handleCheckedTargetProject = (selectedProjectId: number) => {
setSelectedProjectId(selectedProjectId);
handleClickNext();
};
// ✅ SSE 이벤트 감시
useEffect(() => {
if (!lastEvent) return;
const data = lastEvent.data as ImportProgressData;
if (lastEvent.type === 'COMPLETED') {
setSnackbarSeverity('success');
setSnackbarMessage(data.message || translate('ghg:msg.import_completed'));
setShowSnackbar(true);
setTimeout(() => closeDialog(), 2000);
} else if (lastEvent.type === 'FAILED') {
setSnackbarSeverity('error');
setSnackbarMessage(data.message || translate('ghg:msg.import_failed'));
setShowSnackbar(true);
} else if (lastEvent.type === 'PROGRESS') {
// Progress bar 업데이트 가능
console.log(`Import progress: ${data.progress}%`);
}
}, [lastEvent, translate]);
// ✅ Dialog 닫힘 처리
useEffect(() => {
if (!isOpen) {
setCurrentStep(1);
disconnect();
}
}, [isOpen, disconnect]);
return (
<>
<Dialog
open={isOpen}
onClose={closeDialog}
sx={{
'.MuiBackdrop-root': {
backgroundColor: 'rgba(0, 0, 0, 0.6)',
},
'.MuiDialog-paper.MuiPaper-rounded': {
minWidth: 900,
height: 700,
p: 5,
borderRadius: 1,
},
}}
>
<DialogContent sx={{ p: 0, position: 'relative' }}>
{/* 🔴 진행 중 오버레이 */}
{isConnected && (
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
zIndex: 1000,
borderRadius: 1,
gap: 2,
}}
>
<CircularProgress />
<Typography sx={{ fontWeight: 600, fontSize: 16 }}>
{translate('ghg:msg.import_in_progress')}
</Typography>
{(lastEvent?.data as ImportProgressData)?.progress && (
<Typography sx={{ color: '#7A8087' }}>
{(lastEvent.data as ImportProgressData).progress}%
</Typography>
)}
</Box>
)}
{currentStep === 1 && (
<EmissionProjectImportSelectStep
currentProjectId={currentProjectId}
closeDialog={closeDialog}
handleCheckedTargetProject={handleCheckedTargetProject}
/>
)}
{currentStep === 2 && (
<EmissionProjectImportConfirmStep
currentProjectId={currentProjectId}
handlePrev={handleClickPrev}
selectedProjectId={selectedProjectId!}
onImportStart={connect}
/>
)}
</DialogContent>
</Dialog>
{/* Snackbar */}
<Snackbar
open={showSnackbar}
autoHideDuration={snackbarSeverity === 'success' ? 2000 : 6000}
onClose={() => setShowSnackbar(false)}
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<Alert
onClose={() => setShowSnackbar(false)}
severity={snackbarSeverity}
sx={{ width: '100%' }}
>
{snackbarMessage}
</Alert>
</Snackbar>
</>
);
}
