/** * 특정 컬럼들 복합키로 묶어 UPDATE 처리해야 함. */ "use client" import React, { useRef } from 'react' import ExcelJS from 'exceljs' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Upload, Loader } from 'lucide-react' import { createVendorPool } from '../service' import { Input } from '@/components/ui/input' import { useSession } from "next-auth/react" import { getCellValueAsString, parseBoolean, getAccessorKeyByHeader, vendorPoolExcelColumns } from '../excel-utils' import { decryptWithServerAction } from '@/components/drm/drmUtils' import { debugLog, debugError, debugWarn, debugSuccess, debugProcess } from '@/lib/debug-utils' import { enrichVendorPoolData } from '../enrichment-service' import { ImportResultDialog, ImportResult, ImportResultItem } from './import-result-dialog' import { ImportProgressDialog } from './import-progress-dialog' interface ImportExcelProps { onSuccess?: () => void } export function ImportVendorPoolButton({ onSuccess }: ImportExcelProps) { const fileInputRef = useRef(null) const [isImporting, setIsImporting] = React.useState(false) const { data: session } = useSession() const [importResult, setImportResult] = React.useState(null) const [showResultDialog, setShowResultDialog] = React.useState(false) // Progress 상태 const [showProgressDialog, setShowProgressDialog] = React.useState(false) const [totalRows, setTotalRows] = React.useState(0) const [processedRows, setProcessedRows] = React.useState(0) // 헬퍼 함수들은 excel-utils에서 import const handleImport = async (event: React.ChangeEvent) => { const file = event.target.files?.[0] if (!file) { debugWarn('[Import] 파일이 선택되지 않았습니다.') return } debugLog('[Import] 파일 임포트 시작:', { fileName: file.name, fileSize: file.size, fileType: file.type }) setIsImporting(true) try { // DRM 복호화 처리 debugProcess('[Import] DRM 복호화 시작') toast.info("파일을 복호화하고 있습니다...") let decryptedData: ArrayBuffer try { decryptedData = await decryptWithServerAction(file) debugSuccess('[Import] DRM 복호화 성공') toast.success("파일 복호화가 완료되었습니다.") } catch (drmError) { debugWarn('[Import] DRM 복호화 실패, 원본 파일로 진행:', drmError) console.warn("DRM 복호화 실패, 원본 파일로 진행합니다:", drmError) toast.warning("DRM 복호화에 실패했습니다. 원본 파일로 진행합니다.") decryptedData = await file.arrayBuffer() debugLog('[Import] 원본 파일 ArrayBuffer 로드 완료, size:', decryptedData.byteLength) } // 복호화된 데이터로 ExcelJS 워크북 로드 debugProcess('[Import] ExcelJS 워크북 로드 시작') toast.info("엑셀 파일을 분석하고 있습니다...") const workbook = new ExcelJS.Workbook() await workbook.xlsx.load(decryptedData) debugSuccess('[Import] ExcelJS 워크북 로드 완료') // Get the first worksheet const worksheet = workbook.getWorksheet(1) if (!worksheet) { debugError('[Import] 워크시트를 찾을 수 없습니다.') toast.error("No worksheet found in the spreadsheet") return } debugLog('[Import] 워크시트 확인 완료:', { name: worksheet.name, rowCount: worksheet.rowCount, columnCount: worksheet.columnCount }) // Check if there's an instruction row (템플릿 안내 텍스트가 있는지 확인) const firstRowText = getCellValueAsString(worksheet.getRow(1).getCell(1)); const hasInstructionRow = firstRowText.includes('벤더풀 데이터 입력 템플릿') || firstRowText.includes('입력 가이드 시트') || firstRowText.includes('입력 가이드') || (worksheet.getRow(1).getCell(1).value !== null && worksheet.getRow(1).getCell(2).value === null); debugLog('[Import] 첫 번째 행 확인:', { firstRowText, hasInstructionRow }) // Get header row index (row 2 if there's an instruction row, otherwise row 1) const headerRowIndex = hasInstructionRow ? 2 : 1; debugLog('[Import] 헤더 행 인덱스:', headerRowIndex) // Get column headers and their indices debugProcess('[Import] 컬럼 헤더 매핑 시작') const headerRow = worksheet.getRow(headerRowIndex); const columnIndices: Record = {}; headerRow.eachCell((cell, colNumber) => { const header = getCellValueAsString(cell); // Excel 헤더를 통해 accessorKey 찾기 const accessorKey = getAccessorKeyByHeader(header); if (accessorKey) { columnIndices[accessorKey] = colNumber; } }); debugLog('[Import] 컬럼 매핑 완료:', { mappedColumns: Object.keys(columnIndices).length, columnIndices }) // Process data rows debugProcess('[Import] 데이터 행 처리 시작') const rows: any[] = []; const startRow = headerRowIndex + 1; let skippedRows = 0; for (let i = startRow; i <= worksheet.rowCount; i++) { const row = worksheet.getRow(i); // Skip empty rows if (row.cellCount === 0) { skippedRows++; continue; } // Check if this is likely an empty template row (빈 템플릿 행 건너뛰기) let hasAnyData = false; for (let col = 1; col <= row.cellCount; col++) { if (getCellValueAsString(row.getCell(col)).trim()) { hasAnyData = true; break; } } if (!hasAnyData) { skippedRows++; continue; } const rowData: Record = {}; let hasData = false; // Map the data using accessorKey indices Object.entries(columnIndices).forEach(([accessorKey, colIndex]) => { const value = getCellValueAsString(row.getCell(colIndex)); if (value) { rowData[accessorKey] = value; hasData = true; } }); if (hasData) { rows.push(rowData); } else { skippedRows++; } } debugLog('[Import] 데이터 행 처리 완료:', { totalRows: worksheet.rowCount - headerRowIndex, validRows: rows.length, skippedRows }) if (rows.length === 0) { debugWarn('[Import] 유효한 데이터가 없습니다.') toast.error("No data found in the spreadsheet") setIsImporting(false) return } // Progress Dialog 표시 setTotalRows(rows.length) setProcessedRows(0) setShowProgressDialog(true) // Process each row debugProcess('[Import] 데이터베이스 저장 시작') let successCount = 0; let errorCount = 0; let duplicateCount = 0; const resultItems: ImportResultItem[] = []; // 실패한 건만 포함 // Create promises for all vendor pool creation operations const promises = rows.map(async (row, rowIndex) => { // Excel 컬럼 설정을 기반으로 데이터 매핑 (catch 블록에서도 사용하기 위해 밖에서 선언) const vendorPoolData: any = {}; try { debugLog(`[Import] 행 ${rowIndex + 1}/${rows.length} 처리 시작 - 원본 데이터:`, row) vendorPoolExcelColumns.forEach(column => { const { accessorKey, type } = column; const value = row[accessorKey] || ''; if (type === 'boolean') { vendorPoolData[accessorKey] = parseBoolean(String(value)); } else if (value === '') { // 빈 문자열은 null로 설정 (스키마에 맞게) vendorPoolData[accessorKey] = null; } else { vendorPoolData[accessorKey] = String(value); } }); // 현재 사용자 정보 추가 vendorPoolData.registrant = session?.user?.name || 'system'; vendorPoolData.lastModifier = session?.user?.name || 'system'; debugLog(`[Import] 행 ${rowIndex + 1} 데이터 매핑 완료 - 전체 필드:`, { '공사부문': vendorPoolData.constructionSector, 'H/T구분': vendorPoolData.htDivision, '설계기능코드': vendorPoolData.designCategoryCode, '설계기능': vendorPoolData.designCategory, 'Equip/Bulk구분': vendorPoolData.equipBulkDivision, '협력업체코드': vendorPoolData.vendorCode, '협력업체명': vendorPoolData.vendorName, '자재그룹코드': vendorPoolData.materialGroupCode, '자재그룹명': vendorPoolData.materialGroupName, '계약서명주체코드': vendorPoolData.contractSignerCode, '계약서명주체명': vendorPoolData.contractSignerName, '사업자번호': vendorPoolData.taxId, '패키지코드': vendorPoolData.packageCode, '패키지명': vendorPoolData.packageName }) // 코드를 기반으로 자동완성 수행 debugProcess(`[Import] 행 ${rowIndex + 1} enrichment 시작`); const enrichmentResult = await enrichVendorPoolData({ designCategoryCode: vendorPoolData.designCategoryCode, designCategory: vendorPoolData.designCategory, materialGroupCode: vendorPoolData.materialGroupCode, materialGroupName: vendorPoolData.materialGroupName, vendorCode: vendorPoolData.vendorCode, vendorName: vendorPoolData.vendorName, contractSignerCode: vendorPoolData.contractSignerCode, contractSignerName: vendorPoolData.contractSignerName, }); // enrichment 결과 적용 if (enrichmentResult.enrichedFields.length > 0) { debugSuccess(`[Import] 행 ${rowIndex + 1} enrichment 완료 - 자동완성된 필드: ${enrichmentResult.enrichedFields.join(', ')}`); // enrichment된 데이터를 vendorPoolData에 반영 if (enrichmentResult.enriched.designCategory) { vendorPoolData.designCategory = enrichmentResult.enriched.designCategory; } if (enrichmentResult.enriched.materialGroupName) { vendorPoolData.materialGroupName = enrichmentResult.enriched.materialGroupName; } if (enrichmentResult.enriched.vendorName) { vendorPoolData.vendorName = enrichmentResult.enriched.vendorName; } if (enrichmentResult.enriched.contractSignerName) { vendorPoolData.contractSignerName = enrichmentResult.enriched.contractSignerName; } } // enrichment 경고 메시지 로깅만 (resultItems에 추가하지 않음) if (enrichmentResult.warnings.length > 0) { debugWarn(`[Import] 행 ${rowIndex + 1} enrichment 경고:`, enrichmentResult.warnings); enrichmentResult.warnings.forEach(warning => { console.warn(`Row ${rowIndex + 1}: ${warning}`); }); } // Validate required fields (필수 필드 검증) // 자동완성 가능한 필드는 코드가 있으면 명칭은 optional로 처리 const requiredFieldsCheck = { constructionSector: { value: vendorPoolData.constructionSector, label: '공사부문' }, htDivision: { value: vendorPoolData.htDivision, label: 'H/T구분' }, designCategoryCode: { value: vendorPoolData.designCategoryCode, label: '설계기능코드' } }; // 설계기능: 설계기능코드가 없으면 설계기능명 필수 if (!vendorPoolData.designCategoryCode && !vendorPoolData.designCategory) { requiredFieldsCheck['designCategory'] = { value: vendorPoolData.designCategory, label: '설계기능' }; } // 협력업체명: 협력업체코드가 없으면 협력업체명 필수 if (!vendorPoolData.vendorCode && !vendorPoolData.vendorName) { requiredFieldsCheck['vendorName'] = { value: vendorPoolData.vendorName, label: '협력업체명' }; } const missingRequiredFields = Object.entries(requiredFieldsCheck) .filter(([_, field]) => !field.value) .map(([key, field]) => `${field.label}(${key})`); if (missingRequiredFields.length > 0) { debugError(`[Import] 행 ${rowIndex + 1} 필수 필드 누락 [${missingRequiredFields.length}개]:`, { missingFields: missingRequiredFields, currentData: vendorPoolData }); console.error(`Missing required fields in row ${rowIndex + 1}:`, missingRequiredFields.join(', ')); errorCount++; resultItems.push({ rowNumber: rowIndex + 1, status: 'error', message: `필수 필드 누락: ${missingRequiredFields.join(', ')}`, data: { vendorName: vendorPoolData.vendorName, materialGroupName: vendorPoolData.materialGroupName, designCategory: vendorPoolData.designCategory, } }); // Progress 업데이트 setProcessedRows(prev => prev + 1); return null; } debugSuccess(`[Import] 행 ${rowIndex + 1} 필수 필드 검증 통과`); // Validate field lengths and formats (필드 길이 및 형식 검증) const validationErrors: string[] = []; if (vendorPoolData.designCategoryCode && vendorPoolData.designCategoryCode.length > 2) { validationErrors.push(`설계기능코드는 2자리 이하여야 합니다: ${vendorPoolData.designCategoryCode}`); } if (vendorPoolData.equipBulkDivision && vendorPoolData.equipBulkDivision.length > 1) { validationErrors.push(`Equip/Bulk 구분은 1자리여야 합니다: ${vendorPoolData.equipBulkDivision}`); } if (vendorPoolData.constructionSector && !['조선', '해양'].includes(vendorPoolData.constructionSector)) { validationErrors.push(`공사부문은 '조선' 또는 '해양'이어야 합니다: ${vendorPoolData.constructionSector}`); } if (vendorPoolData.htDivision && !['H', 'T', '공통'].includes(vendorPoolData.htDivision)) { validationErrors.push(`H/T구분은 'H', 'T' 또는 '공통'이어야 합니다: ${vendorPoolData.htDivision}`); } if (validationErrors.length > 0) { debugError(`[Import] 행 ${rowIndex + 1} 검증 실패 [${validationErrors.length}개 오류]:`, { errors: validationErrors, problematicFields: { designCategoryCode: vendorPoolData.designCategoryCode, equipBulkDivision: vendorPoolData.equipBulkDivision, constructionSector: vendorPoolData.constructionSector, htDivision: vendorPoolData.htDivision } }); console.error(`Validation errors in row ${rowIndex + 1}:`, validationErrors.join(' | ')); errorCount++; resultItems.push({ rowNumber: rowIndex + 1, status: 'error', message: `검증 실패: ${validationErrors.join(', ')}`, data: { vendorName: vendorPoolData.vendorName, materialGroupName: vendorPoolData.materialGroupName, designCategory: vendorPoolData.designCategory, } }); // Progress 업데이트 setProcessedRows(prev => prev + 1); return null; } debugSuccess(`[Import] 행 ${rowIndex + 1} 형식 검증 통과`); if (!session || !session.user || !session.user.id) { debugError(`[Import] 행 ${rowIndex + 1} 세션 오류: 로그인 정보 없음`); toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") return } // Create the vendor pool entry debugProcess(`[Import] 행 ${rowIndex + 1} 데이터베이스 저장 시도`); const result = await createVendorPool(vendorPoolData as any) if (!result) { debugError(`[Import] 행 ${rowIndex + 1} 저장 실패: createVendorPool returned null`); console.error(`Failed to import row - createVendorPool returned null:`, vendorPoolData); errorCount++; resultItems.push({ rowNumber: rowIndex + 1, status: 'error', message: '데이터 저장 실패', data: { vendorName: vendorPoolData.vendorName, materialGroupName: vendorPoolData.materialGroupName, designCategory: vendorPoolData.designCategory, } }); // Progress 업데이트 setProcessedRows(prev => prev + 1); return null; } debugSuccess(`[Import] 행 ${rowIndex + 1} 저장 성공 (ID: ${result.id})`); successCount++; // 성공한 건은 resultItems에 추가하지 않음 // Progress 업데이트 setProcessedRows(prev => prev + 1); return result; } catch (error) { debugError(`[Import] 행 ${rowIndex + 1} 처리 중 예외 발생:`, error); console.error("Error processing row:", error, row); // Unique 제약 조건 위반 감지 (중복 데이터) const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage === 'DUPLICATE_VENDOR_POOL') { debugWarn(`[Import] 행 ${rowIndex + 1} 중복 데이터 감지:`, { constructionSector: vendorPoolData.constructionSector, htDivision: vendorPoolData.htDivision, materialGroupCode: vendorPoolData.materialGroupCode, vendorName: vendorPoolData.vendorName }); duplicateCount++; // 중복 건은 resultItems에 추가하지 않음 // Progress 업데이트 setProcessedRows(prev => prev + 1); return null; } // 다른 에러의 경우 에러 카운트 증가 errorCount++; resultItems.push({ rowNumber: rowIndex + 1, status: 'error', message: `처리 중 오류 발생: ${errorMessage}`, data: { vendorName: vendorPoolData.vendorName, materialGroupName: vendorPoolData.materialGroupName, designCategory: vendorPoolData.designCategory, } }); // Progress 업데이트 setProcessedRows(prev => prev + 1); return null; } }); // Wait for all operations to complete debugProcess('[Import] 모든 Promise 완료 대기 중...') await Promise.all(promises); debugSuccess('[Import] 모든 데이터 처리 완료') debugLog('[Import] 최종 결과:', { totalRows: rows.length, successCount, errorCount, duplicateCount }) // Progress Dialog 닫기 setShowProgressDialog(false) // Import 결과 Dialog 데이터 생성 (실패한 건만 포함) const result: ImportResult = { totalRows: rows.length, successCount, errorCount, duplicateCount, items: resultItems // 실패한 건만 포함됨 } // Show results if (successCount > 0) { debugSuccess(`[Import] 임포트 성공: ${successCount}개 항목`); toast.success(`${successCount}개 항목이 성공적으로 가져와졌습니다.`); if (errorCount > 0) { debugWarn(`[Import] 일부 실패: ${errorCount}개 항목`); } // Call the success callback to refresh data onSuccess?.(); } else if (errorCount > 0) { debugError(`[Import] 모든 항목 실패: ${errorCount}개`); toast.error(`모든 ${errorCount}개 항목 가져오기에 실패했습니다. 데이터 형식을 확인하세요.`); } if (duplicateCount > 0) { debugWarn(`[Import] 중복 데이터: ${duplicateCount}개`); toast.warning(`${duplicateCount}개의 중복 데이터가 감지되었습니다.`); } // Import 결과 Dialog 표시 setImportResult(result); setShowResultDialog(true); } catch (error) { debugError('[Import] 전체 임포트 프로세스 실패:', error); console.error("Import error:", error); toast.error("Error importing data. Please check file format."); setShowProgressDialog(false); } finally { debugLog('[Import] 임포트 프로세스 종료'); setIsImporting(false); setShowProgressDialog(false); // Reset the file input if (fileInputRef.current) { fileInputRef.current.value = ''; } } } return ( <> {/* Import Progress Dialog */} {/* Import 결과 Dialog */} ) }