summaryrefslogtreecommitdiff
path: root/lib/bidding/detail
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding/detail')
-rw-r--r--lib/bidding/detail/bidding-actions.ts227
-rw-r--r--lib/bidding/detail/service.ts160
-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
7 files changed, 1072 insertions, 593 deletions
diff --git a/lib/bidding/detail/bidding-actions.ts b/lib/bidding/detail/bidding-actions.ts
new file mode 100644
index 00000000..70bba1c3
--- /dev/null
+++ b/lib/bidding/detail/bidding-actions.ts
@@ -0,0 +1,227 @@
+'use server'
+
+import db from '@/db/db'
+import { biddings, biddingCompanies, companyPrItemBids } from '@/db/schema/bidding'
+import { eq, and } from 'drizzle-orm'
+import { revalidateTag, revalidatePath } from 'next/cache'
+import { users } from '@/db/schema'
+
+// userId를 user.name으로 변환하는 유틸리티 함수
+async function getUserNameById(userId: string): Promise<string> {
+ try {
+ const user = await db
+ .select({ name: users.name })
+ .from(users)
+ .where(eq(users.id, parseInt(userId)))
+ .limit(1)
+
+ return user[0]?.name || userId
+ } catch (error) {
+ console.error('Failed to get user name:', error)
+ return userId
+ }
+}
+
+// 응찰 취소 서버 액션 (최종제출이 아닌 경우만 가능)
+export async function cancelBiddingResponse(
+ biddingCompanyId: number,
+ userId: string
+) {
+ try {
+ const userName = await getUserNameById(userId)
+
+ return await db.transaction(async (tx) => {
+ // 1. 현재 상태 확인 (최종제출 여부)
+ const [company] = await tx
+ .select({
+ isFinalSubmission: biddingCompanies.isFinalSubmission,
+ biddingId: biddingCompanies.biddingId,
+ })
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+ .limit(1)
+
+ if (!company) {
+ return {
+ success: false,
+ error: '업체 정보를 찾을 수 없습니다.'
+ }
+ }
+
+ // 최종제출한 경우 취소 불가
+ if (company.isFinalSubmission) {
+ return {
+ success: false,
+ error: '최종 제출된 응찰은 취소할 수 없습니다.'
+ }
+ }
+
+ // 2. 응찰 데이터 초기화
+ await tx
+ .update(biddingCompanies)
+ .set({
+ finalQuoteAmount: null,
+ finalQuoteSubmittedAt: null,
+ isFinalSubmission: false,
+ invitationStatus: 'bidding_cancelled', // 응찰 취소 상태
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ // 3. 품목별 견적 삭제 (본입찰 데이터)
+ await tx
+ .delete(companyPrItemBids)
+ .where(
+ and(
+ eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
+ eq(companyPrItemBids.isPreQuote, false)
+ )
+ )
+
+ // 캐시 무효화
+ revalidateTag(`bidding-${company.biddingId}`)
+ revalidateTag('quotation-vendors')
+ revalidateTag('quotation-details')
+ revalidatePath(`/partners/bid/${company.biddingId}`)
+
+ return {
+ success: true,
+ message: '응찰이 취소되었습니다.'
+ }
+ })
+ } catch (error) {
+ console.error('Failed to cancel bidding response:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '응찰 취소에 실패했습니다.'
+ }
+ }
+}
+
+// 모든 벤더가 최종제출했는지 확인
+export async function checkAllVendorsFinalSubmitted(biddingId: number) {
+ try {
+ const companies = await db
+ .select({
+ id: biddingCompanies.id,
+ isFinalSubmission: biddingCompanies.isFinalSubmission,
+ invitationStatus: biddingCompanies.invitationStatus,
+ })
+ .from(biddingCompanies)
+ .where(
+ and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isBiddingInvited, true) // 본입찰 초대된 업체만
+ )
+ )
+
+ // 초대된 업체가 없으면 false
+ if (companies.length === 0) {
+ return {
+ allSubmitted: false,
+ totalCompanies: 0,
+ submittedCompanies: 0
+ }
+ }
+
+ // 모든 업체가 최종제출했는지 확인
+ const submittedCompanies = companies.filter(c => c.isFinalSubmission).length
+ const allSubmitted = companies.every(c => c.isFinalSubmission)
+
+ return {
+ allSubmitted,
+ totalCompanies: companies.length,
+ submittedCompanies
+ }
+ } catch (error) {
+ console.error('Failed to check all vendors final submitted:', error)
+ return {
+ allSubmitted: false,
+ totalCompanies: 0,
+ submittedCompanies: 0
+ }
+ }
+}
+
+// 개찰 서버 액션 (조기개찰/개찰 구분)
+export async function performBidOpening(
+ biddingId: number,
+ userId: string,
+ isEarly: boolean = false // 조기개찰 여부
+) {
+ try {
+ const userName = await getUserNameById(userId)
+
+ return await db.transaction(async (tx) => {
+ // 1. 입찰 정보 조회
+ const [bidding] = await tx
+ .select({
+ id: biddings.id,
+ status: biddings.status,
+ submissionEndDate: biddings.submissionEndDate,
+ })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ if (!bidding) {
+ return {
+ success: false,
+ error: '입찰 정보를 찾을 수 없습니다.'
+ }
+ }
+
+ // 2. 개찰 가능 여부 확인 (evaluation_of_bidding 상태에서만)
+ if (bidding.status !== 'evaluation_of_bidding') {
+ return {
+ success: false,
+ error: '입찰평가중 상태에서만 개찰할 수 있습니다.'
+ }
+ }
+
+ // 3. 모든 벤더가 최종제출했는지 확인
+ const checkResult = await checkAllVendorsFinalSubmitted(biddingId)
+ if (!checkResult.allSubmitted) {
+ return {
+ success: false,
+ error: `모든 벤더가 최종 제출해야 개찰할 수 있습니다. (${checkResult.submittedCompanies}/${checkResult.totalCompanies})`
+ }
+ }
+
+ // 4. 조기개찰 여부 결정
+ const now = new Date()
+ const submissionEndDate = bidding.submissionEndDate ? new Date(bidding.submissionEndDate) : null
+ const isBeforeDeadline = submissionEndDate && now < submissionEndDate
+
+ // 마감일 전이면 조기개찰, 마감일 후면 일반 개찰
+ const newStatus = (isEarly || isBeforeDeadline) ? 'early_bid_opening' : 'bid_opening'
+
+ // 5. 입찰 상태 변경
+ await tx
+ .update(biddings)
+ .set({
+ status: newStatus,
+ updatedAt: new Date()
+ })
+ .where(eq(biddings.id, biddingId))
+
+ // 캐시 무효화
+ revalidateTag(`bidding-${biddingId}`)
+ revalidateTag('bidding-detail')
+ revalidatePath(`/evcp/bid/${biddingId}`)
+
+ return {
+ success: true,
+ message: `${newStatus === 'early_bid_opening' ? '조기개찰' : '개찰'}이 완료되었습니다.`,
+ status: newStatus
+ }
+ })
+ } catch (error) {
+ console.error('Failed to perform bid opening:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '개찰에 실패했습니다.'
+ }
+ }
+}
+
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index 404bc3cd..d58ded8e 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -81,6 +81,7 @@ export interface QuotationVendor {
vendorId: number
vendorName: string
vendorCode: string
+ vendorEmail?: string // 벤더의 기본 이메일
contactPerson: string
contactEmail: string
contactPhone: string
@@ -90,7 +91,7 @@ export interface QuotationVendor {
isWinner: boolean | null // 낙찰여부 (null: 미정, true: 낙찰, false: 탈락)
awardRatio: number | null // 발주비율
isBiddingParticipated: boolean | null // 본입찰 참여여부
- status: 'pending' | 'submitted' | 'selected' | 'rejected'
+ invitationStatus: 'pending' | 'pre_quote_sent' | 'pre_quote_accepted' | 'pre_quote_declined' | 'pre_quote_submitted' | 'bidding_sent' | 'bidding_accepted' | 'bidding_declined' | 'bidding_cancelled' | 'bidding_submitted'
documents: Array<{
id: number
fileName: string
@@ -241,6 +242,7 @@ export async function getQuotationVendors(biddingId: number): Promise<QuotationV
vendorId: biddingCompanies.companyId,
vendorName: vendors.vendorName,
vendorCode: vendors.vendorCode,
+ vendorEmail: vendors.email, // 벤더의 기본 이메일
contactPerson: biddingCompanies.contactPerson,
contactEmail: biddingCompanies.contactEmail,
contactPhone: biddingCompanies.contactPhone,
@@ -251,12 +253,7 @@ export async function getQuotationVendors(biddingId: number): Promise<QuotationV
// awardRatio: sql<number>`CASE WHEN ${biddingCompanies.isWinner} THEN 100 ELSE 0 END`,
awardRatio: biddingCompanies.awardRatio,
isBiddingParticipated: biddingCompanies.isBiddingParticipated,
- status: sql<string>`CASE
- WHEN ${biddingCompanies.isWinner} THEN 'selected'
- WHEN ${biddingCompanies.finalQuoteSubmittedAt} IS NOT NULL THEN 'submitted'
- WHEN ${biddingCompanies.respondedAt} IS NOT NULL THEN 'submitted'
- ELSE 'pending'
- END`,
+ invitationStatus: biddingCompanies.invitationStatus,
})
.from(biddingCompanies)
.leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
@@ -272,6 +269,7 @@ export async function getQuotationVendors(biddingId: number): Promise<QuotationV
vendorId: vendor.vendorId,
vendorName: vendor.vendorName || `Vendor ${vendor.vendorId}`,
vendorCode: vendor.vendorCode || '',
+ vendorEmail: vendor.vendorEmail || '', // 벤더의 기본 이메일
contactPerson: vendor.contactPerson || '',
contactEmail: vendor.contactEmail || '',
contactPhone: vendor.contactPhone || '',
@@ -281,7 +279,7 @@ export async function getQuotationVendors(biddingId: number): Promise<QuotationV
isWinner: vendor.isWinner,
awardRatio: vendor.awardRatio ? Number(vendor.awardRatio) : null,
isBiddingParticipated: vendor.isBiddingParticipated,
- status: vendor.status as 'pending' | 'submitted' | 'selected' | 'rejected',
+ invitationStatus: vendor.invitationStatus,
documents: [], // 빈 배열로 초기화
}))
} catch (error) {
@@ -622,7 +620,8 @@ export async function updateBiddingDetailVendor(
// 본입찰용 업체 추가
export async function createBiddingDetailVendor(
biddingId: number,
- vendorId: number
+ vendorId: number,
+ isPriceAdjustmentApplicableQuestion?: boolean
) {
try {
const result = await db.transaction(async (tx) => {
@@ -630,9 +629,10 @@ export async function createBiddingDetailVendor(
const biddingCompanyResult = await tx.insert(biddingCompanies).values({
biddingId: biddingId,
companyId: vendorId,
- invitationStatus: 'pending',
+ invitationStatus: 'pending', // 초대 대기
isPreQuoteSelected: true, // 본입찰 등록 기본값
isWinner: null, // 미정 상태로 초기화 0916
+ isPriceAdjustmentApplicableQuestion: isPriceAdjustmentApplicableQuestion ?? false,
createdAt: new Date(),
updatedAt: new Date(),
}).returning({ id: biddingCompanies.id })
@@ -730,9 +730,8 @@ export async function markAsDisposal(biddingId: number, userId: string) {
itemName: bidding.itemName,
biddingType: bidding.biddingType,
processedDate: new Date().toLocaleDateString('ko-KR'),
- managerName: bidding.managerName,
- managerEmail: bidding.managerEmail,
- managerPhone: bidding.managerPhone,
+ bidPicName: bidding.bidPicName,
+ supplyPicName: bidding.supplyPicName,
language: 'ko'
}
})
@@ -807,7 +806,7 @@ export async function registerBidding(biddingId: number, userId: string) {
.update(biddingCompanies)
.set({
isBiddingInvited: true,
- invitationStatus: 'sent',
+ invitationStatus: 'bidding_sent', // 입찰 초대 발송
updatedAt: new Date()
})
.where(and(
@@ -834,9 +833,8 @@ export async function registerBidding(biddingId: number, userId: string) {
submissionStartDate: bidding.submissionStartDate,
submissionEndDate: bidding.submissionEndDate,
biddingUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/partners/bid/${biddingId}`,
- managerName: bidding.managerName,
- managerEmail: bidding.managerEmail,
- managerPhone: bidding.managerPhone,
+ bidPicName: bidding.bidPicName,
+ supplyPicName: bidding.supplyPicName,
language: 'ko'
}
})
@@ -945,9 +943,8 @@ export async function createRebidding(biddingId: number, userId: string) {
submissionStartDate: originalBidding.submissionStartDate,
submissionEndDate: originalBidding.submissionEndDate,
biddingUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/partners/bid/${biddingId}`,
- managerName: originalBidding.managerName,
- managerEmail: originalBidding.managerEmail,
- managerPhone: originalBidding.managerPhone,
+ bidPicName: originalBidding.bidPicName,
+ supplyPicName: originalBidding.supplyPicName,
language: 'ko'
}
})
@@ -1521,6 +1518,7 @@ export interface PartnersBiddingListItem {
// biddings 정보
biddingId: number
biddingNumber: string
+ originalBiddingNumber: string | null // 원입찰번호
revision: number | null
projectName: string
itemName: string
@@ -1533,9 +1531,10 @@ export interface PartnersBiddingListItem {
submissionStartDate: Date | null
submissionEndDate: Date | null
status: string
- managerName: string | null
- managerEmail: string | null
- managerPhone: string | null
+ // 입찰담당자
+ bidPicName: string | null
+ // 조달담당자
+ supplyPicName: string | null
currency: string
budget: number | null
isUrgent: boolean | null // 긴급여부
@@ -1572,6 +1571,7 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part
// biddings 정보
biddingId: biddings.id,
biddingNumber: biddings.biddingNumber,
+ originalBiddingNumber: biddings.originalBiddingNumber, // 원입찰번호
revision: biddings.revision,
projectName: biddings.projectName,
itemName: biddings.itemName,
@@ -1584,9 +1584,12 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part
submissionStartDate: biddings.submissionStartDate,
submissionEndDate: biddings.submissionEndDate,
status: biddings.status,
- managerName: biddings.managerName,
- managerEmail: biddings.managerEmail,
- managerPhone: biddings.managerPhone,
+ // 기존 담당자 필드 (하위호환성 유지)
+
+ // 입찰담당자
+ bidPicName: biddings.bidPicName,
+ // 조달담당자
+ supplyPicName: biddings.supplyPicName,
currency: biddings.currency,
budget: biddings.budget,
isUrgent: biddings.isUrgent,
@@ -1635,7 +1638,6 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId:
itemName: biddings.itemName,
title: biddings.title,
description: biddings.description,
- content: biddings.content,
// 계약 정보
contractType: biddings.contractType,
@@ -1659,15 +1661,15 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId:
// 상태 및 담당자
status: biddings.status,
isUrgent: biddings.isUrgent,
- managerName: biddings.managerName,
- managerEmail: biddings.managerEmail,
- managerPhone: biddings.managerPhone,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
// 협력업체 특정 정보
biddingCompanyId: biddingCompanies.id,
invitationStatus: biddingCompanies.invitationStatus,
finalQuoteAmount: biddingCompanies.finalQuoteAmount,
finalQuoteSubmittedAt: biddingCompanies.finalQuoteSubmittedAt,
+ isFinalSubmission: biddingCompanies.isFinalSubmission,
isWinner: biddingCompanies.isWinner,
isAttendingMeeting: biddingCompanies.isAttendingMeeting,
isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
@@ -1718,6 +1720,7 @@ export async function submitPartnerResponse(
sparePartResponse?: string
additionalProposals?: string
finalQuoteAmount?: number
+ isFinalSubmission?: boolean // 최종제출 여부 추가
prItemQuotations?: Array<{
prItemId: number
bidUnitPrice: number
@@ -1851,7 +1854,15 @@ export async function submitPartnerResponse(
if (response.finalQuoteAmount !== undefined) {
companyUpdateData.finalQuoteAmount = response.finalQuoteAmount
companyUpdateData.finalQuoteSubmittedAt = new Date()
- companyUpdateData.invitationStatus = 'submitted'
+
+ // 최종제출 여부에 따라 상태 및 플래그 설정
+ if (response.isFinalSubmission) {
+ companyUpdateData.isFinalSubmission = true
+ companyUpdateData.invitationStatus = 'bidding_submitted' // 응찰 완료
+ } else {
+ companyUpdateData.isFinalSubmission = false
+ // 임시저장: invitationStatus는 변경하지 않음 (bidding_accepted 유지)
+ }
}
await tx
@@ -1868,8 +1879,8 @@ export async function submitPartnerResponse(
const biddingId = biddingCompanyInfo[0]?.biddingId
- // 응찰 제출 시 입찰 상태를 평가중으로 변경 (bidding_opened 상태에서만)
- if (biddingId && response.finalQuoteAmount !== undefined) {
+ // 최종제출인 경우, 입찰 상태를 평가중으로 변경 (bidding_opened 상태에서만)
+ if (biddingId && response.finalQuoteAmount !== undefined && response.isFinalSubmission) {
await tx
.update(biddings)
.set({
@@ -2023,14 +2034,15 @@ export async function updatePartnerAttendance(
})
.where(eq(biddingCompanies.id, biddingCompanyId))
- // 참석하는 경우, 사양설명회 담당자에게 이메일 발송을 위한 정보 반환
+ // 참석하는 경우, 사양설명회 담당자(contactEmail)에 이메일 발송을 위한 정보 반환
if (attendanceData.isAttending) {
+ // 입찰 + 사양설명회 + 업체 정보 불러오기
const biddingInfo = await tx
.select({
biddingId: biddingCompanies.biddingId,
companyId: biddingCompanies.companyId,
- managerEmail: biddings.managerEmail,
- managerName: biddings.managerName,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
title: biddings.title,
biddingNumber: biddings.biddingNumber,
})
@@ -2040,7 +2052,7 @@ export async function updatePartnerAttendance(
.limit(1)
if (biddingInfo.length > 0) {
- // 협력업체 정보 조회
+ // 업체 정보
const companyInfo = await tx
.select({
vendorName: vendors.vendorName,
@@ -2051,37 +2063,59 @@ export async function updatePartnerAttendance(
const companyName = companyInfo.length > 0 ? companyInfo[0].vendorName : '알 수 없음'
- // 메일 발송 (템플릿 사용)
- try {
- const { sendEmail } = await import('@/lib/mail/sendEmail')
-
- await sendEmail({
- to: biddingInfo[0].managerEmail,
- template: 'specification-meeting-attendance',
- context: {
- biddingNumber: biddingInfo[0].biddingNumber,
- title: biddingInfo[0].title,
- companyName: companyName,
- attendeeCount: attendanceData.attendeeCount,
- representativeName: attendanceData.representativeName,
- representativePhone: attendanceData.representativePhone,
- managerName: biddingInfo[0].managerName,
- managerEmail: biddingInfo[0].managerEmail,
- currentYear: new Date().getFullYear(),
- language: 'ko'
- }
+ // 사양설명회 상세 정보(담당자 email 포함)
+ const specificationMeetingInfo = await tx
+ .select({
+ contactEmail: specificationMeetings.contactEmail,
+ meetingDate: specificationMeetings.meetingDate,
+ meetingTime: specificationMeetings.meetingTime,
+ location: specificationMeetings.location,
})
-
- console.log(`사양설명회 참석 알림 메일 발송 완료: ${biddingInfo[0].managerEmail}`)
- } catch (emailError) {
- console.error('메일 발송 실패:', emailError)
- // 메일 발송 실패해도 참석 여부 업데이트는 성공으로 처리
+ .from(specificationMeetings)
+ .where(eq(specificationMeetings.biddingId, biddingInfo[0].biddingId))
+ .limit(1)
+
+ const contactEmail = specificationMeetingInfo.length > 0 ? specificationMeetingInfo[0].contactEmail : null
+
+ // 메일 발송 (템플릿 사용)
+ if (contactEmail) {
+ try {
+ const { sendEmail } = await import('@/lib/mail/sendEmail')
+
+ await sendEmail({
+ to: contactEmail,
+ template: 'specification-meeting-attendance',
+ context: {
+ biddingNumber: biddingInfo[0].biddingNumber,
+ title: biddingInfo[0].title,
+ companyName: companyName,
+ attendeeCount: attendanceData.attendeeCount,
+ representativeName: attendanceData.representativeName,
+ representativePhone: attendanceData.representativePhone,
+ bidPicName: biddingInfo[0].bidPicName,
+ supplyPicName: biddingInfo[0].supplyPicName,
+ meetingDate: specificationMeetingInfo[0]?.meetingDate,
+ meetingTime: specificationMeetingInfo[0]?.meetingTime,
+ location: specificationMeetingInfo[0]?.location,
+ contactEmail: contactEmail,
+ currentYear: new Date().getFullYear(),
+ language: 'ko'
+ }
+ })
+
+ console.log(`사양설명회 참석 알림 메일 발송 완료: ${contactEmail}`)
+ } catch (emailError) {
+ console.error('메일 발송 실패:', emailError)
+ // 메일 발송 실패해도 참석 여부 업데이트는 성공으로 처리
+ }
+ } else {
+ console.warn('사양설명회 담당자 이메일이 없습니다.')
}
-
+
// 캐시 무효화
revalidateTag(`bidding-${biddingInfo[0].biddingId}`)
revalidateTag('quotation-vendors')
-
+
return {
...biddingInfo[0],
companyName,
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