From 20800b214145ee6056f94ca18fa1054f145eb977 Mon Sep 17 00:00:00 2001
From: dujinkim <dujin.kim@dtsolution.co.kr>
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<typeof bulkUploadSchema>
+
+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<File[]>([])
+const [templateFile, setTemplateFile] = React.useState<File | null>(null)
+const [parsedData, setParsedData] = React.useState<ParsedUploadItem[]>([])
+const [matchResult, setMatchResult] = React.useState<FileMatchResult | null>(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<BulkUploadSchema>({
+  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<string>()
+  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 (
+  <Dialog open={open} onOpenChange={handleDialogClose}>
+    <DialogContent className="sm:max-w-6xl max-h-[90vh] overflow-y-auto">
+      <DialogHeader>
+        <DialogTitle className="flex items-center gap-2">
+          <Files className="w-5 h-5" />
+          일괄 업로드
+        </DialogTitle>
+        <DialogDescription>
+          템플릿을 다운로드하여 파일명을 입력한 후, 실제 파일들을 업로드하세요.
+        </DialogDescription>
+        
+        <div className="flex items-center gap-2 pt-2">
+          <Badge variant={projectType === "ship" ? "default" : "secondary"}>
+            {projectType === "ship" ? "조선 프로젝트" : "플랜트 프로젝트"}
+          </Badge>
+          <Badge variant="outline">
+            총 {documents.length}개 문서
+          </Badge>
+        </div>
+      </DialogHeader>
+
+      {/* 단계별 진행 상태 */}
+      <div className="flex items-center gap-2 mb-4">
+        {[
+          { key: 'template', label: '템플릿' },
+          { key: 'files', label: '파일 업로드' },
+          { key: 'review', label: '검토' },
+          { key: 'upload', label: '업로드' },
+        ].map((step, index) => (
+          <React.Fragment key={step.key}>
+            <div className={`flex items-center gap-1 px-2 py-1 rounded text-xs ${
+              currentStep === step.key ? 'bg-primary text-primary-foreground' :
+              ['template', 'files', 'review'].indexOf(currentStep) > ['template', 'files', 'review'].indexOf(step.key) ? 'bg-green-100 text-green-700' :
+              'bg-gray-100 text-gray-500'
+            }`}>
+              {step.label}
+            </div>
+            {index < 3 && <div className="w-2 h-px bg-gray-300" />}
+          </React.Fragment>
+        ))}
+      </div>
+
+      <Form {...form}>
+        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+          
+          {/* 1단계: 템플릿 다운로드 및 업로드 */}
+          {currentStep === 'template' && (
+            <div className="space-y-4">
+              <Card>
+                <CardHeader>
+                  <CardTitle className="text-lg flex items-center gap-2">
+                    <Download className="w-4 h-4" />
+                    1단계: 템플릿 다운로드
+                  </CardTitle>
+                </CardHeader>
+                <CardContent className="space-y-4">
+                  <p className="text-sm text-gray-600">
+                    현재 문서 목록을 기반으로 업로드 템플릿을 생성합니다. 
+                    마지막 "fileNames" 칼럼에 업로드할 파일명을 ';'로 구분하여 입력하세요.
+                  </p>
+                  <Button type="button" onClick={exportTemplate} className="gap-2">
+                    <Download className="w-4 h-4" />
+                    템플릿 다운로드 ({documents.length}개 문서)
+                  </Button>
+                </CardContent>
+              </Card>
+
+              <Card>
+                <CardHeader>
+                  <CardTitle className="text-lg flex items-center gap-2">
+                    <Upload className="w-4 h-4" />
+                    작성된 템플릿 업로드
+                  </CardTitle>
+                </CardHeader>
+                <CardContent>
+                  <Dropzone
+                    maxSize={10e6} // 10MB
+                    multiple={false}
+                    accept={{ 
+                      'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
+                      'application/vnd.ms-excel': ['.xls']
+                    }}
+                    onDropAccepted={handleTemplateDropAccepted}
+                    disabled={isUploading}
+                  >
+                    <DropzoneZone>
+                      <FormControl>
+                        <DropzoneInput />
+                      </FormControl>
+                      <div className="flex items-center gap-6">
+                        <FileSpreadsheet className="w-8 h-8 text-gray-400" />
+                        <div className="grid gap-0.5">
+                          <DropzoneTitle>작성된 Excel 템플릿을 업로드하세요</DropzoneTitle>
+                          <DropzoneDescription>
+                            .xlsx, .xls 파일을 지원합니다
+                          </DropzoneDescription>
+                        </div>
+                      </div>
+                    </DropzoneZone>
+                  </Dropzone>
+                  
+                  {templateFile && (
+                    <div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg">
+                      <div className="flex items-center gap-2">
+                        <CheckCircle2 className="w-4 h-4 text-green-600" />
+                        <span className="text-sm text-green-700">
+                          템플릿 업로드 완료: {templateFile.name}
+                        </span>
+                      </div>
+                    </div>
+                  )}
+                </CardContent>
+              </Card>
+            </div>
+          )}
+
+          {/* 2단계: 파일 업로드 */}
+          {currentStep === 'files' && (
+            <div className="space-y-4">
+              <Card>
+                <CardHeader>
+                  <CardTitle className="text-lg flex items-center gap-2">
+                    <Files className="w-4 h-4" />
+                    2단계: 실제 파일들 업로드
+                  </CardTitle>
+                </CardHeader>
+                <CardContent>
+                  <div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
+                    <p className="text-sm text-blue-700">
+                      템플릿에서 {parsedData.length}개 항목, 총 {parsedData.reduce((sum, item) => sum + item.fileNames.length, 0)}개 파일이 필요합니다.
+                    </p>
+                  </div>
+
+                  <Dropzone
+                    maxSize={3e9} // 3GB
+                    multiple={true}
+                    onDropAccepted={handleFilesDropAccepted}
+                    disabled={isUploading}
+                  >
+                    <DropzoneZone>
+                      <FormControl>
+                        <DropzoneInput />
+                      </FormControl>
+                      <div className="flex items-center gap-6">
+                        <DropzoneUploadIcon />
+                        <div className="grid gap-0.5">
+                          <DropzoneTitle>실제 파일들을 여기에 드롭하세요</DropzoneTitle>
+                          <DropzoneDescription>
+                            또는 클릭하여 파일들을 선택하세요
+                          </DropzoneDescription>
+                        </div>
+                      </div>
+                    </DropzoneZone>
+                  </Dropzone>
+
+                  {selectedFiles.length > 0 && (
+                    <div className="mt-4 space-y-2">
+                      <h6 className="text-sm font-semibold">
+                        업로드된 파일 ({selectedFiles.length})
+                      </h6>
+                      <ScrollArea className="max-h-[200px]">
+                        <FileList>
+                          {selectedFiles.map((file, index) => (
+                            <FileListItem key={index} className="p-3">
+                              <FileListHeader>
+                                <FileListIcon />
+                                <FileListInfo>
+                                  <FileListName>{file.name}</FileListName>
+                                  <FileListSize>{prettyBytes(file.size)}</FileListSize>
+                                </FileListInfo>
+                                <FileListAction 
+                                  onClick={() => removeFile(index)} 
+                                  disabled={isUploading}
+                                >
+                                  <X className="h-4 w-4" />
+                                </FileListAction>
+                              </FileListHeader>
+                            </FileListItem>
+                          ))}
+                        </FileList>
+                      </ScrollArea>
+                    </div>
+                  )}
+                </CardContent>
+              </Card>
+            </div>
+          )}
+
+          {/* 3단계: 매칭 결과 검토 */}
+          {currentStep === 'review' && matchResult && (
+            <div className="space-y-4">
+              <Card>
+                <CardHeader>
+                  <CardTitle className="text-lg flex items-center gap-2">
+                    <CheckCircle2 className="w-4 h-4" />
+                    3단계: 매칭 결과 검토
+                  </CardTitle>
+                </CardHeader>
+                <CardContent className="space-y-6">
+                  
+                  {/* 통합된 매칭 결과 요약 */}
+                  <div className="grid grid-cols-3 gap-4">
+                    <div className="p-4 bg-green-50 border border-green-200 rounded-lg text-center">
+                      <div className="text-2xl font-bold text-green-600">{matchResult.matched.length}</div>
+                      <div className="text-sm text-green-700">매칭 성공</div>
+                    </div>
+                    <div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg text-center">
+                      <div className="text-2xl font-bold text-yellow-600">{matchResult.unmatched.length}</div>
+                      <div className="text-sm text-yellow-700">매칭 실패</div>
+                    </div>
+                    <div className="p-4 bg-red-50 border border-red-200 rounded-lg text-center">
+                      <div className="text-2xl font-bold text-red-600">{matchResult.missingFiles.length}</div>
+                      <div className="text-sm text-red-700">누락된 파일</div>
+                    </div>
+                  </div>
+
+                  {/* 통합된 상세 결과 */}
+                  <div className="border border-gray-200 rounded-lg overflow-hidden">
+                    {/* 매칭 성공 섹션 */}
+                    {matchResult.matched.length > 0 && (
+                      <div className="border-b border-gray-200">
+                        <div className="p-4 bg-green-50 flex items-center justify-between">
+                          <h6 className="font-semibold text-green-700 flex items-center gap-2">
+                            <CheckCircle2 className="w-4 h-4" />
+                            매칭 성공 ({matchResult.matched.length}개)
+                          </h6>
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            type="button"
+                            onClick={(e) => {
+                              e.preventDefault()
+                              e.stopPropagation()
+                              const element = document.getElementById('matched-details')
+                              if (element) {
+                                element.style.display = element.style.display === 'none' ? 'block' : 'none'
+                              }
+                            }}
+                          >
+                            {matchResult.matched.length <= 5 ? '모두보기' : '상세보기'}
+                          </Button>
+                        </div>
+                        
+                        {/* 미리보기 */}
+                        <div className="p-4 bg-green-25">
+                          <div className="space-y-2">
+                            {matchResult.matched.slice(0, 5).map((match, index) => (
+                              <div key={index} className="flex items-center justify-between text-sm">
+                                <span className="font-mono text-green-600 truncate max-w-[300px]" title={match.file.name}>
+                                  {match.file.name}
+                                </span>
+                                <span className="text-green-700 ml-4 whitespace-nowrap flex-shrink-0">
+                                  → {match.item.docNumber} Rev.{match.item.revision}
+                                </span>
+                              </div>
+                            ))}
+                            {matchResult.matched.length > 5 && (
+                              <div className="text-gray-500 text-center text-sm py-2 border-t border-green-200">
+                                ... 외 {matchResult.matched.length - 5}개 (상세보기로 확인)
+                              </div>
+                            )}
+                          </div>
+
+                          {/* 펼침 상세 내용 */}
+                          <div id="matched-details" style={{ display: 'none' }} className="mt-4 pt-4 border-t border-green-200">
+                            <div className="max-h-64 overflow-y-auto">
+                              <div className="space-y-2">
+                                {matchResult.matched.map((match, index) => (
+                                  <div key={index} className="flex items-center justify-between text-sm py-1">
+                                    <span className="font-mono text-green-600 truncate max-w-[300px]" title={match.file.name}>
+                                      {match.file.name}
+                                    </span>
+                                    <span className="text-green-700 ml-4 whitespace-nowrap flex-shrink-0">
+                                      → {match.item.docNumber} ({match.item.stage} Rev.{match.item.revision})
+                                    </span>
+                                  </div>
+                                ))}
+                              </div>
+                            </div>
+                          </div>
+                        </div>
+                      </div>
+                    )}
+
+                    {/* 매칭 실패 섹션 */}
+                    {matchResult.unmatched.length > 0 && (
+                      <div className="border-b border-gray-200">
+                        <div className="p-4 bg-yellow-50 flex items-center justify-between">
+                          <h6 className="font-semibold text-yellow-700 flex items-center gap-2">
+                            <AlertCircle className="w-4 h-4" />
+                            매칭되지 않은 파일 ({matchResult.unmatched.length}개)
+                          </h6>
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            type="button"
+                            onClick={() => {
+                              const element = document.getElementById('unmatched-details')
+                              if (element) {
+                                element.style.display = element.style.display === 'none' ? 'block' : 'none'
+                              }
+                            }}
+                          >
+                            상세보기
+                          </Button>
+                        </div>
+                        
+                        <div className="p-4 bg-yellow-25">
+                          <div className="space-y-1">
+                            {matchResult.unmatched.slice(0, 3).map((file, index) => (
+                              <div key={index} className="text-sm text-yellow-600 font-mono truncate max-w-full" title={file.name}>
+                                {file.name}
+                              </div>
+                            ))}
+                            {matchResult.unmatched.length > 3 && (
+                              <div className="text-gray-500 text-center text-sm py-2">
+                                ... 외 {matchResult.unmatched.length - 3}개
+                              </div>
+                            )}
+                          </div>
+
+                          <div id="unmatched-details" style={{ display: 'none' }} className="mt-4 pt-4 border-t border-yellow-200">
+                            <div className="max-h-40 overflow-y-auto">
+                              <div className="space-y-1">
+                                {matchResult.unmatched.map((file, index) => (
+                                  <div key={index} className="text-sm text-yellow-600 font-mono truncate max-w-full" title={file.name}>
+                                    {file.name}
+                                  </div>
+                                ))}
+                              </div>
+                            </div>
+                          </div>
+                        </div>
+                      </div>
+                    )}
+
+                    {/* 누락된 파일 섹션 */}
+                    {matchResult.missingFiles.length > 0 && (
+                      <div>
+                        <div className="p-4 bg-red-50 flex items-center justify-between">
+                          <h6 className="font-semibold text-red-700 flex items-center gap-2">
+                            <X className="w-4 h-4" />
+                            누락된 파일 ({matchResult.missingFiles.length}개)
+                          </h6>
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            type="button"
+                            onClick={() => {
+                              const element = document.getElementById('missing-details')
+                              if (element) {
+                                element.style.display = element.style.display === 'none' ? 'block' : 'none'
+                              }
+                            }}
+                          >
+                            상세보기
+                          </Button>
+                        </div>
+                        
+                        <div className="p-4 bg-red-25">
+                          <div className="space-y-1">
+                            {matchResult.missingFiles.slice(0, 3).map((fileName, index) => (
+                              <div key={index} className="text-sm text-red-600 font-mono truncate max-w-full" title={fileName}>
+                                {fileName}
+                              </div>
+                            ))}
+                            {matchResult.missingFiles.length > 3 && (
+                              <div className="text-gray-500 text-center text-sm py-2">
+                                ... 외 {matchResult.missingFiles.length - 3}개
+                              </div>
+                            )}
+                          </div>
+
+                          <div id="missing-details" style={{ display: 'none' }} className="mt-4 pt-4 border-t border-red-200">
+                            <div className="max-h-40 overflow-y-auto">
+                              <div className="space-y-1">
+                                {matchResult.missingFiles.map((fileName, index) => (
+                                  <div key={index} className="text-sm text-red-600 font-mono truncate max-w-full" title={fileName}>
+                                    {fileName}
+                                  </div>
+                                ))}
+                              </div>
+                            </div>
+                          </div>
+                        </div>
+                      </div>
+                    )}
+                  </div>
+
+                  {/* 업로드 불가 경고 */}
+                  {!canProceedToUpload && (
+                    <div className="p-4 bg-red-50 border border-red-200 rounded-lg">
+                      <div className="flex items-center gap-2">
+                        <AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
+                        <span className="text-sm text-red-700">
+                          누락된 파일이 있어 업로드를 진행할 수 없습니다. 누락된 파일들을 추가해주세요.
+                        </span>
+                      </div>
+                    </div>
+                  )}
+                </CardContent>
+              </Card>
+
+              {/* 추가 정보 입력 */}
+              <div className="grid grid-cols-1 gap-4">
+                <FormField
+                  control={form.control}
+                  name="uploaderName"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>업로더명</FormLabel>
+                      <FormControl>
+                        <Input {...field} placeholder="업로더 이름" />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={form.control}
+                  name="comment"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>코멘트 (선택)</FormLabel>
+                      <FormControl>
+                        <Textarea {...field} placeholder="일괄 업로드 코멘트" rows={2} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+              </div>
+            </div>
+          )}
+
+          {/* 4단계: 업로드 진행 */}
+          {currentStep === 'upload' && (
+            <div className="space-y-4">
+              <Card>
+                <CardHeader>
+                  <CardTitle className="text-lg flex items-center gap-2">
+                    <Upload className="w-4 h-4" />
+                    4단계: 업로드 진행중
+                  </CardTitle>
+                </CardHeader>
+                <CardContent>
+                  <div className="space-y-4">
+                    <div className="flex items-center gap-2">
+                      <Loader2 className="h-4 w-4 animate-spin" />
+                      <span className="text-sm">{uploadProgress}% 업로드 중...</span>
+                    </div>
+                    <div className="h-2 w-full bg-muted rounded-full overflow-hidden">
+                      <div 
+                        className="h-full bg-primary rounded-full transition-all" 
+                        style={{ width: `${uploadProgress}%` }} 
+                      />
+                    </div>
+                    {matchResult && (
+                      <p className="text-sm text-gray-600">
+                        {matchResult.matched.length}개 파일을 업로드하고 있습니다...
+                      </p>
+                    )}
+                  </div>
+                </CardContent>
+              </Card>
+            </div>
+          )}
+
+          <DialogFooter>
+            <Button
+              type="button"
+              variant="outline"
+              onClick={handleDialogClose}
+              disabled={isUploading}
+            >
+              취소
+            </Button>
+            
+            {currentStep === 'review' && (
+              <Button 
+                type="submit" 
+                disabled={!canProceedToUpload || isUploading}
+              >
+                <Upload className="mr-2 h-4 w-4" />
+                일괄 업로드 ({matchResult?.matched.length || 0}개 파일)
+              </Button>
+            )}
+          </DialogFooter>
+        </form>
+      </Form>
+    </DialogContent>
+  </Dialog>
+)
+}
\ No newline at end of file
-- 
cgit v1.2.3

