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/quotation-compare-view.tsx | |
| parent | c49d5a42a66d1d29d477cca2ad56f923313c3961 (diff) | |
(최겸) 구매 견적 비교 수정(create-PO 개발 필)
Diffstat (limited to 'lib/rfq-last/quotation-compare-view.tsx')
| -rw-r--r-- | lib/rfq-last/quotation-compare-view.tsx | 970 |
1 files changed, 464 insertions, 506 deletions
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(""); |
