diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-16 09:26:56 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-16 09:26:56 +0000 |
| commit | 0ee0f102634dd703aea7ad0b8a338eb5e9bdadab (patch) | |
| tree | 4ac06d2198a7457cf9602d3ab300b0e233bbe8be /lib/rfq-last | |
| parent | c49d5a42a66d1d29d477cca2ad56f923313c3961 (diff) | |
(최겸) 구매 견적 비교 수정(create-PO 개발 필)
Diffstat (limited to 'lib/rfq-last')
| -rw-r--r-- | lib/rfq-last/compare-action.ts | 477 | ||||
| -rw-r--r-- | lib/rfq-last/contract-actions.ts | 5 | ||||
| -rw-r--r-- | lib/rfq-last/quotation-compare-view.tsx | 970 |
3 files changed, 791 insertions, 661 deletions
diff --git a/lib/rfq-last/compare-action.ts b/lib/rfq-last/compare-action.ts index 2be594e9..1a50a373 100644 --- a/lib/rfq-last/compare-action.ts +++ b/lib/rfq-last/compare-action.ts @@ -1,7 +1,7 @@ "use server"; import db from "@/db/db"; -import { eq, and, inArray,ne } from "drizzle-orm"; +import { eq, and, inArray, ne, asc, desc } from "drizzle-orm"; import { rfqsLast, rfqLastDetails, @@ -10,7 +10,9 @@ import { rfqLastVendorQuotationItems, vendors, paymentTerms, - incoterms,vendorSelections + incoterms, + vendorSelections, + users } from "@/db/schema"; import { revalidatePath } from "next/cache"; import { getServerSession } from "next-auth/next" @@ -44,14 +46,10 @@ export interface ComparisonData { }; } -export interface VendorComparison { - vendorId: number; - vendorName: string; - vendorCode: string; - vendorCountry?: string; - - // 응답 정보 +// 벤더의 각 차수별 응답 정보 +export interface VendorResponseVersion { responseId: number; + responseVersion: number; participationStatus: string; responseStatus: string; submittedAt: Date | null; @@ -62,7 +60,58 @@ export interface VendorComparison { rank?: number; priceVariance?: number; - // 구매자 제시 조건 + // 벤더 제안 조건 + vendorConditions: { + currency?: string; + paymentTermsCode?: string; + paymentTermsDesc?: string; + incotermsCode?: string; + incotermsDesc?: string; + deliveryDate?: Date | null; + contractDuration?: string; + taxCode?: string; + placeOfShipping?: string; + placeOfDestination?: string; + firstAcceptance?: "수용" | "부분수용" | "거부"; + firstDescription?: string; + sparepartAcceptance?: "수용" | "부분수용" | "거부"; + sparepartDescription?: string; + materialPriceRelatedYn?: boolean; + materialPriceRelatedReason?: string; + }; + + // 조건 차이 분석 + conditionDifferences: { + hasDifferences: boolean; + differences: string[]; + criticalDifferences: string[]; + }; + + // 비고 + generalRemark?: string; + technicalProposal?: string; + + // 품목별 견적 아이템 정보 추가 + quotationItems?: { + prItemId: number; + unitPrice: number; + totalPrice: number; + currency: string; + quantity: number; + deliveryDate?: Date | null; + leadTime?: number; + manufacturer?: string; + modelNo?: string; + }[]; +} + +export interface VendorComparison { + vendorId: number; + vendorName: string; + vendorCode: string; + vendorCountry?: string; + + // 구매자 제시 조건 (모든 차수에 공통) buyerConditions: { currency: string; paymentTermsCode: string; @@ -81,7 +130,25 @@ export interface VendorComparison { materialPriceRelatedYn: boolean; }; - // 벤더 제안 조건 + // 차수별 응답 배열 (최신 순) + responses: VendorResponseVersion[]; + + // 최신 응답 정보 (편의성) + latestResponse?: VendorResponseVersion; + + // 응답 정보 (레거시, 하위 호환) + responseId: number; + participationStatus: string; + responseStatus: string; + submittedAt: Date | null; + + // 가격 정보 (최신 응답 기준, 레거시 호환) + totalAmount: number; + currency: string; + rank?: number; + priceVariance?: number; + + // 레거시 호환: 최신 응답의 조건 정보 vendorConditions: { currency?: string; paymentTermsCode?: string; @@ -101,14 +168,14 @@ export interface VendorComparison { materialPriceRelatedReason?: string; }; - // 조건 차이 분석 + // 레거시 호환: 최신 응답의 조건 차이 분석 conditionDifferences: { hasDifferences: boolean; differences: string[]; criticalDifferences: string[]; }; - // 비고 + // 레거시 호환: 최신 응답의 비고 generalRemark?: string; technicalProposal?: string; @@ -188,7 +255,7 @@ export async function getComparisonData( if (!rfqData[0]) return null; - // 2. 벤더별 정보, 응답, 선정 정보 조회 + // 2a. 벤더 기본 정보 + 구매자 조건 + 선정 정보 조회 const vendorData = await db .select({ // 벤더 정보 @@ -228,14 +295,30 @@ export async function getComparisonData( contractStatus: rfqLastDetails.contractStatus, contractNo: rfqLastDetails.contractNo, contractCreatedAt: rfqLastDetails.contractCreatedAt, - - // 벤더 응답 + }) + .from(vendors) + .innerJoin( + rfqLastDetails, + and( + eq(rfqLastDetails.vendorsId, vendors.id), + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.isLatest, true) + ) + ) + .where(inArray(vendors.id, vendorIds)); + + // 2b. 모든 차수의 벤더 응답 조회 (isLatest 조건 제거) + const allVendorResponses = await db + .select({ responseId: rfqLastVendorResponses.id, + vendorId: rfqLastVendorResponses.vendorId, + responseVersion: rfqLastVendorResponses.responseVersion, + isLatest: rfqLastVendorResponses.isLatest, participationStatus: rfqLastVendorResponses.participationStatus, responseStatus: rfqLastVendorResponses.status, submittedAt: rfqLastVendorResponses.submittedAt, totalAmount: rfqLastVendorResponses.totalAmount, - responseCurrency: rfqLastVendorResponses.currency, + currency: rfqLastVendorResponses.currency, // 벤더 제안 조건 vendorCurrency: rfqLastVendorResponses.vendorCurrency, @@ -260,24 +343,14 @@ export async function getComparisonData( generalRemark: rfqLastVendorResponses.generalRemark, technicalProposal: rfqLastVendorResponses.technicalProposal, }) - .from(vendors) - .innerJoin( - rfqLastDetails, - and( - eq(rfqLastDetails.vendorsId, vendors.id), - eq(rfqLastDetails.rfqsLastId, rfqId), - eq(rfqLastDetails.isLatest, true) - ) - ) - .leftJoin( - rfqLastVendorResponses, + .from(rfqLastVendorResponses) + .where( and( - eq(rfqLastVendorResponses.vendorId, vendors.id), eq(rfqLastVendorResponses.rfqsLastId, rfqId), - eq(rfqLastVendorResponses.isLatest, true) + inArray(rfqLastVendorResponses.vendorId, vendorIds) ) ) - .where(inArray(vendors.id, vendorIds)); + .orderBy(asc(rfqLastVendorResponses.vendorId), asc(rfqLastVendorResponses.responseVersion)); // 3. 선정자 이름 조회 (선정된 업체가 있는 경우) const selectedVendor = vendorData.find(v => v.isSelected); @@ -328,90 +401,165 @@ export async function getComparisonData( .from(rfqPrItems) .where(eq(rfqPrItems.rfqsLastId, rfqId)); - // 6. 벤더별 견적 아이템 조회 - const quotationItems = await db - .select({ - vendorResponseId: rfqLastVendorQuotationItems.vendorResponseId, - prItemId: rfqLastVendorQuotationItems.rfqPrItemId, - unitPrice: rfqLastVendorQuotationItems.unitPrice, - totalPrice: rfqLastVendorQuotationItems.totalPrice, - currency: rfqLastVendorQuotationItems.currency, - quantity: rfqLastVendorQuotationItems.quantity, - deliveryDate: rfqLastVendorQuotationItems.vendorDeliveryDate, - leadTime: rfqLastVendorQuotationItems.leadTime, - manufacturer: rfqLastVendorQuotationItems.manufacturer, - modelNo: rfqLastVendorQuotationItems.modelNo, - technicalCompliance: rfqLastVendorQuotationItems.technicalCompliance, - alternativeProposal: rfqLastVendorQuotationItems.alternativeProposal, - itemRemark: rfqLastVendorQuotationItems.itemRemark, - }) - .from(rfqLastVendorQuotationItems) - .where( - inArray( - rfqLastVendorQuotationItems.vendorResponseId, - vendorData.map(v => v.responseId).filter(id => id != null) - ) - ); - - // 7. 데이터 가공 및 분석 - const validAmounts = vendorData - .map(v => v.totalAmount) - .filter(a => a != null && a > 0); - - const minAmount = Math.min(...validAmounts); - const maxAmount = Math.max(...validAmounts); - const avgAmount = validAmounts.reduce((a, b) => a + b, 0) / validAmounts.length; - - // 8. 벤더별 비교 데이터 구성 - const vendorComparisons: VendorComparison[] = vendorData.map((v, index) => { - const differences: string[] = []; - const criticalDifferences: string[] = []; - - // 조건 차이 분석 - if (v.vendorCurrency && v.vendorCurrency !== v.buyerCurrency) { - criticalDifferences.push(`통화: ${v.buyerCurrency} → ${v.vendorCurrency}`); - } - - if (v.vendorPaymentTermsCode && v.vendorPaymentTermsCode !== v.buyerPaymentTermsCode) { - differences.push(`지급조건: ${v.buyerPaymentTermsCode} → ${v.vendorPaymentTermsCode}`); - } - - if (v.vendorIncotermsCode && v.vendorIncotermsCode !== v.buyerIncotermsCode) { - differences.push(`인코텀즈: ${v.buyerIncotermsCode} → ${v.vendorIncotermsCode}`); + // 6. 벤더별 견적 아이템 조회 (모든 응답 버전 포함) + const allResponseIds = allVendorResponses.map(r => r.responseId); + const quotationItems = allResponseIds.length > 0 + ? await db + .select({ + vendorResponseId: rfqLastVendorQuotationItems.vendorResponseId, + prItemId: rfqLastVendorQuotationItems.rfqPrItemId, + unitPrice: rfqLastVendorQuotationItems.unitPrice, + totalPrice: rfqLastVendorQuotationItems.totalPrice, + currency: rfqLastVendorQuotationItems.currency, + quantity: rfqLastVendorQuotationItems.quantity, + deliveryDate: rfqLastVendorQuotationItems.vendorDeliveryDate, + leadTime: rfqLastVendorQuotationItems.leadTime, + manufacturer: rfqLastVendorQuotationItems.manufacturer, + modelNo: rfqLastVendorQuotationItems.modelNo, + technicalCompliance: rfqLastVendorQuotationItems.technicalCompliance, + alternativeProposal: rfqLastVendorQuotationItems.alternativeProposal, + itemRemark: rfqLastVendorQuotationItems.itemRemark, + }) + .from(rfqLastVendorQuotationItems) + .where(inArray(rfqLastVendorQuotationItems.vendorResponseId, allResponseIds)) + : []; + + // 7. 데이터 가공 및 분석 - 각 벤더별 최신 차수 기준으로 평균 계산 + // 각 벤더별로 가장 높은 responseVersion을 가진 응답 찾기 + const latestResponsesByVendor = new Map<number, typeof allVendorResponses[0]>(); + allVendorResponses.forEach(response => { + const existing = latestResponsesByVendor.get(response.vendorId); + if (!existing || response.responseVersion > existing.responseVersion) { + latestResponsesByVendor.set(response.vendorId, response); } + }); + + const latestResponses = Array.from(latestResponsesByVendor.values()); + const validAmounts = latestResponses + .map(r => r.totalAmount) + .filter((a): a is number => a != null && a > 0); + + console.log("Latest responses:", latestResponses.map(r => ({ + vendorId: r.vendorId, + version: r.responseVersion, + amount: r.totalAmount, + isLatest: r.isLatest + }))); + console.log("Valid amounts for average:", validAmounts); + + const minAmount = validAmounts.length > 0 ? Math.min(...validAmounts) : 0; + const maxAmount = validAmounts.length > 0 ? Math.max(...validAmounts) : 0; + const avgAmount = validAmounts.length > 0 + ? validAmounts.reduce((a, b) => a + b, 0) / validAmounts.length + : 0; + + // 8. 벤더별 비교 데이터 구성 (차수별 응답 포함) + const vendorComparisons: VendorComparison[] = vendorData.map((v) => { + // 이 벤더의 모든 응답 가져오기 + const vendorResponses = allVendorResponses.filter(r => r.vendorId === v.vendorId); - if (v.vendorDeliveryDate && v.buyerDeliveryDate) { - const buyerDate = new Date(v.buyerDeliveryDate); - const vendorDate = new Date(v.vendorDeliveryDate); - if (vendorDate > buyerDate) { - criticalDifferences.push(`납기: ${Math.ceil((vendorDate.getTime() - buyerDate.getTime()) / (1000 * 60 * 60 * 24))}일 지연`); + // 차수별 응답 정보 구성 + const responses: VendorResponseVersion[] = vendorResponses.map(resp => { + const differences: string[] = []; + const criticalDifferences: string[] = []; + + // 조건 차이 분석 + if (resp.vendorCurrency && resp.vendorCurrency !== v.buyerCurrency) { + criticalDifferences.push(`통화: ${v.buyerCurrency} → ${resp.vendorCurrency}`); + } + + if (resp.vendorPaymentTermsCode && resp.vendorPaymentTermsCode !== v.buyerPaymentTermsCode) { + differences.push(`지급조건: ${v.buyerPaymentTermsCode} → ${resp.vendorPaymentTermsCode}`); + } + + if (resp.vendorIncotermsCode && resp.vendorIncotermsCode !== v.buyerIncotermsCode) { + differences.push(`인코텀즈: ${v.buyerIncotermsCode} → ${resp.vendorIncotermsCode}`); + } + + if (resp.vendorDeliveryDate && v.buyerDeliveryDate) { + const buyerDate = new Date(v.buyerDeliveryDate); + const vendorDate = new Date(resp.vendorDeliveryDate); + if (vendorDate > buyerDate) { + criticalDifferences.push(`납기: ${Math.ceil((vendorDate.getTime() - buyerDate.getTime()) / (1000 * 60 * 60 * 24))}일 지연`); + } } - } - if (v.vendorFirstAcceptance === "거부" && v.buyerFirstYn) { - criticalDifferences.push("초도품 거부"); - } - - if (v.vendorSparepartAcceptance === "거부" && v.buyerSparepartYn) { - criticalDifferences.push("스페어파트 거부"); - } + if (resp.vendorFirstAcceptance === "거부" && v.buyerFirstYn) { + criticalDifferences.push("초도품 거부"); + } + + if (resp.vendorSparepartAcceptance === "거부" && v.buyerSparepartYn) { + criticalDifferences.push("스페어파트 거부"); + } + // 이 응답의 품목별 견적 아이템 가져오기 + const responseQuotationItems = quotationItems + .filter(q => q.vendorResponseId === resp.responseId) + .map(q => ({ + prItemId: q.prItemId, + unitPrice: q.unitPrice || 0, + totalPrice: q.totalPrice || 0, + currency: q.currency || "USD", + quantity: q.quantity || 0, + deliveryDate: q.deliveryDate, + leadTime: q.leadTime, + manufacturer: q.manufacturer, + modelNo: q.modelNo, + })); + + return { + responseId: resp.responseId, + responseVersion: resp.responseVersion, + participationStatus: resp.participationStatus || "미응답", + responseStatus: resp.responseStatus || "대기중", + submittedAt: resp.submittedAt, + + totalAmount: resp.totalAmount || 0, + currency: resp.currency || v.buyerCurrency || "USD", + rank: 0, // 나중에 계산 + priceVariance: resp.totalAmount ? ((resp.totalAmount - avgAmount) / avgAmount) * 100 : 0, + + vendorConditions: { + currency: resp.vendorCurrency || undefined, + paymentTermsCode: resp.vendorPaymentTermsCode || undefined, + paymentTermsDesc: paymentTermsMap.get(resp.vendorPaymentTermsCode || ""), + incotermsCode: resp.vendorIncotermsCode || undefined, + incotermsDesc: incotermsMap.get(resp.vendorIncotermsCode || ""), + deliveryDate: resp.vendorDeliveryDate, + contractDuration: resp.vendorContractDuration || undefined, + taxCode: resp.vendorTaxCode || undefined, + placeOfShipping: resp.vendorPlaceOfShipping || undefined, + placeOfDestination: resp.vendorPlaceOfDestination || undefined, + firstAcceptance: resp.vendorFirstAcceptance || undefined, + firstDescription: resp.vendorFirstDescription || undefined, + sparepartAcceptance: resp.vendorSparepartAcceptance || undefined, + sparepartDescription: resp.vendorSparepartDescription || undefined, + materialPriceRelatedYn: resp.vendorMaterialPriceRelatedYn || undefined, + materialPriceRelatedReason: resp.vendorMaterialPriceRelatedReason || undefined, + }, + + conditionDifferences: { + hasDifferences: differences.length > 0 || criticalDifferences.length > 0, + differences, + criticalDifferences, + }, + + generalRemark: resp.generalRemark, + technicalProposal: resp.technicalProposal, + quotationItems: responseQuotationItems, + }; + }); + + // 최신 응답 찾기 + const latestResp = responses.find(r => r.responseVersion === Math.max(...responses.map(r => r.responseVersion))); + return { vendorId: v.vendorId, vendorName: v.vendorName, - vendorCode: v.vendorCode, - vendorCountry: v.vendorCountry, - - responseId: v.responseId || 0, - participationStatus: v.participationStatus || "미응답", - responseStatus: v.responseStatus || "대기중", - submittedAt: v.submittedAt, - - totalAmount: v.totalAmount || 0, - currency: v.responseCurrency || v.buyerCurrency || "USD", - rank: 0, // 나중에 계산 - priceVariance: v.totalAmount ? ((v.totalAmount - avgAmount) / avgAmount) * 100 : 0, + vendorCode: v.vendorCode || "", + vendorCountry: v.vendorCountry || undefined, + // 구매자 제시 조건 buyerConditions: { currency: v.buyerCurrency || "USD", paymentTermsCode: v.buyerPaymentTermsCode || "", @@ -430,33 +578,48 @@ export async function getComparisonData( materialPriceRelatedYn: v.buyerMaterialPriceRelatedYn || false, }, - vendorConditions: { - currency: v.vendorCurrency, - paymentTermsCode: v.vendorPaymentTermsCode, - paymentTermsDesc: paymentTermsMap.get(v.vendorPaymentTermsCode || ""), - incotermsCode: v.vendorIncotermsCode, - incotermsDesc: incotermsMap.get(v.vendorIncotermsCode || ""), - deliveryDate: v.vendorDeliveryDate, - contractDuration: v.vendorContractDuration, - taxCode: v.vendorTaxCode, - placeOfShipping: v.vendorPlaceOfShipping, - placeOfDestination: v.vendorPlaceOfDestination, - firstAcceptance: v.vendorFirstAcceptance, - firstDescription: v.vendorFirstDescription, - sparepartAcceptance: v.vendorSparepartAcceptance, - sparepartDescription: v.vendorSparepartDescription, - materialPriceRelatedYn: v.vendorMaterialPriceRelatedYn, - materialPriceRelatedReason: v.vendorMaterialPriceRelatedReason, + // 차수별 응답 배열 + responses, + latestResponse: latestResp, + + // 레거시 호환 필드 (최신 응답 기준) + responseId: latestResp?.responseId || 0, + participationStatus: latestResp?.participationStatus || "미응답", + responseStatus: latestResp?.responseStatus || "대기중", + submittedAt: latestResp?.submittedAt || null, + + totalAmount: latestResp?.totalAmount || 0, + currency: latestResp?.currency || v.buyerCurrency || "USD", + rank: 0, // 나중에 계산 + priceVariance: latestResp?.priceVariance || 0, + + vendorConditions: latestResp?.vendorConditions || { + currency: undefined, + paymentTermsCode: undefined, + paymentTermsDesc: undefined, + incotermsCode: undefined, + incotermsDesc: undefined, + deliveryDate: null, + contractDuration: undefined, + taxCode: undefined, + placeOfShipping: undefined, + placeOfDestination: undefined, + firstAcceptance: undefined, + firstDescription: undefined, + sparepartAcceptance: undefined, + sparepartDescription: undefined, + materialPriceRelatedYn: undefined, + materialPriceRelatedReason: undefined, }, - conditionDifferences: { - hasDifferences: differences.length > 0 || criticalDifferences.length > 0, - differences, - criticalDifferences, + conditionDifferences: latestResp?.conditionDifferences || { + hasDifferences: false, + differences: [], + criticalDifferences: [], }, - generalRemark: v.generalRemark, - technicalProposal: v.technicalProposal, + generalRemark: latestResp?.generalRemark, + technicalProposal: latestResp?.technicalProposal, // 선정 관련 정보 isSelected: v.isSelected || false, @@ -487,7 +650,10 @@ export async function getComparisonData( const itemQuotes = quotationItems .filter(q => q.prItemId === item.id) .map(q => { - const vendor = vendorData.find(v => v.responseId === q.vendorResponseId); + // vendorResponseId로 응답 찾기 + const response = allVendorResponses.find(r => r.responseId === q.vendorResponseId); + // 그 응답의 vendorId로 벤더 정보 찾기 + const vendor = vendorData.find(v => v.vendorId === response?.vendorId); return { vendorId: vendor?.vendorId || 0, vendorName: vendor?.vendorName || "", @@ -495,13 +661,13 @@ export async function getComparisonData( totalPrice: q.totalPrice || 0, currency: q.currency || "USD", quotedQuantity: q.quantity || 0, - deliveryDate: q.deliveryDate, - leadTime: q.leadTime, - manufacturer: q.manufacturer, - modelNo: q.modelNo, + deliveryDate: q.deliveryDate || undefined, + leadTime: q.leadTime || undefined, + manufacturer: q.manufacturer || undefined, + modelNo: q.modelNo || undefined, technicalCompliance: q.technicalCompliance || true, - alternativeProposal: q.alternativeProposal, - itemRemark: q.itemRemark, + alternativeProposal: q.alternativeProposal || undefined, + itemRemark: q.itemRemark || undefined, priceRank: 0, }; }); @@ -555,7 +721,14 @@ export async function getComparisonData( // 11. 최종 데이터 반환 return { - rfqInfo: rfqData[0], + rfqInfo: { + ...rfqData[0], + rfqCode: rfqData[0].rfqCode || "", + rfqTitle: rfqData[0].rfqTitle || "", + rfqType: rfqData[0].rfqType || "", + packageNo: rfqData[0].packageNo || undefined, + packageName: rfqData[0].packageName || undefined, + }, vendors: vendorComparisons, prItems: prItemComparisons, summary: { @@ -586,11 +759,17 @@ interface SelectVendorParams { priceRank: number; hasConditionDifferences: boolean; criticalDifferences: string[]; - userId: number; // 현재 사용자 ID } export async function selectVendor(params: SelectVendorParams) { try { + // 세션에서 사용자 ID 가져오기 + const session = await getServerSession(authOptions); + if (!session?.user) { + throw new Error("인증이 필요합니다."); + } + const userId = Number(session.user.id); + // 트랜잭션 시작 const result = await db.transaction(async (tx) => { // 1. RFQ 상태 확인 @@ -613,7 +792,7 @@ export async function selectVendor(params: SelectVendorParams) { .set({ isSelected: false, updatedAt: new Date(), - updatedBy: params.userId + updatedBy: userId }) .where( and( @@ -629,11 +808,11 @@ export async function selectVendor(params: SelectVendorParams) { isSelected: true, selectionDate: new Date(), selectionReason: params.selectionReason, - selectedBy: params.userId, + selectedBy: userId, totalAmount: params.totalAmount.toString(), priceRank: params.priceRank, updatedAt: new Date(), - updatedBy: params.userId, + updatedBy: userId, }) .where( and( @@ -868,16 +1047,8 @@ export async function processSelectionApproval( throw new Error("선정된 업체를 찾을 수 없습니다."); } - // 승인된 경우 RFQ 상태 업데이트 - if (action === "승인") { - await tx - .update(rfqsLast) - .set({ - status: "계약 진행중", - updatedAt: new Date(), - }) - .where(eq(rfqsLast.id, rfqId)); - } + // 승인된 경우 RFQ 상태는 이미 "최종업체선정"이므로 별도 업데이트 불필요 + // (선정 시 이미 "최종업체선정"으로 설정됨) }); revalidatePath(`/evcp/rfq-last/${rfqId}`); @@ -950,8 +1121,8 @@ export async function getRfqQuotationStatus(rfqId: number) { .select({ id: rfqLastDetails.id, vendorId: rfqLastDetails.vendorsId, - vendorName: vendors.name, - vendorCode: vendors.code, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, totalAmount: rfqLastDetails.totalAmount, currency: rfqLastDetails.currency, priceRank: rfqLastDetails.priceRank, diff --git a/lib/rfq-last/contract-actions.ts b/lib/rfq-last/contract-actions.ts index 082716a0..1f86352a 100644 --- a/lib/rfq-last/contract-actions.ts +++ b/lib/rfq-last/contract-actions.ts @@ -146,9 +146,10 @@ export async function createGeneralContract(params: CreateGeneralContractParams) .where(eq(rfqPrItems.rfqsLastId, params.rfqId)); // 3. 계약번호 생성 - generateContractNumber 함수 사용 + // 매개변수 순서: (userId, contractType) const contractNumber = await generateContractNumber( - params.contractType, // 계약종류 (UP, LE, IL 등) - rfqData.rfq.picCode || undefined // 발주담당자 코드 + rfqData.rfq.picCode || undefined, // 발주담당자 코드 (userId) + params.contractType // 계약종류 (UP, LE, IL 등) ); // 4. 트랜잭션으로 계약 생성 diff --git a/lib/rfq-last/quotation-compare-view.tsx b/lib/rfq-last/quotation-compare-view.tsx index 91d46295..723d1044 100644 --- a/lib/rfq-last/quotation-compare-view.tsx +++ b/lib/rfq-last/quotation-compare-view.tsx @@ -43,7 +43,13 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; -import { ComparisonData, selectVendor, cancelVendorSelection } from "./compare-action"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ComparisonData, selectVendor, cancelVendorSelection, VendorResponseVersion } from "./compare-action"; import { createPO, createGeneralContract, createBidding } from "./contract-actions"; import { toast } from "sonner"; import { useRouter } from "next/navigation" @@ -58,6 +64,9 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { const [showSelectionDialog, setShowSelectionDialog] = React.useState(false); const [showCancelDialog, setShowCancelDialog] = React.useState(false); const [showContractDialog, setShowContractDialog] = React.useState(false); + const [showItemDetailsDialog, setShowItemDetailsDialog] = React.useState(false); + const [selectedResponse, setSelectedResponse] = React.useState<VendorResponseVersion | null>(null); + const [selectedVendorName, setSelectedVendorName] = React.useState<string>(""); const [selectedContractType, setSelectedContractType] = React.useState<"PO" | "CONTRACT" | "BIDDING" | "">(""); const [selectionReason, setSelectionReason] = React.useState(""); const [cancelReason, setCancelReason] = React.useState(""); @@ -88,7 +97,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { ]; // 입찰 관련 state 추가 - const [biddingContractType, setBiddingContractType] = React.useState<"unit_price" | "general" | "sale" | "">(""); + const [biddingContractType, setBiddingContractType] = React.useState<"unit_price" | "general" | "sale">("unit_price"); const [biddingType, setBiddingType] = React.useState<string>(""); const [awardCount, setAwardCount] = React.useState<"single" | "multiple">("single"); const [biddingStartDate, setBiddingStartDate] = React.useState(""); @@ -236,7 +245,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { setSelectedGeneralContractType(""); setContractStartDate(""); setContractEndDate(""); - setBiddingContractType(""); + setBiddingContractType("unit_price"); setBiddingType(""); setAwardCount("single"); setBiddingStartDate(""); @@ -323,17 +332,23 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { throw new Error("선택한 업체를 찾을 수 없습니다."); } + // 최신 응답 정보 사용 + const latestResponse = vendor.responses[0]; + if (!latestResponse) { + throw new Error("업체의 견적 정보를 찾을 수 없습니다."); + } + const result = await selectVendor({ rfqId: data.rfqInfo.id, vendorId: vendor.vendorId, vendorName: vendor.vendorName, vendorCode: vendor.vendorCode, - totalAmount: vendor.totalAmount, - currency: vendor.currency, + totalAmount: latestResponse.totalAmount, + currency: latestResponse.currency, selectionReason: selectionReason, - priceRank: vendor.rank || 0, - hasConditionDifferences: vendor.conditionDifferences.hasDifferences, - criticalDifferences: vendor.conditionDifferences.criticalDifferences, + priceRank: latestResponse.rank || 0, + hasConditionDifferences: latestResponse.conditionDifferences.hasDifferences, + criticalDifferences: latestResponse.conditionDifferences.criticalDifferences, }); if (result.success) { @@ -526,7 +541,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { )} {/* 요약 카드 */} - <div className="grid grid-cols-1 md:grid-cols-4 gap-4"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> {/* 최저가 벤더 */} <Card> <CardHeader className="pb-3"> @@ -577,147 +592,111 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { </CardContent> </Card> )} - - {/* 가격 범위 */} - <Card> - <CardHeader className="pb-3"> - <CardTitle className="text-sm font-medium flex items-center gap-2"> - <TrendingUp className="h-4 w-4" /> - 가격 범위 - </CardTitle> - </CardHeader> - <CardContent> - <p className="text-lg font-bold"> - {((data.summary.priceRange.max - data.summary.priceRange.min) / data.summary.priceRange.min * 100).toFixed(1)}% - </p> - <p className="text-sm text-muted-foreground"> - 최저가 대비 최고가 차이 - </p> - </CardContent> - </Card> - - {/* 조건 불일치 */} - <Card> - <CardHeader className="pb-3"> - <CardTitle className="text-sm font-medium flex items-center gap-2"> - <AlertCircle className="h-4 w-4 text-orange-500" /> - 조건 불일치 - </CardTitle> - </CardHeader> - <CardContent> - <p className="text-lg font-bold"> - {data.vendors.filter(v => v.conditionDifferences.hasDifferences).length}개 - </p> - <p className="text-sm text-muted-foreground"> - 제시 조건과 차이 있음 - </p> - </CardContent> - </Card> </div> {/* 탭 뷰 */} <Tabs defaultValue="overview" className="w-full"> - <TabsList className="grid w-full grid-cols-4"> + <TabsList className="grid w-full grid-cols-2"> <TabsTrigger value="overview">종합 비교</TabsTrigger> <TabsTrigger value="conditions">조건 비교</TabsTrigger> - <TabsTrigger value="items">아이템별 비교</TabsTrigger> - <TabsTrigger value="analysis">상세 분석</TabsTrigger> </TabsList> {/* 종합 비교 */} <TabsContent value="overview" className="space-y-4"> <Card> <CardHeader> - <CardTitle>가격 순위 및 업체 선정</CardTitle> + <CardTitle>협력업체별 차수별 견적</CardTitle> </CardHeader> <CardContent> - <div className="space-y-4"> - {data.vendors.map((vendor) => ( - <div - key={vendor.vendorId} - className={cn( - "flex items-center justify-between p-4 border rounded-lg transition-colors", - vendor.isSelected - ? "bg-blue-100 border-blue-400 border-2" - : hasSelection - ? "opacity-60" - : selectedVendorId === vendor.vendorId.toString() - ? "bg-blue-50 border-blue-300 cursor-pointer" - : "hover:bg-gray-50 cursor-pointer" - )} - onClick={() => { - if (!hasSelection) { - setSelectedVendorId(vendor.vendorId.toString()); - } - }} - > - <div className="flex items-center gap-4"> - {!hasSelection && ( - <input - type="radio" - name="vendor-selection" - value={vendor.vendorId} - checked={selectedVendorId === vendor.vendorId.toString()} - onChange={(e) => setSelectedVendorId(e.target.value)} - className="h-4 w-4 text-blue-600" - /> - )} - <div + <div className="overflow-x-auto"> + <table className="w-full"> + <thead> + <tr className="border-b bg-gray-50"> + {!hasSelection && <th className="text-left p-3 font-semibold">선택</th>} + <th className="text-left p-3 font-semibold">협력사 코드</th> + <th className="text-left p-3 font-semibold">협력사 명</th> + {(() => { + // 모든 차수 추출 (중복 제거 및 내림차순 정렬) + const allVersions = Array.from( + new Set( + data.vendors.flatMap(v => v.responses.map(r => r.responseVersion)) + ) + ).sort((a, b) => b - a); + + return allVersions.map(version => ( + <th key={version} className="text-right p-3 font-semibold"> + {version}차 + </th> + )); + })()} + </tr> + </thead> + <tbody className="divide-y"> + {data.vendors.map((vendor) => ( + <tr + key={vendor.vendorId} className={cn( - "w-10 h-10 rounded-full flex items-center justify-center font-bold", - getRankColor(vendor.rank || 0) + "hover:bg-gray-50 transition-colors", + vendor.isSelected && "bg-blue-50" )} > - {vendor.rank} - </div> - <div> - <p className="font-semibold flex items-center gap-2"> - {vendor.vendorName} - {vendor.isSelected && ( - <Badge className="bg-blue-600">선정</Badge> - )} - </p> - <p className="text-sm text-muted-foreground"> - {vendor.vendorCode} • {vendor.vendorCountry} - </p> - </div> - </div> - - <div className="flex items-center gap-6"> - {/* 조건 차이 표시 */} - {vendor.conditionDifferences.criticalDifferences.length > 0 && ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - <Badge variant="destructive" className="gap-1"> - <AlertTriangle className="h-3 w-3" /> - 중요 차이 {vendor.conditionDifferences.criticalDifferences.length} - </Badge> - </TooltipTrigger> - <TooltipContent> - <div className="space-y-1"> - {vendor.conditionDifferences.criticalDifferences.map((diff, idx) => ( - <p key={idx} className="text-xs">{diff}</p> - ))} - </div> - </TooltipContent> - </Tooltip> - </TooltipProvider> - )} - - {/* 가격 정보 */} - <div className="text-right"> - <p className="text-lg font-bold"> - {formatAmount(vendor.totalAmount, vendor.currency)} - </p> - <p className={cn("text-sm", getVarianceColor(vendor.priceVariance || 0))}> - {vendor.priceVariance && vendor.priceVariance > 0 ? "+" : ""} - {vendor.priceVariance?.toFixed(1)}% vs 평균 - </p> - </div> - </div> - </div> - ))} + {!hasSelection && ( + <td className="p-3"> + <input + type="radio" + name="vendor-selection" + value={vendor.vendorId} + checked={selectedVendorId === vendor.vendorId.toString()} + onChange={(e) => setSelectedVendorId(e.target.value)} + className="h-4 w-4 text-blue-600" + /> + </td> + )} + <td className="p-3 text-sm font-medium">{vendor.vendorCode}</td> + <td className="p-3"> + <div className="flex items-center gap-2"> + <span className="font-semibold">{vendor.vendorName}</span> + {vendor.isSelected && ( + <Badge className="bg-blue-600 text-xs">선정</Badge> + )} + </div> + </td> + {(() => { + const allVersions = Array.from( + new Set( + data.vendors.flatMap(v => v.responses.map(r => r.responseVersion)) + ) + ).sort((a, b) => b - a); + + return allVersions.map(version => { + const response = vendor.responses.find(r => r.responseVersion === version); + + return ( + <td key={version} className="p-3 text-right"> + {response ? ( + <Button + variant="link" + size="sm" + className="h-auto p-0 font-bold text-blue-600 hover:text-blue-800" + onClick={() => { + setSelectedResponse(response); + setSelectedVendorName(vendor.vendorName); + setShowItemDetailsDialog(true); + }} + > + {formatAmount(response.totalAmount, response.currency)} + </Button> + ) : ( + <span className="text-sm text-gray-400">-</span> + )} + </td> + ); + }); + })()} + </tr> + ))} + </tbody> + </table> </div> </CardContent> </Card> @@ -733,16 +712,16 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <table className="w-full"> <thead> <tr className="border-b"> - <th className="text-left p-2">항목</th> - <th className="text-left p-2">구매자 제시</th> + <th className="text-left p-3 font-semibold">항목</th> + <th className="text-left p-3 font-semibold">구매자 제시</th> {data.vendors.map((vendor) => ( <th key={vendor.vendorId} className={cn( - "text-left p-2", + "text-left p-3 font-semibold", vendor.isSelected && "bg-blue-50" )}> {vendor.vendorName} {vendor.isSelected && ( - <Badge className="ml-2 bg-blue-600">선정</Badge> + <Badge className="ml-2 bg-blue-600 text-xs">선정</Badge> )} </th> ))} @@ -751,30 +730,33 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <tbody className="divide-y"> {/* 통화 */} <tr> - <td className="p-2 font-medium">통화</td> - <td className="p-2">{data.vendors[0]?.buyerConditions.currency}</td> - {data.vendors.map((vendor) => ( - <td key={vendor.vendorId} className={cn( - "p-2", - vendor.isSelected && "bg-blue-50" - )}> - <div className="flex items-center gap-2"> - {vendor.vendorConditions.currency || vendor.buyerConditions.currency} - {vendor.vendorConditions.currency !== vendor.buyerConditions.currency && ( - <Badge variant="outline" className="text-xs">변경</Badge> - )} - </div> - </td> - ))} + <td className="p-3 font-medium">통화</td> + <td className="p-3">{data.vendors[0]?.buyerConditions.currency}</td> + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; // 최신 응답 (이미 정렬되어 있음) + return ( + <td key={vendor.vendorId} className={cn( + "p-3", + vendor.isSelected && "bg-blue-50" + )}> + <div className="flex items-center gap-2"> + {latestResponse?.vendorConditions?.currency || vendor.buyerConditions.currency} + {latestResponse?.vendorConditions?.currency && latestResponse.vendorConditions.currency !== vendor.buyerConditions.currency && ( + <Badge variant="destructive" className="text-xs">변경</Badge> + )} + </div> + </td> + ); + })} </tr> {/* 지급조건 */} <tr> - <td className="p-2 font-medium">지급조건</td> - <td className="p-2"> + <td className="p-3 font-medium">지급조건</td> + <td className="p-3"> <TooltipProvider> <Tooltip> - <TooltipTrigger> + <TooltipTrigger className="cursor-help border-b border-dashed"> {data.vendors[0]?.buyerConditions.paymentTermsCode} </TooltipTrigger> <TooltipContent> @@ -783,327 +765,298 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { </Tooltip> </TooltipProvider> </td> - {data.vendors.map((vendor) => ( - <td key={vendor.vendorId} className={cn( - "p-2", - vendor.isSelected && "bg-blue-50" - )}> - <div className="flex items-center gap-2"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - {vendor.vendorConditions.paymentTermsCode || vendor.buyerConditions.paymentTermsCode} - </TooltipTrigger> - <TooltipContent> - {vendor.vendorConditions.paymentTermsDesc || vendor.buyerConditions.paymentTermsDesc} - </TooltipContent> - </Tooltip> - </TooltipProvider> - {vendor.vendorConditions.paymentTermsCode && - vendor.vendorConditions.paymentTermsCode !== vendor.buyerConditions.paymentTermsCode && ( - <Badge variant="outline" className="text-xs">변경</Badge> - )} - </div> - </td> - ))} + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; + return ( + <td key={vendor.vendorId} className={cn( + "p-3", + vendor.isSelected && "bg-blue-50" + )}> + <div className="flex items-center gap-2"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger className="cursor-help border-b border-dashed"> + {latestResponse?.vendorConditions?.paymentTermsCode || vendor.buyerConditions.paymentTermsCode} + </TooltipTrigger> + <TooltipContent> + {latestResponse?.vendorConditions?.paymentTermsDesc || vendor.buyerConditions.paymentTermsDesc} + </TooltipContent> + </Tooltip> + </TooltipProvider> + {latestResponse?.vendorConditions?.paymentTermsCode && + latestResponse.vendorConditions.paymentTermsCode !== vendor.buyerConditions.paymentTermsCode && ( + <Badge variant="outline" className="text-xs">변경</Badge> + )} + </div> + </td> + ); + })} </tr> - {/* 나머지 조건들도 동일한 패턴으로 처리 */} - </tbody> - </table> - </CardContent> - </Card> - </TabsContent> - - {/* 아이템별 비교 */} - <TabsContent value="items" className="space-y-4"> - <Card> - <CardHeader> - <CardTitle>PR 아이템별 가격 비교</CardTitle> - </CardHeader> - <CardContent> - <div className="space-y-2"> - {data.prItems.map((item) => ( - <Collapsible - key={item.prItemId} - open={expandedItems.has(item.prItemId)} - onOpenChange={() => toggleItemExpansion(item.prItemId)} - > - <div className="border rounded-lg"> - <CollapsibleTrigger className="w-full p-4 hover:bg-gray-50 transition-colors"> - <div className="flex items-center justify-between"> - <div className="flex items-center gap-4 text-left"> - <div className="flex items-center gap-2"> - {expandedItems.has(item.prItemId) ? ( - <ChevronUp className="h-4 w-4" /> - ) : ( - <ChevronDown className="h-4 w-4" /> + {/* 인코텀즈 */} + <tr> + <td className="p-3 font-medium">인코텀즈</td> + <td className="p-3"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger className="cursor-help border-b border-dashed"> + {data.vendors[0]?.buyerConditions.incotermsCode} + </TooltipTrigger> + <TooltipContent> + {data.vendors[0]?.buyerConditions.incotermsDesc} + </TooltipContent> + </Tooltip> + </TooltipProvider> + </td> + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; + return ( + <td key={vendor.vendorId} className={cn( + "p-3", + vendor.isSelected && "bg-blue-50" + )}> + <div className="flex items-center gap-2"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger className="cursor-help border-b border-dashed"> + {latestResponse?.vendorConditions?.incotermsCode || vendor.buyerConditions.incotermsCode} + </TooltipTrigger> + <TooltipContent> + {latestResponse?.vendorConditions?.incotermsDesc || vendor.buyerConditions.incotermsDesc} + </TooltipContent> + </Tooltip> + </TooltipProvider> + {latestResponse?.vendorConditions?.incotermsCode && + latestResponse.vendorConditions.incotermsCode !== vendor.buyerConditions.incotermsCode && ( + <Badge variant="outline" className="text-xs">변경</Badge> )} - <Package className="h-4 w-4 text-muted-foreground" /> - </div> - <div> - <p className="font-medium">{item.materialDescription}</p> - <p className="text-sm text-muted-foreground"> - {item.materialCode} • {item.prNo} • {item.requestedQuantity} {item.uom} - </p> - </div> </div> - <div className="text-right"> - <p className="text-sm text-muted-foreground">단가 범위</p> - <p className="font-semibold"> - {formatAmount(item.priceAnalysis.lowestPrice)} ~ {formatAmount(item.priceAnalysis.highestPrice)} - </p> - </div> - </div> - </CollapsibleTrigger> - - <CollapsibleContent> - <div className="p-4 pt-0"> - <table className="w-full"> - <thead> - <tr className="border-b text-sm"> - <th className="text-left p-2">벤더</th> - <th className="text-right p-2">단가</th> - <th className="text-right p-2">총액</th> - <th className="text-right p-2">수량</th> - <th className="text-left p-2">납기</th> - <th className="text-left p-2">제조사</th> - <th className="text-center p-2">순위</th> - </tr> - </thead> - <tbody className="divide-y"> - {item.vendorQuotes.map((quote) => ( - <tr key={quote.vendorId} className="text-sm"> - <td className="p-2 font-medium">{quote.vendorName}</td> - <td className="p-2 text-right"> - {formatAmount(quote.unitPrice, quote.currency)} - </td> - <td className="p-2 text-right"> - {formatAmount(quote.totalPrice, quote.currency)} - </td> - <td className="p-2 text-right">{quote.quotedQuantity}</td> - <td className="p-2"> - {quote.deliveryDate - ? format(new Date(quote.deliveryDate), "yyyy-MM-dd") - : quote.leadTime - ? `${quote.leadTime}일` - : "-"} - </td> - <td className="p-2"> - {quote.manufacturer && ( - <div> - <p>{quote.manufacturer}</p> - {quote.modelNo && ( - <p className="text-xs text-muted-foreground">{quote.modelNo}</p> - )} - </div> - )} - </td> - <td className="p-2 text-center"> - <Badge className={cn("", getRankColor(quote.priceRank || 0))}> - #{quote.priceRank} - </Badge> - </td> - </tr> - ))} - </tbody> - </table> - - {/* 가격 분석 요약 */} - <div className="mt-4 p-3 bg-gray-50 rounded-lg"> - <div className="grid grid-cols-4 gap-4 text-sm"> - <div> - <p className="text-muted-foreground">평균 단가</p> - <p className="font-semibold"> - {formatAmount(item.priceAnalysis.averagePrice)} - </p> - </div> - <div> - <p className="text-muted-foreground">가격 편차</p> - <p className="font-semibold"> - ±{formatAmount(item.priceAnalysis.priceVariance)} - </p> - </div> - <div> - <p className="text-muted-foreground">최저가 업체</p> - <p className="font-semibold"> - {item.vendorQuotes.find(q => q.priceRank === 1)?.vendorName} - </p> - </div> - <div> - <p className="text-muted-foreground">가격 차이</p> - <p className="font-semibold"> - {((item.priceAnalysis.highestPrice - item.priceAnalysis.lowestPrice) / - item.priceAnalysis.lowestPrice * 100).toFixed(1)}% - </p> - </div> - </div> - </div> - </div> - </CollapsibleContent> - </div> - </Collapsible> - ))} - </div> - </CardContent> - </Card> - </TabsContent> + </td> + ); + })} + </tr> - {/* 상세 분석 */} - <TabsContent value="analysis" className="space-y-4"> - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - {/* 위험 요소 분석 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - <AlertTriangle className="h-5 w-5 text-orange-500" /> - 위험 요소 분석 - </CardTitle> - </CardHeader> - <CardContent> - <div className="space-y-3"> - {data.vendors.map((vendor) => { - if (!vendor.conditionDifferences.hasDifferences) return null; - - return ( - <div key={vendor.vendorId} className="p-3 border rounded-lg"> - <p className="font-medium mb-2">{vendor.vendorName}</p> - {vendor.conditionDifferences.criticalDifferences.length > 0 && ( - <div className="space-y-1 mb-2"> - <p className="text-xs font-medium text-red-600">중요 차이점:</p> - {vendor.conditionDifferences.criticalDifferences.map((diff, idx) => ( - <p key={idx} className="text-xs text-red-600 pl-2">• {diff}</p> - ))} - </div> - )} - {vendor.conditionDifferences.differences.length > 0 && ( - <div className="space-y-1"> - <p className="text-xs font-medium text-orange-600">일반 차이점:</p> - {vendor.conditionDifferences.differences.map((diff, idx) => ( - <p key={idx} className="text-xs text-orange-600 pl-2">• {diff}</p> - ))} + {/* 선적지 */} + <tr> + <td className="p-3 font-medium">선적지</td> + <td className="p-3">{data.vendors[0]?.buyerConditions.placeOfShipping || "-"}</td> + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; + return ( + <td key={vendor.vendorId} className={cn( + "p-3", + vendor.isSelected && "bg-blue-50" + )}> + <div className="flex items-center gap-2"> + {latestResponse?.vendorConditions?.placeOfShipping || vendor.buyerConditions.placeOfShipping || "-"} + {latestResponse?.vendorConditions?.placeOfShipping && + latestResponse.vendorConditions.placeOfShipping !== vendor.buyerConditions.placeOfShipping && ( + <Badge variant="outline" className="text-xs">변경</Badge> + )} </div> - )} - </div> - ); - })} - </div> - </CardContent> - </Card> - - {/* 추천 사항 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - <Info className="h-5 w-5 text-blue-500" /> - 선정 추천 - </CardTitle> - </CardHeader> - <CardContent> - <div className="space-y-4"> - {/* 가격 기준 추천 */} - <div className="p-3 bg-green-50 border border-green-200 rounded-lg"> - <p className="font-medium text-green-800 mb-1">가격 우선 선정</p> - <p className="text-sm text-green-700"> - {data.vendors[0]?.vendorName} - {formatAmount(data.vendors[0]?.totalAmount || 0)} - </p> - {data.vendors[0]?.conditionDifferences.hasDifferences && ( - <p className="text-xs text-orange-600 mt-1"> - ⚠️ 조건 차이 검토 필요 - </p> - )} - </div> + </td> + ); + })} + </tr> - {/* 조건 준수 기준 추천 */} - <div className="p-3 bg-blue-50 border border-blue-200 rounded-lg"> - <p className="font-medium text-blue-800 mb-1">조건 준수 우선 선정</p> - {(() => { - const compliantVendor = data.vendors.find(v => !v.conditionDifferences.hasDifferences); - if (compliantVendor) { - return ( - <div> - <p className="text-sm text-blue-700"> - {compliantVendor.vendorName} - {formatAmount(compliantVendor.totalAmount)} - </p> - <p className="text-xs text-blue-600 mt-1"> - 모든 조건 충족 (가격 순위: #{compliantVendor.rank}) - </p> - </div> - ); - } + {/* 하역지 */} + <tr> + <td className="p-3 font-medium">하역지</td> + <td className="p-3">{data.vendors[0]?.buyerConditions.placeOfDestination || "-"}</td> + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; return ( - <p className="text-sm text-blue-700"> - 모든 조건을 충족하는 벤더 없음 - </p> + <td key={vendor.vendorId} className={cn( + "p-3", + vendor.isSelected && "bg-blue-50" + )}> + <div className="flex items-center gap-2"> + {latestResponse?.vendorConditions?.placeOfDestination || vendor.buyerConditions.placeOfDestination || "-"} + {latestResponse?.vendorConditions?.placeOfDestination && + latestResponse.vendorConditions.placeOfDestination !== vendor.buyerConditions.placeOfDestination && ( + <Badge variant="outline" className="text-xs">변경</Badge> + )} + </div> + </td> ); - })()} - </div> + })} + </tr> - {/* 균형 추천 */} - <div className="p-3 bg-purple-50 border border-purple-200 rounded-lg"> - <p className="font-medium text-purple-800 mb-1">균형 선정 (추천)</p> - {(() => { - // 가격 순위와 조건 차이를 고려한 점수 계산 - const scoredVendors = data.vendors.map(v => ({ - ...v, - score: (v.rank || 10) + v.conditionDifferences.criticalDifferences.length * 3 + - v.conditionDifferences.differences.length - })); - scoredVendors.sort((a, b) => a.score - b.score); - const recommended = scoredVendors[0]; + {/* 납기일 */} + <tr> + <td className="p-3 font-medium">납기일</td> + <td className="p-3"> + {data.vendors[0]?.buyerConditions.deliveryDate + ? format(new Date(data.vendors[0].buyerConditions.deliveryDate), "yyyy-MM-dd") + : "-"} + </td> + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; + return ( + <td key={vendor.vendorId} className={cn( + "p-3", + vendor.isSelected && "bg-blue-50" + )}> + <div className="flex items-center gap-2"> + {latestResponse?.vendorConditions?.deliveryDate + ? format(new Date(latestResponse.vendorConditions.deliveryDate), "yyyy-MM-dd") + : vendor.buyerConditions.deliveryDate + ? format(new Date(vendor.buyerConditions.deliveryDate), "yyyy-MM-dd") + : "-"} + {latestResponse?.vendorConditions?.deliveryDate && vendor.buyerConditions.deliveryDate && + new Date(latestResponse.vendorConditions.deliveryDate).getTime() !== new Date(vendor.buyerConditions.deliveryDate).getTime() && ( + <Badge variant="outline" className="text-xs">변경</Badge> + )} + </div> + </td> + ); + })} + </tr> + {/* 세금조건 */} + <tr> + <td className="p-3 font-medium">세금조건</td> + <td className="p-3">{data.vendors[0]?.buyerConditions.taxCode || "-"}</td> + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; return ( - <div> - <p className="text-sm text-purple-700"> - {recommended.vendorName} - {formatAmount(recommended.totalAmount)} - </p> - <p className="text-xs text-purple-600 mt-1"> - 가격 순위 #{recommended.rank}, 조건 차이 최소화 - </p> - </div> + <td key={vendor.vendorId} className={cn( + "p-3", + vendor.isSelected && "bg-blue-50" + )}> + <div className="flex items-center gap-2"> + {latestResponse?.vendorConditions?.taxCode || vendor.buyerConditions.taxCode || "-"} + {latestResponse?.vendorConditions?.taxCode && + latestResponse.vendorConditions.taxCode !== vendor.buyerConditions.taxCode && ( + <Badge variant="outline" className="text-xs">변경</Badge> + )} + </div> + </td> ); - })()} - </div> - </div> - </CardContent> - </Card> - </div> + })} + </tr> - {/* 벤더별 비고사항 */} - {data.vendors.some(v => v.generalRemark || v.technicalProposal) && ( - <Card> - <CardHeader> - <CardTitle>벤더 제안사항 및 비고</CardTitle> - </CardHeader> - <CardContent> - <div className="space-y-4"> - {data.vendors.map((vendor) => { - if (!vendor.generalRemark && !vendor.technicalProposal) return null; - - return ( - <div key={vendor.vendorId} className="border rounded-lg p-4"> - <p className="font-medium mb-2">{vendor.vendorName}</p> - {vendor.generalRemark && ( - <div className="mb-2"> - <p className="text-sm font-medium text-muted-foreground">일반 비고:</p> - <p className="text-sm">{vendor.generalRemark}</p> + {/* 계약기간 */} + <tr> + <td className="p-3 font-medium">계약기간</td> + <td className="p-3">{data.vendors[0]?.buyerConditions.contractDuration || "-"}</td> + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; + return ( + <td key={vendor.vendorId} className={cn( + "p-3", + vendor.isSelected && "bg-blue-50" + )}> + <div className="flex items-center gap-2"> + {latestResponse?.vendorConditions?.contractDuration || vendor.buyerConditions.contractDuration || "-"} + {latestResponse?.vendorConditions?.contractDuration && + latestResponse.vendorConditions.contractDuration !== vendor.buyerConditions.contractDuration && ( + <Badge variant="outline" className="text-xs">변경</Badge> + )} </div> - )} - {vendor.technicalProposal && ( + </td> + ); + })} + </tr> + </tbody> + </table> + </CardContent> + </Card> + </TabsContent> + </Tabs> + + {/* 품목별 상세 정보 다이얼로그 */} + <Dialog open={showItemDetailsDialog} onOpenChange={setShowItemDetailsDialog}> + <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle> + {selectedVendorName} - {selectedResponse?.responseVersion}차 품목별 견적 상세 + </DialogTitle> + </DialogHeader> + + <div className="overflow-x-auto"> + <table className="w-full"> + <thead> + <tr className="border-b bg-gray-50"> + <th className="text-left p-3 font-semibold">품목코드</th> + <th className="text-left p-3 font-semibold">품목명</th> + <th className="text-right p-3 font-semibold">수량</th> + <th className="text-right p-3 font-semibold">단가</th> + <th className="text-right p-3 font-semibold">총액</th> + <th className="text-left p-3 font-semibold">납기</th> + <th className="text-left p-3 font-semibold">제조사</th> + </tr> + </thead> + <tbody className="divide-y"> + {selectedResponse?.quotationItems?.map((quoteItem) => { + const prItem = data.prItems.find(item => item.prItemId === quoteItem.prItemId); + if (!prItem) return null; + + return ( + <tr key={quoteItem.prItemId} className="hover:bg-gray-50"> + <td className="p-3 text-sm">{prItem.materialCode}</td> + <td className="p-3"> + <p className="font-medium">{prItem.materialDescription}</p> + <p className="text-xs text-muted-foreground"> + {prItem.prNo} • {prItem.prItem} + </p> + </td> + <td className="p-3 text-right"> + {quoteItem.quantity} {prItem.uom} + </td> + <td className="p-3 text-right font-medium"> + {formatAmount(quoteItem.unitPrice, quoteItem.currency)} + </td> + <td className="p-3 text-right font-bold"> + {formatAmount(quoteItem.totalPrice, quoteItem.currency)} + </td> + <td className="p-3 text-sm"> + {quoteItem.deliveryDate + ? format(new Date(quoteItem.deliveryDate), "yyyy-MM-dd") + : quoteItem.leadTime + ? `${quoteItem.leadTime}일` + : "-"} + </td> + <td className="p-3 text-sm"> + {quoteItem.manufacturer ? ( <div> - <p className="text-sm font-medium text-muted-foreground">기술 제안:</p> - <p className="text-sm">{vendor.technicalProposal}</p> + <p>{quoteItem.manufacturer}</p> + {quoteItem.modelNo && ( + <p className="text-xs text-muted-foreground">{quoteItem.modelNo}</p> + )} </div> - )} - </div> - ); - })} - </div> - </CardContent> - </Card> + ) : "-"} + </td> + </tr> + ); + })} + </tbody> + </table> + </div> + + {/* 비고사항 */} + {(selectedResponse?.generalRemark || selectedResponse?.technicalProposal) && ( + <div className="mt-6 pt-6 border-t"> + <h4 className="font-semibold mb-3">비고사항</h4> + <div className="space-y-3"> + {selectedResponse.generalRemark && ( + <div> + <p className="text-sm font-medium text-muted-foreground">일반 비고:</p> + <p className="text-sm">{selectedResponse.generalRemark}</p> + </div> + )} + {selectedResponse.technicalProposal && ( + <div> + <p className="text-sm font-medium text-muted-foreground">기술 제안:</p> + <p className="text-sm">{selectedResponse.technicalProposal}</p> + </div> + )} + </div> + </div> )} - </TabsContent> - </Tabs> + </DialogContent> + </Dialog> + {/* 업체 선정 모달 */} {showSelectionDialog && ( @@ -1113,57 +1066,62 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {hasSelection ? "업체 재선정 확인" : "업체 선정 확인"} </h3> - {selectedVendorId && ( - <div className="space-y-4"> - <div className="rounded-lg border p-4"> - <div className="space-y-2"> - <div className="flex justify-between"> - <span className="text-sm font-medium">선정 업체</span> - <span className="text-sm font-bold"> - {data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.vendorName} - </span> - </div> - <div className="flex justify-between"> - <span className="text-sm font-medium">견적 금액</span> - <span className="text-sm"> - {formatAmount( - data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.totalAmount || 0, - data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.currency - )} - </span> - </div> - <div className="flex justify-between"> - <span className="text-sm font-medium">가격 순위</span> - <span className="text-sm"> - #{data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.rank || 0} - </span> + {selectedVendorId && (() => { + const vendor = data.vendors.find(v => v.vendorId === parseInt(selectedVendorId)); + const latestResponse = vendor?.responses[0]; + + return ( + <div className="space-y-4"> + <div className="rounded-lg border p-4"> + <div className="space-y-2"> + <div className="flex justify-between"> + <span className="text-sm font-medium">선정 업체</span> + <span className="text-sm font-bold"> + {vendor?.vendorName} + </span> + </div> + <div className="flex justify-between"> + <span className="text-sm font-medium">견적 금액</span> + <span className="text-sm"> + {formatAmount( + latestResponse?.totalAmount || 0, + latestResponse?.currency || "USD" + )} + </span> + </div> + <div className="flex justify-between"> + <span className="text-sm font-medium">가격 순위</span> + <span className="text-sm"> + #{latestResponse?.rank || 0} + </span> + </div> + {latestResponse?.conditionDifferences.hasDifferences && ( + <Alert className="mt-2"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + 제시 조건과 차이가 있습니다. 선정 사유를 명확히 기재해주세요. + </AlertDescription> + </Alert> + )} </div> - {data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.conditionDifferences.hasDifferences && ( - <Alert className="mt-2"> - <AlertCircle className="h-4 w-4" /> - <AlertDescription> - 제시 조건과 차이가 있습니다. 선정 사유를 명확히 기재해주세요. - </AlertDescription> - </Alert> - )} </div> - </div> - <div className="space-y-2"> - <label htmlFor="selection-reason" className="text-sm font-medium"> - 선정 사유 * - </label> - <textarea - id="selection-reason" - className="w-full min-h-[100px] p-3 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" - placeholder="업체 선정 사유를 입력해주세요..." - value={selectionReason} - onChange={(e) => setSelectionReason(e.target.value)} - required - /> + <div className="space-y-2"> + <label htmlFor="selection-reason" className="text-sm font-medium"> + 선정 사유 * + </label> + <textarea + id="selection-reason" + className="w-full min-h-[100px] p-3 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="업체 선정 사유를 입력해주세요..." + value={selectionReason} + onChange={(e) => setSelectionReason(e.target.value)} + required + /> + </div> </div> - </div> - )} + ); + })()} <div className="flex justify-end gap-2 mt-6"> <Button @@ -1527,7 +1485,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { setSelectedGeneralContractType(""); setContractStartDate(""); setContractEndDate(""); - setBiddingContractType(""); + setBiddingContractType("unit_price"); setBiddingType(""); setAwardCount("single"); setBiddingStartDate(""); |
