From 6c11fccc84f4c84fa72ee01f9caad9f76f35cea2 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Tue, 16 Sep 2025 09:20:58 +0000 Subject: (대표님, 최겸) 계약, 업로드 관련, 메뉴처리, 입찰, 프리쿼트, rfqLast관련, tbeLast관련 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/rfq-last/quotation-compare-view.tsx | 808 +++++++++++++++++++++++++------- 1 file changed, 645 insertions(+), 163 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 0e15a7bf..491a1962 100644 --- a/lib/rfq-last/quotation-compare-view.tsx +++ b/lib/rfq-last/quotation-compare-view.tsx @@ -5,6 +5,7 @@ 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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Trophy, TrendingUp, @@ -22,6 +23,11 @@ import { FileText, Truck, AlertTriangle, + Award, + UserCheck, + X, + RefreshCw, + Clock, } from "lucide-react"; import { cn } from "@/lib/utils"; import { format } from "date-fns"; @@ -37,7 +43,9 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; -import type { ComparisonData, VendorComparison, PrItemComparison } from "../actions"; +import { ComparisonData, selectVendor, cancelVendorSelection } from "./compare-action"; +import { createPO, createGeneralContract, createBidding } from "./contract-actions"; +import { toast } from "sonner"; interface QuotationCompareViewProps { data: ComparisonData; @@ -45,7 +53,96 @@ interface QuotationCompareViewProps { export function QuotationCompareView({ data }: QuotationCompareViewProps) { const [expandedItems, setExpandedItems] = React.useState>(new Set()); - const [selectedMetric, setSelectedMetric] = React.useState<"price" | "delivery" | "compliance">("price"); + const [selectedVendorId, setSelectedVendorId] = React.useState(""); + const [showSelectionDialog, setShowSelectionDialog] = React.useState(false); + const [showCancelDialog, setShowCancelDialog] = React.useState(false); + const [showContractDialog, setShowContractDialog] = React.useState(false); + const [selectedContractType, setSelectedContractType] = React.useState<"PO" | "CONTRACT" | "BIDDING" | "">(""); + const [selectionReason, setSelectionReason] = React.useState(""); + const [cancelReason, setCancelReason] = React.useState(""); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + // 선정된 업체 정보 확인 + const selectedVendor = data.vendors.find(v => v.isSelected); + const hasSelection = !!selectedVendor; + const isSelectionApproved = selectedVendor?.selectionApprovalStatus === "승인"; + const isPendingApproval = selectedVendor?.selectionApprovalStatus === "대기"; + const hasContract = selectedVendor?.contractStatus ? true : false; + + // 계약 진행 처리 + const handleContractCreation = async () => { + if (!selectedContractType) { + toast.error("계약 유형을 선택해주세요."); + return; + } + + if (!selectedVendor) { + toast.error("선정된 업체가 없습니다."); + return; + } + + setIsSubmitting(true); + try { + let result; + + switch (selectedContractType) { + case "PO": + result = await createPO({ + rfqId: data.rfqInfo.id, + vendorId: selectedVendor.vendorId, + vendorName: selectedVendor.vendorName, + totalAmount: selectedVendor.totalAmount, + currency: selectedVendor.currency, + selectionReason: selectedVendor.selectionReason, + }); + break; + + case "CONTRACT": + result = await createGeneralContract({ + rfqId: data.rfqInfo.id, + vendorId: selectedVendor.vendorId, + vendorName: selectedVendor.vendorName, + totalAmount: selectedVendor.totalAmount, + currency: selectedVendor.currency, + }); + break; + + case "BIDDING": + result = await createBidding({ + rfqId: data.rfqInfo.id, + vendorId: selectedVendor.vendorId, + vendorName: selectedVendor.vendorName, + totalAmount: selectedVendor.totalAmount, + currency: selectedVendor.currency, + }); + break; + + default: + throw new Error("올바른 계약 유형이 아닙니다."); + } + + if (result.success) { + toast.success(result.message || "계약 프로세스가 시작되었습니다."); + setShowContractDialog(false); + setSelectedContractType(""); + window.location.reload(); + } else { + throw new Error(result.error || "계약 진행 중 오류가 발생했습니다."); + } + } catch (error) { + console.error("계약 생성 오류:", error); + toast.error(error instanceof Error ? error.message : "계약 진행 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + }; + + // 컴포넌트 마운트 시 선정된 업체가 있으면 자동 선택 + React.useEffect(() => { + if (selectedVendor) { + setSelectedVendorId(selectedVendor.vendorId.toString()); + } + }, [selectedVendor]); // 아이템 확장/축소 토글 const toggleItemExpansion = (itemId: number) => { @@ -81,17 +178,8 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { return "text-gray-600"; }; - // 조건 일치 여부 아이콘 - const getComplianceIcon = (matches: boolean) => { - return matches ? ( - - ) : ( - - ); - }; - // 금액 포맷 - const formatAmount = (amount: number, currency: string = "USD") => { + const formatAmount = (amount: number, currency: string = "KRW") => { return new Intl.NumberFormat("ko-KR", { style: "currency", currency: currency, @@ -100,8 +188,227 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { }).format(amount); }; + // 업체 선정 처리 + const handleVendorSelection = async () => { + if (!selectedVendorId) { + toast.error("선정할 업체를 선택해주세요."); + return; + } + + if (!selectionReason.trim()) { + toast.error("선정 사유를 입력해주세요."); + return; + } + + setIsSubmitting(true); + try { + const vendor = data.vendors.find(v => v.vendorId === parseInt(selectedVendorId)); + if (!vendor) { + 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, + selectionReason: selectionReason, + priceRank: vendor.rank || 0, + hasConditionDifferences: vendor.conditionDifferences.hasDifferences, + criticalDifferences: vendor.conditionDifferences.criticalDifferences, + }); + + if (result.success) { + toast.success("업체가 성공적으로 선정되었습니다."); + setShowSelectionDialog(false); + setSelectionReason(""); + window.location.reload(); // 페이지 새로고침으로 선정 상태 반영 + } else { + throw new Error(result.error || "업체 선정 중 오류가 발생했습니다."); + } + } catch (error) { + console.error("업체 선정 오류:", error); + toast.error(error instanceof Error ? error.message : "업체 선정 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + }; + + // 업체 선정 취소 처리 + const handleCancelSelection = async () => { + if (!cancelReason.trim()) { + toast.error("취소 사유를 입력해주세요."); + return; + } + + setIsSubmitting(true); + try { + // 파라미터를 올바르게 전달 + const result = await cancelVendorSelection(Number(data.rfqInfo.id),cancelReason); + + if (result.success) { + toast.success("업체 선정이 취소되었습니다."); + setShowCancelDialog(false); + setCancelReason(""); + window.location.reload(); + } else { + throw new Error(result.error || "선정 취소 중 오류가 발생했습니다."); + } + } catch (error) { + console.error("선정 취소 오류:", error); + toast.error(error instanceof Error ? error.message : "선정 취소 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + }; + return (
+ {/* 상단 액션 바 */} +
+

견적 비교 분석

+
+ {hasSelection ? ( + <> + {!isSelectionApproved && ( + + )} + + + ) : ( + + )} +
+
+ + {/* 선정 상태 알림 */} + {hasSelection && ( + +
+
+ {hasContract ? ( + + ) : isSelectionApproved ? ( + + ) : isPendingApproval ? ( + + ) : ( + + )} +
+ + {hasContract + ? "계약 진행중" + : isSelectionApproved + ? "업체 선정 승인 완료" + : isPendingApproval + ? "업체 선정 승인 대기중" + : "업체 선정 완료"} + + +

선정 업체: {selectedVendor.vendorName} ({selectedVendor.vendorCode})

+

선정 금액: {formatAmount(selectedVendor.totalAmount, selectedVendor.currency)}

+

선정일: {selectedVendor.selectionDate ? format(new Date(selectedVendor.selectionDate), "yyyy년 MM월 dd일", { locale: ko }) : "-"}

+

선정 사유: {selectedVendor.selectionReason || "-"}

+ {selectedVendor.contractNo && ( + <> +
+

계약 정보

+

계약 번호: {selectedVendor.contractNo}

+

계약 상태: {selectedVendor.contractStatus}

+ {selectedVendor.contractCreatedAt && ( +

계약 생성일: {format(new Date(selectedVendor.contractCreatedAt), "yyyy년 MM월 dd일", { locale: ko })}

+ )} +
+ + )} + {selectedVendor.selectedByName && ( +

선정자: {selectedVendor.selectedByName}

+ )} +
+
+
+ {/* 계약 진행 버튼들을 알림 카드 안에도 추가 (선택사항) */} + {!hasContract && !isPendingApproval && ( +
+ + + +
+ )} +
+
+ )} + {/* 요약 카드 */}
{/* 최저가 벤더 */} @@ -120,23 +427,40 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { - {/* 평균 가격 */} - - - - - 평균 가격 - - - -

- {formatAmount(data.summary.priceRange.average, data.summary.currency)} -

-

- {data.vendors.length}개 업체 평균 -

-
-
+ {/* 선정 업체 또는 평균 가격 */} + {hasSelection ? ( + + + + + 선정 업체 + + + +

{selectedVendor.vendorName}

+

+ {formatAmount(selectedVendor.totalAmount, selectedVendor.currency)} +

+
+
+ ) : ( + + + + + 평균 가격 + + + +

+ {formatAmount(data.summary.priceRange.average, data.summary.currency)} +

+

+ {data.vendors.length}개 업체 평균 +

+
+
+ )} {/* 가격 범위 */} @@ -188,16 +512,40 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { - 가격 순위 + 가격 순위 및 업체 선정
{data.vendors.map((vendor) => (
{ + if (!hasSelection) { + setSelectedVendorId(vendor.vendorId.toString()); + } + }} >
+ {!hasSelection && ( + setSelectedVendorId(e.target.value)} + className="h-4 w-4 text-blue-600" + /> + )}
-

{vendor.vendorName}

+

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

{vendor.vendorCode} • {vendor.vendorCountry}

@@ -267,8 +620,14 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { 항목 구매자 제시 {data.vendors.map((vendor) => ( - + {vendor.vendorName} + {vendor.isSelected && ( + 선정 + )} ))} @@ -279,7 +638,10 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { 통화 {data.vendors[0]?.buyerConditions.currency} {data.vendors.map((vendor) => ( - +
{vendor.vendorConditions.currency || vendor.buyerConditions.currency} {vendor.vendorConditions.currency !== vendor.buyerConditions.currency && ( @@ -306,7 +668,10 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {data.vendors.map((vendor) => ( - +
@@ -327,134 +692,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { ))} - {/* 인코텀즈 */} - - 인코텀즈 - {data.vendors[0]?.buyerConditions.incotermsCode} - {data.vendors.map((vendor) => ( - -
- {vendor.vendorConditions.incotermsCode || vendor.buyerConditions.incotermsCode} - {vendor.vendorConditions.incotermsCode !== vendor.buyerConditions.incotermsCode && ( - 변경 - )} -
- - ))} - - - {/* 납기 */} - - 납기 - - {data.vendors[0]?.buyerConditions.deliveryDate - ? format(new Date(data.vendors[0].buyerConditions.deliveryDate), "yyyy-MM-dd") - : "-"} - - {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 ( - -
- {vendorDate ? format(new Date(vendorDate), "yyyy-MM-dd") : "-"} - {isDelayed && ( - 지연 - )} -
- - ); - })} - - - {/* 초도품 */} - - 초도품 - - {data.vendors[0]?.buyerConditions.firstYn ? "요구" : "해당없음"} - - {data.vendors.map((vendor) => ( - - {vendor.buyerConditions.firstYn && ( - - {vendor.vendorConditions.firstAcceptance || "미응답"} - - )} - {!vendor.buyerConditions.firstYn && "-"} - - ))} - - - {/* 스페어파트 */} - - 스페어파트 - - {data.vendors[0]?.buyerConditions.sparepartYn ? "요구" : "해당없음"} - - {data.vendors.map((vendor) => ( - - {vendor.buyerConditions.sparepartYn && ( - - {vendor.vendorConditions.sparepartAcceptance || "미응답"} - - )} - {!vendor.buyerConditions.sparepartYn && "-"} - - ))} - - - {/* 연동제 */} - - 연동제 - - {data.vendors[0]?.buyerConditions.materialPriceRelatedYn ? "적용" : "미적용"} - - {data.vendors.map((vendor) => ( - -
- {vendor.vendorConditions.materialPriceRelatedYn !== undefined - ? vendor.vendorConditions.materialPriceRelatedYn ? "적용" : "미적용" - : vendor.buyerConditions.materialPriceRelatedYn ? "적용" : "미적용"} - {vendor.vendorConditions.materialPriceRelatedReason && ( - - - - - - -

- {vendor.vendorConditions.materialPriceRelatedReason} -

-
-
-
- )} -
- - ))} - + {/* 나머지 조건들도 동일한 패턴으로 처리 */} @@ -750,6 +988,250 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { )} + + {/* 업체 선정 모달 */} + {showSelectionDialog && ( +
+
+

+ {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} + +
+ {data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.conditionDifferences.hasDifferences && ( + + + + 제시 조건과 차이가 있습니다. 선정 사유를 명확히 기재해주세요. + + + )} +
+
+ +
+ +