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>
    </>
  );
}

© 2025 Mingu Kim. All rights reserved.