diff options
Diffstat (limited to 'components/ship-vendor-document/new-revision-dialog.tsx')
| -rw-r--r-- | components/ship-vendor-document/new-revision-dialog.tsx | 622 |
1 files changed, 622 insertions, 0 deletions
diff --git a/components/ship-vendor-document/new-revision-dialog.tsx b/components/ship-vendor-document/new-revision-dialog.tsx new file mode 100644 index 00000000..092256c7 --- /dev/null +++ b/components/ship-vendor-document/new-revision-dialog.tsx @@ -0,0 +1,622 @@ +"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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Button } from "@/components/ui/button" +import { Progress } from "@/components/ui/progress" +import { Badge } from "@/components/ui/badge" +import { + Upload, + FileText, + X, + Loader2, + AlertCircle, + CheckCircle +} from "lucide-react" +import { toast } from "sonner" +import { useSession } from "next-auth/react" + +// 기존 메인 컴포넌트에서 추가할 import +// import { NewRevisionDialog } from "./new-revision-dialog" + +/* ------------------------------------------------------------------------------------------------- + * Schema & Types + * -----------------------------------------------------------------------------------------------*/ + +// 파일 검증 스키마 +const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB +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' +] + +// drawingKind에 따른 동적 스키마 생성 +const createRevisionUploadSchema = (drawingKind: string) => { + const baseSchema = { + usage: z.string().min(1, "용도를 선택해주세요"), + revision: z.string().min(1, "리비전을 입력해주세요").max(50, "리비전은 50자 이내로 입력해주세요"), + comment: z.string().optional(), + attachments: z + .array(z.instanceof(File)) + .min(1, "최소 1개의 파일을 업로드해주세요") + .max(10, "최대 10개의 파일까지 업로드 가능합니다") + .refine( + (files) => files.every((file) => file.size <= MAX_FILE_SIZE), + "파일 크기는 50MB 이하여야 합니다" + ) + .refine( + (files) => files.every((file) => ACCEPTED_FILE_TYPES.includes(file.type)), + "지원하지 않는 파일 형식입니다" + ), + } + + // B3인 경우에만 usageType 필드 추가 + if (drawingKind === 'B3') { + return z.object({ + ...baseSchema, + usageType: z.string().min(1, "용도 타입을 선택해주세요"), + }) + } else { + return z.object({ + ...baseSchema, + usageType: z.string().optional(), + }) + } +} + +// drawingKind에 따른 용도 옵션 +const getUsageOptions = (drawingKind: string) => { + switch (drawingKind) { + case 'B3': + return [ + { value: "Approval", label: "Approval" }, + { value: "Working", label: "Working" }, + { value: "Reference", label: "Reference" }, + ] + case 'B4': + return [ + { value: "Pre", label: "Pre" }, + { value: "Working", label: "Working" }, + ] + case 'B5': + return [ + { value: "Pre", label: "Pre" }, + { value: "Working", label: "Working" }, + ] + default: + return [ + { value: "Pre", label: "Pre" }, + { value: "Working", label: "Working" }, + ] + } +} + +// B3 전용 용도 타입 옵션 +const getUsageTypeOptions = (usage: string) => { + switch (usage) { + case 'Approval': + return [ + { value: "Approval Submission Full", label: "Approval Submission Full" }, + { value: "Approval Submission Partial", label: "Approval Submission Partial" }, + { value: "Approval Completion Full", label: "Approval Completion Full" }, + { value: "Approval Completion Partial", label: "Approval Completion Partial" }, + ] + case 'Working': + return [ + { value: "Working Full", label: "Working Full" }, + { value: "Working Partial", label: "Working Partial" }, + ] + case 'Reference': + return [ + { value: "Reference Full", label: "Reference Full" }, + { value: "Reference Partial", label: "Reference Partial" }, + { value: "Reference Series Full", label: "Reference Series Full" }, + { value: "Reference Series Partial", label: "Reference Series Partial" }, + ] + default: + return [] + } +} + +// 리비전 형식 가이드 생성 +const getRevisionGuide = (drawingKind: string, usage: string, usageType: string) => { + if (drawingKind === 'B4') { + return "R00, R01, R02... 형식으로 입력하세요" + } + + if (drawingKind === 'B5') { + return "A, B, C... 형식으로 입력하세요" + } + + if (drawingKind === 'B3') { + if (usage === 'Reference') { + return "00, 01, 02... 형식으로 입력하세요" + } + if (usageType === 'Working Partial') { + return "00, 01, 02... 형식으로 입력하세요" + } + if (usageType?.includes('Full') && (usageType.includes('Working') || usageType.includes('Approval'))) { + return "A, B, C... 형식으로 입력하세요" + } + if (usageType?.includes('Partial') && usageType.includes('Approval')) { + return "리비전 형식은 추후 정의 예정입니다" + } + } + + return "리비전을 입력하세요" +} + +interface NewRevisionDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + documentId: number + documentTitle?: string + drawingKind: string + onSuccess?: (result?: any) => void // ✅ result 파라미터 추가 +} + +/* ------------------------------------------------------------------------------------------------- + * File Upload Component + * -----------------------------------------------------------------------------------------------*/ +function FileUploadArea({ + files, + onFilesChange +}: { + files: File[] + onFilesChange: (files: File[]) => void +}) { + const fileInputRef = React.useRef<HTMLInputElement>(null) + + const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { + const selectedFiles = Array.from(event.target.files || []) + if (selectedFiles.length > 0) { + onFilesChange([...files, ...selectedFiles]) + } + } + + const handleDrop = (event: React.DragEvent<HTMLDivElement>) => { + event.preventDefault() + const droppedFiles = Array.from(event.dataTransfer.files) + if (droppedFiles.length > 0) { + onFilesChange([...files, ...droppedFiles]) + } + } + + const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => { + 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 ( + <div className="space-y-4"> + <div + className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer hover:border-gray-400 transition-colors" + onDrop={handleDrop} + onDragOver={handleDragOver} + onClick={() => fileInputRef.current?.click()} + > + <Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" /> + <p className="text-sm text-gray-600 mb-2"> + 파일을 드래그하여 놓거나 클릭하여 선택하세요 + </p> + <p className="text-xs text-gray-500"> + PDF, Word, Excel, 이미지, 텍스트, ZIP 파일 지원 (최대 50MB) + </p> + <input + ref={fileInputRef} + type="file" + multiple + accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif,.txt,.zip" + onChange={handleFileSelect} + className="hidden" + /> + </div> + + {files.length > 0 && ( + <div className="space-y-2 max-h-40 overflow-y-auto overscroll-contain pr-2"> + <p className="text-sm font-medium">선택된 파일 ({files.length}개)</p> + <div className="max-h-40 overflow-y-auto space-y-2"> + {files.map((file, index) => ( + <div + key={index} + className="flex items-center justify-between p-2 bg-gray-50 rounded border" + > + <div className="flex items-center space-x-2 flex-1"> + <FileText className="h-4 w-4 text-gray-500" /> + <div className="flex-1 min-w-0"> + <p className="text-sm font-medium truncate" title={file.name}> + {file.name} + </p> + <p className="text-xs text-gray-500"> + {formatFileSize(file.size)} + </p> + </div> + </div> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeFile(index)} + className="h-8 w-8 p-0" + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + </div> + )} + </div> + ) +} + +/* ------------------------------------------------------------------------------------------------- + * Main Dialog Component + * -----------------------------------------------------------------------------------------------*/ +export function NewRevisionDialog({ + open, + onOpenChange, + documentId, + documentTitle, + drawingKind, + onSuccess +}: NewRevisionDialogProps) { + 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]); + + // drawingKind에 따른 동적 스키마 및 옵션 생성 + const revisionUploadSchema = React.useMemo(() => createRevisionUploadSchema(drawingKind), [drawingKind]) + const usageOptions = React.useMemo(() => getUsageOptions(drawingKind), [drawingKind]) + const showUsageType = drawingKind === 'B3' + + type RevisionUploadSchema = z.infer<typeof revisionUploadSchema> + + const form = useForm<RevisionUploadSchema>({ + resolver: zodResolver(revisionUploadSchema), + defaultValues: { + usage: "", + revision: "", + comment: "", + usageType: showUsageType ? "" : undefined, + attachments: [], + }, + }) + + const watchedFiles = form.watch("attachments") + const watchedUsage = form.watch("usage") + const watchedUsageType = form.watch("usageType") + + // 용도 선택에 따른 용도 타입 옵션 업데이트 + const usageTypeOptions = React.useMemo(() => { + if (drawingKind === 'B3' && watchedUsage) { + return getUsageTypeOptions(watchedUsage) + } + return [] + }, [drawingKind, watchedUsage]) + + // 용도 변경 시 용도 타입 초기화 + React.useEffect(() => { + if (showUsageType && watchedUsage) { + form.setValue("usageType", "") + } + }, [watchedUsage, showUsageType, form]) + + // 리비전 가이드 텍스트 + const revisionGuide = React.useMemo(() => { + return getRevisionGuide(drawingKind, watchedUsage, watchedUsageType || "") + }, [drawingKind, watchedUsage, watchedUsageType]) + + const handleDialogClose = () => { + if (!isUploading) { + form.reset() + setUploadProgress(0) + onOpenChange(false) + } + } + + const onSubmit = async (data: RevisionUploadSchema) => { + setIsUploading(true) + setUploadProgress(0) + + try { + const formData = new FormData() + formData.append("documentId", String(documentId)) + formData.append("usage", data.usage) + formData.append("revision", data.revision) + formData.append("uploaderName", userName || "evcp") + + if (data.comment) { + formData.append("comment", data.comment) + } + + // B3인 경우에만 usageType 추가 + if (showUsageType && 'usageType' in data && data.usageType) { + formData.append("usageType", data.usageType) + } + + // 파일들 추가 + 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-upload-ship', { // ✅ 올바른 API 엔드포인트 사용 + method: 'POST', + body: formData, + }) + + clearInterval(progressInterval) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || errorData.details || '업로드에 실패했습니다.') + } + + const result = await response.json() + setUploadProgress(100) + + toast.success( + result.message || + `리비전 ${data.revision}이 성공적으로 업로드되었습니다. (${result.data?.uploadedFiles?.length || 0}개 파일)` + ) + + console.log('✅ 업로드 성공:', result) + + setTimeout(() => { + handleDialogClose() + onSuccess?.(result) // ✅ API 응답 결과를 콜백에 전달 + }, 1000) + + } catch (error) { + console.error('❌ 업로드 오류:', error) + toast.error(error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다") + } finally { + setIsUploading(false) + setTimeout(() => setUploadProgress(0), 2000) + } + } + + return ( + <Dialog open={open} onOpenChange={handleDialogClose}> + <DialogContent className="max-w-2xl h-[90vh] flex flex-col overflow-hidden" style={{maxHeight:'90vh'}}> + {/* 고정 헤더 */} + <DialogHeader className="flex-shrink-0 pb-4 border-b"> + <DialogTitle className="flex items-center gap-2"> + <Upload className="h-5 w-5" /> + 새 리비전 업로드 + </DialogTitle> + {documentTitle && ( + <DialogDescription className="text-sm space-y-1"> + <div>문서: {documentTitle}</div> + </DialogDescription> + )} + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 overflow-hidden"> + {/* 스크롤 가능한 중간 영역 */} + <div className="flex-1 overflow-y-auto px-1 py-4 space-y-6"> + {/* 용도 선택 */} + <FormField + control={form.control} + name="usage" + render={({ field }) => ( + <FormItem> + <FormLabel className="required">용도</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="용도를 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {usageOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 용도 타입 선택 (B3만) */} + {showUsageType && watchedUsage && ( + <FormField + control={form.control} + name="usageType" + render={({ field }) => ( + <FormItem> + <FormLabel className="required">용도 타입</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="용도 타입을 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {usageTypeOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + )} + + {/* 리비전 */} + <FormField + control={form.control} + name="revision" + render={({ field }) => ( + <FormItem> + <FormLabel className="required">리비전</FormLabel> + <FormControl> + <Input + placeholder={revisionGuide} + {...field} + /> + </FormControl> + <div className="text-xs text-muted-foreground mt-1"> + {revisionGuide} + </div> + <FormMessage /> + </FormItem> + )} + /> + + {/* 코멘트 */} + <FormField + control={form.control} + name="comment" + render={({ field }) => ( + <FormItem> + <FormLabel>코멘트</FormLabel> + <FormControl> + <Textarea + placeholder="리비전에 대한 설명이나 변경사항을 입력하세요 (선택사항)" + className="resize-none" + rows={3} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 파일 업로드 */} + <FormField + control={form.control} + name="attachments" + render={({ field }) => ( + <FormItem> + <FormLabel className="required">첨부파일</FormLabel> + <FormControl> + <FileUploadArea + files={watchedFiles || []} + onFilesChange={field.onChange} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 업로드 진행률 */} + {isUploading && ( + <div className="space-y-2"> + <div className="flex items-center justify-between text-sm"> + <span>업로드 진행률</span> + <span>{uploadProgress.toFixed(0)}%</span> + </div> + <Progress value={uploadProgress} className="w-full" /> + {uploadProgress === 100 && ( + <div className="flex items-center gap-2 text-sm text-green-600"> + <CheckCircle className="h-4 w-4" /> + <span>업로드 완료</span> + </div> + )} + </div> + )} + </div> + + {/* 고정 버튼 영역 */} + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={handleDialogClose} + disabled={isUploading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isUploading || !form.formState.isValid} + className="min-w-[120px]" + > + {isUploading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 업로드 중... + </> + ) : ( + <> + <Upload className="mr-2 h-4 w-4" /> + 업로드 + </> + )} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file |
