summaryrefslogtreecommitdiff
path: root/lib/bidding/detail/table/bidding-invitation-dialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding/detail/table/bidding-invitation-dialog.tsx')
-rw-r--r--lib/bidding/detail/table/bidding-invitation-dialog.tsx718
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