diff options
Diffstat (limited to 'lib/bidding')
19 files changed, 4848 insertions, 21 deletions
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<Part finalQuoteSubmittedAt: biddingCompanies.finalQuoteSubmittedAt, isWinner: biddingCompanies.isWinner, isAttendingMeeting: biddingCompanies.isAttendingMeeting, + isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, notes: biddingCompanies.notes, createdAt: biddingCompanies.createdAt, updatedAt: biddingCompanies.updatedAt, @@ -822,7 +824,7 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId: finalQuoteSubmittedAt: biddingCompanies.finalQuoteSubmittedAt, isWinner: biddingCompanies.isWinner, isAttendingMeeting: biddingCompanies.isAttendingMeeting, - + isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, // 응답한 조건들 (company_condition_responses) - 제시된 조건과 응답 모두 여기서 관리 paymentTermsResponse: companyConditionResponses.paymentTermsResponse, taxConditionsResponse: companyConditionResponses.taxConditionsResponse, diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts new file mode 100644 index 00000000..bf7a4538 --- /dev/null +++ b/lib/bidding/pre-quote/service.ts @@ -0,0 +1,934 @@ +'use server' + +import db from '@/db/db' +import { biddingCompanies, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding' +import { vendors } from '@/db/schema/vendors' +import { sendEmail } from '@/lib/mail/sendEmail' +import { eq, inArray, and } from 'drizzle-orm' + +interface CreateBiddingCompanyInput { + biddingId: number + companyId: number + contactPerson?: string + contactEmail?: string + contactPhone?: string + notes?: string +} + +interface UpdateBiddingCompanyInput { + contactPerson?: string + contactEmail?: string + contactPhone?: string + preQuoteAmount?: number + notes?: string + invitationStatus?: 'pending' | 'accepted' | 'declined' + isPreQuoteSelected?: boolean + isAttendingMeeting?: boolean +} + +interface PrItemQuotation { + prItemId: number + bidUnitPrice: number + bidAmount: number + proposedDeliveryDate?: string + technicalSpecification?: string +} + +interface PreQuoteDocumentUpload { + fileName: string + originalFileName: string + fileSize: number + mimeType: string + filePath: string +} + +// 사전견적용 업체 추가 - biddingCompanies와 company_condition_responses 레코드 생성 +export async function createBiddingCompany(input: CreateBiddingCompanyInput) { + try { + const result = await db.transaction(async (tx) => { + // 1. biddingCompanies 레코드 생성 + const biddingCompanyResult = await tx.insert(biddingCompanies).values({ + biddingId: input.biddingId, + companyId: input.companyId, + invitationStatus: 'pending', // 초기 상태: 입찰생성 + invitedAt: new Date(), + contactPerson: input.contactPerson, + contactEmail: input.contactEmail, + contactPhone: input.contactPhone, + notes: input.notes, + }).returning({ id: biddingCompanies.id }) + + if (biddingCompanyResult.length === 0) { + throw new Error('업체 추가에 실패했습니다.') + } + + const biddingCompanyId = biddingCompanyResult[0].id + + // 2. company_condition_responses 레코드 생성 (기본값으로) + await tx.insert(companyConditionResponses).values({ + biddingCompanyId: biddingCompanyId, + // 나머지 필드들은 null로 시작 (벤더가 나중에 응답) + }) + + return biddingCompanyId + }) + + return { + success: true, + message: '업체가 성공적으로 추가되었습니다.', + data: { id: result } + } + } catch (error) { + console.error('Failed to create bidding company:', error) + return { + success: false, + error: error instanceof Error ? error.message : '업체 추가에 실패했습니다.' + } + } +} + +// 사전견적용 업체 정보 업데이트 +export async function updateBiddingCompany(id: number, input: UpdateBiddingCompanyInput) { + try { + const updateData: any = { + updatedAt: new Date() + } + + if (input.contactPerson !== undefined) updateData.contactPerson = input.contactPerson + if (input.contactEmail !== undefined) updateData.contactEmail = input.contactEmail + if (input.contactPhone !== undefined) updateData.contactPhone = input.contactPhone + if (input.preQuoteAmount !== undefined) updateData.preQuoteAmount = input.preQuoteAmount + if (input.notes !== undefined) updateData.notes = input.notes + if (input.invitationStatus !== undefined) { + updateData.invitationStatus = input.invitationStatus + if (input.invitationStatus !== 'pending') { + updateData.respondedAt = new Date() + } + } + if (input.isPreQuoteSelected !== undefined) updateData.isPreQuoteSelected = input.isPreQuoteSelected + if (input.isAttendingMeeting !== undefined) updateData.isAttendingMeeting = input.isAttendingMeeting + + await db.update(biddingCompanies) + .set(updateData) + .where(eq(biddingCompanies.id, id)) + + return { + success: true, + message: '업체 정보가 성공적으로 업데이트되었습니다.', + } + } catch (error) { + console.error('Failed to update bidding company:', error) + return { + success: false, + error: error instanceof Error ? error.message : '업체 정보 업데이트에 실패했습니다.' + } + } +} + +// 사전견적용 업체 삭제 +export async function deleteBiddingCompany(id: number) { + try { + await db.transaction(async (tx) => { + // 1. 먼저 관련된 조건 응답들 삭제 + await tx.delete(companyConditionResponses) + .where(eq(companyConditionResponses.biddingCompanyId, id)) + + // 2. biddingCompanies 레코드 삭제 + await tx.delete(biddingCompanies) + .where(eq(biddingCompanies.id, id)) + }) + + return { + success: true, + message: '업체가 성공적으로 삭제되었습니다.' + } + } catch (error) { + console.error('Failed to delete bidding company:', error) + return { + success: false, + error: error instanceof Error ? error.message : '업체 삭제에 실패했습니다.' + } + } +} + +// 특정 입찰의 참여 업체 목록 조회 (company_condition_responses와 vendors 조인) +export async function getBiddingCompanies(biddingId: number) { + try { + const companies = await db + .select({ + // bidding_companies 필드들 + id: biddingCompanies.id, + biddingId: biddingCompanies.biddingId, + companyId: biddingCompanies.companyId, + invitationStatus: biddingCompanies.invitationStatus, + invitedAt: biddingCompanies.invitedAt, + respondedAt: biddingCompanies.respondedAt, + preQuoteAmount: biddingCompanies.preQuoteAmount, + preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt, + isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, + isAttendingMeeting: biddingCompanies.isAttendingMeeting, + notes: biddingCompanies.notes, + contactPerson: biddingCompanies.contactPerson, + contactEmail: biddingCompanies.contactEmail, + contactPhone: biddingCompanies.contactPhone, + createdAt: biddingCompanies.createdAt, + updatedAt: biddingCompanies.updatedAt, + + // vendors 테이블에서 업체 정보 + companyName: vendors.vendorName, + companyCode: vendors.vendorCode, + + // company_condition_responses 필드들 + paymentTermsResponse: companyConditionResponses.paymentTermsResponse, + taxConditionsResponse: companyConditionResponses.taxConditionsResponse, + proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate, + priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse, + isInitialResponse: companyConditionResponses.isInitialResponse, + incotermsResponse: companyConditionResponses.incotermsResponse, + proposedShippingPort: companyConditionResponses.proposedShippingPort, + proposedDestinationPort: companyConditionResponses.proposedDestinationPort, + sparePartResponse: companyConditionResponses.sparePartResponse, + }) + .from(biddingCompanies) + .leftJoin( + vendors, + eq(biddingCompanies.companyId, vendors.id) + ) + .leftJoin( + companyConditionResponses, + eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId) + ) + .where(eq(biddingCompanies.biddingId, biddingId)) + + return { + success: true, + data: companies + } + } catch (error) { + console.error('Failed to get bidding companies:', error) + return { + success: false, + error: error instanceof Error ? error.message : '업체 목록 조회에 실패했습니다.' + } + } +} + +// 선택된 업체들에게 사전견적 초대 발송 +export async function sendPreQuoteInvitations(companyIds: number[]) { + try { + if (companyIds.length === 0) { + return { + success: false, + error: '선택된 업체가 없습니다.' + } + } + + // 선택된 업체들의 정보와 입찰 정보 조회 + const companiesInfo = await db + .select({ + biddingCompanyId: biddingCompanies.id, + companyId: biddingCompanies.companyId, + biddingId: biddingCompanies.biddingId, + companyName: vendors.vendorName, + companyEmail: vendors.email, + // 입찰 정보 + biddingNumber: biddings.biddingNumber, + revision: biddings.revision, + projectName: biddings.projectName, + biddingTitle: biddings.title, + itemName: biddings.itemName, + preQuoteDate: biddings.preQuoteDate, + budget: biddings.budget, + currency: biddings.currency, + managerName: biddings.managerName, + managerEmail: biddings.managerEmail, + managerPhone: biddings.managerPhone, + }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id)) + .where(inArray(biddingCompanies.id, companyIds)) + + if (companiesInfo.length === 0) { + return { + success: false, + error: '업체 정보를 찾을 수 없습니다.' + } + } + + await db.transaction(async (tx) => { + // 선택된 업체들의 상태를 '사전견적요청(초대발송)'으로 변경 + for (const id of companyIds) { + await tx.update(biddingCompanies) + .set({ + invitationStatus: 'sent', // 사전견적 초대 발송 상태 + invitedAt: new Date(), + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, id)) + } + }) + + // 각 업체별로 이메일 발송 + for (const company of companiesInfo) { + if (company.companyEmail) { + try { + await sendEmail({ + to: company.companyEmail, + template: 'pre-quote-invitation', + context: { + companyName: company.companyName, + biddingNumber: company.biddingNumber, + revision: company.revision, + projectName: company.projectName, + biddingTitle: company.biddingTitle, + itemName: company.itemName, + preQuoteDate: company.preQuoteDate ? new Date(company.preQuoteDate).toLocaleDateString() : null, + budget: company.budget ? company.budget.toLocaleString() : null, + currency: company.currency, + managerName: company.managerName, + managerEmail: company.managerEmail, + managerPhone: company.managerPhone, + loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${company.biddingId}/pre-quote`, + currentYear: new Date().getFullYear(), + language: 'ko' + } + }) + } catch (emailError) { + console.error(`Failed to send email to ${company.companyEmail}:`, emailError) + // 이메일 발송 실패해도 전체 프로세스는 계속 진행 + } + } + } + + return { + success: true, + message: `${companyIds.length}개 업체에 사전견적 초대를 발송했습니다.` + } + } catch (error) { + console.error('Failed to send pre-quote invitations:', error) + return { + success: false, + error: error instanceof Error ? error.message : '초대 발송에 실패했습니다.' + } + } +} + +// Partners에서 특정 업체의 입찰 정보 조회 (사전견적 단계) +export async function getBiddingCompaniesForPartners(biddingId: number, companyId: number) { + try { + // 1. 먼저 입찰 기본 정보를 가져옴 + const biddingResult = await db + .select({ + id: biddings.id, + biddingNumber: biddings.biddingNumber, + revision: biddings.revision, + projectName: biddings.projectName, + itemName: biddings.itemName, + title: biddings.title, + description: biddings.description, + content: biddings.content, + contractType: biddings.contractType, + biddingType: biddings.biddingType, + awardCount: biddings.awardCount, + contractPeriod: biddings.contractPeriod, + preQuoteDate: biddings.preQuoteDate, + biddingRegistrationDate: biddings.biddingRegistrationDate, + submissionStartDate: biddings.submissionStartDate, + submissionEndDate: biddings.submissionEndDate, + evaluationDate: biddings.evaluationDate, + currency: biddings.currency, + budget: biddings.budget, + targetPrice: biddings.targetPrice, + status: biddings.status, + managerName: biddings.managerName, + managerEmail: biddings.managerEmail, + managerPhone: biddings.managerPhone, + }) + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1) + + if (biddingResult.length === 0) { + return null + } + + const biddingData = biddingResult[0] + + // 2. 해당 업체의 biddingCompanies 정보 조회 + const companyResult = await db + .select({ + biddingCompanyId: biddingCompanies.id, + biddingId: biddingCompanies.biddingId, + invitationStatus: biddingCompanies.invitationStatus, + preQuoteAmount: biddingCompanies.preQuoteAmount, + preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt, + isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, + isAttendingMeeting: biddingCompanies.isAttendingMeeting, + // company_condition_responses 정보 + paymentTermsResponse: companyConditionResponses.paymentTermsResponse, + taxConditionsResponse: companyConditionResponses.taxConditionsResponse, + incotermsResponse: companyConditionResponses.incotermsResponse, + proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate, + proposedShippingPort: companyConditionResponses.proposedShippingPort, + proposedDestinationPort: companyConditionResponses.proposedDestinationPort, + priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse, + sparePartResponse: companyConditionResponses.sparePartResponse, + isInitialResponse: companyConditionResponses.isInitialResponse, + additionalProposals: companyConditionResponses.additionalProposals, + }) + .from(biddingCompanies) + .leftJoin( + companyConditionResponses, + eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId) + ) + .where( + and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.companyId, companyId) + ) + ) + .limit(1) + + // 3. 결과 조합 + if (companyResult.length === 0) { + // 아직 초대되지 않은 상태 + return { + ...biddingData, + biddingCompanyId: null, + biddingId: biddingData.id, + invitationStatus: null, + preQuoteAmount: null, + preQuoteSubmittedAt: null, + isPreQuoteSelected: false, + isAttendingMeeting: null, + paymentTermsResponse: null, + taxConditionsResponse: null, + incotermsResponse: null, + proposedContractDeliveryDate: null, + proposedShippingPort: null, + proposedDestinationPort: null, + priceAdjustmentResponse: null, + sparePartResponse: null, + isInitialResponse: null, + additionalProposals: null, + } + } + + const companyData = companyResult[0] + + return { + ...biddingData, + ...companyData, + biddingId: biddingData.id, // bidding ID 보장 + } + } catch (error) { + console.error('Failed to get bidding companies for partners:', error) + throw error + } +} + +// Partners에서 사전견적 응답 제출 +export async function submitPreQuoteResponse( + biddingCompanyId: number, + responseData: { + preQuoteAmount?: number // 품목별 계산에서 자동으로 계산되므로 optional + prItemQuotations?: PrItemQuotation[] // 품목별 견적 정보 추가 + paymentTermsResponse?: string + taxConditionsResponse?: string + incotermsResponse?: string + proposedContractDeliveryDate?: string + proposedShippingPort?: string + proposedDestinationPort?: string + priceAdjustmentResponse?: boolean + isInitialResponse?: boolean + sparePartResponse?: string + additionalProposals?: string + priceAdjustmentForm?: any + }, + userId: string +) { + try { + let finalAmount = responseData.preQuoteAmount || 0 + + await db.transaction(async (tx) => { + // 1. 품목별 견적 정보 최종 저장 (사전견적 제출) + if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) { + // 기존 사전견적 품목 삭제 후 새로 생성 + await tx.delete(companyPrItemBids) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.isPreQuote, true) + ) + ) + + // 품목별 견적 최종 저장 + for (const item of responseData.prItemQuotations) { + await tx.insert(companyPrItemBids) + .values({ + biddingCompanyId, + prItemId: item.prItemId, + bidUnitPrice: item.bidUnitPrice.toString(), + bidAmount: item.bidAmount.toString(), + proposedDeliveryDate: item.proposedDeliveryDate || null, + technicalSpecification: item.technicalSpecification || null, + currency: 'KRW', + isPreQuote: true, + submittedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date() + }) + } + + // 총 금액 다시 계산 + finalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0) + } + + // 2. biddingCompanies 업데이트 (사전견적 금액, 제출 시간, 상태 변경) + await tx.update(biddingCompanies) + .set({ + preQuoteAmount: finalAmount.toString(), + preQuoteSubmittedAt: new Date(), + invitationStatus: 'submitted', // 사전견적 제출 완료 상태로 변경 + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + + // 3. company_condition_responses 업데이트 + const finalConditionResult = await tx.update(companyConditionResponses) + .set({ + paymentTermsResponse: responseData.paymentTermsResponse, + taxConditionsResponse: responseData.taxConditionsResponse, + incotermsResponse: responseData.incotermsResponse, + proposedContractDeliveryDate: responseData.proposedContractDeliveryDate, + proposedShippingPort: responseData.proposedShippingPort, + proposedDestinationPort: responseData.proposedDestinationPort, + priceAdjustmentResponse: responseData.priceAdjustmentResponse, + isInitialResponse: responseData.isInitialResponse, + sparePartResponse: responseData.sparePartResponse, + additionalProposals: responseData.additionalProposals, + updatedAt: new Date() + }) + .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId)) + .returning() + + // 4. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우) + if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && finalConditionResult.length > 0) { + const companyConditionResponseId = finalConditionResult[0].id + + const priceAdjustmentData = { + companyConditionResponsesId: companyConditionResponseId, + itemName: responseData.priceAdjustmentForm.itemName, + adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint, + majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial, + adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula, + rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex, + referenceDate: responseData.priceAdjustmentForm.referenceDate ? new Date(responseData.priceAdjustmentForm.referenceDate) : null, + comparisonDate: responseData.priceAdjustmentForm.comparisonDate ? new Date(responseData.priceAdjustmentForm.comparisonDate) : null, + adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio, + notes: responseData.priceAdjustmentForm.notes, + adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions, + majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial, + adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod, + contractorWriter: responseData.priceAdjustmentForm.contractorWriter, + adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate ? new Date(responseData.priceAdjustmentForm.adjustmentDate) : null, + nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason, + } + + // 기존 연동제 정보가 있는지 확인 + const existingPriceAdjustment = await tx + .select() + .from(priceAdjustmentForms) + .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId)) + .limit(1) + + if (existingPriceAdjustment.length > 0) { + // 업데이트 + await tx + .update(priceAdjustmentForms) + .set(priceAdjustmentData) + .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId)) + } else { + // 새로 생성 + await tx.insert(priceAdjustmentForms).values(priceAdjustmentData) + } + } + }) + + return { + success: true, + message: '사전견적이 성공적으로 제출되었습니다.' + } + } catch (error) { + console.error('Failed to submit pre-quote response:', error) + return { + success: false, + error: error instanceof Error ? error.message : '사전견적 제출에 실패했습니다.' + } + } +} + +// Partners에서 사전견적 참여 의사 결정 (수락/거절) +export async function respondToPreQuoteInvitation( + biddingCompanyId: number, + response: 'accepted' | 'declined', + userId: string +) { + try { + await db.update(biddingCompanies) + .set({ + invitationStatus: response, // accepted 또는 declined + respondedAt: new Date(), + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + + const message = response === 'accepted' ? + '사전견적 참여를 수락했습니다.' : + '사전견적 참여를 거절했습니다.' + + return { + success: true, + message + } + } catch (error) { + console.error('Failed to respond to pre-quote invitation:', error) + return { + success: false, + error: error instanceof Error ? error.message : '응답 처리에 실패했습니다.' + } + } +} + +// 벤더에서 사전견적 참여 여부 결정 (isPreQuoteSelected 사용) +export async function setPreQuoteParticipation( + biddingCompanyId: number, + isParticipating: boolean, + userId: string +) { + try { + await db.update(biddingCompanies) + .set({ + isPreQuoteSelected: isParticipating, + invitationStatus: isParticipating ? 'accepted' : 'declined', + respondedAt: new Date(), + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + + const message = isParticipating ? + '사전견적 참여를 확정했습니다. 이제 견적서를 작성하실 수 있습니다.' : + '사전견적 참여를 거절했습니다.' + + return { + success: true, + message + } + } catch (error) { + console.error('Failed to set pre-quote participation:', error) + return { + success: false, + error: error instanceof Error ? error.message : '참여 의사 처리에 실패했습니다.' + } + } +} + +// PR 아이템 조회 (입찰에 포함된 품목들) +export async function getPrItemsForBidding(biddingId: number) { + try { + const prItems = await db + .select({ + id: prItemsForBidding.id, + itemNumber: prItemsForBidding.itemNumber, + prNumber: prItemsForBidding.prNumber, + itemInfo: prItemsForBidding.itemInfo, + materialDescription: prItemsForBidding.materialDescription, + quantity: prItemsForBidding.quantity, + quantityUnit: prItemsForBidding.quantityUnit, + currency: prItemsForBidding.currency, + requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate, + hasSpecDocument: prItemsForBidding.hasSpecDocument + }) + .from(prItemsForBidding) + .where(eq(prItemsForBidding.biddingId, biddingId)) + + return prItems + } catch (error) { + console.error('Failed to get PR items for bidding:', error) + return [] + } +} + +// SPEC 문서 조회 (PR 아이템에 연결된 문서들) +export async function getSpecDocumentsForPrItem(prItemId: number) { + try { + const specDocs = await db + .select({ + id: biddingDocuments.id, + fileName: biddingDocuments.fileName, + originalFileName: biddingDocuments.originalFileName, + fileSize: biddingDocuments.fileSize, + filePath: biddingDocuments.filePath, + title: biddingDocuments.title, + description: biddingDocuments.description, + uploadedAt: biddingDocuments.uploadedAt + }) + .from(biddingDocuments) + .where( + and( + eq(biddingDocuments.prItemId, prItemId), + eq(biddingDocuments.documentType, 'specification') + ) + ) + + return specDocs + } catch (error) { + console.error('Failed to get spec documents for PR item:', error) + return [] + } +} + +// 사전견적 임시저장 +export async function savePreQuoteDraft( + biddingCompanyId: number, + responseData: { + prItemQuotations?: PrItemQuotation[] + paymentTermsResponse?: string + taxConditionsResponse?: string + incotermsResponse?: string + proposedContractDeliveryDate?: string + proposedShippingPort?: string + proposedDestinationPort?: string + priceAdjustmentResponse?: boolean + isInitialResponse?: boolean + sparePartResponse?: string + additionalProposals?: string + priceAdjustmentForm?: any + }, + userId: string +) { + try { + let totalAmount = 0 + + await db.transaction(async (tx) => { + // 품목별 견적 정보 저장 + if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) { + // 기존 사전견적 품목 삭제 (임시저장 시 덮어쓰기) + await tx.delete(companyPrItemBids) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.isPreQuote, true) + ) + ) + + // 새로운 품목별 견적 저장 + for (const item of responseData.prItemQuotations) { + await tx.insert(companyPrItemBids) + .values({ + biddingCompanyId, + prItemId: item.prItemId, + bidUnitPrice: item.bidUnitPrice.toString(), + bidAmount: item.bidAmount.toString(), + proposedDeliveryDate: item.proposedDeliveryDate || null, + technicalSpecification: item.technicalSpecification || null, + currency: 'KRW', + isPreQuote: true, // 사전견적 표시 + submittedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date() + }) + } + + // 총 금액 계산 + totalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0) + + // biddingCompanies에 총 금액 임시 저장 (status는 변경하지 않음) + await tx.update(biddingCompanies) + .set({ + preQuoteAmount: totalAmount.toString(), + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + } + + // company_condition_responses 업데이트 (임시저장) + const conditionResult = await tx.update(companyConditionResponses) + .set({ + paymentTermsResponse: responseData.paymentTermsResponse || null, + taxConditionsResponse: responseData.taxConditionsResponse || null, + incotermsResponse: responseData.incotermsResponse || null, + proposedContractDeliveryDate: responseData.proposedContractDeliveryDate || null, + proposedShippingPort: responseData.proposedShippingPort || null, + proposedDestinationPort: responseData.proposedDestinationPort || null, + priceAdjustmentResponse: responseData.priceAdjustmentResponse || null, + isInitialResponse: responseData.isInitialResponse || null, + sparePartResponse: responseData.sparePartResponse || null, + additionalProposals: responseData.additionalProposals || null, + updatedAt: new Date() + }) + .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId)) + .returning() + + // 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우) + if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && conditionResult.length > 0) { + const companyConditionResponseId = conditionResult[0].id + + const priceAdjustmentData = { + companyConditionResponsesId: companyConditionResponseId, + itemName: responseData.priceAdjustmentForm.itemName, + adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint, + majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial, + adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula, + rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex, + referenceDate: responseData.priceAdjustmentForm.referenceDate ? new Date(responseData.priceAdjustmentForm.referenceDate) : null, + comparisonDate: responseData.priceAdjustmentForm.comparisonDate ? new Date(responseData.priceAdjustmentForm.comparisonDate) : null, + adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio, + notes: responseData.priceAdjustmentForm.notes, + adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions, + majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial, + adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod, + contractorWriter: responseData.priceAdjustmentForm.contractorWriter, + adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate ? new Date(responseData.priceAdjustmentForm.adjustmentDate) : null, + nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason, + } + + // 기존 연동제 정보가 있는지 확인 + const existingPriceAdjustment = await tx + .select() + .from(priceAdjustmentForms) + .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId)) + .limit(1) + + if (existingPriceAdjustment.length > 0) { + // 업데이트 + await tx + .update(priceAdjustmentForms) + .set(priceAdjustmentData) + .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId)) + } else { + // 새로 생성 + await tx.insert(priceAdjustmentForms).values(priceAdjustmentData) + } + } + }) + + return { + success: true, + message: '임시저장이 완료되었습니다.', + totalAmount + } + } catch (error) { + console.error('Failed to save pre-quote draft:', error) + return { + success: false, + error: error instanceof Error ? error.message : '임시저장에 실패했습니다.' + } + } +} + +// 견적 문서 업로드 +export async function uploadPreQuoteDocument( + biddingId: number, + companyId: number, + documentInfo: PreQuoteDocumentUpload, + userId: string +) { + try { + const result = await db.insert(biddingDocuments) + .values({ + biddingId, + companyId, + documentType: 'other', // 견적서 타입 + fileName: documentInfo.fileName, + originalFileName: documentInfo.originalFileName, + fileSize: documentInfo.fileSize, + mimeType: documentInfo.mimeType, + filePath: documentInfo.filePath, + title: `견적서 - ${documentInfo.originalFileName}`, + description: '협력업체 제출 견적서', + isPublic: false, + isRequired: false, + uploadedBy: userId, + uploadedAt: new Date() + }) + .returning() + + return { + success: true, + message: '견적서가 성공적으로 업로드되었습니다.', + documentId: result[0].id + } + } catch (error) { + console.error('Failed to upload pre-quote document:', error) + return { + success: false, + error: error instanceof Error ? error.message : '견적서 업로드에 실패했습니다.' + } + } +} + +// 업로드된 견적 문서 목록 조회 +export async function getPreQuoteDocuments(biddingId: number, companyId: number) { + try { + const documents = await db + .select({ + id: biddingDocuments.id, + fileName: biddingDocuments.fileName, + originalFileName: biddingDocuments.originalFileName, + fileSize: biddingDocuments.fileSize, + filePath: biddingDocuments.filePath, + title: biddingDocuments.title, + description: biddingDocuments.description, + uploadedAt: biddingDocuments.uploadedAt + }) + .from(biddingDocuments) + .where( + and( + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.companyId, companyId), + ) + ) + + return documents + } catch (error) { + console.error('Failed to get pre-quote documents:', error) + return [] + } + } + +// 저장된 품목별 견적 조회 (임시저장/기존 데이터 불러오기용) +export async function getSavedPrItemQuotations(biddingCompanyId: number) { + try { + const savedQuotations = await db + .select({ + prItemId: companyPrItemBids.prItemId, + bidUnitPrice: companyPrItemBids.bidUnitPrice, + bidAmount: companyPrItemBids.bidAmount, + proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate, + technicalSpecification: companyPrItemBids.technicalSpecification, + currency: companyPrItemBids.currency + }) + .from(companyPrItemBids) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.isPreQuote, true) + ) + ) + + // Decimal 타입을 number로 변환 + return savedQuotations.map(item => ({ + prItemId: item.prItemId, + bidUnitPrice: parseFloat(item.bidUnitPrice || '0'), + bidAmount: parseFloat(item.bidAmount || '0'), + proposedDeliveryDate: item.proposedDeliveryDate, + technicalSpecification: item.technicalSpecification, + currency: item.currency + })) + } catch (error) { + console.error('Failed to get saved PR item quotations:', error) + return [] + } + }
\ No newline at end of file diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx new file mode 100644 index 00000000..692d12ea --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx @@ -0,0 +1,57 @@ +'use client' + +import * as React from 'react' +import { Bidding } from '@/db/schema' +import { QuotationDetails, QuotationVendor } from '@/lib/bidding/detail/service' +import { getBiddingCompanies } from '../service' + +import { BiddingPreQuoteVendorTableContent } from './bidding-pre-quote-vendor-table' + +interface BiddingPreQuoteContentProps { + bidding: Bidding + quotationDetails: QuotationDetails | null + quotationVendors: QuotationVendor[] + biddingCompanies: any[] + prItems: any[] +} + +export function BiddingPreQuoteContent({ + bidding, + quotationDetails, + quotationVendors, + biddingCompanies: initialBiddingCompanies, + prItems +}: BiddingPreQuoteContentProps) { + const [biddingCompanies, setBiddingCompanies] = React.useState(initialBiddingCompanies) + const [refreshTrigger, setRefreshTrigger] = React.useState(0) + + const handleRefresh = React.useCallback(async () => { + try { + const result = await getBiddingCompanies(bidding.id) + if (result.success && result.data) { + setBiddingCompanies(result.data) + } + setRefreshTrigger(prev => prev + 1) + } catch (error) { + console.error('Failed to refresh bidding companies:', error) + } + }, [bidding.id]) + + return ( + <div className="space-y-6"> + <BiddingPreQuoteVendorTableContent + biddingId={bidding.id} + bidding={bidding} + vendors={quotationVendors} + biddingCompanies={biddingCompanies} + onRefresh={handleRefresh} + onOpenItemsDialog={() => {}} + onOpenTargetPriceDialog={() => {}} + onOpenSelectionReasonDialog={() => {}} + onEdit={undefined} + onDelete={undefined} + onSelectWinner={undefined} + /> + </div> + ) +} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx new file mode 100644 index 00000000..84824c1e --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx @@ -0,0 +1,185 @@ +'use client' + +import * as React from 'react' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Badge } from '@/components/ui/badge' +import { BiddingCompany } from './bidding-pre-quote-vendor-columns' +import { sendPreQuoteInvitations } from '../service' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' +import { Mail, Building2 } from 'lucide-react' + +interface BiddingPreQuoteInvitationDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + companies: BiddingCompany[] + onSuccess: () => void +} + +export function BiddingPreQuoteInvitationDialog({ + open, + onOpenChange, + companies, + onSuccess +}: BiddingPreQuoteInvitationDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [selectedCompanyIds, setSelectedCompanyIds] = React.useState<number[]>([]) + + // 초대 가능한 업체들 (pending 상태인 업체들) + const invitableCompanies = companies.filter(company => + company.invitationStatus === 'pending' && company.companyName + ) + + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedCompanyIds(invitableCompanies.map(company => company.id)) + } else { + setSelectedCompanyIds([]) + } + } + + const handleSelectCompany = (companyId: number, checked: boolean) => { + if (checked) { + setSelectedCompanyIds(prev => [...prev, companyId]) + } else { + setSelectedCompanyIds(prev => prev.filter(id => id !== companyId)) + } + } + + const handleSendInvitations = () => { + if (selectedCompanyIds.length === 0) { + toast({ + title: '알림', + description: '초대를 발송할 업체를 선택해주세요.', + variant: 'default', + }) + return + } + + startTransition(async () => { + const response = await sendPreQuoteInvitations(selectedCompanyIds) + + if (response.success) { + toast({ + title: '성공', + description: response.message, + }) + setSelectedCompanyIds([]) + onOpenChange(false) + onSuccess() + } else { + toast({ + title: '오류', + description: response.error, + variant: 'destructive', + }) + } + }) + } + + const handleOpenChange = (open: boolean) => { + onOpenChange(open) + if (!open) { + setSelectedCompanyIds([]) + } + } + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="sm:max-w-[600px]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Mail className="w-5 h-5" /> + 사전견적 초대 발송 + </DialogTitle> + <DialogDescription> + 선택한 업체들에게 사전견적 요청을 발송합니다. + </DialogDescription> + </DialogHeader> + + <div className="py-4"> + {invitableCompanies.length === 0 ? ( + <div className="text-center py-8 text-muted-foreground"> + 초대 가능한 업체가 없습니다. + </div> + ) : ( + <> + {/* 전체 선택 */} + <div className="flex items-center space-x-2 mb-4 pb-2 border-b"> + <Checkbox + id="select-all" + checked={selectedCompanyIds.length === invitableCompanies.length} + onCheckedChange={handleSelectAll} + /> + <label htmlFor="select-all" className="font-medium"> + 전체 선택 ({invitableCompanies.length}개 업체) + </label> + </div> + + {/* 업체 목록 */} + <div className="space-y-3 max-h-80 overflow-y-auto"> + {invitableCompanies.map((company) => ( + <div key={company.id} className="flex items-center space-x-3 p-3 border rounded-lg"> + <Checkbox + id={`company-${company.id}`} + checked={selectedCompanyIds.includes(company.id)} + onCheckedChange={(checked) => handleSelectCompany(company.id, !!checked)} + /> + <div className="flex-1"> + <div className="flex items-center gap-2"> + <Building2 className="w-4 h-4" /> + <span className="font-medium">{company.companyName}</span> + <Badge variant="outline" className="text-xs"> + {company.companyCode} + </Badge> + </div> + {company.notes && ( + <p className="text-sm text-muted-foreground mt-1"> + {company.notes} + </p> + )} + </div> + <Badge variant="outline"> + 대기중 + </Badge> + </div> + ))} + </div> + + {selectedCompanyIds.length > 0 && ( + <div className="mt-4 p-3 bg-primary/5 rounded-lg"> + <p className="text-sm text-primary"> + <strong>{selectedCompanyIds.length}개 업체</strong>에 사전견적 초대를 발송합니다. + </p> + </div> + )} + </> + )} + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => handleOpenChange(false)}> + 취소 + </Button> + <Button + onClick={handleSendInvitations} + disabled={isPending || selectedCompanyIds.length === 0} + > + <Mail className="w-4 h-4 mr-2" /> + 초대 발송 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx new file mode 100644 index 00000000..30cddbce --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx @@ -0,0 +1,303 @@ +"use client" + +import * as React from "react" +import { type ColumnDef } from "@tanstack/react-table" +import { Checkbox } from "@/components/ui/checkbox" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + MoreHorizontal, Edit, Trash2, UserPlus +} from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +// bidding_companies 테이블 타입 정의 (company_condition_responses와 join) +export interface BiddingCompany { + id: number + biddingId: number + companyId: number + invitationStatus: 'pending' | 'sent' | 'accepted' | 'declined' | 'submitted' + invitedAt: Date | null + respondedAt: Date | null + preQuoteAmount: string | null + preQuoteSubmittedAt: Date | null + isPreQuoteSelected: boolean + isAttendingMeeting: boolean | null + notes: string | null + contactPerson: string | null + contactEmail: string | null + contactPhone: string | null + createdAt: Date + updatedAt: Date + + // company_condition_responses 필드들 + paymentTermsResponse: string | null + taxConditionsResponse: string | null + proposedContractDeliveryDate: string | null + priceAdjustmentResponse: boolean | null + isInitialResponse: boolean | null + incotermsResponse: string | null + proposedShippingPort: string | null + proposedDestinationPort: string | null + sparePartResponse: string | null + additionalProposals: string | null + + // 조인된 업체 정보 + companyName?: string + companyCode?: string +} + +interface GetBiddingCompanyColumnsProps { + onEdit: (company: BiddingCompany) => void + onDelete: (company: BiddingCompany) => void + onInvite: (company: BiddingCompany) => void +} + +export function getBiddingPreQuoteVendorColumns({ + onEdit, + onDelete, + onInvite +}: GetBiddingCompanyColumnsProps): ColumnDef<BiddingCompany>[] { + return [ + { + id: 'select', + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected()} + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="모두 선택" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="행 선택" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: 'companyName', + header: '업체명', + cell: ({ row }) => ( + <div className="font-medium">{row.original.companyName || '-'}</div> + ), + }, + { + accessorKey: 'companyCode', + header: '업체코드', + cell: ({ row }) => ( + <div className="font-mono text-sm">{row.original.companyCode || '-'}</div> + ), + }, + { + accessorKey: 'invitationStatus', + header: '초대 상태', + cell: ({ row }) => { + const status = row.original.invitationStatus + const variant = status === 'accepted' ? 'default' : + status === 'declined' ? 'destructive' : 'outline' + + const label = status === 'accepted' ? '수락' : + status === 'declined' ? '거절' : '대기중' + + return <Badge variant={variant}>{label}</Badge> + }, + }, + { + accessorKey: 'preQuoteAmount', + header: '사전견적금액', + cell: ({ row }) => ( + <div className="text-right font-mono"> + {row.original.preQuoteAmount ? Number(row.original.preQuoteAmount).toLocaleString() : '-'} KRW + </div> + ), + }, + { + accessorKey: 'preQuoteSubmittedAt', + header: '사전견적 제출일', + cell: ({ row }) => ( + <div className="text-sm"> + {row.original.preQuoteSubmittedAt ? new Date(row.original.preQuoteSubmittedAt).toLocaleDateString('ko-KR') : '-'} + </div> + ), + }, + { + accessorKey: 'isPreQuoteSelected', + header: '본입찰 선정', + cell: ({ row }) => ( + <Badge variant={row.original.isPreQuoteSelected ? 'default' : 'secondary'}> + {row.original.isPreQuoteSelected ? '선정' : '미선정'} + </Badge> + ), + }, + { + accessorKey: 'isAttendingMeeting', + header: '사양설명회 참석', + cell: ({ row }) => { + const isAttending = row.original.isAttendingMeeting + if (isAttending === null) return <div className="text-sm">-</div> + return ( + <Badge variant={isAttending ? 'default' : 'secondary'}> + {isAttending ? '참석' : '불참석'} + </Badge> + ) + }, + }, + { + accessorKey: 'paymentTermsResponse', + header: '지급조건', + cell: ({ row }) => ( + <div className="text-sm max-w-32 truncate" title={row.original.paymentTermsResponse || ''}> + {row.original.paymentTermsResponse || '-'} + </div> + ), + }, + { + accessorKey: 'taxConditionsResponse', + header: '세금조건', + cell: ({ row }) => ( + <div className="text-sm max-w-32 truncate" title={row.original.taxConditionsResponse || ''}> + {row.original.taxConditionsResponse || '-'} + </div> + ), + }, + { + accessorKey: 'incotermsResponse', + header: '운송조건', + cell: ({ row }) => ( + <div className="text-sm max-w-24 truncate" title={row.original.incotermsResponse || ''}> + {row.original.incotermsResponse || '-'} + </div> + ), + }, + { + accessorKey: 'isInitialResponse', + header: '초도여부', + cell: ({ row }) => { + const isInitial = row.original.isInitialResponse + if (isInitial === null) return <div className="text-sm">-</div> + return ( + <Badge variant={isInitial ? 'default' : 'secondary'}> + {isInitial ? 'Y' : 'N'} + </Badge> + ) + }, + }, + { + accessorKey: 'priceAdjustmentResponse', + header: '연동제', + cell: ({ row }) => { + const hasPriceAdjustment = row.original.priceAdjustmentResponse + if (hasPriceAdjustment === null) return <div className="text-sm">-</div> + return ( + <Badge variant={hasPriceAdjustment ? 'default' : 'secondary'}> + {hasPriceAdjustment ? '적용' : '미적용'} + </Badge> + ) + }, + }, + { + accessorKey: 'proposedContractDeliveryDate', + header: '제안납기일', + cell: ({ row }) => ( + <div className="text-sm"> + {row.original.proposedContractDeliveryDate ? + new Date(row.original.proposedContractDeliveryDate).toLocaleDateString('ko-KR') : '-'} + </div> + ), + }, + { + accessorKey: 'proposedShippingPort', + header: '제안선적지', + cell: ({ row }) => ( + <div className="text-sm max-w-24 truncate" title={row.original.proposedShippingPort || ''}> + {row.original.proposedShippingPort || '-'} + </div> + ), + }, + { + accessorKey: 'proposedDestinationPort', + header: '제안도착지', + cell: ({ row }) => ( + <div className="text-sm max-w-24 truncate" title={row.original.proposedDestinationPort || ''}> + {row.original.proposedDestinationPort || '-'} + </div> + ), + }, + { + accessorKey: 'sparePartResponse', + header: '스페어파트', + cell: ({ row }) => ( + <div className="text-sm max-w-24 truncate" title={row.original.sparePartResponse || ''}> + {row.original.sparePartResponse || '-'} + </div> + ), + }, + { + accessorKey: 'additionalProposals', + header: '추가제안', + cell: ({ row }) => ( + <div className="text-sm max-w-32 truncate" title={row.original.additionalProposals || ''}> + {row.original.additionalProposals || '-'} + </div> + ), + }, + { + accessorKey: 'notes', + header: '특이사항', + cell: ({ row }) => ( + <div className="text-sm max-w-32 truncate" title={row.original.notes || ''}> + {row.original.notes || '-'} + </div> + ), + }, + { + id: 'actions', + header: '작업', + cell: ({ row }) => { + const company = row.original + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <span className="sr-only">메뉴 열기</span> + <MoreHorizontal className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuLabel>작업</DropdownMenuLabel> + <DropdownMenuItem onClick={() => onEdit(company)}> + <Edit className="mr-2 h-4 w-4" /> + 수정 + </DropdownMenuItem> + {company.invitationStatus === 'pending' && ( + <DropdownMenuItem onClick={() => onInvite(company)}> + <UserPlus className="mr-2 h-4 w-4" /> + 초대 발송 + </DropdownMenuItem> + )} + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => onDelete(company)} + className="text-destructive" + > + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + }, + ] +} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx new file mode 100644 index 00000000..e2a38547 --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx @@ -0,0 +1,205 @@ +'use client' + +import * as React from 'react' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from '@/components/ui/command' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { Check, ChevronsUpDown } from 'lucide-react' +import { cn } from '@/lib/utils' +import { createBiddingCompany } from '@/lib/bidding/pre-quote/service' +import { searchVendors } from '@/lib/vendors/service' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface BiddingPreQuoteVendorCreateDialogProps { + biddingId: number + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +interface Vendor { + id: number + vendorName: string + vendorCode: string + status: string +} + +export function BiddingPreQuoteVendorCreateDialog({ + biddingId, + open, + onOpenChange, + onSuccess +}: BiddingPreQuoteVendorCreateDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + + // Vendor 검색 상태 + const [vendors, setVendors] = React.useState<Vendor[]>([]) + const [selectedVendor, setSelectedVendor] = React.useState<Vendor | null>(null) + const [vendorSearchOpen, setVendorSearchOpen] = React.useState(false) + const [vendorSearchValue, setVendorSearchValue] = React.useState('') + + + // Vendor 검색 + React.useEffect(() => { + const search = async () => { + if (vendorSearchValue.trim().length < 2) { + setVendors([]) + return + } + + try { + const result = await searchVendors(vendorSearchValue.trim(), 10) + setVendors(result) + } catch (error) { + console.error('Vendor search failed:', error) + setVendors([]) + } + } + + const debounceTimer = setTimeout(search, 300) + return () => clearTimeout(debounceTimer) + }, [vendorSearchValue]) + + const handleVendorSelect = (vendor: Vendor) => { + setSelectedVendor(vendor) + setVendorSearchValue(`${vendor.vendorName} (${vendor.vendorCode})`) + setVendorSearchOpen(false) + } + + const handleCreate = () => { + if (!selectedVendor) { + toast({ + title: '오류', + description: '업체를 선택해주세요.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + const response = await createBiddingCompany({ + biddingId, + companyId: selectedVendor.id, + }) + console.log(response) + if (response.success) { + toast({ + title: '성공', + description: response.message, + }) + onOpenChange(false) + resetForm() + onSuccess() + } else { + toast({ + title: '오류', + description: response.error, + variant: 'destructive', + }) + } + }) + } + + const resetForm = () => { + setSelectedVendor(null) + setVendorSearchValue('') + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[600px]"> + <DialogHeader> + <DialogTitle>사전견적 업체 추가</DialogTitle> + <DialogDescription> + 검색해서 업체를 선택해주세요. + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + {/* Vendor 검색 */} + <div className="space-y-2"> + <Label htmlFor="vendor-search">업체 검색</Label> + <Popover open={vendorSearchOpen} onOpenChange={setVendorSearchOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={vendorSearchOpen} + className="w-full justify-between" + > + {selectedVendor + ? `${selectedVendor.vendorName} (${selectedVendor.vendorCode})` + : "업체를 검색해서 선택하세요..."} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput + placeholder="업체명 또는 코드를 입력하세요..." + value={vendorSearchValue} + onValueChange={setVendorSearchValue} + /> + <CommandEmpty> + {vendorSearchValue.length < 2 + ? "최소 2자 이상 입력해주세요" + : "검색 결과가 없습니다"} + </CommandEmpty> + <CommandGroup className="max-h-64 overflow-auto"> + {vendors.map((vendor) => ( + <CommandItem + key={vendor.id} + value={`${vendor.vendorName} ${vendor.vendorCode}`} + onSelect={() => handleVendorSelect(vendor)} + > + <Check + className={cn( + "mr-2 h-4 w-4", + selectedVendor?.id === vendor.id ? "opacity-100" : "opacity-0" + )} + /> + <div className="flex flex-col"> + <span className="font-medium">{vendor.vendorName}</span> + <span className="text-sm text-muted-foreground">{vendor.vendorCode}</span> + </div> + </CommandItem> + ))} + </CommandGroup> + </Command> + </PopoverContent> + </Popover> + </div> + + </div> + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleCreate} disabled={isPending || !selectedVendor}> + 추가 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx new file mode 100644 index 00000000..03bf2ecb --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx @@ -0,0 +1,200 @@ +'use client' + +import * as React from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { updateBiddingCompany } from '../service' +import { BiddingCompany } from './bidding-pre-quote-vendor-columns' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface BiddingPreQuoteVendorEditDialogProps { + company: BiddingCompany | null + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +export function BiddingPreQuoteVendorEditDialog({ + company, + open, + onOpenChange, + onSuccess +}: BiddingPreQuoteVendorEditDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + + // 폼 상태 + const [formData, setFormData] = React.useState({ + contactPerson: '', + contactEmail: '', + contactPhone: '', + preQuoteAmount: 0, + notes: '', + invitationStatus: 'pending' as 'pending' | 'accepted' | 'declined', + isPreQuoteSelected: false, + isAttendingMeeting: false, + }) + + // company가 변경되면 폼 데이터 업데이트 + React.useEffect(() => { + if (company) { + setFormData({ + contactPerson: company.contactPerson || '', + contactEmail: company.contactEmail || '', + contactPhone: company.contactPhone || '', + preQuoteAmount: company.preQuoteAmount ? Number(company.preQuoteAmount) : 0, + notes: company.notes || '', + invitationStatus: company.invitationStatus, + isPreQuoteSelected: company.isPreQuoteSelected, + isAttendingMeeting: company.isAttendingMeeting || false, + }) + } + }, [company]) + + const handleEdit = () => { + if (!company) return + + startTransition(async () => { + const response = await updateBiddingCompany(company.id, formData) + + if (response.success) { + toast({ + title: '성공', + description: response.message, + }) + onOpenChange(false) + onSuccess() + } else { + toast({ + title: '오류', + description: response.error, + variant: 'destructive', + }) + } + }) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[600px]"> + <DialogHeader> + <DialogTitle>사전견적 업체 수정</DialogTitle> + <DialogDescription> + {company?.companyName} 업체의 사전견적 정보를 수정해주세요. + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + <div className="grid grid-cols-3 gap-4"> + <div className="space-y-2"> + <Label htmlFor="edit-contactPerson">담당자</Label> + <Input + id="edit-contactPerson" + value={formData.contactPerson} + onChange={(e) => setFormData({ ...formData, contactPerson: e.target.value })} + /> + </div> + <div className="space-y-2"> + <Label htmlFor="edit-contactEmail">이메일</Label> + <Input + id="edit-contactEmail" + type="email" + value={formData.contactEmail} + onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })} + /> + </div> + <div className="space-y-2"> + <Label htmlFor="edit-contactPhone">연락처</Label> + <Input + id="edit-contactPhone" + value={formData.contactPhone} + onChange={(e) => setFormData({ ...formData, contactPhone: e.target.value })} + /> + </div> + </div> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="edit-preQuoteAmount">사전견적금액</Label> + <Input + id="edit-preQuoteAmount" + type="number" + value={formData.preQuoteAmount} + onChange={(e) => setFormData({ ...formData, preQuoteAmount: Number(e.target.value) })} + /> + </div> + <div className="space-y-2"> + <Label htmlFor="edit-invitationStatus">초대 상태</Label> + <Select value={formData.invitationStatus} onValueChange={(value: any) => setFormData({ ...formData, invitationStatus: value })}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="pending">대기중</SelectItem> + <SelectItem value="accepted">수락</SelectItem> + <SelectItem value="declined">거절</SelectItem> + </SelectContent> + </Select> + </div> + </div> + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-center space-x-2"> + <Checkbox + id="edit-isPreQuoteSelected" + checked={formData.isPreQuoteSelected} + onCheckedChange={(checked) => + setFormData({ ...formData, isPreQuoteSelected: !!checked }) + } + /> + <Label htmlFor="edit-isPreQuoteSelected">본입찰 선정</Label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="edit-isAttendingMeeting" + checked={formData.isAttendingMeeting} + onCheckedChange={(checked) => + setFormData({ ...formData, isAttendingMeeting: !!checked }) + } + /> + <Label htmlFor="edit-isAttendingMeeting">사양설명회 참석</Label> + </div> + </div> + <div className="space-y-2"> + <Label htmlFor="edit-notes">특이사항</Label> + <Textarea + id="edit-notes" + value={formData.notes} + onChange={(e) => setFormData({ ...formData, notes: e.target.value })} + placeholder="특이사항을 입력해주세요..." + /> + </div> + </div> + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleEdit} disabled={isPending}> + 수정 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx new file mode 100644 index 00000000..a9d12629 --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx @@ -0,0 +1,189 @@ +'use client' + +import * as React from 'react' +import { type DataTableAdvancedFilterField, type DataTableFilterField } from '@/types/table' +import { useDataTable } from '@/hooks/use-data-table' +import { DataTable } from '@/components/data-table/data-table' +import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-advanced-toolbar' +import { BiddingPreQuoteVendorToolbarActions } from './bidding-pre-quote-vendor-toolbar-actions' +import { BiddingPreQuoteVendorEditDialog } from './bidding-pre-quote-vendor-edit-dialog' +import { getBiddingPreQuoteVendorColumns, BiddingCompany } from './bidding-pre-quote-vendor-columns' +import { Bidding } from '@/db/schema' +import { + deleteBiddingCompany +} from '../service' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface BiddingPreQuoteVendorTableContentProps { + biddingId: number + bidding: Bidding + vendors: any[] // 사용하지 않음 + biddingCompanies: BiddingCompany[] + onRefresh: () => void + onOpenItemsDialog: () => void + onOpenTargetPriceDialog: () => void + onOpenSelectionReasonDialog: () => void + onEdit?: (company: BiddingCompany) => void + onDelete?: (company: BiddingCompany) => void + onSelectWinner?: (company: BiddingCompany) => void +} + +const filterFields: DataTableFilterField<BiddingCompany>[] = [ + { + id: 'companyName', + label: '업체명', + placeholder: '업체명으로 검색...', + }, + { + id: 'companyCode', + label: '업체코드', + placeholder: '업체코드로 검색...', + }, + { + id: 'contactPerson', + label: '담당자', + placeholder: '담당자로 검색...', + }, +] + +const advancedFilterFields: DataTableAdvancedFilterField<BiddingCompany>[] = [ + { + id: 'companyName', + label: '업체명', + type: 'text', + }, + { + id: 'companyCode', + label: '업체코드', + type: 'text', + }, + { + id: 'contactPerson', + label: '담당자', + type: 'text', + }, + { + id: 'preQuoteAmount', + label: '사전견적금액', + type: 'number', + }, + { + id: 'invitationStatus', + label: '초대 상태', + type: 'multi-select', + options: [ + { label: '수락', value: 'accepted' }, + { label: '거절', value: 'declined' }, + { label: '대기중', value: 'pending' }, + ], + }, +] + +export function BiddingPreQuoteVendorTableContent({ + biddingId, + bidding, + vendors, + biddingCompanies, + onRefresh, + onOpenItemsDialog, + onOpenTargetPriceDialog, + onOpenSelectionReasonDialog, + onEdit, + onDelete, + onSelectWinner +}: BiddingPreQuoteVendorTableContentProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [selectedCompany, setSelectedCompany] = React.useState<BiddingCompany | null>(null) + const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false) + + const handleDelete = (company: BiddingCompany) => { + if (!confirm(`${company.companyName} 업체를 삭제하시겠습니까?`)) return + + startTransition(async () => { + const response = await deleteBiddingCompany(company.id) + + if (response.success) { + toast({ + title: '성공', + description: response.message, + }) + onRefresh() + } else { + toast({ + title: '오류', + description: response.error, + variant: 'destructive', + }) + } + }) + } + + const handleEdit = (company: BiddingCompany) => { + setSelectedCompany(company) + setIsEditDialogOpen(true) + } + + const handleInvite = (company: BiddingCompany) => { + // TODO: 초대 발송 로직 구현 + toast({ + title: '알림', + description: `${company.companyName} 업체에 초대를 발송했습니다.`, + }) + } + + const columns = React.useMemo( + () => getBiddingPreQuoteVendorColumns({ + onEdit: onEdit || handleEdit, + onDelete: onDelete || handleDelete, + onInvite: handleInvite + }), + [onEdit, onDelete, handleEdit, handleDelete, handleInvite] + ) + + const { table } = useDataTable({ + data: biddingCompanies, + columns, + pageCount: 1, + filterFields, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: 'companyName', desc: false }], + columnPinning: { right: ['actions'] }, + }, + getRowId: (originalRow) => originalRow.id.toString(), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <BiddingPreQuoteVendorToolbarActions + table={table} + biddingId={biddingId} + bidding={bidding} + biddingCompanies={biddingCompanies} + onOpenItemsDialog={onOpenItemsDialog} + onOpenTargetPriceDialog={onOpenTargetPriceDialog} + onOpenSelectionReasonDialog={onOpenSelectionReasonDialog} + onSuccess={onRefresh} + /> + </DataTableAdvancedToolbar> + </DataTable> + + <BiddingPreQuoteVendorEditDialog + company={selectedCompany} + open={isEditDialogOpen} + onOpenChange={setIsEditDialogOpen} + onSuccess={onRefresh} + /> + </> + ) +} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx new file mode 100644 index 00000000..c1b1baa5 --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx @@ -0,0 +1,92 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { useTransition } from "react" +import { Button } from "@/components/ui/button" +import { Plus, Send, Mail } from "lucide-react" +import { BiddingCompany } from "./bidding-pre-quote-vendor-columns" +import { BiddingPreQuoteVendorCreateDialog } from "./bidding-pre-quote-vendor-create-dialog" +import { BiddingPreQuoteInvitationDialog } from "./bidding-pre-quote-invitation-dialog" +import { Bidding } from "@/db/schema" +import { useToast } from "@/hooks/use-toast" + +interface BiddingPreQuoteVendorToolbarActionsProps { + table: Table<BiddingCompany> + biddingId: number + bidding: Bidding + biddingCompanies: BiddingCompany[] + onOpenItemsDialog: () => void + onOpenTargetPriceDialog: () => void + onOpenSelectionReasonDialog: () => void + onSuccess: () => void +} + +export function BiddingPreQuoteVendorToolbarActions({ + table, + biddingId, + bidding, + biddingCompanies, + onOpenItemsDialog, + onOpenTargetPriceDialog, + onOpenSelectionReasonDialog, + onSuccess +}: BiddingPreQuoteVendorToolbarActionsProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false) + const [isInvitationDialogOpen, setIsInvitationDialogOpen] = React.useState(false) + + const handleCreateCompany = () => { + setIsCreateDialogOpen(true) + } + + const handleSendInvitations = () => { + setIsInvitationDialogOpen(true) + } + + + + return ( + <> + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={handleCreateCompany} + disabled={isPending} + > + <Plus className="mr-2 h-4 w-4" /> + 업체 추가 + </Button> + + <Button + variant="default" + size="sm" + onClick={handleSendInvitations} + disabled={isPending} + > + <Mail className="mr-2 h-4 w-4" /> + 초대 발송 + </Button> + </div> + + <BiddingPreQuoteVendorCreateDialog + biddingId={biddingId} + open={isCreateDialogOpen} + onOpenChange={setIsCreateDialogOpen} + onSuccess={() => { + onSuccess() + setIsCreateDialogOpen(false) + }} + /> + + <BiddingPreQuoteInvitationDialog + open={isInvitationDialogOpen} + onOpenChange={setIsInvitationDialogOpen} + companies={biddingCompanies} + onSuccess={onSuccess} + /> + </> + ) +} diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index fc96ddfe..ef404561 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -1115,7 +1115,7 @@ export async function getPRDetailsAction( itemInfo: item.itemInfo, quantity: item.quantity ? Number(item.quantity) : null, quantityUnit: item.quantityUnit, - requestedDeliveryDate: item.requestedDeliveryDate?.toISOString().split('T')[0] || null, + requestedDeliveryDate: item.requestedDeliveryDate || null, prNumber: item.prNumber, annualUnitPrice: item.annualUnitPrice ? Number(item.annualUnitPrice) : null, currency: item.currency, diff --git a/lib/bidding/vendor/components/pr-items-pricing-dialog.tsx b/lib/bidding/vendor/components/pr-items-pricing-dialog.tsx new file mode 100644 index 00000000..ff0dfd9c --- /dev/null +++ b/lib/bidding/vendor/components/pr-items-pricing-dialog.tsx @@ -0,0 +1,384 @@ +'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 { Badge } from '@/components/ui/badge' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, +} from '@/components/ui/dialog' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Package, + FileText, + Download, + Calculator, + Save +} from 'lucide-react' +import { formatDate } from '@/lib/utils' +import { downloadFile } from '@/lib/file-download' +import { getSpecDocumentsForPrItem } from '../../pre-quote/service' +import { useToast } from '@/hooks/use-toast' + +interface PrItem { + id: number + itemNumber: string | null + prNumber: string | null + itemInfo: string | null + materialDescription: string | null + quantity: string | null + quantityUnit: string | null + currency: string | null + requestedDeliveryDate: string | null + hasSpecDocument: boolean | null +} + +interface PrItemQuotation { + prItemId: number + bidUnitPrice: number + bidAmount: number + proposedDeliveryDate?: string + technicalSpecification?: string +} + +interface SpecDocument { + id: number + fileName: string + originalFileName: string + fileSize: number | null + filePath: string + title: string | null + description: string | null + uploadedAt: string +} + +interface PrItemsPricingDialogProps { + prItems: PrItem[] + initialQuotations?: PrItemQuotation[] + currency?: string + onSave: (quotations: PrItemQuotation[], totalAmount: number) => void + readOnly?: boolean + children: React.ReactNode +} + +export function PrItemsPricingDialog({ + prItems, + initialQuotations = [], + currency = 'KRW', + onSave, + readOnly = false, + children +}: PrItemsPricingDialogProps) { + const { toast } = useToast() + const [open, setOpen] = React.useState(false) + const [quotations, setQuotations] = React.useState<PrItemQuotation[]>([]) + const [specDocuments, setSpecDocuments] = React.useState<Record<number, SpecDocument[]>>({}) + const [loadingSpecs, setLoadingSpecs] = React.useState<Record<number, boolean>>({}) + + // 다이얼로그 열릴 때 초기 견적 데이터 설정 + React.useEffect(() => { + if (open) { + const initQuotations = prItems.map(item => { + const existing = initialQuotations.find(q => q.prItemId === item.id) + if (existing) { + return existing + } + return { + prItemId: item.id, + bidUnitPrice: 0, + bidAmount: 0, + proposedDeliveryDate: '', + technicalSpecification: '' + } + }) + setQuotations(initQuotations) + } + }, [open, prItems, initialQuotations]) + + // SPEC 문서 로드 + const loadSpecDocuments = async (prItemId: number) => { + if (loadingSpecs[prItemId]) return + + setLoadingSpecs(prev => ({ ...prev, [prItemId]: true })) + try { + const docs = await getSpecDocumentsForPrItem(prItemId) + // Date를 string으로 변환 + const mappedDocs = docs.map(doc => ({ + ...doc, + uploadedAt: doc.uploadedAt.toString() + })) + setSpecDocuments(prev => ({ ...prev, [prItemId]: mappedDocs })) + } catch (error) { + console.error('Failed to load spec documents:', error) + } finally { + setLoadingSpecs(prev => ({ ...prev, [prItemId]: false })) + } + } + + // 견적 데이터 업데이트 + const updateQuotation = (prItemId: number, field: keyof PrItemQuotation, value: any) => { + const updatedQuotations = quotations.map(q => { + if (q.prItemId === prItemId) { + const updated = { ...q, [field]: value } + + // 단가가 변경되면 금액 자동 계산 + if (field === 'bidUnitPrice') { + const prItem = prItems.find(item => item.id === prItemId) + const quantity = parseFloat(prItem?.quantity || '1') + updated.bidAmount = updated.bidUnitPrice * quantity + } + + return updated + } + return q + }) + + setQuotations(updatedQuotations) + } + + // 파일 다운로드 + const handleDownloadSpec = async (document: SpecDocument) => { + try { + await downloadFile(document.filePath, document.originalFileName, { + showToast: true + }) + } catch (error) { + console.error('Failed to download spec document:', error) + } + } + + // 저장 처리 + const handleSave = () => { + const totalAmount = quotations.reduce((sum, q) => sum + q.bidAmount, 0) + onSave(quotations, totalAmount) + setOpen(false) + toast({ + title: '저장 완료', + description: '품목별 견적이 저장되었습니다.', + }) + } + + // 통화 포맷팅 + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: currency, + }).format(amount) + } + + // 총 금액 계산 + const totalAmount = quotations.reduce((sum, q) => sum + q.bidAmount, 0) + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + {children} + </DialogTrigger> + <DialogContent className="max-w-7xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Package className="w-5 h-5" /> + 품목별 견적 작성 + </DialogTitle> + <DialogDescription> + 각 품목별로 견적 단가를 입력하여 총 사전견적 금액을 계산합니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div className="overflow-x-auto"> + <Table> + <TableHeader> + <TableRow> + <TableHead>아이템번호</TableHead> + <TableHead>PR번호</TableHead> + <TableHead>품목정보</TableHead> + <TableHead>자재내역</TableHead> + <TableHead>수량</TableHead> + <TableHead>단위</TableHead> + <TableHead>견적단가</TableHead> + <TableHead>견적금액</TableHead> + <TableHead>납품예정일</TableHead> + <TableHead>기술사양</TableHead> + <TableHead>SPEC</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {prItems.map((item) => { + const quotation = quotations.find(q => q.prItemId === item.id) || { + prItemId: item.id, + bidUnitPrice: 0, + bidAmount: 0, + proposedDeliveryDate: '', + technicalSpecification: '' + } + + return ( + <TableRow key={item.id}> + <TableCell className="font-medium"> + {item.itemNumber || '-'} + </TableCell> + <TableCell>{item.prNumber || '-'}</TableCell> + <TableCell> + <div className="max-w-32 truncate" title={item.itemInfo || ''}> + {item.itemInfo || '-'} + </div> + </TableCell> + <TableCell> + <div className="max-w-32 truncate" title={item.materialDescription || ''}> + {item.materialDescription || '-'} + </div> + </TableCell> + <TableCell className="text-right"> + {item.quantity ? parseFloat(item.quantity).toLocaleString() : '-'} + </TableCell> + <TableCell>{item.quantityUnit || '-'}</TableCell> + <TableCell> + {readOnly ? ( + <span className="font-medium"> + {quotation.bidUnitPrice.toLocaleString()} + </span> + ) : ( + <Input + type="number" + value={quotation.bidUnitPrice} + onChange={(e) => updateQuotation( + item.id, + 'bidUnitPrice', + parseFloat(e.target.value) || 0 + )} + className="w-32 text-right" + placeholder="단가" + /> + )} + </TableCell> + <TableCell> + <div className="font-semibold text-primary"> + {formatCurrency(quotation.bidAmount)} + </div> + </TableCell> + <TableCell> + {readOnly ? ( + quotation.proposedDeliveryDate ? + formatDate(quotation.proposedDeliveryDate, 'KR') : '-' + ) : ( + <Input + type="date" + value={quotation.proposedDeliveryDate} + onChange={(e) => updateQuotation( + item.id, + 'proposedDeliveryDate', + e.target.value + )} + className="w-40" + /> + )} + </TableCell> + <TableCell> + {readOnly ? ( + <div className="max-w-32 truncate" title={quotation.technicalSpecification || ''}> + {quotation.technicalSpecification || '-'} + </div> + ) : ( + <Textarea + value={quotation.technicalSpecification} + onChange={(e) => updateQuotation( + item.id, + 'technicalSpecification', + e.target.value + )} + placeholder="기술사양 입력" + className="w-48 min-h-[60px]" + rows={2} + /> + )} + </TableCell> + <TableCell> + {item.hasSpecDocument ? ( + <div className="space-y-1"> + {!specDocuments[item.id] ? ( + <Button + variant="outline" + size="sm" + onClick={() => loadSpecDocuments(item.id)} + disabled={loadingSpecs[item.id]} + > + <FileText className="w-3 h-3 mr-1" /> + {loadingSpecs[item.id] ? '로딩...' : 'SPEC 보기'} + </Button> + ) : specDocuments[item.id].length > 0 ? ( + <div className="space-y-1"> + {specDocuments[item.id].map((doc) => ( + <Button + key={doc.id} + variant="outline" + size="sm" + onClick={() => handleDownloadSpec(doc)} + className="block text-xs" + > + <Download className="w-3 h-3 mr-1" /> + {doc.originalFileName} + </Button> + ))} + </div> + ) : ( + <Badge variant="secondary">문서 없음</Badge> + )} + </div> + ) : ( + <Badge variant="outline">SPEC 없음</Badge> + )} + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + </div> + + {/* 총 금액 표시 */} + <div className="flex justify-end border-t pt-4"> + <div className="bg-gray-50 rounded-lg p-4 min-w-80"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Calculator className="w-5 h-5 text-primary" /> + <Label className="font-semibold text-lg">총 사전견적 금액</Label> + </div> + <div className="text-2xl font-bold text-primary"> + {formatCurrency(totalAmount)} + </div> + </div> + </div> + </div> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => setOpen(false)}> + 취소 + </Button> + {!readOnly && ( + <Button onClick={handleSave}> + <Save className="w-4 h-4 mr-2" /> + 저장하기 + </Button> + )} + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx new file mode 100644 index 00000000..320ed6eb --- /dev/null +++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx @@ -0,0 +1,347 @@ +'use client' + +import * as React from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { Badge } from '@/components/ui/badge' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Package, + FileText, + Download, + Calculator +} from 'lucide-react' +import { formatDate } from '@/lib/utils' +import { downloadFile } from '@/lib/file-download' +import { getSpecDocumentsForPrItem } from '../../pre-quote/service' + +interface PrItem { + id: number + itemNumber: string | null + prNumber: string | null + itemInfo: string | null + materialDescription: string | null + quantity: string | null + quantityUnit: string | null + currency: string | null + requestedDeliveryDate: string | null + hasSpecDocument: boolean | null +} + +interface PrItemQuotation { + prItemId: number + bidUnitPrice: number + bidAmount: number + proposedDeliveryDate?: string + technicalSpecification?: string +} + +interface SpecDocument { + id: number + fileName: string + originalFileName: string + fileSize: number | null + filePath: string + title: string | null + description: string | null + uploadedAt: string +} + +interface PrItemsPricingTableProps { + prItems: PrItem[] + initialQuotations?: PrItemQuotation[] + currency?: string + onQuotationsChange: (quotations: PrItemQuotation[]) => void + onTotalAmountChange: (total: number) => void + readOnly?: boolean +} + +export function PrItemsPricingTable({ + prItems, + initialQuotations = [], + currency = 'KRW', + onQuotationsChange, + onTotalAmountChange, + readOnly = false +}: PrItemsPricingTableProps) { + const [quotations, setQuotations] = React.useState<PrItemQuotation[]>([]) + const [specDocuments, setSpecDocuments] = React.useState<Record<number, SpecDocument[]>>({}) + const [loadingSpecs, setLoadingSpecs] = React.useState<Record<number, boolean>>({}) + + // 초기 견적 데이터 설정 + React.useEffect(() => { + const initQuotations = prItems.map(item => { + const existing = initialQuotations.find(q => q.prItemId === item.id) + if (existing) { + return existing + } + return { + prItemId: item.id, + bidUnitPrice: 0, + bidAmount: 0, + proposedDeliveryDate: '', + technicalSpecification: '' + } + }) + setQuotations(initQuotations) + }, [prItems, initialQuotations]) + + // SPEC 문서 로드 + const loadSpecDocuments = async (prItemId: number) => { + if (loadingSpecs[prItemId]) return + + setLoadingSpecs(prev => ({ ...prev, [prItemId]: true })) + try { + const docs = await getSpecDocumentsForPrItem(prItemId) + // Date를 string으로 변환 + const mappedDocs = docs.map(doc => ({ + ...doc, + uploadedAt: doc.uploadedAt.toString() + })) + setSpecDocuments(prev => ({ ...prev, [prItemId]: mappedDocs })) + } catch (error) { + console.error('Failed to load spec documents:', error) + } finally { + setLoadingSpecs(prev => ({ ...prev, [prItemId]: false })) + } + } + + // 견적 데이터 업데이트 + const updateQuotation = (prItemId: number, field: keyof PrItemQuotation, value: any) => { + const updatedQuotations = quotations.map(q => { + if (q.prItemId === prItemId) { + const updated = { ...q, [field]: value } + + // 단가나 수량이 변경되면 금액 자동 계산 + if (field === 'bidUnitPrice') { + const prItem = prItems.find(item => item.id === prItemId) + const quantity = parseFloat(prItem?.quantity || '1') + updated.bidAmount = updated.bidUnitPrice * quantity + } + + return updated + } + return q + }) + + setQuotations(updatedQuotations) + onQuotationsChange(updatedQuotations) + + // 총 금액 계산 + const totalAmount = updatedQuotations.reduce((sum, q) => sum + q.bidAmount, 0) + onTotalAmountChange(totalAmount) + } + + // 파일 다운로드 + const handleDownloadSpec = async (document: SpecDocument) => { + try { + await downloadFile(document.filePath, document.originalFileName, { + showToast: true + }) + } catch (error) { + console.error('Failed to download spec document:', error) + } + } + + // 통화 포맷팅 + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: currency, + }).format(amount) + } + + // 총 금액 계산 + const totalAmount = quotations.reduce((sum, q) => sum + q.bidAmount, 0) + + return ( + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Package className="w-5 h-5" /> + 품목별 견적 작성 + </CardTitle> + </CardHeader> + <CardContent> + <div className="space-y-4"> + <div className="overflow-x-auto"> + <Table> + <TableHeader> + <TableRow> + <TableHead>아이템번호</TableHead> + <TableHead>PR번호</TableHead> + <TableHead>품목정보</TableHead> + <TableHead>자재내역</TableHead> + <TableHead>수량</TableHead> + <TableHead>단위</TableHead> + <TableHead>견적단가</TableHead> + <TableHead>견적금액</TableHead> + <TableHead>납품예정일</TableHead> + <TableHead>기술사양</TableHead> + <TableHead>SPEC</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {prItems.map((item) => { + const quotation = quotations.find(q => q.prItemId === item.id) || { + prItemId: item.id, + bidUnitPrice: 0, + bidAmount: 0, + proposedDeliveryDate: '', + technicalSpecification: '' + } + + return ( + <TableRow key={item.id}> + <TableCell className="font-medium"> + {item.itemNumber || '-'} + </TableCell> + <TableCell>{item.prNumber || '-'}</TableCell> + <TableCell> + <div className="max-w-32 truncate" title={item.itemInfo || ''}> + {item.itemInfo || '-'} + </div> + </TableCell> + <TableCell> + <div className="max-w-32 truncate" title={item.materialDescription || ''}> + {item.materialDescription || '-'} + </div> + </TableCell> + <TableCell className="text-right"> + {item.quantity ? parseFloat(item.quantity).toLocaleString() : '-'} + </TableCell> + <TableCell>{item.quantityUnit || '-'}</TableCell> + <TableCell> + {readOnly ? ( + <span className="font-medium"> + {quotation.bidUnitPrice.toLocaleString()} + </span> + ) : ( + <Input + type="number" + value={quotation.bidUnitPrice} + onChange={(e) => updateQuotation( + item.id, + 'bidUnitPrice', + parseFloat(e.target.value) || 0 + )} + className="w-32 text-right" + placeholder="단가" + /> + )} + </TableCell> + <TableCell> + <div className="font-semibold text-primary"> + {formatCurrency(quotation.bidAmount)} + </div> + </TableCell> + <TableCell> + {readOnly ? ( + quotation.proposedDeliveryDate ? + formatDate(quotation.proposedDeliveryDate, 'KR') : '-' + ) : ( + <Input + type="date" + value={quotation.proposedDeliveryDate} + onChange={(e) => updateQuotation( + item.id, + 'proposedDeliveryDate', + e.target.value + )} + className="w-40" + /> + )} + </TableCell> + <TableCell> + {readOnly ? ( + <div className="max-w-32 truncate" title={quotation.technicalSpecification || ''}> + {quotation.technicalSpecification || '-'} + </div> + ) : ( + <Textarea + value={quotation.technicalSpecification} + onChange={(e) => updateQuotation( + item.id, + 'technicalSpecification', + e.target.value + )} + placeholder="기술사양 입력" + className="w-48 min-h-[60px]" + rows={2} + /> + )} + </TableCell> + <TableCell> + {item.hasSpecDocument ? ( + <div className="space-y-1"> + {!specDocuments[item.id] ? ( + <Button + variant="outline" + size="sm" + onClick={() => loadSpecDocuments(item.id)} + disabled={loadingSpecs[item.id]} + > + <FileText className="w-3 h-3 mr-1" /> + {loadingSpecs[item.id] ? '로딩...' : 'SPEC 보기'} + </Button> + ) : specDocuments[item.id].length > 0 ? ( + <div className="space-y-1"> + {specDocuments[item.id].map((doc) => ( + <Button + key={doc.id} + variant="outline" + size="sm" + onClick={() => handleDownloadSpec(doc)} + className="block text-xs" + > + <Download className="w-3 h-3 mr-1" /> + {doc.originalFileName} + </Button> + ))} + </div> + ) : ( + <Badge variant="secondary">문서 없음</Badge> + )} + </div> + ) : ( + <Badge variant="outline">SPEC 없음</Badge> + )} + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + </div> + + {/* 총 금액 표시 */} + <div className="flex justify-end"> + <Card className="w-80"> + <CardContent className="pt-4"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Calculator className="w-4 h-4" /> + <Label className="font-semibold">총 사전견적 금액</Label> + </div> + <div className="text-2xl font-bold text-primary"> + {formatCurrency(totalAmount)} + </div> + </div> + </CardContent> + </Card> + </div> + </div> + </CardContent> + </Card> + ) +} diff --git a/lib/bidding/vendor/components/pre-quote-file-upload.tsx b/lib/bidding/vendor/components/pre-quote-file-upload.tsx new file mode 100644 index 00000000..b6d8990b --- /dev/null +++ b/lib/bidding/vendor/components/pre-quote-file-upload.tsx @@ -0,0 +1,367 @@ +'use client' + +import * as React from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { Progress } from '@/components/ui/progress' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Upload, + FileText, + Download, + Trash2, + AlertCircle +} from 'lucide-react' +import { useToast } from '@/hooks/use-toast' +import { saveFile } from '@/lib/file-stroage' +import { downloadFile } from '@/lib/file-download' +import { + uploadPreQuoteDocument, + getPreQuoteDocuments +} from '../../pre-quote/service' + +interface UploadedDocument { + id: number + fileName: string + originalFileName: string + fileSize: number | null + filePath: string + title: string | null + description: string | null + uploadedAt: string +} + +interface PreQuoteFileUploadProps { + biddingId: number + companyId: number + onUploadComplete?: (documentId: number) => void + readOnly?: boolean +} + +export function PreQuoteFileUpload({ + biddingId, + companyId, + onUploadComplete, + readOnly = false +}: PreQuoteFileUploadProps) { + const { toast } = useToast() + const [documents, setDocuments] = React.useState<UploadedDocument[]>([]) + const [isUploading, setIsUploading] = React.useState(false) + const [uploadProgress, setUploadProgress] = React.useState(0) + const [dragActive, setDragActive] = React.useState(false) + + // 업로드된 문서 목록 로드 + const loadDocuments = React.useCallback(async () => { + try { + const docs = await getPreQuoteDocuments(biddingId, companyId) + // Date를 string으로 변환 + const mappedDocs = docs.map(doc => ({ + ...doc, + uploadedAt: doc.uploadedAt.toString() + })) + setDocuments(mappedDocs) + } catch (error) { + console.error('Failed to load documents:', error) + toast({ + title: '오류', + description: '업로드된 문서 목록을 불러오는데 실패했습니다.', + variant: 'destructive', + }) + } + }, [biddingId, companyId, toast]) + + React.useEffect(() => { + loadDocuments() + }, [loadDocuments]) + + // 파일 업로드 처리 + const handleFileUpload = async (files: FileList | File[]) => { + if (readOnly) return + + const fileArray = Array.from(files) + if (fileArray.length === 0) return + + setIsUploading(true) + setUploadProgress(0) + + try { + for (let i = 0; i < fileArray.length; i++) { + const file = fileArray[i] + + // 파일 크기 체크 (50MB 제한) + if (file.size > 50 * 1024 * 1024) { + toast({ + title: '파일 크기 초과', + description: `${file.name}의 크기가 50MB를 초과합니다.`, + variant: 'destructive', + }) + continue + } + + // 파일 타입 체크 + const allowedTypes = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'image/jpeg', + 'image/png', + 'application/zip' + ] + + if (!allowedTypes.includes(file.type)) { + toast({ + title: '지원하지 않는 파일 형식', + description: `${file.name}: PDF, Word, Excel, 이미지, ZIP 파일만 업로드 가능합니다.`, + variant: 'destructive', + }) + continue + } + + // 파일 저장 + const saveResult = await saveFile({ + file, + directory: `bidding/${biddingId}/quotations`, + originalName: file.name, + userId: 'current-user' // TODO: 실제 사용자 ID + }) + + if (!saveResult.success) { + toast({ + title: '업로드 실패', + description: `${file.name}: ${saveResult.error}`, + variant: 'destructive', + }) + continue + } + + // 데이터베이스에 문서 정보 저장 + const uploadResult = await uploadPreQuoteDocument( + biddingId, + companyId, + { + fileName: saveResult.fileName!, + originalFileName: file.name, + fileSize: file.size, + mimeType: file.type, + filePath: saveResult.path! + }, + 'current-user' // TODO: 실제 사용자 ID + ) + + if (uploadResult.success) { + toast({ + title: '업로드 완료', + description: `${file.name}이 성공적으로 업로드되었습니다.`, + }) + + if (onUploadComplete && uploadResult.documentId) { + onUploadComplete(uploadResult.documentId) + } + } else { + toast({ + title: '업로드 실패', + description: uploadResult.error, + variant: 'destructive', + }) + } + + // 진행률 업데이트 + setUploadProgress(((i + 1) / fileArray.length) * 100) + } + + // 문서 목록 새로고침 + await loadDocuments() + + } catch (error) { + console.error('Upload error:', error) + toast({ + title: '업로드 오류', + description: '파일 업로드 중 오류가 발생했습니다.', + variant: 'destructive', + }) + } finally { + setIsUploading(false) + setUploadProgress(0) + } + } + + // 드래그 앤 드롭 처리 + const handleDrag = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (e.type === 'dragenter' || e.type === 'dragover') { + setDragActive(true) + } else if (e.type === 'dragleave') { + setDragActive(false) + } + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDragActive(false) + + if (readOnly) return + + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + handleFileUpload(e.dataTransfer.files) + } + } + + // 파일 다운로드 + const handleDownload = async (document: UploadedDocument) => { + try { + await downloadFile(document.filePath, document.originalFileName, { + showToast: true + }) + } catch (error) { + console.error('Failed to download document:', error) + toast({ + title: '다운로드 실패', + description: '파일 다운로드에 실패했습니다.', + 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 ( + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileText className="w-5 h-5" /> + 견적 문서 업로드 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {!readOnly && ( + <div + className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${ + dragActive + ? 'border-primary bg-primary/5' + : 'border-gray-300 hover:border-gray-400' + }`} + onDragEnter={handleDrag} + onDragLeave={handleDrag} + onDragOver={handleDrag} + onDrop={handleDrop} + > + <Upload className="w-8 h-8 mx-auto text-gray-400 mb-2" /> + <div className="space-y-2"> + <p className="text-sm text-gray-600"> + 파일을 드래그하여 업로드하거나 클릭하여 선택하세요 + </p> + <Input + type="file" + multiple + accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.zip" + onChange={(e) => e.target.files && handleFileUpload(e.target.files)} + className="hidden" + id="file-upload" + /> + <Label htmlFor="file-upload"> + <Button variant="outline" className="cursor-pointer" asChild> + <span>파일 선택</span> + </Button> + </Label> + </div> + <p className="text-xs text-gray-500 mt-2"> + 지원 형식: PDF, Word, Excel, 이미지, ZIP (최대 50MB) + </p> + </div> + )} + + {isUploading && ( + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <Upload className="w-4 h-4 animate-pulse" /> + <span className="text-sm">업로드 중...</span> + </div> + <Progress value={uploadProgress} className="h-2" /> + </div> + )} + + {/* 업로드된 문서 목록 */} + {documents.length > 0 ? ( + <div className="space-y-2"> + <Label className="text-sm font-medium">업로드된 문서</Label> + <Table> + <TableHeader> + <TableRow> + <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"> + {new Date(doc.uploadedAt).toLocaleDateString('ko-KR')} + </TableCell> + <TableCell> + <Button + variant="outline" + size="sm" + onClick={() => handleDownload(doc)} + > + <Download className="w-3 h-3" /> + </Button> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + ) : ( + <div className="text-center py-4 text-gray-500"> + <FileText className="w-8 h-8 mx-auto mb-2 opacity-50" /> + <p className="text-sm">업로드된 문서가 없습니다</p> + </div> + )} + + {readOnly && documents.length === 0 && ( + <div className="flex items-center gap-2 p-3 bg-yellow-50 border border-yellow-200 rounded-md"> + <AlertCircle className="w-4 h-4 text-yellow-600" /> + <span className="text-sm text-yellow-800"> + 견적 문서가 업로드되지 않았습니다. + </span> + </div> + )} + </CardContent> + </Card> + ) +} diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx index 41cc329f..04575550 100644 --- a/lib/bidding/vendor/partners-bidding-list-columns.tsx +++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx @@ -17,7 +17,8 @@ import { FileText, MoreHorizontal, Calendar, - User + User, + Calculator } from 'lucide-react' import { formatDate } from '@/lib/utils' import { biddingStatusLabels, contractTypeLabels } from '@/db/schema' @@ -113,8 +114,20 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL } } + const handlePreQuote = () => { + if (setRowAction) { + setRowAction({ + type: 'pre-quote', + row: { original: row.original } + }) + } + } + const canManageAttendance = row.original.invitationStatus === 'sent' || row.original.invitationStatus === 'accepted' + + // 사전견적이 가능한 조건: 초대 발송(sent) 상태인 경우 + const canDoPreQuote = row.original.invitationStatus === 'sent' return ( <DropdownMenu> @@ -132,6 +145,12 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL <FileText className="mr-2 h-4 w-4" /> 상세보기 </DropdownMenuItem> + {canDoPreQuote && ( + <DropdownMenuItem onClick={handlePreQuote}> + <Calculator className="mr-2 h-4 w-4" /> + 사전견적하기 + </DropdownMenuItem> + )} {canManageAttendance && ( <DropdownMenuItem onClick={handleAttendance}> <Users className="mr-2 h-4 w-4" /> diff --git a/lib/bidding/vendor/partners-bidding-list.tsx b/lib/bidding/vendor/partners-bidding-list.tsx index aa185c3a..a13334ef 100644 --- a/lib/bidding/vendor/partners-bidding-list.tsx +++ b/lib/bidding/vendor/partners-bidding-list.tsx @@ -15,6 +15,9 @@ import { getPartnersBiddingListColumns } from './partners-bidding-list-columns' import { getBiddingListForPartners, PartnersBiddingListItem } from '../detail/service' import { PartnersBiddingToolbarActions } from './partners-bidding-toolbar-actions' import { PartnersBiddingAttendanceDialog } from './partners-bidding-attendance-dialog' +import { PartnersBiddingParticipationDialog } from './partners-bidding-participation-dialog' +import { VendorPreQuoteParticipationDialog } from './vendor-prequote-participation-dialog' +import { setPreQuoteParticipation, getBiddingCompaniesForPartners } from '../pre-quote/service' interface PartnersBiddingListProps { companyId: number @@ -24,10 +27,59 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) { const [data, setData] = React.useState<PartnersBiddingListItem[]>([]) const [pageCount, setPageCount] = React.useState<number>(1) const [isLoading, setIsLoading] = React.useState(true) - const [rowAction, setRowAction] = React.useState<DataTableRowAction<PartnersBiddingListItem> | null>(null) + const [rowAction, setRowAction] = React.useState<{ type: string; row: { original: PartnersBiddingListItem } } | null>(null) + const [isParticipationDialogOpen, setIsParticipationDialogOpen] = React.useState(false) + const [selectedBiddingForParticipation, setSelectedBiddingForParticipation] = React.useState<PartnersBiddingListItem | null>(null) + const [isPreQuoteParticipationDialogOpen, setIsPreQuoteParticipationDialogOpen] = React.useState(false) + const [selectedBiddingForPreQuoteParticipation, setSelectedBiddingForPreQuoteParticipation] = React.useState<any | null>(null) const router = useRouter() + // 데이터 새로고침 함수 + const refreshData = React.useCallback(async () => { + try { + setIsLoading(true) + const result = await getBiddingListForPartners(companyId) + setData(result) + } catch (error) { + console.error('Failed to refresh bidding list:', error) + } finally { + setIsLoading(false) + } + }, [companyId]) + + // 사전견적 참여의사 결정을 위한 상세 데이터 로드 + const loadBiddingDetailForParticipation = React.useCallback(async (bidding: PartnersBiddingListItem) => { + try { + const biddingDetail = await getBiddingCompaniesForPartners(bidding.biddingId, companyId) + if (biddingDetail) { + setSelectedBiddingForPreQuoteParticipation(biddingDetail) + setIsPreQuoteParticipationDialogOpen(true) + } + } catch (error) { + console.error('Failed to load bidding detail for participation:', error) + } + }, [companyId]) + + // 사전견적 참여의사 결정 핸들러 + const handlePreQuoteParticipationDecision = React.useCallback(async (participate: boolean) => { + if (!selectedBiddingForPreQuoteParticipation?.biddingCompanyId) { + throw new Error('업체 정보를 찾을 수 없습니다.') + } + + const result = await setPreQuoteParticipation( + selectedBiddingForPreQuoteParticipation.biddingCompanyId, + participate, + 'current-user' // TODO: 실제 사용자 ID + ) + + if (result.success) { + await refreshData() // 데이터 새로고침 + } else { + throw new Error(result.error) + } + }, [selectedBiddingForPreQuoteParticipation?.biddingCompanyId, refreshData]) + // 데이터 로드 React.useEffect(() => { const loadData = async () => { @@ -47,7 +99,7 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) { loadData() }, [companyId]) - // rowAction 변경 감지하여 해당 페이지로 이동 + // rowAction 변경 감지하여 해당 페이지로 이동 또는 다이얼로그 열기 React.useEffect(() => { if (rowAction) { switch (rowAction.type) { @@ -55,11 +107,20 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) { // 상세 페이지로 이동 (biddingId 사용) router.push(`/partners/bid/${rowAction.row.original.biddingId}`) break + case 'pre-quote': + // 사전견적 페이지로 이동 + router.push(`/partners/bid/${rowAction.row.original.biddingId}/pre-quote`) + break + case 'participation': + // 사전견적 참여 의사 결정 다이얼로그 열기 - 상세 데이터 로드 필요 + loadBiddingDetailForParticipation(rowAction.row.original) + setRowAction(null) // rowAction 초기화 + break default: break } } - }, [rowAction, router]) + }, [rowAction, router, loadBiddingDetailForParticipation]) const columns = React.useMemo( () => getPartnersBiddingListColumns({ setRowAction }), @@ -135,19 +196,6 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) { clearOnDefault: true, }) - // 데이터 새로고침 함수 - const refreshData = React.useCallback(async () => { - try { - setIsLoading(true) - const result = await getBiddingListForPartners(companyId) - setData(result) - } catch (error) { - console.error('Failed to refresh bidding list:', error) - } finally { - setIsLoading(false) - } - }, [companyId]) - if (isLoading) { return ( <div className="flex items-center justify-center py-12"> @@ -167,7 +215,7 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) { filterFields={advancedFilterFields} shallow={false} > - <PartnersBiddingToolbarActions table={table} onRefresh={refreshData} setRowAction={setRowAction} /> + <PartnersBiddingToolbarActions table={table} companyId={companyId} onRefresh={refreshData} setRowAction={setRowAction} /> </DataTableAdvancedToolbar> </DataTable> @@ -186,6 +234,29 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) { isAttending={rowAction?.row.original?.isAttendingMeeting || null} onSuccess={refreshData} /> + + <PartnersBiddingParticipationDialog + open={isParticipationDialogOpen} + onOpenChange={setIsParticipationDialogOpen} + bidding={selectedBiddingForParticipation} + companyId={companyId} + onSuccess={() => { + refreshData() + setSelectedBiddingForParticipation(null) + }} + /> + + <VendorPreQuoteParticipationDialog + open={isPreQuoteParticipationDialogOpen} + onOpenChange={(open) => { + setIsPreQuoteParticipationDialogOpen(open) + if (!open) { + setSelectedBiddingForPreQuoteParticipation(null) + } + }} + biddingDetail={selectedBiddingForPreQuoteParticipation} + onParticipationDecision={handlePreQuoteParticipationDecision} + /> </> ) } diff --git a/lib/bidding/vendor/partners-bidding-participation-dialog.tsx b/lib/bidding/vendor/partners-bidding-participation-dialog.tsx new file mode 100644 index 00000000..8d6fbeea --- /dev/null +++ b/lib/bidding/vendor/partners-bidding-participation-dialog.tsx @@ -0,0 +1,249 @@ +'use client' + +import * as React from 'react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { CheckCircle, XCircle, AlertCircle, Calendar, Package } from 'lucide-react' +import { PartnersBiddingListItem } from '../detail/service' +import { respondToPreQuoteInvitation, getBiddingCompaniesForPartners } from '../pre-quote/service' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' +import { formatDate } from '@/lib/utils' + +interface PartnersBiddingParticipationDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + bidding: PartnersBiddingListItem | null + companyId: number + onSuccess: () => void +} + +export function PartnersBiddingParticipationDialog({ + open, + onOpenChange, + bidding, + companyId, + onSuccess +}: PartnersBiddingParticipationDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [selectedResponse, setSelectedResponse] = React.useState<'accepted' | 'declined' | null>(null) + + const handleSubmit = () => { + if (!bidding || !selectedResponse) { + toast({ + title: '오류', + description: '참여 의사를 선택해주세요.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + try { + // 먼저 해당 업체의 biddingCompanyId를 조회 + const biddingCompanyData = await getBiddingCompaniesForPartners(bidding.biddingId, companyId) + + if (!biddingCompanyData || !biddingCompanyData.biddingCompanyId) { + toast({ + title: '오류', + description: '입찰 업체 정보를 찾을 수 없습니다.', + variant: 'destructive', + }) + return + } + + const result = await respondToPreQuoteInvitation( + biddingCompanyData.biddingCompanyId, + selectedResponse, + 'current-user' // TODO: 실제 사용자 ID + ) + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + setSelectedResponse(null) + onOpenChange(false) + onSuccess() + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + } catch (error) { + toast({ + title: '오류', + description: '처리 중 오류가 발생했습니다.', + variant: 'destructive', + }) + } + }) + } + + const handleOpenChange = (open: boolean) => { + onOpenChange(open) + if (!open) { + setSelectedResponse(null) + } + } + + if (!bidding) return null + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="sm:max-w-[600px]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <AlertCircle className="w-5 h-5" /> + 사전견적 참여 의사 결정 + </DialogTitle> + <DialogDescription> + 아래 입찰건에 대한 사전견적 참여 여부를 결정해주세요. + </DialogDescription> + </DialogHeader> + + <div className="py-4"> + {/* 입찰 정보 카드 */} + <Card className="mb-6"> + <CardHeader> + <CardTitle className="text-lg flex items-center gap-2"> + <Package className="w-5 h-5" /> + 입찰 정보 + </CardTitle> + </CardHeader> + <CardContent> + <div className="space-y-3"> + <div> + <strong>입찰번호:</strong> {bidding.biddingNumber} + {bidding.revision > 0 && ( + <Badge variant="outline" className="ml-2"> + Rev.{bidding.revision} + </Badge> + )} + </div> + <div> + <strong>입찰명:</strong> {bidding.title} + </div> + <div> + <strong>품목명:</strong> {bidding.itemName} + </div> + <div> + <strong>프로젝트:</strong> {bidding.projectName} + </div> + {bidding.preQuoteDate && ( + <div className="flex items-center gap-2"> + <Calendar className="w-4 h-4" /> + <strong>사전견적 마감일:</strong> + <span className="text-red-600 font-semibold"> + {formatDate(bidding.preQuoteDate, 'KR')} + </span> + </div> + )} + <div> + <strong>담당자:</strong> {bidding.managerName} + </div> + </div> + </CardContent> + </Card> + + {/* 참여 의사 선택 */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold">참여 의사를 선택해주세요:</h3> + + <div className="grid grid-cols-2 gap-4"> + {/* 참여 수락 */} + <Card + className={`cursor-pointer transition-all ${ + selectedResponse === 'accepted' + ? 'ring-2 ring-green-500 bg-green-50' + : 'hover:shadow-md' + }`} + onClick={() => setSelectedResponse('accepted')} + > + <CardContent className="p-6 text-center"> + <CheckCircle className="w-12 h-12 text-green-600 mx-auto mb-4" /> + <h4 className="text-lg font-semibold text-green-700 mb-2"> + 참여 수락 + </h4> + <p className="text-sm text-gray-600"> + 사전견적에 참여하겠습니다. + </p> + </CardContent> + </Card> + + {/* 참여 거절 */} + <Card + className={`cursor-pointer transition-all ${ + selectedResponse === 'declined' + ? 'ring-2 ring-red-500 bg-red-50' + : 'hover:shadow-md' + }`} + onClick={() => setSelectedResponse('declined')} + > + <CardContent className="p-6 text-center"> + <XCircle className="w-12 h-12 text-red-600 mx-auto mb-4" /> + <h4 className="text-lg font-semibold text-red-700 mb-2"> + 참여 거절 + </h4> + <p className="text-sm text-gray-600"> + 사전견적에 참여하지 않겠습니다. + </p> + </CardContent> + </Card> + </div> + + {selectedResponse && ( + <div className="mt-4 p-4 rounded-lg bg-blue-50 border border-blue-200"> + <div className="flex items-center gap-2"> + <AlertCircle className="w-5 h-5 text-blue-600" /> + <span className="font-medium text-blue-800"> + {selectedResponse === 'accepted' + ? '참여 수락을 선택하셨습니다.' + : '참여 거절을 선택하셨습니다.' + } + </span> + </div> + <p className="text-sm text-blue-600 mt-1"> + {selectedResponse === 'accepted' + ? '수락 후 사전견적서를 작성하실 수 있습니다.' + : '거절 후에는 이 입찰건에 참여할 수 없습니다.' + } + </p> + </div> + )} + </div> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => handleOpenChange(false)}> + 취소 + </Button> + <Button + onClick={handleSubmit} + disabled={isPending || !selectedResponse} + className={selectedResponse === 'accepted' ? 'bg-green-600 hover:bg-green-700' : + selectedResponse === 'declined' ? 'bg-red-600 hover:bg-red-700' : ''} + > + {selectedResponse === 'accepted' && <CheckCircle className="w-4 h-4 mr-2" />} + {selectedResponse === 'declined' && <XCircle className="w-4 h-4 mr-2" />} + {selectedResponse === 'accepted' ? '참여 수락' : + selectedResponse === 'declined' ? '참여 거절' : '선택하세요'} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/vendor/partners-bidding-pre-quote.tsx b/lib/bidding/vendor/partners-bidding-pre-quote.tsx new file mode 100644 index 00000000..d5ff3fd6 --- /dev/null +++ b/lib/bidding/vendor/partners-bidding-pre-quote.tsx @@ -0,0 +1,928 @@ +'use client' + +import * as React from 'react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +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 { + ArrowLeft, + Calendar, + Building2, + Package, + User, + DollarSign, + FileText, + Users, + Send, + CheckCircle, + XCircle, + Save +} from 'lucide-react' + +import { formatDate } from '@/lib/utils' +import { + getBiddingCompaniesForPartners, + submitPreQuoteResponse, + getPrItemsForBidding, + getSavedPrItemQuotations, + savePreQuoteDraft +} from '../pre-quote/service' +import { getBiddingConditions } from '../service' +import { PrItemsPricingTable } from './components/pr-items-pricing-table' +import { PreQuoteFileUpload } from './components/pre-quote-file-upload' +import { + biddingStatusLabels, + contractTypeLabels, + biddingTypeLabels +} from '@/db/schema' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface PartnersBiddingPreQuoteProps { + biddingId: number + companyId: number +} + +interface BiddingDetail { + id: number + biddingNumber: string + revision: number | null + projectName: string | null + itemName: string | null + title: string + description: string | null + content: string | null + contractType: string + biddingType: string + awardCount: string + contractPeriod: string | null + preQuoteDate: string | null + biddingRegistrationDate: string | null + submissionStartDate: string | null + submissionEndDate: string | null + evaluationDate: string | null + currency: string + budget: number | null + targetPrice: number | null + status: string + managerName: string | null + managerEmail: string | null + managerPhone: string | null + biddingCompanyId: number | null + biddingId: number // bidding의 ID 추가 + invitationStatus: string | null + preQuoteAmount: string | null + preQuoteSubmittedAt: string | null + isPreQuoteSelected: boolean | null + isAttendingMeeting: boolean | null + // companyConditionResponses에서 가져온 조건들 (제시된 조건과 응답 모두) + paymentTermsResponse: string | null + taxConditionsResponse: string | null + incotermsResponse: string | null + proposedContractDeliveryDate: string | null + proposedShippingPort: string | null + proposedDestinationPort: string | null + priceAdjustmentResponse: boolean | null + sparePartResponse: string | null + isInitialResponse: boolean | null + additionalProposals: string | null +} + +export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddingPreQuoteProps) { + const router = useRouter() + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [biddingDetail, setBiddingDetail] = React.useState<BiddingDetail | null>(null) + const [isLoading, setIsLoading] = React.useState(true) + const [biddingConditions, setBiddingConditions] = React.useState<any | null>(null) + + // 품목별 견적 관련 상태 + const [prItems, setPrItems] = React.useState<any[]>([]) + const [prItemQuotations, setPrItemQuotations] = React.useState<any[]>([]) + const [totalAmount, setTotalAmount] = React.useState(0) + const [isSaving, setIsSaving] = React.useState(false) + + // 사전견적 폼 상태 + const [responseData, setResponseData] = React.useState({ + preQuoteAmount: '', + paymentTermsResponse: '', + taxConditionsResponse: '', + incotermsResponse: '', + proposedContractDeliveryDate: '', + proposedShippingPort: '', + proposedDestinationPort: '', + priceAdjustmentResponse: false, + isInitialResponse: false, + sparePartResponse: '', + additionalProposals: '', + isAttendingMeeting: false, + }) + + // 연동제 폼 상태 + const [priceAdjustmentForm, setPriceAdjustmentForm] = React.useState({ + itemName: '', + adjustmentReflectionPoint: '', + majorApplicableRawMaterial: '', + adjustmentFormula: '', + rawMaterialPriceIndex: '', + referenceDate: '', + comparisonDate: '', + adjustmentRatio: '', + notes: '', + adjustmentConditions: '', + majorNonApplicableRawMaterial: '', + adjustmentPeriod: '', + contractorWriter: '', + adjustmentDate: '', + nonApplicableReason: '', + }) + + // 데이터 로드 + React.useEffect(() => { + const loadData = async () => { + try { + setIsLoading(true) + + // 모든 필요한 데이터를 병렬로 로드 + const [result, conditions, prItemsData] = await Promise.all([ + getBiddingCompaniesForPartners(biddingId, companyId), + getBiddingConditions(biddingId), + getPrItemsForBidding(biddingId) + ]) + + if (result) { + setBiddingDetail(result as BiddingDetail) + + // 저장된 품목별 견적 정보가 있으면 로드 + if (result.biddingCompanyId) { + const savedQuotations = await getSavedPrItemQuotations(result.biddingCompanyId) + setPrItemQuotations(savedQuotations) + + // 총 금액 계산 + const calculatedTotal = savedQuotations.reduce((sum: number, item: any) => sum + item.bidAmount, 0) + setTotalAmount(calculatedTotal) + } + + // 기존 응답 데이터로 폼 초기화 + setResponseData({ + preQuoteAmount: result.preQuoteAmount?.toString() || '', + paymentTermsResponse: result.paymentTermsResponse || '', + taxConditionsResponse: result.taxConditionsResponse || '', + incotermsResponse: result.incotermsResponse || '', + proposedContractDeliveryDate: result.proposedContractDeliveryDate || '', + proposedShippingPort: result.proposedShippingPort || '', + proposedDestinationPort: result.proposedDestinationPort || '', + priceAdjustmentResponse: result.priceAdjustmentResponse || false, + isInitialResponse: result.isInitialResponse || false, + sparePartResponse: result.sparePartResponse || '', + additionalProposals: result.additionalProposals || '', + isAttendingMeeting: result.isAttendingMeeting || false, + }) + } + + if (conditions) { + // BiddingConditionsEdit와 같은 방식으로 raw 데이터 사용 + setBiddingConditions(conditions) + } + + if (prItemsData) { + setPrItems(prItemsData) + } + } catch (error) { + console.error('Failed to load bidding company:', error) + toast({ + title: '오류', + description: '입찰 정보를 불러오는데 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + } + + loadData() + }, [biddingId, companyId, toast]) + + // 임시저장 기능 + const handleTempSave = () => { + if (!biddingDetail) return + + setIsSaving(true) + startTransition(async () => { + const result = await savePreQuoteDraft( + biddingDetail.biddingCompanyId!, + { + prItemQuotations, + 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, + priceAdjustmentForm: responseData.priceAdjustmentResponse ? { + itemName: priceAdjustmentForm.itemName, + adjustmentReflectionPoint: priceAdjustmentForm.adjustmentReflectionPoint, + majorApplicableRawMaterial: priceAdjustmentForm.majorApplicableRawMaterial, + adjustmentFormula: priceAdjustmentForm.adjustmentFormula, + rawMaterialPriceIndex: priceAdjustmentForm.rawMaterialPriceIndex, + referenceDate: priceAdjustmentForm.referenceDate, + comparisonDate: priceAdjustmentForm.comparisonDate, + adjustmentRatio: priceAdjustmentForm.adjustmentRatio ? parseFloat(priceAdjustmentForm.adjustmentRatio) : undefined, + notes: priceAdjustmentForm.notes, + adjustmentConditions: priceAdjustmentForm.adjustmentConditions, + majorNonApplicableRawMaterial: priceAdjustmentForm.majorNonApplicableRawMaterial, + adjustmentPeriod: priceAdjustmentForm.adjustmentPeriod, + contractorWriter: priceAdjustmentForm.contractorWriter, + adjustmentDate: priceAdjustmentForm.adjustmentDate, + nonApplicableReason: priceAdjustmentForm.nonApplicableReason, + } : undefined + }, + 'current-user' // TODO: 실제 사용자 ID + ) + + if (result.success) { + toast({ + title: '임시저장 완료', + description: result.message, + }) + } else { + toast({ + title: '임시저장 실패', + description: result.error, + variant: 'destructive', + }) + } + setIsSaving(false) + }) + } + + const handleSubmitResponse = () => { + if (!biddingDetail) return + + // 필수값 검증 + if (prItemQuotations.length === 0 || totalAmount === 0) { + toast({ + title: '유효성 오류', + description: '품목별 견적을 입력해주세요.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + const submissionData = { + preQuoteAmount: totalAmount, // 품목별 계산된 총 금액 사용 + prItemQuotations, // 품목별 견적 데이터 추가 + 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, + priceAdjustmentForm: responseData.priceAdjustmentResponse ? { + itemName: priceAdjustmentForm.itemName, + adjustmentReflectionPoint: priceAdjustmentForm.adjustmentReflectionPoint, + majorApplicableRawMaterial: priceAdjustmentForm.majorApplicableRawMaterial, + adjustmentFormula: priceAdjustmentForm.adjustmentFormula, + rawMaterialPriceIndex: priceAdjustmentForm.rawMaterialPriceIndex, + referenceDate: priceAdjustmentForm.referenceDate, + comparisonDate: priceAdjustmentForm.comparisonDate, + adjustmentRatio: priceAdjustmentForm.adjustmentRatio ? parseFloat(priceAdjustmentForm.adjustmentRatio) : undefined, + notes: priceAdjustmentForm.notes, + adjustmentConditions: priceAdjustmentForm.adjustmentConditions, + majorNonApplicableRawMaterial: priceAdjustmentForm.majorNonApplicableRawMaterial, + adjustmentPeriod: priceAdjustmentForm.adjustmentPeriod, + contractorWriter: priceAdjustmentForm.contractorWriter, + adjustmentDate: priceAdjustmentForm.adjustmentDate, + nonApplicableReason: priceAdjustmentForm.nonApplicableReason, + } : undefined + } + + const result = await submitPreQuoteResponse( + biddingDetail.biddingCompanyId!, + submissionData, + 'current-user' // TODO: 실제 사용자 ID + ) + + console.log('제출 결과:', result) + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + + // 데이터 새로고침 및 폼 상태 업데이트 + const updatedDetail = await getBiddingCompaniesForPartners(biddingId, companyId) + console.log('업데이트된 데이터:', updatedDetail) + + if (updatedDetail) { + setBiddingDetail(updatedDetail as BiddingDetail) + + // 폼 상태도 업데이트된 데이터로 다시 설정 + setResponseData({ + preQuoteAmount: updatedDetail.preQuoteAmount?.toString() || '', + paymentTermsResponse: updatedDetail.paymentTermsResponse || '', + taxConditionsResponse: updatedDetail.taxConditionsResponse || '', + incotermsResponse: updatedDetail.incotermsResponse || '', + proposedContractDeliveryDate: updatedDetail.proposedContractDeliveryDate || '', + proposedShippingPort: updatedDetail.proposedShippingPort || '', + proposedDestinationPort: updatedDetail.proposedDestinationPort || '', + priceAdjustmentResponse: updatedDetail.priceAdjustmentResponse || false, + isInitialResponse: updatedDetail.isInitialResponse || false, + sparePartResponse: updatedDetail.sparePartResponse || '', + additionalProposals: updatedDetail.additionalProposals || '', + isAttendingMeeting: updatedDetail.isAttendingMeeting || false, + }) + } + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: biddingDetail?.currency || 'KRW', + }).format(amount) + } + + if (isLoading) { + return ( + <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> + ) + } + + if (!biddingDetail) { + return ( + <div className="text-center py-12"> + <p className="text-muted-foreground">입찰 정보를 찾을 수 없습니다.</p> + <Button onClick={() => router.back()} className="mt-4"> + <ArrowLeft className="w-4 h-4 mr-2" /> + 돌아가기 + </Button> + </div> + ) + } + + return ( + <div className="space-y-6"> + {/* 헤더 */} + <div className="flex items-center justify-between"> + <div className="flex items-center gap-4"> + <Button variant="outline" onClick={() => router.back()}> + <ArrowLeft className="w-4 h-4 mr-2" /> + 목록으로 + </Button> + <div> + <h1 className="text-2xl font-semibold">{biddingDetail.title}</h1> + <div className="flex items-center gap-2 mt-1"> + <Badge variant="outline" className="font-mono"> + {biddingDetail.biddingNumber} + {biddingDetail.revision && biddingDetail.revision > 0 && ` Rev.${biddingDetail.revision}`} + </Badge> + <Badge variant={ + biddingDetail.status === 'bidding_disposal' ? 'destructive' : + biddingDetail.status === 'vendor_selected' ? 'default' : + 'secondary' + }> + {biddingStatusLabels[biddingDetail.status]} + </Badge> + </div> + </div> + </div> + + </div> + + {/* 입찰 공고 섹션 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileText className="w-5 h-5" /> + 입찰 공고 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <Label className="text-sm font-medium text-muted-foreground">프로젝트</Label> + <div className="flex items-center gap-2 mt-1"> + <Building2 className="w-4 h-4" /> + <span>{biddingDetail.projectName}</span> + </div> + </div> + <div> + <Label className="text-sm font-medium text-muted-foreground">품목</Label> + <div className="flex items-center gap-2 mt-1"> + <Package className="w-4 h-4" /> + <span>{biddingDetail.itemName}</span> + </div> + </div> + <div> + <Label className="text-sm font-medium text-muted-foreground">계약구분</Label> + <div className="mt-1">{contractTypeLabels[biddingDetail.contractType]}</div> + </div> + <div> + <Label className="text-sm font-medium text-muted-foreground">입찰유형</Label> + <div className="mt-1">{biddingTypeLabels[biddingDetail.biddingType]}</div> + </div> + <div> + <Label className="text-sm font-medium text-muted-foreground">낙찰수</Label> + <div className="mt-1">{biddingDetail.awardCount === 'single' ? '단수' : '복수'}</div> + </div> + <div> + <Label className="text-sm font-medium text-muted-foreground">담당자</Label> + <div className="flex items-center gap-2 mt-1"> + <User className="w-4 h-4" /> + <span>{biddingDetail.managerName}</span> + </div> + </div> + </div> + + {biddingDetail.budget && ( + <div> + <Label className="text-sm font-medium text-muted-foreground">예산</Label> + <div className="flex items-center gap-2 mt-1"> + <DollarSign className="w-4 h-4" /> + <span className="font-semibold">{formatCurrency(biddingDetail.budget)}</span> + </div> + </div> + )} + + {/* 일정 정보 */} + <div className="pt-4 border-t"> + <Label className="text-sm font-medium text-muted-foreground mb-2 block">일정 정보</Label> + <div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm"> + {biddingDetail.submissionStartDate && biddingDetail.submissionEndDate && ( + <div> + <span className="font-medium">제출기간:</span> {formatDate(biddingDetail.submissionStartDate, 'KR')} ~ {formatDate(biddingDetail.submissionEndDate, 'KR')} + </div> + )} + {biddingDetail.evaluationDate && ( + <div> + <span className="font-medium">평가일:</span> {formatDate(biddingDetail.evaluationDate, 'KR')} + </div> + )} + </div> + </div> + </CardContent> + </Card> + + {/* 현재 설정된 조건 섹션 */} + {biddingConditions && ( + <Card> + <CardHeader> + <CardTitle>현재 설정된 입찰 조건</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm"> + <div> + <Label className="text-muted-foreground">지급조건</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + <p className="font-medium">{biddingConditions.paymentTerms || "미설정"}</p> + </div> + </div> + + <div> + <Label className="text-muted-foreground">세금조건</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + <p className="font-medium">{biddingConditions.taxConditions || "미설정"}</p> + </div> + </div> + + <div> + <Label className="text-muted-foreground">운송조건</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + <p className="font-medium">{biddingConditions.incoterms || "미설정"}</p> + </div> + </div> + + <div> + <Label className="text-muted-foreground">계약 납기일</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + <p className="font-medium"> + {biddingConditions.contractDeliveryDate + ? formatDate(biddingConditions.contractDeliveryDate, 'KR') + : "미설정" + } + </p> + </div> + </div> + + <div> + <Label className="text-muted-foreground">선적지</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + <p className="font-medium">{biddingConditions.shippingPort || "미설정"}</p> + </div> + </div> + + <div> + <Label className="text-muted-foreground">도착지</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + <p className="font-medium">{biddingConditions.destinationPort || "미설정"}</p> + </div> + </div> + + <div> + <Label className="text-muted-foreground">연동제 적용</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + <p className="font-medium">{biddingConditions.isPriceAdjustmentApplicable ? "적용 가능" : "적용 불가"}</p> + </div> + </div> + + + <div > + <Label className="text-muted-foreground">스페어파트 옵션</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + <p className="font-medium">{biddingConditions.sparePartOptions}</p> + </div> + </div> + </div> + </CardContent> + </Card> + )} + + {/* 품목별 견적 작성 섹션 */} + {prItems.length > 0 && ( + <PrItemsPricingTable + prItems={prItems} + initialQuotations={prItemQuotations} + currency={biddingDetail?.currency || 'KRW'} + onQuotationsChange={setPrItemQuotations} + onTotalAmountChange={setTotalAmount} + readOnly={false} + /> + )} + + {/* 견적 문서 업로드 섹션 */} + {/* <PreQuoteFileUpload + biddingId={biddingId} + companyId={companyId} + readOnly={biddingDetail?.invitationStatus === 'submitted'} + /> */} + + {/* 사전견적 폼 섹션 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Send className="w-5 h-5" /> + 사전견적 제출하기 + </CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + {/* 총 금액 표시 (읽기 전용) */} + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + <div className="space-y-2"> + <Label htmlFor="totalAmount">총 사전견적 금액 *</Label> + <Input + id="totalAmount" + type="text" + value={new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: biddingDetail?.currency || 'KRW', + }).format(totalAmount)} + readOnly + className="bg-gray-50 font-semibold text-primary" + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="proposedContractDeliveryDate">제안 납품일</Label> + <Input + id="proposedContractDeliveryDate" + type="date" + value={responseData.proposedContractDeliveryDate} + onChange={(e) => setResponseData({...responseData, proposedContractDeliveryDate: e.target.value})} + title={biddingConditions?.contractDeliveryDate ? `참고 납기일: ${formatDate(biddingConditions.contractDeliveryDate, 'KR')}` : "납품일을 선택하세요"} + /> + {biddingConditions?.contractDeliveryDate && ( + <p className="text-xs text-muted-foreground"> + 참고 납기일: {formatDate(biddingConditions.contractDeliveryDate, 'KR')} + </p> + )} + </div> + </div> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + <div className="space-y-2"> + <Label htmlFor="paymentTermsResponse">응답 지급조건</Label> + <Input + id="paymentTermsResponse" + value={responseData.paymentTermsResponse} + onChange={(e) => setResponseData({...responseData, paymentTermsResponse: e.target.value})} + placeholder={biddingConditions?.paymentTerms ? `참고: ${biddingConditions.paymentTerms}` : "지급조건에 대한 의견을 입력하세요"} + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="taxConditionsResponse">응답 세금조건</Label> + <Input + id="taxConditionsResponse" + value={responseData.taxConditionsResponse} + onChange={(e) => setResponseData({...responseData, taxConditionsResponse: e.target.value})} + placeholder={biddingConditions?.taxConditions ? `참고: ${biddingConditions.taxConditions}` : "세금조건에 대한 의견을 입력하세요"} + /> + </div> + </div> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + <div className="space-y-2"> + <Label htmlFor="incotermsResponse">응답 운송조건</Label> + <Input + id="incotermsResponse" + value={responseData.incotermsResponse} + onChange={(e) => setResponseData({...responseData, incotermsResponse: e.target.value})} + placeholder={biddingConditions?.incoterms ? `참고: ${biddingConditions.incoterms}` : "운송조건에 대한 의견을 입력하세요"} + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="proposedShippingPort">제안 선적지</Label> + <Input + id="proposedShippingPort" + value={responseData.proposedShippingPort} + onChange={(e) => setResponseData({...responseData, proposedShippingPort: e.target.value})} + placeholder={biddingConditions?.shippingPort ? `참고: ${biddingConditions.shippingPort}` : "선적지를 입력하세요"} + /> + </div> + </div> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + <div className="space-y-2"> + <Label htmlFor="proposedDestinationPort">제안 도착지</Label> + <Input + id="proposedDestinationPort" + value={responseData.proposedDestinationPort} + onChange={(e) => setResponseData({...responseData, proposedDestinationPort: e.target.value})} + placeholder={biddingConditions?.destinationPort ? `참고: ${biddingConditions.destinationPort}` : "도착지를 입력하세요"} + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="sparePartResponse">스페어파트 응답</Label> + <Input + id="sparePartResponse" + value={responseData.sparePartResponse} + onChange={(e) => setResponseData({...responseData, sparePartResponse: e.target.value})} + placeholder={biddingConditions?.sparePartOptions ? `참고: ${biddingConditions.sparePartOptions}` : "스페어파트 관련 응답을 입력하세요"} + /> + </div> + </div> + + <div className="space-y-2"> + <Label htmlFor="additionalProposals">추가 제안사항</Label> + <Textarea + id="additionalProposals" + value={responseData.additionalProposals} + onChange={(e) => setResponseData({...responseData, additionalProposals: e.target.value})} + placeholder="추가 제안사항을 입력하세요" + rows={4} + /> + </div> + + <div className="space-y-4"> + <div className="flex items-center space-x-2"> + <Checkbox + id="isInitialResponse" + checked={responseData.isInitialResponse} + onCheckedChange={(checked) => + setResponseData({...responseData, isInitialResponse: !!checked}) + } + /> + <Label htmlFor="isInitialResponse">초도 공급입니다</Label> + </div> + + <div className="flex items-center space-x-2"> + <Checkbox + id="priceAdjustmentResponse" + checked={responseData.priceAdjustmentResponse} + onCheckedChange={(checked) => + setResponseData({...responseData, priceAdjustmentResponse: !!checked}) + } + /> + <Label htmlFor="priceAdjustmentResponse">연동제 적용에 동의합니다</Label> + </div> + </div> + + {/* 연동제 상세 정보 (연동제 적용 시에만 표시) */} + {responseData.priceAdjustmentResponse && ( + <Card className="mt-6"> + <CardHeader> + <CardTitle className="text-lg">하도급대금등 연동표</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="itemName">품목등의 명칭</Label> + <Input + id="itemName" + value={priceAdjustmentForm.itemName} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, itemName: e.target.value})} + placeholder="품목명을 입력하세요" + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="adjustmentReflectionPoint">조정대금 반영시점</Label> + <Input + id="adjustmentReflectionPoint" + value={priceAdjustmentForm.adjustmentReflectionPoint} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentReflectionPoint: e.target.value})} + placeholder="반영시점을 입력하세요" + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="adjustmentRatio">연동 비율 (%)</Label> + <Input + id="adjustmentRatio" + type="number" + step="0.01" + value={priceAdjustmentForm.adjustmentRatio} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentRatio: e.target.value})} + placeholder="비율을 입력하세요" + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="adjustmentPeriod">조정주기</Label> + <Input + id="adjustmentPeriod" + value={priceAdjustmentForm.adjustmentPeriod} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentPeriod: e.target.value})} + placeholder="조정주기를 입력하세요" + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="referenceDate">기준시점</Label> + <Input + id="referenceDate" + type="date" + value={priceAdjustmentForm.referenceDate} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, referenceDate: e.target.value})} + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="comparisonDate">비교시점</Label> + <Input + id="comparisonDate" + type="date" + value={priceAdjustmentForm.comparisonDate} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, comparisonDate: e.target.value})} + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="contractorWriter">수탁기업(협력사) 작성자</Label> + <Input + id="contractorWriter" + value={priceAdjustmentForm.contractorWriter} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, contractorWriter: e.target.value})} + placeholder="작성자명을 입력하세요" + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="adjustmentDate">조정일</Label> + <Input + id="adjustmentDate" + type="date" + value={priceAdjustmentForm.adjustmentDate} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentDate: e.target.value})} + /> + </div> + </div> + + <div className="space-y-2"> + <Label htmlFor="majorApplicableRawMaterial">연동대상 주요 원재료</Label> + <Textarea + id="majorApplicableRawMaterial" + value={priceAdjustmentForm.majorApplicableRawMaterial} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, majorApplicableRawMaterial: e.target.value})} + placeholder="연동 대상 원재료를 입력하세요" + rows={3} + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="adjustmentFormula">하도급대금등 연동 산식</Label> + <Textarea + id="adjustmentFormula" + value={priceAdjustmentForm.adjustmentFormula} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentFormula: e.target.value})} + placeholder="연동 산식을 입력하세요" + rows={3} + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="rawMaterialPriceIndex">원재료 가격 기준지표</Label> + <Textarea + id="rawMaterialPriceIndex" + value={priceAdjustmentForm.rawMaterialPriceIndex} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, rawMaterialPriceIndex: e.target.value})} + placeholder="가격 기준지표를 입력하세요" + rows={2} + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="adjustmentConditions">조정요건</Label> + <Textarea + id="adjustmentConditions" + value={priceAdjustmentForm.adjustmentConditions} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentConditions: e.target.value})} + placeholder="조정요건을 입력하세요" + rows={2} + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="majorNonApplicableRawMaterial">연동 미적용 주요 원재료</Label> + <Textarea + id="majorNonApplicableRawMaterial" + value={priceAdjustmentForm.majorNonApplicableRawMaterial} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, majorNonApplicableRawMaterial: e.target.value})} + placeholder="연동 미적용 원재료를 입력하세요" + rows={2} + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="nonApplicableReason">연동 미적용 사유</Label> + <Textarea + id="nonApplicableReason" + value={priceAdjustmentForm.nonApplicableReason} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, nonApplicableReason: e.target.value})} + placeholder="미적용 사유를 입력하세요" + rows={2} + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="priceAdjustmentNotes">기타 사항</Label> + <Textarea + id="priceAdjustmentNotes" + value={priceAdjustmentForm.notes} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, notes: e.target.value})} + placeholder="기타 사항을 입력하세요" + rows={2} + /> + </div> + </CardContent> + </Card> + )} + + <div className="flex justify-end gap-2 pt-4"> + <> + <Button + variant="outline" + onClick={handleTempSave} + disabled={isSaving || isPending} + > + <Save className="w-4 h-4 mr-2" /> + {isSaving ? '저장중...' : '임시저장'} + </Button> + <Button onClick={handleSubmitResponse} disabled={isPending || isSaving}> + <Send className="w-4 h-4 mr-2" /> + 사전견적 제출 + </Button> + </> + + {/* {biddingDetail?.invitationStatus === 'submitted' && ( + <div className="flex items-center gap-2 text-green-600"> + <CheckCircle className="w-5 h-5" /> + <span className="font-medium">사전견적이 제출되었습니다</span> + </div> + )} */} + </div> + </CardContent> + </Card> + </div> + ) +} diff --git a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx index c45568bd..324e21d1 100644 --- a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx +++ b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx @@ -2,19 +2,21 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Users } from "lucide-react" +import { Users, CheckCircle, XCircle } from "lucide-react" import { Button } from "@/components/ui/button" import { PartnersBiddingListItem } from '../detail/service' interface PartnersBiddingToolbarActionsProps { table: Table<PartnersBiddingListItem> + companyId: number onRefresh: () => void setRowAction?: (action: { type: string; row: { original: PartnersBiddingListItem } }) => void } export function PartnersBiddingToolbarActions({ table, + companyId, onRefresh, setRowAction }: PartnersBiddingToolbarActionsProps) { @@ -29,6 +31,11 @@ export function PartnersBiddingToolbarActions({ selectedBidding.invitationStatus === 'submitted' ) + // 참여 의사 결정 버튼 활성화 조건 (sent 상태이고 아직 참여의사를 결정하지 않은 경우) + const canDecideParticipation = selectedBidding && + selectedBidding.invitationStatus === 'sent' && + selectedBidding.isPreQuoteSelected === null + const handleAttendanceClick = () => { if (selectedBidding && setRowAction) { setRowAction({ @@ -38,11 +45,31 @@ export function PartnersBiddingToolbarActions({ } } + const handleParticipationClick = () => { + if (selectedBidding && setRowAction) { + setRowAction({ + type: 'participation', + row: { original: selectedBidding } + }) + } + } + return ( <div className="flex items-center gap-2"> <Button variant="outline" size="sm" + onClick={handleParticipationClick} + disabled={!canDecideParticipation} + className="flex items-center gap-2" + > + <CheckCircle className="w-4 h-4" /> + 참여 의사 결정 + </Button> + + <Button + variant="outline" + size="sm" onClick={handleAttendanceClick} disabled={!canManageAttendance} className="flex items-center gap-2" diff --git a/lib/bidding/vendor/vendor-prequote-participation-dialog.tsx b/lib/bidding/vendor/vendor-prequote-participation-dialog.tsx new file mode 100644 index 00000000..c8098c3d --- /dev/null +++ b/lib/bidding/vendor/vendor-prequote-participation-dialog.tsx @@ -0,0 +1,268 @@ +'use client' + +import * as React from 'react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { CheckCircle, XCircle, AlertCircle, Calendar, Package, Building2, User } from 'lucide-react' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' +import { formatDate } from '@/lib/utils' + +interface VendorPreQuoteParticipationDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + biddingDetail: any // BiddingDetail 타입 + onParticipationDecision: (participate: boolean) => Promise<void> +} + +export function VendorPreQuoteParticipationDialog({ + open, + onOpenChange, + biddingDetail, + onParticipationDecision +}: VendorPreQuoteParticipationDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [selectedDecision, setSelectedDecision] = React.useState<boolean | null>(null) + + const handleSubmit = () => { + if (selectedDecision === null) { + toast({ + title: '선택 필요', + description: '사전견적 참여 여부를 선택해주세요.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + try { + await onParticipationDecision(selectedDecision) + + toast({ + title: '완료', + description: selectedDecision + ? '사전견적 참여를 결정했습니다. 이제 견적서를 작성하실 수 있습니다.' + : '사전견적 참여를 거절했습니다.', + }) + + setSelectedDecision(null) + onOpenChange(false) + } catch (error) { + toast({ + title: '오류', + description: '처리 중 오류가 발생했습니다.', + variant: 'destructive', + }) + } + }) + } + + const handleOpenChange = (open: boolean) => { + onOpenChange(open) + if (!open) { + setSelectedDecision(null) + } + } + + if (!biddingDetail) return null + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="sm:max-w-[700px]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <AlertCircle className="w-5 h-5 text-blue-600" /> + 사전견적 참여 의사 결정 + </DialogTitle> + <DialogDescription> + 다음 입찰건에 대한 사전견적 참여 여부를 결정해주세요. + </DialogDescription> + </DialogHeader> + + <div className="py-4 space-y-6"> + {/* 입찰 정보 카드 */} + <Card> + <CardHeader> + <CardTitle className="text-lg flex items-center gap-2"> + <Package className="w-5 h-5" /> + 입찰 상세 정보 + </CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <strong className="text-gray-700">입찰번호:</strong> + <div className="flex items-center gap-2 mt-1"> + <span className="font-mono">{biddingDetail.biddingNumber}</span> + {biddingDetail.revision && biddingDetail.revision > 0 && ( + <Badge variant="outline">Rev.{biddingDetail.revision}</Badge> + )} + </div> + </div> + + <div> + <strong className="text-gray-700">프로젝트:</strong> + <div className="flex items-center gap-2 mt-1"> + <Building2 className="w-4 h-4" /> + <span>{biddingDetail.projectName}</span> + </div> + </div> + + <div className="md:col-span-2"> + <strong className="text-gray-700">입찰명:</strong> + <div className="mt-1"> + <span className="text-lg">{biddingDetail.title}</span> + </div> + </div> + + <div> + <strong className="text-gray-700">품목명:</strong> + <div className="mt-1">{biddingDetail.itemName}</div> + </div> + + <div> + <strong className="text-gray-700">담당자:</strong> + <div className="flex items-center gap-2 mt-1"> + <User className="w-4 h-4" /> + <span>{biddingDetail.managerName}</span> + </div> + </div> + + {biddingDetail.preQuoteDate && ( + <div className="md:col-span-2"> + <strong className="text-gray-700">사전견적 마감일:</strong> + <div className="flex items-center gap-2 mt-1"> + <Calendar className="w-4 h-4 text-red-500" /> + <span className="text-red-600 font-semibold"> + {formatDate(biddingDetail.preQuoteDate, 'KR')} + </span> + </div> + </div> + )} + + {biddingDetail.budget && ( + <div> + <strong className="text-gray-700">예산:</strong> + <div className="mt-1 font-mono"> + {biddingDetail.budget?.toLocaleString()} {biddingDetail.currency || 'KRW'} + </div> + </div> + )} + </div> + </CardContent> + </Card> + + {/* 참여 의사 선택 */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold text-gray-900"> + 사전견적에 참여하시겠습니까? + </h3> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {/* 참여 */} + <Card + className={`cursor-pointer transition-all border-2 ${ + selectedDecision === true + ? 'border-green-500 bg-green-50 shadow-md' + : 'border-gray-200 hover:border-green-300 hover:shadow-sm' + }`} + onClick={() => setSelectedDecision(true)} + > + <CardContent className="p-6 text-center"> + <CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" /> + <h4 className="text-xl font-semibold text-green-700 mb-2"> + 참여하겠습니다 + </h4> + <p className="text-sm text-gray-600 leading-relaxed"> + 사전견적서를 작성하여 제출하겠습니다.<br/> + 마감일까지 견적을 완료해주세요. + </p> + </CardContent> + </Card> + + {/* 참여 안함 */} + <Card + className={`cursor-pointer transition-all border-2 ${ + selectedDecision === false + ? 'border-red-500 bg-red-50 shadow-md' + : 'border-gray-200 hover:border-red-300 hover:shadow-sm' + }`} + onClick={() => setSelectedDecision(false)} + > + <CardContent className="p-6 text-center"> + <XCircle className="w-16 h-16 text-red-600 mx-auto mb-4" /> + <h4 className="text-xl font-semibold text-red-700 mb-2"> + 참여하지 않겠습니다 + </h4> + <p className="text-sm text-gray-600 leading-relaxed"> + 이번 사전견적에는 참여하지 않겠습니다.<br/> + 다음 기회에 참여하겠습니다. + </p> + </CardContent> + </Card> + </div> + + {selectedDecision !== null && ( + <div className={`mt-4 p-4 rounded-lg border ${ + selectedDecision + ? 'bg-green-50 border-green-200' + : 'bg-red-50 border-red-200' + }`}> + <div className="flex items-center gap-2"> + {selectedDecision ? ( + <CheckCircle className="w-5 h-5 text-green-600" /> + ) : ( + <XCircle className="w-5 h-5 text-red-600" /> + )} + <span className={`font-medium ${ + selectedDecision ? 'text-green-800' : 'text-red-800' + }`}> + {selectedDecision + ? '사전견적 참여를 선택하셨습니다.' + : '사전견적 참여를 거절하셨습니다.' + } + </span> + </div> + <p className={`text-sm mt-1 ${ + selectedDecision ? 'text-green-600' : 'text-red-600' + }`}> + {selectedDecision + ? '확인을 누르시면 견적서 작성 화면으로 이동합니다.' + : '확인을 누르시면 이 입찰건의 참여가 종료됩니다.' + } + </p> + </div> + )} + </div> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => handleOpenChange(false)}> + 취소 + </Button> + <Button + onClick={handleSubmit} + disabled={isPending || selectedDecision === null} + className={selectedDecision === true ? 'bg-green-600 hover:bg-green-700' : + selectedDecision === false ? 'bg-red-600 hover:bg-red-700' : ''} + > + {selectedDecision === true && <CheckCircle className="w-4 h-4 mr-2" />} + {selectedDecision === false && <XCircle className="w-4 h-4 mr-2" />} + {selectedDecision === true ? '참여 확정' : + selectedDecision === false ? '참여 거절' : '선택하세요'} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} |
