diff options
Diffstat (limited to 'lib/bidding/detail/table/bidding-invitation-dialog.tsx')
| -rw-r--r-- | lib/bidding/detail/table/bidding-invitation-dialog.tsx | 718 |
1 files changed, 530 insertions, 188 deletions
diff --git a/lib/bidding/detail/table/bidding-invitation-dialog.tsx b/lib/bidding/detail/table/bidding-invitation-dialog.tsx index cd79850a..ffb1fcb3 100644 --- a/lib/bidding/detail/table/bidding-invitation-dialog.tsx +++ b/lib/bidding/detail/table/bidding-invitation-dialog.tsx @@ -14,7 +14,6 @@ import { 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' @@ -22,24 +21,45 @@ import { cn } from '@/lib/utils' import { Mail, Building2, - Calendar, FileText, CheckCircle, Info, RefreshCw, + X, + ChevronDown, Plus, - X + UserPlus, + Users } from 'lucide-react' -import { sendBiddingBasicContracts, getSelectedVendorsForBidding, getExistingBasicContractsForBidding } from '../../pre-quote/service' +import { getExistingBasicContractsForBidding } from '../../pre-quote/service' import { getActiveContractTemplates } from '../../service' +import { getVendorContacts } from '@/lib/vendors/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 @@ -50,6 +70,20 @@ interface VendorContractRequirement { 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 @@ -74,25 +108,8 @@ interface BiddingInvitationDialogProps { 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 - }> + vendors: VendorWithContactInfo[] generatedPdfs: Array<{ key: string buffer: number[] @@ -108,82 +125,206 @@ export function BiddingInvitationDialog({ vendors, biddingId, biddingTitle, - projectName, onSend, }: BiddingInvitationDialogProps) { const { toast } = useToast() const [isPending, startTransition] = useTransition() // 기본계약 관련 상태 - const [existingContracts, setExistingContracts] = React.useState<any[]>([]) + const [, setExistingContractsList] = React.useState<Array<{ vendorId: number; biddingCompanyId: number }>>([]) const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false) const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0) const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState('') + // 벤더 정보 상태 (담당자 선택 기능 포함) + const [vendorData, setVendorData] = React.useState<VendorWithContactInfo[]>([]) + // 기본계약서 템플릿 관련 상태 - const [availableTemplates, setAvailableTemplates] = React.useState<any[]>([]) + const [availableTemplates, setAvailableTemplates] = React.useState<BasicContractTemplate[]>([]) const [selectedContracts, setSelectedContracts] = React.useState<SelectedContract[]>([]) const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false) const [additionalMessage, setAdditionalMessage] = React.useState('') + // 커스텀 이메일 관련 상태 + const [showCustomEmailForm, setShowCustomEmailForm] = React.useState<Record<number, boolean>>({}) + const [customEmailInputs, setCustomEmailInputs] = React.useState<Record<number, { email: string; name: string }>>({}) + const [customEmailCounter, setCustomEmailCounter] = React.useState(0) + + // 벤더 정보 업데이트 함수 + const updateVendor = React.useCallback((vendorId: number, updates: Partial<VendorWithContactInfo>) => { + 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(() => - selectedVendors.filter(vendor => - existingContracts.some((ec: any) => - ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId - ) - ), - [selectedVendors, existingContracts] + vendorData.filter(vendor => vendor.hasExistingContracts), + [vendorData] ) - 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) { + if (open && selectedVendors.length > 0) { const fetchInitialData = async () => { setIsLoadingTemplates(true); try { - const [contractsResult, templatesData] = await Promise.all([ - getSelectedVendorsForBidding(biddingId), + const [existingContractsResult, templatesData] = await Promise.all([ + getExistingBasicContractsForBidding(biddingId), getActiveContractTemplates(), ]); - // 기존 계약 조회 (사전견적에서 보낸 기본계약 확인) - 서버 액션 사용 - const existingContracts = await getExistingBasicContractsForBidding(biddingId); - setExistingContracts(existingContracts.success ? existingContracts.contracts || [] : []); + // 기존 계약 조회 + const contracts = existingContractsResult.success ? existingContractsResult.contracts || [] : []; + const typedContracts = contracts.map(c => ({ + vendorId: c.vendorId || 0, + biddingCompanyId: c.biddingCompanyId || 0 + })); + setExistingContractsList(typedContracts); // 템플릿 로드 (4개 타입만 필터링) - // 4개 템플릿 타입만 필터링: 비밀, General, Project, 기술자료 const allowedTemplateNames = ['비밀', 'General GTC', '기술', '기술자료']; const rawTemplates = templatesData.templates || []; - const filteredTemplates = rawTemplates.filter((template: any) => + const filteredTemplates = rawTemplates.filter((template: BasicContractTemplate) => allowedTemplateNames.some(allowedName => template.templateName.includes(allowedName) || allowedName.includes(template.templateName) ) ); - setAvailableTemplates(filteredTemplates as any); - const initialSelected = filteredTemplates.map((template: any) => ({ + setAvailableTemplates(filteredTemplates); + const initialSelected = filteredTemplates.map((template: BasicContractTemplate) => ({ templateId: template.id, templateName: template.templateName, contractType: template.templateName, checked: false })); setSelectedContracts(initialSelected); + + // 벤더 담당자 정보 병렬로 가져오기 + const vendorContactsPromises = selectedVendors.map(vendor => + getVendorContacts({ + page: 1, + perPage: 100, + flags: [], + sort: [], + filters: [], + joinOperator: 'and', + search: '', + contactName: '', + contactPosition: '', + contactEmail: '', + contactPhone: '' + }, vendor.vendorId) + .then(result => ({ + vendorId: vendor.vendorId, + contacts: (result.data || []).map(contact => ({ + id: contact.id, + contactName: contact.contactName, + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone, + contactPosition: contact.contactPosition, + contactDepartment: contact.contactDepartment + })) + })) + .catch(() => ({ + vendorId: vendor.vendorId, + contacts: [] + })) + ); + + const vendorContactsResults = await Promise.all(vendorContactsPromises); + const vendorContactsMap = new Map(vendorContactsResults.map(result => [result.vendorId, result.contacts])); + + // vendorData 초기화 (담당자 정보 포함) + const initialVendorData: VendorWithContactInfo[] = selectedVendors.map(vendor => { + const hasExistingContract = typedContracts.some((ec) => + ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId + ); + const vendorContacts = vendorContactsMap.get(vendor.vendorId) || []; + + // 주 수신자 기본값: 벤더의 기본 이메일 (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({ @@ -193,13 +334,14 @@ export function BiddingInvitationDialog({ }); setAvailableTemplates([]); setSelectedContracts([]); + setVendorData([]); } finally { setIsLoadingTemplates(false); } } fetchInitialData(); } - }, [open, biddingId, toast]); + }, [open, biddingId, selectedVendors, toast]); const handleOpenChange = (open: boolean) => { onOpenChange(open) @@ -209,6 +351,7 @@ export function BiddingInvitationDialog({ setIsGeneratingPdfs(false) setPdfGenerationProgress(0) setCurrentGeneratingContract('') + setVendorData([]) } } @@ -245,32 +388,32 @@ export function BiddingInvitationDialog({ 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( { @@ -280,29 +423,29 @@ export function BiddingInvitationDialog({ }, 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); @@ -333,43 +476,39 @@ export function BiddingInvitationDialog({ setPdfGenerationProgress(0) 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}는 사전견적에서 이미 기본계약을 받았으므로 건너뜁니다.`); + for (const vendorWithContact of vendorData) { + // 기존 계약이 있는 경우 건너뛰기 + if (vendorWithContact.hasExistingContracts) { + console.log(`벤더 ${vendorWithContact.vendorName}는 사전견적에서 이미 기본계약을 받았으므로 건너뜁니다.`); generatedCount++; - setPdfGenerationProgress((generatedCount / selectedVendors.length) * 100); + setPdfGenerationProgress((generatedCount / vendorData.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 = '기술자료'; + 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); } - const key = `${vendor.vendorId}_${contractType}_${contract.templateName}`; - generatedPdfsMap.set(key, pdfData); } + generatedCount++; + setPdfGenerationProgress((generatedCount / vendorData.length) * 100); } - generatedCount++; - setPdfGenerationProgress((generatedCount / selectedVendors.length) * 100); - } setIsGeneratingPdfs(false); @@ -382,30 +521,6 @@ export function BiddingInvitationDialog({ generatedPdfs = pdfsArray; } - 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 - }; - }); - await onSend({ vendors: vendorData, generatedPdfs: generatedPdfs, @@ -428,7 +543,7 @@ export function BiddingInvitationDialog({ return ( <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogContent className="sm:max-w-[800px] max-h-[90vh] flex flex-col" style={{width:900, maxWidth:900}}> + <DialogContent className="sm:max-w-[800px] max-h-[90vh] flex flex-col" style={{ width: 900, maxWidth: 900 }}> <DialogHeader> <DialogTitle className="flex items-center gap-2"> <Mail className="w-5 h-5" /> @@ -453,72 +568,299 @@ export function BiddingInvitationDialog({ </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> - )} + {/* 대상 업체 정보 - 테이블 형식 */} + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2 text-sm font-medium"> + <Building2 className="h-4 w-4" /> + 초대 대상 업체 ({vendorData.length}) + </div> + <Badge variant="outline" className="flex items-center gap-1"> + <Users className="h-3 w-3" /> + 총 {totalRecipientCount}명 + </Badge> + </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"> - <X className="h-4 w-4 text-orange-600" /> - <span className="font-medium">{vendor.vendorName}</span> - <Badge variant="outline" className="text-xs"> - {vendor.vendorCode} - </Badge> - <Badge variant="secondary" className="text-xs bg-orange-100 text-orange-800"> - 계약 존재 (재생성 건너뜀) - </Badge> - <Badge variant="outline" className="text-xs border-green-500 text-green-700"> - 본입찰 초대 - </Badge> - </div> - ))} - </div> - </div> - )} - </div> - )} - </CardContent> - </Card> + {vendorData.length === 0 ? ( + <div className="text-center py-6 text-muted-foreground border rounded-lg"> + 초대 가능한 업체가 없습니다. + </div> + ) : ( + <div className="border rounded-lg overflow-hidden"> + <table className="w-full"> + <thead className="bg-muted/50 border-b"> + <tr> + <th className="text-left p-2 text-xs font-medium">No.</th> + <th className="text-left p-2 text-xs font-medium">업체명</th> + <th className="text-left p-2 text-xs font-medium">주 수신자</th> + <th className="text-left p-2 text-xs font-medium">CC</th> + <th className="text-left p-2 text-xs font-medium">작업</th> + </tr> + </thead> + <tbody> + {vendorData.map((vendor, index) => { + const allContacts = vendor.contacts || []; + const allEmails = [ + // 벤더의 기본 이메일을 첫 번째로 표시 + ...(vendor.vendorEmail ? [{ + value: vendor.vendorEmail, + label: `${vendor.vendorEmail}`, + email: vendor.vendorEmail, + type: 'vendor' as const + }] : []), + // 담당자 이메일들 + ...allContacts.map(c => ({ + value: c.contactEmail, + label: `${c.contactName} ${c.contactPosition ? `(${c.contactPosition})` : ''}`, + email: c.contactEmail, + type: 'contact' as const + })), + // 커스텀 이메일들 + ...vendor.customEmails.map(c => ({ + value: c.email, + label: c.name || c.email, + email: c.email, + type: 'custom' as const + })) + ]; + + const ccEmails = allEmails.filter(e => e.value !== vendor.selectedMainEmail); + const selectedMainEmailInfo = allEmails.find(e => e.value === vendor.selectedMainEmail); + const isFormOpen = showCustomEmailForm[vendor.vendorId]; + + return ( + <React.Fragment key={vendor.vendorId}> + <tr className="border-b hover:bg-muted/20"> + <td className="p-2"> + <div className="flex items-center gap-1"> + <div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-medium"> + {index + 1} + </div> + </div> + </td> + <td className="p-2"> + <div className="space-y-1"> + <div className="font-medium text-sm">{vendor.vendorName}</div> + <div className="flex items-center gap-1"> + <Badge variant="outline" className="text-xs"> + {vendor.vendorCountry || vendor.vendorCode} + </Badge> + </div> + </div> + </td> + <td className="p-2"> + <Select + value={vendor.selectedMainEmail} + onValueChange={(value) => updateVendor(vendor.vendorId, { selectedMainEmail: value })} + > + <SelectTrigger className="h-7 text-xs w-[200px]"> + <SelectValue placeholder="선택하세요"> + {selectedMainEmailInfo && ( + <div className="flex items-center gap-1"> + {selectedMainEmailInfo.type === 'custom' && <UserPlus className="h-3 w-3 text-green-500" />} + <span className="truncate">{selectedMainEmailInfo.label}</span> + </div> + )} + </SelectValue> + </SelectTrigger> + <SelectContent> + {allEmails.map((email) => ( + <SelectItem key={email.value} value={email.value} className="text-xs"> + <div className="flex items-center gap-1"> + {email.type === 'custom' && <UserPlus className="h-3 w-3 text-green-500" />} + <span>{email.label}</span> + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + {!vendor.selectedMainEmail && ( + <span className="text-xs text-red-500">필수</span> + )} + </td> + <td className="p-2"> + <Popover> + <PopoverTrigger asChild> + <Button variant="outline" className="h-7 text-xs"> + {vendor.additionalEmails.length > 0 + ? `${vendor.additionalEmails.length}명` + : "선택" + } + <ChevronDown className="ml-1 h-3 w-3" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-48 p-2"> + <div className="max-h-48 overflow-y-auto space-y-1"> + {ccEmails.map((email) => ( + <div key={email.value} className="flex items-center space-x-1 p-1"> + <Checkbox + checked={vendor.additionalEmails.includes(email.value)} + onCheckedChange={() => toggleAdditionalEmail(vendor.vendorId, email.value)} + className="h-3 w-3" + /> + <label className="text-xs cursor-pointer flex-1 truncate"> + {email.label} + </label> + </div> + ))} + </div> + </PopoverContent> + </Popover> + </td> + <td className="p-2"> + <div className="flex items-center gap-1"> + <Button + variant={isFormOpen ? "default" : "ghost"} + size="sm" + className="h-6 w-6 p-0" + onClick={() => { + setShowCustomEmailForm(prev => ({ + ...prev, + [vendor.vendorId]: !prev[vendor.vendorId] + })); + }} + > + {isFormOpen ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />} + </Button> + {vendor.customEmails.length > 0 && ( + <Badge variant="secondary" className="text-xs"> + +{vendor.customEmails.length} + </Badge> + )} + </div> + </td> + </tr> + + {/* 인라인 수신자 추가 폼 */} + {isFormOpen && ( + <tr className="bg-muted/10 border-b"> + <td colSpan={5} className="p-4"> + <div className="space-y-3"> + <div className="flex items-center justify-between mb-2"> + <div className="flex items-center gap-2 text-sm font-medium"> + <UserPlus className="h-4 w-4" /> + 수신자 추가 - {vendor.vendorName} + </div> + <Button + variant="ghost" + size="sm" + className="h-6 w-6 p-0" + onClick={() => setShowCustomEmailForm(prev => ({ + ...prev, + [vendor.vendorId]: false + }))} + > + <X className="h-3 w-3" /> + </Button> + </div> + + <div className="flex gap-2 items-end"> + <div className="w-[150px]"> + <Label className="text-xs mb-1 block">이름 (선택)</Label> + <Input + placeholder="홍길동" + className="h-8 text-sm" + value={customEmailInputs[vendor.vendorId]?.name || ''} + onChange={(e) => setCustomEmailInputs(prev => ({ + ...prev, + [vendor.vendorId]: { + ...prev[vendor.vendorId], + name: e.target.value + } + }))} + /> + </div> + <div className="flex-1"> + <Label className="text-xs mb-1 block">이메일 <span className="text-red-500">*</span></Label> + <Input + type="email" + placeholder="example@company.com" + className="h-8 text-sm" + value={customEmailInputs[vendor.vendorId]?.email || ''} + onChange={(e) => setCustomEmailInputs(prev => ({ + ...prev, + [vendor.vendorId]: { + ...prev[vendor.vendorId], + email: e.target.value + } + }))} + onKeyPress={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addCustomEmail(vendor.vendorId); + } + }} + /> + </div> + <Button + size="sm" + className="h-8 px-4" + onClick={() => addCustomEmail(vendor.vendorId)} + disabled={!customEmailInputs[vendor.vendorId]?.email} + > + <Plus className="h-3 w-3 mr-1" /> + 추가 + </Button> + <Button + variant="outline" + size="sm" + className="h-8 px-4" + onClick={() => { + setCustomEmailInputs(prev => ({ + ...prev, + [vendor.vendorId]: { email: '', name: '' } + })); + setShowCustomEmailForm(prev => ({ + ...prev, + [vendor.vendorId]: false + })); + }} + > + 취소 + </Button> + </div> + + {/* 추가된 커스텀 이메일 목록 */} + {vendor.customEmails.length > 0 && ( + <div className="mt-3 pt-3 border-t"> + <div className="text-xs text-muted-foreground mb-2">추가된 수신자 목록</div> + <div className="grid grid-cols-2 xl:grid-cols-3 gap-2"> + {vendor.customEmails.map((custom) => ( + <div key={custom.id} className="flex items-center justify-between bg-background rounded-md p-2"> + <div className="flex items-center gap-2 min-w-0"> + <UserPlus className="h-3 w-3 text-green-500 flex-shrink-0" /> + <div className="min-w-0"> + <div className="text-sm font-medium truncate">{custom.name}</div> + <div className="text-xs text-muted-foreground truncate">{custom.email}</div> + </div> + </div> + <Button + variant="ghost" + size="sm" + className="h-6 w-6 p-0 flex-shrink-0" + onClick={() => removeCustomEmail(vendor.vendorId, custom.id)} + > + <X className="h-3 w-3" /> + </Button> + </div> + ))} + </div> + </div> + )} + </div> + </td> + </tr> + )} + </React.Fragment> + ); + })} + </tbody> + </table> + </div> + )} + </div> + + <Separator /> {/* 기본계약서 선택 */} <Card> @@ -685,4 +1027,4 @@ export function BiddingInvitationDialog({ </DialogContent> </Dialog> ) -} +}
\ No newline at end of file |
