"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, } from "@/components/ui/file-list" import prettyBytes from "pretty-bytes" import { processRfqAttachments } from "../service" import { formatDate } from "@/lib/utils" import { RfqType } from "../validations" import { RfqWithItemCount } from "@/db/schema/rfq" import { quickDownload } from "@/lib/file-download" import { type FileRejection } from "react-dropzone" 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().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 interface RfqAttachmentsSheetProps extends React.ComponentPropsWithRef { defaultAttachments?: ExistingAttachment[] rfqType?: RfqType rfq: RfqWithItemCount | null /** 업로드/삭제 후 상위 테이블에 itemCount 등을 업데이트하기 위한 콜백 */ onAttachmentsUpdated?: (rfqId: number, newItemCount: number) => void } /** * RfqAttachmentsSheet: * - 기존 첨부 목록 (다운로드 + 삭제) * - 새 파일 Dropzone * - Save 시 processRfqAttachments(server action) */ export function RfqAttachmentsSheet({ defaultAttachments = [], onAttachmentsUpdated, rfq, rfqType, ...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({ 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 rfqType }) 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: FileRejection[]) { // 편집 불가능한 상태에서는 무시 if (!isEditable) return; fileRejections.forEach((rej) => { toast({ variant: "destructive", title: "File Error", description: rej.file.name + " not accepted", }) }) } return ( {isEditable ? "Manage Attachments" : "View Attachments"} {rfq?.status && ( {rfq.status} )} {`RFQ ${rfq?.rfqCode} - `} {isEditable ? '파일 첨부/삭제' : '첨부 파일 보기'} {!isEditable && (
드래프트 상태가 아닌 RFQ는 첨부파일을 수정할 수 없습니다.
)}
{/* 1) 기존 첨부 목록 */}

Existing Attachments

{existingFields.length === 0 && (

No existing attachments

)} {existingFields.map((field, index) => { const vendorLabel = field.vendorId ? "(Vendor)" : "(Internal)" return (
{field.fileName} {vendorLabel} {field.size && ( {Math.round(field.size / 1024)} KB )} {field.createdAt && ( Created at {formatDate(field.createdAt, "KR")} )}
{/* 1) Download button (if filePath) */} {field.filePath && ( )} {/* 2) Remove button - 편집 가능할 때만 표시 */} {isEditable && ( )}
) })}
{/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */} {isEditable ? ( <> {({ maxSize }) => ( ( Drop Files Here
Drop to upload Max size: {maxSize ? prettyBytes(maxSize) : "??? MB"}
Alternatively, click browse.
)} /> )}
{/* newUpload fields -> FileList */} {newUploadFields.length > 0 && (
{`Files (${newUploadFields.length})`}
{newUploadFields.map((field, idx) => { const fileObj = form.getValues(`newUploads.${idx}.fileObj`) if (!fileObj) return null const fileName = fileObj.name const fileSize = fileObj.size return ( {fileName} {`${prettyBytes(fileSize)}`} removeNewUpload(idx)}> Remove ) })}
)} ) : (

보기 모드에서는 파일 첨부를 할 수 없습니다.

)} {isEditable && ( )}
) }