diff options
Diffstat (limited to 'lib/rfq-last/vendor')
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 100 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/send-rfq-dialog.tsx | 378 |
2 files changed, 287 insertions, 191 deletions
diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index 88ae968a..72539113 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -657,6 +657,102 @@ export function RfqVendorTable({ }, { + accessorKey: "tbeStatus", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="TBE 상태" /> + ), + cell: ({ row }) => { + const status = row.original.tbeStatus; + + if (!status || status === "준비중") { + return ( + <Badge variant="outline" className="text-gray-500"> + <Clock className="h-3 w-3 mr-1" /> + 대기 + </Badge> + ); + } + + const statusConfig = { + "진행중": { variant: "default", icon: <Clock className="h-3 w-3 mr-1" />, color: "text-blue-600" }, + "검토중": { variant: "secondary", icon: <Eye className="h-3 w-3 mr-1" />, color: "text-orange-600" }, + "보류": { variant: "outline", icon: <AlertCircle className="h-3 w-3 mr-1" />, color: "text-yellow-600" }, + "완료": { variant: "success", icon: <CheckCircle className="h-3 w-3 mr-1" />, color: "text-green-600" }, + "취소": { variant: "destructive", icon: <XCircle className="h-3 w-3 mr-1" />, color: "text-red-600" }, + }[status] || { variant: "outline", icon: null, color: "text-gray-600" }; + + return ( + <Badge variant={statusConfig.variant as any} className={statusConfig.color}> + {statusConfig.icon} + {status} + </Badge> + ); + }, + size: 100, + }, + + { + accessorKey: "tbeEvaluationResult", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="TBE 평가" /> + ), + cell: ({ row }) => { + const result = row.original.tbeEvaluationResult; + const status = row.original.tbeStatus; + + // TBE가 완료되지 않았으면 표시하지 않음 + if (status !== "완료" || !result) { + return <span className="text-xs text-muted-foreground">-</span>; + } + + const resultConfig = { + "Acceptable": { + variant: "success", + icon: <CheckCircle className="h-3 w-3" />, + text: "적합", + color: "bg-green-50 text-green-700 border-green-200" + }, + "Acceptable with Comment": { + variant: "warning", + icon: <AlertCircle className="h-3 w-3" />, + text: "조건부 적합", + color: "bg-yellow-50 text-yellow-700 border-yellow-200" + }, + "Not Acceptable": { + variant: "destructive", + icon: <XCircle className="h-3 w-3" />, + text: "부적합", + color: "bg-red-50 text-red-700 border-red-200" + }, + }[result]; + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <Badge className={cn("text-xs", resultConfig?.color)}> + {resultConfig?.icon} + <span className="ml-1">{resultConfig?.text}</span> + </Badge> + </TooltipTrigger> + <TooltipContent> + <div className="text-xs"> + <p className="font-semibold">{result}</p> + {row.original.conditionalRequirements && ( + <p className="mt-1 text-muted-foreground"> + 조건: {row.original.conditionalRequirements} + </p> + )} + </div> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ); + }, + size: 120, + }, + + { accessorKey: "contractRequirements", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약 요청" />, cell: ({ row }) => { @@ -785,8 +881,8 @@ export function RfqVendorTable({ // emailSentTo JSON 파싱 let recipients = { to: [], cc: [], sentBy: "" }; try { - if (response?.email?.emailSentTo) { - recipients = JSON.parse(response.email.emailSentTo); + if (response?.emailSentTo) { + recipients = JSON.parse(response.emailSentTo); } } catch (e) { console.error("Failed to parse emailSentTo", e); diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx index ce97dcde..34777864 100644 --- a/lib/rfq-last/vendor/send-rfq-dialog.tsx +++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx @@ -123,13 +123,13 @@ interface Vendor { contactsByPosition?: Record<string, ContactDetail[]>; primaryEmail?: string | null; currency?: string | null; - + // 기본계약 정보 ndaYn?: boolean; generalGtcYn?: boolean; projectGtcYn?: boolean; agreementYn?: boolean; - + // 발송 정보 sendVersion?: number; } @@ -243,15 +243,15 @@ export function SendRfqDialog({ 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, @@ -288,9 +288,9 @@ export function SendRfqDialog({ // 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 @@ -374,7 +374,7 @@ export function SendRfqDialog({ // 초기화 setCustomEmailInputs({}); setShowCustomEmailForm({}); - + // 재전송 업체들의 기본계약 스킵 옵션 초기화 (기본값: false - 재생성) const skipOptions: Record<number, boolean> = {}; selectedVendors.forEach(v => { @@ -511,137 +511,137 @@ export function SendRfqDialog({ 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)); + + // 기본계약이 필요한 계약서 목록 수집 + 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: "기술" + }); + } } - setGeneratedPdfs(pdfsMap); // UI 업데이트용 + 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("PDF 생성 실패:", error); - toast.error("기본계약서 생성에 실패했습니다."); + console.error("RFQ 발송 실패:", error); + toast.error("RFQ 발송에 실패했습니다."); + } finally { + setIsSending(false); setIsGeneratingPdfs(false); setPdfGenerationProgress(0); - return; + setCurrentGeneratingContract(""); + setSkipContractsForVendor({}); // 초기화 } - } - - // 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]); + }, [vendorsWithRecipients, selectedAttachments, additionalMessage, onSend, onOpenChange, rfqInfo, skipContractsForVendor]); // 전송 처리 const handleSend = async () => { @@ -712,7 +712,7 @@ export function SendRfqDialog({ <li>업체는 새로운 버전의 견적서를 작성해야 합니다.</li> <li>이전에 제출한 견적서는 더 이상 유효하지 않습니다.</li> </ul> - + {/* 기본계약 재발송 정보 */} <div className="mt-3 pt-3 border-t border-yellow-400"> <div className="space-y-2"> @@ -836,8 +836,8 @@ export function SendRfqDialog({ setSkipContractsForVendor(newSkipOptions); }} > - {Object.values(skipContractsForVendor).every(v => v) ? "전체 재생성" : - Object.values(skipContractsForVendor).every(v => !v) ? "전체 유지" : "전체 유지"} + {Object.values(skipContractsForVendor).every(v => v) ? "전체 재생성" : + Object.values(skipContractsForVendor).every(v => !v) ? "전체 유지" : "전체 유지"} </Button> </TooltipTrigger> <TooltipContent> @@ -993,7 +993,7 @@ export function SendRfqDialog({ [vendor.vendorId]: !checked })); }} - // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600" + // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600" /> <span className="text-xs"> {skipContractsForVendor[vendor.vendorId] ? "유지" : "재생성"} @@ -1002,8 +1002,8 @@ export function SendRfqDialog({ </TooltipTrigger> <TooltipContent> <p className="text-xs"> - {skipContractsForVendor[vendor.vendorId] - ? "기존 계약서를 그대로 유지합니다" + {skipContractsForVendor[vendor.vendorId] + ? "기존 계약서를 그대로 유지합니다" : "기존 계약서를 삭제하고 새로 생성합니다"} </p> </TooltipContent> @@ -1306,9 +1306,9 @@ export function SendRfqDialog({ onChange={(e) => setAdditionalMessage(e.target.value)} /> </div> - - {/* PDF 생성 진행 상황 표시 */} - {isGeneratingPdfs && ( + + {/* PDF 생성 진행 상황 표시 */} + {isGeneratingPdfs && ( <Alert className="border-blue-500 bg-blue-50"> <div className="space-y-3"> <div className="flex items-center gap-2"> @@ -1327,8 +1327,8 @@ export function SendRfqDialog({ </div> </Alert> )} - - + + </div> </div> @@ -1371,7 +1371,7 @@ export function SendRfqDialog({ </Button> </DialogFooter> </DialogContent> - + {/* 재발송 확인 다이얼로그 */} <AlertDialog open={showResendConfirmDialog} onOpenChange={setShowResendConfirmDialog}> <AlertDialogContent className="max-w-2xl"> @@ -1385,7 +1385,7 @@ export function SendRfqDialog({ <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> @@ -1398,7 +1398,7 @@ export function SendRfqDialog({ 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"> @@ -1422,7 +1422,7 @@ export function SendRfqDialog({ [vendor.vendorId]: !checked })); }} - // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600" + // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600" /> <span className="text-xs text-yellow-800"> {skipContractsForVendor[vendor.vendorId] ? "계약서 유지" : "계약서 재생성"} @@ -1433,43 +1433,43 @@ export function SendRfqDialog({ ); })} </div> - + {/* 전체 선택 버튼 */} - {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0 && + {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 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" /> @@ -1479,17 +1479,17 @@ export function SendRfqDialog({ <li>기존에 작성된 견적 데이터가 <strong>모두 초기화</strong>됩니다.</li> <li>업체는 처음부터 새로 견적서를 작성해야 합니다.</li> <li>이전에 제출한 견적서는 더 이상 유효하지 않습니다.</li> - {Object.entries(skipContractsForVendor).some(([vendorId, skip]) => !skip && + {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 className="text-orange-700 font-medium"> + ⚠️ 선택한 업체의 기존 기본계약서가 <strong>삭제</strong>되고 새로운 계약서가 발송됩니다. + </li> + )} <li>이 작업은 <strong>취소할 수 없습니다</strong>.</li> </ul> </AlertDescription> </Alert> - + <p className="text-sm text-muted-foreground"> 재발송을 진행하시겠습니까? </p> @@ -1503,7 +1503,7 @@ export function SendRfqDialog({ }}> 취소 </AlertDialogCancel> - <AlertDialogAction + <AlertDialogAction onClick={() => { setShowResendConfirmDialog(false); proceedWithSend(); |
