// import-from-dolce-button.tsx - 리비전/첨부파일 포함 버전 "use client" import * as React from "react" import { RefreshCw, Download, Loader2, CheckCircle, AlertTriangle } from "lucide-react" import { toast } from "sonner" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" import { Badge } from "@/components/ui/badge" import { Progress } from "@/components/ui/progress" import { Separator } from "@/components/ui/separator" import { SimplifiedDocumentsView } from "@/db/schema" import { ImportStatus } from "../import-service" import { useSession } from "next-auth/react" import { getProjectIdsByVendor, getProjectsByIds } from "../service" import { useParams } from "next/navigation" import { useTranslation } from "@/i18n/client" // API 응답 캐시 (컴포넌트 외부에 선언하여 인스턴스 간 공유) const statusCache = new Map() const CACHE_TTL = 2 * 60 * 1000 // 2분 캐시 interface ImportFromDOLCEButtonProps { allDocuments: SimplifiedDocumentsView[] projectIds?: number[] // 미리 계산된 projectIds를 props로 받음 onImportComplete?: () => void } // 프로젝트 정보 타입 interface ProjectInfo { id: number code: string name: string } // 디바운스 훅 function useDebounce(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = React.useState(value) React.useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value) }, delay) return () => { clearTimeout(handler) } }, [value, delay]) return debouncedValue } export function ImportFromDOLCEButton({ allDocuments, projectIds: propProjectIds, onImportComplete }: ImportFromDOLCEButtonProps) { const [isDialogOpen, setIsDialogOpen] = React.useState(false) const [importProgress, setImportProgress] = React.useState(0) const [isImporting, setIsImporting] = React.useState(false) const [importStatusMap, setImportStatusMap] = React.useState>(new Map()) const [statusLoading, setStatusLoading] = React.useState(false) const [vendorProjectIds, setVendorProjectIds] = React.useState([]) const [loadingVendorProjects, setLoadingVendorProjects] = React.useState(false) const [projectsMap, setProjectsMap] = React.useState>(new Map()) const { data: session } = useSession() const vendorId = session?.user.companyId const params = useParams() const lng = (params?.lng as string) || "ko" const { t } = useTranslation(lng, "engineering") // allDocuments에서 projectIds 추출 (props로 전달받은 경우 사용) const documentsProjectIds = React.useMemo(() => { if (propProjectIds) return propProjectIds // props로 받은 경우 그대로 사용 const uniqueIds = [...new Set(allDocuments.map(doc => doc.projectId).filter((id): id is number => id !== null))] return uniqueIds.sort() }, [allDocuments, propProjectIds]) // 최종 projectIds (변경 빈도 최소화) const projectIds = React.useMemo(() => { if (documentsProjectIds.length > 0) { return documentsProjectIds } return vendorProjectIds }, [documentsProjectIds, vendorProjectIds]) // projectIds 디바운싱 (API 호출 과다 방지) const debouncedProjectIds = useDebounce(projectIds, 300) // 캐시된 API 호출 함수 const fetchImportStatusCached = React.useCallback(async (projectId: number): Promise => { const cacheKey = `import-status-${projectId}` const cached = statusCache.get(cacheKey) // 캐시된 데이터가 있고 유효하면 사용 if (cached && Date.now() - cached.timestamp < CACHE_TTL) { return cached.data } try { const response = await fetch(`/api/sync/import/status?projectId=${projectId}&sourceSystem=DOLCE`) if (!response.ok) { const errorData = await response.json().catch(() => ({})) throw new Error(errorData.message || 'Failed to fetch import status') } const status = await response.json() if (status.error) { console.warn(`Status error for project ${projectId}:`, status.error) return null } // 캐시에 저장 statusCache.set(cacheKey, { data: status, timestamp: Date.now() }) return status } catch (error) { console.error(`Failed to fetch status for project ${projectId}:`, error) return null } }, []) // 모든 projectId에 대한 상태 조회 (최적화된 버전) const fetchAllImportStatus = React.useCallback(async () => { if (debouncedProjectIds.length === 0) return setStatusLoading(true) const statusMap = new Map() try { // 병렬 처리하되 동시 연결 수 제한 (3개씩) const batchSize = 3 const batches: number[][] = [] for (let i = 0; i < debouncedProjectIds.length; i += batchSize) { batches.push(debouncedProjectIds.slice(i, i + batchSize)) } for (const batch of batches) { const batchPromises = batch.map(async (projectId) => { const status = await fetchImportStatusCached(projectId) return { projectId, status } }) const batchResults = await Promise.all(batchPromises) batchResults.forEach(({ projectId, status }) => { if (status) { statusMap.set(projectId, status) } }) // 배치 간 짧은 지연 if (batches.length > 1) { await new Promise(resolve => setTimeout(resolve, 100)) } } setImportStatusMap(statusMap) } catch (error) { console.error('Failed to fetch import statuses:', error) toast.error(t('dolceImport.messages.statusCheckError')) } finally { setStatusLoading(false) } }, [debouncedProjectIds, fetchImportStatusCached, t]) // vendorId로 projects 가져오기 (최적화) React.useEffect(() => { let isCancelled = false; if (allDocuments.length !== 0 || !vendorId) return; setLoadingVendorProjects(true); getProjectIdsByVendor(vendorId) .then((projectIds) => { if (!isCancelled) setVendorProjectIds(projectIds); }) .catch(() => { if (!isCancelled) toast.error(t('dolceImport.messages.projectFetchError')); }) .finally(() => { if (!isCancelled) setLoadingVendorProjects(false); }); return () => { isCancelled = true; }; }, [allDocuments, vendorId, t]); // projectIds로 프로젝트 정보 가져오기 (서버 액션 사용) React.useEffect(() => { if (projectIds.length === 0) return; const fetchProjectsInfo = async () => { try { const projectsData = await getProjectsByIds(projectIds); const newProjectsMap = new Map(); projectsData.forEach((project) => { newProjectsMap.set(project.id, project); }); setProjectsMap(newProjectsMap); } catch (error) { console.error('프로젝트 정보 조회 실패:', error); } }; fetchProjectsInfo(); }, [projectIds]); // 컴포넌트 마운트 시 상태 조회 (디바운싱 적용) React.useEffect(() => { if (debouncedProjectIds.length > 0) { fetchAllImportStatus() } }, [debouncedProjectIds, fetchAllImportStatus]) // 전체 통계 메모이제이션 - 리비전과 첨부파일 추가 const totalStats = React.useMemo(() => { const statuses = Array.from(importStatusMap.values()) return statuses.reduce((acc, status) => ({ availableDocuments: acc.availableDocuments + (status.availableDocuments || 0), newDocuments: acc.newDocuments + (status.newDocuments || 0), updatedDocuments: acc.updatedDocuments + (status.updatedDocuments || 0), availableRevisions: acc.availableRevisions + (status.availableRevisions || 0), newRevisions: acc.newRevisions + (status.newRevisions || 0), updatedRevisions: acc.updatedRevisions + (status.updatedRevisions || 0), availableAttachments: acc.availableAttachments + (status.availableAttachments || 0), newAttachments: acc.newAttachments + (status.newAttachments || 0), updatedAttachments: acc.updatedAttachments + (status.updatedAttachments || 0), importEnabled: acc.importEnabled || status.importEnabled }), { availableDocuments: 0, newDocuments: 0, updatedDocuments: 0, availableRevisions: 0, newRevisions: 0, updatedRevisions: 0, availableAttachments: 0, newAttachments: 0, updatedAttachments: 0, importEnabled: false }) }, [importStatusMap]) // 가져오기 실행 함수 최적화 const handleImport = React.useCallback(async () => { if (projectIds.length === 0) return setImportProgress(0) setIsImporting(true) try { const progressInterval = setInterval(() => { setImportProgress(prev => Math.min(prev + 10, 85)) }, 500) // 순차 처리로 서버 부하 방지 const results: Array<{ success: boolean newCount?: number updatedCount?: number skippedCount?: number error?: string }> = [] for (const projectId of projectIds) { try { const response = await fetch('/api/sync/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId, sourceSystem: 'DOLCE' }) }) if (!response.ok) { const errorData = await response.json() throw new Error(`Project ${projectId}: ${errorData.message || 'Import failed'}`) } const result = await response.json() results.push(result) // 프로젝트 간 짧은 지연 if (projectIds.length > 1) { await new Promise(resolve => setTimeout(resolve, 200)) } } catch (error) { console.error(`Import failed for project ${projectId}:`, error) results.push({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }) } } clearInterval(progressInterval) setImportProgress(100) // 결과 집계 const totalResult = results.reduce((acc, result) => ({ newCount: (acc.newCount || 0) + (result.newCount || 0), updatedCount: (acc.updatedCount || 0) + (result.updatedCount || 0), skippedCount: (acc.skippedCount || 0) + (result.skippedCount || 0), success: acc.success && result.success }), { newCount: 0 as number, updatedCount: 0 as number, skippedCount: 0 as number, success: true }) setTimeout(() => { setImportProgress(0) setIsDialogOpen(false) setIsImporting(false) if (totalResult.success) { toast.success( t('dolceImport.messages.importSuccess'), { description: t('dolceImport.messages.importSuccessDescription', { newCount: totalResult.newCount, updatedCount: totalResult.updatedCount, skippedCount: totalResult.skippedCount, projectCount: projectIds.length }) } ) } else { toast.error( t('dolceImport.messages.importPartiallyFailed'), { description: t('dolceImport.messages.importPartiallyFailedDescription') } ) } // 캐시 무효화 statusCache.clear() fetchAllImportStatus() onImportComplete?.() }, 500) } catch (error) { setImportProgress(0) setIsImporting(false) toast.error(t('dolceImport.messages.importFailed'), { description: error instanceof Error ? error.message : t('dolceImport.messages.unknownError') }) } }, [projectIds, fetchAllImportStatus, onImportComplete, t]) // 전체 변경 사항 계산 const totalChanges = React.useMemo(() => { return totalStats.newDocuments + totalStats.updatedDocuments + totalStats.newRevisions + totalStats.updatedRevisions + totalStats.newAttachments + totalStats.updatedAttachments }, [totalStats]) // 상태 뱃지 메모이제이션 - 리비전과 첨부파일 포함 const statusBadge = React.useMemo(() => { if (loadingVendorProjects) { return {t('dolceImport.status.loadingProjectInfo')} } if (statusLoading) { return {t('dolceImport.status.checkingConnection')} } if (importStatusMap.size === 0) { return {t('dolceImport.status.connectionError')} } if (!totalStats.importEnabled) { return {t('dolceImport.status.importDisabled')} } if (totalChanges > 0) { return ( {t('dolceImport.status.updatesAvailable', { projectCount: projectIds.length })} ) } return ( {t('dolceImport.status.synchronized')} ) }, [loadingVendorProjects, statusLoading, importStatusMap.size, totalStats.importEnabled, totalChanges, projectIds.length, t]) // 가져오기 가능 여부 - 리비전과 첨부파일도 체크 const canImport = totalStats.importEnabled && totalChanges > 0 // 새로고침 핸들러 최적화 const handleRefresh = React.useCallback(() => { statusCache.clear() // 캐시 무효화 fetchAllImportStatus() }, [fetchAllImportStatus]) // 자동 동기화 실행 (기존 useEffect들 다음에 추가) React.useEffect(() => { // 조건: 가져오기 가능하고, 동기화할 항목이 있고, 현재 진행중이 아닐 때 if (canImport && totalChanges > 0 && !isImporting && !isDialogOpen) { // 상태 로딩이 완료된 후 잠깐 대기 (사용자가 상태를 확인할 수 있도록) const timer = setTimeout(() => { console.log(`🔄 자동 동기화 시작: ${totalChanges}개 항목`) // 동기화 시작 알림 toast.info( '새로운 변경사항이 발견되어 자동 동기화를 시작합니다', { description: `총 ${totalChanges}개 항목 (문서/리비전/첨부파일)`, duration: 3000 } ) // 자동으로 다이얼로그 열고 동기화 실행 setIsDialogOpen(true) // 잠깐 후 실제 동기화 시작 (다이얼로그가 열리는 시간) setTimeout(() => { handleImport() }, 500) }, 1500) // 1.5초 대기 return () => clearTimeout(timer) } }, [canImport, totalChanges, isImporting, isDialogOpen, handleImport]) // 로딩 중이거나 projectIds가 없으면 버튼을 표시하지 않음 if (projectIds.length === 0) { return null } return ( <>

