diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-14 05:28:01 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-14 05:28:01 +0000 |
| commit | 675b4e3d8ffcb57a041db285417d81e61284d900 (patch) | |
| tree | 254f3d6a6c0ce39ae8fba35618f3810e08945f19 /lib/rfq-last/vendor/send-rfq-dialog.tsx | |
| parent | 39f12cb19f29cbc5568057e154e6adf4789ae736 (diff) | |
(대표님) RFQ-last, tbe-last, 기본계약 템플릿 내 견적,입찰,계약 추가, env.dev NAS_PATH 수정
Diffstat (limited to 'lib/rfq-last/vendor/send-rfq-dialog.tsx')
| -rw-r--r-- | lib/rfq-last/vendor/send-rfq-dialog.tsx | 739 |
1 files changed, 673 insertions, 66 deletions
diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx index 9d88bdc9..619ea749 100644 --- a/lib/rfq-last/vendor/send-rfq-dialog.tsx +++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx @@ -39,7 +39,9 @@ import { Building, ChevronDown, ChevronRight, - UserPlus + UserPlus, + Shield, + Globe } from "lucide-react"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; @@ -74,6 +76,25 @@ import { PopoverTrigger, PopoverContent, } from "@/components/ui/popover" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Progress } from "@/components/ui/progress"; + +interface ContractToGenerate { + vendorId: number; + vendorName: string; + type: string; + templateName: string; +} + // 타입 정의 interface ContactDetail { id: number; @@ -102,6 +123,15 @@ interface Vendor { contactsByPosition?: Record<string, ContactDetail[]>; primaryEmail?: string | null; currency?: string | null; + + // 기본계약 정보 + ndaYn?: boolean; + generalGtcYn?: boolean; + projectGtcYn?: boolean; + agreementYn?: boolean; + + // 발송 정보 + sendVersion?: number; } interface Attachment { @@ -149,9 +179,29 @@ interface SendRfqDialogProps { rfqInfo: RfqInfo; attachments?: Attachment[]; onSend: (data: { - vendors: VendorWithRecipients[]; + 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 }>; }) => Promise<void>; } @@ -175,49 +225,6 @@ const getAttachmentIcon = (type: string) => { } }; -// 파일 크기 포맷 -const formatFileSize = (bytes?: number) => { - if (!bytes) return "0 KB"; - const kb = bytes / 1024; - const mb = kb / 1024; - if (mb >= 1) return `${mb.toFixed(2)} MB`; - return `${kb.toFixed(2)} KB`; -}; - -// 포지션별 아이콘 -const getPositionIcon = (position?: string | null) => { - if (!position) return <User className="h-3 w-3" />; - - const lowerPosition = position.toLowerCase(); - if (lowerPosition.includes('대표') || lowerPosition.includes('ceo')) { - return <Building2 className="h-3 w-3" />; - } - if (lowerPosition.includes('영업') || lowerPosition.includes('sales')) { - return <Briefcase className="h-3 w-3" />; - } - if (lowerPosition.includes('기술') || lowerPosition.includes('tech')) { - return <Package className="h-3 w-3" />; - } - return <User className="h-3 w-3" />; -}; - -// 포지션별 색상 -const getPositionColor = (position?: string | null) => { - if (!position) return 'default'; - - const lowerPosition = position.toLowerCase(); - if (lowerPosition.includes('대표') || lowerPosition.includes('ceo')) { - return 'destructive'; - } - if (lowerPosition.includes('영업') || lowerPosition.includes('sales')) { - return 'success'; - } - if (lowerPosition.includes('기술') || lowerPosition.includes('tech')) { - return 'secondary'; - } - return 'default'; -}; - export function SendRfqDialog({ open, onOpenChange, @@ -226,6 +233,7 @@ export function SendRfqDialog({ attachments = [], onSend, }: SendRfqDialogProps) { + const [isSending, setIsSending] = React.useState(false); const [vendorsWithRecipients, setVendorsWithRecipients] = React.useState<VendorWithRecipients[]>([]); const [selectedAttachments, setSelectedAttachments] = React.useState<number[]>([]); @@ -233,6 +241,118 @@ export function SendRfqDialog({ const [expandedVendors, setExpandedVendors] = React.useState<number[]>([]); const [customEmailInputs, setCustomEmailInputs] = React.useState<Record<number, { email: string; name: string }>>({}); const [showCustomEmailForm, setShowCustomEmailForm] = React.useState<Record<number, boolean>>({}); + 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<Map<string, { buffer: number[], fileName: string }>>(new Map()); + + // 재전송 시 기본계약 스킵 옵션 - 업체별 관리 + const [skipContractsForVendor, setSkipContractsForVendor] = React.useState<Record<number, boolean>>({}); + + 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<string, string> + ): Promise<Uint8Array> => { + 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); + } + } + }; // 초기화 React.useEffect(() => { @@ -254,6 +374,15 @@ export function SendRfqDialog({ // 초기화 setCustomEmailInputs({}); setShowCustomEmailForm({}); + + // 재전송 업체들의 기본계약 스킵 옵션 초기화 (기본값: false - 재생성) + const skipOptions: Record<number, boolean> = {}; + selectedVendors.forEach(v => { + if (v.sendVersion && v.sendVersion > 0) { + skipOptions[v.vendorId] = false; // 기본값은 재생성 + } + }); + setSkipContractsForVendor(skipOptions); } }, [open, selectedVendors, attachments]); @@ -378,11 +507,145 @@ export function SendRfqDialog({ ); }; - // 전송 처리 - const handleSend = async () => { + // 실제 발송 처리 함수 (재발송 확인 후 또는 바로 실행) + 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<string, { buffer: number[], fileName: string }>(); + // 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); + + 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 + })), + }); + + toast.success( + `${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.` + + (contractsToGenerate.length > 0 ? ` ${contractsToGenerate.length}개의 기본계약서가 포함되었습니다.` : '') + ); + onOpenChange(false); + + } catch (error) { + console.error("RFQ 발송 실패:", error); + toast.error("RFQ 발송에 실패했습니다."); + } finally { + setIsSending(false); + setIsGeneratingPdfs(false); + setPdfGenerationProgress(0); + setCurrentGeneratingContract(""); + setSkipContractsForVendor({}); // 초기화 + } +}, [vendorsWithRecipients, selectedAttachments, additionalMessage, onSend, onOpenChange, rfqInfo, skipContractsForVendor]); + + // 전송 처리 + const handleSend = async () => { + try { // 유효성 검사 const vendorsWithoutEmail = vendorsWithRecipients.filter(v => !v.selectedMainEmail); if (vendorsWithoutEmail.length > 0) { @@ -395,22 +658,23 @@ export function SendRfqDialog({ return; } - await onSend({ - vendors: vendorsWithRecipients.map(v => ({ - ...v, - additionalRecipients: v.additionalEmails, - })), - attachments: selectedAttachments, - message: additionalMessage, - }); + // 재발송 업체 확인 + 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; // 여기서 일단 중단하고 다이얼로그 응답을 기다림 + } - toast.success(`${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.`); - onOpenChange(false); + // 재발송 업체가 없으면 바로 진행 + await proceedWithSend(); } catch (error) { - console.error("RFQ 발송 실패:", error); - toast.error("RFQ 발송에 실패했습니다."); - } finally { - setIsSending(false); + console.error("RFQ 발송 준비 실패:", error); + toast.error("RFQ 발송 준비에 실패했습니다."); } }; @@ -437,6 +701,35 @@ export function SendRfqDialog({ {/* ScrollArea 대신 div 사용 */} <div className="flex-1 overflow-y-auto px-1" style={{ maxHeight: 'calc(90vh - 200px)' }}> <div className="space-y-6 pr-4"> + {/* 재발송 경고 메시지 - 재발송 업체가 있을 때만 표시 */} + {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0) && ( + <Alert className="border-yellow-500 bg-yellow-50"> + <AlertCircle className="h-4 w-4 text-yellow-600" /> + <AlertTitle className="text-yellow-800">재발송 경고</AlertTitle> + <AlertDescription className="text-yellow-700 space-y-3"> + <ul className="list-disc list-inside space-y-1"> + <li>재발송 대상 업체의 기존 견적 데이터가 초기화됩니다.</li> + <li>업체는 새로운 버전의 견적서를 작성해야 합니다.</li> + <li>이전에 제출한 견적서는 더 이상 유효하지 않습니다.</li> + </ul> + + {/* 기본계약 재발송 정보 */} + <div className="mt-3 pt-3 border-t border-yellow-400"> + <div className="space-y-2"> + <p className="text-sm font-medium flex items-center gap-2"> + <FileText className="h-4 w-4" /> + 기본계약서 재발송 설정 + </p> + <p className="text-xs text-yellow-600"> + 각 재발송 업체별로 기본계약서 재생성 여부를 선택할 수 있습니다. + 아래 표에서 업체별로 설정해주세요. + </p> + </div> + </div> + </AlertDescription> + </Alert> + )} + {/* RFQ 정보 섹션 */} <div className="space-y-4"> <div className="flex items-center gap-2 text-sm font-medium"> @@ -521,6 +814,40 @@ export function SendRfqDialog({ <tr> <th className="text-left p-2 text-xs font-medium">No.</th> <th className="text-left p-2 text-xs font-medium">업체명</th> + <th className="text-left p-2 text-xs font-medium">기본계약</th> + <th className="text-left p-2 text-xs font-medium"> + <div className="flex items-center gap-2"> + <span>계약서 재발송</span> + {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0) && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + className="h-5 px-1 text-xs" + onClick={() => { + const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0); + const allChecked = resendVendors.every(v => !skipContractsForVendor[v.vendorId]); + const newSkipOptions: Record<number, boolean> = {}; + resendVendors.forEach(v => { + newSkipOptions[v.vendorId] = allChecked; + }); + setSkipContractsForVendor(newSkipOptions); + }} + > + {Object.values(skipContractsForVendor).every(v => v) ? "전체 재생성" : + Object.values(skipContractsForVendor).every(v => !v) ? "전체 유지" : "전체 유지"} + </Button> + </TooltipTrigger> + <TooltipContent> + 재발송 업체 전체 선택/해제 + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} + </div> + </th> <th className="text-left p-2 text-xs font-medium">주 수신자</th> <th className="text-left p-2 text-xs font-medium">CC</th> <th className="text-left p-2 text-xs font-medium">작업</th> @@ -559,13 +886,41 @@ export function SendRfqDialog({ 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: <Shield className="h-3 w-3" /> }); + if (vendor.generalGtcYn) contracts.push({ name: "General GTC", icon: <Globe className="h-3 w-3" /> }); + if (vendor.projectGtcYn) contracts.push({ name: "Project GTC", icon: <Building className="h-3 w-3" /> }); + if (vendor.agreementYn) contracts.push({ name: "기술자료", icon: <FileText className="h-3 w-3" /> }); return ( <React.Fragment key={vendor.vendorId}> <tr className="border-b hover:bg-muted/20"> <td className="p-2"> - <div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-medium"> - {index + 1} + <div className="flex items-center gap-1"> + <div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-medium"> + {index + 1} + </div> + {isResend && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <Badge variant="warning" className="text-xs"> + 재발송 + </Badge> + </TooltipTrigger> + <TooltipContent> + <div className="space-y-1"> + <p className="font-semibold">⚠️ 재발송 경고</p> + <p className="text-xs">발송 회차: {vendor.sendVersion + 1}회차</p> + <p className="text-xs text-yellow-600">기존 견적 데이터가 초기화됩니다</p> + </div> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} </div> </td> <td className="p-2"> @@ -582,6 +937,86 @@ export function SendRfqDialog({ </div> </td> <td className="p-2"> + {contracts.length > 0 ? ( + <div className="flex flex-wrap gap-1"> + {/* 재전송이고 스킵 옵션이 켜져 있으면 표시 */} + {isResend && skipContractsForVendor[vendor.vendorId] ? ( + <Badge variant="secondary" className="text-xs px-1"> + <CheckCircle className="h-3 w-3 mr-1 text-green-500" /> + <span>기존 계약서 유지</span> + </Badge> + ) : ( + contracts.map((contract, idx) => ( + <TooltipProvider key={idx}> + <Tooltip> + <TooltipTrigger> + <Badge variant="outline" className="text-xs px-1"> + {contract.icon} + <span className="ml-1">{contract.name}</span> + {isResend && !skipContractsForVendor[vendor.vendorId] && ( + <RefreshCw className="h-3 w-3 ml-1 text-orange-500" /> + )} + </Badge> + </TooltipTrigger> + <TooltipContent> + <p className="text-xs"> + {contract.name === "NDA" && "비밀유지 계약서 요청"} + {contract.name === "General GTC" && "일반 거래약관 요청"} + {contract.name === "Project GTC" && `프로젝트 거래약관 요청 (${rfqInfo?.projectCode})`} + {contract.name === "기술자료" && "기술자료 제공 동의서 요청"} + {isResend && !skipContractsForVendor[vendor.vendorId] && ( + <span className="block mt-1 text-orange-400">⚠️ 재생성됨</span> + )} + </p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )) + )} + </div> + ) : ( + <span className="text-xs text-muted-foreground">없음</span> + )} + </td> + <td className="p-2"> + {isResend && contracts.length > 0 ? ( + <div className="flex items-center justify-center"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <div className="flex items-center gap-2"> + <Checkbox + checked={!skipContractsForVendor[vendor.vendorId]} + onCheckedChange={(checked) => { + setSkipContractsForVendor(prev => ({ + ...prev, + [vendor.vendorId]: !checked + })); + }} + // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600" + /> + <span className="text-xs"> + {skipContractsForVendor[vendor.vendorId] ? "유지" : "재생성"} + </span> + </div> + </TooltipTrigger> + <TooltipContent> + <p className="text-xs"> + {skipContractsForVendor[vendor.vendorId] + ? "기존 계약서를 그대로 유지합니다" + : "기존 계약서를 삭제하고 새로 생성합니다"} + </p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + ) : ( + <span className="text-xs text-muted-foreground text-center block"> + {isResend ? "계약서 없음" : "-"} + </span> + )} + </td> + <td className="p-2"> <Select value={vendor.selectedMainEmail} onValueChange={(value) => handleMainEmailChange(vendor.vendorId, value)} @@ -676,7 +1111,7 @@ export function SendRfqDialog({ {/* 인라인 수신자 추가 폼 - 한 줄 레이아웃 */} {isFormOpen && ( <tr className="bg-muted/10 border-b"> - <td colSpan={5} className="p-4"> + <td colSpan={7} className="p-4"> <div className="space-y-3"> <div className="flex items-center justify-between mb-2"> <div className="flex items-center gap-2 text-sm font-medium"> @@ -871,6 +1306,29 @@ export function SendRfqDialog({ onChange={(e) => setAdditionalMessage(e.target.value)} /> </div> + + {/* PDF 생성 진행 상황 표시 */} + {isGeneratingPdfs && ( + <Alert className="border-blue-500 bg-blue-50"> + <div className="space-y-3"> + <div className="flex items-center gap-2"> + <RefreshCw className="h-4 w-4 animate-spin text-blue-600" /> + <AlertTitle className="text-blue-800">기본계약서 생성 중</AlertTitle> + </div> + <AlertDescription> + <div className="space-y-2"> + <p className="text-sm text-blue-700">{currentGeneratingContract}</p> + <Progress value={pdfGenerationProgress} className="h-2" /> + <p className="text-xs text-blue-600"> + {Math.round(pdfGenerationProgress)}% 완료 + </p> + </div> + </AlertDescription> + </div> + </Alert> + )} + + </div> </div> @@ -892,9 +1350,14 @@ export function SendRfqDialog({ </Button> <Button onClick={handleSend} - disabled={isSending || selectedAttachments.length === 0} + disabled={isSending || isGeneratingPdfs || selectedAttachments.length === 0} > - {isSending ? ( + {isGeneratingPdfs ? ( + <> + <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> + 계약서 생성중... ({Math.round(pdfGenerationProgress)}%) + </> + ) : isSending ? ( <> <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> 발송중... @@ -908,6 +1371,150 @@ export function SendRfqDialog({ </Button> </DialogFooter> </DialogContent> + + {/* 재발송 확인 다이얼로그 */} + <AlertDialog open={showResendConfirmDialog} onOpenChange={setShowResendConfirmDialog}> + <AlertDialogContent className="max-w-2xl"> + <AlertDialogHeader> + <AlertDialogTitle className="flex items-center gap-2"> + <AlertCircle className="h-5 w-5 text-yellow-600" /> + 재발송 확인 + </AlertDialogTitle> + <AlertDialogDescription asChild> + <div className="space-y-4"> + <p className="text-sm"> + <span className="font-semibold text-yellow-700">{resendVendorsInfo.count}개 업체</span>가 재발송 대상입니다. + </p> + + {/* 재발송 대상 업체 목록 및 계약서 설정 */} + <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3"> + <p className="text-sm font-medium text-yellow-800 mb-3">재발송 대상 업체 및 계약서 설정:</p> + <div className="space-y-2"> + {vendorsWithRecipients + .filter(v => v.sendVersion && v.sendVersion > 0) + .map(vendor => { + const contracts = []; + if (vendor.ndaYn) contracts.push("NDA"); + if (vendor.generalGtcYn) contracts.push("General GTC"); + if (vendor.projectGtcYn) contracts.push("Project GTC"); + if (vendor.agreementYn) contracts.push("기술자료"); + + return ( + <div key={vendor.vendorId} className="flex items-center justify-between p-2 bg-white rounded border border-yellow-100"> + <div className="flex items-center gap-3"> + <span className="w-1.5 h-1.5 bg-yellow-600 rounded-full" /> + <div> + <span className="text-sm font-medium text-yellow-900">{vendor.vendorName}</span> + {contracts.length > 0 && ( + <div className="text-xs text-yellow-700 mt-0.5"> + 계약서: {contracts.join(", ")} + </div> + )} + </div> + </div> + {contracts.length > 0 && ( + <div className="flex items-center gap-2"> + <Checkbox + checked={!skipContractsForVendor[vendor.vendorId]} + onCheckedChange={(checked) => { + setSkipContractsForVendor(prev => ({ + ...prev, + [vendor.vendorId]: !checked + })); + }} + className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600" + /> + <span className="text-xs text-yellow-800"> + {skipContractsForVendor[vendor.vendorId] ? "계약서 유지" : "계약서 재생성"} + </span> + </div> + )} + </div> + ); + })} + </div> + + {/* 전체 선택 버튼 */} + {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0 && + (v.ndaYn || v.generalGtcYn || v.projectGtcYn || v.agreementYn)) && ( + <div className="mt-3 pt-3 border-t border-yellow-300 flex justify-end gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => { + const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0); + const newSkipOptions: Record<number, boolean> = {}; + resendVendors.forEach(v => { + newSkipOptions[v.vendorId] = true; // 모두 유지 + }); + setSkipContractsForVendor(newSkipOptions); + }} + > + 전체 계약서 유지 + </Button> + <Button + variant="outline" + size="sm" + onClick={() => { + const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0); + const newSkipOptions: Record<number, boolean> = {}; + resendVendors.forEach(v => { + newSkipOptions[v.vendorId] = false; // 모두 재생성 + }); + setSkipContractsForVendor(newSkipOptions); + }} + > + 전체 계약서 재생성 + </Button> + </div> + )} + </div> + + {/* 경고 메시지 */} + <Alert className="border-red-200 bg-red-50"> + <AlertCircle className="h-4 w-4 text-red-600" /> + <AlertTitle className="text-red-800">중요 안내사항</AlertTitle> + <AlertDescription className="text-red-700 space-y-2"> + <ul className="list-disc list-inside space-y-1 text-sm"> + <li>기존에 작성된 견적 데이터가 <strong>모두 초기화</strong>됩니다.</li> + <li>업체는 처음부터 새로 견적서를 작성해야 합니다.</li> + <li>이전에 제출한 견적서는 더 이상 유효하지 않습니다.</li> + {Object.entries(skipContractsForVendor).some(([vendorId, skip]) => !skip && + vendorsWithRecipients.find(v => v.vendorId === Number(vendorId))) && ( + <li className="text-orange-700 font-medium"> + ⚠️ 선택한 업체의 기존 기본계약서가 <strong>삭제</strong>되고 새로운 계약서가 발송됩니다. + </li> + )} + <li>이 작업은 <strong>취소할 수 없습니다</strong>.</li> + </ul> + </AlertDescription> + </Alert> + + <p className="text-sm text-muted-foreground"> + 재발송을 진행하시겠습니까? + </p> + </div> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel onClick={() => { + setShowResendConfirmDialog(false); + // 취소 시 옵션은 유지 (사용자가 설정한 상태 그대로) + }}> + 취소 + </AlertDialogCancel> + <AlertDialogAction + onClick={() => { + setShowResendConfirmDialog(false); + proceedWithSend(); + }} + > + <RefreshCw className="h-4 w-4 mr-2" /> + 재발송 진행 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> </Dialog> ); }
\ No newline at end of file |
