diff options
Diffstat (limited to 'lib/bidding/pre-quote')
11 files changed, 1618 insertions, 4150 deletions
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts index 0f284297..ea92f294 100644 --- a/lib/bidding/pre-quote/service.ts +++ b/lib/bidding/pre-quote/service.ts @@ -1,1528 +1,1619 @@ -'use server' - -import db from '@/db/db' -import { biddingCompanies, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding' -import { basicContractTemplates } from '@/db/schema' -import { vendors } from '@/db/schema/vendors' -import { users } from '@/db/schema' -import { sendEmail } from '@/lib/mail/sendEmail' -import { eq, inArray, and, ilike, sql } from 'drizzle-orm' -import { mkdir, writeFile } from 'fs/promises' -import path from 'path' -import { revalidateTag, revalidatePath } from 'next/cache' -import { basicContract } from '@/db/schema/basicContractDocumnet' -import { saveFile } from '@/lib/file-stroage' - -// 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 // user.name이 없으면 userId를 그대로 반환 - } catch (error) { - console.error('Failed to get user name:', error) - return userId // 에러 시 userId를 그대로 반환 - } -} - -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 -} - - - -// 사전견적용 업체 추가 - biddingCompanies와 company_condition_responses 레코드 생성 -export async function createBiddingCompany(input: CreateBiddingCompanyInput) { - try { - const result = await db.transaction(async (tx) => { - // 0. 중복 체크 - 이미 해당 입찰에 참여중인 업체인지 확인 - const existingCompany = await tx - .select() - .from(biddingCompanies) - .where(sql`${biddingCompanies.biddingId} = ${input.biddingId} AND ${biddingCompanies.companyId} = ${input.companyId}`) - - if (existingCompany.length > 0) { - throw new Error('이미 등록된 업체입니다') - } - // 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 updatePreQuoteSelection(companyIds: number[], isSelected: boolean) { - try { - // 업체들의 입찰 ID 조회 (캐시 무효화를 위해) - const companies = await db - .select({ biddingId: biddingCompanies.biddingId }) - .from(biddingCompanies) - .where(inArray(biddingCompanies.id, companyIds)) - .limit(1) - - await db.update(biddingCompanies) - .set({ - isPreQuoteSelected: isSelected, - invitationStatus: 'pending', // 초기 상태: 입찰생성 - updatedAt: new Date() - }) - .where(inArray(biddingCompanies.id, companyIds)) - - // 캐시 무효화 - if (companies.length > 0) { - const biddingId = companies[0].biddingId - revalidateTag(`bidding-${biddingId}`) - revalidateTag('bidding-detail') - revalidateTag('quotation-vendors') - revalidateTag('quotation-details') - revalidatePath(`/evcp/bid/${biddingId}`) - } - - const message = isSelected - ? `${companyIds.length}개 업체가 본입찰 대상으로 선정되었습니다.` - : `${companyIds.length}개 업체의 본입찰 선정이 취소되었습니다.` - - return { - success: true, - message - } - } catch (error) { - console.error('Failed to update pre-quote selection:', error) - return { - success: false, - error: error instanceof Error ? error.message : '본입찰 선정 상태 업데이트에 실패했습니다.' - } - } -} - -// 사전견적용 업체 삭제 -export async function deleteBiddingCompany(id: number) { - try { - // 1. 해당 업체의 초대 상태 확인 - const company = await db - .select({ invitationStatus: biddingCompanies.invitationStatus }) - .from(biddingCompanies) - .where(eq(biddingCompanies.id, id)) - .then(rows => rows[0]) - - if (!company) { - return { - success: false, - error: '해당 업체를 찾을 수 없습니다.' - } - } - - // 이미 초대가 발송된 경우(수락, 거절, 요청됨 등) 삭제 불가 - if (company.invitationStatus !== 'pending') { - return { - success: false, - error: '이미 초대를 보낸 업체는 삭제할 수 없습니다.' - } - } - - await db.transaction(async (tx) => { - // 2. 먼저 관련된 조건 응답들 삭제 - await tx.delete(companyConditionResponses) - .where(eq(companyConditionResponses.biddingCompanyId, id)) - - // 3. 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, - preQuoteDeadline: biddingCompanies.preQuoteDeadline, - isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, - isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated, - 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, - additionalProposals: companyConditionResponses.additionalProposals, - }) - .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[], preQuoteDeadline?: Date | string) { - 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(), - preQuoteDeadline: preQuoteDeadline ? new Date(preQuoteDeadline) : null, - 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) - // 이메일 발송 실패해도 전체 프로세스는 계속 진행 - } - } - } - // 3. 입찰 상태를 사전견적 요청으로 변경 (bidding_generated 상태에서만) - for (const company of companiesInfo) { - await db.transaction(async (tx) => { - await tx - .update(biddings) - .set({ - status: 'request_for_quotation', - updatedAt: new Date() - }) - .where(and( - eq(biddings.id, company.biddingId), - eq(biddings.status, 'bidding_generated') - )) - }) - } - 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, - contractStartDate: biddings.contractStartDate, - contractEndDate: biddings.contractEndDate, - 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, - preQuoteDeadline: biddingCompanies.preQuoteDeadline, - isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, - isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated, - 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, - preQuoteDeadline: null, - isPreQuoteSelected: false, - isPreQuoteParticipated: null, - 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 as string || null, - comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null, - adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null, - notes: responseData.priceAdjustmentForm.notes, - adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions, - majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial, - adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod, - contractorWriter: responseData.priceAdjustmentForm.contractorWriter, - adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null, - nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason, - } as any - - // 기존 연동제 정보가 있는지 확인 - 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) - } - } - - // 5. 입찰 상태를 사전견적 접수로 변경 (request_for_quotation 상태에서만) - // 또한 사전견적 접수일 업데이트 - const biddingCompany = await tx - .select({ biddingId: biddingCompanies.biddingId }) - .from(biddingCompanies) - .where(eq(biddingCompanies.id, biddingCompanyId)) - .limit(1) - - if (biddingCompany.length > 0) { - await tx - .update(biddings) - .set({ - status: 'received_quotation', - preQuoteDate: new Date().toISOString().split('T')[0], // 사전견적 접수일 업데이트 - updatedAt: new Date() - }) - .where(and( - eq(biddings.id, biddingCompany[0].biddingId), - eq(biddings.status, 'request_for_quotation') - )) - } - }) - - 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' -) { - 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 : '응답 처리에 실패했습니다.' - } - } -} - -// 벤더에서 사전견적 참여 여부 결정 (isPreQuoteParticipated 사용) -export async function setPreQuoteParticipation( - biddingCompanyId: number, - isParticipating: boolean -) { - try { - await db.update(biddingCompanies) - .set({ - isPreQuoteParticipated: isParticipating, - 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, - totalWeight: prItemsForBidding.totalWeight, - weightUnit: prItemsForBidding.weightUnit, - 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, 'spec_document') - ) - ) - - 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 as string || null, - comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null, - adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null, - notes: responseData.priceAdjustmentForm.notes, - adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions, - majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial, - adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod, - contractorWriter: responseData.priceAdjustmentForm.contractorWriter, - adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null, - nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason, - } as any - - // 기존 연동제 정보가 있는지 확인 - 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, - file: File, - userId: string -) { - try { - const userName = await getUserNameById(userId) - // 파일 저장 - const saveResult = await saveFile({ - file, - directory: `bidding/${biddingId}/quotations`, - originalName: file.name, - userId - }) - - if (!saveResult.success) { - return { - success: false, - error: saveResult.error || '파일 저장에 실패했습니다.' - } - } - - // 데이터베이스에 문서 정보 저장 - const result = await db.insert(biddingDocuments) - .values({ - biddingId, - companyId, - documentType: 'other', // 견적서 타입 - fileName: saveResult.fileName!, - originalFileName: file.name, - fileSize: file.size, - mimeType: file.type, - filePath: saveResult.publicPath!, // publicPath 사용 (웹 접근 가능한 경로) - title: `견적서 - ${file.name}`, - description: '협력업체 제출 견적서', - isPublic: false, - isRequired: false, - uploadedBy: userName, - 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, - uploadedBy: biddingDocuments.uploadedBy - }) - .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 [] - } - } - -// 견적 문서 정보 조회 (다운로드용) -export async function getPreQuoteDocumentForDownload( - documentId: number, - biddingId: number, - companyId: number -) { - try { - const document = await db - .select({ - fileName: biddingDocuments.fileName, - originalFileName: biddingDocuments.originalFileName, - filePath: biddingDocuments.filePath - }) - .from(biddingDocuments) - .where( - and( - eq(biddingDocuments.id, documentId), - eq(biddingDocuments.biddingId, biddingId), - eq(biddingDocuments.companyId, companyId), - eq(biddingDocuments.documentType, 'other') - ) - ) - .limit(1) - - if (document.length === 0) { - return { - success: false, - error: '문서를 찾을 수 없습니다.' - } - } - - return { - success: true, - document: document[0] - } - } catch (error) { - console.error('Failed to get pre-quote document:', error) - return { - success: false, - error: '문서 정보 조회에 실패했습니다.' - } - } -} - -// 견적 문서 삭제 -export async function deletePreQuoteDocument( - documentId: number, - biddingId: number, - companyId: number, - userId: string -) { - try { - // 문서 존재 여부 및 권한 확인 - const document = await db - .select({ - id: biddingDocuments.id, - fileName: biddingDocuments.fileName, - filePath: biddingDocuments.filePath, - uploadedBy: biddingDocuments.uploadedBy - }) - .from(biddingDocuments) - .where( - and( - eq(biddingDocuments.id, documentId), - eq(biddingDocuments.biddingId, biddingId), - eq(biddingDocuments.companyId, companyId), - eq(biddingDocuments.documentType, 'other') - ) - ) - .limit(1) - - if (document.length === 0) { - return { - success: false, - error: '문서를 찾을 수 없습니다.' - } - } - - const doc = document[0] - - // 데이터베이스에서 문서 정보 삭제 - await db - .delete(biddingDocuments) - .where(eq(biddingDocuments.id, documentId)) - - return { - success: true, - message: '문서가 성공적으로 삭제되었습니다.' - } - } catch (error) { - console.error('Failed to delete pre-quote document:', error) - return { - success: false, - error: '문서 삭제에 실패했습니다.' - } - } - } - -// 기본계약 발송 (서버 액션) -export async function sendBiddingBasicContracts( - biddingId: number, - vendorData: Array<{ - vendorId: number - vendorName: string - vendorCode?: string - vendorCountry?: string - selectedMainEmail: string - additionalEmails: string[] - customEmails?: Array<{ email: string; name?: string }> - contractRequirements: { - ndaYn: boolean - generalGtcYn: boolean - projectGtcYn: boolean - agreementYn: boolean - } - biddingCompanyId: number - biddingId: number - hasExistingContracts?: boolean - }>, - generatedPdfs: Array<{ - key: string - buffer: number[] - fileName: string - }>, - message?: string -) { - try { - console.log("sendBiddingBasicContracts called with:", { biddingId, vendorData: vendorData.map(v => ({ vendorId: v.vendorId, biddingCompanyId: v.biddingCompanyId, biddingId: v.biddingId })) }); - - // 현재 사용자 정보 조회 (임시로 첫 번째 사용자 사용) - const [currentUser] = await db.select().from(users).limit(1) - - if (!currentUser) { - throw new Error("사용자 정보를 찾을 수 없습니다.") - } - - const results = [] - const savedContracts = [] - - // 트랜잭션 시작 - const contractsDir = path.join(process.cwd(), `${process.env.NAS_PATH}`, "contracts", "generated"); - await mkdir(contractsDir, { recursive: true }); - - const result = await db.transaction(async (tx) => { - // 각 벤더별로 기본계약 생성 및 이메일 발송 - for (const vendor of vendorData) { - // 기존 계약 확인 (biddingCompanyId 기준) - if (vendor.hasExistingContracts) { - console.log(`벤더 ${vendor.vendorName}는 이미 계약이 체결되어 있어 건너뜁니다.`) - continue - } - - // 벤더 정보 조회 - const [vendorInfo] = await tx - .select() - .from(vendors) - .where(eq(vendors.id, vendor.vendorId)) - .limit(1) - - if (!vendorInfo) { - console.error(`벤더 정보를 찾을 수 없습니다: ${vendor.vendorId}`) - continue - } - - // biddingCompany 정보 조회 (biddingCompanyId를 직접 사용) - console.log(`Looking for biddingCompany with id=${vendor.biddingCompanyId}`) - let [biddingCompanyInfo] = await tx - .select() - .from(biddingCompanies) - .where(eq(biddingCompanies.id, vendor.biddingCompanyId)) - .limit(1) - - console.log(`Found biddingCompanyInfo:`, biddingCompanyInfo) - if (!biddingCompanyInfo) { - console.error(`입찰 회사 정보를 찾을 수 없습니다: biddingCompanyId=${vendor.biddingCompanyId}`) - // fallback: biddingId와 vendorId로 찾기 시도 - console.log(`Fallback: Looking for biddingCompany with biddingId=${biddingId}, companyId=${vendor.vendorId}`) - const [fallbackCompanyInfo] = await tx - .select() - .from(biddingCompanies) - .where(and( - eq(biddingCompanies.biddingId, biddingId), - eq(biddingCompanies.companyId, vendor.vendorId) - )) - .limit(1) - console.log(`Fallback found biddingCompanyInfo:`, fallbackCompanyInfo) - if (fallbackCompanyInfo) { - console.log(`Using fallback biddingCompanyInfo`) - biddingCompanyInfo = fallbackCompanyInfo - } else { - console.log(`Available biddingCompanies for biddingId=${biddingId}:`, await tx.select().from(biddingCompanies).where(eq(biddingCompanies.biddingId, biddingId)).limit(10)) - continue - } - } - - // 계약 요구사항에 따라 계약서 생성 - const contractTypes: Array<{ type: string; templateName: string }> = [] - if (vendor.contractRequirements.ndaYn) contractTypes.push({ type: 'NDA', templateName: '비밀' }) - if (vendor.contractRequirements.generalGtcYn) contractTypes.push({ type: 'General_GTC', templateName: 'General GTC' }) - if (vendor.contractRequirements.projectGtcYn) contractTypes.push({ type: 'Project_GTC', templateName: '기술' }) - if (vendor.contractRequirements.agreementYn) contractTypes.push({ type: '기술자료', templateName: '기술자료' }) - console.log("contractTypes", contractTypes) - for (const contractType of contractTypes) { - // PDF 데이터 찾기 (include를 사용하여 유연하게 찾기) - console.log("generatedPdfs", generatedPdfs.map(pdf => pdf.key)) - const pdfData = generatedPdfs.find((pdf: any) => - pdf.key.includes(`${vendor.vendorId}_`) && - pdf.key.includes(`_${contractType.templateName}`) - ) - console.log("pdfData", pdfData, "for contractType", contractType) - if (!pdfData) { - console.error(`PDF 데이터를 찾을 수 없습니다: vendorId=${vendor.vendorId}, templateName=${contractType.templateName}`) - continue - } - - // 파일 저장 (rfq-last 방식) - const fileName = `${contractType.type}_${vendor.vendorCode || vendor.vendorId}_${vendor.biddingCompanyId}_${Date.now()}.pdf` - const filePath = path.join(contractsDir, fileName); - - await writeFile(filePath, Buffer.from(pdfData.buffer)); - - // 템플릿 정보 조회 (rfq-last 방식) - const [template] = await db - .select() - .from(basicContractTemplates) - .where( - and( - ilike(basicContractTemplates.templateName, `%${contractType.templateName}%`), - eq(basicContractTemplates.status, "ACTIVE") - ) - ) - .limit(1); - - console.log("템플릿", contractType.templateName, template); - - // 기존 계약이 있는지 확인 (rfq-last 방식) - const [existingContract] = await tx - .select() - .from(basicContract) - .where( - and( - eq(basicContract.templateId, template?.id), - eq(basicContract.vendorId, vendor.vendorId), - eq(basicContract.biddingCompanyId, biddingCompanyInfo.id) - ) - ) - .limit(1); - - let contractRecord; - - if (existingContract) { - // 기존 계약이 있으면 업데이트 - [contractRecord] = await tx - .update(basicContract) - .set({ - requestedBy: currentUser.id, - status: "PENDING", // 재발송 상태 - fileName: fileName, - filePath: `/contracts/generated/${fileName}`, - deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), - updatedAt: new Date(), - }) - .where(eq(basicContract.id, existingContract.id)) - .returning(); - - console.log("기존 계약 업데이트:", contractRecord.id); - } else { - // 새 계약 생성 - [contractRecord] = await tx - .insert(basicContract) - .values({ - templateId: template?.id || null, - vendorId: vendor.vendorId, - biddingCompanyId: biddingCompanyInfo.id, - rfqCompanyId: null, - generalContractId: null, - requestedBy: currentUser.id, - status: 'PENDING', - fileName: fileName, - filePath: `/contracts/generated/${fileName}`, - deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10일 후 - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning(); - - console.log("새 계약 생성:", contractRecord.id); - } - - results.push({ - vendorId: vendor.vendorId, - vendorName: vendor.vendorName, - contractId: contractRecord.id, - contractType: contractType.type, - fileName: fileName, - filePath: `/contracts/generated/${fileName}`, - }) - - // savedContracts에 추가 (rfq-last 방식) - // savedContracts.push({ - // vendorId: vendor.vendorId, - // vendorName: vendor.vendorName, - // templateName: contractType.templateName, - // contractId: contractRecord.id, - // fileName: fileName, - // isUpdated: !!existingContract, // 업데이트 여부 표시 - // }) - } - - // 이메일 발송 (선택사항) - if (vendor.selectedMainEmail) { - try { - await sendEmail({ - to: vendor.selectedMainEmail, - template: 'basic-contract-notification', - context: { - vendorName: vendor.vendorName, - biddingId: biddingId, - contractCount: contractTypes.length, - deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toLocaleDateString('ko-KR'), - loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${biddingId}`, - message: message || '', - currentYear: new Date().getFullYear(), - language: 'ko' - } - }) - } catch (emailError) { - console.error(`이메일 발송 실패 (${vendor.selectedMainEmail}):`, emailError) - // 이메일 발송 실패해도 계약 생성은 유지 - } - } - } - - return { - success: true, - message: `${results.length}개의 기본계약이 생성되었습니다.`, - results, - savedContracts, - totalContracts: savedContracts.length, - } - }) - - return result - - } catch (error) { - console.error('기본계약 발송 실패:', error) - throw new Error( - error instanceof Error - ? error.message - : '기본계약 발송 중 오류가 발생했습니다.' - ) - } -} - -// 기존 기본계약 조회 (서버 액션) -export async function getExistingBasicContractsForBidding(biddingId: number) { - try { - // 해당 biddingId에 속한 biddingCompany들의 기존 기본계약 조회 - const existingContracts = await db - .select({ - id: basicContract.id, - vendorId: basicContract.vendorId, - biddingCompanyId: basicContract.biddingCompanyId, - biddingId: biddingCompanies.biddingId, - templateId: basicContract.templateId, - status: basicContract.status, - createdAt: basicContract.createdAt, - }) - .from(basicContract) - .leftJoin(biddingCompanies, eq(basicContract.biddingCompanyId, biddingCompanies.id)) - .where( - and( - eq(biddingCompanies.biddingId, biddingId), - ) - ) - - return { - success: true, - contracts: existingContracts - } - - } catch (error) { - console.error('기존 계약 조회 실패:', error) - return { - success: false, - error: '기존 계약 조회에 실패했습니다.' - } - } -} - -// 선정된 업체들 조회 (서버 액션) -export async function getSelectedVendorsForBidding(biddingId: number) { - try { - const selectedCompanies = await db - .select({ - id: biddingCompanies.id, - companyId: biddingCompanies.companyId, - companyName: vendors.vendorName, - companyCode: vendors.vendorCode, - companyCountry: vendors.country, - contactPerson: biddingCompanies.contactPerson, - contactEmail: biddingCompanies.contactEmail, - biddingId: biddingCompanies.biddingId, - }) - .from(biddingCompanies) - .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) - .where(and( - eq(biddingCompanies.biddingId, biddingId), - eq(biddingCompanies.isPreQuoteSelected, true) - )) - - return { - success: true, - vendors: selectedCompanies.map(company => ({ - vendorId: company.companyId, // 실제 vendor ID - vendorName: company.companyName || '', - vendorCode: company.companyCode, - vendorCountry: company.companyCountry || '대한민국', - contactPerson: company.contactPerson, - contactEmail: company.contactEmail, - biddingCompanyId: company.id, // biddingCompany ID - biddingId: company.biddingId, - ndaYn: true, // 모든 계약 타입을 활성화 (필요에 따라 조정) - generalGtcYn: true, - projectGtcYn: true, - agreementYn: true - })) - } - } catch (error) { - console.error('선정된 업체 조회 실패:', error) - return { - success: false, - error: '선정된 업체 조회에 실패했습니다.', - vendors: [] - } - } +'use server'
+
+import db from '@/db/db'
+import { biddingCompanies, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding'
+import { basicContractTemplates } from '@/db/schema'
+import { vendors } from '@/db/schema/vendors'
+import { users } from '@/db/schema'
+import { sendEmail } from '@/lib/mail/sendEmail'
+import { eq, inArray, and, ilike, sql } from 'drizzle-orm'
+import { mkdir, writeFile } from 'fs/promises'
+import path from 'path'
+import { revalidateTag, revalidatePath } from 'next/cache'
+import { basicContract } from '@/db/schema/basicContractDocumnet'
+import { saveFile } from '@/lib/file-stroage'
+
+// 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 // user.name이 없으면 userId를 그대로 반환
+ } catch (error) {
+ console.error('Failed to get user name:', error)
+ return userId // 에러 시 userId를 그대로 반환
+ }
+}
+
+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' | 'pre_quote_sent' | 'pre_quote_accepted' | 'pre_quote_declined' | 'pre_quote_submitted' | 'bidding_sent' | 'bidding_accepted' | 'bidding_declined' | 'bidding_cancelled' | 'bidding_submitted'
+ isPreQuoteSelected?: boolean
+ isAttendingMeeting?: boolean
+}
+
+interface PrItemQuotation {
+ prItemId: number
+ bidUnitPrice: number
+ bidAmount: number
+ proposedDeliveryDate?: string
+ technicalSpecification?: string
+}
+
+
+
+ // 사전견적용 업체 추가 - biddingCompanies와 company_condition_responses 레코드 생성
+export async function createBiddingCompany(input: CreateBiddingCompanyInput) {
+ try {
+ const result = await db.transaction(async (tx) => {
+ // 0. 중복 체크 - 이미 해당 입찰에 참여중인 업체인지 확인
+ const existingCompany = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(sql`${biddingCompanies.biddingId} = ${input.biddingId} AND ${biddingCompanies.companyId} = ${input.companyId}`)
+
+ if (existingCompany.length > 0) {
+ throw new Error('이미 등록된 업체입니다')
+ }
+ // 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 updatePreQuoteSelection(companyIds: number[], isSelected: boolean) {
+ try {
+ // 업체들의 입찰 ID 조회 (캐시 무효화를 위해)
+ const companies = await db
+ .select({ biddingId: biddingCompanies.biddingId })
+ .from(biddingCompanies)
+ .where(inArray(biddingCompanies.id, companyIds))
+ .limit(1)
+
+ await db.update(biddingCompanies)
+ .set({
+ isPreQuoteSelected: isSelected,
+ invitationStatus: 'pending', // 초기 상태: 초대 대기
+ updatedAt: new Date()
+ })
+ .where(inArray(biddingCompanies.id, companyIds))
+
+ // 캐시 무효화
+ if (companies.length > 0) {
+ const biddingId = companies[0].biddingId
+ revalidateTag(`bidding-${biddingId}`)
+ revalidateTag('bidding-detail')
+ revalidateTag('quotation-vendors')
+ revalidateTag('quotation-details')
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ }
+
+ const message = isSelected
+ ? `${companyIds.length}개 업체가 본입찰 대상으로 선정되었습니다.`
+ : `${companyIds.length}개 업체의 본입찰 선정이 취소되었습니다.`
+
+ return {
+ success: true,
+ message
+ }
+ } catch (error) {
+ console.error('Failed to update pre-quote selection:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '본입찰 선정 상태 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+// 사전견적용 업체 삭제
+export async function deleteBiddingCompany(id: number) {
+ try {
+ // 1. 해당 업체의 초대 상태 확인
+ const company = await db
+ .select({ invitationStatus: biddingCompanies.invitationStatus })
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.id, id))
+ .then(rows => rows[0])
+
+ if (!company) {
+ return {
+ success: false,
+ error: '해당 업체를 찾을 수 없습니다.'
+ }
+ }
+
+ // 이미 초대가 발송된 경우(수락, 거절, 요청됨 등) 삭제 불가
+ if (company.invitationStatus !== 'pending') {
+ return {
+ success: false,
+ error: '이미 초대를 보낸 업체는 삭제할 수 없습니다.'
+ }
+ }
+
+ await db.transaction(async (tx) => {
+ // 2. 먼저 관련된 조건 응답들 삭제
+ await tx.delete(companyConditionResponses)
+ .where(eq(companyConditionResponses.biddingCompanyId, id))
+
+ // 3. 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,
+ preQuoteDeadline: biddingCompanies.preQuoteDeadline,
+ isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
+ isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated,
+ 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,
+ companyEmail: vendors.email, // 벤더의 기본 이메일
+
+ // 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,
+ additionalProposals: companyConditionResponses.additionalProposals,
+ })
+ .from(biddingCompanies)
+ .leftJoin(
+ vendors,
+ eq(biddingCompanies.companyId, vendors.id)
+ )
+ .leftJoin(
+ companyConditionResponses,
+ eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId)
+ )
+ .where(eq(biddingCompanies.biddingId, biddingId))
+
+ // 디버깅: 서버에서 가져온 데이터 확인
+ console.log('=== getBiddingCompanies Server Log ===')
+ console.log('Total companies:', companies.length)
+ if (companies.length > 0) {
+ console.log('First company:', {
+ companyName: companies[0].companyName,
+ companyEmail: companies[0].companyEmail,
+ companyCode: companies[0].companyCode,
+ companyId: companies[0].companyId
+ })
+ }
+ console.log('======================================')
+
+ return {
+ success: true,
+ data: companies
+ }
+ } catch (error) {
+ console.error('Failed to get bidding companies:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 목록 조회에 실패했습니다.'
+ }
+ }
+}
+
+// 선택된 업체들에게 사전견적 초대 발송
+interface CompanyWithContacts {
+ id: number
+ companyId: number
+ companyName: string
+ selectedMainEmail: string
+ additionalEmails: string[]
+}
+
+export async function sendPreQuoteInvitations(companiesData: CompanyWithContacts[], preQuoteDeadline?: Date | string) {
+ try {
+ console.log('=== sendPreQuoteInvitations called ===');
+ console.log('companiesData:', JSON.stringify(companiesData, null, 2));
+
+ if (companiesData.length === 0) {
+ return {
+ success: false,
+ error: '선택된 업체가 없습니다.'
+ }
+ }
+
+ const companyIds = companiesData.map(c => c.id);
+ console.log('companyIds:', companyIds);
+
+ // 선택된 업체들의 정보와 입찰 정보 조회
+ 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,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id))
+ .where(inArray(biddingCompanies.id, companyIds))
+
+ console.log('companiesInfo fetched:', JSON.stringify(companiesInfo, null, 2));
+
+ if (companiesInfo.length === 0) {
+ return {
+ success: false,
+ error: '업체 정보를 찾을 수 없습니다.'
+ }
+ }
+
+ // 모든 필드가 null이 아닌지 확인하고 안전하게 변환
+ const safeCompaniesInfo = companiesInfo.map(company => ({
+ ...company,
+ companyName: company.companyName ?? '',
+ companyEmail: company.companyEmail ?? '',
+ biddingNumber: company.biddingNumber ?? '',
+ revision: company.revision ?? '',
+ projectName: company.projectName ?? '',
+ biddingTitle: company.biddingTitle ?? '',
+ itemName: company.itemName ?? '',
+ preQuoteDate: company.preQuoteDate ?? null,
+ budget: company.budget ?? null,
+ currency: company.currency ?? '',
+ bidPicName: company.bidPicName ?? '',
+ supplyPicName: company.supplyPicName ?? '',
+ }));
+
+ console.log('safeCompaniesInfo prepared:', JSON.stringify(safeCompaniesInfo, null, 2));
+
+ await db.transaction(async (tx) => {
+ // 선택된 업체들의 상태를 '사전견적 초대 발송'으로 변경
+ for (const id of companyIds) {
+ await tx.update(biddingCompanies)
+ .set({
+ invitationStatus: 'pre_quote_sent', // 사전견적 초대 발송 상태
+ invitedAt: new Date(),
+ preQuoteDeadline: preQuoteDeadline ? new Date(preQuoteDeadline) : null,
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, id))
+ }
+ })
+
+ // 각 업체별로 이메일 발송 (담당자 정보 포함)
+ console.log('=== Starting email sending ===');
+ for (const company of safeCompaniesInfo) {
+ console.log(`Processing company: ${company.companyName} (biddingCompanyId: ${company.biddingCompanyId})`);
+
+ const companyData = companiesData.find(c => c.id === company.biddingCompanyId);
+ if (!companyData) {
+ console.log(`No companyData found for biddingCompanyId: ${company.biddingCompanyId}`);
+ continue;
+ }
+
+ console.log('companyData found:', JSON.stringify(companyData, null, 2));
+
+ const mainEmail = companyData.selectedMainEmail || '';
+ const ccEmails = Array.isArray(companyData.additionalEmails) ? companyData.additionalEmails : [];
+
+ console.log(`mainEmail: ${mainEmail}, ccEmails: ${JSON.stringify(ccEmails)}`);
+
+ if (mainEmail) {
+ try {
+ console.log('Preparing to send email...');
+
+ const emailContext = {
+ 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() : '',
+ budget: company.budget ? String(company.budget) : '',
+ currency: company.currency,
+ bidPicName: company.bidPicName,
+ supplyPicName: company.supplyPicName,
+ loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${company.biddingId}/pre-quote`,
+ currentYear: new Date().getFullYear(),
+ language: 'ko'
+ };
+
+ console.log('Email context prepared:', JSON.stringify(emailContext, null, 2));
+
+ await sendEmail({
+ to: mainEmail,
+ cc: ccEmails.length > 0 ? ccEmails : undefined,
+ template: 'pre-quote-invitation',
+ context: emailContext
+ })
+
+ console.log(`Email sent successfully to ${mainEmail}`);
+ } catch (emailError) {
+ console.error(`Failed to send email to ${mainEmail}:`, emailError)
+ // 이메일 발송 실패해도 전체 프로세스는 계속 진행
+ }
+ }
+ }
+ // 3. 입찰 상태를 사전견적 요청으로 변경 (bidding_generated 상태에서만)
+ for (const company of companiesInfo) {
+ await db.transaction(async (tx) => {
+ await tx
+ .update(biddings)
+ .set({
+ status: 'request_for_quotation',
+ updatedAt: new Date()
+ })
+ .where(and(
+ eq(biddings.id, company.biddingId),
+ eq(biddings.status, 'bidding_generated')
+ ))
+ })
+ }
+ 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,
+ contractType: biddings.contractType,
+ biddingType: biddings.biddingType,
+ awardCount: biddings.awardCount,
+ contractStartDate: biddings.contractStartDate,
+ contractEndDate: biddings.contractEndDate,
+ 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,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
+ })
+ .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,
+ preQuoteDeadline: biddingCompanies.preQuoteDeadline,
+ isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
+ isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated,
+ 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,
+ preQuoteDeadline: null,
+ isPreQuoteSelected: false,
+ isPreQuoteParticipated: null,
+ 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: 'pre_quote_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 as string || null,
+ comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null,
+ adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null,
+ notes: responseData.priceAdjustmentForm.notes,
+ adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions,
+ majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial,
+ adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod,
+ contractorWriter: responseData.priceAdjustmentForm.contractorWriter,
+ adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null,
+ nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason,
+ } as any
+
+ // 기존 연동제 정보가 있는지 확인
+ 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)
+ }
+ }
+
+ // 5. 입찰 상태를 사전견적 접수로 변경 (request_for_quotation 상태에서만)
+ // 또한 사전견적 접수일 업데이트
+ const biddingCompany = await tx
+ .select({ biddingId: biddingCompanies.biddingId })
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+ .limit(1)
+
+ if (biddingCompany.length > 0) {
+ await tx
+ .update(biddings)
+ .set({
+ status: 'received_quotation',
+ preQuoteDate: new Date().toISOString().split('T')[0], // 사전견적 접수일 업데이트
+ updatedAt: new Date()
+ })
+ .where(and(
+ eq(biddings.id, biddingCompany[0].biddingId),
+ eq(biddings.status, 'request_for_quotation')
+ ))
+ }
+ })
+
+ 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: 'pre_quote_accepted' | 'pre_quote_declined'
+) {
+ try {
+ await db.update(biddingCompanies)
+ .set({
+ invitationStatus: response, // pre_quote_accepted 또는 pre_quote_declined
+ respondedAt: new Date(),
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ const message = response === 'pre_quote_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 : '응답 처리에 실패했습니다.'
+ }
+ }
+}
+
+// 벤더에서 사전견적 참여 여부 결정 (isPreQuoteParticipated 사용)
+export async function setPreQuoteParticipation(
+ biddingCompanyId: number,
+ isParticipating: boolean
+) {
+ try {
+ await db.update(biddingCompanies)
+ .set({
+ isPreQuoteParticipated: isParticipating,
+ 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,
+ biddingId: prItemsForBidding.biddingId,
+ itemNumber: prItemsForBidding.itemNumber,
+ projectId: prItemsForBidding.projectId,
+ projectInfo: prItemsForBidding.projectInfo,
+ itemInfo: prItemsForBidding.itemInfo,
+ shi: prItemsForBidding.shi,
+ materialGroupNumber: prItemsForBidding.materialGroupNumber,
+ materialGroupInfo: prItemsForBidding.materialGroupInfo,
+ materialNumber: prItemsForBidding.materialNumber,
+ materialInfo: prItemsForBidding.materialInfo,
+ requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate,
+ annualUnitPrice: prItemsForBidding.annualUnitPrice,
+ currency: prItemsForBidding.currency,
+ quantity: prItemsForBidding.quantity,
+ quantityUnit: prItemsForBidding.quantityUnit,
+ totalWeight: prItemsForBidding.totalWeight,
+ weightUnit: prItemsForBidding.weightUnit,
+ priceUnit: prItemsForBidding.priceUnit,
+ purchaseUnit: prItemsForBidding.purchaseUnit,
+ materialWeight: prItemsForBidding.materialWeight,
+ prNumber: prItemsForBidding.prNumber,
+ 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, 'spec_document')
+ )
+ )
+
+ 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
+ console.log('responseData', responseData)
+
+ 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 as string || null,
+ comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null,
+ adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null,
+ notes: responseData.priceAdjustmentForm.notes,
+ adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions,
+ majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial,
+ adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod,
+ contractorWriter: responseData.priceAdjustmentForm.contractorWriter,
+ adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null,
+ nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason,
+ } as any
+
+ // 기존 연동제 정보가 있는지 확인
+ 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,
+ file: File,
+ userId: string
+) {
+ try {
+ const userName = await getUserNameById(userId)
+ // 파일 저장
+ const saveResult = await saveFile({
+ file,
+ directory: `bidding/${biddingId}/quotations`,
+ originalName: file.name,
+ userId
+ })
+
+ if (!saveResult.success) {
+ return {
+ success: false,
+ error: saveResult.error || '파일 저장에 실패했습니다.'
+ }
+ }
+
+ // 데이터베이스에 문서 정보 저장
+ const result = await db.insert(biddingDocuments)
+ .values({
+ biddingId,
+ companyId,
+ documentType: 'other', // 견적서 타입
+ fileName: saveResult.fileName!,
+ originalFileName: file.name,
+ fileSize: file.size,
+ mimeType: file.type,
+ filePath: saveResult.publicPath!, // publicPath 사용 (웹 접근 가능한 경로)
+ title: `견적서 - ${file.name}`,
+ description: '협력업체 제출 견적서',
+ isPublic: false,
+ isRequired: false,
+ uploadedBy: userName,
+ 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,
+ uploadedBy: biddingDocuments.uploadedBy
+ })
+ .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 []
+ }
+ }
+
+// 견적 문서 정보 조회 (다운로드용)
+export async function getPreQuoteDocumentForDownload(
+ documentId: number,
+ biddingId: number,
+ companyId: number
+) {
+ try {
+ const document = await db
+ .select({
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ filePath: biddingDocuments.filePath
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.id, documentId),
+ eq(biddingDocuments.biddingId, biddingId),
+ eq(biddingDocuments.companyId, companyId),
+ eq(biddingDocuments.documentType, 'other')
+ )
+ )
+ .limit(1)
+
+ if (document.length === 0) {
+ return {
+ success: false,
+ error: '문서를 찾을 수 없습니다.'
+ }
+ }
+
+ return {
+ success: true,
+ document: document[0]
+ }
+ } catch (error) {
+ console.error('Failed to get pre-quote document:', error)
+ return {
+ success: false,
+ error: '문서 정보 조회에 실패했습니다.'
+ }
+ }
+}
+
+// 견적 문서 삭제
+export async function deletePreQuoteDocument(
+ documentId: number,
+ biddingId: number,
+ companyId: number,
+ userId: string
+) {
+ try {
+ // 문서 존재 여부 및 권한 확인
+ const document = await db
+ .select({
+ id: biddingDocuments.id,
+ fileName: biddingDocuments.fileName,
+ filePath: biddingDocuments.filePath,
+ uploadedBy: biddingDocuments.uploadedBy
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.id, documentId),
+ eq(biddingDocuments.biddingId, biddingId),
+ eq(biddingDocuments.companyId, companyId),
+ eq(biddingDocuments.documentType, 'other')
+ )
+ )
+ .limit(1)
+
+ if (document.length === 0) {
+ return {
+ success: false,
+ error: '문서를 찾을 수 없습니다.'
+ }
+ }
+
+ const doc = document[0]
+
+ // 데이터베이스에서 문서 정보 삭제
+ await db
+ .delete(biddingDocuments)
+ .where(eq(biddingDocuments.id, documentId))
+
+ return {
+ success: true,
+ message: '문서가 성공적으로 삭제되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to delete pre-quote document:', error)
+ return {
+ success: false,
+ error: '문서 삭제에 실패했습니다.'
+ }
+ }
+ }
+
+// 기본계약 발송 (서버 액션)
+export async function sendBiddingBasicContracts(
+ biddingId: number,
+ vendorData: Array<{
+ vendorId: number
+ vendorName: string
+ vendorCode?: string
+ vendorCountry?: string
+ selectedMainEmail: string
+ additionalEmails: string[]
+ customEmails?: Array<{ email: string; name?: string }>
+ contractRequirements: {
+ ndaYn: boolean
+ generalGtcYn: boolean
+ projectGtcYn: boolean
+ agreementYn: boolean
+ }
+ biddingCompanyId: number
+ biddingId: number
+ hasExistingContracts?: boolean
+ }>,
+ generatedPdfs: Array<{
+ key: string
+ buffer: number[]
+ fileName: string
+ }>,
+ message?: string
+) {
+ try {
+ console.log("sendBiddingBasicContracts called with:", { biddingId, vendorData: vendorData.map(v => ({ vendorId: v.vendorId, biddingCompanyId: v.biddingCompanyId, biddingId: v.biddingId })) });
+
+ // 현재 사용자 정보 조회 (임시로 첫 번째 사용자 사용)
+ const [currentUser] = await db.select().from(users).limit(1)
+
+ if (!currentUser) {
+ throw new Error("사용자 정보를 찾을 수 없습니다.")
+ }
+
+ const results = []
+ const savedContracts = []
+
+ // 트랜잭션 시작
+ const contractsDir = path.join(process.cwd(), `${process.env.NAS_PATH}`, "contracts", "generated");
+ await mkdir(contractsDir, { recursive: true });
+
+ const result = await db.transaction(async (tx) => {
+ // 각 벤더별로 기본계약 생성 및 이메일 발송
+ for (const vendor of vendorData) {
+ // 기존 계약 확인 (biddingCompanyId 기준)
+ if (vendor.hasExistingContracts) {
+ console.log(`벤더 ${vendor.vendorName}는 이미 계약이 체결되어 있어 건너뜁니다.`)
+ continue
+ }
+
+ // 벤더 정보 조회
+ const [vendorInfo] = await tx
+ .select()
+ .from(vendors)
+ .where(eq(vendors.id, vendor.vendorId))
+ .limit(1)
+
+ if (!vendorInfo) {
+ console.error(`벤더 정보를 찾을 수 없습니다: ${vendor.vendorId}`)
+ continue
+ }
+
+ // biddingCompany 정보 조회 (biddingCompanyId를 직접 사용)
+ console.log(`Looking for biddingCompany with id=${vendor.biddingCompanyId}`)
+ let [biddingCompanyInfo] = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.id, vendor.biddingCompanyId))
+ .limit(1)
+
+ console.log(`Found biddingCompanyInfo:`, biddingCompanyInfo)
+ if (!biddingCompanyInfo) {
+ console.error(`입찰 회사 정보를 찾을 수 없습니다: biddingCompanyId=${vendor.biddingCompanyId}`)
+ // fallback: biddingId와 vendorId로 찾기 시도
+ console.log(`Fallback: Looking for biddingCompany with biddingId=${biddingId}, companyId=${vendor.vendorId}`)
+ const [fallbackCompanyInfo] = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.companyId, vendor.vendorId)
+ ))
+ .limit(1)
+ console.log(`Fallback found biddingCompanyInfo:`, fallbackCompanyInfo)
+ if (fallbackCompanyInfo) {
+ console.log(`Using fallback biddingCompanyInfo`)
+ biddingCompanyInfo = fallbackCompanyInfo
+ } else {
+ console.log(`Available biddingCompanies for biddingId=${biddingId}:`, await tx.select().from(biddingCompanies).where(eq(biddingCompanies.biddingId, biddingId)).limit(10))
+ continue
+ }
+ }
+
+ // 계약 요구사항에 따라 계약서 생성
+ const contractTypes: Array<{ type: string; templateName: string }> = []
+ if (vendor.contractRequirements?.ndaYn) contractTypes.push({ type: 'NDA', templateName: '비밀' })
+ if (vendor.contractRequirements?.generalGtcYn) contractTypes.push({ type: 'General_GTC', templateName: 'General GTC' })
+ if (vendor.contractRequirements?.projectGtcYn) contractTypes.push({ type: 'Project_GTC', templateName: '기술' })
+ if (vendor.contractRequirements?.agreementYn) contractTypes.push({ type: '기술자료', templateName: '기술자료' })
+
+ // contractRequirements가 없거나 빈 객체인 경우 빈 배열로 처리
+ if (!vendor.contractRequirements || Object.keys(vendor.contractRequirements).length === 0) {
+ console.log(`Skipping vendor ${vendor.vendorId} - no contract requirements specified`)
+ continue
+ }
+
+ console.log("contractTypes", contractTypes)
+ for (const contractType of contractTypes) {
+ // PDF 데이터 찾기 (include를 사용하여 유연하게 찾기)
+ console.log("generatedPdfs", generatedPdfs.map(pdf => pdf.key))
+ const pdfData = generatedPdfs.find((pdf: any) =>
+ pdf.key.includes(`${vendor.vendorId}_`) &&
+ pdf.key.includes(`_${contractType.templateName}`)
+ )
+ console.log("pdfData", pdfData, "for contractType", contractType)
+ if (!pdfData) {
+ console.error(`PDF 데이터를 찾을 수 없습니다: vendorId=${vendor.vendorId}, templateName=${contractType.templateName}`)
+ continue
+ }
+
+ // 파일 저장 (rfq-last 방식)
+ const fileName = `${contractType.type}_${vendor.vendorCode || vendor.vendorId}_${vendor.biddingCompanyId}_${Date.now()}.pdf`
+ const filePath = path.join(contractsDir, fileName);
+
+ await writeFile(filePath, Buffer.from(pdfData.buffer));
+
+ // 템플릿 정보 조회 (rfq-last 방식)
+ const [template] = await db
+ .select()
+ .from(basicContractTemplates)
+ .where(
+ and(
+ ilike(basicContractTemplates.templateName, `%${contractType.templateName}%`),
+ eq(basicContractTemplates.status, "ACTIVE")
+ )
+ )
+ .limit(1);
+
+ console.log("템플릿", contractType.templateName, template);
+
+ // 기존 계약이 있는지 확인 (rfq-last 방식)
+ const [existingContract] = await tx
+ .select()
+ .from(basicContract)
+ .where(
+ and(
+ eq(basicContract.templateId, template?.id),
+ eq(basicContract.vendorId, vendor.vendorId),
+ eq(basicContract.biddingCompanyId, biddingCompanyInfo.id)
+ )
+ )
+ .limit(1);
+
+ let contractRecord;
+
+ if (existingContract) {
+ // 기존 계약이 있으면 업데이트
+ [contractRecord] = await tx
+ .update(basicContract)
+ .set({
+ requestedBy: currentUser.id,
+ status: "PENDING", // 재발송 상태
+ fileName: fileName,
+ filePath: `/contracts/generated/${fileName}`,
+ deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000),
+ updatedAt: new Date(),
+ })
+ .where(eq(basicContract.id, existingContract.id))
+ .returning();
+
+ console.log("기존 계약 업데이트:", contractRecord.id);
+ } else {
+ // 새 계약 생성
+ [contractRecord] = await tx
+ .insert(basicContract)
+ .values({
+ templateId: template?.id || null,
+ vendorId: vendor.vendorId,
+ biddingCompanyId: biddingCompanyInfo.id,
+ rfqCompanyId: null,
+ generalContractId: null,
+ requestedBy: currentUser.id,
+ status: 'PENDING',
+ fileName: fileName,
+ filePath: `/contracts/generated/${fileName}`,
+ deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10일 후
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ console.log("새 계약 생성:", contractRecord.id);
+ }
+
+ results.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ contractId: contractRecord.id,
+ contractType: contractType.type,
+ fileName: fileName,
+ filePath: `/contracts/generated/${fileName}`,
+ })
+
+ // savedContracts에 추가 (rfq-last 방식)
+ // savedContracts.push({
+ // vendorId: vendor.vendorId,
+ // vendorName: vendor.vendorName,
+ // templateName: contractType.templateName,
+ // contractId: contractRecord.id,
+ // fileName: fileName,
+ // isUpdated: !!existingContract, // 업데이트 여부 표시
+ // })
+ }
+
+ // 이메일 발송 (선택사항)
+ if (vendor.selectedMainEmail) {
+ try {
+ await sendEmail({
+ to: vendor.selectedMainEmail,
+ template: 'basic-contract-notification',
+ context: {
+ vendorName: vendor.vendorName,
+ biddingId: biddingId,
+ contractCount: contractTypes.length,
+ deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toLocaleDateString('ko-KR'),
+ loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${biddingId}`,
+ message: message || '',
+ currentYear: new Date().getFullYear(),
+ language: 'ko'
+ }
+ })
+ } catch (emailError) {
+ console.error(`이메일 발송 실패 (${vendor.selectedMainEmail}):`, emailError)
+ // 이메일 발송 실패해도 계약 생성은 유지
+ }
+ }
+ }
+
+ return {
+ success: true,
+ message: `${results.length}개의 기본계약이 생성되었습니다.`,
+ results,
+ savedContracts,
+ totalContracts: savedContracts.length,
+ }
+ })
+
+ return result
+
+ } catch (error) {
+ console.error('기본계약 발송 실패:', error)
+ throw new Error(
+ error instanceof Error
+ ? error.message
+ : '기본계약 발송 중 오류가 발생했습니다.'
+ )
+ }
+}
+
+// 기존 기본계약 조회 (서버 액션)
+export async function getExistingBasicContractsForBidding(biddingId: number) {
+ try {
+ // 해당 biddingId에 속한 biddingCompany들의 기존 기본계약 조회
+ const existingContracts = await db
+ .select({
+ id: basicContract.id,
+ vendorId: basicContract.vendorId,
+ biddingCompanyId: basicContract.biddingCompanyId,
+ biddingId: biddingCompanies.biddingId,
+ templateId: basicContract.templateId,
+ status: basicContract.status,
+ createdAt: basicContract.createdAt,
+ })
+ .from(basicContract)
+ .leftJoin(biddingCompanies, eq(basicContract.biddingCompanyId, biddingCompanies.id))
+ .where(
+ and(
+ eq(biddingCompanies.biddingId, biddingId),
+ )
+ )
+
+ return {
+ success: true,
+ contracts: existingContracts
+ }
+
+ } catch (error) {
+ console.error('기존 계약 조회 실패:', error)
+ return {
+ success: false,
+ error: '기존 계약 조회에 실패했습니다.'
+ }
+ }
+}
+
+// 선정된 업체들 조회 (서버 액션)
+export async function getSelectedVendorsForBidding(biddingId: number) {
+ try {
+ const selectedCompanies = await db
+ .select({
+ id: biddingCompanies.id,
+ companyId: biddingCompanies.companyId,
+ companyName: vendors.vendorName,
+ companyCode: vendors.vendorCode,
+ companyEmail: vendors.email,
+ companyCountry: vendors.country,
+ contactPerson: biddingCompanies.contactPerson,
+ contactEmail: biddingCompanies.contactEmail,
+ biddingId: biddingCompanies.biddingId,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isPreQuoteSelected, true)
+ ))
+
+ return {
+ success: true,
+ vendors: selectedCompanies.map(company => ({
+ vendorId: company.companyId, // 실제 vendor ID
+ vendorName: company.companyName || '',
+ vendorCode: company.companyCode,
+ vendorEmail: company.companyEmail,
+ vendorCountry: company.companyCountry || '대한민국',
+ contactPerson: company.contactPerson,
+ contactEmail: company.contactEmail,
+ biddingCompanyId: company.id, // biddingCompany ID
+ biddingId: company.biddingId,
+ ndaYn: true, // 모든 계약 타입을 활성화 (필요에 따라 조정)
+ generalGtcYn: true,
+ projectGtcYn: true,
+ agreementYn: true
+ }))
+ }
+ } catch (error) {
+ console.error('선정된 업체 조회 실패:', error)
+ return {
+ success: false,
+ error: '선정된 업체 조회에 실패했습니다.',
+ vendors: []
+ }
+ }
}
\ No newline at end of file diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx deleted file mode 100644 index cfa629e3..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx +++ /dev/null @@ -1,224 +0,0 @@ -'use client' - -import * as React from 'react' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { - FileText, - Download, - User, - Calendar -} from 'lucide-react' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' -import { getPreQuoteDocuments, getPreQuoteDocumentForDownload } from '../service' -import { downloadFile } from '@/lib/file-download' - -interface UploadedDocument { - id: number - fileName: string - originalFileName: string - fileSize: number | null - filePath: string - title: string | null - description: string | null - uploadedAt: string - uploadedBy: string -} - -interface BiddingPreQuoteAttachmentsDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - biddingId: number - companyId: number - companyName: string -} - -export function BiddingPreQuoteAttachmentsDialog({ - open, - onOpenChange, - biddingId, - companyId, - companyName -}: BiddingPreQuoteAttachmentsDialogProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - const [documents, setDocuments] = React.useState<UploadedDocument[]>([]) - const [isLoading, setIsLoading] = React.useState(false) - - // 다이얼로그가 열릴 때 첨부파일 목록 로드 - React.useEffect(() => { - if (open) { - loadDocuments() - } - }, [open, biddingId, companyId]) - - const loadDocuments = async () => { - setIsLoading(true) - try { - const docs = await getPreQuoteDocuments(biddingId, companyId) - // Date를 string으로 변환 - const mappedDocs = docs.map(doc => ({ - ...doc, - uploadedAt: doc.uploadedAt.toString(), - uploadedBy: doc.uploadedBy || '' - })) - setDocuments(mappedDocs) - } catch (error) { - console.error('Failed to load documents:', error) - toast({ - title: '오류', - description: '첨부파일 목록을 불러오는데 실패했습니다.', - variant: 'destructive', - }) - } finally { - setIsLoading(false) - } - } - - // 파일 다운로드 - const handleDownload = (document: UploadedDocument) => { - startTransition(async () => { - const result = await getPreQuoteDocumentForDownload(document.id, biddingId, companyId) - - if (result.success) { - try { - await downloadFile(result.document?.filePath, result.document?.originalFileName, { - showToast: true - }) - } catch (error) { - toast({ - title: '다운로드 실패', - description: '파일 다운로드에 실패했습니다.', - variant: 'destructive', - }) - } - } else { - toast({ - title: '다운로드 실패', - description: result.error, - variant: 'destructive', - }) - } - }) - } - - // 파일 크기 포맷팅 - const formatFileSize = (bytes: number | null) => { - if (!bytes) return '-' - if (bytes === 0) return '0 Bytes' - const k = 1024 - const sizes = ['Bytes', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <FileText className="w-5 h-5" /> - <span>협력업체 첨부파일</span> - <span className="text-sm font-normal text-muted-foreground"> - - {companyName} - </span> - </DialogTitle> - <DialogDescription> - 협력업체가 제출한 견적 관련 첨부파일 목록입니다. - </DialogDescription> - </DialogHeader> - - {isLoading ? ( - <div className="flex items-center justify-center py-12"> - <div className="text-center"> - <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div> - <p className="text-muted-foreground">첨부파일 목록을 불러오는 중...</p> - </div> - </div> - ) : documents.length > 0 ? ( - <div className="space-y-4"> - <div className="flex items-center justify-between"> - <Badge variant="secondary" className="text-sm"> - 총 {documents.length}개 파일 - </Badge> - </div> - - <Table> - <TableHeader> - <TableRow> - <TableHead>파일명</TableHead> - <TableHead>크기</TableHead> - <TableHead>업로드일</TableHead> - <TableHead>작성자</TableHead> - <TableHead className="w-24">작업</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {documents.map((doc) => ( - <TableRow key={doc.id}> - <TableCell> - <div className="flex items-center gap-2"> - <FileText className="w-4 h-4 text-gray-500" /> - <span className="truncate max-w-48" title={doc.originalFileName}> - {doc.originalFileName} - </span> - </div> - </TableCell> - <TableCell className="text-sm text-gray-500"> - {formatFileSize(doc.fileSize)} - </TableCell> - <TableCell className="text-sm text-gray-500"> - <div className="flex items-center gap-1"> - <Calendar className="w-3 h-3" /> - {new Date(doc.uploadedAt).toLocaleDateString('ko-KR')} - </div> - </TableCell> - <TableCell className="text-sm text-gray-500"> - <div className="flex items-center gap-1"> - <User className="w-3 h-3" /> - {doc.uploadedBy} - </div> - </TableCell> - <TableCell> - <Button - variant="outline" - size="sm" - onClick={() => handleDownload(doc)} - disabled={isPending} - title="다운로드" - > - <Download className="w-3 h-3" /> - </Button> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </div> - ) : ( - <div className="text-center py-12 text-gray-500"> - <FileText className="w-12 h-12 mx-auto mb-4 opacity-50" /> - <p className="text-lg font-medium mb-2">첨부파일이 없습니다</p> - <p className="text-sm">협력업체가 아직 첨부파일을 업로드하지 않았습니다.</p> - </div> - )} - </DialogContent> - </Dialog> - ) -} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx deleted file mode 100644 index 91b80bd3..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx +++ /dev/null @@ -1,51 +0,0 @@ -'use client' - -import * as React from 'react' -import { Bidding } from '@/db/schema' -import { QuotationDetails } from '@/lib/bidding/detail/service' -import { getBiddingCompanies } from '../service' - -import { BiddingPreQuoteVendorTableContent } from './bidding-pre-quote-vendor-table' - -interface BiddingPreQuoteContentProps { - bidding: Bidding - quotationDetails: QuotationDetails | null - biddingCompanies: any[] - prItems: any[] -} - -export function BiddingPreQuoteContent({ - bidding, - quotationDetails, - 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} - biddingCompanies={biddingCompanies} - onRefresh={handleRefresh} - onOpenItemsDialog={() => {}} - onOpenTargetPriceDialog={() => {}} - onOpenSelectionReasonDialog={() => {}} - /> - </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 deleted file mode 100644 index 3205df08..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx +++ /dev/null @@ -1,770 +0,0 @@ -'use client' - -import * as React from 'react' -import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/checkbox' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -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, sendBiddingBasicContracts, getExistingBasicContractsForBidding } from '../service' -import { getActiveContractTemplates } from '../../service' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' -import { Mail, Building2, Calendar, FileText, CheckCircle, Info, RefreshCw } from 'lucide-react' -import { Progress } from '@/components/ui/progress' -import { Separator } from '@/components/ui/separator' -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { cn } from '@/lib/utils' - -interface BiddingPreQuoteInvitationDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - companies: BiddingCompany[] - biddingId: number - biddingTitle: string - projectName?: string - onSuccess: () => void -} - -interface BasicContractTemplate { - id: number - templateName: string - revision: number - status: string - filePath: string | null - validityPeriod: number | null - legalReviewRequired: boolean - createdAt: Date | null -} - -interface SelectedContract { - templateId: number - templateName: string - contractType: string // templateName을 contractType으로 사용 - checked: boolean -} - -// PDF 생성 유틸리티 함수 -const generateBasicContractPdf = async ( - template: BasicContractTemplate, - vendorId: number -): Promise<{ buffer: number[]; fileName: string }> => { - try { - // 1. 템플릿 데이터 준비 (서버 API 호출) - const prepareResponse = await fetch("/api/contracts/prepare-template", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - templateName: template.templateName, - 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( - { - path: "/pdftronWeb", - licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, - fullAPI: true, - }, - 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); - } - } - } catch (error) { - console.error(`기본계약 PDF 생성 실패 (${template.templateName}):`, error); - throw error; - } -}; - -export function BiddingPreQuoteInvitationDialog({ - open, - onOpenChange, - companies, - biddingId, - biddingTitle, - projectName, - onSuccess -}: BiddingPreQuoteInvitationDialogProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - const [selectedCompanyIds, setSelectedCompanyIds] = React.useState<number[]>([]) - const [preQuoteDeadline, setPreQuoteDeadline] = React.useState('') - const [additionalMessage, setAdditionalMessage] = React.useState('') - - // 기본계약 관련 상태 - const [existingContracts, setExistingContracts] = React.useState<any[]>([]) - const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false) - const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0) - const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState('') - - // 기본계약서 템플릿 관련 상태 - const [availableTemplates, setAvailableTemplates] = React.useState<BasicContractTemplate[]>([]) - const [selectedContracts, setSelectedContracts] = React.useState<SelectedContract[]>([]) - const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false) - - // 초대 가능한 업체들 (pending 상태인 업체들) - const invitableCompanies = React.useMemo(() => companies.filter(company => - company.invitationStatus === 'pending' && company.companyName - ), [companies]) - - // 다이얼로그가 열릴 때 기존 계약 조회 및 템플릿 로드 - React.useEffect(() => { - if (open) { - const fetchInitialData = async () => { - setIsLoadingTemplates(true); - try { - const [contractsResult, templatesData] = await Promise.all([ - getExistingBasicContractsForBidding(biddingId), - getActiveContractTemplates() - ]); - - // 기존 계약 조회 - 서버 액션 사용 - const existingContractsResult = await getExistingBasicContractsForBidding(biddingId); - setExistingContracts(existingContractsResult.success ? existingContractsResult.contracts || [] : []); - - // 템플릿 로드 (4개 타입만 필터링) - // 4개 템플릿 타입만 필터링: 비밀, General, Project, 기술자료 - const allowedTemplateNames = ['비밀', 'General GTC', '기술', '기술자료']; - const filteredTemplates = (templatesData.templates || []).filter((template: any) => - allowedTemplateNames.some(allowedName => - template.templateName.includes(allowedName) || - allowedName.includes(template.templateName) - ) - ); - setAvailableTemplates(filteredTemplates as BasicContractTemplate[]); - const initialSelected = filteredTemplates.map((template: any) => ({ - templateId: template.id, - templateName: template.templateName, - contractType: template.templateName, - checked: false - })); - setSelectedContracts(initialSelected); - - } catch (error) { - console.error('초기 데이터 로드 실패:', error); - toast({ - title: '오류', - description: '기본 정보를 불러오는 데 실패했습니다.', - variant: 'destructive', - }); - setExistingContracts([]); - setAvailableTemplates([]); - setSelectedContracts([]); - } finally { - setIsLoadingTemplates(false); - } - } - fetchInitialData(); - } - }, [open, biddingId, toast]); - - const handleSelectAll = (checked: boolean | 'indeterminate') => { - if (checked) { - // 기존 계약이 없는 업체만 선택 - const availableCompanies = invitableCompanies.filter(company => - !existingContracts.some(ec => ec.vendorId === company.companyId) - ) - setSelectedCompanyIds(availableCompanies.map(company => company.id)) - } else { - setSelectedCompanyIds([]) - } - } - - const handleSelectCompany = (companyId: number, checked: boolean) => { - const company = invitableCompanies.find(c => c.id === companyId) - const hasExistingContract = company ? existingContracts.some(ec => ec.vendorId === company.companyId) : false - - if (hasExistingContract) { - toast({ - title: '선택 불가', - description: '이미 기본계약서를 받은 업체는 다시 선택할 수 없습니다.', - variant: 'default', - }) - return - } - - if (checked) { - setSelectedCompanyIds(prev => [...prev, companyId]) - } else { - setSelectedCompanyIds(prev => prev.filter(id => id !== companyId)) - } - } - - // 기본계약서 선택 토글 - const toggleContractSelection = (templateId: number) => { - setSelectedContracts(prev => - prev.map(contract => - contract.templateId === templateId - ? { ...contract, checked: !contract.checked } - : contract - ) - ) - } - - // 모든 기본계약서 선택/해제 - const toggleAllContractSelection = (checked: boolean | 'indeterminate') => { - setSelectedContracts(prev => - prev.map(contract => ({ ...contract, checked: !!checked })) - ) - } - - const handleSendInvitations = () => { - if (selectedCompanyIds.length === 0) { - toast({ - title: '알림', - description: '초대를 발송할 업체를 선택해주세요.', - variant: 'default', - }) - return - } - - const selectedContractTemplates = selectedContracts.filter(c => c.checked); - const companiesForContracts = invitableCompanies.filter(company => selectedCompanyIds.includes(company.id)); - - const vendorsToGenerateContracts = companiesForContracts.filter(company => - !existingContracts.some(ec => - ec.vendorId === company.companyId && ec.biddingCompanyId === company.id - ) - ); - - startTransition(async () => { - try { - // 1. 사전견적 초대 발송 - const invitationResponse = await sendPreQuoteInvitations( - selectedCompanyIds, - preQuoteDeadline || undefined - ) - - if (!invitationResponse.success) { - toast({ - title: '초대 발송 실패', - description: invitationResponse.error, - variant: 'destructive', - }) - return - } - - // 2. 기본계약 발송 (선택된 템플릿과 업체가 있는 경우) - let contractResponse: Awaited<ReturnType<typeof sendBiddingBasicContracts>> | null = null - if (selectedContractTemplates.length > 0 && selectedCompanyIds.length > 0) { - setIsGeneratingPdfs(true) - setPdfGenerationProgress(0) - - const generatedPdfsMap = new Map<string, { buffer: number[], fileName: string }>() - - let generatedCount = 0; - for (const vendor of vendorsToGenerateContracts) { - for (const contract of selectedContractTemplates) { - setCurrentGeneratingContract(`${vendor.companyName} - ${contract.templateName}`); - const templateDetails = availableTemplates.find(t => t.id === contract.templateId); - - if (templateDetails) { - const pdfData = await generateBasicContractPdf(templateDetails, vendor.companyId); - // 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 = `${vendor.companyId}_${contractType}_${contract.templateName}`; - generatedPdfsMap.set(key, pdfData); - } - } - generatedCount++; - setPdfGenerationProgress((generatedCount / vendorsToGenerateContracts.length) * 100); - } - - setIsGeneratingPdfs(false); - - const vendorData = companiesForContracts.map(company => { - // 선택된 템플릿에 따라 contractRequirements 동적으로 설정 - const contractRequirements = { - ndaYn: selectedContractTemplates.some(c => c.templateName.includes('비밀')), - generalGtcYn: selectedContractTemplates.some(c => c.templateName.includes('General GTC')), - projectGtcYn: selectedContractTemplates.some(c => c.templateName.includes('기술') && !c.templateName.includes('기술자료')), - agreementYn: selectedContractTemplates.some(c => c.templateName.includes('기술자료')) - }; - - return { - vendorId: company.companyId, - vendorName: company.companyName || '', - vendorCode: company.companyCode, - vendorCountry: '대한민국', - selectedMainEmail: company.contactEmail || '', - contactPerson: company.contactPerson, - contactEmail: company.contactEmail, - biddingCompanyId: company.id, - biddingId: biddingId, - hasExistingContracts: existingContracts.some(ec => - ec.vendorId === company.companyId && ec.biddingCompanyId === company.id - ), - contractRequirements, - additionalEmails: [], - customEmails: [] - }; - }); - - const pdfsArray = Array.from(generatedPdfsMap.entries()).map(([key, data]) => ({ - key, - buffer: data.buffer, - fileName: data.fileName, - })); - - console.log("Calling sendBiddingBasicContracts with biddingId:", biddingId); - console.log("vendorData:", vendorData.map(v => ({ vendorId: v.vendorId, biddingCompanyId: v.biddingCompanyId, biddingId: v.biddingId }))); - - contractResponse = await sendBiddingBasicContracts( - biddingId, - vendorData, - pdfsArray, - additionalMessage - ); - } - - let successMessage = '사전견적 초대가 성공적으로 발송되었습니다.'; - if (contractResponse && contractResponse.success) { - successMessage += `\n${contractResponse.message}`; - } - - toast({ - title: '성공', - description: successMessage, - }) - - // 상태 초기화 - setSelectedCompanyIds([]); - setPreQuoteDeadline(''); - setAdditionalMessage(''); - setExistingContracts([]); - setIsGeneratingPdfs(false); - setPdfGenerationProgress(0); - setCurrentGeneratingContract(''); - setSelectedContracts(prev => prev.map(c => ({ ...c, checked: false }))); - - onOpenChange(false); - onSuccess(); - - } catch (error) { - console.error('발송 실패:', error); - toast({ - title: '오류', - description: '발송 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', - variant: 'destructive', - }); - setIsGeneratingPdfs(false); - } - }) - } - - const handleOpenChange = (open: boolean) => { - onOpenChange(open) - if (!open) { - setSelectedCompanyIds([]) - setPreQuoteDeadline('') - setAdditionalMessage('') - setExistingContracts([]) - setIsGeneratingPdfs(false) - setPdfGenerationProgress(0) - setCurrentGeneratingContract('') - setSelectedContracts([]) - } - } - - const selectedContractCount = selectedContracts.filter(c => c.checked).length; - const selectedCompanyCount = selectedCompanyIds.length; - const companiesToReceiveContracts = invitableCompanies.filter(company => selectedCompanyIds.includes(company.id)); - - // 기존 계약이 없는 업체들만 계산 - const availableCompanies = invitableCompanies.filter(company => - !existingContracts.some(ec => ec.vendorId === company.companyId) - ); - const selectedAvailableCompanyCount = selectedCompanyIds.filter(id => - availableCompanies.some(company => company.id === id) - ).length; - - // 선택된 업체들 중 기존 계약이 있는 업체들 - const selectedCompaniesWithExistingContracts = invitableCompanies.filter(company => - selectedCompanyIds.includes(company.id) && - existingContracts.some(ec => ec.vendorId === company.companyId) - ); - - return ( - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogContent className="sm:max-w-[800px] max-h-[90vh] flex flex-col"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <Mail className="w-5 h-5" /> - 사전견적 초대 및 기본계약 발송 - </DialogTitle> - <DialogDescription> - 선택한 업체들에게 사전견적 요청과 기본계약서를 발송합니다. - </DialogDescription> - </DialogHeader> - - <div className="flex-1 overflow-y-auto px-1" style={{ maxHeight: 'calc(70vh - 200px)' }}> - <div className="space-y-6 pr-4"> - {/* 견적 마감일 설정 */} - <div className="mb-6 p-4 border rounded-lg bg-muted/30"> - <Label htmlFor="preQuoteDeadline" className="text-sm font-medium mb-2 flex items-center gap-2"> - <Calendar className="w-4 h-4" /> - 견적 마감일 - </Label> - <Input - id="preQuoteDeadline" - type="datetime-local" - value={preQuoteDeadline} - onChange={(e) => setPreQuoteDeadline(e.target.value)} - className="w-full" - /> - </div> - - {/* 기존 계약 정보 알림 */} - {existingContracts.length > 0 && ( - <Alert className="border-orange-500 bg-orange-50"> - <Info className="h-4 w-4 text-orange-600" /> - <AlertTitle className="text-orange-800">기존 계약 정보</AlertTitle> - <AlertDescription className="text-orange-700"> - 이미 기본계약을 받은 업체가 있습니다. - 해당 업체들은 초대 대상에서 제외되며, 계약서 재생성도 건너뜁니다. - </AlertDescription> - </Alert> - )} - - {/* 업체 선택 섹션 */} - <Card className="border-2 border-dashed"> - <CardHeader className="pb-3"> - <CardTitle className="flex items-center gap-2 text-base"> - <Building2 className="h-5 w-5 text-green-600" /> - 초대 대상 업체 - </CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - {invitableCompanies.length === 0 ? ( - <div className="text-center py-8 text-muted-foreground"> - 초대 가능한 업체가 없습니다. - </div> - ) : ( - <> - <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"> - <div className="flex items-center gap-2"> - <Checkbox - id="select-all-companies" - checked={selectedAvailableCompanyCount === availableCompanies.length && availableCompanies.length > 0} - onCheckedChange={handleSelectAll} - /> - <Label htmlFor="select-all-companies" className="font-medium"> - 전체 선택 ({availableCompanies.length}개 업체) - </Label> - </div> - <Badge variant="outline"> - {selectedCompanyCount}개 선택됨 - </Badge> - </div> - - <div className="space-y-3 max-h-80 overflow-y-auto"> - {invitableCompanies.map((company) => { - const hasExistingContract = existingContracts.some(ec => ec.vendorId === company.companyId); - return ( - <div key={company.id} className={cn("flex items-center space-x-3 p-3 border rounded-lg transition-colors", - selectedCompanyIds.includes(company.id) && !hasExistingContract && "border-green-500 bg-green-50", - hasExistingContract && "border-orange-500 bg-orange-50 opacity-75" - )}> - <Checkbox - id={`company-${company.id}`} - checked={selectedCompanyIds.includes(company.id)} - disabled={hasExistingContract} - onCheckedChange={(checked) => handleSelectCompany(company.id, !!checked)} - /> - <div className="flex-1"> - <div className="flex items-center gap-2"> - <span className={cn("font-medium", hasExistingContract && "text-muted-foreground")}> - {company.companyName} - </span> - <Badge variant="outline" className="text-xs"> - {company.companyCode} - </Badge> - {hasExistingContract && ( - <Badge variant="secondary" className="text-xs"> - <CheckCircle className="h-3 w-3 mr-1" /> - 계약 체결됨 - </Badge> - )} - </div> - {hasExistingContract && ( - <p className="text-xs text-orange-600 mt-1"> - 이미 기본계약서를 받은 업체입니다. 선택에서 제외됩니다. - </p> - )} - </div> - </div> - ) - })} - </div> - </> - )} - </CardContent> - </Card> - - {/* 선택된 업체 중 기존 계약이 있는 경우 경고 */} - {selectedCompaniesWithExistingContracts.length > 0 && ( - <Alert className="border-red-500 bg-red-50"> - <Info className="h-4 w-4 text-red-600" /> - <AlertTitle className="text-red-800">선택한 업체 중 제외될 업체</AlertTitle> - <AlertDescription className="text-red-700"> - 선택한 {selectedCompaniesWithExistingContracts.length}개 업체가 이미 기본계약서를 받았습니다. - 이 업체들은 초대 발송 및 계약서 생성에서 제외됩니다. - <br /> - <strong>실제 발송 대상: {selectedCompanyCount - selectedCompaniesWithExistingContracts.length}개 업체</strong> - </AlertDescription> - </Alert> - )} - - {/* 기본계약서 선택 섹션 */} - <Separator /> - <Card className="border-2 border-dashed"> - <CardHeader className="pb-3"> - <CardTitle className="flex items-center gap-2 text-base"> - <FileText className="h-5 w-5 text-blue-600" /> - 기본계약서 선택 (선택된 업체에만 발송) - </CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - {isLoadingTemplates ? ( - <div className="text-center py-6"> - <RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2 text-blue-600" /> - <p className="text-sm text-muted-foreground">기본계약서 템플릿을 불러오는 중...</p> - </div> - ) : ( - <div className="space-y-4"> - {selectedCompanyCount === 0 && ( - <Alert className="border-red-500 bg-red-50"> - <Info className="h-4 w-4 text-red-600" /> - <AlertTitle className="text-red-800">알림</AlertTitle> - <AlertDescription className="text-red-700"> - 기본계약서를 발송할 업체를 먼저 선택해주세요. - </AlertDescription> - </Alert> - )} - {availableTemplates.length === 0 ? ( - <div className="text-center py-8 text-muted-foreground"> - <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" /> - <p>사용 가능한 기본계약서 템플릿이 없습니다.</p> - </div> - ) : ( - <> - <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"> - <div className="flex items-center gap-2"> - <Checkbox - id="select-all-contracts" - checked={selectedContracts.length > 0 && selectedContracts.every(c => c.checked)} - onCheckedChange={toggleAllContractSelection} - /> - <Label htmlFor="select-all-contracts" className="font-medium"> - 전체 선택 ({availableTemplates.length}개 템플릿) - </Label> - </div> - <Badge variant="outline"> - {selectedContractCount}개 선택됨 - </Badge> - </div> - <div className="grid gap-3 max-h-60 overflow-y-auto"> - {selectedContracts.map((contract) => ( - <div - key={contract.templateId} - className={cn( - "flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer", - contract.checked && "border-blue-500 bg-blue-50" - )} - onClick={() => toggleContractSelection(contract.templateId)} - > - <div className="flex items-center gap-3"> - <Checkbox - id={`contract-${contract.templateId}`} - checked={contract.checked} - onCheckedChange={() => toggleContractSelection(contract.templateId)} - /> - <div className="flex-1"> - <Label - htmlFor={`contract-${contract.templateId}`} - className="font-medium cursor-pointer" - > - {contract.templateName} - </Label> - <p className="text-xs text-muted-foreground mt-1"> - {contract.contractType} - </p> - </div> - </div> - </div> - ))} - </div> - </> - )} - {selectedContractCount > 0 && ( - <div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg"> - <div className="flex items-center gap-2 mb-2"> - <CheckCircle className="h-4 w-4 text-green-600" /> - <span className="font-medium text-green-900 text-sm"> - 선택된 기본계약서 ({selectedContractCount}개) - </span> - </div> - <ul className="space-y-1 text-xs text-green-800 list-disc list-inside"> - {selectedContracts.filter(c => c.checked).map((contract) => ( - <li key={contract.templateId}> - {contract.templateName} - </li> - ))} - </ul> - </div> - )} - </div> - )} - </CardContent> - </Card> - - {/* 추가 메시지 */} - <div className="space-y-2"> - <Label htmlFor="contractMessage" className="text-sm font-medium"> - 계약서 추가 메시지 (선택사항) - </Label> - <textarea - id="contractMessage" - className="w-full min-h-[60px] p-3 text-sm border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-primary" - placeholder="기본계약서와 함께 보낼 추가 메시지를 입력하세요..." - value={additionalMessage} - onChange={(e) => setAdditionalMessage(e.target.value)} - /> - </div> - - {/* PDF 생성 진행 상황 */} - {isGeneratingPdfs && ( - <Alert className="border-blue-500 bg-blue-50"> - <div className="space-y-3"> - <div className="flex items-center gap-2"> - <RefreshCw className="h-4 w-4 animate-spin text-blue-600" /> - <AlertTitle className="text-blue-800">기본계약서 생성 중</AlertTitle> - </div> - <AlertDescription> - <div className="space-y-2"> - <p className="text-sm text-blue-700">{currentGeneratingContract}</p> - <Progress value={pdfGenerationProgress} className="h-2" /> - <p className="text-xs text-blue-600"> - {Math.round(pdfGenerationProgress)}% 완료 - </p> - </div> - </AlertDescription> - </div> - </Alert> - )} - </div> - </div> - - <DialogFooter className="flex-col sm:flex-row-reverse sm:justify-between items-center px-4 pt-4"> - <div className="flex gap-2 w-full sm:w-auto"> - <Button variant="outline" onClick={() => handleOpenChange(false)} className="w-full sm:w-auto"> - 취소 - </Button> - <Button - onClick={handleSendInvitations} - disabled={isPending || selectedCompanyCount === 0 || isGeneratingPdfs} - className="w-full sm:w-auto" - > - {isPending ? ( - <> - <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> - 발송 중... - </> - ) : ( - <> - <Mail className="w-4 h-4 mr-2" /> - 초대 발송 및 계약서 생성 - </> - )} - </Button> - </div> - {/* {(selectedCompanyCount > 0 || selectedContractCount > 0) && ( - <div className="mt-4 sm:mt-0 text-sm text-muted-foreground"> - {selectedCompanyCount > 0 && ( - <p> - <strong>{selectedCompanyCount}개 업체</strong>에 초대를 발송합니다. - </p> - )} - {selectedContractCount > 0 && selectedCompanyCount > 0 && ( - <p> - 이 중 <strong>{companiesToReceiveContracts.length}개 업체</strong>에 <strong>{selectedContractCount}개</strong>의 기본계약서를 발송합니다. - </p> - )} - </div> - )} */} - </DialogFooter> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx deleted file mode 100644 index f676709c..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx +++ /dev/null @@ -1,125 +0,0 @@ -'use client' - -import * as React from 'react' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { PrItemsPricingTable } from '../../vendor/components/pr-items-pricing-table' -import { getSavedPrItemQuotations } from '../service' - -interface PrItem { - id: number - itemNumber: string | null - prNumber: string | null - itemInfo: string | null - materialDescription: string | null - quantity: string | null - quantityUnit: string | null - totalWeight: string | null - weightUnit: string | null - currency: string | null - requestedDeliveryDate: string | null - hasSpecDocument: boolean | null -} - -interface PrItemQuotation { - prItemId: number - bidUnitPrice: number - bidAmount: number - proposedDeliveryDate?: string - technicalSpecification?: string -} - -interface BiddingPreQuoteItemDetailsDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - biddingId: number - biddingCompanyId: number - companyName: string - prItems: PrItem[] - currency?: string -} - -export function BiddingPreQuoteItemDetailsDialog({ - open, - onOpenChange, - biddingId, - biddingCompanyId, - companyName, - prItems, - currency = 'KRW' -}: BiddingPreQuoteItemDetailsDialogProps) { - const [prItemQuotations, setPrItemQuotations] = React.useState<PrItemQuotation[]>([]) - const [isLoading, setIsLoading] = React.useState(false) - - // 다이얼로그가 열릴 때 저장된 품목별 견적 데이터 로드 - React.useEffect(() => { - if (open && biddingCompanyId) { - loadSavedQuotations() - } - }, [open, biddingCompanyId]) - - const loadSavedQuotations = async () => { - setIsLoading(true) - try { - console.log('Loading saved quotations for biddingCompanyId:', biddingCompanyId) - const savedQuotations = await getSavedPrItemQuotations(biddingCompanyId) - console.log('Loaded saved quotations:', savedQuotations) - setPrItemQuotations(savedQuotations) - } catch (error) { - console.error('Failed to load saved quotations:', error) - } finally { - setIsLoading(false) - } - } - - const handleQuotationsChange = (quotations: PrItemQuotation[]) => { - // ReadOnly 모드이므로 변경사항을 저장하지 않음 - console.log('Quotations changed (readonly):', quotations) - } - - const handleTotalAmountChange = (total: number) => { - // ReadOnly 모드이므로 총 금액 변경을 처리하지 않음 - console.log('Total amount changed (readonly):', total) - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-7xl max-h-[90vh] overflow-y-auto"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <span>품목별 견적 상세</span> - <span className="text-sm font-normal text-muted-foreground"> - - {companyName} - </span> - </DialogTitle> - <DialogDescription> - 협력업체가 제출한 품목별 견적 상세 정보입니다. - </DialogDescription> - </DialogHeader> - - {isLoading ? ( - <div className="flex items-center justify-center py-12"> - <div className="text-center"> - <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div> - <p className="text-muted-foreground">견적 정보를 불러오는 중...</p> - </div> - </div> - ) : ( - <PrItemsPricingTable - prItems={prItems} - initialQuotations={prItemQuotations} - currency={currency} - onQuotationsChange={handleQuotationsChange} - onTotalAmountChange={handleTotalAmountChange} - readOnly={true} - /> - )} - </DialogContent> - </Dialog> - ) -} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx deleted file mode 100644 index e0194f2a..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx +++ /dev/null @@ -1,157 +0,0 @@ -'use client' - -import * as React from 'react' -import { BiddingCompany } from './bidding-pre-quote-vendor-columns' -import { updatePreQuoteSelection } from '../service' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { CheckCircle, XCircle, AlertCircle } from 'lucide-react' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' - -interface BiddingPreQuoteSelectionDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - selectedCompanies: BiddingCompany[] - onSuccess: () => void -} - -export function BiddingPreQuoteSelectionDialog({ - open, - onOpenChange, - selectedCompanies, - onSuccess -}: BiddingPreQuoteSelectionDialogProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - // 선택된 업체들의 현재 상태 분석 (선정만 가능) - const unselectedCompanies = selectedCompanies.filter(c => !c.isPreQuoteSelected) - const hasQuotationCompanies = selectedCompanies.filter(c => c.preQuoteAmount && Number(c.preQuoteAmount) > 0) - - const handleConfirm = () => { - const companyIds = selectedCompanies.map(c => c.id) - const isSelected = true // 항상 선정으로 고정 - - startTransition(async () => { - const result = await updatePreQuoteSelection( - companyIds, - isSelected - ) - - if (result.success) { - toast({ - title: '성공', - description: result.message, - }) - onSuccess() - onOpenChange(false) - } else { - toast({ - title: '오류', - description: result.error, - variant: 'destructive', - }) - } - }) - } - - const getActionIcon = (isSelected: boolean) => { - return isSelected ? - <CheckCircle className="h-4 w-4 text-muted-foreground" /> : - <CheckCircle className="h-4 w-4 text-green-600" /> - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[600px]"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <AlertCircle className="h-5 w-5 text-amber-500" /> - 본입찰 선정 상태 변경 - </DialogTitle> - <DialogDescription> - 선택된 {selectedCompanies.length}개 업체의 본입찰 선정 상태를 변경합니다. - </DialogDescription> - </DialogHeader> - - <div className="space-y-4"> - {/* 견적 제출 여부 안내 */} - {hasQuotationCompanies.length !== selectedCompanies.length && ( - <div className="bg-amber-50 border border-amber-200 rounded-lg p-3"> - <div className="flex items-center gap-2 text-amber-800"> - <AlertCircle className="h-4 w-4" /> - <span className="text-sm font-medium">알림</span> - </div> - <p className="text-sm text-amber-700 mt-1"> - 사전견적을 제출하지 않은 업체도 포함되어 있습니다. - 견적 미제출 업체도 본입찰에 참여시키시겠습니까? - </p> - </div> - )} - - {/* 업체 목록 */} - <div className="border rounded-lg"> - <div className="p-3 bg-muted/50 border-b"> - <h4 className="font-medium">대상 업체 목록</h4> - </div> - <div className="max-h-64 overflow-y-auto"> - {selectedCompanies.map((company) => ( - <div key={company.id} className="flex items-center justify-between p-3 border-b last:border-b-0"> - <div className="flex items-center gap-3"> - {getActionIcon(company.isPreQuoteSelected)} - <div> - <div className="font-medium">{company.companyName}</div> - <div className="text-sm text-muted-foreground">{company.companyCode}</div> - </div> - </div> - <div className="flex items-center gap-2"> - <Badge variant={company.isPreQuoteSelected ? 'default' : 'secondary'}> - {company.isPreQuoteSelected ? '현재 선정' : '현재 미선정'} - </Badge> - {company.preQuoteAmount && Number(company.preQuoteAmount) > 0 ? ( - <Badge variant="outline" className="text-green-600"> - 견적 제출 - </Badge> - ) : ( - <Badge variant="outline" className="text-muted-foreground"> - 견적 미제출 - </Badge> - )} - </div> - </div> - ))} - </div> - </div> - - {/* 결과 요약 */} - <div className="bg-blue-50 border border-blue-200 rounded-lg p-3"> - <h5 className="font-medium text-blue-900 mb-2">변경 결과</h5> - <div className="text-sm text-blue-800"> - <p>• {unselectedCompanies.length}개 업체가 본입찰 대상으로 <span className="font-medium text-green-600">선정</span>됩니다.</p> - {selectedCompanies.length > unselectedCompanies.length && ( - <p>• {selectedCompanies.length - unselectedCompanies.length}개 업체는 이미 선정 상태이므로 변경되지 않습니다.</p> - )} - </div> - </div> - </div> - - <DialogFooter> - <Button variant="outline" onClick={() => onOpenChange(false)}> - 취소 - </Button> - <Button onClick={handleConfirm} disabled={isPending}> - {isPending ? '처리 중...' : '확인'} - </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 deleted file mode 100644 index 3266a568..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx +++ /dev/null @@ -1,398 +0,0 @@ -"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, Paperclip -} 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 - preQuoteDeadline: Date | null - isPreQuoteSelected: boolean - isPreQuoteParticipated: boolean | null - 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 - onViewPriceAdjustment?: (company: BiddingCompany) => void - onViewItemDetails?: (company: BiddingCompany) => void - onViewAttachments?: (company: BiddingCompany) => void -} - -export function getBiddingPreQuoteVendorColumns({ - onEdit, - onDelete, - onViewPriceAdjustment, - onViewItemDetails, - onViewAttachments -}: 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 - let variant: any - let label: string - - if (status === 'accepted') { - variant = 'default' - label = '수락' - } else if (status === 'declined') { - variant = 'destructive' - label = '거절' - } else if (status === 'pending') { - variant = 'outline' - label = '대기중' - } else if (status === 'sent') { - variant = 'outline' - label = '요청됨' - } else if (status === 'submitted') { - variant = 'outline' - label = '제출됨' - } else { - variant = 'outline' - label = status || '-' - } - - return <Badge variant={variant}>{label}</Badge> - }, - }, - { - accessorKey: 'preQuoteAmount', - header: '사전견적금액', - cell: ({ row }) => { - const hasAmount = row.original.preQuoteAmount && Number(row.original.preQuoteAmount) > 0 - return ( - <div className="text-right font-mono"> - {hasAmount ? ( - <button - onClick={() => onViewItemDetails?.(row.original)} - className="text-primary hover:text-primary/80 hover:underline cursor-pointer" - title="품목별 견적 상세 보기" - > - {Number(row.original.preQuoteAmount).toLocaleString()} KRW - </button> - ) : ( - <span className="text-muted-foreground">-</span> - )} - </div> - ) - }, - }, - { - accessorKey: 'preQuoteSubmittedAt', - header: '사전견적 제출일', - cell: ({ row }) => ( - <div className="text-sm"> - {row.original.preQuoteSubmittedAt ? new Date(row.original.preQuoteSubmittedAt).toLocaleDateString('ko-KR') : '-'} - </div> - ), - }, - { - accessorKey: 'preQuoteDeadline', - header: '사전견적 마감일', - cell: ({ row }) => { - const deadline = row.original.preQuoteDeadline - if (!deadline) { - return <div className="text-muted-foreground text-sm">-</div> - } - - const now = new Date() - const deadlineDate = new Date(deadline) - const isExpired = deadlineDate < now - - return ( - <div className={`text-sm ${isExpired ? 'text-red-600' : ''}`}> - <div>{deadlineDate.toLocaleDateString('ko-KR')}</div> - {isExpired && ( - <Badge variant="destructive" className="text-xs mt-1"> - 마감 - </Badge> - )} - </div> - ) - }, - }, - { - accessorKey: 'attachments', - header: '첨부파일', - cell: ({ row }) => { - const hasAttachments = row.original.preQuoteSubmittedAt // 제출된 경우에만 첨부파일이 있을 수 있음 - return ( - <div className="text-center"> - {hasAttachments ? ( - <Button - variant="ghost" - size="sm" - onClick={() => onViewAttachments?.(row.original)} - className="h-8 w-8 p-0" - title="첨부파일 보기" - > - <Paperclip className="h-4 w-4" /> - </Button> - ) : ( - <span className="text-muted-foreground text-sm">-</span> - )} - </div> - ) - }, - }, - { - accessorKey: 'isPreQuoteParticipated', - header: '사전견적 참여의사', - cell: ({ row }) => { - const participated = row.original.isPreQuoteParticipated - if (participated === null) { - return <Badge variant="outline">미결정</Badge> - } - return ( - <Badge variant={participated ? 'default' : 'destructive'}> - {participated ? '참여' : '미참여'} - </Badge> - ) - }, - }, - { - 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 ( - <div className="flex items-center gap-2"> - <Badge variant={hasPriceAdjustment ? 'default' : 'secondary'}> - {hasPriceAdjustment ? '적용' : '미적용'} - </Badge> - {hasPriceAdjustment && onViewPriceAdjustment && ( - <Button - variant="ghost" - size="sm" - onClick={() => onViewPriceAdjustment(row.original)} - className="h-6 px-2 text-xs" - > - 상세 - </Button> - )} - </div> - ) - }, - }, - { - 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> - ), - }, - { - 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"> - {/* <DropdownMenuItem onClick={() => onEdit(company)}> - <Edit className="mr-2 h-4 w-4" /> - 수정 - </DropdownMenuItem> */} - <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 deleted file mode 100644 index bd078192..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx +++ /dev/null @@ -1,311 +0,0 @@ -'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, - CommandList, -} from '@/components/ui/command' -import { - Popover, - PopoverContent, - PopoverTrigger -} from '@/components/ui/popover' -import { Check, ChevronsUpDown, Loader2, X, Plus, Search } from 'lucide-react' -import { cn } from '@/lib/utils' -import { createBiddingCompany } from '@/lib/bidding/pre-quote/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' -import { ScrollArea } from '@/components/ui/scroll-area' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { Info } from 'lucide-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 [vendorList, setVendorList] = React.useState<Vendor[]>([]) - const [selectedVendors, setSelectedVendors] = React.useState<Vendor[]>([]) - const [vendorOpen, setVendorOpen] = React.useState(false) - - - // 벤더 로드 - 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([]) - } - }, [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 createBiddingCompany({ - biddingId, - companyId: 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([]) - } - - 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/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx deleted file mode 100644 index 03bf2ecb..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx +++ /dev/null @@ -1,200 +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 { 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 deleted file mode 100644 index 5f600882..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx +++ /dev/null @@ -1,257 +0,0 @@ -'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 { getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' -import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog' -import { BiddingPreQuoteItemDetailsDialog } from './bidding-pre-quote-item-details-dialog' -import { BiddingPreQuoteAttachmentsDialog } from './bidding-pre-quote-attachments-dialog' -import { getPrItemsForBidding } from '../service' - -interface BiddingPreQuoteVendorTableContentProps { - biddingId: number - bidding: Bidding - biddingCompanies: BiddingCompany[] - onRefresh: () => void - onOpenItemsDialog: () => void - onOpenTargetPriceDialog: () => void - onOpenSelectionReasonDialog: () => void - onEdit?: (company: BiddingCompany) => void - onDelete?: (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: 'sent' }, - { label: '대기중', value: 'pending' }, - ], - }, -] - -export function BiddingPreQuoteVendorTableContent({ - biddingId, - bidding, - biddingCompanies, - onRefresh, - onOpenItemsDialog, - onOpenTargetPriceDialog, - onOpenSelectionReasonDialog, - onEdit, - onDelete -}: BiddingPreQuoteVendorTableContentProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - const [selectedCompany, setSelectedCompany] = React.useState<BiddingCompany | null>(null) - const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false) - const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false) - const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<any>(null) - const [isItemDetailsDialogOpen, setIsItemDetailsDialogOpen] = React.useState(false) - const [selectedCompanyForDetails, setSelectedCompanyForDetails] = React.useState<BiddingCompany | null>(null) - const [prItems, setPrItems] = React.useState<any[]>([]) - const [isAttachmentsDialogOpen, setIsAttachmentsDialogOpen] = React.useState(false) - const [selectedCompanyForAttachments, setSelectedCompanyForAttachments] = React.useState<BiddingCompany | null>(null) - - const handleDelete = (company: BiddingCompany) => { - 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 handleViewPriceAdjustment = async (company: BiddingCompany) => { - startTransition(async () => { - const priceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(company.id) - if (priceAdjustmentForm) { - setPriceAdjustmentData(priceAdjustmentForm) - setSelectedCompany(company) - setIsPriceAdjustmentDialogOpen(true) - } else { - toast({ - title: '정보 없음', - description: '연동제 정보가 없습니다.', - variant: 'destructive', - }) - } - }) - } - - const handleViewItemDetails = async (company: BiddingCompany) => { - startTransition(async () => { - try { - // PR 아이템 정보 로드 - const prItemsData = await getPrItemsForBidding(biddingId) - setPrItems(prItemsData) - setSelectedCompanyForDetails(company) - setIsItemDetailsDialogOpen(true) - } catch (error) { - console.error('Failed to load PR items:', error) - toast({ - title: '오류', - description: '품목 정보를 불러오는데 실패했습니다.', - variant: 'destructive', - }) - } - }) - } - - const handleViewAttachments = (company: BiddingCompany) => { - setSelectedCompanyForAttachments(company) - setIsAttachmentsDialogOpen(true) - } - - const columns = React.useMemo( - () => getBiddingPreQuoteVendorColumns({ - onEdit: onEdit || handleEdit, - onDelete: onDelete || handleDelete, - onViewPriceAdjustment: handleViewPriceAdjustment, - onViewItemDetails: handleViewItemDetails, - onViewAttachments: handleViewAttachments - }), - [onEdit, onDelete, handleEdit, handleDelete, handleViewPriceAdjustment, handleViewItemDetails, handleViewAttachments] - ) - - 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} - /> - - <PriceAdjustmentDialog - open={isPriceAdjustmentDialogOpen} - onOpenChange={setIsPriceAdjustmentDialogOpen} - data={priceAdjustmentData} - vendorName={selectedCompany?.companyName || ''} - /> - - <BiddingPreQuoteItemDetailsDialog - open={isItemDetailsDialogOpen} - onOpenChange={setIsItemDetailsDialogOpen} - biddingId={biddingId} - biddingCompanyId={selectedCompanyForDetails?.id || 0} - companyName={selectedCompanyForDetails?.companyName || ''} - prItems={prItems} - currency={bidding.currency || 'KRW'} - /> - - <BiddingPreQuoteAttachmentsDialog - open={isAttachmentsDialogOpen} - onOpenChange={setIsAttachmentsDialogOpen} - biddingId={biddingId} - companyId={selectedCompanyForAttachments?.companyId || 0} - companyName={selectedCompanyForAttachments?.companyName || ''} - /> - </> - ) -} 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 deleted file mode 100644 index 34e53fb2..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx +++ /dev/null @@ -1,130 +0,0 @@ -"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, CheckSquare } 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 { BiddingPreQuoteSelectionDialog } from "./bidding-pre-quote-selection-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 [isSelectionDialogOpen, setIsSelectionDialogOpen] = React.useState(false) - - const handleCreateCompany = () => { - setIsCreateDialogOpen(true) - } - - const handleSendInvitations = () => { - setIsInvitationDialogOpen(true) - } - - const handleManageSelection = () => { - const selectedRows = table.getFilteredSelectedRowModel().rows - if (selectedRows.length === 0) { - toast({ - title: '선택 필요', - description: '본입찰 선정 상태를 변경할 업체를 선택해주세요.', - variant: 'destructive', - }) - return - } - setIsSelectionDialogOpen(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> - - <Button - variant="secondary" - size="sm" - onClick={handleManageSelection} - disabled={isPending} - > - <CheckSquare 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} - biddingId={biddingId} - biddingTitle={bidding.title} - projectName={bidding.projectName} - onSuccess={onSuccess} - /> - - <BiddingPreQuoteSelectionDialog - open={isSelectionDialogOpen} - onOpenChange={setIsSelectionDialogOpen} - selectedCompanies={table.getFilteredSelectedRowModel().rows.map(row => row.original)} - onSuccess={() => { - onSuccess() - table.resetRowSelection() - }} - /> - </> - ) -} |
