From b67e36df49f067cbd5ba899f9fbcc755f38d4b4f Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 4 Sep 2025 08:31:31 +0000 Subject: (대표님, 최겸, 임수민) 작업사항 커밋 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bidding/detail/service.ts | 4 +- lib/bidding/pre-quote/service.ts | 934 +++++++++++++++++++++ .../pre-quote/table/bidding-pre-quote-content.tsx | 57 ++ .../table/bidding-pre-quote-invitation-dialog.tsx | 185 ++++ .../table/bidding-pre-quote-vendor-columns.tsx | 303 +++++++ .../bidding-pre-quote-vendor-create-dialog.tsx | 205 +++++ .../table/bidding-pre-quote-vendor-edit-dialog.tsx | 200 +++++ .../table/bidding-pre-quote-vendor-table.tsx | 189 +++++ .../bidding-pre-quote-vendor-toolbar-actions.tsx | 92 ++ lib/bidding/service.ts | 2 +- .../vendor/components/pr-items-pricing-dialog.tsx | 384 +++++++++ .../vendor/components/pr-items-pricing-table.tsx | 347 ++++++++ .../vendor/components/pre-quote-file-upload.tsx | 367 ++++++++ .../vendor/partners-bidding-list-columns.tsx | 21 +- lib/bidding/vendor/partners-bidding-list.tsx | 105 ++- .../partners-bidding-participation-dialog.tsx | 249 ++++++ lib/bidding/vendor/partners-bidding-pre-quote.tsx | 928 ++++++++++++++++++++ .../vendor/partners-bidding-toolbar-actions.tsx | 29 +- .../vendor-prequote-participation-dialog.tsx | 268 ++++++ lib/forms/vendor-completion-stats.ts | 340 +++++++- lib/mail/templates/pre-quote-invitation.hbs | 190 +++++ lib/rfq-last/attachment/add-attachment-dialog.tsx | 365 ++++++++ .../attachment/delete-attachments-dialog.tsx | 117 +++ lib/rfq-last/attachment/rfq-attachments-table.tsx | 539 ++++++++++++ lib/rfq-last/attachment/update-revision-dialog.tsx | 216 +++++ lib/rfq-last/service.ts | 91 +- lib/rfq-last/table/rfq-table-columns.tsx | 175 +++- lib/rfq-last/table/rfq-table.tsx | 10 +- lib/rfq-last/validations.ts | 104 ++- lib/vendor-document-list/import-service.ts | 3 +- 30 files changed, 6828 insertions(+), 191 deletions(-) create mode 100644 lib/bidding/pre-quote/service.ts create mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx create mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx create mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx create mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx create mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx create mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx create mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx create mode 100644 lib/bidding/vendor/components/pr-items-pricing-dialog.tsx create mode 100644 lib/bidding/vendor/components/pr-items-pricing-table.tsx create mode 100644 lib/bidding/vendor/components/pre-quote-file-upload.tsx create mode 100644 lib/bidding/vendor/partners-bidding-participation-dialog.tsx create mode 100644 lib/bidding/vendor/partners-bidding-pre-quote.tsx create mode 100644 lib/bidding/vendor/vendor-prequote-participation-dialog.tsx create mode 100644 lib/mail/templates/pre-quote-invitation.hbs create mode 100644 lib/rfq-last/attachment/add-attachment-dialog.tsx create mode 100644 lib/rfq-last/attachment/delete-attachments-dialog.tsx create mode 100644 lib/rfq-last/attachment/rfq-attachments-table.tsx create mode 100644 lib/rfq-last/attachment/update-revision-dialog.tsx (limited to 'lib') diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index c811f46d..7c7ae498 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -682,6 +682,7 @@ export interface PartnersBiddingListItem { finalQuoteSubmittedAt: string | null isWinner: boolean | null isAttendingMeeting: boolean | null + isPreQuoteSelected: boolean | null notes: string | null createdAt: Date updatedAt: Date @@ -724,6 +725,7 @@ export async function getBiddingListForPartners(companyId: number): Promise { + // 1. biddingCompanies 레코드 생성 + const biddingCompanyResult = await tx.insert(biddingCompanies).values({ + biddingId: input.biddingId, + companyId: input.companyId, + invitationStatus: 'pending', // 초기 상태: 입찰생성 + invitedAt: new Date(), + contactPerson: input.contactPerson, + contactEmail: input.contactEmail, + contactPhone: input.contactPhone, + notes: input.notes, + }).returning({ id: biddingCompanies.id }) + + if (biddingCompanyResult.length === 0) { + throw new Error('업체 추가에 실패했습니다.') + } + + const biddingCompanyId = biddingCompanyResult[0].id + + // 2. company_condition_responses 레코드 생성 (기본값으로) + await tx.insert(companyConditionResponses).values({ + biddingCompanyId: biddingCompanyId, + // 나머지 필드들은 null로 시작 (벤더가 나중에 응답) + }) + + return biddingCompanyId + }) + + return { + success: true, + message: '업체가 성공적으로 추가되었습니다.', + data: { id: result } + } + } catch (error) { + console.error('Failed to create bidding company:', error) + return { + success: false, + error: error instanceof Error ? error.message : '업체 추가에 실패했습니다.' + } + } +} + +// 사전견적용 업체 정보 업데이트 +export async function updateBiddingCompany(id: number, input: UpdateBiddingCompanyInput) { + try { + const updateData: any = { + updatedAt: new Date() + } + + if (input.contactPerson !== undefined) updateData.contactPerson = input.contactPerson + if (input.contactEmail !== undefined) updateData.contactEmail = input.contactEmail + if (input.contactPhone !== undefined) updateData.contactPhone = input.contactPhone + if (input.preQuoteAmount !== undefined) updateData.preQuoteAmount = input.preQuoteAmount + if (input.notes !== undefined) updateData.notes = input.notes + if (input.invitationStatus !== undefined) { + updateData.invitationStatus = input.invitationStatus + if (input.invitationStatus !== 'pending') { + updateData.respondedAt = new Date() + } + } + if (input.isPreQuoteSelected !== undefined) updateData.isPreQuoteSelected = input.isPreQuoteSelected + if (input.isAttendingMeeting !== undefined) updateData.isAttendingMeeting = input.isAttendingMeeting + + await db.update(biddingCompanies) + .set(updateData) + .where(eq(biddingCompanies.id, id)) + + return { + success: true, + message: '업체 정보가 성공적으로 업데이트되었습니다.', + } + } catch (error) { + console.error('Failed to update bidding company:', error) + return { + success: false, + error: error instanceof Error ? error.message : '업체 정보 업데이트에 실패했습니다.' + } + } +} + +// 사전견적용 업체 삭제 +export async function deleteBiddingCompany(id: number) { + try { + await db.transaction(async (tx) => { + // 1. 먼저 관련된 조건 응답들 삭제 + await tx.delete(companyConditionResponses) + .where(eq(companyConditionResponses.biddingCompanyId, id)) + + // 2. biddingCompanies 레코드 삭제 + await tx.delete(biddingCompanies) + .where(eq(biddingCompanies.id, id)) + }) + + return { + success: true, + message: '업체가 성공적으로 삭제되었습니다.' + } + } catch (error) { + console.error('Failed to delete bidding company:', error) + return { + success: false, + error: error instanceof Error ? error.message : '업체 삭제에 실패했습니다.' + } + } +} + +// 특정 입찰의 참여 업체 목록 조회 (company_condition_responses와 vendors 조인) +export async function getBiddingCompanies(biddingId: number) { + try { + const companies = await db + .select({ + // bidding_companies 필드들 + id: biddingCompanies.id, + biddingId: biddingCompanies.biddingId, + companyId: biddingCompanies.companyId, + invitationStatus: biddingCompanies.invitationStatus, + invitedAt: biddingCompanies.invitedAt, + respondedAt: biddingCompanies.respondedAt, + preQuoteAmount: biddingCompanies.preQuoteAmount, + preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt, + isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, + isAttendingMeeting: biddingCompanies.isAttendingMeeting, + notes: biddingCompanies.notes, + contactPerson: biddingCompanies.contactPerson, + contactEmail: biddingCompanies.contactEmail, + contactPhone: biddingCompanies.contactPhone, + createdAt: biddingCompanies.createdAt, + updatedAt: biddingCompanies.updatedAt, + + // vendors 테이블에서 업체 정보 + companyName: vendors.vendorName, + companyCode: vendors.vendorCode, + + // company_condition_responses 필드들 + paymentTermsResponse: companyConditionResponses.paymentTermsResponse, + taxConditionsResponse: companyConditionResponses.taxConditionsResponse, + proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate, + priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse, + isInitialResponse: companyConditionResponses.isInitialResponse, + incotermsResponse: companyConditionResponses.incotermsResponse, + proposedShippingPort: companyConditionResponses.proposedShippingPort, + proposedDestinationPort: companyConditionResponses.proposedDestinationPort, + sparePartResponse: companyConditionResponses.sparePartResponse, + }) + .from(biddingCompanies) + .leftJoin( + vendors, + eq(biddingCompanies.companyId, vendors.id) + ) + .leftJoin( + companyConditionResponses, + eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId) + ) + .where(eq(biddingCompanies.biddingId, biddingId)) + + return { + success: true, + data: companies + } + } catch (error) { + console.error('Failed to get bidding companies:', error) + return { + success: false, + error: error instanceof Error ? error.message : '업체 목록 조회에 실패했습니다.' + } + } +} + +// 선택된 업체들에게 사전견적 초대 발송 +export async function sendPreQuoteInvitations(companyIds: number[]) { + try { + if (companyIds.length === 0) { + return { + success: false, + error: '선택된 업체가 없습니다.' + } + } + + // 선택된 업체들의 정보와 입찰 정보 조회 + const companiesInfo = await db + .select({ + biddingCompanyId: biddingCompanies.id, + companyId: biddingCompanies.companyId, + biddingId: biddingCompanies.biddingId, + companyName: vendors.vendorName, + companyEmail: vendors.email, + // 입찰 정보 + biddingNumber: biddings.biddingNumber, + revision: biddings.revision, + projectName: biddings.projectName, + biddingTitle: biddings.title, + itemName: biddings.itemName, + preQuoteDate: biddings.preQuoteDate, + budget: biddings.budget, + currency: biddings.currency, + managerName: biddings.managerName, + managerEmail: biddings.managerEmail, + managerPhone: biddings.managerPhone, + }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id)) + .where(inArray(biddingCompanies.id, companyIds)) + + if (companiesInfo.length === 0) { + return { + success: false, + error: '업체 정보를 찾을 수 없습니다.' + } + } + + await db.transaction(async (tx) => { + // 선택된 업체들의 상태를 '사전견적요청(초대발송)'으로 변경 + for (const id of companyIds) { + await tx.update(biddingCompanies) + .set({ + invitationStatus: 'sent', // 사전견적 초대 발송 상태 + invitedAt: new Date(), + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, id)) + } + }) + + // 각 업체별로 이메일 발송 + for (const company of companiesInfo) { + if (company.companyEmail) { + try { + await sendEmail({ + to: company.companyEmail, + template: 'pre-quote-invitation', + context: { + companyName: company.companyName, + biddingNumber: company.biddingNumber, + revision: company.revision, + projectName: company.projectName, + biddingTitle: company.biddingTitle, + itemName: company.itemName, + preQuoteDate: company.preQuoteDate ? new Date(company.preQuoteDate).toLocaleDateString() : null, + budget: company.budget ? company.budget.toLocaleString() : null, + currency: company.currency, + managerName: company.managerName, + managerEmail: company.managerEmail, + managerPhone: company.managerPhone, + loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${company.biddingId}/pre-quote`, + currentYear: new Date().getFullYear(), + language: 'ko' + } + }) + } catch (emailError) { + console.error(`Failed to send email to ${company.companyEmail}:`, emailError) + // 이메일 발송 실패해도 전체 프로세스는 계속 진행 + } + } + } + + return { + success: true, + message: `${companyIds.length}개 업체에 사전견적 초대를 발송했습니다.` + } + } catch (error) { + console.error('Failed to send pre-quote invitations:', error) + return { + success: false, + error: error instanceof Error ? error.message : '초대 발송에 실패했습니다.' + } + } +} + +// Partners에서 특정 업체의 입찰 정보 조회 (사전견적 단계) +export async function getBiddingCompaniesForPartners(biddingId: number, companyId: number) { + try { + // 1. 먼저 입찰 기본 정보를 가져옴 + const biddingResult = await db + .select({ + id: biddings.id, + biddingNumber: biddings.biddingNumber, + revision: biddings.revision, + projectName: biddings.projectName, + itemName: biddings.itemName, + title: biddings.title, + description: biddings.description, + content: biddings.content, + contractType: biddings.contractType, + biddingType: biddings.biddingType, + awardCount: biddings.awardCount, + contractPeriod: biddings.contractPeriod, + preQuoteDate: biddings.preQuoteDate, + biddingRegistrationDate: biddings.biddingRegistrationDate, + submissionStartDate: biddings.submissionStartDate, + submissionEndDate: biddings.submissionEndDate, + evaluationDate: biddings.evaluationDate, + currency: biddings.currency, + budget: biddings.budget, + targetPrice: biddings.targetPrice, + status: biddings.status, + managerName: biddings.managerName, + managerEmail: biddings.managerEmail, + managerPhone: biddings.managerPhone, + }) + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1) + + if (biddingResult.length === 0) { + return null + } + + const biddingData = biddingResult[0] + + // 2. 해당 업체의 biddingCompanies 정보 조회 + const companyResult = await db + .select({ + biddingCompanyId: biddingCompanies.id, + biddingId: biddingCompanies.biddingId, + invitationStatus: biddingCompanies.invitationStatus, + preQuoteAmount: biddingCompanies.preQuoteAmount, + preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt, + isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, + isAttendingMeeting: biddingCompanies.isAttendingMeeting, + // company_condition_responses 정보 + paymentTermsResponse: companyConditionResponses.paymentTermsResponse, + taxConditionsResponse: companyConditionResponses.taxConditionsResponse, + incotermsResponse: companyConditionResponses.incotermsResponse, + proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate, + proposedShippingPort: companyConditionResponses.proposedShippingPort, + proposedDestinationPort: companyConditionResponses.proposedDestinationPort, + priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse, + sparePartResponse: companyConditionResponses.sparePartResponse, + isInitialResponse: companyConditionResponses.isInitialResponse, + additionalProposals: companyConditionResponses.additionalProposals, + }) + .from(biddingCompanies) + .leftJoin( + companyConditionResponses, + eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId) + ) + .where( + and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.companyId, companyId) + ) + ) + .limit(1) + + // 3. 결과 조합 + if (companyResult.length === 0) { + // 아직 초대되지 않은 상태 + return { + ...biddingData, + biddingCompanyId: null, + biddingId: biddingData.id, + invitationStatus: null, + preQuoteAmount: null, + preQuoteSubmittedAt: null, + isPreQuoteSelected: false, + isAttendingMeeting: null, + paymentTermsResponse: null, + taxConditionsResponse: null, + incotermsResponse: null, + proposedContractDeliveryDate: null, + proposedShippingPort: null, + proposedDestinationPort: null, + priceAdjustmentResponse: null, + sparePartResponse: null, + isInitialResponse: null, + additionalProposals: null, + } + } + + const companyData = companyResult[0] + + return { + ...biddingData, + ...companyData, + biddingId: biddingData.id, // bidding ID 보장 + } + } catch (error) { + console.error('Failed to get bidding companies for partners:', error) + throw error + } +} + +// Partners에서 사전견적 응답 제출 +export async function submitPreQuoteResponse( + biddingCompanyId: number, + responseData: { + preQuoteAmount?: number // 품목별 계산에서 자동으로 계산되므로 optional + prItemQuotations?: PrItemQuotation[] // 품목별 견적 정보 추가 + paymentTermsResponse?: string + taxConditionsResponse?: string + incotermsResponse?: string + proposedContractDeliveryDate?: string + proposedShippingPort?: string + proposedDestinationPort?: string + priceAdjustmentResponse?: boolean + isInitialResponse?: boolean + sparePartResponse?: string + additionalProposals?: string + priceAdjustmentForm?: any + }, + userId: string +) { + try { + let finalAmount = responseData.preQuoteAmount || 0 + + await db.transaction(async (tx) => { + // 1. 품목별 견적 정보 최종 저장 (사전견적 제출) + if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) { + // 기존 사전견적 품목 삭제 후 새로 생성 + await tx.delete(companyPrItemBids) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.isPreQuote, true) + ) + ) + + // 품목별 견적 최종 저장 + for (const item of responseData.prItemQuotations) { + await tx.insert(companyPrItemBids) + .values({ + biddingCompanyId, + prItemId: item.prItemId, + bidUnitPrice: item.bidUnitPrice.toString(), + bidAmount: item.bidAmount.toString(), + proposedDeliveryDate: item.proposedDeliveryDate || null, + technicalSpecification: item.technicalSpecification || null, + currency: 'KRW', + isPreQuote: true, + submittedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date() + }) + } + + // 총 금액 다시 계산 + finalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0) + } + + // 2. biddingCompanies 업데이트 (사전견적 금액, 제출 시간, 상태 변경) + await tx.update(biddingCompanies) + .set({ + preQuoteAmount: finalAmount.toString(), + preQuoteSubmittedAt: new Date(), + invitationStatus: 'submitted', // 사전견적 제출 완료 상태로 변경 + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + + // 3. company_condition_responses 업데이트 + const finalConditionResult = await tx.update(companyConditionResponses) + .set({ + paymentTermsResponse: responseData.paymentTermsResponse, + taxConditionsResponse: responseData.taxConditionsResponse, + incotermsResponse: responseData.incotermsResponse, + proposedContractDeliveryDate: responseData.proposedContractDeliveryDate, + proposedShippingPort: responseData.proposedShippingPort, + proposedDestinationPort: responseData.proposedDestinationPort, + priceAdjustmentResponse: responseData.priceAdjustmentResponse, + isInitialResponse: responseData.isInitialResponse, + sparePartResponse: responseData.sparePartResponse, + additionalProposals: responseData.additionalProposals, + updatedAt: new Date() + }) + .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId)) + .returning() + + // 4. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우) + if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && finalConditionResult.length > 0) { + const companyConditionResponseId = finalConditionResult[0].id + + const priceAdjustmentData = { + companyConditionResponsesId: companyConditionResponseId, + itemName: responseData.priceAdjustmentForm.itemName, + adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint, + majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial, + adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula, + rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex, + referenceDate: responseData.priceAdjustmentForm.referenceDate ? new Date(responseData.priceAdjustmentForm.referenceDate) : null, + comparisonDate: responseData.priceAdjustmentForm.comparisonDate ? new Date(responseData.priceAdjustmentForm.comparisonDate) : null, + adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio, + notes: responseData.priceAdjustmentForm.notes, + adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions, + majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial, + adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod, + contractorWriter: responseData.priceAdjustmentForm.contractorWriter, + adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate ? new Date(responseData.priceAdjustmentForm.adjustmentDate) : null, + nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason, + } + + // 기존 연동제 정보가 있는지 확인 + const existingPriceAdjustment = await tx + .select() + .from(priceAdjustmentForms) + .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId)) + .limit(1) + + if (existingPriceAdjustment.length > 0) { + // 업데이트 + await tx + .update(priceAdjustmentForms) + .set(priceAdjustmentData) + .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId)) + } else { + // 새로 생성 + await tx.insert(priceAdjustmentForms).values(priceAdjustmentData) + } + } + }) + + return { + success: true, + message: '사전견적이 성공적으로 제출되었습니다.' + } + } catch (error) { + console.error('Failed to submit pre-quote response:', error) + return { + success: false, + error: error instanceof Error ? error.message : '사전견적 제출에 실패했습니다.' + } + } +} + +// Partners에서 사전견적 참여 의사 결정 (수락/거절) +export async function respondToPreQuoteInvitation( + biddingCompanyId: number, + response: 'accepted' | 'declined', + userId: string +) { + try { + await db.update(biddingCompanies) + .set({ + invitationStatus: response, // accepted 또는 declined + respondedAt: new Date(), + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + + const message = response === 'accepted' ? + '사전견적 참여를 수락했습니다.' : + '사전견적 참여를 거절했습니다.' + + return { + success: true, + message + } + } catch (error) { + console.error('Failed to respond to pre-quote invitation:', error) + return { + success: false, + error: error instanceof Error ? error.message : '응답 처리에 실패했습니다.' + } + } +} + +// 벤더에서 사전견적 참여 여부 결정 (isPreQuoteSelected 사용) +export async function setPreQuoteParticipation( + biddingCompanyId: number, + isParticipating: boolean, + userId: string +) { + try { + await db.update(biddingCompanies) + .set({ + isPreQuoteSelected: isParticipating, + invitationStatus: isParticipating ? 'accepted' : 'declined', + respondedAt: new Date(), + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + + const message = isParticipating ? + '사전견적 참여를 확정했습니다. 이제 견적서를 작성하실 수 있습니다.' : + '사전견적 참여를 거절했습니다.' + + return { + success: true, + message + } + } catch (error) { + console.error('Failed to set pre-quote participation:', error) + return { + success: false, + error: error instanceof Error ? error.message : '참여 의사 처리에 실패했습니다.' + } + } +} + +// PR 아이템 조회 (입찰에 포함된 품목들) +export async function getPrItemsForBidding(biddingId: number) { + try { + const prItems = await db + .select({ + id: prItemsForBidding.id, + itemNumber: prItemsForBidding.itemNumber, + prNumber: prItemsForBidding.prNumber, + itemInfo: prItemsForBidding.itemInfo, + materialDescription: prItemsForBidding.materialDescription, + quantity: prItemsForBidding.quantity, + quantityUnit: prItemsForBidding.quantityUnit, + currency: prItemsForBidding.currency, + requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate, + hasSpecDocument: prItemsForBidding.hasSpecDocument + }) + .from(prItemsForBidding) + .where(eq(prItemsForBidding.biddingId, biddingId)) + + return prItems + } catch (error) { + console.error('Failed to get PR items for bidding:', error) + return [] + } +} + +// SPEC 문서 조회 (PR 아이템에 연결된 문서들) +export async function getSpecDocumentsForPrItem(prItemId: number) { + try { + const specDocs = await db + .select({ + id: biddingDocuments.id, + fileName: biddingDocuments.fileName, + originalFileName: biddingDocuments.originalFileName, + fileSize: biddingDocuments.fileSize, + filePath: biddingDocuments.filePath, + title: biddingDocuments.title, + description: biddingDocuments.description, + uploadedAt: biddingDocuments.uploadedAt + }) + .from(biddingDocuments) + .where( + and( + eq(biddingDocuments.prItemId, prItemId), + eq(biddingDocuments.documentType, 'specification') + ) + ) + + return specDocs + } catch (error) { + console.error('Failed to get spec documents for PR item:', error) + return [] + } +} + +// 사전견적 임시저장 +export async function savePreQuoteDraft( + biddingCompanyId: number, + responseData: { + prItemQuotations?: PrItemQuotation[] + paymentTermsResponse?: string + taxConditionsResponse?: string + incotermsResponse?: string + proposedContractDeliveryDate?: string + proposedShippingPort?: string + proposedDestinationPort?: string + priceAdjustmentResponse?: boolean + isInitialResponse?: boolean + sparePartResponse?: string + additionalProposals?: string + priceAdjustmentForm?: any + }, + userId: string +) { + try { + let totalAmount = 0 + + await db.transaction(async (tx) => { + // 품목별 견적 정보 저장 + if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) { + // 기존 사전견적 품목 삭제 (임시저장 시 덮어쓰기) + await tx.delete(companyPrItemBids) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.isPreQuote, true) + ) + ) + + // 새로운 품목별 견적 저장 + for (const item of responseData.prItemQuotations) { + await tx.insert(companyPrItemBids) + .values({ + biddingCompanyId, + prItemId: item.prItemId, + bidUnitPrice: item.bidUnitPrice.toString(), + bidAmount: item.bidAmount.toString(), + proposedDeliveryDate: item.proposedDeliveryDate || null, + technicalSpecification: item.technicalSpecification || null, + currency: 'KRW', + isPreQuote: true, // 사전견적 표시 + submittedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date() + }) + } + + // 총 금액 계산 + totalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0) + + // biddingCompanies에 총 금액 임시 저장 (status는 변경하지 않음) + await tx.update(biddingCompanies) + .set({ + preQuoteAmount: totalAmount.toString(), + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + } + + // company_condition_responses 업데이트 (임시저장) + const conditionResult = await tx.update(companyConditionResponses) + .set({ + paymentTermsResponse: responseData.paymentTermsResponse || null, + taxConditionsResponse: responseData.taxConditionsResponse || null, + incotermsResponse: responseData.incotermsResponse || null, + proposedContractDeliveryDate: responseData.proposedContractDeliveryDate || null, + proposedShippingPort: responseData.proposedShippingPort || null, + proposedDestinationPort: responseData.proposedDestinationPort || null, + priceAdjustmentResponse: responseData.priceAdjustmentResponse || null, + isInitialResponse: responseData.isInitialResponse || null, + sparePartResponse: responseData.sparePartResponse || null, + additionalProposals: responseData.additionalProposals || null, + updatedAt: new Date() + }) + .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId)) + .returning() + + // 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우) + if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && conditionResult.length > 0) { + const companyConditionResponseId = conditionResult[0].id + + const priceAdjustmentData = { + companyConditionResponsesId: companyConditionResponseId, + itemName: responseData.priceAdjustmentForm.itemName, + adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint, + majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial, + adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula, + rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex, + referenceDate: responseData.priceAdjustmentForm.referenceDate ? new Date(responseData.priceAdjustmentForm.referenceDate) : null, + comparisonDate: responseData.priceAdjustmentForm.comparisonDate ? new Date(responseData.priceAdjustmentForm.comparisonDate) : null, + adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio, + notes: responseData.priceAdjustmentForm.notes, + adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions, + majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial, + adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod, + contractorWriter: responseData.priceAdjustmentForm.contractorWriter, + adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate ? new Date(responseData.priceAdjustmentForm.adjustmentDate) : null, + nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason, + } + + // 기존 연동제 정보가 있는지 확인 + const existingPriceAdjustment = await tx + .select() + .from(priceAdjustmentForms) + .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId)) + .limit(1) + + if (existingPriceAdjustment.length > 0) { + // 업데이트 + await tx + .update(priceAdjustmentForms) + .set(priceAdjustmentData) + .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId)) + } else { + // 새로 생성 + await tx.insert(priceAdjustmentForms).values(priceAdjustmentData) + } + } + }) + + return { + success: true, + message: '임시저장이 완료되었습니다.', + totalAmount + } + } catch (error) { + console.error('Failed to save pre-quote draft:', error) + return { + success: false, + error: error instanceof Error ? error.message : '임시저장에 실패했습니다.' + } + } +} + +// 견적 문서 업로드 +export async function uploadPreQuoteDocument( + biddingId: number, + companyId: number, + documentInfo: PreQuoteDocumentUpload, + userId: string +) { + try { + const result = await db.insert(biddingDocuments) + .values({ + biddingId, + companyId, + documentType: 'other', // 견적서 타입 + fileName: documentInfo.fileName, + originalFileName: documentInfo.originalFileName, + fileSize: documentInfo.fileSize, + mimeType: documentInfo.mimeType, + filePath: documentInfo.filePath, + title: `견적서 - ${documentInfo.originalFileName}`, + description: '협력업체 제출 견적서', + isPublic: false, + isRequired: false, + uploadedBy: userId, + uploadedAt: new Date() + }) + .returning() + + return { + success: true, + message: '견적서가 성공적으로 업로드되었습니다.', + documentId: result[0].id + } + } catch (error) { + console.error('Failed to upload pre-quote document:', error) + return { + success: false, + error: error instanceof Error ? error.message : '견적서 업로드에 실패했습니다.' + } + } +} + +// 업로드된 견적 문서 목록 조회 +export async function getPreQuoteDocuments(biddingId: number, companyId: number) { + try { + const documents = await db + .select({ + id: biddingDocuments.id, + fileName: biddingDocuments.fileName, + originalFileName: biddingDocuments.originalFileName, + fileSize: biddingDocuments.fileSize, + filePath: biddingDocuments.filePath, + title: biddingDocuments.title, + description: biddingDocuments.description, + uploadedAt: biddingDocuments.uploadedAt + }) + .from(biddingDocuments) + .where( + and( + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.companyId, companyId), + ) + ) + + return documents + } catch (error) { + console.error('Failed to get pre-quote documents:', error) + return [] + } + } + +// 저장된 품목별 견적 조회 (임시저장/기존 데이터 불러오기용) +export async function getSavedPrItemQuotations(biddingCompanyId: number) { + try { + const savedQuotations = await db + .select({ + prItemId: companyPrItemBids.prItemId, + bidUnitPrice: companyPrItemBids.bidUnitPrice, + bidAmount: companyPrItemBids.bidAmount, + proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate, + technicalSpecification: companyPrItemBids.technicalSpecification, + currency: companyPrItemBids.currency + }) + .from(companyPrItemBids) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.isPreQuote, true) + ) + ) + + // Decimal 타입을 number로 변환 + return savedQuotations.map(item => ({ + prItemId: item.prItemId, + bidUnitPrice: parseFloat(item.bidUnitPrice || '0'), + bidAmount: parseFloat(item.bidAmount || '0'), + proposedDeliveryDate: item.proposedDeliveryDate, + technicalSpecification: item.technicalSpecification, + currency: item.currency + })) + } catch (error) { + console.error('Failed to get saved PR item quotations:', error) + return [] + } + } \ No newline at end of file diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx new file mode 100644 index 00000000..692d12ea --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx @@ -0,0 +1,57 @@ +'use client' + +import * as React from 'react' +import { Bidding } from '@/db/schema' +import { QuotationDetails, QuotationVendor } from '@/lib/bidding/detail/service' +import { getBiddingCompanies } from '../service' + +import { BiddingPreQuoteVendorTableContent } from './bidding-pre-quote-vendor-table' + +interface BiddingPreQuoteContentProps { + bidding: Bidding + quotationDetails: QuotationDetails | null + quotationVendors: QuotationVendor[] + biddingCompanies: any[] + prItems: any[] +} + +export function BiddingPreQuoteContent({ + bidding, + quotationDetails, + quotationVendors, + biddingCompanies: initialBiddingCompanies, + prItems +}: BiddingPreQuoteContentProps) { + const [biddingCompanies, setBiddingCompanies] = React.useState(initialBiddingCompanies) + const [refreshTrigger, setRefreshTrigger] = React.useState(0) + + const handleRefresh = React.useCallback(async () => { + try { + const result = await getBiddingCompanies(bidding.id) + if (result.success && result.data) { + setBiddingCompanies(result.data) + } + setRefreshTrigger(prev => prev + 1) + } catch (error) { + console.error('Failed to refresh bidding companies:', error) + } + }, [bidding.id]) + + return ( +
+ {}} + onOpenTargetPriceDialog={() => {}} + onOpenSelectionReasonDialog={() => {}} + onEdit={undefined} + onDelete={undefined} + onSelectWinner={undefined} + /> +
+ ) +} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx new file mode 100644 index 00000000..84824c1e --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx @@ -0,0 +1,185 @@ +'use client' + +import * as React from 'react' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Badge } from '@/components/ui/badge' +import { BiddingCompany } from './bidding-pre-quote-vendor-columns' +import { sendPreQuoteInvitations } from '../service' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' +import { Mail, Building2 } from 'lucide-react' + +interface BiddingPreQuoteInvitationDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + companies: BiddingCompany[] + onSuccess: () => void +} + +export function BiddingPreQuoteInvitationDialog({ + open, + onOpenChange, + companies, + onSuccess +}: BiddingPreQuoteInvitationDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [selectedCompanyIds, setSelectedCompanyIds] = React.useState([]) + + // 초대 가능한 업체들 (pending 상태인 업체들) + const invitableCompanies = companies.filter(company => + company.invitationStatus === 'pending' && company.companyName + ) + + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedCompanyIds(invitableCompanies.map(company => company.id)) + } else { + setSelectedCompanyIds([]) + } + } + + const handleSelectCompany = (companyId: number, checked: boolean) => { + if (checked) { + setSelectedCompanyIds(prev => [...prev, companyId]) + } else { + setSelectedCompanyIds(prev => prev.filter(id => id !== companyId)) + } + } + + const handleSendInvitations = () => { + if (selectedCompanyIds.length === 0) { + toast({ + title: '알림', + description: '초대를 발송할 업체를 선택해주세요.', + variant: 'default', + }) + return + } + + startTransition(async () => { + const response = await sendPreQuoteInvitations(selectedCompanyIds) + + if (response.success) { + toast({ + title: '성공', + description: response.message, + }) + setSelectedCompanyIds([]) + onOpenChange(false) + onSuccess() + } else { + toast({ + title: '오류', + description: response.error, + variant: 'destructive', + }) + } + }) + } + + const handleOpenChange = (open: boolean) => { + onOpenChange(open) + if (!open) { + setSelectedCompanyIds([]) + } + } + + return ( + + + + + + 사전견적 초대 발송 + + + 선택한 업체들에게 사전견적 요청을 발송합니다. + + + +
+ {invitableCompanies.length === 0 ? ( +
+ 초대 가능한 업체가 없습니다. +
+ ) : ( + <> + {/* 전체 선택 */} +
+ + +
+ + {/* 업체 목록 */} +
+ {invitableCompanies.map((company) => ( +
+ handleSelectCompany(company.id, !!checked)} + /> +
+
+ + {company.companyName} + + {company.companyCode} + +
+ {company.notes && ( +

+ {company.notes} +

+ )} +
+ + 대기중 + +
+ ))} +
+ + {selectedCompanyIds.length > 0 && ( +
+

+ {selectedCompanyIds.length}개 업체에 사전견적 초대를 발송합니다. +

+
+ )} + + )} +
+ + + + + +
+
+ ) +} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx new file mode 100644 index 00000000..30cddbce --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx @@ -0,0 +1,303 @@ +"use client" + +import * as React from "react" +import { type ColumnDef } from "@tanstack/react-table" +import { Checkbox } from "@/components/ui/checkbox" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + MoreHorizontal, Edit, Trash2, UserPlus +} from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +// bidding_companies 테이블 타입 정의 (company_condition_responses와 join) +export interface BiddingCompany { + id: number + biddingId: number + companyId: number + invitationStatus: 'pending' | 'sent' | 'accepted' | 'declined' | 'submitted' + invitedAt: Date | null + respondedAt: Date | null + preQuoteAmount: string | null + preQuoteSubmittedAt: Date | null + isPreQuoteSelected: boolean + isAttendingMeeting: boolean | null + notes: string | null + contactPerson: string | null + contactEmail: string | null + contactPhone: string | null + createdAt: Date + updatedAt: Date + + // company_condition_responses 필드들 + paymentTermsResponse: string | null + taxConditionsResponse: string | null + proposedContractDeliveryDate: string | null + priceAdjustmentResponse: boolean | null + isInitialResponse: boolean | null + incotermsResponse: string | null + proposedShippingPort: string | null + proposedDestinationPort: string | null + sparePartResponse: string | null + additionalProposals: string | null + + // 조인된 업체 정보 + companyName?: string + companyCode?: string +} + +interface GetBiddingCompanyColumnsProps { + onEdit: (company: BiddingCompany) => void + onDelete: (company: BiddingCompany) => void + onInvite: (company: BiddingCompany) => void +} + +export function getBiddingPreQuoteVendorColumns({ + onEdit, + onDelete, + onInvite +}: GetBiddingCompanyColumnsProps): ColumnDef[] { + return [ + { + id: 'select', + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="모두 선택" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="행 선택" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: 'companyName', + header: '업체명', + cell: ({ row }) => ( +
{row.original.companyName || '-'}
+ ), + }, + { + accessorKey: 'companyCode', + header: '업체코드', + cell: ({ row }) => ( +
{row.original.companyCode || '-'}
+ ), + }, + { + accessorKey: 'invitationStatus', + header: '초대 상태', + cell: ({ row }) => { + const status = row.original.invitationStatus + const variant = status === 'accepted' ? 'default' : + status === 'declined' ? 'destructive' : 'outline' + + const label = status === 'accepted' ? '수락' : + status === 'declined' ? '거절' : '대기중' + + return {label} + }, + }, + { + accessorKey: 'preQuoteAmount', + header: '사전견적금액', + cell: ({ row }) => ( +
+ {row.original.preQuoteAmount ? Number(row.original.preQuoteAmount).toLocaleString() : '-'} KRW +
+ ), + }, + { + accessorKey: 'preQuoteSubmittedAt', + header: '사전견적 제출일', + cell: ({ row }) => ( +
+ {row.original.preQuoteSubmittedAt ? new Date(row.original.preQuoteSubmittedAt).toLocaleDateString('ko-KR') : '-'} +
+ ), + }, + { + accessorKey: 'isPreQuoteSelected', + header: '본입찰 선정', + cell: ({ row }) => ( + + {row.original.isPreQuoteSelected ? '선정' : '미선정'} + + ), + }, + { + accessorKey: 'isAttendingMeeting', + header: '사양설명회 참석', + cell: ({ row }) => { + const isAttending = row.original.isAttendingMeeting + if (isAttending === null) return
-
+ return ( + + {isAttending ? '참석' : '불참석'} + + ) + }, + }, + { + accessorKey: 'paymentTermsResponse', + header: '지급조건', + cell: ({ row }) => ( +
+ {row.original.paymentTermsResponse || '-'} +
+ ), + }, + { + accessorKey: 'taxConditionsResponse', + header: '세금조건', + cell: ({ row }) => ( +
+ {row.original.taxConditionsResponse || '-'} +
+ ), + }, + { + accessorKey: 'incotermsResponse', + header: '운송조건', + cell: ({ row }) => ( +
+ {row.original.incotermsResponse || '-'} +
+ ), + }, + { + accessorKey: 'isInitialResponse', + header: '초도여부', + cell: ({ row }) => { + const isInitial = row.original.isInitialResponse + if (isInitial === null) return
-
+ return ( + + {isInitial ? 'Y' : 'N'} + + ) + }, + }, + { + accessorKey: 'priceAdjustmentResponse', + header: '연동제', + cell: ({ row }) => { + const hasPriceAdjustment = row.original.priceAdjustmentResponse + if (hasPriceAdjustment === null) return
-
+ return ( + + {hasPriceAdjustment ? '적용' : '미적용'} + + ) + }, + }, + { + accessorKey: 'proposedContractDeliveryDate', + header: '제안납기일', + cell: ({ row }) => ( +
+ {row.original.proposedContractDeliveryDate ? + new Date(row.original.proposedContractDeliveryDate).toLocaleDateString('ko-KR') : '-'} +
+ ), + }, + { + accessorKey: 'proposedShippingPort', + header: '제안선적지', + cell: ({ row }) => ( +
+ {row.original.proposedShippingPort || '-'} +
+ ), + }, + { + accessorKey: 'proposedDestinationPort', + header: '제안도착지', + cell: ({ row }) => ( +
+ {row.original.proposedDestinationPort || '-'} +
+ ), + }, + { + accessorKey: 'sparePartResponse', + header: '스페어파트', + cell: ({ row }) => ( +
+ {row.original.sparePartResponse || '-'} +
+ ), + }, + { + accessorKey: 'additionalProposals', + header: '추가제안', + cell: ({ row }) => ( +
+ {row.original.additionalProposals || '-'} +
+ ), + }, + { + accessorKey: 'notes', + header: '특이사항', + cell: ({ row }) => ( +
+ {row.original.notes || '-'} +
+ ), + }, + { + id: 'actions', + header: '작업', + cell: ({ row }) => { + const company = row.original + + return ( + + + + + + 작업 + onEdit(company)}> + + 수정 + + {company.invitationStatus === 'pending' && ( + onInvite(company)}> + + 초대 발송 + + )} + + onDelete(company)} + className="text-destructive" + > + + 삭제 + + + + ) + }, + }, + ] +} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx new file mode 100644 index 00000000..e2a38547 --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx @@ -0,0 +1,205 @@ +'use client' + +import * as React from 'react' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from '@/components/ui/command' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { Check, ChevronsUpDown } from 'lucide-react' +import { cn } from '@/lib/utils' +import { createBiddingCompany } from '@/lib/bidding/pre-quote/service' +import { searchVendors } from '@/lib/vendors/service' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface BiddingPreQuoteVendorCreateDialogProps { + biddingId: number + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +interface Vendor { + id: number + vendorName: string + vendorCode: string + status: string +} + +export function BiddingPreQuoteVendorCreateDialog({ + biddingId, + open, + onOpenChange, + onSuccess +}: BiddingPreQuoteVendorCreateDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + + // Vendor 검색 상태 + const [vendors, setVendors] = React.useState([]) + const [selectedVendor, setSelectedVendor] = React.useState(null) + const [vendorSearchOpen, setVendorSearchOpen] = React.useState(false) + const [vendorSearchValue, setVendorSearchValue] = React.useState('') + + + // Vendor 검색 + React.useEffect(() => { + const search = async () => { + if (vendorSearchValue.trim().length < 2) { + setVendors([]) + return + } + + try { + const result = await searchVendors(vendorSearchValue.trim(), 10) + setVendors(result) + } catch (error) { + console.error('Vendor search failed:', error) + setVendors([]) + } + } + + const debounceTimer = setTimeout(search, 300) + return () => clearTimeout(debounceTimer) + }, [vendorSearchValue]) + + const handleVendorSelect = (vendor: Vendor) => { + setSelectedVendor(vendor) + setVendorSearchValue(`${vendor.vendorName} (${vendor.vendorCode})`) + setVendorSearchOpen(false) + } + + const handleCreate = () => { + if (!selectedVendor) { + toast({ + title: '오류', + description: '업체를 선택해주세요.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + const response = await createBiddingCompany({ + biddingId, + companyId: selectedVendor.id, + }) + console.log(response) + if (response.success) { + toast({ + title: '성공', + description: response.message, + }) + onOpenChange(false) + resetForm() + onSuccess() + } else { + toast({ + title: '오류', + description: response.error, + variant: 'destructive', + }) + } + }) + } + + const resetForm = () => { + setSelectedVendor(null) + setVendorSearchValue('') + } + + return ( + + + + 사전견적 업체 추가 + + 검색해서 업체를 선택해주세요. + + +
+ {/* Vendor 검색 */} +
+ + + + + + + + + + {vendorSearchValue.length < 2 + ? "최소 2자 이상 입력해주세요" + : "검색 결과가 없습니다"} + + + {vendors.map((vendor) => ( + handleVendorSelect(vendor)} + > + +
+ {vendor.vendorName} + {vendor.vendorCode} +
+
+ ))} +
+
+
+
+
+ +
+ + + + +
+
+ ) +} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx new file mode 100644 index 00000000..03bf2ecb --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx @@ -0,0 +1,200 @@ +'use client' + +import * as React from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { updateBiddingCompany } from '../service' +import { BiddingCompany } from './bidding-pre-quote-vendor-columns' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface BiddingPreQuoteVendorEditDialogProps { + company: BiddingCompany | null + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +export function BiddingPreQuoteVendorEditDialog({ + company, + open, + onOpenChange, + onSuccess +}: BiddingPreQuoteVendorEditDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + + // 폼 상태 + const [formData, setFormData] = React.useState({ + contactPerson: '', + contactEmail: '', + contactPhone: '', + preQuoteAmount: 0, + notes: '', + invitationStatus: 'pending' as 'pending' | 'accepted' | 'declined', + isPreQuoteSelected: false, + isAttendingMeeting: false, + }) + + // company가 변경되면 폼 데이터 업데이트 + React.useEffect(() => { + if (company) { + setFormData({ + contactPerson: company.contactPerson || '', + contactEmail: company.contactEmail || '', + contactPhone: company.contactPhone || '', + preQuoteAmount: company.preQuoteAmount ? Number(company.preQuoteAmount) : 0, + notes: company.notes || '', + invitationStatus: company.invitationStatus, + isPreQuoteSelected: company.isPreQuoteSelected, + isAttendingMeeting: company.isAttendingMeeting || false, + }) + } + }, [company]) + + const handleEdit = () => { + if (!company) return + + startTransition(async () => { + const response = await updateBiddingCompany(company.id, formData) + + if (response.success) { + toast({ + title: '성공', + description: response.message, + }) + onOpenChange(false) + onSuccess() + } else { + toast({ + title: '오류', + description: response.error, + variant: 'destructive', + }) + } + }) + } + + return ( + + + + 사전견적 업체 수정 + + {company?.companyName} 업체의 사전견적 정보를 수정해주세요. + + +
+
+
+ + setFormData({ ...formData, contactPerson: e.target.value })} + /> +
+
+ + setFormData({ ...formData, contactEmail: e.target.value })} + /> +
+
+ + setFormData({ ...formData, contactPhone: e.target.value })} + /> +
+
+
+
+ + setFormData({ ...formData, preQuoteAmount: Number(e.target.value) })} + /> +
+
+ + +
+
+
+
+ + setFormData({ ...formData, isPreQuoteSelected: !!checked }) + } + /> + +
+
+ + setFormData({ ...formData, isAttendingMeeting: !!checked }) + } + /> + +
+
+
+ +