'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 { 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, FileText, CheckCircle, Info, RefreshCw, X, ChevronDown, Plus, UserPlus, Users } from 'lucide-react' import { getExistingBasicContractsForBidding } from '../../pre-quote/service' import { getActiveContractTemplates } from '../../service' import { useToast } from '@/hooks/use-toast' import { useTransition } from 'react' import { SelectTrigger } from '@/components/ui/select' import { SelectValue } from '@/components/ui/select' import { SelectContent } from '@/components/ui/select' import { SelectItem } from '@/components/ui/select' import { Select } from '@/components/ui/select' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Separator } from '@/components/ui/separator' interface VendorContact { id: number contactName: string contactEmail: string contactPhone?: string | null contactPosition?: string | null contactDepartment?: string | null } interface VendorContractRequirement { vendorId: number vendorName: string vendorCode?: string vendorCountry?: string vendorEmail?: string // 벤더의 기본 이메일 (vendors.email) contactPerson?: string contactEmail?: string ndaYn?: boolean generalGtcYn?: boolean projectGtcYn?: boolean agreementYn?: boolean biddingCompanyId: number biddingId: number } interface CustomEmail { id: string email: string name?: string } interface VendorWithContactInfo extends VendorContractRequirement { contacts: VendorContact[] selectedMainEmail: string additionalEmails: string[] customEmails: CustomEmail[] hasExistingContracts: boolean } 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 onSend: (data: { vendors: VendorWithContactInfo[] generatedPdfs: Array<{ key: string buffer: number[] fileName: string }> message?: string }) => Promise } export function BiddingInvitationDialog({ open, onOpenChange, vendors, biddingId, biddingTitle, onSend, }: BiddingInvitationDialogProps) { const { toast } = useToast() const [isPending, startTransition] = useTransition() // 기본계약 관련 상태 const [, setExistingContractsList] = React.useState>([]) const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false) const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0) const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState('') // 벤더 정보 상태 (담당자 선택 기능 포함) const [vendorData, setVendorData] = React.useState([]) // 기본계약서 템플릿 관련 상태 const [availableTemplates, setAvailableTemplates] = React.useState([]) const [selectedContracts, setSelectedContracts] = React.useState([]) const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false) const [additionalMessage, setAdditionalMessage] = React.useState('') // 커스텀 이메일 관련 상태 const [showCustomEmailForm, setShowCustomEmailForm] = React.useState>({}) const [customEmailInputs, setCustomEmailInputs] = React.useState>({}) const [customEmailCounter, setCustomEmailCounter] = React.useState(0) // 벤더 정보 업데이트 함수 const updateVendor = React.useCallback((vendorId: number, updates: Partial) => { setVendorData(prev => prev.map(vendor => vendor.vendorId === vendorId ? { ...vendor, ...updates } : vendor )) }, []) // CC 이메일 토글 const toggleAdditionalEmail = React.useCallback((vendorId: number, email: string) => { setVendorData(prev => prev.map(vendor => { if (vendor.vendorId === vendorId) { const additionalEmails = vendor.additionalEmails.includes(email) ? vendor.additionalEmails.filter(e => e !== email) : [...vendor.additionalEmails, email] return { ...vendor, additionalEmails } } return vendor })) }, []) // 커스텀 이메일 추가 const addCustomEmail = React.useCallback((vendorId: number) => { const input = customEmailInputs[vendorId] if (!input?.email) return setVendorData(prev => prev.map(vendor => { if (vendor.vendorId === vendorId) { const newCustomEmail: CustomEmail = { id: `custom-${customEmailCounter}`, email: input.email, name: input.name || input.email } return { ...vendor, customEmails: [...vendor.customEmails, newCustomEmail] } } return vendor })) setCustomEmailInputs(prev => ({ ...prev, [vendorId]: { email: '', name: '' } })) setCustomEmailCounter(prev => prev + 1) }, [customEmailInputs, customEmailCounter]) // 커스텀 이메일 제거 const removeCustomEmail = React.useCallback((vendorId: number, customEmailId: string) => { setVendorData(prev => prev.map(vendor => { if (vendor.vendorId === vendorId) { return { ...vendor, customEmails: vendor.customEmails.filter(ce => ce.id !== customEmailId), additionalEmails: vendor.additionalEmails.filter(email => !vendor.customEmails.find(ce => ce.id === customEmailId)?.email || email !== vendor.customEmails.find(ce => ce.id === customEmailId)?.email ) } } return vendor })) }, []) // 총 수신자 수 계산 const totalRecipientCount = React.useMemo(() => { return vendorData.reduce((sum, vendor) => { return sum + 1 + vendor.additionalEmails.length // 주 수신자 1명 + CC }, 0) }, [vendorData]) // 선택된 업체들 (사전견적에서 선정된 업체들만) const selectedVendors = React.useMemo(() => vendors.filter(vendor => vendor.ndaYn || vendor.generalGtcYn || vendor.projectGtcYn || vendor.agreementYn), [vendors] ) // 기존 계약이 있는 업체들 분리 const vendorsWithExistingContracts = React.useMemo(() => vendorData.filter(vendor => vendor.hasExistingContracts), [vendorData] ) // 다이얼로그가 열릴 때 기존 계약 조회, 템플릿 로드, 벤더 담당자 로드 React.useEffect(() => { if (open && selectedVendors.length > 0) { const fetchInitialData = async () => { setIsLoadingTemplates(true); try { const [existingContractsResult, templatesData] = await Promise.all([ getExistingBasicContractsForBidding(biddingId), getActiveContractTemplates(), ]); // 기존 계약 조회 const contracts = existingContractsResult.success ? existingContractsResult.contracts || [] : []; const typedContracts = contracts.map(c => ({ vendorId: c.vendorId || 0, biddingCompanyId: c.biddingCompanyId || 0 })); setExistingContractsList(typedContracts); // 템플릿 로드 (4개 타입만 필터링) const allowedTemplateNames = ['비밀', 'General GTC', '기술', '기술자료']; const rawTemplates = templatesData.templates || []; const filteredTemplates = rawTemplates.filter((template: BasicContractTemplate) => allowedTemplateNames.some(allowedName => template.templateName.includes(allowedName) || allowedName.includes(template.templateName) ) ); setAvailableTemplates(filteredTemplates); const initialSelected = filteredTemplates.map((template: BasicContractTemplate) => ({ templateId: template.id, templateName: template.templateName, contractType: template.templateName, checked: false })); setSelectedContracts(initialSelected); // 담당자 정보는 selectedVendors에 이미 포함되어 있음 // vendorData 초기화 (담당자 정보 포함) const initialVendorData: VendorWithContactInfo[] = selectedVendors.map(vendor => { const hasExistingContract = typedContracts.some((ec) => ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId ); // contacts 정보가 이미 selectedVendors에 포함되어 있음 const vendorContacts = (vendor.contacts || []).map(contact => ({ id: contact.id, contactName: contact.contactName, contactEmail: contact.contactEmail, contactPhone: contact.contactNumber, contactPosition: null, contactDepartment: null })); // 주 수신자 기본값: 벤더의 기본 이메일 (vendorEmail) const defaultEmail = vendor.vendorEmail || (vendorContacts.length > 0 ? vendorContacts[0].contactEmail : ''); console.log(defaultEmail, "defaultEmail"); return { ...vendor, contacts: vendorContacts, selectedMainEmail: defaultEmail, additionalEmails: [], customEmails: [], hasExistingContracts: hasExistingContract }; }); setVendorData(initialVendorData); } catch (error) { console.error('초기 데이터 로드 실패:', error); toast({ title: '오류', description: '기본 정보를 불러오는 데 실패했습니다.', variant: 'destructive', }); setAvailableTemplates([]); setSelectedContracts([]); setVendorData([]); } finally { setIsLoadingTemplates(false); } } fetchInitialData(); } }, [open, biddingId, selectedVendors, toast]); const handleOpenChange = (open: boolean) => { onOpenChange(open) if (!open) { setSelectedContracts([]) setAdditionalMessage('') setIsGeneratingPdfs(false) setPdfGenerationProgress(0) setCurrentGeneratingContract('') setVendorData([]) } } // 기본계약서 선택 토글 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); startTransition(async () => { try { let generatedPdfs: Array<{ key: string buffer: number[] fileName: string }> = [] const generatedPdfsMap = new Map() // 선택된 템플릿이 있는 경우에만 PDF 생성 if (selectedContractTemplates.length > 0) { setIsGeneratingPdfs(true) setPdfGenerationProgress(0) let generatedCount = 0; for (const vendorWithContact of vendorData) { // 기존 계약이 있는 경우 건너뛰기 if (vendorWithContact.hasExistingContracts) { console.log(`벤더 ${vendorWithContact.vendorName}는 사전견적에서 이미 기본계약을 받았으므로 건너뜁니다.`); generatedCount++; setPdfGenerationProgress((generatedCount / vendorData.length) * 100); continue; } for (const contract of selectedContractTemplates) { setCurrentGeneratingContract(`${vendorWithContact.vendorName} - ${contract.templateName}`); const templateDetails = availableTemplates.find(t => t.id === contract.templateId); if (templateDetails) { const pdfData = await generateBasicContractPdf(templateDetails, vendorWithContact.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 = `${vendorWithContact.vendorId}_${contractType}_${contract.templateName}`; generatedPdfsMap.set(key, pdfData); } } generatedCount++; setPdfGenerationProgress((generatedCount / vendorData.length) * 100); } setIsGeneratingPdfs(false); const pdfsArray = Array.from(generatedPdfsMap.entries()).map(([key, data]) => ({ key, buffer: data.buffer, fileName: data.fileName, })); generatedPdfs = pdfsArray; } await onSend({ vendors: vendorData, generatedPdfs: generatedPdfs, 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 && ( 기존 계약 정보 사전견적에서 이미 기본계약을 받은 업체가 있습니다. 해당 업체들은 계약서 재생성을 건너뜁니다. (본입찰 초대는 정상 진행됩니다) )} {/* 대상 업체 정보 - 테이블 형식 */} {/* 기본계약서 선택 */} 기본계약 선택 (선택사항) {/* 템플릿 로딩 */} {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}
  • ))}
)}
)}
{/* 추가 메시지 */}