From 4863ba5d336297dddcc8d6d4b414beceb5559742 Mon Sep 17 00:00:00 2001
From: dujinkim <dujin.kim@dtsolution.co.kr>
Date: Mon, 8 Dec 2025 03:03:26 +0000
Subject: (최겸) 기술영업 rfq eml첨부기능 추가
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../tech-sales-vendor-eml-attachments-sheet.tsx    | 348 +++++++++++++++++++++
 1 file changed, 348 insertions(+)
 create mode 100644 lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx

(limited to 'lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx')

diff --git a/lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx
new file mode 100644
index 00000000..2b6f6753
--- /dev/null
+++ b/lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx
@@ -0,0 +1,348 @@
+"use client"
+
+import * as React from "react"
+import {
+  Sheet,
+  SheetContent,
+  SheetHeader,
+  SheetTitle,
+  SheetDescription,
+  SheetFooter,
+  SheetClose,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+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 { Badge } from "@/components/ui/badge"
+import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel } from "@/components/ui/form"
+import { toast } from "sonner"
+import { Download, Loader, Trash2, X } from "lucide-react"
+import prettyBytes from "pretty-bytes"
+import { useSession } from "next-auth/react"
+import { useForm } from "react-hook-form"
+import { formatDate } from "@/lib/utils"
+import {
+  getTechSalesVendorQuotationEmlAttachments,
+  processTechSalesVendorQuotationEmlAttachments,
+} from "@/lib/techsales-rfq/service"
+
+const MAX_FILE_SIZE = 6e8 // 600MB
+
+export interface VendorEmlAttachment {
+  id: number
+  quotationId: number
+  revisionId: number
+  fileName: string
+  originalFileName: string
+  fileSize: number
+  fileType: string | null
+  filePath: string
+  description: string | null
+  uploadedBy: number | null
+  vendorId: number | null
+  isVendorUpload: boolean
+  createdAt: Date
+  updatedAt: Date
+}
+
+interface QuotationInfo {
+  id: number
+  quotationCode: string | null
+  vendorName?: string
+  rfqCode?: string
+}
+
+interface TechSalesVendorEmlAttachmentsSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+  quotation: QuotationInfo | null
+  attachments: VendorEmlAttachment[]
+  onAttachmentsChange?: (attachments: VendorEmlAttachment[]) => void
+  isLoading?: boolean
+}
+
+export function TechSalesVendorEmlAttachmentsSheet({
+  quotation,
+  attachments,
+  onAttachmentsChange,
+  isLoading = false,
+  ...props
+}: TechSalesVendorEmlAttachmentsSheetProps) {
+  const session = useSession()
+  const [isPending, setIsPending] = React.useState(false)
+  const [existing, setExisting] = React.useState<VendorEmlAttachment[]>(attachments)
+  const [newUploads, setNewUploads] = React.useState<File[]>([])
+  const [deleteIds, setDeleteIds] = React.useState<number[]>([])
+
+  const form = useForm({
+    defaultValues: {
+      dummy: true,
+    },
+  })
+
+  // sync when parent changes
+  React.useEffect(() => {
+    setExisting(attachments)
+    setNewUploads([])
+    setDeleteIds([])
+  }, [attachments])
+
+  const handleDownloadClick = React.useCallback(async (attachment: VendorEmlAttachment) => {
+    try {
+      const { downloadFile } = await import("@/lib/file-download")
+      await downloadFile(attachment.filePath, attachment.originalFileName || attachment.fileName, {
+        showToast: true,
+        onError: (error) => {
+          console.error("다운로드 오류:", error)
+          toast.error(error)
+        },
+      })
+    } catch (error) {
+      console.error("다운로드 오류:", error)
+      toast.error("파일 다운로드 중 오류가 발생했습니다.")
+    }
+  }, [])
+
+  const handleDropAccepted = React.useCallback((accepted: File[]) => {
+    setNewUploads((prev) => [...prev, ...accepted])
+  }, [])
+
+  const handleDropRejected = React.useCallback(() => {
+    toast.error("파일 크기가 너무 크거나 지원하지 않는 형식입니다.")
+  }, [])
+
+  const handleRemoveExisting = React.useCallback((id: number) => {
+    setDeleteIds((prev) => (prev.includes(id) ? prev : [...prev, id]))
+    setExisting((prev) => prev.filter((att) => att.id !== id))
+  }, [])
+
+  const handleRemoveNewUpload = React.useCallback((index: number) => {
+    setNewUploads((prev) => prev.filter((_, i) => i !== index))
+  }, [])
+
+  const handleSubmit = async () => {
+    if (!quotation) {
+      toast.error("견적 정보를 찾을 수 없습니다.")
+      return
+    }
+
+    const userId = Number(session.data?.user.id || 0)
+    if (!userId) {
+      toast.error("로그인 정보를 확인해주세요.")
+      return
+    }
+
+    setIsPending(true)
+    try {
+      const result = await processTechSalesVendorQuotationEmlAttachments({
+        quotationId: quotation.id,
+        newFiles: newUploads.map((file) => ({ file })),
+        deleteAttachmentIds: deleteIds,
+        uploadedBy: userId,
+      })
+
+      if (result.error) {
+        toast.error(result.error)
+        return
+      }
+
+      const refreshed =
+        result.data ||
+        (await getTechSalesVendorQuotationEmlAttachments(quotation.id)).data ||
+        []
+
+      setExisting(refreshed)
+      setNewUploads([])
+      setDeleteIds([])
+      onAttachmentsChange?.(refreshed)
+      toast.success("Eml 첨부파일이 저장되었습니다.")
+      props.onOpenChange?.(false)
+    } catch (error) {
+      console.error("eml 첨부파일 저장 오류:", error)
+      toast.error("eml 첨부파일 저장 중 오류가 발생했습니다.")
+    } finally {
+      setIsPending(false)
+    }
+  }
+
+  const totalNewSize = newUploads.reduce((acc, f) => acc + f.size, 0)
+
+  return (
+    <Sheet {...props}>
+      <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+        <SheetHeader className="text-left">
+          <SheetTitle>eml 첨부파일</SheetTitle>
+          <SheetDescription>
+            <div className="space-y-1">
+              {quotation?.vendorName && <div>벤더: {quotation.vendorName}</div>}
+              {quotation?.rfqCode && <div>RFQ: {quotation.rfqCode}</div>}
+            </div>
+          </SheetDescription>
+        </SheetHeader>
+
+        <Form {...form}>
+          <form onSubmit={(e) => e.preventDefault()} className="flex flex-1 flex-col gap-6">
+            {/* 기존 첨부 */}
+            <div className="grid gap-4">
+              <h6 className="font-semibold leading-none tracking-tight">
+                기존 첨부파일 ({existing.length}개)
+              </h6>
+              {isLoading ? (
+                <div className="flex items-center gap-2 text-sm text-muted-foreground">
+                  <Loader className="h-4 w-4 animate-spin" />
+                  로딩 중...
+                </div>
+              ) : existing.length === 0 ? (
+                <div className="text-sm text-muted-foreground">첨부파일이 없습니다.</div>
+              ) : (
+                existing.map((att) => (
+                  <div
+                    key={att.id}
+                    className="flex items-start justify-between p-3 border rounded-md gap-3"
+                  >
+                    <div className="flex-1 min-w-0">
+                      <div className="flex items-center gap-2 mb-1 flex-wrap">
+                        <p className="text-sm font-medium break-words leading-tight">
+                          {att.originalFileName || att.fileName}
+                        </p>
+                        <Badge variant="outline" className="text-xs shrink-0">
+                          rev {att.revisionId}
+                        </Badge>
+                      </div>
+                      <p className="text-xs text-muted-foreground">
+                        {prettyBytes(att.fileSize)} • {formatDate(att.createdAt, "KR")}
+                      </p>
+                      {att.description && (
+                        <p className="text-xs text-muted-foreground mt-1 break-words">
+                          {att.description}
+                        </p>
+                      )}
+                    </div>
+
+                    <div className="flex items-center gap-1 shrink-0">
+                      <Button
+                        variant="ghost"
+                        size="icon"
+                        className="h-8 w-8"
+                        type="button"
+                        onClick={() => handleDownloadClick(att)}
+                        title="다운로드"
+                      >
+                        <Download className="h-4 w-4" />
+                      </Button>
+                      <Button
+                        variant="ghost"
+                        size="icon"
+                        className="h-8 w-8"
+                        type="button"
+                        onClick={() => handleRemoveExisting(att.id)}
+                        title="삭제"
+                      >
+                        <Trash2 className="h-4 w-4" />
+                      </Button>
+                    </div>
+                  </div>
+                ))
+              )}
+            </div>
+
+            {/* 새 업로드 */}
+            <Dropzone
+              maxSize={MAX_FILE_SIZE}
+              onDropAccepted={handleDropAccepted}
+              onDropRejected={handleDropRejected}
+            >
+              {({ maxSize }) => (
+                <FormField
+                  control={form.control}
+                  name="dummy"
+                  render={() => (
+                    <FormItem>
+                      <FormLabel>새 eml 파일 업로드</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>
+                    </FormItem>
+                  )}
+                />
+              )}
+            </Dropzone>
+
+            {newUploads.length > 0 && (
+              <div className="grid gap-3">
+                <div className="flex items-center justify-between">
+                  <h6 className="font-semibold leading-none tracking-tight">
+                    새 파일 ({newUploads.length}개)
+                  </h6>
+                  <span className="text-xs text-muted-foreground">
+                    총 용량 {prettyBytes(totalNewSize)}
+                  </span>
+                </div>
+                <FileList>
+                  {newUploads.map((file, idx) => (
+                    <FileListItem key={`${file.name}-${idx}`}>
+                      <FileListHeader>
+                        <FileListIcon />
+                        <FileListInfo>
+                          <FileListName>{file.name}</FileListName>
+                          <FileListDescription>{prettyBytes(file.size)}</FileListDescription>
+                        </FileListInfo>
+                        <FileListAction onClick={() => handleRemoveNewUpload(idx)}>
+                          <X />
+                          <span className="sr-only">제거</span>
+                        </FileListAction>
+                      </FileListHeader>
+                    </FileListItem>
+                  ))}
+                </FileList>
+              </div>
+            )}
+
+            <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+              <SheetClose asChild>
+                <Button type="button" variant="outline">
+                  닫기
+                </Button>
+              </SheetClose>
+              <Button
+                type="button"
+                onClick={handleSubmit}
+                disabled={isPending || (!newUploads.length && deleteIds.length === 0)}
+              >
+                {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+                {isPending ? "저장 중..." : "저장"}
+              </Button>
+            </SheetFooter>
+          </form>
+        </Form>
+      </SheetContent>
+    </Sheet>
+  )
+}
+
-- 
cgit v1.2.3

