diff options
Diffstat (limited to 'lib/dolce/dialogs/upload-files-to-detail-dialog.tsx')
| -rw-r--r-- | lib/dolce/dialogs/upload-files-to-detail-dialog.tsx | 314 |
1 files changed, 314 insertions, 0 deletions
diff --git a/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx b/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx new file mode 100644 index 00000000..1d8ac582 --- /dev/null +++ b/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx @@ -0,0 +1,314 @@ +"use client"; + +import * as React from "react"; +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Upload, FolderOpen, Loader2, X, FileText, AlertCircle } from "lucide-react"; +import { toast } from "sonner"; +import { uploadFilesToDetailDrawing, type UploadFilesResult } from "../actions"; + +interface UploadFilesToDetailDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + uploadId: string; + drawingNo: string; + revNo: string; + userId: string; + onUploadComplete?: () => void; +} + +export function UploadFilesToDetailDialog({ + open, + onOpenChange, + uploadId, + drawingNo, + revNo, + userId, + onUploadComplete, +}: UploadFilesToDetailDialogProps) { + const [selectedFiles, setSelectedFiles] = useState<File[]>([]); + const [isUploading, setIsUploading] = useState(false); + const [isDragging, setIsDragging] = useState(false); + + // 다이얼로그 닫을 때 초기화 + React.useEffect(() => { + if (!open) { + setSelectedFiles([]); + setIsDragging(false); + } + }, [open]); + + // 파일 선택 핸들러 + const handleFilesChange = (files: File[]) => { + if (files.length === 0) return; + + // 파일 크기 및 확장자 검증 + const MAX_FILE_SIZE = 1024 * 1024 * 1024; // 1GB + const FORBIDDEN_EXTENSIONS = ['exe', 'com', 'dll', 'vbs', 'js', 'asp', 'aspx', 'bat', 'cmd']; + + const validFiles: File[] = []; + const invalidFiles: string[] = []; + + files.forEach((file) => { + // 크기 검증 + if (file.size > MAX_FILE_SIZE) { + invalidFiles.push(`${file.name}: 파일 크기가 1GB를 초과합니다`); + return; + } + + // 확장자 검증 + const extension = file.name.split('.').pop()?.toLowerCase(); + if (extension && FORBIDDEN_EXTENSIONS.includes(extension)) { + invalidFiles.push(`${file.name}: 금지된 파일 형식입니다 (.${extension})`); + return; + } + + validFiles.push(file); + }); + + if (invalidFiles.length > 0) { + invalidFiles.forEach((msg) => toast.error(msg)); + } + + if (validFiles.length > 0) { + // 중복 제거 + const existingNames = new Set(selectedFiles.map((f) => f.name)); + const newFiles = validFiles.filter((f) => !existingNames.has(f.name)); + + if (newFiles.length === 0) { + toast.error("이미 선택된 파일입니다"); + return; + } + + setSelectedFiles((prev) => [...prev, ...newFiles]); + toast.success(`${newFiles.length}개 파일이 선택되었습니다`); + } + }; + + // Drag & Drop 핸들러 + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + 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); + } + }; + + // 파일 제거 + const handleRemoveFile = (index: number) => { + setSelectedFiles((prev) => prev.filter((_, i) => i !== index)); + }; + + // 업로드 처리 + const handleUpload = async () => { + if (selectedFiles.length === 0) { + toast.error("파일을 선택해주세요"); + return; + } + + setIsUploading(true); + + try { + // FormData 생성 + const formData = new FormData(); + formData.append("uploadId", uploadId); + formData.append("userId", userId); + formData.append("fileCount", String(selectedFiles.length)); + + selectedFiles.forEach((file, index) => { + formData.append(`file_${index}`, file); + }); + + // 서버 액션 호출 + const result: UploadFilesResult = await uploadFilesToDetailDrawing(formData); + + if (result.success) { + toast.success(`${result.uploadedCount}개 파일 업로드 완료`); + onOpenChange(false); + onUploadComplete?.(); + } else { + toast.error(result.error || "업로드 실패"); + } + } catch (error) { + console.error("업로드 실패:", error); + toast.error( + error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다" + ); + } finally { + setIsUploading(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>파일 업로드</DialogTitle> + <DialogDescription> + {drawingNo} - Rev. {revNo}에 파일을 업로드합니다 + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 안내 메시지 */} + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + 선택한 상세도면의 UploadId에 파일을 추가합니다. 파일 업로드 후 자동으로 메타데이터가 저장됩니다. + </AlertDescription> + </Alert> + + {/* 파일 선택 영역 */} + <div + className={`border-2 border-dashed rounded-lg p-8 transition-all duration-200 ${ + isDragging + ? "border-primary bg-primary/5 scale-[1.02]" + : "border-muted-foreground/30 hover:border-muted-foreground/50" + }`} + onDragEnter={handleDragEnter} + onDragLeave={handleDragLeave} + onDragOver={handleDragOver} + onDrop={handleDrop} + > + <input + type="file" + multiple + accept=".pdf,.doc,.docx,.xls,.xlsx,.dwg,.dxf,.zip" + onChange={(e) => handleFilesChange(Array.from(e.target.files || []))} + className="hidden" + id="detail-file-upload" + /> + <label + htmlFor="detail-file-upload" + className="flex flex-col items-center justify-center cursor-pointer" + > + <FolderOpen + className={`h-12 w-12 mb-3 transition-colors ${ + isDragging ? "text-primary" : "text-muted-foreground" + }`} + /> + <p + className={`text-sm transition-colors ${ + isDragging + ? "text-primary font-medium" + : "text-muted-foreground" + }`} + > + {isDragging + ? "파일을 여기에 놓으세요" + : "클릭하거나 파일을 드래그하여 선택"} + </p> + <p className="text-xs text-muted-foreground mt-1"> + PDF, DOC, DOCX, XLS, XLSX, DWG, DXF, ZIP (max 1GB per file) + </p> + </label> + </div> + + {/* 선택된 파일 목록 */} + {selectedFiles.length > 0 && ( + <div className="border rounded-lg p-4"> + <div className="flex items-center justify-between mb-3"> + <h4 className="text-sm font-medium"> + 선택된 파일 ({selectedFiles.length}개) + </h4> + <Button + variant="ghost" + size="sm" + onClick={() => setSelectedFiles([])} + disabled={isUploading} + > + 전체 제거 + </Button> + </div> + <div className="max-h-48 overflow-auto space-y-2"> + {selectedFiles.map((file, index) => ( + <div + key={index} + className="flex items-center justify-between p-2 rounded bg-muted/50" + > + <div className="flex items-center gap-2 flex-1 min-w-0"> + <FileText className="h-4 w-4 text-muted-foreground shrink-0" /> + <div className="flex-1 min-w-0"> + <p className="text-sm truncate">{file.name}</p> + <p className="text-xs text-muted-foreground"> + {(file.size / 1024 / 1024).toFixed(2)} MB + </p> + </div> + </div> + <Button + variant="ghost" + size="sm" + onClick={() => handleRemoveFile(index)} + disabled={isUploading} + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + </div> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isUploading} + > + 취소 + </Button> + <Button + onClick={handleUpload} + disabled={selectedFiles.length === 0 || isUploading} + > + {isUploading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 업로드 중... + </> + ) : ( + <> + <Upload className="mr-2 h-4 w-4" /> + 업로드 ({selectedFiles.length}개) + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + |
