diff options
Diffstat (limited to 'components/documents/add-document-dialog.tsx')
| -rw-r--r-- | components/documents/add-document-dialog.tsx | 515 |
1 files changed, 515 insertions, 0 deletions
diff --git a/components/documents/add-document-dialog.tsx b/components/documents/add-document-dialog.tsx new file mode 100644 index 00000000..15c1e021 --- /dev/null +++ b/components/documents/add-document-dialog.tsx @@ -0,0 +1,515 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" + +import { + Dialog, DialogTrigger, DialogContent, DialogHeader, + DialogTitle, DialogDescription, DialogFooter +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription +} from "@/components/ui/form" +import { + Select, SelectContent, SelectGroup, + SelectItem, SelectTrigger, SelectValue +} from "@/components/ui/select" +import { Textarea } from "@/components/ui/textarea" +import { createRevisionAction } from "@/lib/vendor-document/service" +import { useToast } from "@/hooks/use-toast" +import { FilePlus, X, Loader2, Building2, User, Building, Plus } from "lucide-react" +import { Badge } from "@/components/ui/badge" +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 prettyBytes from "pretty-bytes" +import { ScrollArea } from "../ui/scroll-area" + +// 최대 파일 크기 설정 (3000MB) +const MAX_FILE_SIZE = 3e9 + +// zod 스키마 +export const createDocumentVersionSchema = z.object({ + attachments: z.array(z.instanceof(File)).min(1, "At least one file is required"), + stage: z.string().min(1, "Stage is required"), + revision: z.string().min(1, "Revision is required"), + uploaderType: z.enum(["vendor", "client", "shi"]).default("vendor"), + uploaderName: z.string().optional(), + comment: z.string().optional(), +}) +export type CreateDocumentVersionSchema = z.infer<typeof createDocumentVersionSchema> + +// First, let's add a function to generate filenames based on your pattern +const generateFileName = ( + documentNo: string, + stage: string, + revision: string, + originalFileName: string, + index: number, + totalFiles: number +) => { + // Get the file extension + const extension = originalFileName.split('.').pop() || ''; + + // Base name without extension + const baseName = `${documentNo}_${stage}_${revision}`; + + // For multiple files, add a suffix + if (totalFiles > 1) { + return `${baseName}_${index + 1}.${extension}`; + } + + // For a single file, no suffix needed + return `${baseName}.${extension}`; +}; + +// AddDocumentDialog Props +interface AddDocumentDialogProps { + onSuccess?: () => void + stageOptions?: string[] + documentId: number + documentNo: string + // 업로더 타입 + uploaderType?: "vendor" | "client" | "shi" + // 버튼 라벨 추가 + buttonLabel?: string +} + +export function AddDocumentDialog({ + onSuccess, + stageOptions = [], + documentId, + documentNo, + uploaderType = "vendor", + buttonLabel = "Add Document", +}: AddDocumentDialogProps) { + const [open, setOpen] = React.useState(false) + const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]) + const [isUploading, setIsUploading] = React.useState(false) + const [uploadProgress, setUploadProgress] = React.useState(0) + const { toast } = useToast() + + const form = useForm<CreateDocumentVersionSchema>({ + resolver: zodResolver(createDocumentVersionSchema), + defaultValues: { + stage: "", + revision: "", + attachments: [], + uploaderType, + uploaderName: "", + comment: "", + }, + }) + + // 업로더 타입이 바뀌면 폼 값도 업데이트 + React.useEffect(() => { + form.setValue('uploaderType', uploaderType); + }, [uploaderType, form]); + + // 드롭존 - 파일 드랍 처리 + const handleDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...selectedFiles, ...acceptedFiles]; + setSelectedFiles(newFiles); + form.setValue('attachments', newFiles, { shouldValidate: true }); + }; + + // 드롭존 - 파일 거부(에러) 처리 + const handleDropRejected = (fileRejections: any[]) => { + fileRejections.forEach((rejection) => { + toast({ + variant: "destructive", + title: "File Error", + description: `${rejection.file.name}: ${rejection.errors[0]?.message || "Upload failed"}`, + }); + }); + }; + + // 파일 제거 핸들러 + const removeFile = (index: number) => { + const updatedFiles = [...selectedFiles] + updatedFiles.splice(index, 1) + setSelectedFiles(updatedFiles) + form.setValue('attachments', updatedFiles, { shouldValidate: true }) + } + + // Submit + async function onSubmit(data: CreateDocumentVersionSchema) { + setIsUploading(true) + setUploadProgress(0) + + try { + // 각 파일별로 별도 요청 + const totalFiles = data.attachments.length; + let successCount = 0; + + for (let i = 0; i < totalFiles; i++) { + const file = data.attachments[i]; + + const newFileName = generateFileName( + documentNo, + data.stage, + data.revision, + file.name, + i, + totalFiles + ); + + const fData = new FormData(); + fData.append("documentId", String(documentId)); + fData.append("stage", data.stage); + fData.append("revision", data.revision); + fData.append("uploaderType", data.uploaderType); + + if (data.uploaderName) { + fData.append("uploaderName", data.uploaderName); + } + + if (data.comment) { + fData.append("comment", data.comment); + } + + fData.append("attachment", file); + fData.append("customFileName", newFileName); + + // 각 파일 업로드를 요청 + await createRevisionAction(fData); + + // 진행 상황 업데이트 + successCount++; + setUploadProgress(Math.round((successCount / totalFiles) * 100)); + } + + // 성공 메시지 + toast({ + title: "Success", + description: `${successCount} 파일이 업로드되었습니다.`, + }); + + // 폼 초기화 + form.reset(); + setSelectedFiles([]); + setOpen(false); + + // 콜백 실행 + if (onSuccess) { + onSuccess(); + } + } catch (err) { + console.error(err); + toast({ + title: "Error", + description: "파일 업로드 중 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setIsUploading(false); + setUploadProgress(0); + } + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset(); + form.setValue('uploaderType', uploaderType); + setSelectedFiles([]); + setIsUploading(false); + setUploadProgress(0); + } + setOpen(nextOpen); + } + + // 업로더 타입에 따른 UI 설정 + const uploaderConfig = { + vendor: { + icon: <Plus className="h-4 w-4" />, + buttonStyle: "bg-blue-600 hover:bg-blue-700", + badgeStyle: "bg-blue-100 text-blue-800", + title: "업체 문서 등록", + nameLabel: "업체 이름", + namePlaceholder: "예: 홍길동(ABC 업체)" + }, + client: { + icon: <User className="mr-2 h-4 w-4" />, + buttonStyle: "bg-amber-600 hover:bg-amber-700", + badgeStyle: "bg-amber-100 text-amber-800", + title: "고객사 문서 등록", + nameLabel: "담당자 이름", + namePlaceholder: "예: 김철수(고객사)" + }, + shi: { + icon: <Building className="mr-2 h-4 w-4" />, + buttonStyle: "bg-purple-600 hover:bg-purple-700", + badgeStyle: "bg-purple-100 text-purple-800", + title: "삼성중공업 문서 등록", + nameLabel: "담당자 이름", + namePlaceholder: "예: 이영희(삼성중공업)" + } + } + + const config = uploaderConfig[uploaderType as keyof typeof uploaderConfig]; + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogTrigger asChild> + <Button + size="sm" + className="border-blue-200" + variant="outline" + > + {config.icon} + {buttonLabel} + </Button> + </DialogTrigger> + + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle> + {config.title} + </DialogTitle> + <DialogDescription> + 스테이지/리비전을 입력하고 파일을 업로드하세요. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} encType="multipart/form-data"> + <div className="space-y-4 py-4"> + {/* stage */} + <FormField + control={form.control} + name="stage" + render={({ field }) => ( + <FormItem> + <FormLabel>Stage</FormLabel> + <FormControl> + {stageOptions.length > 0 ? ( + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <SelectTrigger> + <SelectValue placeholder="Select a stage" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {stageOptions.map((st) => ( + <SelectItem key={st} value={st}> + {st} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + ) : ( + <Input {...field} placeholder="예: Issued for Review" /> + )} + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* revision */} + <FormField + control={form.control} + name="revision" + render={({ field }) => ( + <FormItem> + <FormLabel>Revision</FormLabel> + <FormControl> + <Input {...field} placeholder="예: A, B, 1, 2..." /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* attachments - 드롭존 */} + <FormField + control={form.control} + name="attachments" + render={() => ( + <FormItem> + <FormLabel>파일 첨부</FormLabel> + <Dropzone + maxSize={MAX_FILE_SIZE} + multiple={true} + onDropAccepted={handleDropAccepted} + onDropRejected={handleDropRejected} + disabled={isUploading} + > + {({ maxSize }) => ( + <> + <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> + 또는 클릭하여 파일을 선택하세요. + 최대 크기: {maxSize ? prettyBytes(maxSize) : "무제한"} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + <FormDescription className="text-xs text-muted-foreground"> + 여러 파일을 선택할 수 있습니다. + </FormDescription> + </> + )} + </Dropzone> + <FormMessage /> + </FormItem> + )} + /> + + {/* 선택된 파일 목록 */} + + {selectedFiles.length > 0 && ( + <div className="grid gap-2"> + <div className="flex items-center justify-between"> + <h6 className="text-sm font-semibold"> + 선택된 파일 ({selectedFiles.length}) + </h6> + <Badge variant="secondary"> + {selectedFiles.length}개 파일 + </Badge> + </div> + <ScrollArea> + <FileList className="max-h-[200px] gap-3"> + {selectedFiles.map((file, index) => ( + <FileListItem key={index} className="p-3"> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListDescription> + {prettyBytes(file.size)} + </FileListDescription> + </FileListInfo> + <FileListAction onClick={() => removeFile(index)} disabled={isUploading}> + <X className="h-4 w-4" /> + <span className="sr-only">Remove</span> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + </ScrollArea> + + </div> + )} + + {/* 업로드 진행 상태 */} + {isUploading && ( + <div className="flex flex-col gap-1 mt-2"> + <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> + </div> + )} + + {/* 선택적 필드들 */} + {/* <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <FormField + control={form.control} + name="uploaderName" + render={({ field }) => ( + <FormItem> + <FormLabel>{config.nameLabel} (선택)</FormLabel> + <FormControl> + <Input + {...field} + placeholder={config.namePlaceholder} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> */} + + {/* comment (optional) */} + {/* <FormField + control={form.control} + name="comment" + render={({ field }) => ( + <FormItem> + <FormLabel>코멘트 (선택)</FormLabel> + <FormControl> + <Textarea + {...field} + placeholder="파일에 대한 설명이나 코멘트를 입력하세요." + className="resize-none" + rows={2} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> */} + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => { + // 파일 리스트 리셋 + setSelectedFiles([]); + form.reset({ + ...form.getValues(), + attachments: [] + }); + setOpen(false); + }} + disabled={isUploading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isUploading || selectedFiles.length === 0 || !form.formState.isValid} + className={config.buttonStyle} + > + {isUploading ? "업로드 중..." : "등록"} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file |
