summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-05-29 05:12:36 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-29 05:37:04 +0000
commite484964b1d78cedabbe182c789a8e4c9b53e29d3 (patch)
treed18133dde99e6feb773c95d04f7e79715ab24252 /lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
parent37f55540833c2d5894513eca9fc8f7c6233fc2d2 (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.tsx540
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