diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:54:26 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:54:26 +0000 |
| commit | 14f61e24947fb92dd71ec0a7196a6e815f8e66da (patch) | |
| tree | 317c501d64662d05914330628f867467fba78132 /lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx | |
| parent | 194bd4bd7e6144d5c09c5e3f5476d254234dce72 (diff) | |
(최겸)기술영업 RFQ 담당자 초대, 요구사항 반영
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 | 1118 |
1 files changed, 569 insertions, 549 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 index fccedf0a..f2ae1084 100644 --- a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx +++ b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx @@ -1,550 +1,570 @@ -"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 { cn } from "@/lib/utils" -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" -import { downloadFile } from "@/lib/file-download" - -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<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 - /** 첨부파일 타입 */ - 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 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<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: 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) - - // // 즉시 첨부파일 목록 새로고침 - // 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 ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-md"> - <SheetHeader className="text-left"> - <SheetTitle>{attachmentConfig.title}</SheetTitle> - <SheetDescription> - <div>RFQ: {rfq?.rfqCode || "N/A"}</div> - <div className="mt-1">{attachmentConfig.description}</div> - {!attachmentConfig.canEdit && ( - <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 = attachmentConfig.fileTypeLabel - const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음" - const dateText = field.createdAt ? formatDate(field.createdAt, "KR") : "" - - 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} - onClick={() => downloadFile(field.filePath, 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 - 편집 가능할 때만 표시 */} - {attachmentConfig.canEdit && ( - <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 - 편집 가능할 때만 표시 */} - {attachmentConfig.canEdit ? ( - <> - <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> - - </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"> - {attachmentConfig.canEdit ? "취소" : "닫기"} - </Button> - </SheetClose> - {attachmentConfig.canEdit && ( - <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> - ) +"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<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
+ /** 첨부파일 타입 */
+ 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<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: 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 (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>{attachmentConfig.title}</SheetTitle>
+ <SheetDescription>
+ <div>RFQ: {rfq?.rfqCode || "N/A"}</div>
+ <div className="mt-1">{attachmentConfig.description}</div>
+ {!attachmentConfig.canEdit && (
+ <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 = attachmentConfig.fileTypeLabel
+ const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음"
+ const dateText = field.createdAt ? formatDate(field.createdAt, "KR") : ""
+
+ 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 && (
+ <Button
+ variant="ghost"
+ size="icon"
+ type="button"
+ className="h-8 w-8"
+ onClick={() => handleDownloadClick(field.filePath, field.originalFileName || field.fileName)}
+ title="다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ )}
+ {/* Remove button - 편집 가능할 때만 표시 */}
+ {attachmentConfig.canEdit && (
+ <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 - 편집 가능할 때만 표시 */}
+ {attachmentConfig.canEdit ? (
+ <>
+ <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>
+
+ </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">
+ {attachmentConfig.canEdit ? "취소" : "닫기"}
+ </Button>
+ </SheetClose>
+ {attachmentConfig.canEdit && (
+ <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 |
