summaryrefslogtreecommitdiff
path: root/lib/bidding/pre-quote
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding/pre-quote')
-rw-r--r--lib/bidding/pre-quote/service.ts934
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx57
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx185
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx303
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx205
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx200
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx189
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx92
8 files changed, 2165 insertions, 0 deletions
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts
new file mode 100644
index 00000000..bf7a4538
--- /dev/null
+++ b/lib/bidding/pre-quote/service.ts
@@ -0,0 +1,934 @@
+'use server'
+
+import db from '@/db/db'
+import { biddingCompanies, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding'
+import { vendors } from '@/db/schema/vendors'
+import { sendEmail } from '@/lib/mail/sendEmail'
+import { eq, inArray, and } from 'drizzle-orm'
+
+interface CreateBiddingCompanyInput {
+ biddingId: number
+ companyId: number
+ contactPerson?: string
+ contactEmail?: string
+ contactPhone?: string
+ notes?: string
+}
+
+interface UpdateBiddingCompanyInput {
+ contactPerson?: string
+ contactEmail?: string
+ contactPhone?: string
+ preQuoteAmount?: number
+ notes?: string
+ invitationStatus?: 'pending' | 'accepted' | 'declined'
+ isPreQuoteSelected?: boolean
+ isAttendingMeeting?: boolean
+}
+
+interface PrItemQuotation {
+ prItemId: number
+ bidUnitPrice: number
+ bidAmount: number
+ proposedDeliveryDate?: string
+ technicalSpecification?: string
+}
+
+interface PreQuoteDocumentUpload {
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ mimeType: string
+ filePath: string
+}
+
+// 사전견적용 업체 추가 - biddingCompanies와 company_condition_responses 레코드 생성
+export async function createBiddingCompany(input: CreateBiddingCompanyInput) {
+ try {
+ const result = await db.transaction(async (tx) => {
+ // 1. biddingCompanies 레코드 생성
+ const biddingCompanyResult = await tx.insert(biddingCompanies).values({
+ biddingId: input.biddingId,
+ companyId: input.companyId,
+ invitationStatus: 'pending', // 초기 상태: 입찰생성
+ invitedAt: new Date(),
+ contactPerson: input.contactPerson,
+ contactEmail: input.contactEmail,
+ contactPhone: input.contactPhone,
+ notes: input.notes,
+ }).returning({ id: biddingCompanies.id })
+
+ if (biddingCompanyResult.length === 0) {
+ throw new Error('업체 추가에 실패했습니다.')
+ }
+
+ const biddingCompanyId = biddingCompanyResult[0].id
+
+ // 2. company_condition_responses 레코드 생성 (기본값으로)
+ await tx.insert(companyConditionResponses).values({
+ biddingCompanyId: biddingCompanyId,
+ // 나머지 필드들은 null로 시작 (벤더가 나중에 응답)
+ })
+
+ return biddingCompanyId
+ })
+
+ return {
+ success: true,
+ message: '업체가 성공적으로 추가되었습니다.',
+ data: { id: result }
+ }
+ } catch (error) {
+ console.error('Failed to create bidding company:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 추가에 실패했습니다.'
+ }
+ }
+}
+
+// 사전견적용 업체 정보 업데이트
+export async function updateBiddingCompany(id: number, input: UpdateBiddingCompanyInput) {
+ try {
+ const updateData: any = {
+ updatedAt: new Date()
+ }
+
+ if (input.contactPerson !== undefined) updateData.contactPerson = input.contactPerson
+ if (input.contactEmail !== undefined) updateData.contactEmail = input.contactEmail
+ if (input.contactPhone !== undefined) updateData.contactPhone = input.contactPhone
+ if (input.preQuoteAmount !== undefined) updateData.preQuoteAmount = input.preQuoteAmount
+ if (input.notes !== undefined) updateData.notes = input.notes
+ if (input.invitationStatus !== undefined) {
+ updateData.invitationStatus = input.invitationStatus
+ if (input.invitationStatus !== 'pending') {
+ updateData.respondedAt = new Date()
+ }
+ }
+ if (input.isPreQuoteSelected !== undefined) updateData.isPreQuoteSelected = input.isPreQuoteSelected
+ if (input.isAttendingMeeting !== undefined) updateData.isAttendingMeeting = input.isAttendingMeeting
+
+ await db.update(biddingCompanies)
+ .set(updateData)
+ .where(eq(biddingCompanies.id, id))
+
+ return {
+ success: true,
+ message: '업체 정보가 성공적으로 업데이트되었습니다.',
+ }
+ } catch (error) {
+ console.error('Failed to update bidding company:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 정보 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+// 사전견적용 업체 삭제
+export async function deleteBiddingCompany(id: number) {
+ try {
+ await db.transaction(async (tx) => {
+ // 1. 먼저 관련된 조건 응답들 삭제
+ await tx.delete(companyConditionResponses)
+ .where(eq(companyConditionResponses.biddingCompanyId, id))
+
+ // 2. biddingCompanies 레코드 삭제
+ await tx.delete(biddingCompanies)
+ .where(eq(biddingCompanies.id, id))
+ })
+
+ return {
+ success: true,
+ message: '업체가 성공적으로 삭제되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to delete bidding company:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 삭제에 실패했습니다.'
+ }
+ }
+}
+
+// 특정 입찰의 참여 업체 목록 조회 (company_condition_responses와 vendors 조인)
+export async function getBiddingCompanies(biddingId: number) {
+ try {
+ const companies = await db
+ .select({
+ // bidding_companies 필드들
+ id: biddingCompanies.id,
+ biddingId: biddingCompanies.biddingId,
+ companyId: biddingCompanies.companyId,
+ invitationStatus: biddingCompanies.invitationStatus,
+ invitedAt: biddingCompanies.invitedAt,
+ respondedAt: biddingCompanies.respondedAt,
+ preQuoteAmount: biddingCompanies.preQuoteAmount,
+ preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt,
+ isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
+ isAttendingMeeting: biddingCompanies.isAttendingMeeting,
+ notes: biddingCompanies.notes,
+ contactPerson: biddingCompanies.contactPerson,
+ contactEmail: biddingCompanies.contactEmail,
+ contactPhone: biddingCompanies.contactPhone,
+ createdAt: biddingCompanies.createdAt,
+ updatedAt: biddingCompanies.updatedAt,
+
+ // vendors 테이블에서 업체 정보
+ companyName: vendors.vendorName,
+ companyCode: vendors.vendorCode,
+
+ // company_condition_responses 필드들
+ paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
+ taxConditionsResponse: companyConditionResponses.taxConditionsResponse,
+ proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate,
+ priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
+ isInitialResponse: companyConditionResponses.isInitialResponse,
+ incotermsResponse: companyConditionResponses.incotermsResponse,
+ proposedShippingPort: companyConditionResponses.proposedShippingPort,
+ proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
+ sparePartResponse: companyConditionResponses.sparePartResponse,
+ })
+ .from(biddingCompanies)
+ .leftJoin(
+ vendors,
+ eq(biddingCompanies.companyId, vendors.id)
+ )
+ .leftJoin(
+ companyConditionResponses,
+ eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId)
+ )
+ .where(eq(biddingCompanies.biddingId, biddingId))
+
+ return {
+ success: true,
+ data: companies
+ }
+ } catch (error) {
+ console.error('Failed to get bidding companies:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 목록 조회에 실패했습니다.'
+ }
+ }
+}
+
+// 선택된 업체들에게 사전견적 초대 발송
+export async function sendPreQuoteInvitations(companyIds: number[]) {
+ try {
+ if (companyIds.length === 0) {
+ return {
+ success: false,
+ error: '선택된 업체가 없습니다.'
+ }
+ }
+
+ // 선택된 업체들의 정보와 입찰 정보 조회
+ const companiesInfo = await db
+ .select({
+ biddingCompanyId: biddingCompanies.id,
+ companyId: biddingCompanies.companyId,
+ biddingId: biddingCompanies.biddingId,
+ companyName: vendors.vendorName,
+ companyEmail: vendors.email,
+ // 입찰 정보
+ biddingNumber: biddings.biddingNumber,
+ revision: biddings.revision,
+ projectName: biddings.projectName,
+ biddingTitle: biddings.title,
+ itemName: biddings.itemName,
+ preQuoteDate: biddings.preQuoteDate,
+ budget: biddings.budget,
+ currency: biddings.currency,
+ managerName: biddings.managerName,
+ managerEmail: biddings.managerEmail,
+ managerPhone: biddings.managerPhone,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id))
+ .where(inArray(biddingCompanies.id, companyIds))
+
+ if (companiesInfo.length === 0) {
+ return {
+ success: false,
+ error: '업체 정보를 찾을 수 없습니다.'
+ }
+ }
+
+ await db.transaction(async (tx) => {
+ // 선택된 업체들의 상태를 '사전견적요청(초대발송)'으로 변경
+ for (const id of companyIds) {
+ await tx.update(biddingCompanies)
+ .set({
+ invitationStatus: 'sent', // 사전견적 초대 발송 상태
+ invitedAt: new Date(),
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, id))
+ }
+ })
+
+ // 각 업체별로 이메일 발송
+ for (const company of companiesInfo) {
+ if (company.companyEmail) {
+ try {
+ await sendEmail({
+ to: company.companyEmail,
+ template: 'pre-quote-invitation',
+ context: {
+ companyName: company.companyName,
+ biddingNumber: company.biddingNumber,
+ revision: company.revision,
+ projectName: company.projectName,
+ biddingTitle: company.biddingTitle,
+ itemName: company.itemName,
+ preQuoteDate: company.preQuoteDate ? new Date(company.preQuoteDate).toLocaleDateString() : null,
+ budget: company.budget ? company.budget.toLocaleString() : null,
+ currency: company.currency,
+ managerName: company.managerName,
+ managerEmail: company.managerEmail,
+ managerPhone: company.managerPhone,
+ loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${company.biddingId}/pre-quote`,
+ currentYear: new Date().getFullYear(),
+ language: 'ko'
+ }
+ })
+ } catch (emailError) {
+ console.error(`Failed to send email to ${company.companyEmail}:`, emailError)
+ // 이메일 발송 실패해도 전체 프로세스는 계속 진행
+ }
+ }
+ }
+
+ return {
+ success: true,
+ message: `${companyIds.length}개 업체에 사전견적 초대를 발송했습니다.`
+ }
+ } catch (error) {
+ console.error('Failed to send pre-quote invitations:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '초대 발송에 실패했습니다.'
+ }
+ }
+}
+
+// Partners에서 특정 업체의 입찰 정보 조회 (사전견적 단계)
+export async function getBiddingCompaniesForPartners(biddingId: number, companyId: number) {
+ try {
+ // 1. 먼저 입찰 기본 정보를 가져옴
+ const biddingResult = await db
+ .select({
+ id: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ revision: biddings.revision,
+ projectName: biddings.projectName,
+ itemName: biddings.itemName,
+ title: biddings.title,
+ description: biddings.description,
+ content: biddings.content,
+ contractType: biddings.contractType,
+ biddingType: biddings.biddingType,
+ awardCount: biddings.awardCount,
+ contractPeriod: biddings.contractPeriod,
+ preQuoteDate: biddings.preQuoteDate,
+ biddingRegistrationDate: biddings.biddingRegistrationDate,
+ submissionStartDate: biddings.submissionStartDate,
+ submissionEndDate: biddings.submissionEndDate,
+ evaluationDate: biddings.evaluationDate,
+ currency: biddings.currency,
+ budget: biddings.budget,
+ targetPrice: biddings.targetPrice,
+ status: biddings.status,
+ managerName: biddings.managerName,
+ managerEmail: biddings.managerEmail,
+ managerPhone: biddings.managerPhone,
+ })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ if (biddingResult.length === 0) {
+ return null
+ }
+
+ const biddingData = biddingResult[0]
+
+ // 2. 해당 업체의 biddingCompanies 정보 조회
+ const companyResult = await db
+ .select({
+ biddingCompanyId: biddingCompanies.id,
+ biddingId: biddingCompanies.biddingId,
+ invitationStatus: biddingCompanies.invitationStatus,
+ preQuoteAmount: biddingCompanies.preQuoteAmount,
+ preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt,
+ isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
+ isAttendingMeeting: biddingCompanies.isAttendingMeeting,
+ // company_condition_responses 정보
+ paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
+ taxConditionsResponse: companyConditionResponses.taxConditionsResponse,
+ incotermsResponse: companyConditionResponses.incotermsResponse,
+ proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate,
+ proposedShippingPort: companyConditionResponses.proposedShippingPort,
+ proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
+ priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
+ sparePartResponse: companyConditionResponses.sparePartResponse,
+ isInitialResponse: companyConditionResponses.isInitialResponse,
+ additionalProposals: companyConditionResponses.additionalProposals,
+ })
+ .from(biddingCompanies)
+ .leftJoin(
+ companyConditionResponses,
+ eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId)
+ )
+ .where(
+ and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.companyId, companyId)
+ )
+ )
+ .limit(1)
+
+ // 3. 결과 조합
+ if (companyResult.length === 0) {
+ // 아직 초대되지 않은 상태
+ return {
+ ...biddingData,
+ biddingCompanyId: null,
+ biddingId: biddingData.id,
+ invitationStatus: null,
+ preQuoteAmount: null,
+ preQuoteSubmittedAt: null,
+ isPreQuoteSelected: false,
+ isAttendingMeeting: null,
+ paymentTermsResponse: null,
+ taxConditionsResponse: null,
+ incotermsResponse: null,
+ proposedContractDeliveryDate: null,
+ proposedShippingPort: null,
+ proposedDestinationPort: null,
+ priceAdjustmentResponse: null,
+ sparePartResponse: null,
+ isInitialResponse: null,
+ additionalProposals: null,
+ }
+ }
+
+ const companyData = companyResult[0]
+
+ return {
+ ...biddingData,
+ ...companyData,
+ biddingId: biddingData.id, // bidding ID 보장
+ }
+ } catch (error) {
+ console.error('Failed to get bidding companies for partners:', error)
+ throw error
+ }
+}
+
+// Partners에서 사전견적 응답 제출
+export async function submitPreQuoteResponse(
+ biddingCompanyId: number,
+ responseData: {
+ preQuoteAmount?: number // 품목별 계산에서 자동으로 계산되므로 optional
+ prItemQuotations?: PrItemQuotation[] // 품목별 견적 정보 추가
+ paymentTermsResponse?: string
+ taxConditionsResponse?: string
+ incotermsResponse?: string
+ proposedContractDeliveryDate?: string
+ proposedShippingPort?: string
+ proposedDestinationPort?: string
+ priceAdjustmentResponse?: boolean
+ isInitialResponse?: boolean
+ sparePartResponse?: string
+ additionalProposals?: string
+ priceAdjustmentForm?: any
+ },
+ userId: string
+) {
+ try {
+ let finalAmount = responseData.preQuoteAmount || 0
+
+ await db.transaction(async (tx) => {
+ // 1. 품목별 견적 정보 최종 저장 (사전견적 제출)
+ if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) {
+ // 기존 사전견적 품목 삭제 후 새로 생성
+ await tx.delete(companyPrItemBids)
+ .where(
+ and(
+ eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
+ eq(companyPrItemBids.isPreQuote, true)
+ )
+ )
+
+ // 품목별 견적 최종 저장
+ for (const item of responseData.prItemQuotations) {
+ await tx.insert(companyPrItemBids)
+ .values({
+ biddingCompanyId,
+ prItemId: item.prItemId,
+ bidUnitPrice: item.bidUnitPrice.toString(),
+ bidAmount: item.bidAmount.toString(),
+ proposedDeliveryDate: item.proposedDeliveryDate || null,
+ technicalSpecification: item.technicalSpecification || null,
+ currency: 'KRW',
+ isPreQuote: true,
+ submittedAt: new Date(),
+ createdAt: new Date(),
+ updatedAt: new Date()
+ })
+ }
+
+ // 총 금액 다시 계산
+ finalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0)
+ }
+
+ // 2. biddingCompanies 업데이트 (사전견적 금액, 제출 시간, 상태 변경)
+ await tx.update(biddingCompanies)
+ .set({
+ preQuoteAmount: finalAmount.toString(),
+ preQuoteSubmittedAt: new Date(),
+ invitationStatus: 'submitted', // 사전견적 제출 완료 상태로 변경
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ // 3. company_condition_responses 업데이트
+ const finalConditionResult = await tx.update(companyConditionResponses)
+ .set({
+ paymentTermsResponse: responseData.paymentTermsResponse,
+ taxConditionsResponse: responseData.taxConditionsResponse,
+ incotermsResponse: responseData.incotermsResponse,
+ proposedContractDeliveryDate: responseData.proposedContractDeliveryDate,
+ proposedShippingPort: responseData.proposedShippingPort,
+ proposedDestinationPort: responseData.proposedDestinationPort,
+ priceAdjustmentResponse: responseData.priceAdjustmentResponse,
+ isInitialResponse: responseData.isInitialResponse,
+ sparePartResponse: responseData.sparePartResponse,
+ additionalProposals: responseData.additionalProposals,
+ updatedAt: new Date()
+ })
+ .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
+ .returning()
+
+ // 4. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우)
+ if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && finalConditionResult.length > 0) {
+ const companyConditionResponseId = finalConditionResult[0].id
+
+ const priceAdjustmentData = {
+ companyConditionResponsesId: companyConditionResponseId,
+ itemName: responseData.priceAdjustmentForm.itemName,
+ adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint,
+ majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial,
+ adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula,
+ rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex,
+ referenceDate: responseData.priceAdjustmentForm.referenceDate ? new Date(responseData.priceAdjustmentForm.referenceDate) : null,
+ comparisonDate: responseData.priceAdjustmentForm.comparisonDate ? new Date(responseData.priceAdjustmentForm.comparisonDate) : null,
+ adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio,
+ notes: responseData.priceAdjustmentForm.notes,
+ adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions,
+ majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial,
+ adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod,
+ contractorWriter: responseData.priceAdjustmentForm.contractorWriter,
+ adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate ? new Date(responseData.priceAdjustmentForm.adjustmentDate) : null,
+ nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason,
+ }
+
+ // 기존 연동제 정보가 있는지 확인
+ const existingPriceAdjustment = await tx
+ .select()
+ .from(priceAdjustmentForms)
+ .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
+ .limit(1)
+
+ if (existingPriceAdjustment.length > 0) {
+ // 업데이트
+ await tx
+ .update(priceAdjustmentForms)
+ .set(priceAdjustmentData)
+ .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
+ } else {
+ // 새로 생성
+ await tx.insert(priceAdjustmentForms).values(priceAdjustmentData)
+ }
+ }
+ })
+
+ return {
+ success: true,
+ message: '사전견적이 성공적으로 제출되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to submit pre-quote response:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '사전견적 제출에 실패했습니다.'
+ }
+ }
+}
+
+// Partners에서 사전견적 참여 의사 결정 (수락/거절)
+export async function respondToPreQuoteInvitation(
+ biddingCompanyId: number,
+ response: 'accepted' | 'declined',
+ userId: string
+) {
+ try {
+ await db.update(biddingCompanies)
+ .set({
+ invitationStatus: response, // accepted 또는 declined
+ respondedAt: new Date(),
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ const message = response === 'accepted' ?
+ '사전견적 참여를 수락했습니다.' :
+ '사전견적 참여를 거절했습니다.'
+
+ return {
+ success: true,
+ message
+ }
+ } catch (error) {
+ console.error('Failed to respond to pre-quote invitation:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '응답 처리에 실패했습니다.'
+ }
+ }
+}
+
+// 벤더에서 사전견적 참여 여부 결정 (isPreQuoteSelected 사용)
+export async function setPreQuoteParticipation(
+ biddingCompanyId: number,
+ isParticipating: boolean,
+ userId: string
+) {
+ try {
+ await db.update(biddingCompanies)
+ .set({
+ isPreQuoteSelected: isParticipating,
+ invitationStatus: isParticipating ? 'accepted' : 'declined',
+ respondedAt: new Date(),
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ const message = isParticipating ?
+ '사전견적 참여를 확정했습니다. 이제 견적서를 작성하실 수 있습니다.' :
+ '사전견적 참여를 거절했습니다.'
+
+ return {
+ success: true,
+ message
+ }
+ } catch (error) {
+ console.error('Failed to set pre-quote participation:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '참여 의사 처리에 실패했습니다.'
+ }
+ }
+}
+
+// PR 아이템 조회 (입찰에 포함된 품목들)
+export async function getPrItemsForBidding(biddingId: number) {
+ try {
+ const prItems = await db
+ .select({
+ id: prItemsForBidding.id,
+ itemNumber: prItemsForBidding.itemNumber,
+ prNumber: prItemsForBidding.prNumber,
+ itemInfo: prItemsForBidding.itemInfo,
+ materialDescription: prItemsForBidding.materialDescription,
+ quantity: prItemsForBidding.quantity,
+ quantityUnit: prItemsForBidding.quantityUnit,
+ currency: prItemsForBidding.currency,
+ requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate,
+ hasSpecDocument: prItemsForBidding.hasSpecDocument
+ })
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+
+ return prItems
+ } catch (error) {
+ console.error('Failed to get PR items for bidding:', error)
+ return []
+ }
+}
+
+// SPEC 문서 조회 (PR 아이템에 연결된 문서들)
+export async function getSpecDocumentsForPrItem(prItemId: number) {
+ try {
+ const specDocs = await db
+ .select({
+ id: biddingDocuments.id,
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ fileSize: biddingDocuments.fileSize,
+ filePath: biddingDocuments.filePath,
+ title: biddingDocuments.title,
+ description: biddingDocuments.description,
+ uploadedAt: biddingDocuments.uploadedAt
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.prItemId, prItemId),
+ eq(biddingDocuments.documentType, 'specification')
+ )
+ )
+
+ return specDocs
+ } catch (error) {
+ console.error('Failed to get spec documents for PR item:', error)
+ return []
+ }
+}
+
+// 사전견적 임시저장
+export async function savePreQuoteDraft(
+ biddingCompanyId: number,
+ responseData: {
+ prItemQuotations?: PrItemQuotation[]
+ paymentTermsResponse?: string
+ taxConditionsResponse?: string
+ incotermsResponse?: string
+ proposedContractDeliveryDate?: string
+ proposedShippingPort?: string
+ proposedDestinationPort?: string
+ priceAdjustmentResponse?: boolean
+ isInitialResponse?: boolean
+ sparePartResponse?: string
+ additionalProposals?: string
+ priceAdjustmentForm?: any
+ },
+ userId: string
+) {
+ try {
+ let totalAmount = 0
+
+ await db.transaction(async (tx) => {
+ // 품목별 견적 정보 저장
+ if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) {
+ // 기존 사전견적 품목 삭제 (임시저장 시 덮어쓰기)
+ await tx.delete(companyPrItemBids)
+ .where(
+ and(
+ eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
+ eq(companyPrItemBids.isPreQuote, true)
+ )
+ )
+
+ // 새로운 품목별 견적 저장
+ for (const item of responseData.prItemQuotations) {
+ await tx.insert(companyPrItemBids)
+ .values({
+ biddingCompanyId,
+ prItemId: item.prItemId,
+ bidUnitPrice: item.bidUnitPrice.toString(),
+ bidAmount: item.bidAmount.toString(),
+ proposedDeliveryDate: item.proposedDeliveryDate || null,
+ technicalSpecification: item.technicalSpecification || null,
+ currency: 'KRW',
+ isPreQuote: true, // 사전견적 표시
+ submittedAt: new Date(),
+ createdAt: new Date(),
+ updatedAt: new Date()
+ })
+ }
+
+ // 총 금액 계산
+ totalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0)
+
+ // biddingCompanies에 총 금액 임시 저장 (status는 변경하지 않음)
+ await tx.update(biddingCompanies)
+ .set({
+ preQuoteAmount: totalAmount.toString(),
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+ }
+
+ // company_condition_responses 업데이트 (임시저장)
+ const conditionResult = await tx.update(companyConditionResponses)
+ .set({
+ paymentTermsResponse: responseData.paymentTermsResponse || null,
+ taxConditionsResponse: responseData.taxConditionsResponse || null,
+ incotermsResponse: responseData.incotermsResponse || null,
+ proposedContractDeliveryDate: responseData.proposedContractDeliveryDate || null,
+ proposedShippingPort: responseData.proposedShippingPort || null,
+ proposedDestinationPort: responseData.proposedDestinationPort || null,
+ priceAdjustmentResponse: responseData.priceAdjustmentResponse || null,
+ isInitialResponse: responseData.isInitialResponse || null,
+ sparePartResponse: responseData.sparePartResponse || null,
+ additionalProposals: responseData.additionalProposals || null,
+ updatedAt: new Date()
+ })
+ .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
+ .returning()
+
+ // 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우)
+ if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && conditionResult.length > 0) {
+ const companyConditionResponseId = conditionResult[0].id
+
+ const priceAdjustmentData = {
+ companyConditionResponsesId: companyConditionResponseId,
+ itemName: responseData.priceAdjustmentForm.itemName,
+ adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint,
+ majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial,
+ adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula,
+ rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex,
+ referenceDate: responseData.priceAdjustmentForm.referenceDate ? new Date(responseData.priceAdjustmentForm.referenceDate) : null,
+ comparisonDate: responseData.priceAdjustmentForm.comparisonDate ? new Date(responseData.priceAdjustmentForm.comparisonDate) : null,
+ adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio,
+ notes: responseData.priceAdjustmentForm.notes,
+ adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions,
+ majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial,
+ adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod,
+ contractorWriter: responseData.priceAdjustmentForm.contractorWriter,
+ adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate ? new Date(responseData.priceAdjustmentForm.adjustmentDate) : null,
+ nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason,
+ }
+
+ // 기존 연동제 정보가 있는지 확인
+ const existingPriceAdjustment = await tx
+ .select()
+ .from(priceAdjustmentForms)
+ .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
+ .limit(1)
+
+ if (existingPriceAdjustment.length > 0) {
+ // 업데이트
+ await tx
+ .update(priceAdjustmentForms)
+ .set(priceAdjustmentData)
+ .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
+ } else {
+ // 새로 생성
+ await tx.insert(priceAdjustmentForms).values(priceAdjustmentData)
+ }
+ }
+ })
+
+ return {
+ success: true,
+ message: '임시저장이 완료되었습니다.',
+ totalAmount
+ }
+ } catch (error) {
+ console.error('Failed to save pre-quote draft:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '임시저장에 실패했습니다.'
+ }
+ }
+}
+
+// 견적 문서 업로드
+export async function uploadPreQuoteDocument(
+ biddingId: number,
+ companyId: number,
+ documentInfo: PreQuoteDocumentUpload,
+ userId: string
+) {
+ try {
+ const result = await db.insert(biddingDocuments)
+ .values({
+ biddingId,
+ companyId,
+ documentType: 'other', // 견적서 타입
+ fileName: documentInfo.fileName,
+ originalFileName: documentInfo.originalFileName,
+ fileSize: documentInfo.fileSize,
+ mimeType: documentInfo.mimeType,
+ filePath: documentInfo.filePath,
+ title: `견적서 - ${documentInfo.originalFileName}`,
+ description: '협력업체 제출 견적서',
+ isPublic: false,
+ isRequired: false,
+ uploadedBy: userId,
+ uploadedAt: new Date()
+ })
+ .returning()
+
+ return {
+ success: true,
+ message: '견적서가 성공적으로 업로드되었습니다.',
+ documentId: result[0].id
+ }
+ } catch (error) {
+ console.error('Failed to upload pre-quote document:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '견적서 업로드에 실패했습니다.'
+ }
+ }
+}
+
+// 업로드된 견적 문서 목록 조회
+export async function getPreQuoteDocuments(biddingId: number, companyId: number) {
+ try {
+ const documents = await db
+ .select({
+ id: biddingDocuments.id,
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ fileSize: biddingDocuments.fileSize,
+ filePath: biddingDocuments.filePath,
+ title: biddingDocuments.title,
+ description: biddingDocuments.description,
+ uploadedAt: biddingDocuments.uploadedAt
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.biddingId, biddingId),
+ eq(biddingDocuments.companyId, companyId),
+ )
+ )
+
+ return documents
+ } catch (error) {
+ console.error('Failed to get pre-quote documents:', error)
+ return []
+ }
+ }
+
+// 저장된 품목별 견적 조회 (임시저장/기존 데이터 불러오기용)
+export async function getSavedPrItemQuotations(biddingCompanyId: number) {
+ try {
+ const savedQuotations = await db
+ .select({
+ prItemId: companyPrItemBids.prItemId,
+ bidUnitPrice: companyPrItemBids.bidUnitPrice,
+ bidAmount: companyPrItemBids.bidAmount,
+ proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
+ technicalSpecification: companyPrItemBids.technicalSpecification,
+ currency: companyPrItemBids.currency
+ })
+ .from(companyPrItemBids)
+ .where(
+ and(
+ eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
+ eq(companyPrItemBids.isPreQuote, true)
+ )
+ )
+
+ // Decimal 타입을 number로 변환
+ return savedQuotations.map(item => ({
+ prItemId: item.prItemId,
+ bidUnitPrice: parseFloat(item.bidUnitPrice || '0'),
+ bidAmount: parseFloat(item.bidAmount || '0'),
+ proposedDeliveryDate: item.proposedDeliveryDate,
+ technicalSpecification: item.technicalSpecification,
+ currency: item.currency
+ }))
+ } catch (error) {
+ console.error('Failed to get saved PR item quotations:', error)
+ return []
+ }
+ } \ No newline at end of file
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx
new file mode 100644
index 00000000..692d12ea
--- /dev/null
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx
@@ -0,0 +1,57 @@
+'use client'
+
+import * as React from 'react'
+import { Bidding } from '@/db/schema'
+import { QuotationDetails, QuotationVendor } from '@/lib/bidding/detail/service'
+import { getBiddingCompanies } from '../service'
+
+import { BiddingPreQuoteVendorTableContent } from './bidding-pre-quote-vendor-table'
+
+interface BiddingPreQuoteContentProps {
+ bidding: Bidding
+ quotationDetails: QuotationDetails | null
+ quotationVendors: QuotationVendor[]
+ biddingCompanies: any[]
+ prItems: any[]
+}
+
+export function BiddingPreQuoteContent({
+ bidding,
+ quotationDetails,
+ quotationVendors,
+ biddingCompanies: initialBiddingCompanies,
+ prItems
+}: BiddingPreQuoteContentProps) {
+ const [biddingCompanies, setBiddingCompanies] = React.useState(initialBiddingCompanies)
+ const [refreshTrigger, setRefreshTrigger] = React.useState(0)
+
+ const handleRefresh = React.useCallback(async () => {
+ try {
+ const result = await getBiddingCompanies(bidding.id)
+ if (result.success && result.data) {
+ setBiddingCompanies(result.data)
+ }
+ setRefreshTrigger(prev => prev + 1)
+ } catch (error) {
+ console.error('Failed to refresh bidding companies:', error)
+ }
+ }, [bidding.id])
+
+ return (
+ <div className="space-y-6">
+ <BiddingPreQuoteVendorTableContent
+ biddingId={bidding.id}
+ bidding={bidding}
+ vendors={quotationVendors}
+ biddingCompanies={biddingCompanies}
+ onRefresh={handleRefresh}
+ onOpenItemsDialog={() => {}}
+ onOpenTargetPriceDialog={() => {}}
+ onOpenSelectionReasonDialog={() => {}}
+ onEdit={undefined}
+ onDelete={undefined}
+ onSelectWinner={undefined}
+ />
+ </div>
+ )
+}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx
new file mode 100644
index 00000000..84824c1e
--- /dev/null
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx
@@ -0,0 +1,185 @@
+'use client'
+
+import * as React from 'react'
+import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Badge } from '@/components/ui/badge'
+import { BiddingCompany } from './bidding-pre-quote-vendor-columns'
+import { sendPreQuoteInvitations } from '../service'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+import { Mail, Building2 } from 'lucide-react'
+
+interface BiddingPreQuoteInvitationDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ companies: BiddingCompany[]
+ onSuccess: () => void
+}
+
+export function BiddingPreQuoteInvitationDialog({
+ open,
+ onOpenChange,
+ companies,
+ onSuccess
+}: BiddingPreQuoteInvitationDialogProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [selectedCompanyIds, setSelectedCompanyIds] = React.useState<number[]>([])
+
+ // 초대 가능한 업체들 (pending 상태인 업체들)
+ const invitableCompanies = companies.filter(company =>
+ company.invitationStatus === 'pending' && company.companyName
+ )
+
+ const handleSelectAll = (checked: boolean) => {
+ if (checked) {
+ setSelectedCompanyIds(invitableCompanies.map(company => company.id))
+ } else {
+ setSelectedCompanyIds([])
+ }
+ }
+
+ const handleSelectCompany = (companyId: number, checked: boolean) => {
+ if (checked) {
+ setSelectedCompanyIds(prev => [...prev, companyId])
+ } else {
+ setSelectedCompanyIds(prev => prev.filter(id => id !== companyId))
+ }
+ }
+
+ const handleSendInvitations = () => {
+ if (selectedCompanyIds.length === 0) {
+ toast({
+ title: '알림',
+ description: '초대를 발송할 업체를 선택해주세요.',
+ variant: 'default',
+ })
+ return
+ }
+
+ startTransition(async () => {
+ const response = await sendPreQuoteInvitations(selectedCompanyIds)
+
+ if (response.success) {
+ toast({
+ title: '성공',
+ description: response.message,
+ })
+ setSelectedCompanyIds([])
+ onOpenChange(false)
+ onSuccess()
+ } else {
+ toast({
+ title: '오류',
+ description: response.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const handleOpenChange = (open: boolean) => {
+ onOpenChange(open)
+ if (!open) {
+ setSelectedCompanyIds([])
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className="sm:max-w-[600px]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Mail className="w-5 h-5" />
+ 사전견적 초대 발송
+ </DialogTitle>
+ <DialogDescription>
+ 선택한 업체들에게 사전견적 요청을 발송합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="py-4">
+ {invitableCompanies.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ 초대 가능한 업체가 없습니다.
+ </div>
+ ) : (
+ <>
+ {/* 전체 선택 */}
+ <div className="flex items-center space-x-2 mb-4 pb-2 border-b">
+ <Checkbox
+ id="select-all"
+ checked={selectedCompanyIds.length === invitableCompanies.length}
+ onCheckedChange={handleSelectAll}
+ />
+ <label htmlFor="select-all" className="font-medium">
+ 전체 선택 ({invitableCompanies.length}개 업체)
+ </label>
+ </div>
+
+ {/* 업체 목록 */}
+ <div className="space-y-3 max-h-80 overflow-y-auto">
+ {invitableCompanies.map((company) => (
+ <div key={company.id} className="flex items-center space-x-3 p-3 border rounded-lg">
+ <Checkbox
+ id={`company-${company.id}`}
+ checked={selectedCompanyIds.includes(company.id)}
+ onCheckedChange={(checked) => handleSelectCompany(company.id, !!checked)}
+ />
+ <div className="flex-1">
+ <div className="flex items-center gap-2">
+ <Building2 className="w-4 h-4" />
+ <span className="font-medium">{company.companyName}</span>
+ <Badge variant="outline" className="text-xs">
+ {company.companyCode}
+ </Badge>
+ </div>
+ {company.notes && (
+ <p className="text-sm text-muted-foreground mt-1">
+ {company.notes}
+ </p>
+ )}
+ </div>
+ <Badge variant="outline">
+ 대기중
+ </Badge>
+ </div>
+ ))}
+ </div>
+
+ {selectedCompanyIds.length > 0 && (
+ <div className="mt-4 p-3 bg-primary/5 rounded-lg">
+ <p className="text-sm text-primary">
+ <strong>{selectedCompanyIds.length}개 업체</strong>에 사전견적 초대를 발송합니다.
+ </p>
+ </div>
+ )}
+ </>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => handleOpenChange(false)}>
+ 취소
+ </Button>
+ <Button
+ onClick={handleSendInvitations}
+ disabled={isPending || selectedCompanyIds.length === 0}
+ >
+ <Mail className="w-4 h-4 mr-2" />
+ 초대 발송
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx
new file mode 100644
index 00000000..30cddbce
--- /dev/null
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx
@@ -0,0 +1,303 @@
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ MoreHorizontal, Edit, Trash2, UserPlus
+} from "lucide-react"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+// bidding_companies 테이블 타입 정의 (company_condition_responses와 join)
+export interface BiddingCompany {
+ id: number
+ biddingId: number
+ companyId: number
+ invitationStatus: 'pending' | 'sent' | 'accepted' | 'declined' | 'submitted'
+ invitedAt: Date | null
+ respondedAt: Date | null
+ preQuoteAmount: string | null
+ preQuoteSubmittedAt: Date | null
+ isPreQuoteSelected: boolean
+ isAttendingMeeting: boolean | null
+ notes: string | null
+ contactPerson: string | null
+ contactEmail: string | null
+ contactPhone: string | null
+ createdAt: Date
+ updatedAt: Date
+
+ // company_condition_responses 필드들
+ paymentTermsResponse: string | null
+ taxConditionsResponse: string | null
+ proposedContractDeliveryDate: string | null
+ priceAdjustmentResponse: boolean | null
+ isInitialResponse: boolean | null
+ incotermsResponse: string | null
+ proposedShippingPort: string | null
+ proposedDestinationPort: string | null
+ sparePartResponse: string | null
+ additionalProposals: string | null
+
+ // 조인된 업체 정보
+ companyName?: string
+ companyCode?: string
+}
+
+interface GetBiddingCompanyColumnsProps {
+ onEdit: (company: BiddingCompany) => void
+ onDelete: (company: BiddingCompany) => void
+ onInvite: (company: BiddingCompany) => void
+}
+
+export function getBiddingPreQuoteVendorColumns({
+ onEdit,
+ onDelete,
+ onInvite
+}: GetBiddingCompanyColumnsProps): ColumnDef<BiddingCompany>[] {
+ return [
+ {
+ id: 'select',
+ header: ({ table }) => (
+ <Checkbox
+ checked={table.getIsAllPageRowsSelected()}
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="모두 선택"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="행 선택"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: 'companyName',
+ header: '업체명',
+ cell: ({ row }) => (
+ <div className="font-medium">{row.original.companyName || '-'}</div>
+ ),
+ },
+ {
+ accessorKey: 'companyCode',
+ header: '업체코드',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.original.companyCode || '-'}</div>
+ ),
+ },
+ {
+ accessorKey: 'invitationStatus',
+ header: '초대 상태',
+ cell: ({ row }) => {
+ const status = row.original.invitationStatus
+ const variant = status === 'accepted' ? 'default' :
+ status === 'declined' ? 'destructive' : 'outline'
+
+ const label = status === 'accepted' ? '수락' :
+ status === 'declined' ? '거절' : '대기중'
+
+ return <Badge variant={variant}>{label}</Badge>
+ },
+ },
+ {
+ accessorKey: 'preQuoteAmount',
+ header: '사전견적금액',
+ cell: ({ row }) => (
+ <div className="text-right font-mono">
+ {row.original.preQuoteAmount ? Number(row.original.preQuoteAmount).toLocaleString() : '-'} KRW
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'preQuoteSubmittedAt',
+ header: '사전견적 제출일',
+ cell: ({ row }) => (
+ <div className="text-sm">
+ {row.original.preQuoteSubmittedAt ? new Date(row.original.preQuoteSubmittedAt).toLocaleDateString('ko-KR') : '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'isPreQuoteSelected',
+ header: '본입찰 선정',
+ cell: ({ row }) => (
+ <Badge variant={row.original.isPreQuoteSelected ? 'default' : 'secondary'}>
+ {row.original.isPreQuoteSelected ? '선정' : '미선정'}
+ </Badge>
+ ),
+ },
+ {
+ accessorKey: 'isAttendingMeeting',
+ header: '사양설명회 참석',
+ cell: ({ row }) => {
+ const isAttending = row.original.isAttendingMeeting
+ if (isAttending === null) return <div className="text-sm">-</div>
+ return (
+ <Badge variant={isAttending ? 'default' : 'secondary'}>
+ {isAttending ? '참석' : '불참석'}
+ </Badge>
+ )
+ },
+ },
+ {
+ accessorKey: 'paymentTermsResponse',
+ header: '지급조건',
+ cell: ({ row }) => (
+ <div className="text-sm max-w-32 truncate" title={row.original.paymentTermsResponse || ''}>
+ {row.original.paymentTermsResponse || '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'taxConditionsResponse',
+ header: '세금조건',
+ cell: ({ row }) => (
+ <div className="text-sm max-w-32 truncate" title={row.original.taxConditionsResponse || ''}>
+ {row.original.taxConditionsResponse || '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'incotermsResponse',
+ header: '운송조건',
+ cell: ({ row }) => (
+ <div className="text-sm max-w-24 truncate" title={row.original.incotermsResponse || ''}>
+ {row.original.incotermsResponse || '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'isInitialResponse',
+ header: '초도여부',
+ cell: ({ row }) => {
+ const isInitial = row.original.isInitialResponse
+ if (isInitial === null) return <div className="text-sm">-</div>
+ return (
+ <Badge variant={isInitial ? 'default' : 'secondary'}>
+ {isInitial ? 'Y' : 'N'}
+ </Badge>
+ )
+ },
+ },
+ {
+ accessorKey: 'priceAdjustmentResponse',
+ header: '연동제',
+ cell: ({ row }) => {
+ const hasPriceAdjustment = row.original.priceAdjustmentResponse
+ if (hasPriceAdjustment === null) return <div className="text-sm">-</div>
+ return (
+ <Badge variant={hasPriceAdjustment ? 'default' : 'secondary'}>
+ {hasPriceAdjustment ? '적용' : '미적용'}
+ </Badge>
+ )
+ },
+ },
+ {
+ accessorKey: 'proposedContractDeliveryDate',
+ header: '제안납기일',
+ cell: ({ row }) => (
+ <div className="text-sm">
+ {row.original.proposedContractDeliveryDate ?
+ new Date(row.original.proposedContractDeliveryDate).toLocaleDateString('ko-KR') : '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'proposedShippingPort',
+ header: '제안선적지',
+ cell: ({ row }) => (
+ <div className="text-sm max-w-24 truncate" title={row.original.proposedShippingPort || ''}>
+ {row.original.proposedShippingPort || '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'proposedDestinationPort',
+ header: '제안도착지',
+ cell: ({ row }) => (
+ <div className="text-sm max-w-24 truncate" title={row.original.proposedDestinationPort || ''}>
+ {row.original.proposedDestinationPort || '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'sparePartResponse',
+ header: '스페어파트',
+ cell: ({ row }) => (
+ <div className="text-sm max-w-24 truncate" title={row.original.sparePartResponse || ''}>
+ {row.original.sparePartResponse || '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'additionalProposals',
+ header: '추가제안',
+ cell: ({ row }) => (
+ <div className="text-sm max-w-32 truncate" title={row.original.additionalProposals || ''}>
+ {row.original.additionalProposals || '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'notes',
+ header: '특이사항',
+ cell: ({ row }) => (
+ <div className="text-sm max-w-32 truncate" title={row.original.notes || ''}>
+ {row.original.notes || '-'}
+ </div>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '작업',
+ cell: ({ row }) => {
+ const company = row.original
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="h-8 w-8 p-0">
+ <span className="sr-only">메뉴 열기</span>
+ <MoreHorizontal className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuLabel>작업</DropdownMenuLabel>
+ <DropdownMenuItem onClick={() => onEdit(company)}>
+ <Edit className="mr-2 h-4 w-4" />
+ 수정
+ </DropdownMenuItem>
+ {company.invitationStatus === 'pending' && (
+ <DropdownMenuItem onClick={() => onInvite(company)}>
+ <UserPlus className="mr-2 h-4 w-4" />
+ 초대 발송
+ </DropdownMenuItem>
+ )}
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onClick={() => onDelete(company)}
+ className="text-destructive"
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ },
+ ]
+}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx
new file mode 100644
index 00000000..e2a38547
--- /dev/null
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx
@@ -0,0 +1,205 @@
+'use client'
+
+import * as React from 'react'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from '@/components/ui/command'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import { Check, ChevronsUpDown } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { createBiddingCompany } from '@/lib/bidding/pre-quote/service'
+import { searchVendors } from '@/lib/vendors/service'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface BiddingPreQuoteVendorCreateDialogProps {
+ biddingId: number
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSuccess: () => void
+}
+
+interface Vendor {
+ id: number
+ vendorName: string
+ vendorCode: string
+ status: string
+}
+
+export function BiddingPreQuoteVendorCreateDialog({
+ biddingId,
+ open,
+ onOpenChange,
+ onSuccess
+}: BiddingPreQuoteVendorCreateDialogProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+
+ // Vendor 검색 상태
+ const [vendors, setVendors] = React.useState<Vendor[]>([])
+ const [selectedVendor, setSelectedVendor] = React.useState<Vendor | null>(null)
+ const [vendorSearchOpen, setVendorSearchOpen] = React.useState(false)
+ const [vendorSearchValue, setVendorSearchValue] = React.useState('')
+
+
+ // Vendor 검색
+ React.useEffect(() => {
+ const search = async () => {
+ if (vendorSearchValue.trim().length < 2) {
+ setVendors([])
+ return
+ }
+
+ try {
+ const result = await searchVendors(vendorSearchValue.trim(), 10)
+ setVendors(result)
+ } catch (error) {
+ console.error('Vendor search failed:', error)
+ setVendors([])
+ }
+ }
+
+ const debounceTimer = setTimeout(search, 300)
+ return () => clearTimeout(debounceTimer)
+ }, [vendorSearchValue])
+
+ const handleVendorSelect = (vendor: Vendor) => {
+ setSelectedVendor(vendor)
+ setVendorSearchValue(`${vendor.vendorName} (${vendor.vendorCode})`)
+ setVendorSearchOpen(false)
+ }
+
+ const handleCreate = () => {
+ if (!selectedVendor) {
+ toast({
+ title: '오류',
+ description: '업체를 선택해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ startTransition(async () => {
+ const response = await createBiddingCompany({
+ biddingId,
+ companyId: selectedVendor.id,
+ })
+ console.log(response)
+ if (response.success) {
+ toast({
+ title: '성공',
+ description: response.message,
+ })
+ onOpenChange(false)
+ resetForm()
+ onSuccess()
+ } else {
+ toast({
+ title: '오류',
+ description: response.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const resetForm = () => {
+ setSelectedVendor(null)
+ setVendorSearchValue('')
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[600px]">
+ <DialogHeader>
+ <DialogTitle>사전견적 업체 추가</DialogTitle>
+ <DialogDescription>
+ 검색해서 업체를 선택해주세요.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ {/* Vendor 검색 */}
+ <div className="space-y-2">
+ <Label htmlFor="vendor-search">업체 검색</Label>
+ <Popover open={vendorSearchOpen} onOpenChange={setVendorSearchOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={vendorSearchOpen}
+ className="w-full justify-between"
+ >
+ {selectedVendor
+ ? `${selectedVendor.vendorName} (${selectedVendor.vendorCode})`
+ : "업체를 검색해서 선택하세요..."}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-full p-0">
+ <Command>
+ <CommandInput
+ placeholder="업체명 또는 코드를 입력하세요..."
+ value={vendorSearchValue}
+ onValueChange={setVendorSearchValue}
+ />
+ <CommandEmpty>
+ {vendorSearchValue.length < 2
+ ? "최소 2자 이상 입력해주세요"
+ : "검색 결과가 없습니다"}
+ </CommandEmpty>
+ <CommandGroup className="max-h-64 overflow-auto">
+ {vendors.map((vendor) => (
+ <CommandItem
+ key={vendor.id}
+ value={`${vendor.vendorName} ${vendor.vendorCode}`}
+ onSelect={() => handleVendorSelect(vendor)}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ selectedVendor?.id === vendor.id ? "opacity-100" : "opacity-0"
+ )}
+ />
+ <div className="flex flex-col">
+ <span className="font-medium">{vendor.vendorName}</span>
+ <span className="text-sm text-muted-foreground">{vendor.vendorCode}</span>
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </div>
+
+ </div>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button onClick={handleCreate} disabled={isPending || !selectedVendor}>
+ 추가
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx
new file mode 100644
index 00000000..03bf2ecb
--- /dev/null
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx
@@ -0,0 +1,200 @@
+'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 { updateBiddingCompany } from '../service'
+import { BiddingCompany } from './bidding-pre-quote-vendor-columns'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface BiddingPreQuoteVendorEditDialogProps {
+ company: BiddingCompany | null
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSuccess: () => void
+}
+
+export function BiddingPreQuoteVendorEditDialog({
+ company,
+ open,
+ onOpenChange,
+ onSuccess
+}: BiddingPreQuoteVendorEditDialogProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+
+ // 폼 상태
+ const [formData, setFormData] = React.useState({
+ contactPerson: '',
+ contactEmail: '',
+ contactPhone: '',
+ preQuoteAmount: 0,
+ notes: '',
+ invitationStatus: 'pending' as 'pending' | 'accepted' | 'declined',
+ isPreQuoteSelected: false,
+ isAttendingMeeting: false,
+ })
+
+ // company가 변경되면 폼 데이터 업데이트
+ React.useEffect(() => {
+ if (company) {
+ setFormData({
+ contactPerson: company.contactPerson || '',
+ contactEmail: company.contactEmail || '',
+ contactPhone: company.contactPhone || '',
+ preQuoteAmount: company.preQuoteAmount ? Number(company.preQuoteAmount) : 0,
+ notes: company.notes || '',
+ invitationStatus: company.invitationStatus,
+ isPreQuoteSelected: company.isPreQuoteSelected,
+ isAttendingMeeting: company.isAttendingMeeting || false,
+ })
+ }
+ }, [company])
+
+ const handleEdit = () => {
+ if (!company) return
+
+ startTransition(async () => {
+ const response = await updateBiddingCompany(company.id, formData)
+
+ if (response.success) {
+ toast({
+ title: '성공',
+ description: response.message,
+ })
+ onOpenChange(false)
+ onSuccess()
+ } else {
+ toast({
+ title: '오류',
+ description: response.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[600px]">
+ <DialogHeader>
+ <DialogTitle>사전견적 업체 수정</DialogTitle>
+ <DialogDescription>
+ {company?.companyName} 업체의 사전견적 정보를 수정해주세요.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ <div className="grid grid-cols-3 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="edit-contactPerson">담당자</Label>
+ <Input
+ id="edit-contactPerson"
+ value={formData.contactPerson}
+ onChange={(e) => setFormData({ ...formData, contactPerson: e.target.value })}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-contactEmail">이메일</Label>
+ <Input
+ id="edit-contactEmail"
+ type="email"
+ value={formData.contactEmail}
+ onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-contactPhone">연락처</Label>
+ <Input
+ id="edit-contactPhone"
+ value={formData.contactPhone}
+ onChange={(e) => setFormData({ ...formData, contactPhone: e.target.value })}
+ />
+ </div>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="edit-preQuoteAmount">사전견적금액</Label>
+ <Input
+ id="edit-preQuoteAmount"
+ type="number"
+ value={formData.preQuoteAmount}
+ onChange={(e) => setFormData({ ...formData, preQuoteAmount: Number(e.target.value) })}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-invitationStatus">초대 상태</Label>
+ <Select value={formData.invitationStatus} onValueChange={(value: any) => setFormData({ ...formData, invitationStatus: value })}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="pending">대기중</SelectItem>
+ <SelectItem value="accepted">수락</SelectItem>
+ <SelectItem value="declined">거절</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="edit-isPreQuoteSelected"
+ checked={formData.isPreQuoteSelected}
+ onCheckedChange={(checked) =>
+ setFormData({ ...formData, isPreQuoteSelected: !!checked })
+ }
+ />
+ <Label htmlFor="edit-isPreQuoteSelected">본입찰 선정</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="edit-isAttendingMeeting"
+ checked={formData.isAttendingMeeting}
+ onCheckedChange={(checked) =>
+ setFormData({ ...formData, isAttendingMeeting: !!checked })
+ }
+ />
+ <Label htmlFor="edit-isAttendingMeeting">사양설명회 참석</Label>
+ </div>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-notes">특이사항</Label>
+ <Textarea
+ id="edit-notes"
+ value={formData.notes}
+ onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
+ placeholder="특이사항을 입력해주세요..."
+ />
+ </div>
+ </div>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button onClick={handleEdit} disabled={isPending}>
+ 수정
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx
new file mode 100644
index 00000000..a9d12629
--- /dev/null
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx
@@ -0,0 +1,189 @@
+'use client'
+
+import * as React from 'react'
+import { type DataTableAdvancedFilterField, type DataTableFilterField } from '@/types/table'
+import { useDataTable } from '@/hooks/use-data-table'
+import { DataTable } from '@/components/data-table/data-table'
+import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-advanced-toolbar'
+import { BiddingPreQuoteVendorToolbarActions } from './bidding-pre-quote-vendor-toolbar-actions'
+import { BiddingPreQuoteVendorEditDialog } from './bidding-pre-quote-vendor-edit-dialog'
+import { getBiddingPreQuoteVendorColumns, BiddingCompany } from './bidding-pre-quote-vendor-columns'
+import { Bidding } from '@/db/schema'
+import {
+ deleteBiddingCompany
+} from '../service'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface BiddingPreQuoteVendorTableContentProps {
+ biddingId: number
+ bidding: Bidding
+ vendors: any[] // 사용하지 않음
+ biddingCompanies: BiddingCompany[]
+ onRefresh: () => void
+ onOpenItemsDialog: () => void
+ onOpenTargetPriceDialog: () => void
+ onOpenSelectionReasonDialog: () => void
+ onEdit?: (company: BiddingCompany) => void
+ onDelete?: (company: BiddingCompany) => void
+ onSelectWinner?: (company: BiddingCompany) => void
+}
+
+const filterFields: DataTableFilterField<BiddingCompany>[] = [
+ {
+ id: 'companyName',
+ label: '업체명',
+ placeholder: '업체명으로 검색...',
+ },
+ {
+ id: 'companyCode',
+ label: '업체코드',
+ placeholder: '업체코드로 검색...',
+ },
+ {
+ id: 'contactPerson',
+ label: '담당자',
+ placeholder: '담당자로 검색...',
+ },
+]
+
+const advancedFilterFields: DataTableAdvancedFilterField<BiddingCompany>[] = [
+ {
+ id: 'companyName',
+ label: '업체명',
+ type: 'text',
+ },
+ {
+ id: 'companyCode',
+ label: '업체코드',
+ type: 'text',
+ },
+ {
+ id: 'contactPerson',
+ label: '담당자',
+ type: 'text',
+ },
+ {
+ id: 'preQuoteAmount',
+ label: '사전견적금액',
+ type: 'number',
+ },
+ {
+ id: 'invitationStatus',
+ label: '초대 상태',
+ type: 'multi-select',
+ options: [
+ { label: '수락', value: 'accepted' },
+ { label: '거절', value: 'declined' },
+ { label: '대기중', value: 'pending' },
+ ],
+ },
+]
+
+export function BiddingPreQuoteVendorTableContent({
+ biddingId,
+ bidding,
+ vendors,
+ biddingCompanies,
+ onRefresh,
+ onOpenItemsDialog,
+ onOpenTargetPriceDialog,
+ onOpenSelectionReasonDialog,
+ onEdit,
+ onDelete,
+ onSelectWinner
+}: BiddingPreQuoteVendorTableContentProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [selectedCompany, setSelectedCompany] = React.useState<BiddingCompany | null>(null)
+ const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
+
+ const handleDelete = (company: BiddingCompany) => {
+ if (!confirm(`${company.companyName} 업체를 삭제하시겠습니까?`)) return
+
+ startTransition(async () => {
+ const response = await deleteBiddingCompany(company.id)
+
+ if (response.success) {
+ toast({
+ title: '성공',
+ description: response.message,
+ })
+ onRefresh()
+ } else {
+ toast({
+ title: '오류',
+ description: response.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const handleEdit = (company: BiddingCompany) => {
+ setSelectedCompany(company)
+ setIsEditDialogOpen(true)
+ }
+
+ const handleInvite = (company: BiddingCompany) => {
+ // TODO: 초대 발송 로직 구현
+ toast({
+ title: '알림',
+ description: `${company.companyName} 업체에 초대를 발송했습니다.`,
+ })
+ }
+
+ const columns = React.useMemo(
+ () => getBiddingPreQuoteVendorColumns({
+ onEdit: onEdit || handleEdit,
+ onDelete: onDelete || handleDelete,
+ onInvite: handleInvite
+ }),
+ [onEdit, onDelete, handleEdit, handleDelete, handleInvite]
+ )
+
+ const { table } = useDataTable({
+ data: biddingCompanies,
+ columns,
+ pageCount: 1,
+ filterFields,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: 'companyName', desc: false }],
+ columnPinning: { right: ['actions'] },
+ },
+ getRowId: (originalRow) => originalRow.id.toString(),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <BiddingPreQuoteVendorToolbarActions
+ table={table}
+ biddingId={biddingId}
+ bidding={bidding}
+ biddingCompanies={biddingCompanies}
+ onOpenItemsDialog={onOpenItemsDialog}
+ onOpenTargetPriceDialog={onOpenTargetPriceDialog}
+ onOpenSelectionReasonDialog={onOpenSelectionReasonDialog}
+ onSuccess={onRefresh}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <BiddingPreQuoteVendorEditDialog
+ company={selectedCompany}
+ open={isEditDialogOpen}
+ onOpenChange={setIsEditDialogOpen}
+ onSuccess={onRefresh}
+ />
+ </>
+ )
+}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx
new file mode 100644
index 00000000..c1b1baa5
--- /dev/null
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx
@@ -0,0 +1,92 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { useTransition } from "react"
+import { Button } from "@/components/ui/button"
+import { Plus, Send, Mail } from "lucide-react"
+import { BiddingCompany } from "./bidding-pre-quote-vendor-columns"
+import { BiddingPreQuoteVendorCreateDialog } from "./bidding-pre-quote-vendor-create-dialog"
+import { BiddingPreQuoteInvitationDialog } from "./bidding-pre-quote-invitation-dialog"
+import { Bidding } from "@/db/schema"
+import { useToast } from "@/hooks/use-toast"
+
+interface BiddingPreQuoteVendorToolbarActionsProps {
+ table: Table<BiddingCompany>
+ biddingId: number
+ bidding: Bidding
+ biddingCompanies: BiddingCompany[]
+ onOpenItemsDialog: () => void
+ onOpenTargetPriceDialog: () => void
+ onOpenSelectionReasonDialog: () => void
+ onSuccess: () => void
+}
+
+export function BiddingPreQuoteVendorToolbarActions({
+ table,
+ biddingId,
+ bidding,
+ biddingCompanies,
+ onOpenItemsDialog,
+ onOpenTargetPriceDialog,
+ onOpenSelectionReasonDialog,
+ onSuccess
+}: BiddingPreQuoteVendorToolbarActionsProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false)
+ const [isInvitationDialogOpen, setIsInvitationDialogOpen] = React.useState(false)
+
+ const handleCreateCompany = () => {
+ setIsCreateDialogOpen(true)
+ }
+
+ const handleSendInvitations = () => {
+ setIsInvitationDialogOpen(true)
+ }
+
+
+
+ return (
+ <>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleCreateCompany}
+ disabled={isPending}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 업체 추가
+ </Button>
+
+ <Button
+ variant="default"
+ size="sm"
+ onClick={handleSendInvitations}
+ disabled={isPending}
+ >
+ <Mail className="mr-2 h-4 w-4" />
+ 초대 발송
+ </Button>
+ </div>
+
+ <BiddingPreQuoteVendorCreateDialog
+ biddingId={biddingId}
+ open={isCreateDialogOpen}
+ onOpenChange={setIsCreateDialogOpen}
+ onSuccess={() => {
+ onSuccess()
+ setIsCreateDialogOpen(false)
+ }}
+ />
+
+ <BiddingPreQuoteInvitationDialog
+ open={isInvitationDialogOpen}
+ onOpenChange={setIsInvitationDialogOpen}
+ companies={biddingCompanies}
+ onSuccess={onSuccess}
+ />
+ </>
+ )
+}