From dd831478a3ab5ac7182903d41aa4b3e47f28224f Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 15 Sep 2025 10:13:11 +0000 Subject: (최겸) 구매 입찰 테스트 및 수정사항 반영 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx | 17 +- app/[lng]/evcp/(evcp)/bid/page.tsx | 12 +- components/bidding/bidding-conditions-edit.tsx | 81 ++- config/menuConfig.ts | 36 +- db/schema/bidding.ts | 2 + .../bidding-detail-vendor-toolbar-actions.tsx | 88 ++- .../detail/table/bidding-invitation-dialog.tsx | 692 ++++++++++++++++++ .../list/biddings-table-toolbar-actions.tsx | 9 +- lib/bidding/list/biddings-table.tsx | 25 +- lib/bidding/list/create-bidding-dialog.tsx | 78 ++- lib/bidding/pre-quote/service.ts | 383 +++++++++- .../table/bidding-pre-quote-invitation-dialog.tsx | 774 ++++++++++++++++++--- .../table/bidding-pre-quote-vendor-table.tsx | 2 - .../bidding-pre-quote-vendor-toolbar-actions.tsx | 3 + lib/bidding/service.ts | 87 ++- 15 files changed, 2098 insertions(+), 191 deletions(-) create mode 100644 lib/bidding/detail/table/bidding-invitation-dialog.tsx diff --git a/app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx index 490f1360..5c4e9b99 100644 --- a/app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx +++ b/app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx @@ -2,7 +2,7 @@ import { Metadata } from "next" import { Separator } from "@/components/ui/separator" import { SidebarNav } from "@/components/layout/sidebar-nav" -import { getBiddingById, getBiddingConditions } from "@/lib/bidding/service" +import { getBiddingById, getBiddingConditions, getActivePaymentTerms, getActiveIncoterms } from "@/lib/bidding/service" import { Bidding } from "@/db/schema/bidding" import { Button } from "@/components/ui/button" import { ArrowLeft } from "lucide-react" @@ -31,6 +31,13 @@ export default async function SettingsLayout({ const bidding: Bidding | null = await getBiddingById(idAsNumber) const biddingConditions = await getBiddingConditions(idAsNumber) + // 3) 옵션 데이터 가져오기 + const paymentTermsResult = await getActivePaymentTerms() + const incotermsResult = await getActiveIncoterms() + + const paymentTermsOptions = paymentTermsResult.success && 'data' in paymentTermsResult ? paymentTermsResult.data || [] : [] + const incotermsOptions = incotermsResult.success && 'data' in incotermsResult ? incotermsResult.data || [] : [] + // 3) 사이드바 메뉴 const sidebarNavItems = [ { @@ -71,9 +78,11 @@ export default async function SettingsLayout({ {/* 입찰 조건 */} {bidding && ( - )} diff --git a/app/[lng]/evcp/(evcp)/bid/page.tsx b/app/[lng]/evcp/(evcp)/bid/page.tsx index 0e129e8a..c477e4f8 100644 --- a/app/[lng]/evcp/(evcp)/bid/page.tsx +++ b/app/[lng]/evcp/(evcp)/bid/page.tsx @@ -6,7 +6,9 @@ import { getBiddingStatusCounts, getBiddingTypeCounts, getBiddingManagerCounts, - getBiddingMonthlyStats + getBiddingMonthlyStats, + getActivePaymentTerms, + getActiveIncoterms } from "@/lib/bidding/service" import { searchParamsCache } from "@/lib/bidding/validation" import { BiddingsPageHeader } from "@/lib/bidding/list/biddings-page-header" @@ -42,6 +44,8 @@ export default async function BiddingsPage(props: IndexPageProps) { getBiddingTypeCounts(), getBiddingManagerCounts(), getBiddingMonthlyStats(), + getActivePaymentTerms(), + getActiveIncoterms(), ]) return ( @@ -89,10 +93,12 @@ async function BiddingsStatsCardsWrapper({ Awaited>, Awaited>, Awaited>, - Awaited> + Awaited>, + Awaited>, + Awaited> ]> }) { - const [biddingsResult, statusCounts, typeCounts, managerCounts, monthlyStats] = await promises + const [biddingsResult, statusCounts, typeCounts, managerCounts, monthlyStats, paymentTermsResult, incotermsResult] = await promises return ( + incotermsOptions: Array<{code: string, description: string}> } -export function BiddingConditionsEdit({ biddingId, initialConditions }: BiddingConditionsEditProps) { +export function BiddingConditionsEdit({ biddingId, initialConditions, paymentTermsOptions, incotermsOptions }: BiddingConditionsEditProps) { const router = useRouter() const { toast } = useToast() const [isPending, startTransition] = useTransition() @@ -34,8 +36,8 @@ export function BiddingConditionsEdit({ biddingId, initialConditions }: BiddingC paymentTerms: initialConditions?.paymentTerms || "", taxConditions: initialConditions?.taxConditions || "", incoterms: initialConditions?.incoterms || "", - contractDeliveryDate: initialConditions?.contractDeliveryDate - ? new Date(initialConditions.contractDeliveryDate).toISOString().split('T')[0] + contractDeliveryDate: initialConditions?.contractDeliveryDate + ? new Date(initialConditions.contractDeliveryDate).toISOString().split('T')[0] : "", shippingPort: initialConditions?.shippingPort || "", destinationPort: initialConditions?.destinationPort || "", @@ -43,6 +45,7 @@ export function BiddingConditionsEdit({ biddingId, initialConditions }: BiddingC sparePartOptions: initialConditions?.sparePartOptions || "", }) + const handleSave = () => { startTransition(async () => { try { @@ -51,14 +54,15 @@ export function BiddingConditionsEdit({ biddingId, initialConditions }: BiddingC if (result.success) { toast({ title: "성공", - description: result.message, + description: (result as { success: true; message: string }).message, + variant: "default", }) setIsEditing(false) router.refresh() } else { toast({ title: "오류", - description: result.error, + description: (result as { success: false; error: string }).error || "입찰 조건 업데이트 중 오류가 발생했습니다.", variant: "destructive", }) } @@ -108,7 +112,12 @@ export function BiddingConditionsEdit({ biddingId, initialConditions }: BiddingC
-

{conditions.paymentTerms || "미설정"}

+

+ {conditions.paymentTerms + ? paymentTermsOptions.find(opt => opt.code === conditions.paymentTerms)?.code || conditions.paymentTerms + : "미설정" + } +

@@ -116,7 +125,12 @@ export function BiddingConditionsEdit({ biddingId, initialConditions }: BiddingC
-

{conditions.incoterms || "미설정"}

+

+ {conditions.incoterms + ? incotermsOptions.find(opt => opt.code === conditions.incoterms)?.code || conditions.incoterms + : "미설정" + } +

@@ -180,15 +194,30 @@ export function BiddingConditionsEdit({ biddingId, initialConditions }: BiddingC
- setConditions(prev => ({ + onValueChange={(value) => setConditions(prev => ({ ...prev, - paymentTerms: e.target.value + paymentTerms: value }))} - /> + > + + + + + {paymentTermsOptions.length > 0 ? ( + paymentTermsOptions.map((option) => ( + + {option.code} {option.description && `(${option.description})`} + + )) + ) : ( + + 데이터 없음 + + )} + +
@@ -206,7 +235,7 @@ export function BiddingConditionsEdit({ biddingId, initialConditions }: BiddingC
-
diff --git a/config/menuConfig.ts b/config/menuConfig.ts index 40e0102f..4e468347 100644 --- a/config/menuConfig.ts +++ b/config/menuConfig.ts @@ -342,10 +342,16 @@ export const mainNav: MenuSection[] = [ descriptionKey: "menu.procurement.po_issuance_desc", groupKey: "groups.order_management" }, + // { + // titleKey: "menu.procurement.po_amendment", + // href: "/evcp/poa", + // descriptionKey: "menu.procurement.po_amendment_desc", + // groupKey: "groups.order_management" + // }, { - titleKey: "menu.procurement.po_amendment", - href: "/evcp/poa", - descriptionKey: "menu.procurement.po_amendment_desc", + titleKey: "menu.procurement.pcr", + href: "/evcp/pcr", + descriptionKey: "menu.procurement.pcr_desc", groupKey: "groups.order_management" }, { @@ -670,10 +676,16 @@ export const procurementNav: MenuSection[] = [ descriptionKey: "menu.procurement.po_issuance_desc", groupKey: "groups.order_management" }, + // { + // titleKey: "menu.procurement.po_amendment", + // href: "/procurement/poa", + // descriptionKey: "menu.procurement.po_amendment_desc", + // groupKey: "groups.order_management" + // }, { - titleKey: "menu.procurement.po_amendment", - href: "/procurement/poa", - descriptionKey: "menu.procurement.po_amendment_desc", + titleKey: "menu.procurement.pcr", + href: "/procurement/pcr", + descriptionKey: "menu.procurement.pcr_desc", groupKey: "groups.order_management" }, { @@ -900,10 +912,16 @@ export const mainNavVendor: MenuSection[] = [ descriptionKey: "menu.vendor.procurement.po_desc", groupKey: "groups.order_contract" }, + // { + // titleKey: "menu.vendor.procurement.po_amendment", + // href: `/partners/poa`, + // descriptionKey: "menu.vendor.procurement.po_amendment_desc", + // groupKey: "groups.order_contract" + // }, { - titleKey: "menu.vendor.procurement.po_amendment", - href: `/partners/poa`, - descriptionKey: "menu.vendor.procurement.po_amendment_desc", + titleKey: "menu.vendor.procurement.pcr", + href: `/partners/pcr`, + descriptionKey: "menu.vendor.procurement.pcr_desc", groupKey: "groups.order_contract" }, { diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts index c4913e7e..9015026d 100644 --- a/db/schema/bidding.ts +++ b/db/schema/bidding.ts @@ -160,6 +160,7 @@ export const biddings = pgTable('biddings', { // 상태 및 설정 status: biddingStatusEnum('status').default('bidding_generated').notNull(), isPublic: boolean('is_public').default(false), // 공개 입찰 여부 + isUrgent: boolean('is_urgent').default(false), // 긴급여부 // 담당자 정보 managerName: varchar('manager_name', { length: 100 }), // 입찰담당자 @@ -612,6 +613,7 @@ export const biddingListView = pgView('bidding_list_view').as((qb) => title: biddings.title, description: biddings.description, content: biddings.content, + isUrgent: biddings.isUrgent, // ═══════════════════════════════════════════════════════════════ // 계약 정보 diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx index 484b1b1e..0b707944 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -7,11 +7,14 @@ import { useTransition } from "react" import { Button } from "@/components/ui/button" import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign } from "lucide-react" import { QuotationVendor, registerBidding, markAsDisposal, createRebidding, awardBidding } from "@/lib/bidding/detail/service" +import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from "@/lib/bidding/pre-quote/service" + import { BiddingDetailVendorCreateDialog } from "./bidding-detail-vendor-create-dialog" import { BiddingDocumentUploadDialog } from "./bidding-document-upload-dialog" import { BiddingVendorPricesDialog } from "./bidding-vendor-prices-dialog" import { Bidding } from "@/db/schema" import { useToast } from "@/hooks/use-toast" +import { BiddingInvitationDialog } from "./bidding-invitation-dialog" interface BiddingDetailVendorToolbarActionsProps { table: Table @@ -40,6 +43,17 @@ export function BiddingDetailVendorToolbarActions({ const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false) const [isDocumentDialogOpen, setIsDocumentDialogOpen] = React.useState(false) const [isPricesDialogOpen, setIsPricesDialogOpen] = React.useState(false) + const [isBiddingInvitationDialogOpen, setIsBiddingInvitationDialogOpen] = React.useState(false) + const [selectedVendors, setSelectedVendors] = React.useState([]) + + // 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회 + React.useEffect(() => { + if (isBiddingInvitationDialogOpen) { + getSelectedVendors().then(vendors => { + setSelectedVendors(vendors) + }) + } + }, [isBiddingInvitationDialogOpen, biddingId]) const handleCreateVendor = () => { setIsCreateDialogOpen(true) @@ -54,23 +68,71 @@ export function BiddingDetailVendorToolbarActions({ } const handleRegister = () => { - startTransition(async () => { - const result = await registerBidding(bidding.id, userId) + // 본입찰 초대 다이얼로그 열기 + setIsBiddingInvitationDialogOpen(true) + } - if (result.success) { + const handleBiddingInvitationSend = async (data: any) => { + try { + // 1. 기본계약 발송 + const contractResult = await sendBiddingBasicContracts( + biddingId, + data.vendors, + data.generatedPdfs, + data.message + ) + + if (!contractResult.success) { toast({ - title: result.message, - description: result.message, + title: '기본계약 발송 실패', + description: contractResult.error, + variant: 'destructive', + }) + return + } + + // 2. 입찰 등록 진행 + const registerResult = await registerBidding(bidding.id, userId) + + if (registerResult.success) { + toast({ + title: '본입찰 초대 완료', + description: '기본계약 발송 및 본입찰 초대가 완료되었습니다.', }) + setIsBiddingInvitationDialogOpen(false) router.refresh() + onSuccess() } else { toast({ - title: result.error, - description: result.error, + title: '오류', + description: registerResult.error, variant: 'destructive', }) } - }) + } catch (error) { + console.error('본입찰 초대 실패:', error) + toast({ + title: '오류', + description: '본입찰 초대에 실패했습니다.', + variant: 'destructive', + }) + } + } + + // 선정된 업체들 조회 (서버 액션 함수 사용) + const getSelectedVendors = async () => { + try { + const result = await getSelectedVendorsForBidding(biddingId) + if (result.success) { + return result.vendors + } else { + console.error('선정된 업체 조회 실패:', result.error) + return [] + } + } catch (error) { + console.error('선정된 업체 조회 실패:', error) + return [] + } } const handleMarkAsDisposal = () => { @@ -234,6 +296,16 @@ export function BiddingDetailVendorToolbarActions({ targetPrice={bidding.targetPrice ? parseFloat(bidding.targetPrice.toString()) : null} currency={bidding.currency} /> + + ) } diff --git a/lib/bidding/detail/table/bidding-invitation-dialog.tsx b/lib/bidding/detail/table/bidding-invitation-dialog.tsx new file mode 100644 index 00000000..031231a1 --- /dev/null +++ b/lib/bidding/detail/table/bidding-invitation-dialog.tsx @@ -0,0 +1,692 @@ +'use client' + +import * as React from 'react' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { Progress } from '@/components/ui/progress' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { cn } from '@/lib/utils' +import { + Mail, + Building2, + Calendar, + FileText, + CheckCircle, + Info, + RefreshCw, + Plus, + X +} from 'lucide-react' +import { sendBiddingBasicContracts, getSelectedVendorsForBidding, getExistingBasicContractsForBidding } from '../../pre-quote/service' +import { getActiveContractTemplates } from '../../service' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface VendorContractRequirement { + vendorId: number + vendorName: string + vendorCode?: string + vendorCountry?: string + contactPerson?: string + contactEmail?: string + ndaYn?: boolean + generalGtcYn?: boolean + projectGtcYn?: boolean + agreementYn?: boolean + biddingCompanyId: number + biddingId: number +} + +interface BasicContractTemplate { + id: number + templateName: string + revision: number + status: string + filePath: string | null + validityPeriod: number | null + legalReviewRequired: boolean + createdAt: Date | null +} + +interface SelectedContract { + templateId: number + templateName: string + contractType: string + checked: boolean +} + +interface BiddingInvitationDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + vendors: VendorContractRequirement[] + biddingId: number + biddingTitle: string + projectName?: string + onSend: (data: { + vendors: Array<{ + vendorId: number + vendorName: string + vendorCode?: string + vendorCountry?: string + selectedMainEmail: string + additionalEmails: string[] + contractRequirements: { + ndaYn: boolean + generalGtcYn: boolean + projectGtcYn: boolean + agreementYn: boolean + } + biddingCompanyId: number + biddingId: number + hasExistingContracts?: boolean + }> + generatedPdfs: Array<{ + key: string + buffer: number[] + fileName: string + }> + message?: string + }) => Promise +} + +export function BiddingInvitationDialog({ + open, + onOpenChange, + vendors, + biddingId, + biddingTitle, + projectName, + onSend, +}: BiddingInvitationDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + + // 기본계약 관련 상태 + const [existingContracts, setExistingContracts] = React.useState([]) + const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false) + const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0) + const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState('') + + // 기본계약서 템플릿 관련 상태 + const [availableTemplates, setAvailableTemplates] = React.useState([]) + const [selectedContracts, setSelectedContracts] = React.useState([]) + const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false) + const [additionalMessage, setAdditionalMessage] = React.useState('') + + // 선택된 업체들 (사전견적에서 선정된 업체들만) + const selectedVendors = React.useMemo(() => + vendors.filter(vendor => vendor.ndaYn || vendor.generalGtcYn || vendor.projectGtcYn || vendor.agreementYn), + [vendors] + ) + + // 기존 계약이 있는 업체들과 없는 업체들 분리 + const vendorsWithExistingContracts = React.useMemo(() => + selectedVendors.filter(vendor => + existingContracts.some((ec: any) => + ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId + ) + ), + [selectedVendors, existingContracts] + ) + + const vendorsWithoutExistingContracts = React.useMemo(() => + selectedVendors.filter(vendor => + !existingContracts.some((ec: any) => + ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId + ) + ), + [selectedVendors, existingContracts] + ) + + // 다이얼로그가 열릴 때 기존 계약 조회 및 템플릿 로드 + React.useEffect(() => { + if (open) { + const fetchInitialData = async () => { + setIsLoadingTemplates(true); + try { + const [contractsResult, templatesData] = await Promise.all([ + getSelectedVendorsForBidding(biddingId), + getActiveContractTemplates() + ]); + + // 기존 계약 조회 (사전견적에서 보낸 기본계약 확인) - 서버 액션 사용 + const existingContracts = await getExistingBasicContractsForBidding(biddingId); + setExistingContracts(existingContracts.success ? existingContracts.contracts || [] : []); + + // 템플릿 로드 (4개 타입만 필터링) + // 4개 템플릿 타입만 필터링: 비밀, General, Project, 기술자료 + const allowedTemplateNames = ['비밀', 'General GTC', '기술', '기술자료']; + const rawTemplates = templatesData.templates || []; + const filteredTemplates = rawTemplates.filter((template: any) => + allowedTemplateNames.some(allowedName => + template.templateName.includes(allowedName) || + allowedName.includes(template.templateName) + ) + ); + setAvailableTemplates(filteredTemplates as any); + const initialSelected = filteredTemplates.map((template: any) => ({ + templateId: template.id, + templateName: template.templateName, + contractType: template.templateName, + checked: false + })); + setSelectedContracts(initialSelected); + + } catch (error) { + console.error('초기 데이터 로드 실패:', error); + toast({ + title: '오류', + description: '기본 정보를 불러오는 데 실패했습니다.', + variant: 'destructive', + }); + setAvailableTemplates([]); + setSelectedContracts([]); + } finally { + setIsLoadingTemplates(false); + } + } + fetchInitialData(); + } + }, [open, biddingId, toast]); + + const handleOpenChange = (open: boolean) => { + onOpenChange(open) + if (!open) { + setSelectedContracts([]) + setAdditionalMessage('') + setIsGeneratingPdfs(false) + setPdfGenerationProgress(0) + setCurrentGeneratingContract('') + } + } + + // 기본계약서 선택 토글 + const toggleContractSelection = (templateId: number) => { + setSelectedContracts(prev => + prev.map(contract => + contract.templateId === templateId + ? { ...contract, checked: !contract.checked } + : contract + ) + ) + } + + // 모든 기본계약서 선택/해제 + const toggleAllContractSelection = (checked: boolean | 'indeterminate') => { + setSelectedContracts(prev => + prev.map(contract => ({ ...contract, checked: !!checked })) + ) + } + + // PDF 생성 유틸리티 함수 + const generateBasicContractPdf = async ( + template: BasicContractTemplate, + vendorId: number + ): Promise<{ buffer: number[]; fileName: string }> => { + try { + // 1. 템플릿 데이터 준비 (서버 API 호출) + const prepareResponse = await fetch("/api/contracts/prepare-template", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + templateName: template.templateName, + vendorId, + }), + }); + + if (!prepareResponse.ok) { + throw new Error("템플릿 준비 실패"); + } + + const { template: preparedTemplate, templateData } = await prepareResponse.json(); + + // 2. 템플릿 파일 다운로드 + const templateResponse = await fetch("/api/contracts/get-template", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ templatePath: preparedTemplate.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 { default: WebViewer } = await import("@pdftron/webviewer"); + + const tempDiv = document.createElement('div'); + tempDiv.style.display = 'none'; + document.body.appendChild(tempDiv); + + try { + const instance = await WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + }, + tempDiv + ); + + const { Core } = instance; + const { createDocument } = Core; + + const templateDoc = await createDocument(templateFile, { + filename: templateFile.name, + extension: 'docx', + }); + + // 변수 치환 적용 + await templateDoc.applyTemplateValues(templateData); + + // PDF 변환 + const fileData = await templateDoc.getFileData(); + const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' }); + + const fileName = `${template.templateName}_${Date.now()}.pdf`; + + return { + buffer: Array.from(pdfBuffer), // Uint8Array를 일반 배열로 변환 + fileName + }; + + } finally { + if (tempDiv.parentNode) { + document.body.removeChild(tempDiv); + } + } + } catch (error) { + console.error(`기본계약 PDF 생성 실패 (${template.templateName}):`, error); + throw error; + } + }; + + const handleSendInvitation = () => { + const selectedContractTemplates = selectedContracts.filter(c => c.checked); + + if (selectedContractTemplates.length === 0) { + toast({ + title: '알림', + description: '발송할 기본계약서를 선택해주세요.', + variant: 'default', + }) + return + } + + startTransition(async () => { + try { + // 선택된 템플릿에 따라 PDF 생성 + setIsGeneratingPdfs(true) + setPdfGenerationProgress(0) + + const generatedPdfsMap = new Map() + + let generatedCount = 0; + for (const vendor of selectedVendors) { + // 사전견적에서 이미 기본계약을 보낸 벤더인지 확인 + const hasExistingContract = existingContracts.some((ec: any) => + ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId + ); + + if (hasExistingContract) { + console.log(`벤더 ${vendor.vendorName}는 사전견적에서 이미 기본계약을 받았으므로 건너뜁니다.`); + generatedCount++; + setPdfGenerationProgress((generatedCount / selectedVendors.length) * 100); + continue; + } + + for (const contract of selectedContractTemplates) { + setCurrentGeneratingContract(`${vendor.vendorName} - ${contract.templateName}`); + const templateDetails = availableTemplates.find(t => t.id === contract.templateId); + + if (templateDetails) { + const pdfData = await generateBasicContractPdf(templateDetails, vendor.vendorId); + // sendBiddingBasicContracts와 동일한 키 형식 사용 + let contractType = ''; + if (contract.templateName.includes('비밀')) { + contractType = 'NDA'; + } else if (contract.templateName.includes('General GTC')) { + contractType = 'General_GTC'; + } else if (contract.templateName.includes('기술') && !contract.templateName.includes('기술자료')) { + contractType = 'Project_GTC'; + } else if (contract.templateName.includes('기술자료')) { + contractType = '기술자료'; + } + const key = `${vendor.vendorId}_${contractType}_${contract.templateName}`; + generatedPdfsMap.set(key, pdfData); + } + } + generatedCount++; + setPdfGenerationProgress((generatedCount / selectedVendors.length) * 100); + } + + setIsGeneratingPdfs(false); + + const vendorData = selectedVendors.map(vendor => { + const hasExistingContract = existingContracts.some((ec: any) => + ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId + ); + + return { + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode, + vendorCountry: vendor.vendorCountry, + selectedMainEmail: vendor.contactEmail || '', + additionalEmails: [], + contractRequirements: { + ndaYn: vendor.ndaYn || false, + generalGtcYn: vendor.generalGtcYn || false, + projectGtcYn: vendor.projectGtcYn || false, + agreementYn: vendor.agreementYn || false + }, + biddingCompanyId: vendor.biddingCompanyId, + biddingId: vendor.biddingId, + hasExistingContracts: hasExistingContract + }; + }); + + const pdfsArray = Array.from(generatedPdfsMap.entries()).map(([key, data]) => ({ + key, + buffer: data.buffer, + fileName: data.fileName, + })); + + await onSend({ + vendors: vendorData, + generatedPdfs: pdfsArray, + message: additionalMessage + }); + + } catch (error) { + console.error('본입찰 초대 실패:', error); + toast({ + title: '오류', + description: '본입찰 초대 중 오류가 발생했습니다.', + variant: 'destructive', + }); + setIsGeneratingPdfs(false); + } + }) + } + + const selectedContractCount = selectedContracts.filter(c => c.checked).length; + + return ( + + + + + + 본입찰 초대 + + + {biddingTitle} - 선정된 {selectedVendors.length}개 업체에 본입찰 초대와 기본계약서를 발송합니다. + + + +
+
+ {/* 기존 계약 정보 */} + {vendorsWithExistingContracts.length > 0 && ( + + + 기존 계약 정보 + + 사전견적에서 이미 기본계약을 받은 업체가 있습니다. + 해당 업체들은 계약서 재생성을 건너뜁니다. + + + )} + + {/* 대상 업체 정보 */} + + + + + 초대 대상 업체 ({selectedVendors.length}개) + + + + {selectedVendors.length === 0 ? ( +
+ 초대 가능한 업체가 없습니다. +
+ ) : ( +
+ {/* 계약서가 생성될 업체들 */} + {vendorsWithoutExistingContracts.length > 0 && ( +
+

+ + 계약서 생성 대상 ({vendorsWithoutExistingContracts.length}개) +

+
+ {vendorsWithoutExistingContracts.map((vendor) => ( +
+ + {vendor.vendorName} + + {vendor.vendorCode} + +
+ ))} +
+
+ )} + + {/* 기존 계약이 있는 업체들 */} + {vendorsWithExistingContracts.length > 0 && ( +
+

+ + 기존 계약 존재 (건너뜀) ({vendorsWithExistingContracts.length}개) +

+
+ {vendorsWithExistingContracts.map((vendor) => ( +
+ + {vendor.vendorName} + + {vendor.vendorCode} + + + 계약 존재 + +
+ ))} +
+
+ )} +
+ )} +
+
+ + {/* 기본계약서 선택 */} + + + + + 기본계약 선택 + + + + {/* 템플릿 로딩 */} + {isLoadingTemplates ? ( +
+ +

기본계약서 템플릿을 불러오는 중...

+
+ ) : ( +
+ {availableTemplates.length === 0 ? ( +
+ +

사용 가능한 기본계약서 템플릿이 없습니다.

+
+ ) : ( + <> +
+
+ 0 && selectedContracts.every(c => c.checked)} + onCheckedChange={toggleAllContractSelection} + /> + +
+ + {selectedContractCount}개 선택됨 + +
+
+ {selectedContracts.map((contract) => ( +
toggleContractSelection(contract.templateId)} + > +
+ toggleContractSelection(contract.templateId)} + /> +
+ +

+ {contract.contractType} +

+
+
+
+ ))} +
+ + )} + + {/* 선택된 템플릿 요약 */} + {selectedContractCount > 0 && ( +
+
+ + + 선택된 기본계약서 ({selectedContractCount}개) + +
+
    + {selectedContracts.filter(c => c.checked).map((contract) => ( +
  • + {contract.templateName} +
  • + ))} +
+
+ )} +
+ )} +
+
+ + {/* 추가 메시지 */} +
+ +