summaryrefslogtreecommitdiff
path: root/lib/bidding/detail/table
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding/detail/table')
-rw-r--r--lib/bidding/detail/table/bidding-detail-content.tsx201
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-columns.tsx23
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx328
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx8
-rw-r--r--lib/bidding/detail/table/bidding-invitation-dialog.tsx718
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