diff options
Diffstat (limited to 'lib/rfqs-tech/table/attachment-rfq-sheet.tsx')
| -rw-r--r-- | lib/rfqs-tech/table/attachment-rfq-sheet.tsx | 426 |
1 files changed, 426 insertions, 0 deletions
diff --git a/lib/rfqs-tech/table/attachment-rfq-sheet.tsx b/lib/rfqs-tech/table/attachment-rfq-sheet.tsx new file mode 100644 index 00000000..d06fae09 --- /dev/null +++ b/lib/rfqs-tech/table/attachment-rfq-sheet.tsx @@ -0,0 +1,426 @@ +"use client" + +import * as React from "react" +import { z } from "zod" +import { useForm, useFieldArray } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" + +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter, + SheetClose, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription +} from "@/components/ui/form" +import { Loader, Download, X, Eye, AlertCircle } from "lucide-react" +import { useToast } from "@/hooks/use-toast" +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 { processRfqAttachments } from "../service" +import { format } from "path" +import { formatDate } from "@/lib/utils" +import { RfqWithItemCount } from "@/db/schema/rfq" + +const MAX_FILE_SIZE = 6e8 // 600MB + +/** 기존 첨부 파일 정보 */ +interface ExistingAttachment { + id: number + fileName: string + filePath: string + createdAt?: Date // or Date + vendorId?: number | null + size?: number +} + +/** 새로 업로드할 파일 */ +const newUploadSchema = z.object({ + fileObj: z.any().optional(), // 실제 File +}) + +/** 기존 첨부 (react-hook-form에서 관리) */ +const existingAttachSchema = z.object({ + id: z.number(), + fileName: z.string(), + filePath: z.string(), + vendorId: z.number().nullable().optional(), + createdAt: z.custom<Date>().optional(), // or use z.any().optional() + size: z.number().optional(), +}) + +/** RHF 폼 전체 스키마 */ +const attachmentsFormSchema = z.object({ + rfqId: z.number().int(), + existing: z.array(existingAttachSchema), + newUploads: z.array(newUploadSchema), +}) + +type AttachmentsFormValues = z.infer<typeof attachmentsFormSchema> + +interface RfqAttachmentsSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + defaultAttachments?: ExistingAttachment[] + rfq: RfqWithItemCount | null + /** 업로드/삭제 후 상위 테이블에 itemCount 등을 업데이트하기 위한 콜백 */ + onAttachmentsUpdated?: (rfqId: number, newItemCount: number) => void +} + +/** + * RfqAttachmentsSheet: + * - 기존 첨부 목록 (다운로드 + 삭제) + * - 새 파일 Dropzone + * - Save 시 processRfqAttachments(server action) + */ +export function RfqAttachmentsSheet({ + defaultAttachments = [], + onAttachmentsUpdated, + rfq, + ...props +}: RfqAttachmentsSheetProps) { + const { toast } = useToast() + const [isPending, startUpdate] = React.useTransition() + const rfqId = rfq?.rfqId ?? 0; + + // 편집 가능 여부 확인 - DRAFT 상태일 때만 편집 가능 + const isEditable = rfq?.status === "DRAFT"; + + // React Hook Form + const form = useForm<AttachmentsFormValues>({ + resolver: zodResolver(attachmentsFormSchema), + defaultValues: { + rfqId, + existing: [], + newUploads: [], + }, + }) + + const { reset, control, handleSubmit } = form + + // defaultAttachments가 바뀔 때마다, RHF 상태를 reset + React.useEffect(() => { + reset({ + rfqId, + existing: defaultAttachments.map((att) => ({ + ...att, + vendorId: att.vendorId ?? null, + size: att.size ?? undefined, + })), + newUploads: [], + }) + }, [rfqId, defaultAttachments, reset]) + + // Field Arrays + const { + fields: existingFields, + remove: removeExisting, + } = useFieldArray({ control, name: "existing" }) + + const { + fields: newUploadFields, + append: appendNewUpload, + remove: removeNewUpload, + } = useFieldArray({ control, name: "newUploads" }) + + // 기존 첨부 항목 중 삭제된 것 찾기 + function findRemovedExistingIds(data: AttachmentsFormValues): number[] { + const finalIds = data.existing.map((att) => att.id) + const originalIds = defaultAttachments.map((att) => att.id) + return originalIds.filter((id) => !finalIds.includes(id)) + } + + async function onSubmit(data: AttachmentsFormValues) { + // 편집 불가능한 상태에서는 제출 방지 + if (!isEditable) return; + + startUpdate(async () => { + try { + const removedExistingIds = findRemovedExistingIds(data) + const newFiles = data.newUploads + .map((it) => it.fileObj) + .filter((f): f is File => !!f) + + // 서버 액션 + const res = await processRfqAttachments({ + rfqId, + removedExistingIds, + newFiles, + vendorId: null, // vendor ID if needed + }) + + if (!res.ok) throw new Error(res.error ?? "Unknown error") + + const newCount = res.updatedItemCount ?? 0 + + toast({ + variant: "default", + title: "Success", + description: "File(s) updated", + }) + + // 상위 테이블 등에 itemCount 업데이트 + onAttachmentsUpdated?.(rfqId, newCount) + + // 모달 닫기 + props.onOpenChange?.(false) + } catch (err) { + toast({ + variant: "destructive", + title: "Error", + description: String(err), + }) + } + }) + } + + /** 기존 첨부 - X 버튼 */ + function handleRemoveExisting(idx: number) { + // 편집 불가능한 상태에서는 삭제 방지 + if (!isEditable) return; + removeExisting(idx) + } + + /** 드롭존에서 파일 받기 */ + function handleDropAccepted(acceptedFiles: File[]) { + // 편집 불가능한 상태에서는 파일 추가 방지 + if (!isEditable) return; + const mapped = acceptedFiles.map((file) => ({ fileObj: file })) + appendNewUpload(mapped) + } + + /** 드롭존에서 파일 거부(에러) */ + function handleDropRejected(fileRejections: any[]) { + // 편집 불가능한 상태에서는 무시 + if (!isEditable) return; + + fileRejections.forEach((rej) => { + toast({ + variant: "destructive", + title: "File Error", + description: rej.file.name + " not accepted", + }) + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-sm"> + <SheetHeader> + <SheetTitle className="flex items-center gap-2"> + {isEditable ? "Manage Attachments" : "View Attachments"} + {rfq?.status && ( + <Badge + variant={rfq.status === "DRAFT" ? "outline" : "secondary"} + className="ml-1" + > + {rfq.status} + </Badge> + )} + </SheetTitle> + <SheetDescription> + {`RFQ ${rfq?.rfqCode} - `} + {isEditable ? '파일 첨부/삭제' : '첨부 파일 보기'} + {!isEditable && ( + <div className="mt-1 text-xs flex items-center gap-1 text-amber-600"> + <AlertCircle className="h-3 w-3" /> + <span>드래프트 상태가 아닌 RFQ는 첨부파일을 수정할 수 없습니다.</span> + </div> + )} + </SheetDescription> + </SheetHeader> + + <Form {...form}> + <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4"> + {/* 1) 기존 첨부 목록 */} + <div className="space-y-2"> + <p className="font-semibold text-sm">Existing Attachments</p> + {existingFields.length === 0 && ( + <p className="text-sm text-muted-foreground">No existing attachments</p> + )} + {existingFields.map((field, index) => { + const vendorLabel = field.vendorId ? "(Vendor)" : "(Internal)" + return ( + <div + key={field.id} + className="flex items-center justify-between rounded border p-2" + > + <div className="flex flex-col text-sm"> + <span className="font-medium"> + {field.fileName} {vendorLabel} + </span> + {field.size && ( + <span className="text-xs text-muted-foreground"> + {Math.round(field.size / 1024)} KB + </span> + )} + {field.createdAt && ( + <span className="text-xs text-muted-foreground"> + Created at {formatDate(field.createdAt)} + </span> + )} + </div> + <div className="flex items-center gap-2"> + {/* 1) Download button (if filePath) */} + {field.filePath && ( + <a + href={`/api/rfq-download?path=${encodeURIComponent(field.filePath)}`} + download={field.fileName} + className="text-sm" + > + <Button variant="ghost" size="icon" type="button"> + <Download className="h-4 w-4" /> + </Button> + </a> + )} + {/* 2) Remove button - 편집 가능할 때만 표시 */} + {isEditable && ( + <Button + type="button" + variant="ghost" + size="icon" + onClick={() => handleRemoveExisting(index)} + > + <X className="h-4 w-4" /> + </Button> + )} + </div> + </div> + ) + })} + </div> + + {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */} + {isEditable ? ( + <> + <Dropzone + maxSize={MAX_FILE_SIZE} + onDropAccepted={handleDropAccepted} + onDropRejected={handleDropRejected} + > + {({ maxSize }) => ( + <FormField + control={control} + name="newUploads" // not actually used for storing each file detail + render={() => ( + <FormItem> + <FormLabel>Drop Files Here</FormLabel> + <DropzoneZone className="flex justify-center"> + <FormControl> + <DropzoneInput /> + </FormControl> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>Drop to upload</DropzoneTitle> + <DropzoneDescription> + Max size: {maxSize ? prettyBytes(maxSize) : "??? MB"} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + <FormDescription>Alternatively, click browse.</FormDescription> + <FormMessage /> + </FormItem> + )} + /> + )} + </Dropzone> + + {/* newUpload fields -> FileList */} + {newUploadFields.length > 0 && ( + <div className="grid gap-4"> + <h6 className="font-semibold leading-none tracking-tight"> + {`Files (${newUploadFields.length})`} + </h6> + <FileList> + {newUploadFields.map((field, idx) => { + const fileObj = form.getValues(`newUploads.${idx}.fileObj`) + if (!fileObj) return null + + const fileName = fileObj.name + const fileSize = fileObj.size + return ( + <FileListItem key={field.id}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{fileName}</FileListName> + <FileListDescription> + {`${prettyBytes(fileSize)}`} + </FileListDescription> + </FileListInfo> + <FileListAction onClick={() => removeNewUpload(idx)}> + <X /> + <span className="sr-only">Remove</span> + </FileListAction> + </FileListHeader> + </FileListItem> + ) + })} + </FileList> + </div> + )} + </> + ) : ( + <div className="p-3 bg-muted rounded-md flex items-center justify-center"> + <div className="text-center text-sm text-muted-foreground"> + <Eye className="h-4 w-4 mx-auto mb-2" /> + <p>보기 모드에서는 파일 첨부를 할 수 없습니다.</p> + </div> + </div> + )} + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + {isEditable ? "Cancel" : "Close"} + </Button> + </SheetClose> + {isEditable && ( + <Button + type="submit" + disabled={isPending || (form.getValues().newUploads.length === 0 && defaultAttachments.length === form.getValues().existing.length)} + > + {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + Save + </Button> + )} + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file |
