diff options
Diffstat (limited to 'lib/rfq-last/attachment')
| -rw-r--r-- | lib/rfq-last/attachment/add-attachment-dialog.tsx | 365 | ||||
| -rw-r--r-- | lib/rfq-last/attachment/delete-attachments-dialog.tsx | 117 | ||||
| -rw-r--r-- | lib/rfq-last/attachment/rfq-attachments-table.tsx | 539 | ||||
| -rw-r--r-- | lib/rfq-last/attachment/update-revision-dialog.tsx | 216 |
4 files changed, 1237 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 diff --git a/lib/rfq-last/attachment/delete-attachments-dialog.tsx b/lib/rfq-last/attachment/delete-attachments-dialog.tsx new file mode 100644 index 00000000..c9041639 --- /dev/null +++ b/lib/rfq-last/attachment/delete-attachments-dialog.tsx @@ -0,0 +1,117 @@ +"use client" + +import * as React from "react" +import { Loader, Trash2 } from "lucide-react" +import { toast } from "sonner" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" + +interface DeleteAttachmentsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + attachments: Array<{ + id: number; + originalFileName?: string | null; + serialNo?: string | null; + }>; + onSuccess?: () => void; +} + +export function DeleteAttachmentsDialog({ + open, + onOpenChange, + attachments, + onSuccess, +}: DeleteAttachmentsDialogProps) { + const [isDeleting, setIsDeleting] = React.useState(false) + + async function onDelete() { + setIsDeleting(true); + + try { + // 여러 개 삭제 시 병렬 처리 + const deletePromises = attachments.map(async (attachment) => { + const response = await fetch(`/api/rfq-attachments/${attachment.id}`, { + method: "DELETE", + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "삭제 실패"); + } + + return response.json(); + }); + + const results = await Promise.allSettled(deletePromises); + + const failures = results.filter(r => r.status === 'rejected'); + if (failures.length > 0) { + const firstError = failures[0]; + if (firstError.status === 'rejected') { + toast.error(`일부 파일 삭제 실패: ${firstError.reason}`); + } + return; + } + + onOpenChange(false); + toast.success(`${attachments.length}개 파일이 삭제되었습니다`); + onSuccess?.(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "파일 삭제 중 오류가 발생했습니다"); + } finally { + setIsDeleting(false); + } + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle>파일을 삭제하시겠습니까?</DialogTitle> + <DialogDescription> + 이 작업은 되돌릴 수 없습니다. 선택한{" "} + <span className="font-medium">{attachments.length}개</span>의 파일이 + 영구적으로 삭제됩니다. + {attachments[0]?.originalFileName && ( + <div className="mt-2 p-2 bg-muted rounded text-sm"> + {attachments[0].originalFileName} + {attachments[0].serialNo && ( + <span className="text-muted-foreground"> ({attachments[0].serialNo})</span> + )} + </div> + )} + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline" disabled={isDeleting}> + 취소 + </Button> + </DialogClose> + <Button + variant="destructive" + onClick={onDelete} + disabled={isDeleting} + > + {isDeleting && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 삭제 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfq-last/attachment/rfq-attachments-table.tsx b/lib/rfq-last/attachment/rfq-attachments-table.tsx new file mode 100644 index 00000000..a66e12a2 --- /dev/null +++ b/lib/rfq-last/attachment/rfq-attachments-table.tsx @@ -0,0 +1,539 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Download, + FileText, + Upload, + RefreshCw, + Eye, + Trash2, + History, + Plus, + File, + FileImage, + FileSpreadsheet, + FileCode +} from "lucide-react"; +import { format, formatDistanceToNow } from "date-fns"; +import { ko } from "date-fns/locale"; +import { type ColumnDef } from "@tanstack/react-table"; +import { Checkbox } from "@/components/ui/checkbox"; +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; +import { useDataTable } from "@/hooks/use-data-table"; +import { DataTable } from "@/components/data-table/data-table"; +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table"; +import { cn } from "@/lib/utils"; +import { getRfqLastAttachments } from "@/lib/rfq-last/service"; +import { downloadFile } from "@/lib/file-download"; +import { DeleteAttachmentsDialog } from "./delete-attachments-dialog"; +import { AddAttachmentDialog } from "./add-attachment-dialog"; +import { UpdateRevisionDialog } from "./update-revision-dialog"; +import { useQueryState ,parseAsStringEnum} from "nuqs"; + +// 타입 정의 +interface RfqAttachment { + id: number; + attachmentType: "설계" | "구매"; + serialNo: string | null; + rfqId: number; + currentRevision: string | null; + latestRevisionId: number | null; + description: string | null; + createdBy: number; + createdAt: Date; + updatedAt: Date; + fileName: string | null; + originalFileName: string | null; + filePath: string | null; + fileSize: number | null; + fileType: string | null; + revisionComment: string | null; + createdByName: string | null; +} + +interface RfqAttachmentsTableProps { + rfqId: number; + initialDesignData: Awaited<ReturnType<typeof getRfqLastAttachments>>; + initialPurchaseData: Awaited<ReturnType<typeof getRfqLastAttachments>>; + className?: string; +} + +// 파일 타입별 아이콘 반환 +const getFileIcon = (fileType: string | null) => { + if (!fileType) return <File className="h-4 w-4" />; + + const type = fileType.toLowerCase(); + if (type.includes('image') || ['jpg', 'jpeg', 'png', 'gif'].includes(type)) { + return <FileImage className="h-4 w-4 text-blue-500" />; + } + if (type.includes('excel') || type.includes('spreadsheet') || ['xls', 'xlsx'].includes(type)) { + return <FileSpreadsheet className="h-4 w-4 text-green-500" />; + } + if (type.includes('pdf')) { + return <FileText className="h-4 w-4 text-red-500" />; + } + if (type.includes('code') || ['js', 'ts', 'tsx', 'jsx', 'html', 'css'].includes(type)) { + return <FileCode className="h-4 w-4 text-purple-500" />; + } + return <File className="h-4 w-4 text-gray-500" />; +}; + +// 파일 크기 포맷팅 +const formatFileSize = (bytes: number | null) => { + if (!bytes) return "-"; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`; +}; + +export function RfqAttachmentsTable({ + rfqId, + initialDesignData, + initialPurchaseData, + className +}: RfqAttachmentsTableProps) { + const router = useRouter(); + const [activeTab, setActiveTab] = useQueryState( + 'tab', + parseAsStringEnum(['설계', '구매']) + .withDefault('설계') + .withOptions({ shallow: false }) + ); + + const [designData] = React.useState(initialDesignData); + const [purchaseData] = React.useState(initialPurchaseData); + const [selectedAttachment, setSelectedAttachment] = React.useState<RfqAttachment | null>(null); + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); + const [updateRevisionDialogOpen, setUpdateRevisionDialogOpen] = React.useState(false); + const [isRefreshing, setIsRefreshing] = React.useState(false); + + // 새로고침 (router.refresh 사용) + const handleRefresh = React.useCallback(() => { + setIsRefreshing(true); + router.refresh(); + setTimeout(() => setIsRefreshing(false), 1000); + }, [router]); + + // 액션 처리 + const handleAction = React.useCallback(async (action: DataTableRowAction<RfqAttachment>) => { + const attachment = action.row.original; + + switch (action.type) { + case "download": + if (attachment.filePath && attachment.originalFileName) { + await downloadFile(attachment.filePath, attachment.originalFileName, { + action: 'download', + showToast: true + }); + } + break; + + case "preview": + if (attachment.filePath && attachment.originalFileName) { + await downloadFile(attachment.filePath, attachment.originalFileName, { + action: 'preview', + showToast: true + }); + } + break; + + case "history": + // 리비전 이력 보기 - 별도 구현 필요 + console.log("History:", attachment); + break; + + case "update": + setSelectedAttachment(attachment); + setUpdateRevisionDialogOpen(true); + break; + + case "delete": + setSelectedAttachment(attachment); + setDeleteDialogOpen(true); + break; + } + }, []); + + // 컬럼 정의 + const getAttachmentColumns = React.useCallback(( + onAction: (action: DataTableRowAction<RfqAttachment>) => void + ): ColumnDef<RfqAttachment>[] => [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} + onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(v) => row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "serialNo", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="일련번호" />, + cell: ({ row }) => ( + <span className="font-mono text-sm">{row.original.serialNo || "-"}</span> + ), + size: 100, + }, + { + accessorKey: "originalFileName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="파일명" />, + cell: ({ row }) => { + const file = row.original; + return ( + <div className="flex items-center gap-2"> + {getFileIcon(file.fileType)} + <div className="flex flex-col"> + <span className="text-sm font-medium truncate max-w-[250px]" title={file.originalFileName || ""}> + {file.originalFileName || file.fileName || "-"} + </span> + {file.fileName && file.fileName !== file.originalFileName && ( + <span className="text-xs text-muted-foreground truncate max-w-[250px]"> + ({file.fileName}) + </span> + )} + </div> + </div> + ); + }, + size: 300, + }, + { + accessorKey: "description", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="설명" />, + cell: ({ row }) => ( + <div className="max-w-[200px] truncate" title={row.original.description || ""}> + {row.original.description || "-"} + </div> + ), + size: 200, + }, + { + accessorKey: "currentRevision", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="리비전" />, + cell: ({ row }) => { + const revision = row.original.currentRevision; + return revision ? ( + <Badge variant="outline" className="font-mono"> + Rev. {revision} + </Badge> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + accessorKey: "fileSize", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="크기" />, + cell: ({ row }) => ( + <span className="text-sm text-muted-foreground"> + {formatFileSize(row.original.fileSize)} + </span> + ), + size: 80, + }, + { + accessorKey: "fileType", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="타입" />, + cell: ({ row }) => { + const type = row.original.fileType; + return type ? ( + <Badge variant="secondary" className="text-xs"> + {type.toUpperCase()} + </Badge> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 80, + }, + { + accessorKey: "createdByName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="업로드자" />, + cell: ({ row }) => row.original.createdByName || "-", + size: 100, + }, + { + accessorKey: "createdAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="업로드일" />, + cell: ({ row }) => { + const date = row.original.createdAt; + return date ? ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <span className="text-sm cursor-help"> + {format(new Date(date), "MM-dd HH:mm")} + </span> + </TooltipTrigger> + <TooltipContent> + <p>{format(new Date(date), "yyyy년 MM월 dd일 HH시 mm분")}</p> + <p className="text-xs text-muted-foreground"> + ({formatDistanceToNow(new Date(date), { addSuffix: true, locale: ko })}) + </p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ) : ( + "-" + ); + }, + size: 100, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="수정일" />, + cell: ({ row }) => { + const date = row.original.updatedAt; + return date ? format(new Date(date), "MM-dd HH:mm") : "-"; + }, + size: 100, + }, + { + id: "actions", + header: "작업", + cell: ({ row }) => { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <span className="sr-only">메뉴 열기</span> + <svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M3.625 7.5C3.625 8.12132 3.12132 8.625 2.5 8.625C1.87868 8.625 1.375 8.12132 1.375 7.5C1.375 6.87868 1.87868 6.375 2.5 6.375C3.12132 6.375 3.625 6.87868 3.625 7.5ZM8.625 7.5C8.625 8.12132 8.12132 8.625 7.5 8.625C6.87868 8.625 6.375 8.12132 6.375 7.5C6.375 6.87868 6.87868 6.375 7.5 6.375C8.12132 6.375 8.625 6.87868 8.625 7.5ZM12.5 8.625C13.1213 8.625 13.625 8.12132 13.625 7.5C13.625 6.87868 13.1213 6.375 12.5 6.375C11.8787 6.375 11.375 6.87868 11.375 7.5C11.375 8.12132 11.8787 8.625 12.5 8.625Z" fill="currentColor" fillRule="evenodd" clipRule="evenodd"></path> + </svg> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={() => onAction({ row, type: "download" })}> + <Download className="mr-2 h-4 w-4" /> + 다운로드 + </DropdownMenuItem> + <DropdownMenuItem onClick={() => onAction({ row, type: "preview" })}> + <Eye className="mr-2 h-4 w-4" /> + 미리보기 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={() => onAction({ row, type: "history" })}> + <History className="mr-2 h-4 w-4" /> + 리비전 이력 + </DropdownMenuItem> + <DropdownMenuItem onClick={() => onAction({ row, type: "update" })}> + <Upload className="mr-2 h-4 w-4" /> + 새 버전 업로드 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => onAction({ row, type: "delete" })} + className="text-red-600" + > + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); + }, + size: 60, + }, + ], []); + + const columns = React.useMemo(() => getAttachmentColumns(handleAction), [getAttachmentColumns, handleAction]); + + const filterFields: DataTableFilterField<RfqAttachment>[] = [ + { id: "serialNo", label: "일련번호" }, + { id: "originalFileName", label: "파일명" }, + { id: "description", label: "설명" }, + { id: "createdByName", label: "업로드자" }, + ]; + + const advancedFilterFields: DataTableAdvancedFilterField<RfqAttachment>[] = [ + { id: "serialNo", label: "일련번호", type: "text" }, + { id: "originalFileName", label: "파일명", type: "text" }, + { id: "description", label: "설명", type: "text" }, + { id: "currentRevision", label: "리비전", type: "text" }, + { + id: "fileType", + label: "파일 타입", + type: "select", + options: [ + { label: "PDF", value: "pdf" }, + { label: "Excel", value: "xlsx" }, + { label: "Word", value: "docx" }, + { label: "이미지", value: "image" }, + { label: "기타", value: "other" }, + ] + }, + { id: "createdByName", label: "업로드자", type: "text" }, + { id: "createdAt", label: "업로드일", type: "date" }, + { id: "updatedAt", label: "수정일", type: "date" }, + ]; + + const { table: designTable } = useDataTable({ + data: designData.data, + columns, + pageCount: designData.pageCount, + rowCount: designData.data.length, + filterFields, + enableAdvancedFilter: true, + // 설계 탭용 파라미터 prefix + paramPrefix: 'design_', + initialState: { + sorting: [{ id: "createdAt", desc: true }], + }, + getRowId: (row) => String(row.id), + shallow: false, + clearOnDefault: true, + }); + + const { table: purchaseTable } = useDataTable({ + data: purchaseData.data, + columns, + pageCount: purchaseData.pageCount, + rowCount: purchaseData.data.length, + filterFields, + enableAdvancedFilter: true, + // 구매 탭용 파라미터 prefix + paramPrefix: 'purchase_', + initialState: { + sorting: [{ id: "createdAt", desc: true }], + }, + getRowId: (row) => String(row.id), + shallow: false, + clearOnDefault: true, + }); + + + React.useEffect(() => { + router.refresh(); + }, [activeTab]); + + return ( + <div className={cn("w-full space-y-4", className)}> + <Tabs value={activeTab} onValueChange={setActiveTab}> + <div className="flex items-center justify-between mb-4"> + <TabsList> + <TabsTrigger value="설계"> + 설계 첨부파일 + <Badge variant="secondary" className="ml-2"> + {designData.data.length} + </Badge> + </TabsTrigger> + <TabsTrigger value="구매"> + 구매 첨부파일 + <Badge variant="secondary" className="ml-2"> + {purchaseData.data.length} + </Badge> + </TabsTrigger> + </TabsList> + + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={handleRefresh} + disabled={isRefreshing} + > + <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} /> + 새로고침 + </Button> + + {/* 구매 탭에서만 파일 업로드 버튼 표시 */} + {activeTab === "구매" && ( + <AddAttachmentDialog + rfqId={rfqId} + attachmentType="구매" + onSuccess={handleRefresh} + /> + )} + </div> + </div> + + <TabsContent value="설계" className="mt-0"> + <Card> + <CardContent className="p-0"> + <DataTable table={designTable}> + <DataTableAdvancedToolbar + table={designTable} + filterFields={advancedFilterFields} + shallow={false} + /> + </DataTable> + </CardContent> + </Card> + </TabsContent> + + <TabsContent value="구매" className="mt-0"> + <Card> + <CardContent className="p-0"> + <DataTable table={purchaseTable}> + <DataTableAdvancedToolbar + table={purchaseTable} + filterFields={advancedFilterFields} + shallow={false} + /> + </DataTable> + </CardContent> + </Card> + </TabsContent> + </Tabs> + + {/* 삭제 다이얼로그 */} + {selectedAttachment && ( + <DeleteAttachmentsDialog + open={deleteDialogOpen} + onOpenChange={setDeleteDialogOpen} + attachments={[selectedAttachment]} + onSuccess={handleRefresh} + /> + )} + + {/* 새 버전 업로드 다이얼로그 */} + {selectedAttachment && ( + <UpdateRevisionDialog + open={updateRevisionDialogOpen} + onOpenChange={setUpdateRevisionDialogOpen} + attachment={selectedAttachment} + onSuccess={handleRefresh} + /> + )} + </div> + ); +}
\ No newline at end of file diff --git a/lib/rfq-last/attachment/update-revision-dialog.tsx b/lib/rfq-last/attachment/update-revision-dialog.tsx new file mode 100644 index 00000000..ce31da64 --- /dev/null +++ b/lib/rfq-last/attachment/update-revision-dialog.tsx @@ -0,0 +1,216 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { Upload } from "lucide-react" +import { toast } from "sonner" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} 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 { Input } from "@/components/ui/input" +import { Progress } from "@/components/ui/progress" + +const updateRevisionSchema = z.object({ + revisionComment: z.string().min(1, "리비전 설명을 입력해주세요"), + file: z.instanceof(File, { + message: "새 버전 파일을 선택해주세요", + }).refine((file) => file.size <= 100 * 1024 * 1024, { + message: "파일 크기는 100MB를 초과할 수 없습니다.", + }), +}) + +type UpdateRevisionFormData = z.infer<typeof updateRevisionSchema> + +interface UpdateRevisionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + attachment: { + id: number; + originalFileName?: string | null; + currentRevision?: string | null; + }; + onSuccess?: () => void; +} + +export function UpdateRevisionDialog({ + open, + onOpenChange, + attachment, + onSuccess, +}: UpdateRevisionDialogProps) { + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [uploadProgress, setUploadProgress] = React.useState(0) + + const form = useForm<UpdateRevisionFormData>({ + resolver: zodResolver(updateRevisionSchema), + defaultValues: { + revisionComment: "", + }, + }) + + const onSubmit = async (data: UpdateRevisionFormData) => { + setIsSubmitting(true); + setUploadProgress(0); + + try { + const formData = new FormData(); + formData.append("attachmentId", attachment.id.toString()); + formData.append("revisionComment", data.revisionComment); + formData.append("file", data.file); + + // 진행률 시뮬레이션 + setUploadProgress(30); + + const response = await fetch("/api/rfq-attachments/revision", { + method: "POST", + body: formData, + }); + + setUploadProgress(70); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "리비전 업데이트 실패"); + } + + const result = await response.json(); + setUploadProgress(100); + + if (result.success) { + toast.success(result.message); + form.reset(); + onOpenChange(false); + onSuccess?.(); + } else { + toast.error(result.message); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "파일 업로드 중 오류가 발생했습니다"); + } finally { + setIsSubmitting(false); + setUploadProgress(0); + } + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>새 버전 업로드</DialogTitle> + <DialogDescription> + {attachment.originalFileName && ( + <div className="mt-2"> + <div className="text-sm font-medium">현재 파일:</div> + <div className="text-sm text-muted-foreground"> + {attachment.originalFileName} + {attachment.currentRevision && ` (Rev. ${attachment.currentRevision})`} + </div> + </div> + )} + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="file" + render={({ field: { onChange, value, ...field } }) => ( + <FormItem> + <FormLabel>새 버전 파일</FormLabel> + <FormControl> + <Input + type="file" + accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip,.rar,.dwg,.dxf" + onChange={(e) => { + const file = e.target.files?.[0]; + if (file) { + onChange(file); + // 파일 크기 검증 + if (file.size > 100 * 1024 * 1024) { + form.setError("file", { + message: "파일 크기는 100MB를 초과할 수 없습니다." + }); + } + } + }} + disabled={isSubmitting} + {...field} + /> + </FormControl> + {value && ( + <p className="text-sm text-muted-foreground mt-1"> + 선택된 파일: {value.name} ({(value.size / 1024 / 1024).toFixed(2)}MB) + </p> + )} + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="revisionComment" + 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> + )} + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting ? "업로드 중..." : "업로드"} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +} |
