diff options
| author | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2025-11-10 11:25:19 +0900 |
|---|---|---|
| committer | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2025-11-10 11:25:19 +0900 |
| commit | a5501ad1d1cb836d2b2f84e9b0f06049e22c901e (patch) | |
| tree | 667ed8c5d6ec35b109190e9f976d66ae54def4ce /lib/bidding/detail/table | |
| parent | b0fe980376fcf1a19ff4b90851ca8b01f378fdc0 (diff) | |
| parent | f8a38907911d940cb2e8e6c9aa49488d05b2b578 (diff) | |
Merge remote-tracking branch 'origin/dujinkim' into master_homemaster
Diffstat (limited to 'lib/bidding/detail/table')
5 files changed, 748 insertions, 530 deletions
diff --git a/lib/bidding/detail/table/bidding-detail-content.tsx b/lib/bidding/detail/table/bidding-detail-content.tsx index 895016a2..05c7d567 100644 --- a/lib/bidding/detail/table/bidding-detail-content.tsx +++ b/lib/bidding/detail/table/bidding-detail-content.tsx @@ -9,8 +9,17 @@ import { BiddingDetailItemsDialog } from './bidding-detail-items-dialog' import { BiddingDetailTargetPriceDialog } from './bidding-detail-target-price-dialog' import { BiddingPreQuoteItemDetailsDialog } from '../../../bidding/pre-quote/table/bidding-pre-quote-item-details-dialog' import { getPrItemsForBidding } from '../../../bidding/pre-quote/service' +import { checkAllVendorsFinalSubmitted, performBidOpening } from '../bidding-actions' import { useToast } from '@/hooks/use-toast' import { useTransition } from 'react' +import { useSession } from 'next-auth/react' +import { BiddingNoticeEditor } from '@/lib/bidding/bidding-notice-editor' +import { getBiddingNotice } from '@/lib/bidding/service' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { FileText, Eye, CheckCircle2, AlertCircle } from 'lucide-react' interface BiddingDetailContentProps { bidding: Bidding @@ -27,12 +36,14 @@ export function BiddingDetailContent({ }: BiddingDetailContentProps) { const { toast } = useToast() const [isPending, startTransition] = useTransition() + const session = useSession() const [dialogStates, setDialogStates] = React.useState({ items: false, targetPrice: false, selectionReason: false, - award: false + award: false, + biddingNotice: false }) const [, setRefreshTrigger] = React.useState(0) @@ -42,14 +53,119 @@ export function BiddingDetailContent({ const [selectedVendorForDetails, setSelectedVendorForDetails] = React.useState<QuotationVendor | null>(null) const [prItemsForDialog, setPrItemsForDialog] = React.useState<any[]>([]) + // 입찰공고 관련 state + const [biddingNotice, setBiddingNotice] = React.useState<any>(null) + const [isBiddingNoticeLoading, setIsBiddingNoticeLoading] = React.useState(false) + + // 최종제출 현황 관련 state + const [finalSubmissionStatus, setFinalSubmissionStatus] = React.useState<{ + allSubmitted: boolean + totalCompanies: number + submittedCompanies: number + }>({ allSubmitted: false, totalCompanies: 0, submittedCompanies: 0 }) + const [isPerformingBidOpening, setIsPerformingBidOpening] = React.useState(false) + const handleRefresh = React.useCallback(() => { setRefreshTrigger(prev => prev + 1) }, []) + // 입찰공고 로드 함수 + const loadBiddingNotice = React.useCallback(async () => { + if (!bidding.id) return + + setIsBiddingNoticeLoading(true) + try { + const notice = await getBiddingNotice(bidding.id) + setBiddingNotice(notice) + } catch (error) { + console.error('Failed to load bidding notice:', error) + toast({ + title: '오류', + description: '입찰공고문을 불러오는데 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsBiddingNoticeLoading(false) + } + }, [bidding.id, toast]) + const openDialog = React.useCallback((type: keyof typeof dialogStates) => { setDialogStates(prev => ({ ...prev, [type]: true })) }, []) + // 최종제출 현황 로드 함수 + const loadFinalSubmissionStatus = React.useCallback(async () => { + if (!bidding.id) return + + try { + const status = await checkAllVendorsFinalSubmitted(bidding.id) + setFinalSubmissionStatus(status) + } catch (error) { + console.error('Failed to load final submission status:', error) + } + }, [bidding.id]) + + // 개찰 핸들러 + const handlePerformBidOpening = async (isEarly: boolean = false) => { + if (!session.data?.user?.id) { + toast({ + title: '권한 없음', + description: '로그인이 필요합니다.', + variant: 'destructive', + }) + return + } + + if (!finalSubmissionStatus.allSubmitted) { + toast({ + title: '개찰 불가', + description: `모든 벤더가 최종 제출해야 개찰할 수 있습니다. (${finalSubmissionStatus.submittedCompanies}/${finalSubmissionStatus.totalCompanies})`, + variant: 'destructive', + }) + return + } + + const message = isEarly ? '조기개찰을 진행하시겠습니까?' : '개찰을 진행하시겠습니까?' + if (!window.confirm(message)) { + return + } + + setIsPerformingBidOpening(true) + try { + const result = await performBidOpening(bidding.id, session.data.user.id.toString(), isEarly) + + if (result.success) { + toast({ + title: '개찰 완료', + description: result.message, + }) + // 페이지 새로고침 + window.location.reload() + } else { + toast({ + title: '개찰 실패', + description: result.error, + variant: 'destructive', + }) + } + } catch (error) { + console.error('Failed to perform bid opening:', error) + toast({ + title: '오류', + description: '개찰에 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsPerformingBidOpening(false) + } + } + + // 컴포넌트 마운트 시 입찰공고 및 최종제출 현황 로드 + React.useEffect(() => { + loadBiddingNotice() + loadFinalSubmissionStatus() + }, [loadBiddingNotice, loadFinalSubmissionStatus]) + const closeDialog = React.useCallback((type: keyof typeof dialogStates) => { setDialogStates(prev => ({ ...prev, [type]: false })) }, []) @@ -73,8 +189,91 @@ export function BiddingDetailContent({ }) }, [bidding.id, toast]) + // 개찰 버튼 표시 여부 (입찰평가중 상태에서만) + const showBidOpeningButtons = bidding.status === 'evaluation_of_bidding' + return ( <div className="space-y-6"> + {/* 입찰공고 편집 버튼 */} + <div className="flex justify-between items-center"> + <div> + <h2 className="text-2xl font-bold">입찰 상세</h2> + <p className="text-muted-foreground">{bidding.title}</p> + </div> + <Dialog open={dialogStates.biddingNotice} onOpenChange={(open) => setDialogStates(prev => ({ ...prev, biddingNotice: open }))}> + <DialogTrigger asChild> + <Button variant="outline" className="gap-2"> + <FileText className="h-4 w-4" /> + 입찰공고 편집 + </Button> + </DialogTrigger> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden"> + <DialogHeader> + <DialogTitle>입찰공고 편집</DialogTitle> + </DialogHeader> + <div className="max-h-[60vh] overflow-y-auto"> + <BiddingNoticeEditor + initialData={biddingNotice} + biddingId={bidding.id} + onSaveSuccess={() => setDialogStates(prev => ({ ...prev, biddingNotice: false }))} + /> + </div> + </DialogContent> + </Dialog> + </div> + + {/* 최종제출 현황 및 개찰 버튼 */} + {showBidOpeningButtons && ( + <Card> + <CardContent className="pt-6"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-4"> + <div> + <div className="flex items-center gap-2 mb-1"> + {finalSubmissionStatus.allSubmitted ? ( + <CheckCircle2 className="h-5 w-5 text-green-600" /> + ) : ( + <AlertCircle className="h-5 w-5 text-yellow-600" /> + )} + <h3 className="text-lg font-semibold">최종제출 현황</h3> + </div> + <div className="flex items-center gap-2"> + <span className="text-sm text-muted-foreground"> + 최종 제출: {finalSubmissionStatus.submittedCompanies}/{finalSubmissionStatus.totalCompanies}개 업체 + </span> + {finalSubmissionStatus.allSubmitted ? ( + <Badge variant="default">모든 업체 제출 완료</Badge> + ) : ( + <Badge variant="secondary">제출 대기 중</Badge> + )} + </div> + </div> + </div> + + {/* 개찰 버튼들 */} + <div className="flex gap-2"> + <Button + onClick={() => handlePerformBidOpening(false)} + disabled={!finalSubmissionStatus.allSubmitted || isPerformingBidOpening} + variant="default" + > + <Eye className="h-4 w-4 mr-2" /> + {isPerformingBidOpening ? '처리 중...' : '개찰'} + </Button> + <Button + onClick={() => handlePerformBidOpening(true)} + disabled={!finalSubmissionStatus.allSubmitted || isPerformingBidOpening} + variant="outline" + > + <Eye className="h-4 w-4 mr-2" /> + {isPerformingBidOpening ? '처리 중...' : '조기개찰'} + </Button> + </div> + </div> + </CardContent> + </Card> + )} + <BiddingDetailVendorTableContent biddingId={bidding.id} bidding={bidding} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx index 1de7c768..10085e55 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx @@ -130,17 +130,24 @@ export function getBiddingDetailVendorColumns({ }, }, { - accessorKey: 'status', + accessorKey: 'invitationStatus', header: '상태', cell: ({ row }) => { - const status = row.original.status - const variant = status === 'selected' ? 'default' : - status === 'submitted' ? 'secondary' : - status === 'rejected' ? 'destructive' : 'outline' + const invitationStatus = row.original.invitationStatus + const variant = invitationStatus === 'bidding_submitted' ? 'default' : + invitationStatus === 'pre_quote_submitted' ? 'secondary' : + invitationStatus === 'bidding_declined' ? 'destructive' : 'outline' - const label = status === 'selected' ? '선정' : - status === 'submitted' ? '견적 제출' : - status === 'rejected' ? '거절' : '대기' + const label = invitationStatus === 'bidding_submitted' ? '응찰 완료' : + invitationStatus === 'pre_quote_submitted' ? '사전견적 제출' : + invitationStatus === 'bidding_declined' ? '응찰 거절' : + invitationStatus === 'pre_quote_declined' ? '사전견적 거절' : + invitationStatus === 'bidding_accepted' ? '응찰 참여' : + invitationStatus === 'pre_quote_accepted' ? '사전견적 참여' : + invitationStatus === 'pending' ? '대기' : + invitationStatus === 'pre_quote_sent' ? '사전견적 초대' : + invitationStatus === 'bidding_sent' ? '응찰 초대' : + invitationStatus || '알 수 없음' return <Badge variant={variant}>{label}</Badge> }, diff --git a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx deleted file mode 100644 index d0f85b14..00000000 --- a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx +++ /dev/null @@ -1,328 +0,0 @@ -'use client' - -import * as React from 'react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Textarea } from '@/components/ui/textarea' -import { Checkbox } from '@/components/ui/checkbox' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from '@/components/ui/command' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@/components/ui/popover' -import { Check, ChevronsUpDown, Search, Loader2, X, Plus } from 'lucide-react' -import { cn } from '@/lib/utils' -import { createBiddingDetailVendor } from '@/lib/bidding/detail/service' -import { searchVendorsForBidding } from '@/lib/bidding/service' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' -import { Badge } from '@/components/ui/badge' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' - -interface BiddingDetailVendorCreateDialogProps { - biddingId: number - open: boolean - onOpenChange: (open: boolean) => void - onSuccess: () => void -} - -interface Vendor { - id: number - vendorName: string - vendorCode: string - status: string -} - -export function BiddingDetailVendorCreateDialog({ - biddingId, - open, - onOpenChange, - onSuccess -}: BiddingDetailVendorCreateDialogProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - - // Vendor 검색 상태 - const [vendorList, setVendorList] = React.useState<Vendor[]>([]) - const [selectedVendors, setSelectedVendors] = React.useState<Vendor[]>([]) - const [vendorOpen, setVendorOpen] = React.useState(false) - - // 폼 상태 (간소화 - 필수 항목만) - const [formData, setFormData] = React.useState({ - awardRatio: 100, // 기본 100% - }) - - // 벤더 로드 - const loadVendors = React.useCallback(async () => { - try { - const result = await searchVendorsForBidding('', biddingId) // 빈 검색어로 모든 벤더 로드 - setVendorList(result || []) - } catch (error) { - console.error('Failed to load vendors:', error) - toast({ - title: '오류', - description: '벤더 목록을 불러오는데 실패했습니다.', - variant: 'destructive', - }) - setVendorList([]) - } - }, [biddingId]) - - React.useEffect(() => { - if (open) { - loadVendors() - } - }, [open, loadVendors]) - - // 초기화 - React.useEffect(() => { - if (!open) { - setSelectedVendors([]) - setFormData({ - awardRatio: 100, // 기본 100% - }) - } - }, [open]) - - // 벤더 추가 - const handleAddVendor = (vendor: Vendor) => { - if (!selectedVendors.find(v => v.id === vendor.id)) { - setSelectedVendors([...selectedVendors, vendor]) - } - setVendorOpen(false) - } - - // 벤더 제거 - const handleRemoveVendor = (vendorId: number) => { - setSelectedVendors(selectedVendors.filter(v => v.id !== vendorId)) - } - - // 이미 선택된 벤더인지 확인 - const isVendorSelected = (vendorId: number) => { - return selectedVendors.some(v => v.id === vendorId) - } - - const handleCreate = () => { - if (selectedVendors.length === 0) { - toast({ - title: '오류', - description: '업체를 선택해주세요.', - variant: 'destructive', - }) - return - } - - startTransition(async () => { - let successCount = 0 - let errorMessages: string[] = [] - - for (const vendor of selectedVendors) { - try { - const response = await createBiddingDetailVendor( - biddingId, - vendor.id - ) - - if (response.success) { - successCount++ - } else { - errorMessages.push(`${vendor.vendorName}: ${response.error}`) - } - } catch (error) { - errorMessages.push(`${vendor.vendorName}: 처리 중 오류가 발생했습니다.`) - } - } - - if (successCount > 0) { - toast({ - title: '성공', - description: `${successCount}개의 업체가 성공적으로 추가되었습니다.${errorMessages.length > 0 ? ` ${errorMessages.length}개는 실패했습니다.` : ''}`, - }) - onOpenChange(false) - resetForm() - onSuccess() - } - - if (errorMessages.length > 0 && successCount === 0) { - toast({ - title: '오류', - description: `업체 추가에 실패했습니다: ${errorMessages.join(', ')}`, - variant: 'destructive', - }) - } - }) - } - - const resetForm = () => { - setSelectedVendors([]) - setFormData({ - awardRatio: 100, // 기본 100% - }) - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-4xl max-h-[90vh] p-0 flex flex-col"> - {/* 헤더 */} - <DialogHeader className="p-6 pb-0"> - <DialogTitle>협력업체 추가</DialogTitle> - <DialogDescription> - 입찰에 참여할 업체를 선택하세요. 여러 개 선택 가능합니다. - </DialogDescription> - </DialogHeader> - - {/* 메인 컨텐츠 */} - <div className="flex-1 px-6 py-4 overflow-y-auto"> - <div className="space-y-6"> - {/* 업체 선택 카드 */} - <Card> - <CardHeader> - <CardTitle className="text-lg">업체 선택</CardTitle> - <CardDescription> - 입찰에 참여할 협력업체를 선택하세요. - </CardDescription> - </CardHeader> - <CardContent> - <div className="space-y-4"> - {/* 업체 추가 버튼 */} - <Popover open={vendorOpen} onOpenChange={setVendorOpen}> - <PopoverTrigger asChild> - <Button - variant="outline" - role="combobox" - aria-expanded={vendorOpen} - className="w-full justify-between" - disabled={vendorList.length === 0} - > - <span className="flex items-center gap-2"> - <Plus className="h-4 w-4" /> - 업체 선택하기 - </span> - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> - </Button> - </PopoverTrigger> - <PopoverContent className="w-[500px] p-0" align="start"> - <Command> - <CommandInput placeholder="업체명 또는 코드로 검색..." /> - <CommandList> - <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> - <CommandGroup> - {vendorList - .filter(vendor => !isVendorSelected(vendor.id)) - .map((vendor) => ( - <CommandItem - key={vendor.id} - value={`${vendor.vendorCode} ${vendor.vendorName}`} - onSelect={() => handleAddVendor(vendor)} - > - <div className="flex items-center gap-2 w-full"> - <Badge variant="outline" className="shrink-0"> - {vendor.vendorCode} - </Badge> - <span className="truncate">{vendor.vendorName}</span> - </div> - </CommandItem> - ))} - </CommandGroup> - </CommandList> - </Command> - </PopoverContent> - </Popover> - - {/* 선택된 업체 목록 */} - {selectedVendors.length > 0 && ( - <div className="space-y-2"> - <div className="flex items-center justify-between"> - <h4 className="text-sm font-medium">선택된 업체 ({selectedVendors.length}개)</h4> - </div> - <div className="space-y-2"> - {selectedVendors.map((vendor, index) => ( - <div - key={vendor.id} - className="flex items-center justify-between p-3 rounded-lg bg-secondary/50" - > - <div className="flex items-center gap-3"> - <span className="text-sm text-muted-foreground"> - {index + 1}. - </span> - <Badge variant="outline"> - {vendor.vendorCode} - </Badge> - <span className="text-sm font-medium"> - {vendor.vendorName} - </span> - </div> - <Button - variant="ghost" - size="sm" - onClick={() => handleRemoveVendor(vendor.id)} - className="h-8 w-8 p-0" - > - <X className="h-4 w-4" /> - </Button> - </div> - ))} - </div> - </div> - )} - - {selectedVendors.length === 0 && ( - <div className="text-center py-8 text-muted-foreground"> - <p className="text-sm">아직 선택된 업체가 없습니다.</p> - <p className="text-xs mt-1">위 버튼을 클릭하여 업체를 추가하세요.</p> - </div> - )} - </div> - </CardContent> - </Card> - </div> - </div> - - {/* 푸터 */} - <DialogFooter className="p-6 pt-0 border-t"> - <Button - variant="outline" - onClick={() => onOpenChange(false)} - disabled={isPending} - > - 취소 - </Button> - <Button - onClick={handleCreate} - disabled={isPending || selectedVendors.length === 0} - > - {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - {selectedVendors.length > 0 - ? `${selectedVendors.length}개 업체 추가` - : '업체 추가' - } - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -} 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 e3b5c288..4d987739 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -8,7 +8,7 @@ import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign } from "lu import { registerBidding, markAsDisposal, createRebidding } from "@/lib/bidding/detail/service" import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from "@/lib/bidding/pre-quote/service" -import { BiddingDetailVendorCreateDialog } from "./bidding-detail-vendor-create-dialog" +import { BiddingDetailVendorCreateDialog } from "../../../../components/bidding/manage/bidding-detail-vendor-create-dialog" import { BiddingDocumentUploadDialog } from "./bidding-document-upload-dialog" import { BiddingVendorPricesDialog } from "./bidding-vendor-prices-dialog" import { Bidding } from "@/db/schema" @@ -189,13 +189,11 @@ export function BiddingDetailVendorToolbarActions({ variant="default" size="sm" onClick={handleRegister} - disabled={isPending || bidding.status === 'received_quotation'} + disabled={isPending} > + {/* 입찰등록 시점 재정의 필요*/} <Send className="mr-2 h-4 w-4" /> 입찰 등록 - {bidding.status === 'received_quotation' && ( - <span className="text-xs text-muted-foreground ml-2">(사전견적 제출 완료)</span> - )} </Button> <Button variant="destructive" 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 |
