// hooks/use-sync-status.ts (업데이트된 버전) import useSWR, { mutate as globalMutate } from 'swr' import useSWRMutation from 'swr/mutation' import * as React from 'react' import { fabClasses } from '@mui/material' // 🔧 타입 정의 강화 - entityTypeDetails 추가 interface EntityTypeDetail { pending: number synced: number failed: number total: number } interface SyncStatus { syncEnabled: boolean pendingChanges: number syncedChanges: number failedChanges: number totalChanges: number error?: string | null projectId?: number vendorId?: number targetSystem?: string lastUpdated?: string requiresSync: boolean // 새로 추가된 필드들 entityTypeDetails?: { document: EntityTypeDetail revision: EntityTypeDetail attachment: EntityTypeDetail } hasPendingChanges?: boolean hasFailedChanges?: boolean syncHealthy?: boolean } interface ApiError extends Error { status: number response?: any } interface SyncBatch { id: string projectId: number targetSystem: string status: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED' startedAt?: string completedAt?: string itemCount: number successCount: number failureCount: number errors?: string[] } interface SyncConfig { projectId: number targetSystem: string autoSyncEnabled: boolean syncInterval: number batchSize: number retryAttempts: number notificationEnabled: boolean } // 🔧 환경 설정 const MAX_CONTRACTS = parseInt(process.env.NEXT_PUBLIC_MAX_SYNC_CONTRACTS || '20', 10) // 🔧 개선된 fetcher 함수 const fetcher = async (url: string): Promise => { try { const response = await fetch(url, { headers: { 'Content-Type': 'application/json', } }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) const error = new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`) as ApiError error.status = response.status error.response = errorData throw error } return response.json() } catch (error) { if (error instanceof TypeError && error.message === 'Failed to fetch') { // 네트워크 오류 const networkError = new Error('네트워크 연결을 확인해주세요') as ApiError networkError.status = 0 throw networkError } throw error } } // 🔧 안전한 기본값 생성 - entityTypeDetails 추가 const createDefaultSyncStatus = (error?: string, projectId?: number): SyncStatus => ({ syncEnabled: false, requiresSync:false, pendingChanges: 0, syncedChanges: 0, failedChanges: 0, totalChanges: 0, error, projectId, lastUpdated: new Date().toISOString(), entityTypeDetails: { document: { pending: 0, synced: 0, failed: 0, total: 0 }, revision: { pending: 0, synced: 0, failed: 0, total: 0 }, attachment: { pending: 0, synced: 0, failed: 0, total: 0 } }, hasPendingChanges: false, hasFailedChanges: false, syncHealthy: true }) // ✅ 단일 계약 동기화 상태 조회 - DOLCE를 기본값으로 변경 export function useSyncStatus(projectId: number | null, targetSystem: string = 'DOLCE') { const key = projectId ? `/api/sync/status?projectId=${projectId}&targetSystem=${targetSystem}` : null const { data, error, isLoading, mutate: localMutate } = useSWR( key, fetcher, { refreshInterval: 30000, revalidateOnFocus: true, revalidateOnReconnect: true, shouldRetryOnError: (error: ApiError) => { // 4xx 에러는 재시도하지 않음 return error.status >= 500 || error.status === 0 }, errorRetryCount: 3, errorRetryInterval: 2000, dedupingInterval: 5000, keepPreviousData: true, // 서버 사이드 렌더링에서는 요청하지 않음 isPaused: () => typeof window === 'undefined' } ) const refetch = React.useCallback(() => { if (key) { localMutate() } }, [key, localMutate]) // 항상 안전한 데이터 반환 - 새 필드들 포함 const safeData: SyncStatus = React.useMemo(() => { if (data && typeof data === 'object') { return { syncEnabled: Boolean(data.syncEnabled), pendingChanges: Number(data.pendingChanges) || 0, syncedChanges: Number(data.syncedChanges) || 0, failedChanges: Number(data.failedChanges) || 0, totalChanges: Number(data.totalChanges) || 0, error: data.error || (error ? (error as ApiError).message : null), projectId: projectId || data.projectId, vendorId: data.vendorId, targetSystem: targetSystem, lastUpdated: new Date().toISOString(), entityTypeDetails: data.entityTypeDetails || { document: { pending: 0, synced: 0, failed: 0, total: 0 }, revision: { pending: 0, synced: 0, failed: 0, total: 0 }, attachment: { pending: 0, synced: 0, failed: 0, total: 0 } }, hasPendingChanges: Boolean(data.hasPendingChanges), hasFailedChanges: Boolean(data.hasFailedChanges), syncHealthy: Boolean(data.syncHealthy) } } return createDefaultSyncStatus( error ? (error as ApiError).message : undefined, projectId || undefined ) }, [data, error, projectId, targetSystem]) return { syncStatus: safeData, isLoading: isLoading && Boolean(projectId), error: error as ApiError | null, refetch } } // ✅ 다중 계약 동기화 상태 조회 (Hook 규칙 준수) - DOLCE를 기본값으로 export function useDynamicSyncStatus(projectIds: number[], targetSystem: string = 'DOLCE') { // Hook 규칙 준수: 고정된 수의 Hook 호출 const paddedContractIds = React.useMemo(() => { // 입력 검증 및 경고 if (projectIds.length > MAX_CONTRACTS) { console.warn(`Contract count (${projectIds.length}) exceeds maximum (${MAX_CONTRACTS}). Only first ${MAX_CONTRACTS} will be processed.`) } // 🔥 명시적 타입 선언 const padded: (number | null)[] = [...projectIds.slice(0, MAX_CONTRACTS)] // null로 패딩 while (padded.length < MAX_CONTRACTS) { padded.push(null) } return padded }, [projectIds]) // 각 contractId에 대해 고정된 수의 Hook 호출 const allResults = paddedContractIds.map((projectId) => { const result = useSyncStatus(projectId, targetSystem) return projectId ? { projectId, ...result } : null }) // 유효한 결과만 필터링 const validResults = React.useMemo(() => { return allResults.filter((result): result is { projectId: number syncStatus: SyncStatus isLoading: boolean error: ApiError | null refetch: () => void } => result !== null) }, [allResults]) // 전체 통계 계산 - entityTypeDetails 포함 const totalStats = React.useMemo(() => { let totalPending = 0 let totalSynced = 0 let totalFailed = 0 let totalChanges = 0 let hasError = false let isLoading = false // entityType별 합계 초기화 const entityTypeDetailsTotals = { document: { pending: 0, synced: 0, failed: 0, total: 0 }, revision: { pending: 0, synced: 0, failed: 0, total: 0 }, attachment: { pending: 0, synced: 0, failed: 0, total: 0 } } validResults.forEach(({ syncStatus, error, isLoading: loading }) => { if (error) hasError = true if (loading) isLoading = true if (syncStatus && typeof syncStatus === 'object') { totalPending += Number(syncStatus.pendingChanges) || 0 totalSynced += Number(syncStatus.syncedChanges) || 0 totalFailed += Number(syncStatus.failedChanges) || 0 totalChanges += Number(syncStatus.totalChanges) || 0 // entityTypeDetails 합계 계산 if (syncStatus.entityTypeDetails) { Object.keys(entityTypeDetailsTotals).forEach((entityType) => { const key = entityType as keyof typeof entityTypeDetailsTotals if (syncStatus.entityTypeDetails?.[key]) { entityTypeDetailsTotals[key].pending += syncStatus.entityTypeDetails[key].pending || 0 entityTypeDetailsTotals[key].synced += syncStatus.entityTypeDetails[key].synced || 0 entityTypeDetailsTotals[key].failed += syncStatus.entityTypeDetails[key].failed || 0 entityTypeDetailsTotals[key].total += syncStatus.entityTypeDetails[key].total || 0 } }) } } }) return { totalPending, totalSynced, totalFailed, totalChanges, hasError, isLoading, canSync: totalPending > 0 && !hasError && projectIds.length > 0, entityTypeDetailsTotals, hasPendingChanges: totalPending > 0, hasFailedChanges: totalFailed > 0, syncHealthy: totalFailed === 0 && totalPending < 100 } }, [validResults, projectIds.length]) const refetchAll = React.useCallback(() => { validResults.forEach(({ refetch }) => { try { refetch() } catch (error) { console.warn('Failed to refetch sync status:', error) } }) }, [validResults]) return { contractStatuses: validResults, totalStats, refetchAll } } // ✅ 클라이언트 전용 동기화 상태 조회 (서버 사이드 렌더링 호환) - DOLCE 기본값 export function useClientSyncStatus(projectIds: number[], targetSystem: string = 'DOLCE') { const [isClient, setIsClient] = React.useState(false) React.useEffect(() => { setIsClient(true) }, []) const syncResult = useDynamicSyncStatus( isClient ? projectIds : [], targetSystem ) // 서버 사이드에서는 안전한 기본값 반환 if (!isClient) { return { contractStatuses: [], totalStats: { totalPending: 0, totalSynced: 0, totalFailed: 0, totalChanges: 0, hasError: false, isLoading: true, canSync: false, entityTypeDetailsTotals: { document: { pending: 0, synced: 0, failed: 0, total: 0 }, revision: { pending: 0, synced: 0, failed: 0, total: 0 }, attachment: { pending: 0, synced: 0, failed: 0, total: 0 } }, hasPendingChanges: false, hasFailedChanges: false, syncHealthy: true }, refetchAll: () => {} } } return syncResult } // ✅ 동기화 배치 목록 조회 - DOLCE 기본값 export function useSyncBatches(projectId: number | null, targetSystem: string = 'DOLCE') { const key = projectId ? `/api/sync/batches?projectId=${projectId}&targetSystem=${targetSystem}` : null const { data, error, isLoading } = useSWR( key, fetcher, { revalidateOnFocus: false, shouldRetryOnError: false, refreshInterval: 10000, // 10초마다 갱신 } ) return { syncBatches: data || [], isLoading: isLoading && Boolean(projectId), error: error as ApiError | null } } // ✅ 동기화 설정 조회 - DOLCE 기본값 export function useSyncConfig(projectId: number | null, targetSystem: string = 'DOLCE') { const key = projectId ? `/api/sync/config?projectId=${projectId}&targetSystem=${targetSystem}` : null const { data, error, isLoading, mutate: localMutate } = useSWR( key, fetcher, { revalidateOnFocus: false, shouldRetryOnError: false, } ) const refetch = React.useCallback(() => { if (key) { localMutate() } }, [key, localMutate]) return { syncConfig: data, isLoading: isLoading && Boolean(projectId), error: error as ApiError | null, refetch } } // ✅ 동기화 트리거 (뮤테이션) - DOLCE 기본값 export function useTriggerSync() { const { trigger, isMutating, error } = useSWRMutation( '/api/sync/trigger', async (url: string, { arg }: { arg: { projectId: number; targetSystem?: string } }) => { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...arg, targetSystem: arg.targetSystem || 'DOLCE' }) }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) const error = new Error(errorData.message || `동기화 실패: HTTP ${response.status}`) as ApiError error.status = response.status error.response = errorData throw error } return response.json() } ) const triggerSync = React.useCallback(async (arg: { projectId: number; targetSystem?: string }) => { try { const result = await trigger(arg) // 성공 후 관련 캐시 무효화 const targetSystem = arg.targetSystem || 'DOLCE' const statusKey = `/api/sync/status?projectId=${arg.projectId}&targetSystem=${targetSystem}` const batchesKey = `/api/sync/batches?projectId=${arg.projectId}&targetSystem=${targetSystem}` globalMutate(statusKey) globalMutate(batchesKey) return result } catch (error) { console.error('Sync trigger failed:', error) throw error } }, [trigger]) return { triggerSync, isLoading: isMutating, error: error as ApiError | null } } // ✅ 동기화 설정 업데이트 (뮤테이션) export function useUpdateSyncConfig() { const { trigger, isMutating, error } = useSWRMutation( '/api/sync/config', async (url: string, { arg }: { arg: Partial & { projectId: number; targetSystem: string } }) => { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(arg) }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) const error = new Error(errorData.message || `설정 업데이트 실패: HTTP ${response.status}`) as ApiError error.status = response.status error.response = errorData throw error } return response.json() } ) // ✅ 수동 캐시 무효화를 포함한 래핑 함수 const updateConfig = React.useCallback(async (arg: Partial & { projectId: number; targetSystem: string }) => { try { const result = await trigger(arg) // ✅ 성공 후 수동으로 캐시 무효화 const { projectId, targetSystem } = arg const configKey = `/api/sync/config?projectId=${projectId}&targetSystem=${targetSystem}` globalMutate(configKey) return result } catch (error) { console.error('Sync config update failed:', error) throw error } }, [trigger]) return { updateConfig, isLoading: isMutating, error: error as ApiError | null } } // ✅ 실시간 동기화 상태 훅 (높은 갱신 빈도) - DOLCE 기본값 export function useRealtimeSyncStatus(projectId: number | null, targetSystem: string = 'DOLCE') { const key = projectId ? `/api/sync/status?projectId=${projectId}&targetSystem=${targetSystem}&realtime=true` : null const { data, error, isLoading } = useSWR( key, fetcher, { refreshInterval: 5000, // 5초마다 갱신 revalidateOnFocus: true, revalidateOnReconnect: true, shouldRetryOnError: (error: ApiError) => error.status >= 500 || error.status === 0, dedupingInterval: 2000, // 실시간이므로 중복 제거 간격 단축 } ) const safeData = React.useMemo(() => { return data || createDefaultSyncStatus( error ? (error as ApiError).message : undefined, projectId || undefined ) }, [data, error, projectId]) return { syncStatus: safeData, isLoading: isLoading && Boolean(projectId), error: error as ApiError | null } } // 🔧 유틸리티 함수들 export const syncUtils = { // 캐시 수동 무효화 - DOLCE 기본값 invalidateCache: (projectId: number, targetSystem: string = 'DOLCE') => { const statusKey = `/api/sync/status?projectId=${projectId}&targetSystem=${targetSystem}` const batchesKey = `/api/sync/batches?projectId=${projectId}&targetSystem=${targetSystem}` const configKey = `/api/sync/config?projectId=${projectId}&targetSystem=${targetSystem}` globalMutate(statusKey) globalMutate(batchesKey) globalMutate(configKey) }, // 모든 동기화 관련 캐시 무효화 invalidateAllCache: () => { globalMutate((key) => typeof key === 'string' && key.startsWith('/api/sync/')) }, // 에러 메시지 정리 formatError: (error: ApiError | null): string => { if (!error) return '' if (error.status === 0) return '네트워크 연결을 확인해주세요' if (error.status === 401) return '인증이 필요합니다' if (error.status === 403) return '권한이 없습니다' if (error.status === 404) return '요청한 리소스를 찾을 수 없습니다' if (error.status >= 500) return '서버 오류가 발생했습니다' return error.message || '알 수 없는 오류가 발생했습니다' }, // entityType별 통계 포맷터 formatEntityTypeStats: (details?: SyncStatus['entityTypeDetails']): string => { if (!details) return '통계 없음' const parts: string[] = [] if (details.document.total > 0) { parts.push(`document: ${details.document.pending}/${details.document.total}`) } if (details.revision.total > 0) { parts.push(`revision: ${details.revision.pending}/${details.revision.total}`) } if (details.attachment.total > 0) { parts.push(`attachment: ${details.attachment.pending}/${details.attachment.total}`) } return parts.length > 0 ? parts.join(', ') : '변경사항 없음' } }