summaryrefslogtreecommitdiff
path: root/lib/vendor-document-list/table/bulk-upload-dialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendor-document-list/table/bulk-upload-dialog.tsx')
-rw-r--r--lib/vendor-document-list/table/bulk-upload-dialog.tsx1162
1 files changed, 1162 insertions, 0 deletions
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