From e484964b1d78cedabbe182c789a8e4c9b53e29d3 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Thu, 29 May 2025 05:12:36 +0000 Subject: (김준회) 기술영업 조선 RFQ 파일첨부 및 채팅 기능 구현 / menuConfig 수정 (벤더 기술영업) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table/tech-sales-rfq-attachments-sheet.tsx | 540 +++++++++++++++++++++ 1 file changed, 540 insertions(+) create mode 100644 lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx (limited to 'lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx') diff --git a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx new file mode 100644 index 00000000..ecdf6d81 --- /dev/null +++ b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx @@ -0,0 +1,540 @@ +"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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" + +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, getTechSalesRfqAttachments } from "@/lib/techsales-rfq/service" + +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" | "VENDOR_SPECIFIC" + description?: string + createdBy: number + createdAt: Date +} + +/** 새로 업로드할 파일 */ +const newUploadSchema = z.object({ + fileObj: z.any().optional(), // 실제 File + attachmentType: z.enum(["RFQ_COMMON", "VENDOR_SPECIFIC"]).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", "VENDOR_SPECIFIC"]), + 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 + /** 업로드/삭제 후 상위 테이블에 attachmentCount 등을 업데이트하기 위한 콜백 */ + onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void + /** 강제 읽기 전용 모드 (파트너/벤더용) */ + readOnly?: boolean +} + +export function TechSalesRfqAttachmentsSheet({ + defaultAttachments = [], + onAttachmentsUpdated, + rfq, + readOnly = false, + ...props +}: TechSalesRfqAttachmentsSheetProps) { + const [isPending, setIsPending] = React.useState(false) + + // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false) + const isEditable = React.useMemo(() => { + if (!rfq || readOnly) return false + // RFQ Created, RFQ Vendor Assignned 상태에서만 편집 가능 + return ["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status) + }, [rfq, readOnly]) + + 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: upload.attachmentType, + description: upload.description, + })) + + // 실제 API 호출 + const result = await processTechSalesRfqAttachments({ + techSalesRfqId: rfq.id, + newFiles, + deleteAttachmentIds, + createdBy: 1, // TODO: 실제 사용자 ID로 변경 + }) + + 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) + + // 즉시 첨부파일 목록 새로고침 + 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" | "VENDOR_SPECIFIC", + 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 ( + + + + 첨부파일 관리 + + RFQ: {rfq?.rfqCode || "N/A"} + {!isEditable && ( +
+ + 현재 상태에서는 편집할 수 없습니다 +
+ )} +
+
+ +
+ + {/* 1) Existing attachments */} +
+
+ 기존 첨부파일 ({existingFields.length}개) +
+ {existingFields.map((field, index) => { + const typeLabel = field.attachmentType === "RFQ_COMMON" ? "공통" : "벤더별" + const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음" + const dateText = field.createdAt ? formatDate(field.createdAt) : "" + + return ( +
+
+
+

+ {field.originalFileName || field.fileName} +

+ + {typeLabel} + +
+

+ {sizeText} • {dateText} +

+ {field.description && ( +

+ {field.description} +

+ )} +
+ +
+ {/* Download button */} + {field.filePath && ( + + + + )} + {/* Remove button - 편집 가능할 때만 표시 */} + {isEditable && ( + + )} +
+
+ ) + })} +
+ + {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */} + {isEditable ? ( + <> + + {({ 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)}> + + 제거 + + + + {/* 파일별 설정 */} +
+ ( + + 파일 타입 + + + + )} + /> +
+
+ ) + })} +
+
+ )} + + ) : ( +
+
+ +

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

+
+
+ )} + + + + + + {isEditable && ( + + )} + +
+ +
+
+ ) +} \ No newline at end of file -- cgit v1.2.3