From 0ee0f102634dd703aea7ad0b8a338eb5e9bdadab Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 16 Oct 2025 09:26:56 +0000 Subject: (최겸) 구매 견적 비교 수정(create-PO 개발 필) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/rfq-last/quotation-compare-view.tsx | 970 +++++++++++++++----------------- 1 file changed, 464 insertions(+), 506 deletions(-) (limited to 'lib/rfq-last/quotation-compare-view.tsx') 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(null); + const [selectedVendorName, setSelectedVendorName] = React.useState(""); 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(""); 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) { )} {/* 요약 카드 */} -
+
{/* 최저가 벤더 */} @@ -577,147 +592,111 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { )} - - {/* 가격 범위 */} - - - - - 가격 범위 - - - -

- {((data.summary.priceRange.max - data.summary.priceRange.min) / data.summary.priceRange.min * 100).toFixed(1)}% -

-

- 최저가 대비 최고가 차이 -

-
-
- - {/* 조건 불일치 */} - - - - - 조건 불일치 - - - -

- {data.vendors.filter(v => v.conditionDifferences.hasDifferences).length}개 -

-

- 제시 조건과 차이 있음 -

-
-
{/* 탭 뷰 */} - + 종합 비교 조건 비교 - 아이템별 비교 - 상세 분석 {/* 종합 비교 */} - 가격 순위 및 업체 선정 + 협력업체별 차수별 견적 -
- {data.vendors.map((vendor) => ( -
{ - if (!hasSelection) { - setSelectedVendorId(vendor.vendorId.toString()); - } - }} - > -
- {!hasSelection && ( - setSelectedVendorId(e.target.value)} - className="h-4 w-4 text-blue-600" - /> - )} -
+ + + + {!hasSelection && } + + + {(() => { + // 모든 차수 추출 (중복 제거 및 내림차순 정렬) + 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 => ( + + )); + })()} + + + + {data.vendors.map((vendor) => ( + - {vendor.rank} - -
-

- {vendor.vendorName} - {vendor.isSelected && ( - 선정 - )} -

-

- {vendor.vendorCode} • {vendor.vendorCountry} -

-
- - -
- {/* 조건 차이 표시 */} - {vendor.conditionDifferences.criticalDifferences.length > 0 && ( - - - - - - 중요 차이 {vendor.conditionDifferences.criticalDifferences.length} - - - -
- {vendor.conditionDifferences.criticalDifferences.map((diff, idx) => ( -

{diff}

- ))} -
-
-
-
- )} - - {/* 가격 정보 */} -
-

- {formatAmount(vendor.totalAmount, vendor.currency)} -

-

- {vendor.priceVariance && vendor.priceVariance > 0 ? "+" : ""} - {vendor.priceVariance?.toFixed(1)}% vs 평균 -

-
-
- - ))} + {!hasSelection && ( + + )} + + + {(() => { + 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 ( + + ); + }); + })()} + + ))} + +
선택협력사 코드협력사 명 + {version}차 +
+ setSelectedVendorId(e.target.value)} + className="h-4 w-4 text-blue-600" + /> + {vendor.vendorCode} +
+ {vendor.vendorName} + {vendor.isSelected && ( + 선정 + )} +
+
+ {response ? ( + + ) : ( + - + )} +
@@ -733,16 +712,16 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { - - + + {data.vendors.map((vendor) => ( ))} @@ -751,30 +730,33 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {/* 통화 */} - - - {data.vendors.map((vendor) => ( - - ))} + + + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; // 최신 응답 (이미 정렬되어 있음) + return ( + + ); + })} {/* 지급조건 */} - - + - {data.vendors.map((vendor) => ( - - ))} + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; + return ( + + ); + })} - {/* 나머지 조건들도 동일한 패턴으로 처리 */} - -
항목구매자 제시항목구매자 제시 {vendor.vendorName} {vendor.isSelected && ( - 선정 + 선정 )}
통화{data.vendors[0]?.buyerConditions.currency} -
- {vendor.vendorConditions.currency || vendor.buyerConditions.currency} - {vendor.vendorConditions.currency !== vendor.buyerConditions.currency && ( - 변경 - )} -
-
통화{data.vendors[0]?.buyerConditions.currency} +
+ {latestResponse?.vendorConditions?.currency || vendor.buyerConditions.currency} + {latestResponse?.vendorConditions?.currency && latestResponse.vendorConditions.currency !== vendor.buyerConditions.currency && ( + 변경 + )} +
+
지급조건 + 지급조건 - + {data.vendors[0]?.buyerConditions.paymentTermsCode} @@ -783,327 +765,298 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { -
- - - - {vendor.vendorConditions.paymentTermsCode || vendor.buyerConditions.paymentTermsCode} - - - {vendor.vendorConditions.paymentTermsDesc || vendor.buyerConditions.paymentTermsDesc} - - - - {vendor.vendorConditions.paymentTermsCode && - vendor.vendorConditions.paymentTermsCode !== vendor.buyerConditions.paymentTermsCode && ( - 변경 - )} -
-
+
+ + + + {latestResponse?.vendorConditions?.paymentTermsCode || vendor.buyerConditions.paymentTermsCode} + + + {latestResponse?.vendorConditions?.paymentTermsDesc || vendor.buyerConditions.paymentTermsDesc} + + + + {latestResponse?.vendorConditions?.paymentTermsCode && + latestResponse.vendorConditions.paymentTermsCode !== vendor.buyerConditions.paymentTermsCode && ( + 변경 + )} +
+
- - - - - {/* 아이템별 비교 */} - - - - PR 아이템별 가격 비교 - - -
- {data.prItems.map((item) => ( - toggleItemExpansion(item.prItemId)} - > -
- -
-
-
- {expandedItems.has(item.prItemId) ? ( - - ) : ( - + {/* 인코텀즈 */} + + 인코텀즈 + + + + + {data.vendors[0]?.buyerConditions.incotermsCode} + + + {data.vendors[0]?.buyerConditions.incotermsDesc} + + + + + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; + return ( + +
+ + + + {latestResponse?.vendorConditions?.incotermsCode || vendor.buyerConditions.incotermsCode} + + + {latestResponse?.vendorConditions?.incotermsDesc || vendor.buyerConditions.incotermsDesc} + + + + {latestResponse?.vendorConditions?.incotermsCode && + latestResponse.vendorConditions.incotermsCode !== vendor.buyerConditions.incotermsCode && ( + 변경 )} - -
-
-

{item.materialDescription}

-

- {item.materialCode} • {item.prNo} • {item.requestedQuantity} {item.uom} -

-
-
-

단가 범위

-

- {formatAmount(item.priceAnalysis.lowestPrice)} ~ {formatAmount(item.priceAnalysis.highestPrice)} -

-
-
- - - -
- - - - - - - - - - - - - - {item.vendorQuotes.map((quote) => ( - - - - - - - - - - ))} - -
벤더단가총액수량납기제조사순위
{quote.vendorName} - {formatAmount(quote.unitPrice, quote.currency)} - - {formatAmount(quote.totalPrice, quote.currency)} - {quote.quotedQuantity} - {quote.deliveryDate - ? format(new Date(quote.deliveryDate), "yyyy-MM-dd") - : quote.leadTime - ? `${quote.leadTime}일` - : "-"} - - {quote.manufacturer && ( -
-

{quote.manufacturer}

- {quote.modelNo && ( -

{quote.modelNo}

- )} -
- )} -
- - #{quote.priceRank} - -
- - {/* 가격 분석 요약 */} -
-
-
-

평균 단가

-

- {formatAmount(item.priceAnalysis.averagePrice)} -

-
-
-

가격 편차

-

- ±{formatAmount(item.priceAnalysis.priceVariance)} -

-
-
-

최저가 업체

-

- {item.vendorQuotes.find(q => q.priceRank === 1)?.vendorName} -

-
-
-

가격 차이

-

- {((item.priceAnalysis.highestPrice - item.priceAnalysis.lowestPrice) / - item.priceAnalysis.lowestPrice * 100).toFixed(1)}% -

-
-
-
-
-
-
- - ))} -
- - - + + ); + })} + - {/* 상세 분석 */} - -
- {/* 위험 요소 분석 */} - - - - - 위험 요소 분석 - - - -
- {data.vendors.map((vendor) => { - if (!vendor.conditionDifferences.hasDifferences) return null; - - return ( -
-

{vendor.vendorName}

- {vendor.conditionDifferences.criticalDifferences.length > 0 && ( -
-

중요 차이점:

- {vendor.conditionDifferences.criticalDifferences.map((diff, idx) => ( -

• {diff}

- ))} -
- )} - {vendor.conditionDifferences.differences.length > 0 && ( -
-

일반 차이점:

- {vendor.conditionDifferences.differences.map((diff, idx) => ( -

• {diff}

- ))} + {/* 선적지 */} + + 선적지 + {data.vendors[0]?.buyerConditions.placeOfShipping || "-"} + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; + return ( + +
+ {latestResponse?.vendorConditions?.placeOfShipping || vendor.buyerConditions.placeOfShipping || "-"} + {latestResponse?.vendorConditions?.placeOfShipping && + latestResponse.vendorConditions.placeOfShipping !== vendor.buyerConditions.placeOfShipping && ( + 변경 + )}
- )} -
- ); - })} -
- - - - {/* 추천 사항 */} - - - - - 선정 추천 - - - -
- {/* 가격 기준 추천 */} -
-

가격 우선 선정

-

- {data.vendors[0]?.vendorName} - {formatAmount(data.vendors[0]?.totalAmount || 0)} -

- {data.vendors[0]?.conditionDifferences.hasDifferences && ( -

- ⚠️ 조건 차이 검토 필요 -

- )} -
+ + ); + })} + - {/* 조건 준수 기준 추천 */} -
-

