diff options
Diffstat (limited to 'lib/vendor-document-list/table/bulk-upload-dialog.tsx')
| -rw-r--r-- | lib/vendor-document-list/table/bulk-upload-dialog.tsx | 1162 |
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 |
