"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 { toast } from "sonner" 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 { formatDate } from "@/lib/utils" import { processTechSalesRfqAttachments } from "@/lib/techsales-rfq/service" import { useSession } from "next-auth/react" const MAX_FILE_SIZE = 6e8 // 600MB /** 기존 첨부 파일 정보 (techSalesAttachments 테이블 구조) */ export interface ExistingTechSalesAttachment { id: number techSalesRfqId: number fileName: string originalFileName: string filePath: string fileSize?: number fileType?: string attachmentType: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT" description?: string createdBy: number createdAt: Date } /** 새로 업로드할 파일 */ const newUploadSchema = z.object({ fileObj: z.any().optional(), // 실제 File attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]).default("RFQ_COMMON"), description: z.string().optional(), }) /** 기존 첨부 (react-hook-form에서 관리) */ const existingAttachSchema = z.object({ id: z.number(), techSalesRfqId: z.number(), fileName: z.string(), originalFileName: z.string(), filePath: z.string(), fileSize: z.number().optional(), fileType: z.string().optional(), attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]), description: z.string().optional(), createdBy: z.number(), createdAt: z.custom(), }) /** RHF 폼 전체 스키마 */ const attachmentsFormSchema = z.object({ techSalesRfqId: z.number().int(), existing: z.array(existingAttachSchema), newUploads: z.array(newUploadSchema), }) type AttachmentsFormValues = z.infer // TechSalesRfq 타입 (간단 버전) interface TechSalesRfq { id: number rfqCode: string | null status: string // 필요한 다른 필드들... } interface TechSalesRfqAttachmentsSheetProps extends React.ComponentPropsWithRef { defaultAttachments?: ExistingTechSalesAttachment[] rfq: TechSalesRfq | null /** 첨부파일 타입 */ attachmentType?: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT" /** 읽기 전용 모드 (벤더용) */ readOnly?: boolean /** 업로드/삭제 후 상위 테이블에 attachmentCount 등을 업데이트하기 위한 콜백 */ // onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void } export function TechSalesRfqAttachmentsSheet({ defaultAttachments = [], // onAttachmentsUpdated, rfq, attachmentType = "RFQ_COMMON", readOnly = false, ...props }: TechSalesRfqAttachmentsSheetProps) { const [isPending, setIsPending] = React.useState(false) const session = useSession() // 파일 다운로드 핸들러 const handleDownloadClick = React.useCallback(async (filePath: string, fileName: string) => { try { const { downloadFile } = await import('@/lib/file-download') await downloadFile(filePath, fileName, { showToast: true, onError: (error) => { console.error('다운로드 오류:', error) toast.error(error) }, onSuccess: (fileName, fileSize) => { console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`) } }) } catch (error) { console.error('다운로드 오류:', error) toast.error('파일 다운로드 중 오류가 발생했습니다.') } }, []) // 첨부파일 타입별 제목과 설명 설정 const attachmentConfig = React.useMemo(() => { switch (attachmentType) { case "TBE_RESULT": return { title: "TBE 결과 첨부파일", description: "기술 평가(TBE) 결과 파일을 관리합니다.", fileTypeLabel: "TBE 결과", canEdit: !readOnly } case "CBE_RESULT": return { title: "CBE 결과 첨부파일", description: "상업성 평가(CBE) 결과 파일을 관리합니다.", fileTypeLabel: "CBE 결과", canEdit: !readOnly } default: // RFQ_COMMON return { title: "RFQ 첨부파일", description: readOnly ? "RFQ 공통 첨부파일을 조회합니다." : "RFQ 공통 첨부파일을 관리합니다.", fileTypeLabel: "공통", canEdit: !readOnly } } }, [attachmentType, readOnly]) // // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false) // const isEditable = React.useMemo(() => { // if (!rfq) return false // return attachmentConfig.canEdit // }, [rfq, attachmentConfig.canEdit]) const form = useForm({ resolver: zodResolver(attachmentsFormSchema), defaultValues: { techSalesRfqId: rfq?.id || 0, existing: defaultAttachments.map(att => ({ id: att.id, techSalesRfqId: att.techSalesRfqId, fileName: att.fileName, originalFileName: att.originalFileName, filePath: att.filePath, fileSize: att.fileSize || undefined, fileType: att.fileType || undefined, attachmentType: att.attachmentType, description: att.description || undefined, createdBy: att.createdBy, createdAt: att.createdAt, })), newUploads: [], }, }) // useFieldArray for existing and new uploads const { fields: existingFields, remove: removeExisting, } = useFieldArray({ control: form.control, name: "existing", }) const { fields: newUploadFields, append: appendNewUpload, remove: removeNewUpload, } = useFieldArray({ control: form.control, name: "newUploads", }) // Reset form when defaultAttachments changes React.useEffect(() => { if (defaultAttachments) { form.reset({ techSalesRfqId: rfq?.id || 0, existing: defaultAttachments.map(att => ({ id: att.id, techSalesRfqId: att.techSalesRfqId, fileName: att.fileName, originalFileName: att.originalFileName, filePath: att.filePath, fileSize: att.fileSize || undefined, fileType: att.fileType || undefined, attachmentType: att.attachmentType, description: att.description || undefined, createdBy: att.createdBy, createdAt: att.createdAt, })), newUploads: [], }) } }, [defaultAttachments, rfq?.id, form]) // Handle dropzone accept const handleDropAccepted = React.useCallback((acceptedFiles: File[]) => { acceptedFiles.forEach((file) => { appendNewUpload({ fileObj: file, attachmentType: "RFQ_COMMON", description: "", }) }) }, [appendNewUpload]) // Handle dropzone reject const handleDropRejected = React.useCallback(() => { toast.error("파일 크기가 너무 크거나 지원하지 않는 파일 형식입니다.") }, []) // Handle remove existing attachment const handleRemoveExisting = React.useCallback((index: number) => { removeExisting(index) }, [removeExisting]) // Handle form submission const onSubmit = async (data: AttachmentsFormValues) => { if (!rfq) { toast.error("RFQ 정보를 찾을 수 없습니다.") return } setIsPending(true) try { // 삭제할 첨부파일 ID 수집 const deleteAttachmentIds = defaultAttachments .filter((original) => !data.existing.find(existing => existing.id === original.id)) .map(attachment => attachment.id) // 새 파일 정보 수집 const newFiles = data.newUploads .filter(upload => upload.fileObj) .map(upload => ({ file: upload.fileObj as File, attachmentType: attachmentType, description: upload.description, })) // 실제 API 호출 const result = await processTechSalesRfqAttachments({ techSalesRfqId: rfq.id, newFiles, deleteAttachmentIds, createdBy: parseInt(session.data?.user.id || "0"), }) if (result.error) { toast.error(result.error) return } // 성공 메시지 표시 (업로드된 파일 수 포함) const uploadedCount = newFiles.length const deletedCount = deleteAttachmentIds.length let successMessage = "첨부파일이 저장되었습니다." if (uploadedCount > 0 && deletedCount > 0) { successMessage = `${uploadedCount}개 파일 업로드, ${deletedCount}개 파일 삭제 완료` } else if (uploadedCount > 0) { successMessage = `${uploadedCount}개 파일이 업로드되었습니다.` } else if (deletedCount > 0) { successMessage = `${deletedCount}개 파일이 삭제되었습니다.` } toast.success(successMessage) // 다이얼로그 자동 닫기 props.onOpenChange?.(false) // // 즉시 첨부파일 목록 새로고침 // const refreshResult = await getTechSalesRfqAttachments(rfq.id) // if (refreshResult.error) { // console.error("첨부파일 목록 새로고침 실패:", refreshResult.error) // toast.warning("첨부파일 목록 새로고침에 실패했습니다. 시트를 다시 열어주세요.") // } else { // // 새로운 첨부파일 목록으로 폼 업데이트 // const refreshedAttachments = refreshResult.data.map(att => ({ // id: att.id, // techSalesRfqId: att.techSalesRfqId || rfq.id, // fileName: att.fileName, // originalFileName: att.originalFileName, // filePath: att.filePath, // fileSize: att.fileSize, // fileType: att.fileType, // attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT", // description: att.description, // createdBy: att.createdBy, // createdAt: att.createdAt, // })) // // 폼을 새로운 데이터로 리셋 (새 업로드 목록은 비움) // form.reset({ // techSalesRfqId: rfq.id, // existing: refreshedAttachments.map(att => ({ // ...att, // fileSize: att.fileSize || undefined, // fileType: att.fileType || undefined, // description: att.description || undefined, // })), // newUploads: [], // }) // // 즉시 UI 업데이트를 위한 추가 피드백 // if (uploadedCount > 0) { // toast.success("첨부파일 목록이 업데이트되었습니다.", { duration: 2000 }) // } // } // // 콜백으로 상위 컴포넌트에 변경사항 알림 // const newAttachmentCount = refreshResult.error ? // (data.existing.length + newFiles.length - deleteAttachmentIds.length) : // refreshResult.data.length // onAttachmentsUpdated?.(rfq.id, newAttachmentCount) } catch (error) { console.error("첨부파일 저장 오류:", error) toast.error("첨부파일 저장 중 오류가 발생했습니다.") } finally { setIsPending(false) } } return ( {attachmentConfig.title}
RFQ: {rfq?.rfqCode || "N/A"}
{attachmentConfig.description}
{!attachmentConfig.canEdit && (
현재 상태에서는 편집할 수 없습니다
)}
{/* 1) Existing attachments */}
기존 첨부파일 ({existingFields.length}개)
{existingFields.map((field, index) => { const typeLabel = attachmentConfig.fileTypeLabel const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음" const dateText = field.createdAt ? formatDate(field.createdAt, "KR") : "" return (

{field.originalFileName || field.fileName}

{typeLabel}

{sizeText} • {dateText}

{field.description && (

{field.description}

)}
{/* Download button */} {field.filePath && ( )} {/* Remove button - 편집 가능할 때만 표시 */} {attachmentConfig.canEdit && ( )}
) })}
{/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */} {attachmentConfig.canEdit ? ( <> {({ maxSize }) => ( ( 새 파일 업로드
파일을 드래그하거나 클릭하세요 최대 크기: {maxSize ? prettyBytes(maxSize) : "600MB"}
파일을 여러 개 선택할 수 있습니다.
)} /> )}
{/* newUpload fields -> FileList */} {newUploadFields.length > 0 && (
새 파일 ({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)}> 제거 ) })}
)} ) : (

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

)} {attachmentConfig.canEdit && ( )}
) }