From 95866a13ba4e1c235373834460aa284b763fe0d9 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 23 Jun 2025 09:03:29 +0000 Subject: (최겸) 기술영업 RFQ 개발(0620 요구사항, 첨부파일, REV 등) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table/detail-table/add-vendor-dialog.tsx | 10 +- .../detail-table/quotation-history-dialog.tsx | 312 +++++++++++++++++++ .../table/detail-table/rfq-detail-column.tsx | 101 +++++- .../table/detail-table/rfq-detail-table.tsx | 178 ++++++++--- .../vendor-quotation-comparison-dialog.tsx | 341 --------------------- 5 files changed, 546 insertions(+), 396 deletions(-) create mode 100644 lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx delete mode 100644 lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx (limited to 'lib/techsales-rfq/table/detail-table') diff --git a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx index 3574111f..8f2fe948 100644 --- a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx +++ b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx @@ -29,6 +29,8 @@ type VendorFormValues = z.infer type TechSalesRfq = { id: number rfqCode: string | null + rfqType: "SHIP" | "TOP" | "HULL" | null + ptypeNm: string | null // 프로젝트 타입명 추가 status: string [key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any } @@ -118,10 +120,8 @@ export function AddVendorDialog({ setIsSearching(true) try { // 선택된 RFQ의 타입을 기반으로 벤더 검색 - const rfqType = selectedRfq?.rfqCode?.includes("SHIP") ? "SHIP" : - selectedRfq?.rfqCode?.includes("TOP") ? "TOP" : - selectedRfq?.rfqCode?.includes("HULL") ? "HULL" : undefined; - + const rfqType = selectedRfq?.rfqType || undefined; + console.log("rfqType", rfqType) // 디버깅용 const results = await searchTechVendors(term, 100, rfqType) // 이미 추가된 벤더 제외 @@ -136,7 +136,7 @@ export function AddVendorDialog({ setIsSearching(false) } }, - [existingVendorIds] + [existingVendorIds, selectedRfq?.rfqType] ) // 검색어 변경 시 디바운스 적용 diff --git a/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx new file mode 100644 index 00000000..7832fa2b --- /dev/null +++ b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx @@ -0,0 +1,312 @@ +"use client" + +import * as React from "react" +import { useState, useEffect } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Separator } from "@/components/ui/separator" +import { Skeleton } from "@/components/ui/skeleton" +import { Clock, User, FileText, AlertCircle, Paperclip } from "lucide-react" +import { formatDate } from "@/lib/utils" +import { toast } from "sonner" + +interface QuotationAttachment { + id: number + quotationId: number + revisionId: number + fileName: string + originalFileName: string + fileSize: number + fileType: string | null + filePath: string + description: string | null + isVendorUpload: boolean + createdAt: Date + updatedAt: Date +} + +interface QuotationSnapshot { + currency: string | null + totalPrice: string | null + validUntil: Date | null + remark: string | null + status: string | null + quotationVersion: number | null + submittedAt: Date | null + acceptedAt: Date | null + updatedAt: Date | null +} + +interface QuotationRevision { + id: number + version: number + snapshot: QuotationSnapshot + changeReason: string | null + revisionNote: string | null + revisedBy: number | null + revisedAt: Date + revisedByName: string | null + attachments: QuotationAttachment[] +} + +interface QuotationHistoryData { + current: { + id: number + currency: string | null + totalPrice: string | null + validUntil: Date | null + remark: string | null + status: string + quotationVersion: number | null + submittedAt: Date | null + acceptedAt: Date | null + updatedAt: Date | null + attachments: QuotationAttachment[] + } + revisions: QuotationRevision[] +} + +interface QuotationHistoryDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + quotationId: number | null +} + +const statusConfig = { + "Draft": { label: "초안", color: "bg-yellow-100 text-yellow-800" }, + "Submitted": { label: "제출됨", color: "bg-blue-100 text-blue-800" }, + "Revised": { label: "수정됨", color: "bg-purple-100 text-purple-800" }, + "Accepted": { label: "승인됨", color: "bg-green-100 text-green-800" }, + "Rejected": { label: "거절됨", color: "bg-red-100 text-red-800" }, +} + +function QuotationCard({ + data, + version, + isCurrent = false, + changeReason, + revisedBy, + revisedAt, + attachments +}: { + data: QuotationSnapshot | QuotationHistoryData["current"] + version: number + isCurrent?: boolean + changeReason?: string | null + revisedBy?: string | null + revisedAt?: Date + attachments?: QuotationAttachment[] +}) { + const statusInfo = statusConfig[data.status as keyof typeof statusConfig] || + { label: data.status || "알 수 없음", color: "bg-gray-100 text-gray-800" } + + return ( + + +
+ + 버전 {version} + {isCurrent && 현재} + + + {statusInfo.label} + +
+ {changeReason && ( +
+ + {changeReason} +
+ )} +
+ +
+
+

견적 금액

+

+ {data.totalPrice ? `${data.currency} ${Number(data.totalPrice).toLocaleString()}` : "미입력"} +

+
+
+

유효 기한

+

+ {data.validUntil ? formatDate(data.validUntil) : "미설정"} +

+
+
+ + {data.remark && ( +
+

비고

+

{data.remark}

+
+ )} + + {/* 첨부파일 섹션 */} + {attachments && attachments.length > 0 && ( +
+

+ + 첨부파일 ({attachments.length}개) +

+
+ {attachments.map((attachment) => ( +
+
+
+

+ {attachment.originalFileName} +

+ {attachment.description && ( +

+ {attachment.description} +

+ )} +
+
+
+ {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB +
+
+ ))} +
+
+ )} + + + +
+
+ + + {isCurrent + ? `수정: ${data.updatedAt ? formatDate(data.updatedAt) : "N/A"}` + : `변경: ${revisedAt ? formatDate(revisedAt) : "N/A"}` + } + +
+ {revisedBy && ( +
+ + {revisedBy} +
+ )} +
+
+
+ ) +} + +export function QuotationHistoryDialog({ + open, + onOpenChange, + quotationId +}: QuotationHistoryDialogProps) { + const [data, setData] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + if (open && quotationId) { + loadQuotationHistory() + } + }, [open, quotationId]) + + const loadQuotationHistory = async () => { + if (!quotationId) return + + try { + setIsLoading(true) + const { getTechSalesVendorQuotationWithRevisions } = await import("@/lib/techsales-rfq/service") + + const result = await getTechSalesVendorQuotationWithRevisions(quotationId) + + if (result.error) { + toast.error(result.error) + return + } + + setData(result.data as QuotationHistoryData) + } catch (error) { + console.error("견적 히스토리 로드 오류:", error) + toast.error("견적 히스토리를 불러오는 중 오류가 발생했습니다") + } finally { + setIsLoading(false) + } + } + + const handleOpenChange = (newOpen: boolean) => { + onOpenChange(newOpen) + if (!newOpen) { + setData(null) // 다이얼로그 닫을 때 데이터 초기화 + } + } + + return ( + + + + 견적서 수정 히스토리 + + 견적서의 변경 이력을 확인할 수 있습니다. 최신 버전부터 순서대로 표시됩니다. + + + +
+ {isLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ + +
+ ))} +
+ ) : data ? ( + <> + {/* 현재 버전 */} + + + {/* 이전 버전들 */} + {data.revisions.length > 0 ? ( + data.revisions.map((revision) => ( + + )) + ) : ( +
+ +

수정 이력이 없습니다.

+

이 견적서는 아직 수정되지 않았습니다.

+
+ )} + + ) : ( +
+ +

견적서 정보를 불러올 수 없습니다.

+
+ )} +
+
+
+ ) +} \ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx index 3e50a516..e921fcaa 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx @@ -5,7 +5,7 @@ import type { ColumnDef, Row } from "@tanstack/react-table"; import { formatDate } from "@/lib/utils" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" import { Checkbox } from "@/components/ui/checkbox"; -import { MessageCircle, MoreHorizontal, Trash2 } from "lucide-react"; +import { MessageCircle, MoreHorizontal, Trash2, Paperclip } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { @@ -38,6 +38,24 @@ export interface RfqDetailView { createdAt: Date | null updatedAt: Date | null createdByName: string | null + quotationCode?: string | null + rfqCode?: string | null + quotationAttachments?: Array<{ + id: number + revisionId: number + fileName: string + fileSize: number + filePath: string + description?: string | null + }> +} + +// 견적서 정보 타입 (Sheet용) +export interface QuotationInfo { + id: number + quotationCode: string | null + vendorName?: string + rfqCode?: string } interface GetColumnsProps { @@ -45,11 +63,15 @@ interface GetColumnsProps { React.SetStateAction | null> >; unreadMessages?: Record; // 읽지 않은 메시지 개수 + onQuotationClick?: (quotationId: number) => void; // 견적 클릭 핸들러 + openQuotationAttachmentsSheet?: (quotationId: number, quotationInfo: QuotationInfo) => void; // 견적서 첨부파일 sheet 열기 } export function getRfqDetailColumns({ setRowAction, - unreadMessages = {} + unreadMessages = {}, + onQuotationClick, + openQuotationAttachmentsSheet }: GetColumnsProps): ColumnDef[] { return [ { @@ -66,15 +88,15 @@ export function getRfqDetailColumns({ ), cell: ({ row }) => { const status = row.original.status; - const isDraft = status === "Draft"; + const isSelectable = status ? !["Accepted", "Rejected"].includes(status) : true; return ( row.toggleSelected(!!value)} - disabled={!isDraft} + disabled={!isSelectable} aria-label="행 선택" - className={!isDraft ? "opacity-50 cursor-not-allowed" : ""} + className={!isSelectable ? "opacity-50 cursor-not-allowed" : ""} /> ); }, @@ -163,15 +185,31 @@ export function getRfqDetailColumns({ cell: ({ row }) => { const value = row.getValue("totalPrice") as string | number | null; const currency = row.getValue("currency") as string | null; + const quotationId = row.original.id; if (value === null || value === undefined) return "-"; // 숫자로 변환 시도 const numValue = typeof value === 'string' ? parseFloat(value) : value; + const displayValue = isNaN(numValue) ? value : numValue.toLocaleString(); + + // 견적값이 있고 클릭 핸들러가 있는 경우 클릭 가능한 버튼으로 표시 + if (onQuotationClick && quotationId) { + return ( + + ); + } return (
- {isNaN(numValue) ? value : numValue.toLocaleString()} {currency} + {displayValue} {currency}
); }, @@ -181,6 +219,57 @@ export function getRfqDetailColumns({ enableResizing: true, size: 140, }, + { + accessorKey: "quotationAttachments", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const attachments = row.original.quotationAttachments || []; + const attachmentCount = attachments.length; + + if (attachmentCount === 0) { + return
-
; + } + + return ( + + ); + }, + meta: { + excelHeader: "첨부파일" + }, + enableResizing: false, + size: 80, + }, { accessorKey: "currency", header: ({ column }) => ( diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx index f2eda8d9..1d701bd5 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx @@ -12,12 +12,14 @@ import { toast } from "sonner" import { Skeleton } from "@/components/ui/skeleton" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" -import { Loader2, UserPlus, BarChart2, Send, Trash2 } from "lucide-react" +import { Loader2, UserPlus, Send, Trash2, CheckCircle } from "lucide-react" import { ClientDataTable } from "@/components/client-data-table/data-table" import { AddVendorDialog } from "./add-vendor-dialog" import { VendorCommunicationDrawer } from "./vendor-communication-drawer" -import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog" import { DeleteVendorsDialog } from "../delete-vendors-dialog" +import { QuotationHistoryDialog } from "@/lib/techsales-rfq/table/detail-table/quotation-history-dialog" +import { TechSalesQuotationAttachmentsSheet, type QuotationAttachment } from "../tech-sales-quotation-attachments-sheet" +import type { QuotationInfo } from "./rfq-detail-column" // 기본적인 RFQ 타입 정의 interface TechSalesRfq { @@ -30,6 +32,8 @@ interface TechSalesRfq { rfqSendDate?: Date | null dueDate?: Date | null createdByName?: string | null + rfqType: "SHIP" | "TOP" | "HULL" | null + ptypeNm?: string | null } // 프로퍼티 정의 @@ -58,9 +62,6 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps // 읽지 않은 메시지 개수 const [unreadMessages, setUnreadMessages] = useState>({}) - // 견적 비교 다이얼로그 상태 관리 - const [comparisonDialogOpen, setComparisonDialogOpen] = useState(false) - // 테이블 선택 상태 관리 const [selectedRows, setSelectedRows] = useState([]) const [isSendingRfq, setIsSendingRfq] = useState(false) @@ -69,6 +70,16 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps // 벤더 삭제 확인 다이얼로그 상태 추가 const [deleteConfirmDialogOpen, setDeleteConfirmDialogOpen] = useState(false) + // 견적 히스토리 다이얼로그 상태 관리 + const [historyDialogOpen, setHistoryDialogOpen] = useState(false) + const [selectedQuotationId, setSelectedQuotationId] = useState(null) + + // 견적서 첨부파일 sheet 상태 관리 + const [quotationAttachmentsSheetOpen, setQuotationAttachmentsSheetOpen] = useState(false) + const [selectedQuotationInfo, setSelectedQuotationInfo] = useState(null) + const [quotationAttachments, setQuotationAttachments] = useState([]) + const [isLoadingAttachments, setIsLoadingAttachments] = useState(false) + // selectedRfq ID 메모이제이션 (객체 참조 변경 방지) const selectedRfqId = useMemo(() => selectedRfq?.id, [selectedRfq?.id]) @@ -108,6 +119,8 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps detailId: item.id, rfqId: selectedRfqId, rfqCode: selectedRfq?.rfqCode || null, + rfqType: selectedRfq?.rfqType || null, + ptypeNm: selectedRfq?.ptypeNm || null, vendorId: item.vendorId ? Number(item.vendorId) : undefined, })) || [] @@ -121,7 +134,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps console.error("데이터 새로고침 오류:", error) toast.error("데이터를 새로고침하는 중 오류가 발생했습니다") } - }, [selectedRfqId, selectedRfq?.rfqCode, loadUnreadMessages]) + }, [selectedRfqId, selectedRfq?.rfqCode, selectedRfq?.rfqType, selectedRfq?.ptypeNm, loadUnreadMessages]) // 벤더 추가 핸들러 메모이제이션 const handleAddVendor = useCallback(async () => { @@ -180,6 +193,54 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps } }, [selectedRows, selectedRfqId, handleRefreshData]); + // 벤더 선택 핸들러 추가 + const [isAcceptingVendors, setIsAcceptingVendors] = useState(false); + + const handleAcceptVendors = useCallback(async () => { + if (selectedRows.length === 0) { + toast.warning("선택할 벤더를 선택해주세요."); + return; + } + + if (selectedRows.length > 1) { + toast.warning("하나의 벤더만 선택할 수 있습니다."); + return; + } + + const selectedQuotation = selectedRows[0]; + if (selectedQuotation.status !== "Submitted") { + toast.warning("제출된 견적서만 선택할 수 있습니다."); + return; + } + + try { + setIsAcceptingVendors(true); + + // 벤더 견적 승인 서비스 함수 호출 + const { acceptTechSalesVendorQuotationAction } = await import("@/lib/techsales-rfq/actions"); + + const result = await acceptTechSalesVendorQuotationAction(selectedQuotation.id); + + if (result.success) { + toast.success(result.message || "벤더가 성공적으로 선택되었습니다."); + } else { + toast.error(result.error || "벤더 선택 중 오류가 발생했습니다."); + } + + // 선택 해제 + setSelectedRows([]); + + // 데이터 새로고침 + await handleRefreshData(); + + } catch (error) { + console.error("벤더 선택 오류:", error); + toast.error("벤더 선택 중 오류가 발생했습니다."); + } finally { + setIsAcceptingVendors(false); + } + }, [selectedRows, handleRefreshData]); + // 벤더 삭제 핸들러 메모이제이션 const handleDeleteVendors = useCallback(async () => { if (selectedRows.length === 0) { @@ -246,27 +307,47 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps await handleDeleteVendors(); }, [handleDeleteVendors]); - // 견적 비교 다이얼로그 열기 핸들러 메모이제이션 - const handleOpenComparisonDialog = useCallback(() => { - // 제출된 견적이 있는 벤더가 최소 1개 이상 있는지 확인 - const hasSubmittedQuotations = details.some(detail => - detail.status === "Submitted" // RfqDetailView의 실제 필드 사용 - ); - if (!hasSubmittedQuotations) { - toast.warning("제출된 견적이 없습니다."); - return; - } + // 견적 히스토리 다이얼로그 열기 핸들러 메모이제이션 + const handleOpenHistoryDialog = useCallback((quotationId: number) => { + setSelectedQuotationId(quotationId); + setHistoryDialogOpen(true); + }, []) - setComparisonDialogOpen(true); - }, [details]) + // 견적서 첨부파일 sheet 열기 핸들러 메모이제이션 + const handleOpenQuotationAttachmentsSheet = useCallback(async (quotationId: number, quotationInfo: QuotationInfo) => { + try { + setIsLoadingAttachments(true); + setSelectedQuotationInfo(quotationInfo); + setQuotationAttachmentsSheetOpen(true); + + // 견적서 첨부파일 조회 + const { getTechSalesVendorQuotationAttachments } = await import("@/lib/techsales-rfq/service"); + const result = await getTechSalesVendorQuotationAttachments(quotationId); + + if (result.error) { + toast.error(result.error); + setQuotationAttachments([]); + } else { + setQuotationAttachments(result.data || []); + } + } catch (error) { + console.error("견적서 첨부파일 조회 오류:", error); + toast.error("견적서 첨부파일을 불러오는 중 오류가 발생했습니다."); + setQuotationAttachments([]); + } finally { + setIsLoadingAttachments(false); + } + }, []) // 칼럼 정의 - unreadMessages 상태 전달 (메모이제이션) const columns = useMemo(() => getRfqDetailColumns({ setRowAction, - unreadMessages - }), [unreadMessages]) + unreadMessages, + onQuotationClick: handleOpenHistoryDialog, + openQuotationAttachmentsSheet: handleOpenQuotationAttachmentsSheet + }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet]) // 필터 필드 정의 (메모이제이션) const advancedFilterFields = useMemo( @@ -493,6 +574,22 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps )}
+ {/* 벤더 선택 버튼 */} + + {/* RFQ 발송 버튼 */} - {/* 견적 비교 버튼 */} - - {/* 벤더 추가 버튼 */}
) } \ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx b/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx deleted file mode 100644 index 0a6caa5c..00000000 --- a/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx +++ /dev/null @@ -1,341 +0,0 @@ -"use client" - -import * as React from "react" -import { useEffect, useState } from "react" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Skeleton } from "@/components/ui/skeleton" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { toast } from "sonner" -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog" - -// Lucide 아이콘 -import { Plus, Minus, CheckCircle, Loader2 } from "lucide-react" - -import { getTechSalesVendorQuotationsWithJoin } from "@/lib/techsales-rfq/service" -import { acceptTechSalesVendorQuotationAction } from "@/lib/techsales-rfq/actions" -import { formatCurrency, formatDate } from "@/lib/utils" -import { techSalesVendorQuotations } from "@/db/schema/techSales" - -// 기술영업 견적 정보 타입 -interface TechSalesVendorQuotation { - id: number - rfqId: number - vendorId: number - vendorName?: string | null - totalPrice: string | null - currency: string | null - validUntil: Date | null - status: string - remark: string | null - submittedAt: Date | null - acceptedAt: Date | null - createdAt: Date - updatedAt: Date -} - -interface VendorQuotationComparisonDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - selectedRfq: { - id: number; - rfqCode: string | null; - status: string; - [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any - } | null -} - -export function VendorQuotationComparisonDialog({ - open, - onOpenChange, - selectedRfq, -}: VendorQuotationComparisonDialogProps) { - const [isLoading, setIsLoading] = useState(false) - const [quotations, setQuotations] = useState([]) - const [selectedVendorId, setSelectedVendorId] = useState(null) - const [isAccepting, setIsAccepting] = useState(false) - const [showConfirmDialog, setShowConfirmDialog] = useState(false) - - useEffect(() => { - async function loadQuotationData() { - if (!open || !selectedRfq?.id) return - - try { - setIsLoading(true) - // 기술영업 견적 목록 조회 (제출된 견적만) - const result = await getTechSalesVendorQuotationsWithJoin({ - rfqId: selectedRfq.id, - page: 1, - perPage: 100, - filters: [ - { - id: "status" as keyof typeof techSalesVendorQuotations, - value: "Submitted", - type: "select" as const, - operator: "eq" as const, - rowId: "status" - } - ] - }) - - setQuotations(result.data || []) - } catch (error) { - console.error("견적 데이터 로드 오류:", error) - toast.error("견적 데이터를 불러오는 데 실패했습니다") - } finally { - setIsLoading(false) - } - } - - loadQuotationData() - }, [open, selectedRfq]) - - // 견적 상태 -> 뱃지 색 - const getStatusBadgeVariant = (status: string) => { - switch (status) { - case "Submitted": - return "default" - case "Accepted": - return "default" - case "Rejected": - return "destructive" - case "Revised": - return "destructive" - default: - return "secondary" - } - } - - // 벤더 선택 핸들러 - const handleSelectVendor = (vendorId: number) => { - setSelectedVendorId(vendorId) - setShowConfirmDialog(true) - } - - // 벤더 선택 확정 - const handleConfirmSelection = async () => { - if (!selectedVendorId) return - - try { - setIsAccepting(true) - - // 선택된 견적의 ID 찾기 - const selectedQuotation = quotations.find(q => q.vendorId === selectedVendorId) - if (!selectedQuotation) { - toast.error("선택된 견적을 찾을 수 없습니다") - return - } - - // 벤더 선택 API 호출 - const result = await acceptTechSalesVendorQuotationAction(selectedQuotation.id) - - if (result.success) { - toast.success(result.message || "벤더가 선택되었습니다") - setShowConfirmDialog(false) - onOpenChange(false) - - // 페이지 새로고침 또는 데이터 재로드 - window.location.reload() - } else { - toast.error(result.error || "벤더 선택에 실패했습니다") - } - } catch (error) { - console.error("벤더 선택 오류:", error) - toast.error("벤더 선택에 실패했습니다") - } finally { - setIsAccepting(false) - } - } - - const selectedVendor = quotations.find(q => q.vendorId === selectedVendorId) - - return ( - <> - - - - 벤더 견적 비교 및 선택 - - {selectedRfq - ? `RFQ ${selectedRfq.rfqCode} - 제출된 견적을 비교하고 벤더를 선택하세요` - : ""} - - - - {isLoading ? ( -
- - -
- ) : quotations.length === 0 ? ( -
- 제출된(Submitted) 견적이 없습니다 -
- ) : ( -
- - - - - 항목 - - {quotations.map((q) => ( - -
- {q.vendorName || `벤더 ID: ${q.vendorId}`} - -
-
- ))} -
- - - {/* 견적 상태 */} - - - 견적 상태 - - {quotations.map((q) => ( - - - {q.status} - - - ))} - - - {/* 총 금액 */} - - - 총 금액 - - {quotations.map((q) => ( - - {q.totalPrice ? formatCurrency(Number(q.totalPrice), q.currency || 'USD') : '-'} - - ))} - - - {/* 통화 */} - - - 통화 - - {quotations.map((q) => ( - - {q.currency || '-'} - - ))} - - - {/* 유효기간 */} - - - 유효 기간 - - {quotations.map((q) => ( - - {q.validUntil ? formatDate(q.validUntil, "KR") : '-'} - - ))} - - - {/* 제출일 */} - - - 제출일 - - {quotations.map((q) => ( - - {q.submittedAt ? formatDate(q.submittedAt, "KR") : '-'} - - ))} - - - {/* 비고 */} - - - 비고 - - {quotations.map((q) => ( - - {q.remark || "-"} - - ))} - - -
-
- )} - - - - -
-
- - {/* 벤더 선택 확인 다이얼로그 */} - - - - 벤더 선택 확인 - - {selectedVendor?.vendorName || `벤더 ID: ${selectedVendorId}`}를 선택하시겠습니까? -
-
- 선택된 벤더의 견적이 승인되며, 다른 벤더들의 견적은 자동으로 거절됩니다. - 이 작업은 되돌릴 수 없습니다. -
-
- - 취소 - - {isAccepting && } - 확인 - - -
-
- - ) -} -- cgit v1.2.3