diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx | 88 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-invitation-dialog.tsx | 692 | ||||
| -rw-r--r-- | lib/bidding/list/biddings-table-toolbar-actions.tsx | 9 | ||||
| -rw-r--r-- | lib/bidding/list/biddings-table.tsx | 25 | ||||
| -rw-r--r-- | lib/bidding/list/create-bidding-dialog.tsx | 78 | ||||
| -rw-r--r-- | lib/bidding/pre-quote/service.ts | 383 | ||||
| -rw-r--r-- | lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx | 774 | ||||
| -rw-r--r-- | lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx | 2 | ||||
| -rw-r--r-- | lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx | 3 | ||||
| -rw-r--r-- | lib/bidding/service.ts | 87 |
10 files changed, 1992 insertions, 149 deletions
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<QuotationVendor> @@ -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<any[]>([]) + + // 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회 + 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} /> + + <BiddingInvitationDialog + open={isBiddingInvitationDialogOpen} + onOpenChange={setIsBiddingInvitationDialogOpen} + vendors={selectedVendors} + biddingId={biddingId} + biddingTitle={bidding.title || ''} + projectName={bidding.projectName} + onSend={handleBiddingInvitationSend} + /> </> ) } 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<void> +} + +export function BiddingInvitationDialog({ + open, + onOpenChange, + vendors, + biddingId, + biddingTitle, + projectName, + onSend, +}: BiddingInvitationDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + + // 기본계약 관련 상태 + const [existingContracts, setExistingContracts] = React.useState<any[]>([]) + const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false) + const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0) + const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState('') + + // 기본계약서 템플릿 관련 상태 + const [availableTemplates, setAvailableTemplates] = React.useState<any[]>([]) + const [selectedContracts, setSelectedContracts] = React.useState<SelectedContract[]>([]) + 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<string, { buffer: number[], fileName: string }>() + + 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 ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Mail className="w-5 h-5" /> + 본입찰 초대 + </DialogTitle> + <DialogDescription> + {biddingTitle} - 선정된 {selectedVendors.length}개 업체에 본입찰 초대와 기본계약서를 발송합니다. + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-y-auto px-1" style={{ maxHeight: 'calc(70vh - 200px)' }}> + <div className="space-y-6 pr-4"> + {/* 기존 계약 정보 */} + {vendorsWithExistingContracts.length > 0 && ( + <Alert className="border-orange-500 bg-orange-50"> + <Info className="h-4 w-4 text-orange-600" /> + <AlertTitle className="text-orange-800">기존 계약 정보</AlertTitle> + <AlertDescription className="text-orange-700"> + 사전견적에서 이미 기본계약을 받은 업체가 있습니다. + 해당 업체들은 계약서 재생성을 건너뜁니다. + </AlertDescription> + </Alert> + )} + + {/* 대상 업체 정보 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="flex items-center gap-2 text-base"> + <Building2 className="h-5 w-5 text-green-600" /> + 초대 대상 업체 ({selectedVendors.length}개) + </CardTitle> + </CardHeader> + <CardContent> + {selectedVendors.length === 0 ? ( + <div className="text-center py-6 text-muted-foreground"> + 초대 가능한 업체가 없습니다. + </div> + ) : ( + <div className="space-y-4"> + {/* 계약서가 생성될 업체들 */} + {vendorsWithoutExistingContracts.length > 0 && ( + <div> + <h4 className="text-sm font-medium text-green-700 mb-2 flex items-center gap-2"> + <CheckCircle className="h-4 w-4 text-green-600" /> + 계약서 생성 대상 ({vendorsWithoutExistingContracts.length}개) + </h4> + <div className="space-y-2 max-h-32 overflow-y-auto"> + {vendorsWithoutExistingContracts.map((vendor) => ( + <div key={vendor.vendorId} className="flex items-center gap-2 text-sm p-2 bg-green-50 rounded border border-green-200"> + <CheckCircle className="h-4 w-4 text-green-600" /> + <span className="font-medium">{vendor.vendorName}</span> + <Badge variant="outline" className="text-xs"> + {vendor.vendorCode} + </Badge> + </div> + ))} + </div> + </div> + )} + + {/* 기존 계약이 있는 업체들 */} + {vendorsWithExistingContracts.length > 0 && ( + <div> + <h4 className="text-sm font-medium text-orange-700 mb-2 flex items-center gap-2"> + <X className="h-4 w-4 text-orange-600" /> + 기존 계약 존재 (건너뜀) ({vendorsWithExistingContracts.length}개) + </h4> + <div className="space-y-2 max-h-32 overflow-y-auto"> + {vendorsWithExistingContracts.map((vendor) => ( + <div key={vendor.vendorId} className="flex items-center gap-2 text-sm p-2 bg-orange-50 rounded border border-orange-200 opacity-75"> + <X className="h-4 w-4 text-orange-600" /> + <span className="text-muted-foreground">{vendor.vendorName}</span> + <Badge variant="outline" className="text-xs"> + {vendor.vendorCode} + </Badge> + <Badge variant="secondary" className="text-xs"> + 계약 존재 + </Badge> + </div> + ))} + </div> + </div> + )} + </div> + )} + </CardContent> + </Card> + + {/* 기본계약서 선택 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="flex items-center gap-2 text-base"> + <FileText className="h-5 w-5 text-blue-600" /> + 기본계약 선택 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* 템플릿 로딩 */} + {isLoadingTemplates ? ( + <div className="text-center py-6"> + <RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2 text-blue-600" /> + <p className="text-sm text-muted-foreground">기본계약서 템플릿을 불러오는 중...</p> + </div> + ) : ( + <div className="space-y-4"> + {availableTemplates.length === 0 ? ( + <div className="text-center py-8 text-muted-foreground"> + <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" /> + <p>사용 가능한 기본계약서 템플릿이 없습니다.</p> + </div> + ) : ( + <> + <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"> + <div className="flex items-center gap-2"> + <Checkbox + id="select-all-contracts" + checked={selectedContracts.length > 0 && selectedContracts.every(c => c.checked)} + onCheckedChange={toggleAllContractSelection} + /> + <Label htmlFor="select-all-contracts" className="font-medium"> + 전체 선택 ({availableTemplates.length}개 템플릿) + </Label> + </div> + <Badge variant="outline"> + {selectedContractCount}개 선택됨 + </Badge> + </div> + <div className="grid gap-3 max-h-60 overflow-y-auto"> + {selectedContracts.map((contract) => ( + <div + key={contract.templateId} + className={cn( + "flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer", + contract.checked && "border-blue-500 bg-blue-50" + )} + onClick={() => toggleContractSelection(contract.templateId)} + > + <div className="flex items-center gap-3"> + <Checkbox + id={`contract-${contract.templateId}`} + checked={contract.checked} + onCheckedChange={() => toggleContractSelection(contract.templateId)} + /> + <div className="flex-1"> + <Label + htmlFor={`contract-${contract.templateId}`} + className="font-medium cursor-pointer" + > + {contract.templateName} + </Label> + <p className="text-xs text-muted-foreground mt-1"> + {contract.contractType} + </p> + </div> + </div> + </div> + ))} + </div> + </> + )} + + {/* 선택된 템플릿 요약 */} + {selectedContractCount > 0 && ( + <div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg"> + <div className="flex items-center gap-2 mb-2"> + <CheckCircle className="h-4 w-4 text-green-600" /> + <span className="font-medium text-green-900 text-sm"> + 선택된 기본계약서 ({selectedContractCount}개) + </span> + </div> + <ul className="space-y-1 text-xs text-green-800 list-disc list-inside"> + {selectedContracts.filter(c => c.checked).map((contract) => ( + <li key={contract.templateId}> + {contract.templateName} + </li> + ))} + </ul> + </div> + )} + </div> + )} + </CardContent> + </Card> + + {/* 추가 메시지 */} + <div className="space-y-2"> + <Label htmlFor="invitationMessage" className="text-sm font-medium"> + 초대 메시지 (선택사항) + </Label> + <textarea + id="invitationMessage" + className="w-full min-h-[60px] p-3 text-sm border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-primary" + placeholder="업체에 전달할 추가 메시지를 입력하세요..." + value={additionalMessage} + 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> + + <DialogFooter className="flex-col sm:flex-row-reverse sm:justify-between items-center px-4 pt-4"> + <div className="flex gap-2 w-full sm:w-auto"> + <Button variant="outline" onClick={() => handleOpenChange(false)} className="w-full sm:w-auto"> + 취소 + </Button> + <Button + onClick={handleSendInvitation} + disabled={isPending || selectedContractCount === 0 || isGeneratingPdfs} + className="w-full sm:w-auto" + > + {isGeneratingPdfs ? ( + <> + <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> + 계약서 생성중... ({Math.round(pdfGenerationProgress)}%) + </> + ) : isPending ? ( + <> + <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> + 발송 중... + </> + ) : ( + <> + <Mail className="w-4 h-4 mr-2" /> + 본입찰 초대 발송 + </> + )} + </Button> + </div> + {/* {(selectedContractCount > 0) && ( + <div className="mt-4 sm:mt-0 text-sm text-muted-foreground"> + <p> + {selectedVendors.length}개 업체에 <strong>{selectedContractCount}개</strong>의 기본계약서를 발송합니다. + </p> + </div> + )} */} + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx index 70b48a36..2b7a9d7d 100644 --- a/lib/bidding/list/biddings-table-toolbar-actions.tsx +++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx @@ -22,9 +22,11 @@ import { CreateBiddingDialog } from "./create-bidding-dialog" interface BiddingsTableToolbarActionsProps { table: Table<BiddingListItem> + paymentTermsOptions: Array<{code: string, description: string}> + incotermsOptions: Array<{code: string, description: string}> } -export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActionsProps) { +export function BiddingsTableToolbarActions({ table, paymentTermsOptions, incotermsOptions }: BiddingsTableToolbarActionsProps) { const router = useRouter() const [isExporting, setIsExporting] = React.useState(false) @@ -54,7 +56,10 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio return ( <div className="flex items-center gap-2"> {/* 신규 생성 */} - <CreateBiddingDialog/> + <CreateBiddingDialog + paymentTermsOptions={paymentTermsOptions} + incotermsOptions={incotermsOptions} + /> {/* 개찰 (입찰 오픈) */} {/* {openEligibleBiddings.length > 0 && ( diff --git a/lib/bidding/list/biddings-table.tsx b/lib/bidding/list/biddings-table.tsx index 3b60c69b..2a8f98c3 100644 --- a/lib/bidding/list/biddings-table.tsx +++ b/lib/bidding/list/biddings-table.tsx @@ -12,7 +12,7 @@ import { useDataTable } from "@/hooks/use-data-table" import { DataTable } from "@/components/data-table/data-table" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" import { getBiddingsColumns } from "./biddings-table-columns" -import { getBiddings, getBiddingStatusCounts } from "@/lib/bidding/service" +import { getBiddings, getBiddingStatusCounts, getActivePaymentTerms, getActiveIncoterms, getBiddingTypeCounts, getBiddingManagerCounts, getBiddingMonthlyStats } from "@/lib/bidding/service" import { BiddingListItem } from "@/db/schema" import { BiddingsTableToolbarActions } from "./biddings-table-toolbar-actions" import { @@ -28,13 +28,26 @@ interface BiddingsTableProps { promises: Promise< [ Awaited<ReturnType<typeof getBiddings>>, - Awaited<ReturnType<typeof getBiddingStatusCounts>> + Awaited<ReturnType<typeof getBiddingStatusCounts>>, + Awaited<ReturnType<typeof getBiddingTypeCounts>>, // 추가 + Awaited<ReturnType<typeof getBiddingManagerCounts>>, // 추가 + Awaited<ReturnType<typeof getBiddingMonthlyStats>>, // 추가 + Awaited<ReturnType<typeof getActivePaymentTerms>>, + Awaited<ReturnType<typeof getActiveIncoterms>> ] > } export function BiddingsTable({ promises }: BiddingsTableProps) { - const [{ data, pageCount }, statusCounts] = React.use(promises) + const [biddingsResult, statusCounts, typeCounts, managerCounts, monthlyStats, paymentTermsResult, incotermsResult] = React.use(promises) + + // biddingsResult에서 data와 pageCount 추출 + const { data, pageCount } = biddingsResult + + const paymentTermsOptions = paymentTermsResult.success && 'data' in paymentTermsResult ? paymentTermsResult.data || [] : [] + const incotermsOptions = incotermsResult.success && 'data' in incotermsResult ? incotermsResult.data || [] : [] + console.log(paymentTermsOptions,"paymentTermsOptions") + console.log(incotermsOptions,"incotermsOptions") const [isCompact, setIsCompact] = React.useState<boolean>(false) const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false) const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false) @@ -164,7 +177,11 @@ export function BiddingsTable({ promises }: BiddingsTableProps) { compactStorageKey="biddingsTableCompact" onCompactChange={handleCompactChange} > - <BiddingsTableToolbarActions table={table} /> + <BiddingsTableToolbarActions + table={table} + paymentTermsOptions={paymentTermsOptions} + incotermsOptions={incotermsOptions} + /> </DataTableAdvancedToolbar> </DataTable> diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx index 57cc1002..4fc4fd7b 100644 --- a/lib/bidding/list/create-bidding-dialog.tsx +++ b/lib/bidding/list/create-bidding-dialog.tsx @@ -67,7 +67,7 @@ import { } from "@/components/ui/file-list" import { Checkbox } from "@/components/ui/checkbox" -import { createBidding, type CreateBiddingInput } from "@/lib/bidding/service" +import { createBidding, type CreateBiddingInput, getActivePaymentTerms, getActiveIncoterms } from "@/lib/bidding/service" import { createBiddingSchema, type CreateBiddingSchema @@ -116,6 +116,8 @@ interface PRItemInfo { quantityUnit: string totalWeight: string weightUnit: string + materialDescription: string + hasSpecDocument: boolean requestedDeliveryDate: string specFiles: File[] isRepresentative: boolean // 대표 아이템 여부 @@ -125,7 +127,12 @@ interface PRItemInfo { const TAB_ORDER = ["basic", "contract", "schedule", "conditions", "details", "manager"] as const type TabType = typeof TAB_ORDER[number] -export function CreateBiddingDialog() { +interface CreateBiddingDialogProps { + paymentTermsOptions?: Array<{code: string, description: string}> + incotermsOptions?: Array<{code: string, description: string}> +} + +export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions = [] }: CreateBiddingDialogProps) { const router = useRouter() const [isSubmitting, setIsSubmitting] = React.useState(false) const { data: session } = useSession() @@ -168,6 +175,7 @@ export function CreateBiddingDialog() { sparePartOptions: "", }) + // 사양설명회 파일 추가 const addMeetingFiles = (files: File[]) => { setSpecMeetingInfo(prev => ({ @@ -311,6 +319,8 @@ export function CreateBiddingDialog() { form.setValue("prNumber", representativePrNumber) }, [hasPrDocuments, representativePrNumber, form]) + + // 세션 정보로 담당자 정보 자동 채우기 React.useEffect(() => { if (session?.user) { @@ -331,8 +341,8 @@ export function CreateBiddingDialog() { } // 담당자 전화번호는 세션에 있다면 설정 (보통 세션에 전화번호는 없지만, 있다면) - if (session.user.phone) { - form.setValue("managerPhone", session.user.phone) + if ('phone' in session.user && session.user.phone) { + form.setValue("managerPhone", session.user.phone as string) } } }, [session, form]) @@ -340,7 +350,7 @@ export function CreateBiddingDialog() { // PR 아이템 추가 const addPRItem = () => { const newItem: PRItemInfo = { - id: `pr-${Date.now()}`, + id: `pr-${Math.random().toString(36).substr(2, 9)}`, prNumber: "", itemCode: "", itemInfo: "", @@ -348,6 +358,8 @@ export function CreateBiddingDialog() { quantityUnit: "EA", totalWeight: "", weightUnit: "KG", + materialDescription: "", + hasSpecDocument: false, requestedDeliveryDate: "", specFiles: [], isRepresentative: prItems.length === 0, // 첫 번째 아이템은 자동으로 대표 아이템 @@ -477,7 +489,7 @@ export function CreateBiddingDialog() { const result = await createBidding(extendedData, userId) if (result.success) { - toast.success(result.message || "입찰이 성공적으로 생성되었습니다.") + toast.success((result as { success: true; message: string }).message || "입찰이 성공적으로 생성되었습니다.") setOpen(false) router.refresh() @@ -624,7 +636,7 @@ export function CreateBiddingDialog() { > {/* 탭 영역 */} <div className="flex-1 overflow-hidden"> - <Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col"> + <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TabType)} className="h-full flex flex-col"> <div className="px-6"> <div className="flex space-x-1 bg-muted p-1 rounded-lg overflow-x-auto"> <button @@ -1305,14 +1317,30 @@ export function CreateBiddingDialog() { <label className="text-sm font-medium"> 지급조건 <span className="text-red-500">*</span> </label> - <Input - placeholder="예: 월말결제, 60일" + <Select value={biddingConditions.paymentTerms} - onChange={(e) => setBiddingConditions(prev => ({ + onValueChange={(value) => setBiddingConditions(prev => ({ ...prev, - paymentTerms: e.target.value + paymentTerms: value }))} - /> + > + <SelectTrigger> + <SelectValue placeholder="지급조건 선택" /> + </SelectTrigger> + <SelectContent> + {paymentTermsOptions.length > 0 ? ( + paymentTermsOptions.map((option) => ( + <SelectItem key={option.code} value={option.code}> + {option.code} {option.description && `(${option.description})`} + </SelectItem> + )) + ) : ( + <SelectItem value="loading" disabled> + 데이터를 불러오는 중... + </SelectItem> + )} + </SelectContent> + </Select> </div> <div className="space-y-2"> @@ -1333,14 +1361,30 @@ export function CreateBiddingDialog() { <label className="text-sm font-medium"> 운송조건(인코텀즈) <span className="text-red-500">*</span> </label> - <Input - placeholder="예: FOB, CIF 등" + <Select value={biddingConditions.incoterms} - onChange={(e) => setBiddingConditions(prev => ({ + onValueChange={(value) => setBiddingConditions(prev => ({ ...prev, - incoterms: e.target.value + incoterms: value }))} - /> + > + <SelectTrigger> + <SelectValue placeholder="인코텀즈 선택" /> + </SelectTrigger> + <SelectContent> + {incotermsOptions.length > 0 ? ( + incotermsOptions.map((option) => ( + <SelectItem key={option.code} value={option.code}> + {option.code} {option.description && `(${option.description})`} + </SelectItem> + )) + ) : ( + <SelectItem value="loading" disabled> + 데이터를 불러오는 중... + </SelectItem> + )} + </SelectContent> + </Select> </div> <div className="space-y-2"> diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts index 7a5db949..680a8ff5 100644 --- a/lib/bidding/pre-quote/service.ts +++ b/lib/bidding/pre-quote/service.ts @@ -2,13 +2,16 @@ import db from '@/db/db' import { biddingCompanies, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding' +import { basicContractTemplates } from '@/db/schema' import { vendors } from '@/db/schema/vendors' import { users } from '@/db/schema' import { sendEmail } from '@/lib/mail/sendEmail' -import { eq, inArray, and } from 'drizzle-orm' -import { saveFile } from '@/lib/file-stroage' -import { downloadFile } from '@/lib/file-download' +import { eq, inArray, and, ilike } from 'drizzle-orm' +import { mkdir, writeFile } from 'fs/promises' +import path from 'path' import { revalidateTag, revalidatePath } from 'next/cache' +import { basicContract } from '@/db/schema/basicContractDocumnet' +import { saveFile } from '@/lib/file-stroage' // userId를 user.name으로 변환하는 유틸리티 함수 async function getUserNameById(userId: string): Promise<string> { @@ -193,18 +196,40 @@ export async function updatePreQuoteSelection(companyIds: number[], isSelected: // 사전견적용 업체 삭제 export async function deleteBiddingCompany(id: number) { try { + // 1. 해당 업체의 초대 상태 확인 + const company = await db + .select({ invitationStatus: biddingCompanies.invitationStatus }) + .from(biddingCompanies) + .where(eq(biddingCompanies.id, id)) + .then(rows => rows[0]) + + if (!company) { + return { + success: false, + error: '해당 업체를 찾을 수 없습니다.' + } + } + + // 이미 초대가 발송된 경우(수락, 거절, 요청됨 등) 삭제 불가 + if (company.invitationStatus !== 'pending') { + return { + success: false, + error: '이미 초대를 보낸 업체는 삭제할 수 없습니다.' + } + } + await db.transaction(async (tx) => { - // 1. 먼저 관련된 조건 응답들 삭제 + // 2. 먼저 관련된 조건 응답들 삭제 await tx.delete(companyConditionResponses) .where(eq(companyConditionResponses.biddingCompanyId, id)) - // 2. biddingCompanies 레코드 삭제 + // 3. biddingCompanies 레코드 삭제 await tx.delete(biddingCompanies) .where(eq(biddingCompanies.id, id)) - }) + }) - return { - success: true, + return { + success: true, message: '업체가 성공적으로 삭제되었습니다.' } } catch (error) { @@ -1157,4 +1182,344 @@ export async function deletePreQuoteDocument( error: '문서 삭제에 실패했습니다.' } } - }
\ No newline at end of file + } + +// 기본계약 발송 (서버 액션) +export async function sendBiddingBasicContracts( + biddingId: number, + vendorData: Array<{ + vendorId: number + vendorName: string + vendorCode?: string + vendorCountry?: string + selectedMainEmail: string + additionalEmails: string[] + customEmails?: Array<{ email: string; name?: 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 +) { + try { + console.log("sendBiddingBasicContracts called with:", { biddingId, vendorData: vendorData.map(v => ({ vendorId: v.vendorId, biddingCompanyId: v.biddingCompanyId, biddingId: v.biddingId })) }); + + // 현재 사용자 정보 조회 (임시로 첫 번째 사용자 사용) + const [currentUser] = await db.select().from(users).limit(1) + + if (!currentUser) { + throw new Error("사용자 정보를 찾을 수 없습니다.") + } + + const results = [] + const savedContracts = [] + + // 트랜잭션 시작 + const contractsDir = path.join(process.cwd(), `${process.env.NAS_PATH}`, "contracts", "generated"); + await mkdir(contractsDir, { recursive: true }); + + const result = await db.transaction(async (tx) => { + // 각 벤더별로 기본계약 생성 및 이메일 발송 + for (const vendor of vendorData) { + // 기존 계약 확인 (biddingCompanyId 기준) + if (vendor.hasExistingContracts) { + console.log(`벤더 ${vendor.vendorName}는 이미 계약이 체결되어 있어 건너뜁니다.`) + continue + } + + // 벤더 정보 조회 + const [vendorInfo] = await tx + .select() + .from(vendors) + .where(eq(vendors.id, vendor.vendorId)) + .limit(1) + + if (!vendorInfo) { + console.error(`벤더 정보를 찾을 수 없습니다: ${vendor.vendorId}`) + continue + } + + // biddingCompany 정보 조회 (biddingCompanyId를 직접 사용) + console.log(`Looking for biddingCompany with id=${vendor.biddingCompanyId}`) + let [biddingCompanyInfo] = await tx + .select() + .from(biddingCompanies) + .where(eq(biddingCompanies.id, vendor.biddingCompanyId)) + .limit(1) + + console.log(`Found biddingCompanyInfo:`, biddingCompanyInfo) + if (!biddingCompanyInfo) { + console.error(`입찰 회사 정보를 찾을 수 없습니다: biddingCompanyId=${vendor.biddingCompanyId}`) + // fallback: biddingId와 vendorId로 찾기 시도 + console.log(`Fallback: Looking for biddingCompany with biddingId=${biddingId}, companyId=${vendor.vendorId}`) + const [fallbackCompanyInfo] = await tx + .select() + .from(biddingCompanies) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.companyId, vendor.vendorId) + )) + .limit(1) + console.log(`Fallback found biddingCompanyInfo:`, fallbackCompanyInfo) + if (fallbackCompanyInfo) { + console.log(`Using fallback biddingCompanyInfo`) + biddingCompanyInfo = fallbackCompanyInfo + } else { + console.log(`Available biddingCompanies for biddingId=${biddingId}:`, await tx.select().from(biddingCompanies).where(eq(biddingCompanies.biddingId, biddingId)).limit(10)) + continue + } + } + + // 계약 요구사항에 따라 계약서 생성 + const contractTypes: Array<{ type: string; templateName: string }> = [] + if (vendor.contractRequirements.ndaYn) contractTypes.push({ type: 'NDA', templateName: '비밀' }) + if (vendor.contractRequirements.generalGtcYn) contractTypes.push({ type: 'General_GTC', templateName: 'General GTC' }) + if (vendor.contractRequirements.projectGtcYn) contractTypes.push({ type: 'Project_GTC', templateName: '기술' }) + if (vendor.contractRequirements.agreementYn) contractTypes.push({ type: '기술자료', templateName: '기술자료' }) + console.log("contractTypes", contractTypes) + for (const contractType of contractTypes) { + // PDF 데이터 찾기 (include를 사용하여 유연하게 찾기) + console.log("generatedPdfs", generatedPdfs.map(pdf => pdf.key)) + const pdfData = generatedPdfs.find((pdf: any) => + pdf.key.includes(`${vendor.vendorId}_`) && + pdf.key.includes(`_${contractType.templateName}`) + ) + console.log("pdfData", pdfData, "for contractType", contractType) + if (!pdfData) { + console.error(`PDF 데이터를 찾을 수 없습니다: vendorId=${vendor.vendorId}, templateName=${contractType.templateName}`) + continue + } + + // 파일 저장 (rfq-last 방식) + const fileName = `${contractType.type}_${vendor.vendorCode || vendor.vendorId}_${vendor.biddingCompanyId}_${Date.now()}.pdf` + const filePath = path.join(contractsDir, fileName); + + await writeFile(filePath, Buffer.from(pdfData.buffer)); + + // 템플릿 정보 조회 (rfq-last 방식) + const [template] = await db + .select() + .from(basicContractTemplates) + .where( + and( + ilike(basicContractTemplates.templateName, `%${contractType.templateName}%`), + eq(basicContractTemplates.status, "ACTIVE") + ) + ) + .limit(1); + + console.log("템플릿", contractType.templateName, template); + + // 기존 계약이 있는지 확인 (rfq-last 방식) + const [existingContract] = await tx + .select() + .from(basicContract) + .where( + and( + eq(basicContract.templateId, template?.id), + eq(basicContract.vendorId, vendor.vendorId), + eq(basicContract.biddingCompanyId, biddingCompanyInfo.id) + ) + ) + .limit(1); + + let contractRecord; + + if (existingContract) { + // 기존 계약이 있으면 업데이트 + [contractRecord] = await tx + .update(basicContract) + .set({ + requestedBy: currentUser.id, + status: "PENDING", // 재발송 상태 + fileName: fileName, + filePath: `/contracts/generated/${fileName}`, + deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), + updatedAt: new Date(), + }) + .where(eq(basicContract.id, existingContract.id)) + .returning(); + + console.log("기존 계약 업데이트:", contractRecord.id); + } else { + // 새 계약 생성 + [contractRecord] = await tx + .insert(basicContract) + .values({ + templateId: template?.id || null, + vendorId: vendor.vendorId, + biddingCompanyId: biddingCompanyInfo.id, + rfqCompanyId: null, + generalContractId: null, + requestedBy: currentUser.id, + status: 'PENDING', + fileName: fileName, + filePath: `/contracts/generated/${fileName}`, + deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10일 후 + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + console.log("새 계약 생성:", contractRecord.id); + } + + results.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + contractId: contractRecord.id, + contractType: contractType.type, + fileName: fileName, + filePath: `/contracts/generated/${fileName}`, + }) + + // savedContracts에 추가 (rfq-last 방식) + // savedContracts.push({ + // vendorId: vendor.vendorId, + // vendorName: vendor.vendorName, + // templateName: contractType.templateName, + // contractId: contractRecord.id, + // fileName: fileName, + // isUpdated: !!existingContract, // 업데이트 여부 표시 + // }) + } + + // 이메일 발송 (선택사항) + if (vendor.selectedMainEmail) { + try { + await sendEmail({ + to: vendor.selectedMainEmail, + template: 'basic-contract-notification', + context: { + vendorName: vendor.vendorName, + biddingId: biddingId, + contractCount: contractTypes.length, + deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toLocaleDateString('ko-KR'), + loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${biddingId}`, + message: message || '', + currentYear: new Date().getFullYear(), + language: 'ko' + } + }) + } catch (emailError) { + console.error(`이메일 발송 실패 (${vendor.selectedMainEmail}):`, emailError) + // 이메일 발송 실패해도 계약 생성은 유지 + } + } + } + + return { + success: true, + message: `${results.length}개의 기본계약이 생성되었습니다.`, + results, + savedContracts, + totalContracts: savedContracts.length + } + }) + + return result + + } catch (error) { + console.error('기본계약 발송 실패:', error) + throw new Error( + error instanceof Error + ? error.message + : '기본계약 발송 중 오류가 발생했습니다.' + ) + } +} + +// 기존 기본계약 조회 (서버 액션) +export async function getExistingBasicContractsForBidding(biddingId: number) { + try { + // 해당 biddingId에 속한 biddingCompany들의 기존 기본계약 조회 + const existingContracts = await db + .select({ + id: basicContract.id, + vendorId: basicContract.vendorId, + biddingCompanyId: basicContract.biddingCompanyId, + biddingId: biddingCompanies.biddingId, + templateId: basicContract.templateId, + status: basicContract.status, + createdAt: basicContract.createdAt, + }) + .from(basicContract) + .leftJoin(biddingCompanies, eq(basicContract.biddingCompanyId, biddingCompanies.id)) + .where( + and( + eq(biddingCompanies.biddingId, biddingId), + ) + ) + + return { + success: true, + contracts: existingContracts + } + + } catch (error) { + console.error('기존 계약 조회 실패:', error) + return { + success: false, + error: '기존 계약 조회에 실패했습니다.' + } + } +} + +// 선정된 업체들 조회 (서버 액션) +export async function getSelectedVendorsForBidding(biddingId: number) { + try { + const selectedCompanies = await db + .select({ + id: biddingCompanies.id, + companyId: biddingCompanies.companyId, + companyName: vendors.vendorName, + companyCode: vendors.vendorCode, + companyCountry: vendors.country, + contactPerson: biddingCompanies.contactPerson, + contactEmail: biddingCompanies.contactEmail, + biddingId: biddingCompanies.biddingId, + }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isPreQuoteSelected, true) + )) + + return { + success: true, + vendors: selectedCompanies.map(company => ({ + vendorId: company.companyId, // 실제 vendor ID + vendorName: company.companyName || '', + vendorCode: company.companyCode, + vendorCountry: company.companyCountry || '대한민국', + contactPerson: company.contactPerson, + contactEmail: company.contactEmail, + biddingCompanyId: company.id, // biddingCompany ID + biddingId: company.biddingId, + ndaYn: true, // 모든 계약 타입을 활성화 (필요에 따라 조정) + generalGtcYn: true, + projectGtcYn: true, + agreementYn: true + })) + } + } catch (error) { + console.error('선정된 업체 조회 실패:', error) + return { + success: false, + error: '선정된 업체 조회에 실패했습니다.', + vendors: [] + } + } +}
\ No newline at end of file diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx index 1b0598b7..5fc0a0ee 100644 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx @@ -15,43 +15,236 @@ import { } from '@/components/ui/dialog' import { Badge } from '@/components/ui/badge' import { BiddingCompany } from './bidding-pre-quote-vendor-columns' -import { sendPreQuoteInvitations } from '../service' +import { sendPreQuoteInvitations, sendBiddingBasicContracts, getExistingBasicContractsForBidding } from '../service' +import { getActiveContractTemplates } from '../../service' import { useToast } from '@/hooks/use-toast' import { useTransition } from 'react' -import { Mail, Building2, Calendar } from 'lucide-react' +import { Mail, Building2, Calendar, FileText, CheckCircle, Info, RefreshCw } from 'lucide-react' +import { Progress } from '@/components/ui/progress' +import { Separator } from '@/components/ui/separator' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { cn } from '@/lib/utils' interface BiddingPreQuoteInvitationDialogProps { open: boolean onOpenChange: (open: boolean) => void companies: BiddingCompany[] + biddingId: number + biddingTitle: string + projectName?: string onSuccess: () => void } +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 // templateName을 contractType으로 사용 + checked: boolean +} + +// 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; + } +}; + export function BiddingPreQuoteInvitationDialog({ open, onOpenChange, companies, + biddingId, + biddingTitle, + projectName, onSuccess }: BiddingPreQuoteInvitationDialogProps) { const { toast } = useToast() const [isPending, startTransition] = useTransition() const [selectedCompanyIds, setSelectedCompanyIds] = React.useState<number[]>([]) const [preQuoteDeadline, setPreQuoteDeadline] = React.useState('') + const [additionalMessage, setAdditionalMessage] = React.useState('') + + // 기본계약 관련 상태 + const [existingContracts, setExistingContracts] = React.useState<any[]>([]) + const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false) + const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0) + const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState('') + + // 기본계약서 템플릿 관련 상태 + const [availableTemplates, setAvailableTemplates] = React.useState<BasicContractTemplate[]>([]) + const [selectedContracts, setSelectedContracts] = React.useState<SelectedContract[]>([]) + const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false) // 초대 가능한 업체들 (pending 상태인 업체들) - const invitableCompanies = companies.filter(company => + const invitableCompanies = React.useMemo(() => companies.filter(company => company.invitationStatus === 'pending' && company.companyName - ) + ), [companies]) + + // 다이얼로그가 열릴 때 기존 계약 조회 및 템플릿 로드 + React.useEffect(() => { + if (open) { + const fetchInitialData = async () => { + setIsLoadingTemplates(true); + try { + const [contractsResult, templatesData] = await Promise.all([ + getExistingBasicContractsForBidding(biddingId), + getActiveContractTemplates() + ]); + + // 기존 계약 조회 - 서버 액션 사용 + const existingContractsResult = await getExistingBasicContractsForBidding(biddingId); + setExistingContracts(existingContractsResult.success ? existingContractsResult.contracts || [] : []); + + // 템플릿 로드 (4개 타입만 필터링) + // 4개 템플릿 타입만 필터링: 비밀, General, Project, 기술자료 + const allowedTemplateNames = ['비밀', 'General GTC', '기술', '기술자료']; + const filteredTemplates = (templatesData.templates || []).filter((template: any) => + allowedTemplateNames.some(allowedName => + template.templateName.includes(allowedName) || + allowedName.includes(template.templateName) + ) + ); + setAvailableTemplates(filteredTemplates as BasicContractTemplate[]); + 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', + }); + setExistingContracts([]); + setAvailableTemplates([]); + setSelectedContracts([]); + } finally { + setIsLoadingTemplates(false); + } + } + fetchInitialData(); + } + }, [open, biddingId, toast]); - const handleSelectAll = (checked: boolean) => { + const handleSelectAll = (checked: boolean | 'indeterminate') => { if (checked) { - setSelectedCompanyIds(invitableCompanies.map(company => company.id)) + // 기존 계약이 없는 업체만 선택 + const availableCompanies = invitableCompanies.filter(company => + !existingContracts.some(ec => ec.vendorId === company.companyId) + ) + setSelectedCompanyIds(availableCompanies.map(company => company.id)) } else { setSelectedCompanyIds([]) } } const handleSelectCompany = (companyId: number, checked: boolean) => { + const company = invitableCompanies.find(c => c.id === companyId) + const hasExistingContract = company ? existingContracts.some(ec => ec.vendorId === company.companyId) : false + + if (hasExistingContract) { + toast({ + title: '선택 불가', + description: '이미 기본계약서를 받은 업체는 다시 선택할 수 없습니다.', + variant: 'default', + }) + return + } + if (checked) { setSelectedCompanyIds(prev => [...prev, companyId]) } else { @@ -59,6 +252,24 @@ export function BiddingPreQuoteInvitationDialog({ } } + // 기본계약서 선택 토글 + 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 })) + ) + } + const handleSendInvitations = () => { if (selectedCompanyIds.length === 0) { toast({ @@ -69,27 +280,145 @@ export function BiddingPreQuoteInvitationDialog({ return } - startTransition(async () => { - const response = await sendPreQuoteInvitations( - selectedCompanyIds, - preQuoteDeadline || undefined + const selectedContractTemplates = selectedContracts.filter(c => c.checked); + const companiesForContracts = invitableCompanies.filter(company => selectedCompanyIds.includes(company.id)); + + const vendorsToGenerateContracts = companiesForContracts.filter(company => + !existingContracts.some(ec => + ec.vendorId === company.companyId && ec.biddingCompanyId === company.id ) - - if (response.success) { + ); + + startTransition(async () => { + try { + // 1. 사전견적 초대 발송 + const invitationResponse = await sendPreQuoteInvitations( + selectedCompanyIds, + preQuoteDeadline || undefined + ) + + if (!invitationResponse.success) { + toast({ + title: '초대 발송 실패', + description: invitationResponse.error, + variant: 'destructive', + }) + return + } + + // 2. 기본계약 발송 (선택된 템플릿과 업체가 있는 경우) + let contractResponse: Awaited<ReturnType<typeof sendBiddingBasicContracts>> | null = null + if (selectedContractTemplates.length > 0 && selectedCompanyIds.length > 0) { + setIsGeneratingPdfs(true) + setPdfGenerationProgress(0) + + const generatedPdfsMap = new Map<string, { buffer: number[], fileName: string }>() + + let generatedCount = 0; + for (const vendor of vendorsToGenerateContracts) { + for (const contract of selectedContractTemplates) { + setCurrentGeneratingContract(`${vendor.companyName} - ${contract.templateName}`); + const templateDetails = availableTemplates.find(t => t.id === contract.templateId); + + if (templateDetails) { + const pdfData = await generateBasicContractPdf(templateDetails, vendor.companyId); + // 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.companyId}_${contractType}_${contract.templateName}`; + generatedPdfsMap.set(key, pdfData); + } + } + generatedCount++; + setPdfGenerationProgress((generatedCount / vendorsToGenerateContracts.length) * 100); + } + + setIsGeneratingPdfs(false); + + const vendorData = companiesForContracts.map(company => { + // 선택된 템플릿에 따라 contractRequirements 동적으로 설정 + const contractRequirements = { + ndaYn: selectedContractTemplates.some(c => c.templateName.includes('비밀')), + generalGtcYn: selectedContractTemplates.some(c => c.templateName.includes('General GTC')), + projectGtcYn: selectedContractTemplates.some(c => c.templateName.includes('기술') && !c.templateName.includes('기술자료')), + agreementYn: selectedContractTemplates.some(c => c.templateName.includes('기술자료')) + }; + + return { + vendorId: company.companyId, + vendorName: company.companyName || '', + vendorCode: company.companyCode, + vendorCountry: '대한민국', + selectedMainEmail: company.contactEmail || '', + contactPerson: company.contactPerson, + contactEmail: company.contactEmail, + biddingCompanyId: company.id, + biddingId: biddingId, + hasExistingContracts: existingContracts.some(ec => + ec.vendorId === company.companyId && ec.biddingCompanyId === company.id + ), + contractRequirements, + additionalEmails: [], + customEmails: [] + }; + }); + + const pdfsArray = Array.from(generatedPdfsMap.entries()).map(([key, data]) => ({ + key, + buffer: data.buffer, + fileName: data.fileName, + })); + + console.log("Calling sendBiddingBasicContracts with biddingId:", biddingId); + console.log("vendorData:", vendorData.map(v => ({ vendorId: v.vendorId, biddingCompanyId: v.biddingCompanyId, biddingId: v.biddingId }))); + + contractResponse = await sendBiddingBasicContracts( + biddingId, + vendorData, + pdfsArray, + additionalMessage + ); + } + + let successMessage = '사전견적 초대가 성공적으로 발송되었습니다.'; + if (contractResponse && contractResponse.success) { + successMessage += `\n${contractResponse.message}`; + } + toast({ title: '성공', - description: response.message, + description: successMessage, }) - setSelectedCompanyIds([]) - setPreQuoteDeadline('') - onOpenChange(false) - onSuccess() - } else { + + // 상태 초기화 + setSelectedCompanyIds([]); + setPreQuoteDeadline(''); + setAdditionalMessage(''); + setExistingContracts([]); + setIsGeneratingPdfs(false); + setPdfGenerationProgress(0); + setCurrentGeneratingContract(''); + setSelectedContracts(prev => prev.map(c => ({ ...c, checked: false }))); + + onOpenChange(false); + onSuccess(); + + } catch (error) { + console.error('발송 실패:', error); toast({ title: '오류', - description: response.error, + description: '발송 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', variant: 'destructive', - }) + }); + setIsGeneratingPdfs(false); } }) } @@ -99,113 +428,346 @@ export function BiddingPreQuoteInvitationDialog({ if (!open) { setSelectedCompanyIds([]) setPreQuoteDeadline('') + setAdditionalMessage('') + setExistingContracts([]) + setIsGeneratingPdfs(false) + setPdfGenerationProgress(0) + setCurrentGeneratingContract('') + setSelectedContracts([]) } } + const selectedContractCount = selectedContracts.filter(c => c.checked).length; + const selectedCompanyCount = selectedCompanyIds.length; + const companiesToReceiveContracts = invitableCompanies.filter(company => selectedCompanyIds.includes(company.id)); + + // 기존 계약이 없는 업체들만 계산 + const availableCompanies = invitableCompanies.filter(company => + !existingContracts.some(ec => ec.vendorId === company.companyId) + ); + const selectedAvailableCompanyCount = selectedCompanyIds.filter(id => + availableCompanies.some(company => company.id === id) + ).length; + + // 선택된 업체들 중 기존 계약이 있는 업체들 + const selectedCompaniesWithExistingContracts = invitableCompanies.filter(company => + selectedCompanyIds.includes(company.id) && + existingContracts.some(ec => ec.vendorId === company.companyId) + ); + return ( <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogContent className="sm:max-w-[600px]"> + <DialogContent className="sm:max-w-[800px] max-h-[90vh] flex flex-col"> <DialogHeader> <DialogTitle className="flex items-center gap-2"> <Mail className="w-5 h-5" /> - 사전견적 초대 발송 + 사전견적 초대 및 기본계약 발송 </DialogTitle> <DialogDescription> - 선택한 업체들에게 사전견적 요청을 발송합니다. + 선택한 업체들에게 사전견적 요청과 기본계약서를 발송합니다. </DialogDescription> </DialogHeader> - - <div className="py-4"> - {invitableCompanies.length === 0 ? ( - <div className="text-center py-8 text-muted-foreground"> - 초대 가능한 업체가 없습니다. + + <div className="flex-1 overflow-y-auto px-1" style={{ maxHeight: 'calc(70vh - 200px)' }}> + <div className="space-y-6 pr-4"> + {/* 견적 마감일 설정 */} + <div className="mb-6 p-4 border rounded-lg bg-muted/30"> + <Label htmlFor="preQuoteDeadline" className="text-sm font-medium mb-2 flex items-center gap-2"> + <Calendar className="w-4 h-4" /> + 견적 마감일 (선택사항) + </Label> + <Input + id="preQuoteDeadline" + type="datetime-local" + value={preQuoteDeadline} + onChange={(e) => setPreQuoteDeadline(e.target.value)} + className="w-full" + /> + <p className="text-xs text-muted-foreground mt-1"> + 설정하지 않으면 마감일 없이 초대가 발송됩니다. + </p> </div> - ) : ( - <> - {/* 견적마감일 설정 */} - <div className="mb-6 p-4 border rounded-lg bg-muted/30"> - <Label htmlFor="preQuoteDeadline" className="text-sm font-medium mb-2 flex items-center gap-2"> - <Calendar className="w-4 h-4" /> - 견적 마감일 (선택사항) - </Label> - <Input - id="preQuoteDeadline" - type="datetime-local" - value={preQuoteDeadline} - onChange={(e) => setPreQuoteDeadline(e.target.value)} - className="w-full" - /> - <p className="text-xs text-muted-foreground mt-1"> - 설정하지 않으면 마감일 없이 초대가 발송됩니다. - </p> - </div> - - {/* 전체 선택 */} - <div className="flex items-center space-x-2 mb-4 pb-2 border-b"> - <Checkbox - id="select-all" - checked={selectedCompanyIds.length === invitableCompanies.length} - onCheckedChange={handleSelectAll} - /> - <label htmlFor="select-all" className="font-medium"> - 전체 선택 ({invitableCompanies.length}개 업체) - </label> - </div> - - {/* 업체 목록 */} - <div className="space-y-3 max-h-80 overflow-y-auto"> - {invitableCompanies.map((company) => ( - <div key={company.id} className="flex items-center space-x-3 p-3 border rounded-lg"> - <Checkbox - id={`company-${company.id}`} - checked={selectedCompanyIds.includes(company.id)} - onCheckedChange={(checked) => handleSelectCompany(company.id, !!checked)} - /> - <div className="flex-1"> + + {/* 기존 계약 정보 알림 */} + {existingContracts.length > 0 && ( + <Alert className="border-orange-500 bg-orange-50"> + <Info className="h-4 w-4 text-orange-600" /> + <AlertTitle className="text-orange-800">기존 계약 정보</AlertTitle> + <AlertDescription className="text-orange-700"> + 이미 기본계약을 받은 업체가 있습니다. + 해당 업체들은 초대 대상에서 제외되며, 계약서 재생성도 건너뜁니다. + </AlertDescription> + </Alert> + )} + + {/* 업체 선택 섹션 */} + <Card className="border-2 border-dashed"> + <CardHeader className="pb-3"> + <CardTitle className="flex items-center gap-2 text-base"> + <Building2 className="h-5 w-5 text-green-600" /> + 초대 대상 업체 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {invitableCompanies.length === 0 ? ( + <div className="text-center py-8 text-muted-foreground"> + 초대 가능한 업체가 없습니다. + </div> + ) : ( + <> + <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"> <div className="flex items-center gap-2"> - <Building2 className="w-4 h-4" /> - <span className="font-medium">{company.companyName}</span> - <Badge variant="outline" className="text-xs"> - {company.companyCode} - </Badge> + <Checkbox + id="select-all-companies" + checked={selectedAvailableCompanyCount === availableCompanies.length && availableCompanies.length > 0} + onCheckedChange={handleSelectAll} + /> + <Label htmlFor="select-all-companies" className="font-medium"> + 전체 선택 ({availableCompanies.length}개 업체) + </Label> </div> - {company.notes && ( - <p className="text-sm text-muted-foreground mt-1"> - {company.notes} - </p> - )} + <Badge variant="outline"> + {selectedCompanyCount}개 선택됨 + </Badge> </div> - <Badge variant="outline"> - 대기중 - </Badge> + + <div className="space-y-3 max-h-80 overflow-y-auto"> + {invitableCompanies.map((company) => { + const hasExistingContract = existingContracts.some(ec => ec.vendorId === company.companyId); + return ( + <div key={company.id} className={cn("flex items-center space-x-3 p-3 border rounded-lg transition-colors", + selectedCompanyIds.includes(company.id) && !hasExistingContract && "border-green-500 bg-green-50", + hasExistingContract && "border-orange-500 bg-orange-50 opacity-75" + )}> + <Checkbox + id={`company-${company.id}`} + checked={selectedCompanyIds.includes(company.id)} + disabled={hasExistingContract} + onCheckedChange={(checked) => handleSelectCompany(company.id, !!checked)} + /> + <div className="flex-1"> + <div className="flex items-center gap-2"> + <span className={cn("font-medium", hasExistingContract && "text-muted-foreground")}> + {company.companyName} + </span> + <Badge variant="outline" className="text-xs"> + {company.companyCode} + </Badge> + {hasExistingContract && ( + <Badge variant="secondary" className="text-xs"> + <CheckCircle className="h-3 w-3 mr-1" /> + 계약 체결됨 + </Badge> + )} + </div> + {hasExistingContract && ( + <p className="text-xs text-orange-600 mt-1"> + 이미 기본계약서를 받은 업체입니다. 선택에서 제외됩니다. + </p> + )} + </div> + </div> + ) + })} + </div> + </> + )} + </CardContent> + </Card> + + {/* 선택된 업체 중 기존 계약이 있는 경우 경고 */} + {selectedCompaniesWithExistingContracts.length > 0 && ( + <Alert className="border-red-500 bg-red-50"> + <Info className="h-4 w-4 text-red-600" /> + <AlertTitle className="text-red-800">선택한 업체 중 제외될 업체</AlertTitle> + <AlertDescription className="text-red-700"> + 선택한 {selectedCompaniesWithExistingContracts.length}개 업체가 이미 기본계약서를 받았습니다. + 이 업체들은 초대 발송 및 계약서 생성에서 제외됩니다. + <br /> + <strong>실제 발송 대상: {selectedCompanyCount - selectedCompaniesWithExistingContracts.length}개 업체</strong> + </AlertDescription> + </Alert> + )} + + {/* 기본계약서 선택 섹션 */} + <Separator /> + <Card className="border-2 border-dashed"> + <CardHeader className="pb-3"> + <CardTitle className="flex items-center gap-2 text-base"> + <FileText className="h-5 w-5 text-blue-600" /> + 기본계약서 선택 (선택된 업체에만 발송) + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {isLoadingTemplates ? ( + <div className="text-center py-6"> + <RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2 text-blue-600" /> + <p className="text-sm text-muted-foreground">기본계약서 템플릿을 불러오는 중...</p> + </div> + ) : ( + <div className="space-y-4"> + {selectedCompanyCount === 0 && ( + <Alert className="border-red-500 bg-red-50"> + <Info className="h-4 w-4 text-red-600" /> + <AlertTitle className="text-red-800">알림</AlertTitle> + <AlertDescription className="text-red-700"> + 기본계약서를 발송할 업체를 먼저 선택해주세요. + </AlertDescription> + </Alert> + )} + {availableTemplates.length === 0 ? ( + <div className="text-center py-8 text-muted-foreground"> + <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" /> + <p>사용 가능한 기본계약서 템플릿이 없습니다.</p> + </div> + ) : ( + <> + <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"> + <div className="flex items-center gap-2"> + <Checkbox + id="select-all-contracts" + checked={selectedContracts.length > 0 && selectedContracts.every(c => c.checked)} + onCheckedChange={toggleAllContractSelection} + /> + <Label htmlFor="select-all-contracts" className="font-medium"> + 전체 선택 ({availableTemplates.length}개 템플릿) + </Label> + </div> + <Badge variant="outline"> + {selectedContractCount}개 선택됨 + </Badge> + </div> + <div className="grid gap-3 max-h-60 overflow-y-auto"> + {selectedContracts.map((contract) => ( + <div + key={contract.templateId} + className={cn( + "flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer", + contract.checked && "border-blue-500 bg-blue-50" + )} + onClick={() => toggleContractSelection(contract.templateId)} + > + <div className="flex items-center gap-3"> + <Checkbox + id={`contract-${contract.templateId}`} + checked={contract.checked} + onCheckedChange={() => toggleContractSelection(contract.templateId)} + /> + <div className="flex-1"> + <Label + htmlFor={`contract-${contract.templateId}`} + className="font-medium cursor-pointer" + > + {contract.templateName} + </Label> + <p className="text-xs text-muted-foreground mt-1"> + {contract.contractType} + </p> + </div> + </div> + </div> + ))} + </div> + </> + )} + {selectedContractCount > 0 && ( + <div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg"> + <div className="flex items-center gap-2 mb-2"> + <CheckCircle className="h-4 w-4 text-green-600" /> + <span className="font-medium text-green-900 text-sm"> + 선택된 기본계약서 ({selectedContractCount}개) + </span> + </div> + <ul className="space-y-1 text-xs text-green-800 list-disc list-inside"> + {selectedContracts.filter(c => c.checked).map((contract) => ( + <li key={contract.templateId}> + {contract.templateName} + </li> + ))} + </ul> + </div> + )} </div> - ))} - </div> - - {selectedCompanyIds.length > 0 && ( - <div className="mt-4 p-3 bg-primary/5 rounded-lg"> - <p className="text-sm text-primary"> - <strong>{selectedCompanyIds.length}개 업체</strong>에 사전견적 초대를 발송합니다. - </p> + )} + </CardContent> + </Card> + + {/* 추가 메시지 */} + <div className="space-y-2"> + <Label htmlFor="contractMessage" className="text-sm font-medium"> + 계약서 추가 메시지 (선택사항) + </Label> + <textarea + id="contractMessage" + className="w-full min-h-[60px] p-3 text-sm border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-primary" + placeholder="기본계약서와 함께 보낼 추가 메시지를 입력하세요..." + value={additionalMessage} + 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> - <DialogFooter> - <Button variant="outline" onClick={() => handleOpenChange(false)}> - 취소 - </Button> - <Button - onClick={handleSendInvitations} - disabled={isPending || selectedCompanyIds.length === 0} - > - <Mail className="w-4 h-4 mr-2" /> - 초대 발송 - </Button> + <DialogFooter className="flex-col sm:flex-row-reverse sm:justify-between items-center px-4 pt-4"> + <div className="flex gap-2 w-full sm:w-auto"> + <Button variant="outline" onClick={() => handleOpenChange(false)} className="w-full sm:w-auto"> + 취소 + </Button> + <Button + onClick={handleSendInvitations} + disabled={isPending || selectedCompanyCount === 0 || isGeneratingPdfs} + className="w-full sm:w-auto" + > + {isPending ? ( + <> + <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> + 발송 중... + </> + ) : ( + <> + <Mail className="w-4 h-4 mr-2" /> + 초대 발송 및 계약서 생성 + </> + )} + </Button> + </div> + {/* {(selectedCompanyCount > 0 || selectedContractCount > 0) && ( + <div className="mt-4 sm:mt-0 text-sm text-muted-foreground"> + {selectedCompanyCount > 0 && ( + <p> + <strong>{selectedCompanyCount}개 업체</strong>에 초대를 발송합니다. + </p> + )} + {selectedContractCount > 0 && selectedCompanyCount > 0 && ( + <p> + 이 중 <strong>{companiesToReceiveContracts.length}개 업체</strong>에 <strong>{selectedContractCount}개</strong>의 기본계약서를 발송합니다. + </p> + )} + </div> + )} */} </DialogFooter> </DialogContent> </Dialog> ) -} +}
\ No newline at end of file diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx index 7ea05721..5f600882 100644 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx @@ -108,8 +108,6 @@ export function BiddingPreQuoteVendorTableContent({ const [selectedCompanyForAttachments, setSelectedCompanyForAttachments] = React.useState<BiddingCompany | null>(null) const handleDelete = (company: BiddingCompany) => { - if (!confirm(`${company.companyName} 업체를 삭제하시겠습니까?`)) return - startTransition(async () => { const response = await deleteBiddingCompany(company.id) diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx index 6c209e2d..34e53fb2 100644 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx @@ -110,6 +110,9 @@ export function BiddingPreQuoteVendorToolbarActions({ open={isInvitationDialogOpen} onOpenChange={setIsInvitationDialogOpen} companies={biddingCompanies} + biddingId={biddingId} + biddingTitle={bidding.title} + projectName={bidding.projectName} onSuccess={onSuccess} /> diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index 70458a15..90a379e1 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -11,7 +11,10 @@ import { specificationMeetings, prDocuments, biddingConditions, - users + users, + basicContractTemplates, + paymentTerms, + incoterms } from '@/db/schema' import { eq, @@ -1336,4 +1339,86 @@ export async function updateBiddingConditions( error: error instanceof Error ? error.message : '입찰 조건 업데이트 중 오류가 발생했습니다.' } } +} + +// 활성 템플릿 조회 서버 액션 +// 입찰 조건 옵션 관련 서버 액션들 +export async function getActivePaymentTerms() { + try { + const result = await db + .select({ + code: paymentTerms.code, + description: paymentTerms.description, + isActive: paymentTerms.isActive, + createdAt: paymentTerms.createdAt, + }) + .from(paymentTerms) + .where(eq(paymentTerms.isActive, true)) + .orderBy(paymentTerms.createdAt) + + return { + success: true, + data: result + } + } catch (error) { + console.error('Error fetching active payment terms:', error) + return { + success: false, + error: '지급조건 조회 중 오류가 발생했습니다.' + } + } +} + +export async function getActiveIncoterms() { + try { + const result = await db + .select({ + code: incoterms.code, + description: incoterms.description, + isActive: incoterms.isActive, + createdAt: incoterms.createdAt, + }) + .from(incoterms) + .where(eq(incoterms.isActive, true)) + .orderBy(incoterms.createdAt) + + return { + success: true, + data: result + } + } catch (error) { + console.error('Error fetching active incoterms:', error) + return { + success: false, + error: '운송조건 조회 중 오류가 발생했습니다.' + } + } +} + +export async function getActiveContractTemplates() { + try { + // 활성 상태의 템플릿들 조회 + const templates = await db + .select({ + id: basicContractTemplates.id, + templateName: basicContractTemplates.templateName, + revision: basicContractTemplates.revision, + status: basicContractTemplates.status, + filePath: basicContractTemplates.filePath, + validityPeriod: basicContractTemplates.validityPeriod, + legalReviewRequired: basicContractTemplates.legalReviewRequired, + createdAt: basicContractTemplates.createdAt, + }) + .from(basicContractTemplates) + .where(eq(basicContractTemplates.status, 'ACTIVE')) + .orderBy(basicContractTemplates.templateName); + + return { + templates + }; + + } catch (error) { + console.error('활성 템플릿 조회 실패:', error); + throw new Error('템플릿 조회에 실패했습니다.'); + } }
\ No newline at end of file |
