From 20800b214145ee6056f94ca18fa1054f145eb977 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 28 May 2025 00:32:31 +0000 Subject: (대표님) lib 파트 커밋 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table/bulk-upload-dialog.tsx | 1162 ++++++++++++++++++++ 1 file changed, 1162 insertions(+) create mode 100644 lib/vendor-document-list/table/bulk-upload-dialog.tsx (limited to 'lib/vendor-document-list/table/bulk-upload-dialog.tsx') diff --git a/lib/vendor-document-list/table/bulk-upload-dialog.tsx b/lib/vendor-document-list/table/bulk-upload-dialog.tsx new file mode 100644 index 00000000..b7021985 --- /dev/null +++ b/lib/vendor-document-list/table/bulk-upload-dialog.tsx @@ -0,0 +1,1162 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { toast } from "sonner" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" +import ExcelJS from 'exceljs' + +import { +Dialog, +DialogContent, +DialogDescription, +DialogFooter, +DialogHeader, +DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { +Form, +FormControl, +FormField, +FormItem, +FormLabel, +FormMessage, +} from "@/components/ui/form" +import { +Dropzone, +DropzoneDescription, +DropzoneInput, +DropzoneTitle, +DropzoneUploadIcon, +DropzoneZone, +} from "@/components/ui/dropzone" +import { +FileList, +FileListAction, +FileListHeader, +FileListIcon, +FileListInfo, +FileListItem, +FileListName, +FileListSize, +} from "@/components/ui/file-list" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { +Upload, +X, +Loader2, +FileSpreadsheet, +Files, +CheckCircle2, +AlertCircle, +Download +} from "lucide-react" +import prettyBytes from "pretty-bytes" +import type { EnhancedDocument } from "@/types/enhanced-documents" + +// 일괄 업로드 스키마 +const bulkUploadSchema = z.object({ +uploaderName: z.string().optional(), +comment: z.string().optional(), +templateFile: z.instanceof(File).optional(), +attachmentFiles: z.array(z.instanceof(File)).min(1, "최소 1개 파일이 필요합니다"), +}) + +type BulkUploadSchema = z.infer + +interface ParsedUploadItem { +documentId: number +docNumber: string +title: string +stage: string +revision: string +fileNames: string[] // ';'로 구분된 파일명들 +} + +interface FileMatchResult { +matched: { file: File; item: ParsedUploadItem }[] +unmatched: File[] +missingFiles: string[] +} + +interface BulkUploadDialogProps { +open: boolean +onOpenChange: (open: boolean) => void +documents: EnhancedDocument[] +projectType: "ship" | "plant" +contractId: number // ✅ contractId 추가 +} + +export function BulkUploadDialog({ +open, +onOpenChange, +documents, +projectType, +contractId, // ✅ contractId 받기 +}: BulkUploadDialogProps) { +const [selectedFiles, setSelectedFiles] = React.useState([]) +const [templateFile, setTemplateFile] = React.useState(null) +const [parsedData, setParsedData] = React.useState([]) +const [matchResult, setMatchResult] = React.useState(null) +const [isUploading, setIsUploading] = React.useState(false) +const [uploadProgress, setUploadProgress] = React.useState(0) +const [currentStep, setCurrentStep] = React.useState<'template' | 'files' | 'review' | 'upload'>('template') + +const router = useRouter() +const { data: session } = useSession() + +const form = useForm({ + resolver: zodResolver(bulkUploadSchema), + defaultValues: { + uploaderName: session?.user?.name || "", + comment: "", + templateFile: undefined, + attachmentFiles: [], + }, +}) + +React.useEffect(() => { + if (session?.user?.name) { + form.setValue('uploaderName', session.user.name) + } +}, [session?.user?.name, form]) + +// 다이얼로그가 열릴 때마다 업로더명 리프레시 +React.useEffect(() => { + if (open && session?.user?.name) { + form.setValue('uploaderName', session.user.name) + } +}, [open, session?.user?.name, form]) + +// 리비전 정렬 및 최신 리비전 찾기 헬퍼 함수들 +const compareRevisions = (a: string, b: string): number => { + // 알파벳 리비전 (A, B, C, ..., Z, AA, AB, ...) + const aIsAlpha = /^[A-Z]+$/.test(a) + const bIsAlpha = /^[A-Z]+$/.test(b) + + if (aIsAlpha && bIsAlpha) { + // 길이 먼저 비교 (A < AA) + if (a.length !== b.length) { + return a.length - b.length + } + // 같은 길이면 알파벳 순서 + return a.localeCompare(b) + } + + // 숫자 리비전 (0, 1, 2, ...) + const aIsNumber = /^\d+$/.test(a) + const bIsNumber = /^\d+$/.test(b) + + if (aIsNumber && bIsNumber) { + return parseInt(a) - parseInt(b) + } + + // 혼재된 경우 알파벳이 먼저 + if (aIsAlpha && bIsNumber) return -1 + if (aIsNumber && bIsAlpha) return 1 + + // 기타 복잡한 형태는 문자열 비교 + return a.localeCompare(b) +} + +const getLatestRevisionInStage = (document: EnhancedDocument, stageName: string): string => { + const stage = document.allStages?.find(s => s.stageName === stageName) + if (!stage || !stage.revisions || stage.revisions.length === 0) { + return '' + } + + // 리비전들을 정렬해서 최신 것 찾기 + const sortedRevisions = [...stage.revisions].sort((a, b) => + compareRevisions(a.revision, b.revision) + ) + + return sortedRevisions[sortedRevisions.length - 1]?.revision || '' +} + +const getNextRevision = (currentRevision: string): string => { + if (!currentRevision) return "A" + + // 알파벳 리비전 (A, B, C...) + if (/^[A-Z]+$/.test(currentRevision)) { + // 한 글자인 경우 + if (currentRevision.length === 1) { + const charCode = currentRevision.charCodeAt(0) + if (charCode < 90) { // Z가 아닌 경우 + return String.fromCharCode(charCode + 1) + } + return "AA" // Z 다음은 AA + } + + // 여러 글자인 경우 (AA, AB, ... AZ, BA, ...) + let result = currentRevision + let carry = true + let newResult = '' + + for (let i = result.length - 1; i >= 0 && carry; i--) { + let charCode = result.charCodeAt(i) + if (charCode < 90) { // Z가 아닌 경우 + newResult = String.fromCharCode(charCode + 1) + newResult + carry = false + } else { // Z인 경우 + newResult = 'A' + newResult + } + } + + if (carry) { + newResult = 'A' + newResult + } else { + newResult = result.substring(0, result.length - newResult.length) + newResult + } + + return newResult + } + + // 숫자 리비전 (0, 1, 2...) + if (/^\d+$/.test(currentRevision)) { + return String(parseInt(currentRevision) + 1) + } + + // 기타 복잡한 리비전 형태는 그대로 반환 + return currentRevision +} + +// 템플릿 export 함수 +const exportTemplate = async () => { + try { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet('BulkUploadTemplate') + + // 헤더 정의 + const headers = [ + 'documentId', + 'docNumber', + 'title', + 'currentStage', + 'latestRevision', + 'targetStage', + 'targetRevision', + 'fileNames' + ] + + // 헤더 스타일링 + const headerRow = worksheet.addRow(headers) + headerRow.eachCell((cell, colNumber) => { + cell.font = { bold: true, color: { argb: 'FFFFFF' } } + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: '366092' } + } + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + cell.alignment = { horizontal: 'center', vertical: 'middle' } + }) + + // 데이터 추가 + documents.forEach(doc => { + const currentStageName = doc.currentStageName || '' + const latestRevision = getLatestRevisionInStage(doc, currentStageName) + + const row = worksheet.addRow([ + doc.documentId, + doc.docNumber, + doc.title, + currentStageName, + latestRevision, // 현재 스테이지의 최신 리비전 + currentStageName, // 기본값으로 현재 스테이지 설정 + latestRevision, // 기본값으로 현재 최신 리비전 설정 (사용자가 선택) + '', // 사용자가 입력할 파일명들 (';'로 구분) + ]) + + // 데이터 행 스타일링 + row.eachCell((cell, colNumber) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + + // 편집 가능한 칼럼 (targetStage, targetRevision, fileNames) 강조 + if (colNumber >= 6) { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFF2CC' } // 연한 노란색 + } + } + }) + }) + + // 칼럼 너비 설정 + worksheet.columns = [ + { width: 12 }, // documentId + { width: 18 }, // docNumber + { width: 35 }, // title + { width: 20 }, // currentStage + { width: 15 }, // latestRevision + { width: 20 }, // targetStage + { width: 15 }, // targetRevision + { width: 60 }, // fileNames + ] + + // 헤더 고정 + worksheet.views = [{ state: 'frozen', ySplit: 1 }] + + // 주석 추가 + const instructionRow = worksheet.insertRow(1, [ + '지침:', + '1. latestRevision: 현재 스테이지의 최신 리비전', + '2. targetStage: 업로드할 스테이지명 (수정 가능)', + '3. targetRevision: 같은 리비전에 파일 추가 시 그대로, 새 리비전 생성 시 수정', + '4. fileNames: 파일명들을 세미콜론(;)으로 구분', + '예: file1.pdf;file2.dwg;file3.xlsx', + '', + '← 이 행은 삭제하고 사용해도 됩니다' + ]) + + instructionRow.eachCell((cell) => { + cell.font = { italic: true, color: { argb: '888888' } } + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'F0F0F0' } + } + }) + + // 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }) + + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `bulk-upload-template-${new Date().toISOString().split('T')[0]}.xlsx` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(url) + + toast.success("템플릿이 다운로드되었습니다. targetRevision은 기본값(최신 리비전)이 설정되어 있습니다!") + + } catch (error) { + console.error('템플릿 생성 오류:', error) + toast.error('템플릿 생성에 실패했습니다.') + } +} + +// 템플릿 파일 파싱 +const parseTemplateFile = async (file: File) => { + try { + const arrayBuffer = await file.arrayBuffer() + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(arrayBuffer) + + const worksheet = workbook.getWorksheet(1) // 첫 번째 워크시트 + if (!worksheet) { + throw new Error('워크시트를 찾을 수 없습니다.') + } + + // 헤더 행 찾기 (지침 행이 있을 수 있으므로) + let headerRowIndex = 1 + let headers: string[] = [] + + // 최대 5행까지 헤더를 찾아본다 + for (let i = 1; i <= 5; i++) { + const row = worksheet.getRow(i) + const firstCell = row.getCell(1).value + + if (firstCell && String(firstCell).includes('documentId')) { + headerRowIndex = i + headers = [] + + // 헤더 추출 + for (let col = 1; col <= 8; col++) { + const cellValue = row.getCell(col).value + headers.push(String(cellValue || '')) + } + break + } + } + + const expectedHeaders = ['documentId', 'docNumber', 'title', 'currentStage', 'latestRevision', 'targetStage', 'targetRevision', 'fileNames'] + const missingHeaders = expectedHeaders.filter(h => !headers.includes(h)) + + if (missingHeaders.length > 0) { + throw new Error(`필수 칼럼이 누락되었습니다: ${missingHeaders.join(', ')}`) + } + + // 데이터 파싱 + const parsed: ParsedUploadItem[] = [] + const rowCount = worksheet.rowCount + + console.log(`📊 파싱 시작: 총 ${rowCount}행, 헤더 행: ${headerRowIndex}`) + + for (let i = headerRowIndex + 1; i <= rowCount; i++) { + const row = worksheet.getRow(i) + + // 빈 행 스킵 + if (!row.hasValues) { + console.log(`행 ${i}: 빈 행 스킵`) + continue + } + + const documentIdCell = row.getCell(headers.indexOf('documentId') + 1).value + const docNumberCell = row.getCell(headers.indexOf('docNumber') + 1).value + const titleCell = row.getCell(headers.indexOf('title') + 1).value + const stageCell = row.getCell(headers.indexOf('targetStage') + 1).value + const revisionCell = row.getCell(headers.indexOf('targetRevision') + 1).value + const fileNamesCell = row.getCell(headers.indexOf('fileNames') + 1).value + + // 값들을 안전하게 변환 + const documentId = Number(documentIdCell) || 0 + const docNumber = String(docNumberCell || '').trim() + const title = String(titleCell || '').trim() + const stage = String(stageCell || '').trim().replace(/[ \s]/g, ' ').trim() // 전각공백 처리 + const revision = String(revisionCell || '').trim() + const fileNamesStr = String(fileNamesCell || '').trim().replace(/[ \s]/g, ' ').trim() // 전각공백 처리 + + console.log(`행 ${i} 파싱 결과:`, { + documentId, docNumber, title, stage, revision, fileNamesStr, + originalCells: { documentIdCell, docNumberCell, titleCell, stageCell, revisionCell, fileNamesCell } + }) + + // 필수 데이터 체크 (documentId와 docNumber만 체크, stage와 revision은 빈 값 허용) + if (!documentId || !docNumber) { + console.warn(`행 ${i}: 필수 데이터 누락 (documentId: ${documentId}, docNumber: ${docNumber})`) + continue + } + + // fileNames가 비어있는 행은 무시 + if (!fileNamesStr || fileNamesStr === '' || fileNamesStr === 'undefined' || fileNamesStr === 'null') { + console.log(`행 ${i}: fileNames가 비어있어 스킵합니다. (${docNumber}) - fileNamesStr: "${fileNamesStr}"`) + continue + } + + // stage와 revision이 비어있는 경우 기본값 설정 + const finalStage = stage || 'Default Stage' + const finalRevision = revision || 'A' + + const fileNames = fileNamesStr.split(';').map(name => name.trim()).filter(Boolean) + if (fileNames.length === 0) { + console.warn(`행 ${i}: 파일명 파싱 실패 (${docNumber}) - 원본: "${fileNamesStr}"`) + continue + } + + console.log(`✅ 행 ${i} 파싱 성공:`, { + documentId, docNumber, stage: finalStage, revision: finalRevision, fileNames + }) + + parsed.push({ + documentId, + docNumber, + title, + stage: finalStage, + revision: finalRevision, + fileNames, + }) + } + + console.log(`📋 파싱 완료: ${parsed.length}개 항목`) + + if (parsed.length === 0) { + console.error('파싱된 데이터:', parsed) + throw new Error('파싱할 수 있는 유효한 데이터가 없습니다. fileNames 칼럼에 파일명이 입력되어 있는지 확인해주세요.') + } + + setParsedData(parsed) + setCurrentStep('files') + toast.success(`템플릿 파싱 완료: ${parsed.length}개 항목, 총 ${parsed.reduce((sum, item) => sum + item.fileNames.length, 0)}개 파일 필요`) + + } catch (error) { + console.error('템플릿 파싱 오류:', error) + toast.error(error instanceof Error ? error.message : '템플릿 파싱에 실패했습니다.') + } +} + +// 파일 매칭 로직 +const matchFiles = (files: File[], uploadItems: ParsedUploadItem[]): FileMatchResult => { + const matched: { file: File; item: ParsedUploadItem }[] = [] + const unmatched: File[] = [] + const missingFiles: string[] = [] + + // 모든 필요한 파일명 수집 + const requiredFileNames = new Set() + uploadItems.forEach(item => { + item.fileNames.forEach(fileName => requiredFileNames.add(fileName)) + }) + + // 파일 매칭 + files.forEach(file => { + let isMatched = false + + for (const item of uploadItems) { + if (item.fileNames.some(fileName => fileName === file.name)) { + matched.push({ file, item }) + isMatched = true + break + } + } + + if (!isMatched) { + unmatched.push(file) + } + }) + + // 누락된 파일 찾기 + const uploadedFileNames = new Set(files.map(f => f.name)) + requiredFileNames.forEach(fileName => { + if (!uploadedFileNames.has(fileName)) { + missingFiles.push(fileName) + } + }) + + return { matched, unmatched, missingFiles } +} + +// 템플릿 드롭 처리 +const handleTemplateDropAccepted = (acceptedFiles: File[]) => { + const file = acceptedFiles[0] + if (!file) return + + if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) { + toast.error('Excel 파일(.xlsx, .xls)만 업로드 가능합니다.') + return + } + + setTemplateFile(file) + form.setValue('templateFile', file) + parseTemplateFile(file) +} + +// 파일 드롭 처리 +const handleFilesDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...selectedFiles, ...acceptedFiles] + setSelectedFiles(newFiles) + form.setValue('attachmentFiles', newFiles, { shouldValidate: true }) + + // 파일 매칭 수행 + if (parsedData.length > 0) { + const result = matchFiles(newFiles, parsedData) + setMatchResult(result) + setCurrentStep('review') + } +} + +// 파일 제거 +const removeFile = (index: number) => { + const updatedFiles = [...selectedFiles] + updatedFiles.splice(index, 1) + setSelectedFiles(updatedFiles) + form.setValue('attachmentFiles', updatedFiles, { shouldValidate: true }) + + if (parsedData.length > 0) { + const result = matchFiles(updatedFiles, parsedData) + setMatchResult(result) + } +} + +// 일괄 업로드 처리 +const onSubmit = async (data: BulkUploadSchema) => { + if (!matchResult || matchResult.matched.length === 0) { + toast.error('매칭된 파일이 없습니다.') + return + } + + setIsUploading(true) + setUploadProgress(0) + setCurrentStep('upload') + + try { + const formData = new FormData() + + // 메타데이터 + formData.append('uploaderName', data.uploaderName || '') + formData.append('comment', data.comment || '') + formData.append('projectType', projectType) + if (contractId) { + formData.append('contractId', String(contractId)) // ✅ contractId 추가 + } + formData.append('contractId', String(contractId)) // ✅ contractId 추가 + + // 매칭된 파일들과 메타데이터 + const uploadData = matchResult.matched.map(({ file, item }) => ({ + documentId: item.documentId, + stage: item.stage, + revision: item.revision, + fileName: file.name, + })) + + formData.append('uploadData', JSON.stringify(uploadData)) + + // 파일들 추가 + matchResult.matched.forEach(({ file }, index) => { + formData.append(`file_${index}`, file) + }) + + // 진행률 시뮬레이션 + const progressInterval = setInterval(() => { + setUploadProgress(prev => Math.min(prev + 10, 90)) + }, 500) + + const response = await fetch('/api/bulk-upload', { + method: 'POST', + body: formData, + }) + + clearInterval(progressInterval) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || '일괄 업로드에 실패했습니다.') + } + + const result = await response.json() + setUploadProgress(100) + + toast.success(`${result.data?.uploadedCount || 0}개 파일이 성공적으로 업로드되었습니다.`) + + setTimeout(() => { + handleDialogClose() + router.refresh() + }, 1000) + + } catch (error) { + console.error('일괄 업로드 오류:', error) + toast.error(error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다") + } finally { + setIsUploading(false) + setTimeout(() => setUploadProgress(0), 2000) + } +} + +const handleDialogClose = () => { + form.reset({ + uploaderName: session?.user?.name || "", // ✅ 항상 최신 session 값으로 리셋 + comment: "", + templateFile: undefined, + attachmentFiles: [], + }) + setSelectedFiles([]) + setTemplateFile(null) + setParsedData([]) + setMatchResult(null) + setCurrentStep('template') + setIsUploading(false) + setUploadProgress(0) + onOpenChange(false) +} + +const canProceedToUpload = matchResult && matchResult.matched.length > 0 && matchResult.missingFiles.length === 0 + +return ( + + + + + + 일괄 업로드 + + + 템플릿을 다운로드하여 파일명을 입력한 후, 실제 파일들을 업로드하세요. + + +
+ + {projectType === "ship" ? "조선 프로젝트" : "플랜트 프로젝트"} + + + 총 {documents.length}개 문서 + +
+
+ + {/* 단계별 진행 상태 */} +
+ {[ + { key: 'template', label: '템플릿' }, + { key: 'files', label: '파일 업로드' }, + { key: 'review', label: '검토' }, + { key: 'upload', label: '업로드' }, + ].map((step, index) => ( + +
['template', 'files', 'review'].indexOf(step.key) ? 'bg-green-100 text-green-700' : + 'bg-gray-100 text-gray-500' + }`}> + {step.label} +
+ {index < 3 &&
} + + ))} +
+ +
+ + + {/* 1단계: 템플릿 다운로드 및 업로드 */} + {currentStep === 'template' && ( +
+ + + + + 1단계: 템플릿 다운로드 + + + +

+ 현재 문서 목록을 기반으로 업로드 템플릿을 생성합니다. + 마지막 "fileNames" 칼럼에 업로드할 파일명을 ';'로 구분하여 입력하세요. +

+ +
+
+ + + + + + 작성된 템플릿 업로드 + + + + + + + + +
+ +
+ 작성된 Excel 템플릿을 업로드하세요 + + .xlsx, .xls 파일을 지원합니다 + +
+
+
+
+ + {templateFile && ( +
+
+ + + 템플릿 업로드 완료: {templateFile.name} + +
+
+ )} +
+
+
+ )} + + {/* 2단계: 파일 업로드 */} + {currentStep === 'files' && ( +
+ + + + + 2단계: 실제 파일들 업로드 + + + +
+

+ 템플릿에서 {parsedData.length}개 항목, 총 {parsedData.reduce((sum, item) => sum + item.fileNames.length, 0)}개 파일이 필요합니다. +

+
+ + + + + + +
+ +
+ 실제 파일들을 여기에 드롭하세요 + + 또는 클릭하여 파일들을 선택하세요 + +
+
+
+
+ + {selectedFiles.length > 0 && ( +
+
+ 업로드된 파일 ({selectedFiles.length}) +
+ + + {selectedFiles.map((file, index) => ( + + + + + {file.name} + {prettyBytes(file.size)} + + removeFile(index)} + disabled={isUploading} + > + + + + + ))} + + +
+ )} +
+
+
+ )} + + {/* 3단계: 매칭 결과 검토 */} + {currentStep === 'review' && matchResult && ( +
+ + + + + 3단계: 매칭 결과 검토 + + + + + {/* 통합된 매칭 결과 요약 */} +
+
+
{matchResult.matched.length}
+
매칭 성공
+
+
+
{matchResult.unmatched.length}
+
매칭 실패
+
+
+
{matchResult.missingFiles.length}
+
누락된 파일
+
+
+ + {/* 통합된 상세 결과 */} +
+ {/* 매칭 성공 섹션 */} + {matchResult.matched.length > 0 && ( +
+
+
+ + 매칭 성공 ({matchResult.matched.length}개) +
+ +
+ + {/* 미리보기 */} +
+
+ {matchResult.matched.slice(0, 5).map((match, index) => ( +
+ + {match.file.name} + + + → {match.item.docNumber} Rev.{match.item.revision} + +
+ ))} + {matchResult.matched.length > 5 && ( +
+ ... 외 {matchResult.matched.length - 5}개 (상세보기로 확인) +
+ )} +
+ + {/* 펼침 상세 내용 */} +
+
+
+ {matchResult.matched.map((match, index) => ( +
+ + {match.file.name} + + + → {match.item.docNumber} ({match.item.stage} Rev.{match.item.revision}) + +
+ ))} +
+
+
+
+
+ )} + + {/* 매칭 실패 섹션 */} + {matchResult.unmatched.length > 0 && ( +
+
+
+ + 매칭되지 않은 파일 ({matchResult.unmatched.length}개) +
+ +
+ +
+
+ {matchResult.unmatched.slice(0, 3).map((file, index) => ( +
+ {file.name} +
+ ))} + {matchResult.unmatched.length > 3 && ( +
+ ... 외 {matchResult.unmatched.length - 3}개 +
+ )} +
+ +
+
+
+ {matchResult.unmatched.map((file, index) => ( +
+ {file.name} +
+ ))} +
+
+
+
+
+ )} + + {/* 누락된 파일 섹션 */} + {matchResult.missingFiles.length > 0 && ( +
+
+
+ + 누락된 파일 ({matchResult.missingFiles.length}개) +
+ +
+ +
+
+ {matchResult.missingFiles.slice(0, 3).map((fileName, index) => ( +
+ {fileName} +
+ ))} + {matchResult.missingFiles.length > 3 && ( +
+ ... 외 {matchResult.missingFiles.length - 3}개 +
+ )} +
+ +
+
+
+ {matchResult.missingFiles.map((fileName, index) => ( +
+ {fileName} +
+ ))} +
+
+
+
+
+ )} +
+ + {/* 업로드 불가 경고 */} + {!canProceedToUpload && ( +
+
+ + + 누락된 파일이 있어 업로드를 진행할 수 없습니다. 누락된 파일들을 추가해주세요. + +
+
+ )} +
+
+ + {/* 추가 정보 입력 */} +
+ ( + + 업로더명 + + + + + + )} + /> + + ( + + 코멘트 (선택) + +