"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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog" import { Button } from "@/components/ui/button" import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Badge } from "@/components/ui/badge" import { Upload, X, Loader2, CheckCircle2, AlertCircle, AlertTriangle, FileText, } from "lucide-react" import { bulkUploadB4Documents } from "../enhanced-document-service" // 파일명 파싱 유틸리티 function parseFileName(fileName: string): { docNumber: string | null; revision: string | null } { // 공백으로 단어 분리 (첫 번째는 파일명으로 무시) const words = fileName.trim().split(/\s+/).filter(word => word.length > 0) if (words.length < 2) { return { docNumber: null, revision: null } } // 마지막 단어에서 확장자와 리비전을 분리 const lastWord = words[words.length - 1] const lastDotIndex = lastWord.lastIndexOf('.') let revision: string | null = null if (lastDotIndex !== -1) { // 마지막 '.' 기준으로 확장자 앞부분만 사용 const beforeExt = lastWord.substring(0, lastDotIndex) // revision 패턴 찾기 (R01, r01, REV01, rev01 등) const revisionMatch = beforeExt.match(/[Rr](?:EV)?(\d+)/) revision = revisionMatch ? revisionMatch[0].toUpperCase() : null } // 문서번호: 첫 번째(파일명)와 마지막(REV.ext)을 제외한 모든 단어들을 '-'로 연결 const docWords = words.slice(1, -1) // 첫 번째와 마지막 제외 const docNumber = docWords.length > 0 ? docWords.join('-').toUpperCase() : null return { docNumber, revision } } // Form schema const formSchema = z.object({ projectId: z.string().min(1, "Please select a project"), files: z.array(z.instanceof(File)).min(1, "Please select files"), }) export interface ProjectOption { id: string code: string } interface BulkB4UploadDialogProps { open: boolean onOpenChange: (open: boolean) => void projectOptions: ProjectOption[] } interface ParsedFile { file: File docNumber: string | null revision: string | null status: 'pending' | 'uploading' | 'success' | 'error' | 'ignored' message?: string } export function BulkB4UploadDialog({ open, onOpenChange, projectOptions }: BulkB4UploadDialogProps) { const [isUploading, setIsUploading] = React.useState(false) const [parsedFiles, setParsedFiles] = React.useState([]) const [isDragging, setIsDragging] = React.useState(false) const [currentProjectId, setCurrentProjectId] = React.useState("") const [showProjectChangeWarning, setShowProjectChangeWarning] = React.useState(false) const [pendingProjectId, setPendingProjectId] = React.useState("") const router = useRouter() const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { projectId: "", files: [], }, }) // 프로젝트 변경 핸들러 const handleProjectChange = (newProjectId: string) => { // 기존 파일 목록이 있고, 다른 프로젝트로 변경하는 경우 경고 표시 if (parsedFiles.length > 0 && currentProjectId && currentProjectId !== newProjectId) { setPendingProjectId(newProjectId) setShowProjectChangeWarning(true) } else { // 파일 목록이 비어있거나 첫 선택인 경우 바로 변경 setCurrentProjectId(newProjectId) form.setValue("projectId", newProjectId) } } // 프로젝트 변경 확인 처리 const confirmProjectChange = () => { // 파일 목록 초기화하고 프로젝트 변경 setParsedFiles([]) form.setValue("files", []) setCurrentProjectId(pendingProjectId) form.setValue("projectId", pendingProjectId) setShowProjectChangeWarning(false) setPendingProjectId("") } // 프로젝트 변경 취소 처리 const cancelProjectChange = () => { setShowProjectChangeWarning(false) setPendingProjectId("") } // 파일 검증 함수 const validateFile = (file: File): { valid: boolean; error?: string } => { const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB const FORBIDDEN_EXTENSIONS = ['exe', 'com', 'dll', 'vbs', 'js', 'asp', 'aspx', 'bat', 'cmd'] // 파일 크기 검증 if (file.size > MAX_FILE_SIZE) { return { valid: false, error: `파일 크기가 1GB를 초과합니다 (${(file.size / (1024 * 1024 * 1024)).toFixed(2)}GB)` } } // 파일 확장자 검증 const extension = file.name.split('.').pop()?.toLowerCase() if (extension && FORBIDDEN_EXTENSIONS.includes(extension)) { return { valid: false, error: `금지된 파일 형식입니다 (.${extension})` } } return { valid: true } } // 파일 선택 시 파싱 const handleFilesChange = (files: File[]) => { const validFiles: File[] = [] const invalidFiles: string[] = [] // 파일 검증 files.forEach(file => { const validation = validateFile(file) if (validation.valid) { validFiles.push(file) } else { invalidFiles.push(`${file.name}: ${validation.error}`) } }) // 유효하지 않은 파일이 있으면 토스트 표시 if (invalidFiles.length > 0) { invalidFiles.forEach(msg => toast.error(msg)) } // 유효한 파일만 파싱 const parsed = validFiles.map(file => { const { docNumber, revision } = parseFileName(file.name) return { file, docNumber, revision, status: docNumber ? 'pending' as const : 'ignored' as const, message: !docNumber ? 'docNumber를 찾을 수 없음' : undefined } }) // 기존 파일들과 새 파일들을 합침 (중복 파일명은 제외) const existingFileNames = new Set(parsedFiles.map(pf => pf.file.name)) const newFiles = parsed.filter(pf => !existingFileNames.has(pf.file.name)) const combinedParsed = [...parsedFiles, ...newFiles] const combinedFiles = [...parsedFiles.map(pf => pf.file), ...newFiles.map(pf => pf.file)] setParsedFiles(combinedParsed) form.setValue("files", combinedFiles) } // 파일 제거 const removeFile = (index: number) => { const newParsedFiles = parsedFiles.filter((_, i) => i !== index) setParsedFiles(newParsedFiles) form.setValue("files", newParsedFiles.map(pf => pf.file)) } // Drag & Drop 핸들러 const handleDragEnter = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsDragging(true) } const handleDragLeave = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() // 자식 요소로 이동할 때도 leave가 발생하므로 실제로 영역을 벗어날 때만 처리 if (e.currentTarget === e.target) { setIsDragging(false) } } const handleDragOver = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() // 드롭 가능하도록 설정 e.dataTransfer.dropEffect = 'copy' } const handleDrop = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsDragging(false) const droppedFiles = Array.from(e.dataTransfer.files) if (droppedFiles.length > 0) { handleFilesChange(droppedFiles) } } // 업로드 처리 async function onSubmit(values: z.infer) { setIsUploading(true) try { // 유효한 파일만 필터링 const validFiles = parsedFiles.filter(pf => pf.docNumber && pf.status === 'pending') if (validFiles.length === 0) { toast.error("업로드 가능한 파일이 없습니다") return } // 파일별로 상태 업데이트 setParsedFiles(prev => prev.map(pf => pf.docNumber && pf.status === 'pending' ? { ...pf, status: 'uploading' as const } : pf )) // FormData 생성 const formData = new FormData() formData.append("projectId", values.projectId) validFiles.forEach((pf, index) => { formData.append(`file_${index}`, pf.file) formData.append(`docNumber_${index}`, pf.docNumber!) formData.append(`revision_${index}`, pf.revision || "00") }) formData.append("fileCount", String(validFiles.length)) // 서버 액션 호출 const result = await bulkUploadB4Documents(formData) if (result.success) { // 성공한 파일들 표시 setParsedFiles(prev => prev.map(pf => { const uploadResult = result.results?.find(r => r.docNumber === pf.docNumber && r.revision === (pf.revision || "00") ) if (uploadResult?.success) { return { ...pf, status: 'success' as const, message: uploadResult.message } } else if (uploadResult) { return { ...pf, status: 'error' as const, message: uploadResult.error } } return pf })) toast.success(`${result.successCount}/${validFiles.length} 파일 업로드 완료`) // 모두 성공하면 닫기 if (result.successCount === validFiles.length) { setTimeout(() => { onOpenChange(false) router.refresh() }, 1500) } } else { toast.error(result.error || "업로드 실패") setParsedFiles(prev => prev.map(pf => pf.status === 'uploading' ? { ...pf, status: 'error' as const, message: result.error } : pf )) } } catch { toast.error("업로드 중 오류가 발생했습니다") setParsedFiles(prev => prev.map(pf => pf.status === 'uploading' ? { ...pf, status: 'error' as const, message: '업로드 실패' } : pf )) } finally { setIsUploading(false) } } // 다이얼로그 닫을 때 초기화 React.useEffect(() => { if (!open) { form.reset() setParsedFiles([]) setIsDragging(false) setCurrentProjectId("") setShowProjectChangeWarning(false) setPendingProjectId("") } }, [open, form]) const validFileCount = parsedFiles.filter(pf => pf.docNumber).length const ignoredFileCount = parsedFiles.filter(pf => !pf.docNumber).length return ( <> {/* 프로젝트 변경 경고 다이얼로그 */} 프로젝트 변경 확인 프로젝트를 변경하면 현재 선택된 {parsedFiles.length}개의 파일이 모두 제거됩니다. 계속하시겠습니까? 취소 확인 {/* 메인 업로드 다이얼로그 */} B4 Document Bulk Upload Document numbers and revisions will be automatically extracted from file names. Format: [filename] [DOC1] [DOC2] ... [DOCN] [REV].[ext] Examples: "testfile TANK ANA R01.pdf" → Document Number: TANK-ANA, Revision: R01 "drawing ABC DEF GHI JKL R02.pdf" → Document Number: ABC-DEF-GHI-JKL, Revision: R02
( Select Project * )} />
Select Files
handleFilesChange(Array.from(e.target.files || []))} className="hidden" id="file-upload" disabled={!currentProjectId || isUploading} />
{parsedFiles.length > 0 && (
Selected Files
Valid: {validFileCount} {ignoredFileCount > 0 && ( Ignored: {ignoredFileCount} )}
{parsedFiles.map((pf, index) => (

{pf.file.name}

{pf.docNumber ? ( <> Doc: {pf.docNumber} {pf.revision && ( Rev: {pf.revision} )} ) : ( {pf.message} )}
{pf.status === 'uploading' && ( )} {pf.status === 'success' && ( )} {pf.status === 'error' && ( )} {pf.status === 'ignored' && ( )} {!isUploading && ( )}
))}
)}
) }