"use client"; import * as React from "react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { Checkbox } from "@/components/ui/checkbox"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Send, Building2, User, Calendar, Package, FileText, Plus, X, Paperclip, Download, Mail, Users, AlertCircle, Info, File, CheckCircle, RefreshCw, Phone, Briefcase, Building, ChevronDown, ChevronRight, UserPlus, Shield, Globe } from "lucide-react"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; import { toast } from "sonner"; import { cn, formatDate } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Popover, PopoverTrigger, PopoverContent, } from "@/components/ui/popover" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "@/components/ui/tabs"; import { Progress } from "@/components/ui/progress"; import { getRfqEmailTemplate } from "../service"; interface ContractToGenerate { vendorId: number; vendorName: string; type: string; templateName: string; } // 타입 정의 interface ContactDetail { id: number; name: string; position?: string | null; department?: string | null; email: string; phone?: string | null; isPrimary: boolean; } interface CustomEmail { id: string; email: string; name?: string; } interface Vendor { vendorId: number; vendorName: string; vendorCode?: string | null; vendorCountry?: string | null; vendorEmail?: string | null; representativeEmail?: string | null; contacts?: ContactDetail[]; contactsByPosition?: Record; primaryEmail?: string | null; currency?: string | null; // 기본계약 정보 ndaYn?: boolean; generalGtcYn?: boolean; projectGtcYn?: boolean; agreementYn?: boolean; // 발송 정보 sendVersion?: number; } interface Attachment { id: number; attachmentType: string; serialNo: string; currentRevision: string; description?: string; fileName?: string; fileSize?: number; uploadedAt?: Date; } interface RfqInfo { rfqCode: string; rfqTitle: string; rfqType: string; projectCode?: string; projectName?: string; picName?: string; picCode?: string; picTeam?: string; packageNo?: string; packageName?: string; designPicName?: string; designTeam?: string; materialGroup?: string; materialGroupDesc?: string; dueDate: Date; quotationType?: string; evaluationApply?: boolean; contractType?: string; // 추가 필드들 (HTML 템플릿에서 사용되는 변수들) customerName?: string; customerCode?: string; shipType?: string; shipClass?: string; shipCount?: number; projectFlag?: string; flag?: string; contractStartDate?: string; contractEndDate?: string; scDate?: string; dlDate?: string; itemCode?: string; itemName?: string; itemCount?: number; prNumber?: string; prIssueDate?: string; warrantyDescription?: string; repairDescription?: string; totalWarrantyDescription?: string; requiredDocuments?: string[]; contractRequirements?: { hasNda: boolean; ndaDescription: string; hasGeneralGtc: boolean; generalGtcDescription: string; hasProjectGtc: boolean; projectGtcDescription: string; hasAgreement: boolean; agreementDescription: string; }; vendorCountry?: string; formattedDueDate?: string; systemName?: string; hasAttachments?: boolean; attachmentsCount?: number; language?: string; companyName?: string; now?: Date; } interface VendorWithRecipients extends Vendor { selectedMainEmail: string; additionalEmails: string[]; customEmails: CustomEmail[]; } interface SendRfqDialogProps { open: boolean; onOpenChange: (open: boolean) => void; selectedVendors: Vendor[]; rfqInfo: RfqInfo; attachments?: Attachment[]; onSend: (data: { vendors: Array<{ vendorId: number; vendorName: string; vendorCode?: string | null; vendorCountry?: string | null; selectedMainEmail: string; additionalEmails: string[]; customEmails?: Array<{ email: string; name?: string }>; currency?: string | null; contractRequirements?: { ndaYn: boolean; generalGtcYn: boolean; projectGtcYn: boolean; agreementYn: boolean; projectCode?: string; }; isResend: boolean; sendVersion?: number; contractsSkipped?: boolean; }>; attachments: number[]; message?: string; generatedPdfs?: Array<{ key: string; buffer: number[]; fileName: string }>; hasToSendEmail?: boolean; }) => Promise<{ success: boolean; message: string; sentCount?: number; failedCount?: number; error?: string; }>; } // 이메일 유효성 검사 함수 const validateEmail = (email: string): boolean => { const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return re.test(email); }; // 첨부파일 타입별 아이콘 const getAttachmentIcon = (type: string) => { switch (type.toLowerCase()) { case "technical": return ; case "commercial": return ; case "drawing": return ; default: return ; } }; export function SendRfqDialog({ open, onOpenChange, selectedVendors, rfqInfo, attachments = [], onSend, }: SendRfqDialogProps) { const [isSending, setIsSending] = React.useState(false); const [vendorsWithRecipients, setVendorsWithRecipients] = React.useState([]); const [selectedAttachments, setSelectedAttachments] = React.useState([]); const [additionalMessage, setAdditionalMessage] = React.useState(""); const [expandedVendors, setExpandedVendors] = React.useState([]); const [customEmailInputs, setCustomEmailInputs] = React.useState>({}); const [showCustomEmailForm, setShowCustomEmailForm] = React.useState>({}); const [showResendConfirmDialog, setShowResendConfirmDialog] = React.useState(false); const [resendVendorsInfo, setResendVendorsInfo] = React.useState<{ count: number; names: string[] }>({ count: 0, names: [] }); const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false); const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0); const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState(""); const [generatedPdfs, setGeneratedPdfs] = React.useState>(new Map()); // 재전송 시 기본계약 스킵 옵션 - 업체별 관리 const [skipContractsForVendor, setSkipContractsForVendor] = React.useState>({}); // 이메일 템플릿 관련 상태 const [activeTab, setActiveTab] = React.useState<"recipients" | "template">("recipients"); const [selectedTemplateSlug, setSelectedTemplateSlug] = React.useState(""); const [templatePreview, setTemplatePreview] = React.useState<{ subject: string; content: string } | null>(null); const [isGeneratingPreview, setIsGeneratingPreview] = React.useState(false); const [hasToSendEmail, setHasToSendEmail] = React.useState(true); // 이메일 발송 여부 const generateContractPdf = async ( vendor: VendorWithRecipients, contractType: string, templateName: string ): Promise<{ buffer: number[], fileName: string }> => { try { // 1. 템플릿 데이터 준비 (서버 액션 호출) const prepareResponse = await fetch("/api/contracts/prepare-template", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ templateName, vendorId: vendor.vendorId, }), }); if (!prepareResponse.ok) { throw new Error("템플릿 준비 실패"); } const { template, templateData } = await prepareResponse.json(); // 2. 템플릿 파일 다운로드 const templateResponse = await fetch("/api/contracts/get-template", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ templatePath: template.filePath }), }); const templateBlob = await templateResponse.blob(); const templateFile = new window.File([templateBlob], "template.docx", { type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }); // 3. PDFtron WebViewer로 PDF 변환 const pdfBuffer = await convertToPdfWithWebViewer(templateFile, templateData); const fileName = `${contractType}_${vendor.vendorCode || vendor.vendorId}_${Date.now()}.pdf`; return { buffer: Array.from(pdfBuffer), // Uint8Array를 일반 배열로 변환 fileName }; } catch (error) { console.error(`PDF 생성 실패 (${vendor.vendorName} - ${contractType}):`, error); throw error; } }; // PDFtron WebViewer 변환 함수 const convertToPdfWithWebViewer = async ( templateFile: File, templateData: Record ): Promise => { const { default: WebViewer } = await import("@pdftron/webviewer"); const tempDiv = document.createElement('div'); tempDiv.style.display = 'none'; tempDiv.style.position = 'absolute'; tempDiv.style.top = '-9999px'; tempDiv.style.left = '-9999px'; tempDiv.style.width = '1px'; tempDiv.style.height = '1px'; document.body.appendChild(tempDiv); try { const instance = await WebViewer( { path: "/pdftronWeb", licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, fullAPI: true, enableOfficeEditing: true, }, tempDiv ); await new Promise(resolve => setTimeout(resolve, 1000)); const { Core } = instance; const { createDocument } = Core; const templateDoc = await createDocument(templateFile, { filename: templateFile.name, extension: 'docx', }); await templateDoc.applyTemplateValues(templateData); await new Promise(resolve => setTimeout(resolve, 3000)); const fileData = await templateDoc.getFileData(); const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' }); instance.UI.dispose(); return new Uint8Array(pdfBuffer); } finally { if (tempDiv.parentNode) { document.body.removeChild(tempDiv); } } }; // 템플릿 미리보기 생성 const generateTemplatePreview = React.useCallback(async (templateSlug: string) => { try { setIsGeneratingPreview(true); const template = await getRfqEmailTemplate(); templateSlug = template?.slug || ""; const response = await fetch('/api/email-template/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ templateSlug, sampleData: { // 기본 RFQ 정보 (실제 데이터 사용) rfqCode: rfqInfo?.rfqCode || '', rfqTitle: rfqInfo?.rfqTitle || '', projectCode: rfqInfo?.projectCode, projectName: rfqInfo?.projectName, vendorName: "업체명 예시", // 실제로는 선택된 벤더 이름 사용 picName: rfqInfo?.picName, picCode: rfqInfo?.picCode, picTeam: rfqInfo?.picTeam, dueDate: rfqInfo?.dueDate, // 프로젝트 관련 정보 customerName: rfqInfo?.customerName || (rfqInfo?.projectCode ? `${rfqInfo.projectCode} 고객사` : undefined), customerCode: rfqInfo?.customerCode || rfqInfo?.projectCode, shipType: rfqInfo?.shipType || "선종 정보", shipClass: rfqInfo?.shipClass || "선급 정보", shipCount: rfqInfo?.shipCount || 1, projectFlag: rfqInfo?.projectFlag || "KR", flag: rfqInfo?.flag || "한국", contractStartDate: rfqInfo?.contractStartDate || new Date().toISOString().split('T')[0], contractEndDate: rfqInfo?.contractEndDate || new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], scDate: rfqInfo?.scDate || new Date().toISOString().split('T')[0], dlDate: rfqInfo?.dlDate || new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], // 패키지/자재 정보 packageNo: rfqInfo?.packageNo, packageName: rfqInfo?.packageName, materialGroup: rfqInfo?.materialGroup, materialGroupDesc: rfqInfo?.materialGroupDesc, // 품목 정보 itemCode: rfqInfo?.itemCode || "품목코드", itemName: rfqInfo?.itemName || "품목명", itemCount: rfqInfo?.itemCount || 1, prNumber: rfqInfo?.prNumber || "PR-001", prIssueDate: rfqInfo?.prIssueDate || new Date().toISOString().split('T')[0], // 보증 정보 warrantyDescription: rfqInfo?.warrantyDescription || "제조사의 표준 보증 조건 적용", repairDescription: rfqInfo?.repairDescription || "하자 발생 시 무상 수리", totalWarrantyDescription: rfqInfo?.totalWarrantyDescription || "전체 품목에 대한 보증 적용", // 필요 문서 requiredDocuments: rfqInfo?.requiredDocuments || [ "상세 견적서", "납기 계획서", "품질 보증서", "기술 사양서" ], // 계약 요구사항 contractRequirements: rfqInfo?.contractRequirements || { hasNda: true, ndaDescription: "NDA (비밀유지계약)", hasGeneralGtc: true, generalGtcDescription: "General GTC", hasProjectGtc: !!rfqInfo?.projectCode, projectGtcDescription: `Project GTC (${rfqInfo?.projectCode || ''})`, hasAgreement: false, agreementDescription: "기술 자료 제공 동의서" }, // 벤더 정보 vendorCountry: rfqInfo?.vendorCountry || "한국", // 시스템 정보 formattedDueDate: rfqInfo?.formattedDueDate || (rfqInfo?.dueDate ? new Date(rfqInfo.dueDate).toLocaleDateString('ko-KR') : ''), systemName: rfqInfo?.systemName || "SHI EVCP", hasAttachments: rfqInfo?.hasAttachments || false, attachmentsCount: rfqInfo?.attachmentsCount || 0, // 언어 설정 language: rfqInfo?.language || "ko", // 회사 정보 (t helper 대체용) companyName: "삼성중공업", email: "삼성중공업", // 현재 시간 now: new Date(), // 기타 정보 designPicName: rfqInfo?.designPicName, designTeam: rfqInfo?.designTeam, quotationType: rfqInfo?.quotationType, evaluationApply: rfqInfo?.evaluationApply, contractType: rfqInfo?.contractType } }) }); const data = await response.json(); if (data.success) { setTemplatePreview({ subject: data.subject || '', content: data.html || '' }); } else { console.error('미리보기 생성 실패:', data.error); setTemplatePreview(null); } } catch (error) { console.error('미리보기 생성 실패:', error); setTemplatePreview(null); } finally { setIsGeneratingPreview(false); } }, [rfqInfo]); // 초기화 React.useEffect(() => { if (open && selectedVendors.length > 0) { setVendorsWithRecipients( selectedVendors.map(v => ({ ...v, selectedMainEmail: '', // 기본값 제거 - 사용자가 직접 선택하도록 additionalEmails: [], customEmails: [] })) ); // 모든 첨부파일 선택 setSelectedAttachments(attachments.map(a => a.id)); // 첫 번째 벤더를 자동으로 확장 if (selectedVendors.length > 0) { setExpandedVendors([selectedVendors[0].vendorId]); } // 초기화 setCustomEmailInputs({}); setShowCustomEmailForm({}); // 재전송 업체들의 기본계약 스킵 옵션 초기화 (기본값: false - 재생성) const skipOptions: Record = {}; selectedVendors.forEach(v => { if (v.sendVersion && v.sendVersion > 0) { skipOptions[v.vendorId] = false; // 기본값은 재생성 } }); setSkipContractsForVendor(skipOptions); } }, [open, selectedVendors, attachments]); // 커스텀 이메일 추가 const addCustomEmail = (vendorId: number) => { const input = customEmailInputs[vendorId]; if (!input || !input.email) { toast.error("이메일 주소를 입력해주세요."); return; } if (!validateEmail(input.email)) { toast.error("올바른 이메일 형식이 아닙니다."); return; } setVendorsWithRecipients(prev => prev.map(v => { if (v.vendorId !== vendorId) return v; // 중복 체크 const allEmails = [ v.vendorEmail, v.representativeEmail, ...(v.contacts?.map(c => c.email) || []), ...v.customEmails.map(c => c.email) ].filter(Boolean); if (allEmails.includes(input.email)) { toast.error("이미 등록된 이메일 주소입니다."); return v; } const newCustomEmail: CustomEmail = { id: `custom-${Date.now()}`, email: input.email, name: input.name || input.email.split('@')[0] }; return { ...v, customEmails: [...v.customEmails, newCustomEmail] }; }) ); // 입력 필드 초기화 setCustomEmailInputs(prev => ({ ...prev, [vendorId]: { email: '', name: '' } })); setShowCustomEmailForm(prev => ({ ...prev, [vendorId]: false })); toast.success("수신자가 추가되었습니다."); }; // 커스텀 이메일 삭제 const removeCustomEmail = (vendorId: number, emailId: string) => { setVendorsWithRecipients(prev => prev.map(v => { if (v.vendorId !== vendorId) return v; const emailToRemove = v.customEmails.find(e => e.id === emailId); if (!emailToRemove) return v; return { ...v, customEmails: v.customEmails.filter(e => e.id !== emailId), // 만약 삭제하는 이메일이 선택된 주 수신자라면 초기화 selectedMainEmail: v.selectedMainEmail === emailToRemove.email ? '' : v.selectedMainEmail, // 추가 수신자에서도 제거 additionalEmails: v.additionalEmails.filter(e => e !== emailToRemove.email) }; }) ); }; // 주 수신자 이메일 변경 const handleMainEmailChange = (vendorId: number, email: string) => { setVendorsWithRecipients(prev => prev.map(v => v.vendorId === vendorId ? { ...v, selectedMainEmail: email } : v ) ); }; // 추가 수신자 토글 const toggleAdditionalEmail = (vendorId: number, email: string) => { setVendorsWithRecipients(prev => prev.map(v => { if (v.vendorId !== vendorId) return v; const additionalEmails = v.additionalEmails.includes(email) ? v.additionalEmails.filter(e => e !== email) : [...v.additionalEmails, email]; return { ...v, additionalEmails }; }) ); }; // 벤더 확장/축소 토글 const toggleVendorExpand = (vendorId: number) => { setExpandedVendors(prev => prev.includes(vendorId) ? prev.filter(id => id !== vendorId) : [...prev, vendorId] ); }; // 첨부파일 선택 토글 const toggleAttachment = (attachmentId: number) => { setSelectedAttachments(prev => prev.includes(attachmentId) ? prev.filter(id => id !== attachmentId) : [...prev, attachmentId] ); }; // 실제 발송 처리 함수 (재발송 확인 후 또는 바로 실행) const proceedWithSend = React.useCallback(async () => { try { setIsSending(true); // 기본계약이 필요한 계약서 목록 수집 const contractsToGenerate: ContractToGenerate[] = []; for (const vendor of vendorsWithRecipients) { // 재전송 업체이고 해당 업체의 스킵 옵션이 켜져 있으면 계약서 생성 건너뛰기 const isResendVendor = vendor.sendVersion && vendor.sendVersion > 0; if (isResendVendor && skipContractsForVendor[vendor.vendorId]) { continue; // 이 벤더의 계약서 생성을 스킵 } if (vendor.ndaYn) { contractsToGenerate.push({ vendorId: vendor.vendorId, vendorName: vendor.vendorName, type: "NDA", templateName: "비밀" }); } if (vendor.generalGtcYn) { contractsToGenerate.push({ vendorId: vendor.vendorId, vendorName: vendor.vendorName, type: "General_GTC", templateName: "General GTC" }); } if (vendor.projectGtcYn && rfqInfo?.projectCode) { contractsToGenerate.push({ vendorId: vendor.vendorId, vendorName: vendor.vendorName, type: "Project_GTC", templateName: rfqInfo.projectCode }); } if (vendor.agreementYn) { contractsToGenerate.push({ vendorId: vendor.vendorId, vendorName: vendor.vendorName, type: "기술자료", templateName: "기술" }); } } let pdfsMap = new Map(); // PDF 생성이 필요한 경우 if (contractsToGenerate.length > 0) { setIsGeneratingPdfs(true); setPdfGenerationProgress(0); try { let completed = 0; for (const contract of contractsToGenerate) { setCurrentGeneratingContract(`${contract.vendorName} - ${contract.type}`); const vendor = vendorsWithRecipients.find(v => v.vendorId === contract.vendorId); if (!vendor) continue; const pdf = await generateContractPdf(vendor, contract.type, contract.templateName); pdfsMap.set(`${contract.vendorId}_${contract.type}_${contract.templateName}`, pdf); completed++; setPdfGenerationProgress((completed / contractsToGenerate.length) * 100); await new Promise(resolve => setTimeout(resolve, 100)); } setGeneratedPdfs(pdfsMap); // UI 업데이트용 } catch (error) { console.error("PDF 생성 실패:", error); toast.error("기본계약서 생성에 실패했습니다."); setIsGeneratingPdfs(false); setPdfGenerationProgress(0); return; } } // RFQ 발송 - pdfsMap을 직접 사용 setIsGeneratingPdfs(false); setIsSending(true); const sendResult = await onSend({ vendors: vendorsWithRecipients.map(v => ({ vendorId: v.vendorId, vendorName: v.vendorName, vendorCode: v.vendorCode, vendorCountry: v.vendorCountry, selectedMainEmail: v.selectedMainEmail, additionalEmails: v.additionalEmails, customEmails: v.customEmails.map(c => ({ email: c.email, name: c.name })), currency: v.currency, contractRequirements: { ndaYn: v.ndaYn || false, generalGtcYn: v.generalGtcYn || false, projectGtcYn: v.projectGtcYn || false, agreementYn: v.agreementYn || false, projectCode: v.projectGtcYn ? rfqInfo?.projectCode : undefined, }, isResend: (v.sendVersion || 0) > 0, sendVersion: v.sendVersion, contractsSkipped: ((v.sendVersion || 0) > 0) && skipContractsForVendor[v.vendorId], })), attachments: selectedAttachments, message: additionalMessage, // 생성된 PDF 데이터 추가 generatedPdfs: Array.from(pdfsMap.entries()).map(([key, data]) => ({ key, ...data })), // 이메일 발송 처리 (사용자 선택에 따라) hasToSendEmail: hasToSendEmail, }); if (!sendResult) { throw new Error("서버 응답이 없습니다."); } if (!sendResult.success) { throw new Error(sendResult.message || "RFQ 발송에 실패했습니다."); } toast.success(sendResult.message); onOpenChange(false); } catch (error) { console.error("RFQ 발송 실패:", error); toast.error(error instanceof Error ? error.message : "RFQ 발송에 실패했습니다."); } finally { setIsSending(false); setIsGeneratingPdfs(false); setPdfGenerationProgress(0); setCurrentGeneratingContract(""); setSkipContractsForVendor({}); // 초기화 } }, [vendorsWithRecipients, selectedAttachments, additionalMessage, onSend, onOpenChange, rfqInfo, skipContractsForVendor, hasToSendEmail]); // 전송 처리 const handleSend = async () => { try { // 주 수신자가 없는 벤더 확인 (가장 먼저 검증) const vendorsWithoutMainEmail = vendorsWithRecipients.filter(v => !v.selectedMainEmail || v.selectedMainEmail.trim() === ''); if (vendorsWithoutMainEmail.length > 0) { // 사용 가능한 이메일이 있는지 확인 const vendorsWithAvailableEmails = vendorsWithoutMainEmail.filter(v => { const hasRepresentativeEmail = !!v.representativeEmail; const hasVendorEmail = !!v.vendorEmail; const hasContacts = v.contacts && v.contacts.length > 0; const hasCustomEmails = v.customEmails && v.customEmails.length > 0; const hasAdditionalEmails = v.additionalEmails && v.additionalEmails.length > 0; return hasRepresentativeEmail || hasVendorEmail || hasContacts || hasCustomEmails || hasAdditionalEmails; }); if (vendorsWithAvailableEmails.length > 0) { // 사용 가능한 이메일이 있지만 주 수신자가 선택되지 않은 경우 toast.error( `${vendorsWithAvailableEmails.map(v => v.vendorName).join(', ')}의 주 수신자를 선택해주세요. ` + `(CC에서 선택하거나 수신자 추가 버튼으로 이메일을 추가한 후 주 수신자로 선택해주세요)` ); return; } // 사용 가능한 이메일이 전혀 없는 경우 const vendorsWithoutAnyEmail = vendorsWithoutMainEmail.filter(v => { const hasRepresentativeEmail = !!v.representativeEmail; const hasVendorEmail = !!v.vendorEmail; const hasContacts = v.contacts && v.contacts.length > 0; const hasCustomEmails = v.customEmails && v.customEmails.length > 0; return !hasRepresentativeEmail && !hasVendorEmail && !hasContacts && !hasCustomEmails; }); if (vendorsWithoutAnyEmail.length > 0) { toast.error( `${vendorsWithoutAnyEmail.map(v => v.vendorName).join(', ')}에 사용 가능한 이메일이 없습니다. ` + `수신자 추가 버튼(+)을 눌러 이메일을 추가해주세요.` ); return; } } // 모든 벤더가 주 수신자를 가지고 있는지 최종 확인 const finalCheck = vendorsWithRecipients.filter(v => !v.selectedMainEmail || v.selectedMainEmail.trim() === ''); if (finalCheck.length > 0) { toast.error(`${finalCheck.map(v => v.vendorName).join(', ')}의 주 수신자를 선택해주세요.`); return; } // 첨부파일은 선택사항 - 없어도 발송 가능 // 재발송 업체 확인 const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0); if (resendVendors.length > 0) { // AlertDialog를 표시하기 위해 상태 설정 setResendVendorsInfo({ count: resendVendors.length, names: resendVendors.map(v => v.vendorName) }); setShowResendConfirmDialog(true); return; // 여기서 일단 중단하고 다이얼로그 응답을 기다림 } // 재발송 업체가 없으면 바로 진행 await proceedWithSend(); } catch (error) { console.error("RFQ 발송 준비 실패:", error); toast.error("RFQ 발송 준비에 실패했습니다."); } }; // 총 수신자 수 계산 const totalRecipientCount = React.useMemo(() => { return vendorsWithRecipients.reduce((acc, v) => acc + 1 + v.additionalEmails.length, 0 ); }, [vendorsWithRecipients]); // 발송 가능 여부 확인 (모든 벤더가 주 수신자를 가지고 있어야 함) const canSend = React.useMemo(() => { if (vendorsWithRecipients.length === 0) return false; return vendorsWithRecipients.every(v => v.selectedMainEmail && v.selectedMainEmail.trim() !== ''); }, [vendorsWithRecipients]); return ( RFQ 일괄 발송 선택한 {selectedVendors.length}개 업체에 RFQ를 발송합니다. {/* 탭 구조 */} setActiveTab(value as "recipients" | "template")} className="flex-1 flex flex-col"> 수신자 설정 이메일 템플릿
{/* 재발송 경고 메시지 - 재발송 업체가 있을 때만 표시 */} {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0) && ( 재발송 경고
  • 재발송 대상 업체의 기존 견적 데이터가 초기화됩니다.
  • 업체는 새로운 버전의 견적서를 작성해야 합니다.
  • 이전에 제출한 견적서는 더 이상 유효하지 않습니다.
{/* 기본계약 재발송 정보 */}

기본계약서 재발송 설정

각 재발송 업체별로 기본계약서 재생성 여부를 선택할 수 있습니다. 아래 표에서 업체별로 설정해주세요.

)} {/* RFQ 정보 섹션 */}
RFQ 정보
RFQ 코드: {rfqInfo?.rfqCode}
견적마감일: {formatDate(rfqInfo?.dueDate, "KR")}
프로젝트: {rfqInfo?.projectCode} ({rfqInfo?.projectName})
자재그룹: {rfqInfo?.packageNo} - {rfqInfo?.packageName}
구매담당자: {rfqInfo?.picName} ({rfqInfo?.picCode}) {rfqInfo?.picTeam}
설계담당자: {rfqInfo?.designPicName}
{rfqInfo?.rfqCode.startsWith("F") && <>
견적명: {rfqInfo?.rfqTitle}
견적종류: {rfqInfo?.rfqType}
}
{/* 수신 업체 섹션 - 테이블 버전 with 인라인 추가 폼 */}
수신 업체 ({selectedVendors.length})
총 {totalRecipientCount}명
{/* */} {vendorsWithRecipients.map((vendor, index) => { const allContacts = vendor.contacts || []; const allEmails = [ ...(vendor.representativeEmail ? [{ value: vendor.representativeEmail, label: '대표자', email: vendor.representativeEmail, type: 'representative' }] : []), ...allContacts.map(c => ({ value: c.email, label: `${c.name} ${c.position ? `(${c.position})` : ''}`, email: c.email, type: 'contact' })), ...vendor.customEmails.map(c => ({ value: c.email, label: c.name || c.email, email: c.email, type: 'custom' })), ...(vendor.vendorEmail && vendor.vendorEmail !== vendor.representativeEmail ? [{ value: vendor.vendorEmail, label: '업체 기본', email: vendor.vendorEmail, type: 'default' }] : []) ]; const ccEmails = allEmails.filter(e => e.value !== vendor.selectedMainEmail); const selectedMainEmailInfo = allEmails.find(e => e.value === vendor.selectedMainEmail); const isFormOpen = showCustomEmailForm[vendor.vendorId]; const isResend = vendor.sendVersion && vendor.sendVersion > 0; // 기본계약 요구사항 확인 const contracts = []; if (vendor.ndaYn) contracts.push({ name: "NDA", icon: }); if (vendor.generalGtcYn) contracts.push({ name: "General GTC", icon: }); if (vendor.projectGtcYn) contracts.push({ name: "Project GTC", icon: }); if (vendor.agreementYn) contracts.push({ name: "기술자료", icon: }); return ( {/* */} {/* 인라인 수신자 추가 폼 - 한 줄 레이아웃 */} {isFormOpen && ( )} ); })}
No. 업체명 기본계약
계약서 재발송 {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0) && ( 재발송 업체 전체 선택/해제 )}
주 수신자 CC 작업
{index + 1}
{isResend && ( 재발송

⚠️ 재발송 경고

발송 회차: {vendor.sendVersion + 1}회차

기존 견적 데이터가 초기화됩니다

)}
{vendor.vendorName}
{vendor.vendorCountry} {vendor.vendorCode}
{contracts.length > 0 ? (
{/* 재전송이고 스킵 옵션이 켜져 있으면 표시 */} {isResend && skipContractsForVendor[vendor.vendorId] ? ( 기존 계약서 유지 ) : ( contracts.map((contract, idx) => ( {contract.icon} {contract.name} {isResend && !skipContractsForVendor[vendor.vendorId] && ( )}

{contract.name === "NDA" && "비밀유지 계약서 요청"} {contract.name === "General GTC" && "일반 거래약관 요청"} {contract.name === "Project GTC" && `프로젝트 거래약관 요청 (${rfqInfo?.projectCode})`} {contract.name === "기술자료" && "기술자료 제공 동의서 요청"} {isResend && !skipContractsForVendor[vendor.vendorId] && ( ⚠️ 재생성됨 )}

)) )}
) : ( 없음 )}
{isResend && contracts.length > 0 ? (
{ setSkipContractsForVendor(prev => ({ ...prev, [vendor.vendorId]: !checked })); }} // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600" /> {skipContractsForVendor[vendor.vendorId] ? "유지" : "재생성"}

{skipContractsForVendor[vendor.vendorId] ? "기존 계약서를 그대로 유지합니다" : "기존 계약서를 삭제하고 새로 생성합니다"}

) : ( {isResend ? "계약서 없음" : "-"} )}
{!vendor.selectedMainEmail && ( 필수 )}
{ccEmails.map((email) => (
toggleAdditionalEmail(vendor.vendorId, email.value)} className="h-3 w-3" />
))}
{isFormOpen ? "닫기" : "수신자 추가"} {vendor.customEmails.length > 0 && ( +{vendor.customEmails.length} )}
수신자 추가 - {vendor.vendorName}
{/* 한 줄에 모든 요소 배치 - 명확한 너비 지정 */}
setCustomEmailInputs(prev => ({ ...prev, [vendor.vendorId]: { ...prev[vendor.vendorId], name: e.target.value } }))} />
setCustomEmailInputs(prev => ({ ...prev, [vendor.vendorId]: { ...prev[vendor.vendorId], email: e.target.value } }))} onKeyPress={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCustomEmail(vendor.vendorId); } }} />
{/* 추가된 커스텀 이메일 목록 */} {vendor.customEmails.length > 0 && (
추가된 수신자 목록
{vendor.customEmails.map((custom) => (
{custom.name}
{custom.email}
))}
)}
{/* 첨부파일 섹션 */}
첨부파일 ({selectedAttachments.length}/{attachments.length})
{attachments.length > 0 ? ( attachments.map((attachment) => (
toggleAttachment(attachment.id)} /> {getAttachmentIcon(attachment.attachmentType)} {attachment.fileName || `${attachment.attachmentType}_${attachment.serialNo}`} {attachment.currentRevision}
)) ) : (

첨부파일이 없습니다.

)}
{/* 추가 메시지 */}