diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-04 08:31:31 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-04 08:31:31 +0000 |
| commit | b67e36df49f067cbd5ba899f9fbcc755f38d4b4f (patch) | |
| tree | 5a71c5960f90d988cd509e3ef26bff497a277661 /lib/rfq-last/attachment/add-attachment-dialog.tsx | |
| parent | b7f54b06c1ef9e619f5358fb0a5caad9703c8905 (diff) | |
(대표님, 최겸, 임수민) 작업사항 커밋
Diffstat (limited to 'lib/rfq-last/attachment/add-attachment-dialog.tsx')
| -rw-r--r-- | lib/rfq-last/attachment/add-attachment-dialog.tsx | 365 |
1 files changed, 365 insertions, 0 deletions
diff --git a/lib/rfq-last/attachment/add-attachment-dialog.tsx b/lib/rfq-last/attachment/add-attachment-dialog.tsx new file mode 100644 index 00000000..14baf7c7 --- /dev/null +++ b/lib/rfq-last/attachment/add-attachment-dialog.tsx @@ -0,0 +1,365 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { Plus, X, FileIcon } from "lucide-react" +import { toast } from "sonner" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { Progress } from "@/components/ui/progress" +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone" +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list" +import { useRouter } from "next/navigation"; + +const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB +const MAX_FILES = 10; // 최대 10개 파일 + +const addAttachmentSchema = z.object({ + description: z.string().optional(), + files: z + .array(z.instanceof(File)) + .min(1, "최소 1개 이상의 파일을 선택해주세요.") + .max(MAX_FILES, `최대 ${MAX_FILES}개까지 업로드 가능합니다.`) + .refine( + (files) => files.every((file) => file.size <= MAX_FILE_SIZE), + `각 파일 크기는 100MB를 초과할 수 없습니다.` + ), +}) + +type AddAttachmentFormData = z.infer<typeof addAttachmentSchema> + +interface AddAttachmentDialogProps { + rfqId: number; + attachmentType: "구매" | "설계"; + onSuccess?: () => void; +} + +export function AddAttachmentDialog({ + rfqId, + attachmentType, + onSuccess +}: AddAttachmentDialogProps) { + const [open, setOpen] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [uploadProgress, setUploadProgress] = React.useState(0) + const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]) + const router = useRouter(); + + const form = useForm<AddAttachmentFormData>({ + resolver: zodResolver(addAttachmentSchema), + defaultValues: { + description: "", + files: [], + }, + }) + + // 파일 크기 포맷팅 함수 + 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]; + }; + + // 파일 확장자 가져오기 + const getFileExtension = (fileName: string) => { + return fileName.split('.').pop()?.toUpperCase() || 'FILE'; + }; + + // 파일 추가 처리 + const handleFilesAdd = (newFiles: FileList | null) => { + if (!newFiles) return; + + const filesArray = Array.from(newFiles); + const totalFiles = selectedFiles.length + filesArray.length; + + if (totalFiles > MAX_FILES) { + toast.error(`최대 ${MAX_FILES}개까지만 업로드할 수 있습니다.`); + return; + } + + // 파일 크기 체크 + const oversizedFiles = filesArray.filter(file => file.size > MAX_FILE_SIZE); + if (oversizedFiles.length > 0) { + toast.error(`다음 파일들이 100MB를 초과합니다: ${oversizedFiles.map(f => f.name).join(", ")}`); + return; + } + + const updatedFiles = [...selectedFiles, ...filesArray]; + setSelectedFiles(updatedFiles); + form.setValue("files", updatedFiles, { shouldValidate: true }); + }; + + + const onSubmit = async (data: AddAttachmentFormData) => { + setIsSubmitting(true); + setUploadProgress(0); + + try { + const formData = new FormData(); + formData.append("rfqId", rfqId.toString()); + formData.append("attachmentType", attachmentType); + formData.append("description", data.description || ""); + + // 모든 파일 추가 + data.files.forEach((file) => { + formData.append("files", file); + }); + + // 진행률 시뮬레이션 + const progressInterval = setInterval(() => { + setUploadProgress((prev) => { + if (prev >= 90) { + clearInterval(progressInterval); + return 90; + } + return prev + 10; + }); + }, 200); + + const response = await fetch("/api/rfq-attachments/upload", { + method: "POST", + body: formData, + }); + + clearInterval(progressInterval); + setUploadProgress(100); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "파일 업로드 실패"); + } + + const result = await response.json(); + + if (result.success) { + toast.success(`${result.uploadedCount}개 파일이 성공적으로 업로드되었습니다`); + form.reset(); + setSelectedFiles([]); + setOpen(false); + + router.refresh() + onSuccess?.(); + } else { + toast.error(result.message); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "파일 업로드 중 오류가 발생했습니다"); + } finally { + setIsSubmitting(false); + setUploadProgress(0); + } + }; + + // 다이얼로그 닫을 때 상태 초기화 + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + form.reset(); + setSelectedFiles([]); + setUploadProgress(0); + } + setOpen(newOpen); + }; + + const handleDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...selectedFiles, ...acceptedFiles] + setSelectedFiles(newFiles) + form.setValue('files', newFiles, { shouldValidate: true }) + } + + const removeFile = (index: number) => { + const updatedFiles = [...selectedFiles] + updatedFiles.splice(index, 1) + setSelectedFiles(updatedFiles) + form.setValue('files', updatedFiles, { shouldValidate: true }) + } + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button size="sm"> + <Plus className="h-4 w-4 mr-2" /> + 파일 업로드 + </Button> + </DialogTrigger> + + <DialogContent className="sm:max-w-[600px] max-h-[90vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle>새 첨부파일 추가</DialogTitle> + <DialogDescription> + {attachmentType} 문서를 업로드합니다. (파일당 최대 100MB, 최대 {MAX_FILES}개) + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0"> + <div className="flex-1 overflow-y-auto px-1"> + <div className="space-y-4 py-4"> + <FormField + control={form.control} + name="files" + render={() => ( + <FormItem> + <FormLabel>파일 첨부</FormLabel> + <Dropzone + maxSize={MAX_FILE_SIZE} // 3GB + multiple={true} + onDropAccepted={handleDropAccepted} + disabled={isSubmitting} + > + <DropzoneZone className="flex justify-center"> + <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> + <FormMessage /> + </FormItem> + )} + /> + + {/* 선택된 파일 목록 */} + {selectedFiles.length > 0 && ( + <div className="space-y-2"> + <FileList> + <FileListHeader> + <div className="flex justify-between items-center"> + <span className="text-sm font-medium"> + 선택된 파일 ({selectedFiles.length}/{MAX_FILES}) + </span> + <span className="text-sm text-muted-foreground"> + 총 {formatFileSize(selectedFiles.reduce((acc, file) => acc + file.size, 0))} + </span> + </div> + </FileListHeader> + {selectedFiles.map((file, index) => ( + <FileListItem key={index}> + <FileListIcon> + <FileIcon className="h-4 w-4" /> + </FileListIcon> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListDescription> + {getFileExtension(file.name)} • {formatFileSize(file.size)} + </FileListDescription> + </FileListInfo> + <FileListAction> + <Button + type="button" + variant="ghost" + size="icon" + onClick={() => removeFile(index)} + disabled={isSubmitting} + > + <X className="h-4 w-4" /> + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + </div> + )} + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명 (선택)</FormLabel> + <FormControl> + <Textarea + placeholder="첨부파일에 대한 설명을 입력하세요" + className="resize-none" + rows={3} + disabled={isSubmitting} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {isSubmitting && ( + <div className="space-y-2"> + <div className="flex justify-between text-sm"> + <span>업로드 진행중...</span> + <span>{uploadProgress}%</span> + </div> + <Progress value={uploadProgress} /> + </div> + )} + </div> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => handleOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + type="submit" + disabled={isSubmitting || selectedFiles.length === 0} + > + {isSubmitting + ? `업로드 중... (${uploadProgress}%)` + : `${selectedFiles.length}개 파일 업로드` + } + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file |
