diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-15 14:41:01 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-15 14:41:01 +0000 |
| commit | 4ee8b24cfadf47452807fa2af801385ed60ab47c (patch) | |
| tree | e1d1fb029f0cf5519c517494bf9a545505c35700 /lib/rfq-last/quotation-compare-view.tsx | |
| parent | 265859d691a01cdcaaf9154f93c38765bc34df06 (diff) | |
(대표님) 작업사항 - rfqLast, tbeLast, pdfTron, userAuth
Diffstat (limited to 'lib/rfq-last/quotation-compare-view.tsx')
| -rw-r--r-- | lib/rfq-last/quotation-compare-view.tsx | 755 |
1 files changed, 755 insertions, 0 deletions
diff --git a/lib/rfq-last/quotation-compare-view.tsx b/lib/rfq-last/quotation-compare-view.tsx new file mode 100644 index 00000000..0e15a7bf --- /dev/null +++ b/lib/rfq-last/quotation-compare-view.tsx @@ -0,0 +1,755 @@ +"use client"; + +import * as React from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Trophy, + TrendingUp, + TrendingDown, + AlertCircle, + CheckCircle, + XCircle, + ChevronDown, + ChevronUp, + Info, + DollarSign, + Calendar, + Package, + Globe, + FileText, + Truck, + AlertTriangle, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import type { ComparisonData, VendorComparison, PrItemComparison } from "../actions"; + +interface QuotationCompareViewProps { + data: ComparisonData; +} + +export function QuotationCompareView({ data }: QuotationCompareViewProps) { + const [expandedItems, setExpandedItems] = React.useState<Set<number>>(new Set()); + const [selectedMetric, setSelectedMetric] = React.useState<"price" | "delivery" | "compliance">("price"); + + // 아이템 확장/축소 토글 + const toggleItemExpansion = (itemId: number) => { + setExpandedItems((prev) => { + const newSet = new Set(prev); + if (newSet.has(itemId)) { + newSet.delete(itemId); + } else { + newSet.add(itemId); + } + return newSet; + }); + }; + + // 순위에 따른 색상 + const getRankColor = (rank: number) => { + switch (rank) { + case 1: + return "text-green-600 bg-green-50"; + case 2: + return "text-blue-600 bg-blue-50"; + case 3: + return "text-orange-600 bg-orange-50"; + default: + return "text-gray-600 bg-gray-50"; + } + }; + + // 가격 차이 색상 + const getVarianceColor = (variance: number) => { + if (variance < -5) return "text-green-600"; + if (variance > 5) return "text-red-600"; + return "text-gray-600"; + }; + + // 조건 일치 여부 아이콘 + const getComplianceIcon = (matches: boolean) => { + return matches ? ( + <CheckCircle className="h-4 w-4 text-green-500" /> + ) : ( + <XCircle className="h-4 w-4 text-red-500" /> + ); + }; + + // 금액 포맷 + const formatAmount = (amount: number, currency: string = "USD") => { + return new Intl.NumberFormat("ko-KR", { + style: "currency", + currency: currency, + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(amount); + }; + + return ( + <div className="space-y-6"> + {/* 요약 카드 */} + <div className="grid grid-cols-1 md:grid-cols-4 gap-4"> + {/* 최저가 벤더 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-sm font-medium flex items-center gap-2"> + <Trophy className="h-4 w-4 text-yellow-500" /> + 최저가 벤더 + </CardTitle> + </CardHeader> + <CardContent> + <p className="text-lg font-bold">{data.summary.lowestBidder}</p> + <p className="text-sm text-muted-foreground"> + {formatAmount(data.summary.priceRange.min, data.summary.currency)} + </p> + </CardContent> + </Card> + + {/* 평균 가격 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-sm font-medium flex items-center gap-2"> + <DollarSign className="h-4 w-4" /> + 평균 가격 + </CardTitle> + </CardHeader> + <CardContent> + <p className="text-lg font-bold"> + {formatAmount(data.summary.priceRange.average, data.summary.currency)} + </p> + <p className="text-sm text-muted-foreground"> + {data.vendors.length}개 업체 평균 + </p> + </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"> + <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> + </CardHeader> + <CardContent> + <div className="space-y-4"> + {data.vendors.map((vendor) => ( + <div + key={vendor.vendorId} + className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 transition-colors" + > + <div className="flex items-center gap-4"> + <div + className={cn( + "w-10 h-10 rounded-full flex items-center justify-center font-bold", + getRankColor(vendor.rank || 0) + )} + > + {vendor.rank} + </div> + <div> + <p className="font-semibold">{vendor.vendorName}</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> + ))} + </div> + </CardContent> + </Card> + </TabsContent> + + {/* 조건 비교 */} + <TabsContent value="conditions" className="space-y-4"> + <Card> + <CardHeader> + <CardTitle>거래 조건 비교</CardTitle> + </CardHeader> + <CardContent className="overflow-x-auto"> + <table className="w-full"> + <thead> + <tr className="border-b"> + <th className="text-left p-2">항목</th> + <th className="text-left p-2">구매자 제시</th> + {data.vendors.map((vendor) => ( + <th key={vendor.vendorId} className="text-left p-2"> + {vendor.vendorName} + </th> + ))} + </tr> + </thead> + <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="p-2"> + <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> + ))} + </tr> + + {/* 지급조건 */} + <tr> + <td className="p-2 font-medium">지급조건</td> + <td className="p-2"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + {data.vendors[0]?.buyerConditions.paymentTermsCode} + </TooltipTrigger> + <TooltipContent> + {data.vendors[0]?.buyerConditions.paymentTermsDesc} + </TooltipContent> + </Tooltip> + </TooltipProvider> + </td> + {data.vendors.map((vendor) => ( + <td key={vendor.vendorId} className="p-2"> + <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> + ))} + </tr> + + {/* 인코텀즈 */} + <tr> + <td className="p-2 font-medium">인코텀즈</td> + <td className="p-2">{data.vendors[0]?.buyerConditions.incotermsCode}</td> + {data.vendors.map((vendor) => ( + <td key={vendor.vendorId} className="p-2"> + <div className="flex items-center gap-2"> + {vendor.vendorConditions.incotermsCode || vendor.buyerConditions.incotermsCode} + {vendor.vendorConditions.incotermsCode !== vendor.buyerConditions.incotermsCode && ( + <Badge variant="outline" className="text-xs">변경</Badge> + )} + </div> + </td> + ))} + </tr> + + {/* 납기 */} + <tr> + <td className="p-2 font-medium">납기</td> + <td className="p-2"> + {data.vendors[0]?.buyerConditions.deliveryDate + ? format(new Date(data.vendors[0].buyerConditions.deliveryDate), "yyyy-MM-dd") + : "-"} + </td> + {data.vendors.map((vendor) => { + const vendorDate = vendor.vendorConditions.deliveryDate || vendor.buyerConditions.deliveryDate; + const isDelayed = vendorDate && vendor.buyerConditions.deliveryDate && + new Date(vendorDate) > new Date(vendor.buyerConditions.deliveryDate); + + return ( + <td key={vendor.vendorId} className="p-2"> + <div className="flex items-center gap-2"> + {vendorDate ? format(new Date(vendorDate), "yyyy-MM-dd") : "-"} + {isDelayed && ( + <Badge variant="destructive" className="text-xs">지연</Badge> + )} + </div> + </td> + ); + })} + </tr> + + {/* 초도품 */} + <tr> + <td className="p-2 font-medium">초도품</td> + <td className="p-2"> + {data.vendors[0]?.buyerConditions.firstYn ? "요구" : "해당없음"} + </td> + {data.vendors.map((vendor) => ( + <td key={vendor.vendorId} className="p-2"> + {vendor.buyerConditions.firstYn && ( + <Badge + variant={ + vendor.vendorConditions.firstAcceptance === "수용" + ? "default" + : vendor.vendorConditions.firstAcceptance === "부분수용" + ? "secondary" + : vendor.vendorConditions.firstAcceptance === "거부" + ? "destructive" + : "outline" + } + > + {vendor.vendorConditions.firstAcceptance || "미응답"} + </Badge> + )} + {!vendor.buyerConditions.firstYn && "-"} + </td> + ))} + </tr> + + {/* 스페어파트 */} + <tr> + <td className="p-2 font-medium">스페어파트</td> + <td className="p-2"> + {data.vendors[0]?.buyerConditions.sparepartYn ? "요구" : "해당없음"} + </td> + {data.vendors.map((vendor) => ( + <td key={vendor.vendorId} className="p-2"> + {vendor.buyerConditions.sparepartYn && ( + <Badge + variant={ + vendor.vendorConditions.sparepartAcceptance === "수용" + ? "default" + : vendor.vendorConditions.sparepartAcceptance === "부분수용" + ? "secondary" + : vendor.vendorConditions.sparepartAcceptance === "거부" + ? "destructive" + : "outline" + } + > + {vendor.vendorConditions.sparepartAcceptance || "미응답"} + </Badge> + )} + {!vendor.buyerConditions.sparepartYn && "-"} + </td> + ))} + </tr> + + {/* 연동제 */} + <tr> + <td className="p-2 font-medium">연동제</td> + <td className="p-2"> + {data.vendors[0]?.buyerConditions.materialPriceRelatedYn ? "적용" : "미적용"} + </td> + {data.vendors.map((vendor) => ( + <td key={vendor.vendorId} className="p-2"> + <div className="flex items-center gap-2"> + {vendor.vendorConditions.materialPriceRelatedYn !== undefined + ? vendor.vendorConditions.materialPriceRelatedYn ? "적용" : "미적용" + : vendor.buyerConditions.materialPriceRelatedYn ? "적용" : "미적용"} + {vendor.vendorConditions.materialPriceRelatedReason && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <Info className="h-3 w-3" /> + </TooltipTrigger> + <TooltipContent> + <p className="max-w-xs text-xs"> + {vendor.vendorConditions.materialPriceRelatedReason} + </p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} + </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" /> + )} + <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> + + {/* 상세 분석 */} + <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> + ))} + </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> + + {/* 조건 준수 기준 추천 */} + <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> + ); + } + return ( + <p className="text-sm text-blue-700"> + 모든 조건을 충족하는 벤더 없음 + </p> + ); + })()} + </div> + + {/* 균형 추천 */} + <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]; + + 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> + ); + })()} + </div> + </div> + </CardContent> + </Card> + </div> + + {/* 벤더별 비고사항 */} + {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> + </div> + )} + {vendor.technicalProposal && ( + <div> + <p className="text-sm font-medium text-muted-foreground">기술 제안:</p> + <p className="text-sm">{vendor.technicalProposal}</p> + </div> + )} + </div> + ); + })} + </div> + </CardContent> + </Card> + )} + </TabsContent> + </Tabs> + </div> + ); +}
\ No newline at end of file |