{t('dolceImport.labels.importStatus')}

{t('dolceImport.labels.currentStatus')} {statusBadge}
{/* 프로젝트 소스 표시 */} {allDocuments.length === 0 && vendorProjectIds.length > 0 && (
{t('dolceImport.descriptions.noDocumentsImportAll')}
)} {/* 다중 프로젝트 정보 표시 */} {projectIds.length > 1 && (
{t('dolceImport.labels.targetProjects')}
{t('dolceImport.labels.projectCount', { count: projectIds.length })}
{t('dolceImport.labels.projectIds')}: {projectIds.map(id => projectsMap.get(id)?.code || id).join(', ')}
)} {totalStats && (
{/* 문서 정보 */}
{t('dolceImport.labels.documents')}
{t('dolceImport.labels.total')}
{totalStats.availableDocuments || 0}
{t('dolceImport.labels.new')}
{totalStats.newDocuments || 0}
{t('dolceImport.labels.updates')}
{totalStats.updatedDocuments || 0}
{/* 리비전 정보 */} {(totalStats.availableRevisions > 0 || totalStats.newRevisions > 0 || totalStats.updatedRevisions > 0) && (
{t('dolceImport.labels.revisions')}
{t('dolceImport.labels.total')}
{totalStats.availableRevisions || 0}
{t('dolceImport.labels.new')}
{totalStats.newRevisions || 0}
{t('dolceImport.labels.updates')}
{totalStats.updatedRevisions || 0}
)} {/* 첨부파일 정보 */} {(totalStats.availableAttachments > 0 || totalStats.newAttachments > 0 || totalStats.updatedAttachments > 0) && (
{t('dolceImport.labels.attachments')}
{t('dolceImport.labels.total')}
{totalStats.availableAttachments || 0}
{t('dolceImport.labels.new')}
{totalStats.newAttachments || 0}
{t('dolceImport.labels.updates')}
{totalStats.updatedAttachments || 0}
)} {/* 요약 */} {totalChanges > 0 && (
{t('dolceImport.labels.totalChanges')}: {totalChanges}
)} {/* 각 프로젝트별 세부 정보 */} {projectIds.length > 1 && (
{t('dolceImport.labels.detailsByProject')}
{projectIds.map(projectId => { const status = importStatusMap.get(projectId) const projectInfo = projectsMap.get(projectId) const projectLabel = projectInfo?.code || projectId return (
{t('dolceImport.labels.projectLabel', { projectId: projectLabel })}
{status ? (
{t('dolceImport.descriptions.projectDocuments', { newDocuments: status.newDocuments, updatedDocuments: status.updatedDocuments })}
{(status.newRevisions > 0 || status.updatedRevisions > 0) && (
{t('dolceImport.descriptions.projectRevisions', { newRevisions: status.newRevisions, updatedRevisions: status.updatedRevisions })}
)} {(status.newAttachments > 0 || status.updatedAttachments > 0) && (
{t('dolceImport.descriptions.projectAttachments', { newAttachments: status.newAttachments, updatedAttachments: status.updatedAttachments })}
)}
) : (
{t('dolceImport.status.statusCheckFailed')}
)}
) })}
)}
)}
{/* 가져오기 진행 다이얼로그 */} {t('dolceImport.dialog.title')} {t('dolceImport.dialog.description')} {projectIds.length > 1 && ` (${t('dolceImport.dialog.multipleProjects', { count: projectIds.length })})`}
{totalStats && (
{t('dolceImport.labels.itemsToImport')} {totalChanges}
{totalStats.newDocuments + totalStats.updatedDocuments > 0 && (
• {t('dolceImport.labels.documents')}: {totalStats.newDocuments} {t('dolceImport.labels.new')}, {totalStats.updatedDocuments} {t('dolceImport.labels.updates')}
)} {totalStats.newRevisions + totalStats.updatedRevisions > 0 && (
• {t('dolceImport.labels.revisions')}: {totalStats.newRevisions} {t('dolceImport.labels.new')}, {totalStats.updatedRevisions} {t('dolceImport.labels.updates')}
)} {totalStats.newAttachments + totalStats.updatedAttachments > 0 && (
• {t('dolceImport.labels.attachments')}: {totalStats.newAttachments} {t('dolceImport.labels.new')}, {totalStats.updatedAttachments} {t('dolceImport.labels.updates')}
)}
{t('dolceImport.descriptions.b4DocumentsNote')} {projectIds.length > 1 && ( <>
{t('dolceImport.descriptions.sequentialImport', { count: projectIds.length })} )}
{isImporting && (
{t('dolceImport.labels.progress')} {importProgress}%
)}
)}
) }