1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
|
/**
* 특정 컬럼들 복합키로 묶어 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<HTMLInputElement>(null)
const [isImporting, setIsImporting] = React.useState(false)
const { data: session } = useSession()
const [importResult, setImportResult] = React.useState<ImportResult | null>(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<HTMLInputElement>) => {
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<string, number> = {};
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<string, any> = {};
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 (
<>
<Input
type="file"
ref={fileInputRef}
onChange={handleImport}
accept=".xlsx,.xls"
className="hidden"
/>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isImporting}
className="gap-2"
>
{isImporting ? (
<Loader className="size-4 animate-spin" aria-hidden="true" />
) : (
<Upload className="size-4" aria-hidden="true" />
)}
<span className="hidden sm:inline">
{isImporting ? "Importing..." : "Import"}
</span>
</Button>
{/* Import Progress Dialog */}
<ImportProgressDialog
open={showProgressDialog}
totalRows={totalRows}
processedRows={processedRows}
/>
{/* Import 결과 Dialog */}
<ImportResultDialog
open={showResultDialog}
onOpenChange={setShowResultDialog}
result={importResult}
/>
</>
)
}
|