diff options
| author | joonhoekim <26rote@gmail.com> | 2025-05-29 05:12:36 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-29 05:37:04 +0000 |
| commit | e484964b1d78cedabbe182c789a8e4c9b53e29d3 (patch) | |
| tree | d18133dde99e6feb773c95d04f7e79715ab24252 /lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx | |
| parent | 37f55540833c2d5894513eca9fc8f7c6233fc2d2 (diff) | |
(김준회) 기술영업 조선 RFQ 파일첨부 및 채팅 기능 구현 / menuConfig 수정 (벤더 기술영업)
Diffstat (limited to 'lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx')
| -rw-r--r-- | lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx | 540 |
1 files changed, 540 insertions, 0 deletions
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<Date>(), +}) + +/** RHF 폼 전체 스키마 */ +const attachmentsFormSchema = z.object({ + techSalesRfqId: z.number().int(), + existing: z.array(existingAttachSchema), + newUploads: z.array(newUploadSchema), +}) + +type AttachmentsFormValues = z.infer<typeof attachmentsFormSchema> + +// TechSalesRfq 타입 (간단 버전) +interface TechSalesRfq { + id: number + rfqCode: string | null + status: string + // 필요한 다른 필드들... +} + +interface TechSalesRfqAttachmentsSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + 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<AttachmentsFormValues>({ + 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 ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>첨부파일 관리</SheetTitle> + <SheetDescription> + RFQ: {rfq?.rfqCode || "N/A"} + {!isEditable && ( + <div className="mt-2 flex items-center gap-2 text-amber-600"> + <AlertCircle className="h-4 w-4" /> + <span className="text-sm">현재 상태에서는 편집할 수 없습니다</span> + </div> + )} + </SheetDescription> + </SheetHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-1 flex-col gap-6"> + {/* 1) Existing attachments */} + <div className="grid gap-4"> + <h6 className="font-semibold leading-none tracking-tight"> + 기존 첨부파일 ({existingFields.length}개) + </h6> + {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 ( + <div key={field.id} className="flex items-start justify-between p-3 border rounded-md gap-3"> + <div className="flex-1 min-w-0 overflow-hidden"> + <div className="flex items-center gap-2 mb-1 flex-wrap"> + <p className="text-sm font-medium break-words leading-tight"> + {field.originalFileName || field.fileName} + </p> + <Badge variant="outline" className="text-xs shrink-0"> + {typeLabel} + </Badge> + </div> + <p className="text-xs text-muted-foreground"> + {sizeText} • {dateText} + </p> + {field.description && ( + <p className="text-xs text-muted-foreground mt-1 break-words"> + {field.description} + </p> + )} + </div> + + <div className="flex items-center gap-1 shrink-0"> + {/* Download button */} + {field.filePath && ( + <a + href={`/api/tech-sales-rfq-download?path=${encodeURIComponent(field.filePath)}`} + download={field.originalFileName || field.fileName} + className="inline-block" + > + <Button variant="ghost" size="icon" type="button" className="h-8 w-8"> + <Download className="h-4 w-4" /> + </Button> + </a> + )} + {/* Remove button - 편집 가능할 때만 표시 */} + {isEditable && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-8 w-8" + 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={form.control} + name="newUploads" + render={() => ( + <FormItem> + <FormLabel>새 파일 업로드</FormLabel> + <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> + 최대 크기: {maxSize ? prettyBytes(maxSize) : "600MB"} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + <FormDescription>파일을 여러 개 선택할 수 있습니다.</FormDescription> + <FormMessage /> + </FormItem> + )} + /> + )} + </Dropzone> + + {/* newUpload fields -> FileList */} + {newUploadFields.length > 0 && ( + <div className="grid gap-4"> + <h6 className="font-semibold leading-none tracking-tight"> + 새 파일 ({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">제거</span> + </FileListAction> + </FileListHeader> + + {/* 파일별 설정 */} + <div className="px-4 pb-3 space-y-3"> + <FormField + control={form.control} + name={`newUploads.${idx}.attachmentType`} + render={({ field: formField }) => ( + <FormItem> + <FormLabel className="text-xs">파일 타입</FormLabel> + <Select onValueChange={formField.onChange} defaultValue={formField.value}> + <FormControl> + <SelectTrigger className="h-8"> + <SelectValue placeholder="파일 타입 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="RFQ_COMMON">공통 파일</SelectItem> + {/* <SelectItem value="VENDOR_SPECIFIC">벤더별 파일</SelectItem> */} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + </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 ? "취소" : "닫기"} + </Button> + </SheetClose> + {isEditable && ( + <Button + type="submit" + disabled={ + isPending || + ( + form.getValues().newUploads.length === 0 && + form.getValues().existing.length === defaultAttachments.length && + form.getValues().existing.every(existing => + defaultAttachments.some(original => original.id === existing.id) + ) + ) + } + > + {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + {isPending ? "저장 중..." : "저장"} + </Button> + )} + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file |
