"use client" import React from "react" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { z } from "zod" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form" import { Button } from "@/components/ui/button" import { Progress } from "@/components/ui/progress" import { Upload, FileText, X, Loader2, CheckCircle, Paperclip } from "lucide-react" import { toast } from "sonner" import { useSession } from "next-auth/react" /* ------------------------------------------------------------------------------------------------- * Schema & Types * -----------------------------------------------------------------------------------------------*/ // 파일 검증 스키마 const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB const ACCEPTED_FILE_TYPES = [ 'application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-excel', 'image/jpeg', 'image/png', 'image/gif', 'text/plain', 'application/zip', 'application/x-zip-compressed', // Presentations 'application/vnd.ms-powerpoint', // .ppt 'application/vnd.openxmlformats-officedocument.presentationml.presentation', // .pptx // CAD and Drawing files 'application/acad', // .dwg 'image/vnd.dwg', // .dwg (alternative MIME) 'application/x-autocad', // .dwg (alternative MIME) 'application/dxf', // .dxf 'image/vnd.dxf', // .dxf (alternative MIME) 'application/x-dxf', // .dxf (alternative MIME) 'application/step', // .step/.stp 'application/sla', // .stl 'model/stl', // .stl (alternative MIME) 'application/iges', // .iges/.igs ] const attachmentUploadSchema = z.object({ attachments: z .array(z.instanceof(File)) .min(1, "Please upload at least 1 file") // .max(10, "Maximum 10 files can be uploaded") .refine( (files) => files.every((file) => file.size <= MAX_FILE_SIZE), "File size must be 1GB or less" ) // .refine( // (files) => files.every((file) => ACCEPTED_FILE_TYPES.includes(file.type)), // "Unsupported file format" // ), }) interface AddAttachmentDialogProps { open: boolean onOpenChange: (open: boolean) => void revisionId: number revisionName: string onSuccess?: (result?: any) => void } /* ------------------------------------------------------------------------------------------------- * File Upload Component * -----------------------------------------------------------------------------------------------*/ function FileUploadArea({ files, onFilesChange }: { files: File[] onFilesChange: (files: File[]) => void }) { const fileInputRef = React.useRef(null) // 파일 검증 함수 const validateFiles = (filesToValidate: File[]): { valid: File[], invalid: string[] } => { const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB const FORBIDDEN_EXTENSIONS = ['exe', 'com', 'dll', 'vbs', 'js', 'asp', 'aspx', 'bat', 'cmd'] const valid: File[] = [] const invalid: string[] = [] filesToValidate.forEach(file => { // 파일 크기 검증 if (file.size > MAX_FILE_SIZE) { invalid.push(`${file.name}: 파일 크기가 1GB를 초과합니다 (${formatFileSize(file.size)})`) return } // 파일 확장자 검증 const extension = file.name.split('.').pop()?.toLowerCase() if (extension && FORBIDDEN_EXTENSIONS.includes(extension)) { invalid.push(`${file.name}: 금지된 파일 형식입니다 (.${extension})`) return } valid.push(file) }) return { valid, invalid } } const handleFileSelect = (event: React.ChangeEvent) => { const selectedFiles = Array.from(event.target.files || []) if (selectedFiles.length > 0) { const { valid, invalid } = validateFiles(selectedFiles) if (invalid.length > 0) { invalid.forEach(msg => toast.error(msg)) } if (valid.length > 0) { onFilesChange([...files, ...valid]) } } } const handleDrop = (event: React.DragEvent) => { event.preventDefault() const droppedFiles = Array.from(event.dataTransfer.files) if (droppedFiles.length > 0) { const { valid, invalid } = validateFiles(droppedFiles) if (invalid.length > 0) { invalid.forEach(msg => toast.error(msg)) } if (valid.length > 0) { onFilesChange([...files, ...valid]) } } } const handleDragOver = (event: React.DragEvent) => { event.preventDefault() } const removeFile = (index: number) => { onFilesChange(files.filter((_, i) => i !== index)) } const formatFileSize = (bytes: number) => { if (bytes === 0) return '0 Bytes' const k = 1024 const sizes = ['Bytes', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] } return (
fileInputRef.current?.click()} >

Drag files to add here or click to select

Supports PDF, Word, Excel, Image, Text, ZIP, CAD files (DWG, DXF, STEP, STL, IGES) (max 1GB)

Forbidden file types: .exe, .com, .dll, .vbs, .js, .asp, .aspx, .bat, .cmd

Note: File names cannot contain these characters: < > : " ' | ? *