조건 준수 우선 선정

- {(() => { - const compliantVendor = data.vendors.find(v => !v.conditionDifferences.hasDifferences); - if (compliantVendor) { - return ( -
-

- {compliantVendor.vendorName} - {formatAmount(compliantVendor.totalAmount)} -

-

- 모든 조건 충족 (가격 순위: #{compliantVendor.rank}) -

-
- ); - } + {/* 하역지 */} + + 하역지 + {data.vendors[0]?.buyerConditions.placeOfDestination || "-"} + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; return ( -

- 모든 조건을 충족하는 벤더 없음 -

+ +
+ {latestResponse?.vendorConditions?.placeOfDestination || vendor.buyerConditions.placeOfDestination || "-"} + {latestResponse?.vendorConditions?.placeOfDestination && + latestResponse.vendorConditions.placeOfDestination !== vendor.buyerConditions.placeOfDestination && ( + 변경 + )} +
+ ); - })()} -
+ })} + - {/* 균형 추천 */} -
-

균형 선정 (추천)

- {(() => { - // 가격 순위와 조건 차이를 고려한 점수 계산 - 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]; + {/* 납기일 */} + + 납기일 + + {data.vendors[0]?.buyerConditions.deliveryDate + ? format(new Date(data.vendors[0].buyerConditions.deliveryDate), "yyyy-MM-dd") + : "-"} + + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; + return ( + +
+ {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() && ( + 변경 + )} +
+ + ); + })} + + {/* 세금조건 */} + + 세금조건 + {data.vendors[0]?.buyerConditions.taxCode || "-"} + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; return ( -
-

- {recommended.vendorName} - {formatAmount(recommended.totalAmount)} -

-

- 가격 순위 #{recommended.rank}, 조건 차이 최소화 -

-
+ +
+ {latestResponse?.vendorConditions?.taxCode || vendor.buyerConditions.taxCode || "-"} + {latestResponse?.vendorConditions?.taxCode && + latestResponse.vendorConditions.taxCode !== vendor.buyerConditions.taxCode && ( + 변경 + )} +
+ ); - })()} -
-
-
-
-
+ })} + - {/* 벤더별 비고사항 */} - {data.vendors.some(v => v.generalRemark || v.technicalProposal) && ( - - - 벤더 제안사항 및 비고 - - -
- {data.vendors.map((vendor) => { - if (!vendor.generalRemark && !vendor.technicalProposal) return null; - - return ( -
-

{vendor.vendorName}

- {vendor.generalRemark && ( -
-

일반 비고:

-

{vendor.generalRemark}

+ {/* 계약기간 */} + + 계약기간 + {data.vendors[0]?.buyerConditions.contractDuration || "-"} + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; + return ( + +
+ {latestResponse?.vendorConditions?.contractDuration || vendor.buyerConditions.contractDuration || "-"} + {latestResponse?.vendorConditions?.contractDuration && + latestResponse.vendorConditions.contractDuration !== vendor.buyerConditions.contractDuration && ( + 변경 + )}
- )} - {vendor.technicalProposal && ( + + ); + })} + + + + + + + + + {/* 품목별 상세 정보 다이얼로그 */} + + + + + {selectedVendorName} - {selectedResponse?.responseVersion}차 품목별 견적 상세 + + + +
+ + + + + + + + + + + + + + {selectedResponse?.quotationItems?.map((quoteItem) => { + const prItem = data.prItems.find(item => item.prItemId === quoteItem.prItemId); + if (!prItem) return null; + + return ( + + + + + + + + + + ); + })} + +
품목코드품목명수량단가총액납기제조사
{prItem.materialCode} +

{prItem.materialDescription}

+

+ {prItem.prNo} • {prItem.prItem} +

+
+ {quoteItem.quantity} {prItem.uom} + + {formatAmount(quoteItem.unitPrice, quoteItem.currency)} + + {formatAmount(quoteItem.totalPrice, quoteItem.currency)} + + {quoteItem.deliveryDate + ? format(new Date(quoteItem.deliveryDate), "yyyy-MM-dd") + : quoteItem.leadTime + ? `${quoteItem.leadTime}일` + : "-"} + + {quoteItem.manufacturer ? (
-

기술 제안:

-

{vendor.technicalProposal}

+

{quoteItem.manufacturer}

+ {quoteItem.modelNo && ( +

{quoteItem.modelNo}

+ )}
- )} - - ); - })} - - - + ) : "-"} +
+
+ + {/* 비고사항 */} + {(selectedResponse?.generalRemark || selectedResponse?.technicalProposal) && ( +
+

비고사항

+
+ {selectedResponse.generalRemark && ( +
+

일반 비고:

+

{selectedResponse.generalRemark}

+
+ )} + {selectedResponse.technicalProposal && ( +
+

기술 제안:

+

{selectedResponse.technicalProposal}

+
+ )} +
+
)} - - +
+
+ {/* 업체 선정 모달 */} {showSelectionDialog && ( @@ -1113,57 +1066,62 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {hasSelection ? "업체 재선정 확인" : "업체 선정 확인"} - {selectedVendorId && ( -
-
-
-
- 선정 업체 - - {data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.vendorName} - -
-
- 견적 금액 - - {formatAmount( - data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.totalAmount || 0, - data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.currency - )} - -
-
- 가격 순위 - - #{data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.rank || 0} - + {selectedVendorId && (() => { + const vendor = data.vendors.find(v => v.vendorId === parseInt(selectedVendorId)); + const latestResponse = vendor?.responses[0]; + + return ( +
+
+
+
+ 선정 업체 + + {vendor?.vendorName} + +
+
+ 견적 금액 + + {formatAmount( + latestResponse?.totalAmount || 0, + latestResponse?.currency || "USD" + )} + +
+
+ 가격 순위 + + #{latestResponse?.rank || 0} + +
+ {latestResponse?.conditionDifferences.hasDifferences && ( + + + + 제시 조건과 차이가 있습니다. 선정 사유를 명확히 기재해주세요. + + + )}
- {data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.conditionDifferences.hasDifferences && ( - - - - 제시 조건과 차이가 있습니다. 선정 사유를 명확히 기재해주세요. - - - )}
-
-
- -