{files.length > 0 && (

Selected Files ({files.length})

{files.map((file, index) => (

{file.name}

{formatFileSize(file.size)}

))}
)}
) } /* ------------------------------------------------------------------------------------------------- * Main Dialog Component * -----------------------------------------------------------------------------------------------*/ export function AddAttachmentDialog({ open, onOpenChange, revisionId, revisionName, onSuccess }: AddAttachmentDialogProps) { const [isUploading, setIsUploading] = React.useState(false) const [uploadProgress, setUploadProgress] = React.useState(0) const { data: session } = useSession() const userName = React.useMemo(() => { return session?.user?.name ? session.user.name : null; }, [session]); type AttachmentUploadSchema = z.infer const form = useForm({ resolver: zodResolver(attachmentUploadSchema), defaultValues: { attachments: [], }, }) const watchedFiles = form.watch("attachments") const handleDialogClose = () => { if (!isUploading) { form.reset() setUploadProgress(0) onOpenChange(false) } } const onSubmit = async (data: AttachmentUploadSchema) => { setIsUploading(true) setUploadProgress(0) try { const formData = new FormData() formData.append("revisionId", String(revisionId)) formData.append("uploaderName", userName || "evcp") // 파일들 추가 data.attachments.forEach((file) => { formData.append("attachments", file) }) // 진행률 업데이트 시뮬레이션 const totalSize = data.attachments.reduce((sum, file) => sum + file.size, 0) let uploadedSize = 0 const progressInterval = setInterval(() => { uploadedSize += totalSize * 0.1 const progress = Math.min((uploadedSize / totalSize) * 100, 90) setUploadProgress(progress) }, 300) const response = await fetch('/api/revision-attachment', { method: 'POST', body: formData, }) clearInterval(progressInterval) if (!response.ok) { const errorData = await response.json() throw new Error(errorData.error || errorData.details || 'Failed to upload attachments.') } const result = await response.json() setUploadProgress(100) toast.success( result.message || `${result.data?.uploadedFiles?.length || 0} attachments added.` ) console.log('✅ Attachment upload successful:', result) setTimeout(() => { handleDialogClose() onSuccess?.(result) }, 1000) } catch (error) { console.error('❌ Attachment upload error:', error) let userMessage = "An error occurred while uploading attachments" if (error instanceof Error) { const message = error.message.toLowerCase() // 파일명 관련 에러 (보안상 허용) if (message.includes("안전하지 않은 파일명") || message.includes("unsafe filename") || message.includes("filename") && message.includes("invalid")) { userMessage = "File name contains invalid characters. Please avoid using < > : \" ' | ? * in file names. filename can't start with '..'." } // 파일명 길이 에러 (보안상 허용) else if (message.includes("파일명이 너무 깁니다") || message.includes("filename too long") || message.includes("파일명") && message.includes("길이")) { userMessage = "File name is too long. Please use a shorter name (max 255 characters)." } // 파일 크기 에러 (보안상 허용) else if (message.includes("파일 크기가 너무 큽니다") || message.includes("file size") || message.includes("1gb limit") || message.includes("exceeds") && message.includes("limit")) { userMessage = "File size is too large. Please use files smaller than 1GB." } // 클라이언트측 네트워크 에러 (기존과 같이 처리) else if (message.includes("network") || message.includes("fetch") || message.includes("connection") || message.includes("timeout")) { userMessage = "Network error occurred. Please check your connection and try again." } // 서버측 오류는 보안상 일반적인 메시지로 처리 else if (message.includes("500") || message.includes("server") || message.includes("database") || message.includes("internal") || message.includes("security") || message.includes("validation")) { userMessage = "Please try again later. If the problem persists, please contact the administrator." } // 기타 알려진 클라이언트 에러는 원본 메시지 사용 (영어인 경우) else if (!/[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/.test(error.message) && error.message.length < 200) { userMessage = error.message } // 그 외는 일반적인 메시지 else { userMessage = "Please try again later. If the problem persists, please contact the administrator." } } toast.error(userMessage) } finally { setIsUploading(false) setTimeout(() => setUploadProgress(0), 2000) } } return ( {/* 고정 헤더 */} Add Attachments Upload additional attachments to revision {revisionName}
{/* 스크롤 가능한 중간 영역 */}
{/* 파일 업로드 */} ( Attachments )} /> {/* 업로드 진행률 */} {isUploading && (
Upload Progress {uploadProgress.toFixed(0)}%
{uploadProgress === 100 && (
Upload Complete
)}
)}
{/* 고정 버튼 영역 */}
) }