diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:54:26 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:54:26 +0000 |
| commit | 14f61e24947fb92dd71ec0a7196a6e815f8e66da (patch) | |
| tree | 317c501d64662d05914330628f867467fba78132 /lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx | |
| parent | 194bd4bd7e6144d5c09c5e3f5476d254234dce72 (diff) | |
(최겸)기술영업 RFQ 담당자 초대, 요구사항 반영
Diffstat (limited to 'lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx')
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx | 1483 |
1 files changed, 774 insertions, 709 deletions
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 1d701bd5..41572a93 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx @@ -1,710 +1,775 @@ -"use client" - -import * as React from "react" -import { useEffect, useState, useCallback, useMemo } from "react" -import { - DataTableRowAction, - getRfqDetailColumns, - RfqDetailView -} from "./rfq-detail-column" -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, 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 { 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 { - id: number - rfqCode: string | null - status: string - materialCode?: string | null - itemName?: string | null - remark?: string | null - rfqSendDate?: Date | null - dueDate?: Date | null - createdByName?: string | null - rfqType: "SHIP" | "TOP" | "HULL" | null - ptypeNm?: string | null -} - -// 프로퍼티 정의 -interface RfqDetailTablesProps { - selectedRfq: TechSalesRfq | null - maxHeight?: string | number -} - - -export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps) { - // console.log("selectedRfq", selectedRfq) - - // 상태 관리 - const [isLoading, setIsLoading] = useState(false) - const [details, setDetails] = useState<RfqDetailView[]>([]) - const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false) - - const [isAdddialogLoading, setIsAdddialogLoading] = useState(false) - - const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDetailView> | null>(null) - - // 벤더 커뮤니케이션 상태 관리 - const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false) - const [selectedVendor, setSelectedVendor] = useState<RfqDetailView | null>(null) - - // 읽지 않은 메시지 개수 - const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({}) - - // 테이블 선택 상태 관리 - const [selectedRows, setSelectedRows] = useState<RfqDetailView[]>([]) - const [isSendingRfq, setIsSendingRfq] = useState(false) - const [isDeletingVendors, setIsDeletingVendors] = useState(false) - - // 벤더 삭제 확인 다이얼로그 상태 추가 - const [deleteConfirmDialogOpen, setDeleteConfirmDialogOpen] = useState(false) - - // 견적 히스토리 다이얼로그 상태 관리 - const [historyDialogOpen, setHistoryDialogOpen] = useState(false) - const [selectedQuotationId, setSelectedQuotationId] = useState<number | null>(null) - - // 견적서 첨부파일 sheet 상태 관리 - const [quotationAttachmentsSheetOpen, setQuotationAttachmentsSheetOpen] = useState(false) - const [selectedQuotationInfo, setSelectedQuotationInfo] = useState<QuotationInfo | null>(null) - const [quotationAttachments, setQuotationAttachments] = useState<QuotationAttachment[]>([]) - const [isLoadingAttachments, setIsLoadingAttachments] = useState(false) - - // selectedRfq ID 메모이제이션 (객체 참조 변경 방지) - const selectedRfqId = useMemo(() => selectedRfq?.id, [selectedRfq?.id]) - - // existingVendorIds 메모이제이션 - const existingVendorIds = useMemo(() => { - return details.map(detail => Number(detail.vendorId)).filter(Boolean); - }, [details]); - - // 읽지 않은 메시지 로드 함수 메모이제이션 - const loadUnreadMessages = useCallback(async () => { - if (!selectedRfqId) return; - - try { - // 기술영업용 읽지 않은 메시지 수 가져오기 함수 구현 - const { getTechSalesUnreadMessageCounts } = await import("@/lib/techsales-rfq/service"); - const unreadData = await getTechSalesUnreadMessageCounts(selectedRfqId); - setUnreadMessages(unreadData); - } catch (error) { - console.error("읽지 않은 메시지 로드 오류:", error); - setUnreadMessages({}); - } - }, [selectedRfqId]); - - // 데이터 새로고침 함수 메모이제이션 - const handleRefreshData = useCallback(async () => { - if (!selectedRfqId) return - - try { - // 실제 벤더 견적 데이터 다시 로딩 - const { getTechSalesRfqTechVendors } = await import("@/lib/techsales-rfq/service") - - const result = await getTechSalesRfqTechVendors(selectedRfqId) - - // 데이터 변환 - const transformedData = result.data?.map((item: any) => ({ - ...item, - detailId: item.id, - rfqId: selectedRfqId, - rfqCode: selectedRfq?.rfqCode || null, - rfqType: selectedRfq?.rfqType || null, - ptypeNm: selectedRfq?.ptypeNm || null, - vendorId: item.vendorId ? Number(item.vendorId) : undefined, - })) || [] - - setDetails(transformedData) - - // 읽지 않은 메시지 개수 업데이트 - await loadUnreadMessages(); - - toast.success("데이터를 성공적으로 새로고침했습니다") - } catch (error) { - console.error("데이터 새로고침 오류:", error) - toast.error("데이터를 새로고침하는 중 오류가 발생했습니다") - } - }, [selectedRfqId, selectedRfq?.rfqCode, selectedRfq?.rfqType, selectedRfq?.ptypeNm, loadUnreadMessages]) - - // 벤더 추가 핸들러 메모이제이션 - const handleAddVendor = useCallback(async () => { - try { - setIsAdddialogLoading(true) - setVendorDialogOpen(true) - } catch (error) { - console.error("데이터 로드 오류:", error) - toast.error("벤더 정보를 불러오는 중 오류가 발생했습니다") - } finally { - setIsAdddialogLoading(false) - } - }, []) - - // RFQ 발송 핸들러 메모이제이션 - const handleSendRfq = useCallback(async () => { - if (selectedRows.length === 0) { - toast.warning("발송할 벤더를 선택해주세요."); - return; - } - - if (!selectedRfqId) { - toast.error("선택된 RFQ가 없습니다."); - return; - } - - try { - setIsSendingRfq(true); - - // 기술영업 RFQ 발송 서비스 함수 호출 - const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean); - const { sendTechSalesRfqToVendors } = await import("@/lib/techsales-rfq/service"); - - const result = await sendTechSalesRfqToVendors({ - rfqId: selectedRfqId, - vendorIds: vendorIds as number[] - }); - - if (result.success) { - toast.success(result.message || `${selectedRows.length}개 벤더에게 RFQ가 발송되었습니다.`); - } else { - toast.error(result.message || "RFQ 발송 중 오류가 발생했습니다."); - } - - // 선택 해제 - setSelectedRows([]); - - // 데이터 새로고침 - await handleRefreshData(); - - } catch (error) { - console.error("RFQ 발송 오류:", error); - toast.error("RFQ 발송 중 오류가 발생했습니다."); - } finally { - setIsSendingRfq(false); - } - }, [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) { - toast.warning("삭제할 벤더를 선택해주세요."); - return; - } - - if (!selectedRfqId) { - toast.error("선택된 RFQ가 없습니다."); - return; - } - - try { - setIsDeletingVendors(true); - - const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean) as number[]; - - if (vendorIds.length === 0) { - toast.error("유효한 벤더 ID가 없습니다."); - return; - } - - // 서비스 함수 호출 - const { removeTechVendorsFromTechSalesRfq } = await import("@/lib/techsales-rfq/service"); - - const result = await removeTechVendorsFromTechSalesRfq({ - rfqId: selectedRfqId, - vendorIds: vendorIds - }); - - if (result.error) { - toast.error(result.error); - } else { - const successCount = result.data?.length || 0 - toast.success(`${successCount}개의 벤더가 성공적으로 삭제되었습니다`); - } - - // 선택 해제 - setSelectedRows([]); - - // 데이터 새로고침 - await handleRefreshData(); - - } catch (error) { - console.error("벤더 삭제 오류:", error); - toast.error("벤더 삭제 중 오류가 발생했습니다."); - } finally { - setIsDeletingVendors(false); - } - }, [selectedRows, selectedRfqId, handleRefreshData]); - - // 벤더 삭제 확인 핸들러 - const handleDeleteVendorsConfirm = useCallback(() => { - if (selectedRows.length === 0) { - toast.warning("삭제할 벤더를 선택해주세요."); - return; - } - setDeleteConfirmDialogOpen(true); - }, [selectedRows]); - - // 벤더 삭제 확정 실행 - const executeDeleteVendors = useCallback(async () => { - setDeleteConfirmDialogOpen(false); - await handleDeleteVendors(); - }, [handleDeleteVendors]); - - - // 견적 히스토리 다이얼로그 열기 핸들러 메모이제이션 - const handleOpenHistoryDialog = useCallback((quotationId: number) => { - setSelectedQuotationId(quotationId); - setHistoryDialogOpen(true); - }, []) - - // 견적서 첨부파일 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, - onQuotationClick: handleOpenHistoryDialog, - openQuotationAttachmentsSheet: handleOpenQuotationAttachmentsSheet - }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet]) - - // 필터 필드 정의 (메모이제이션) - const advancedFilterFields = useMemo( - () => [ - { - id: "vendorName", - label: "벤더명", - type: "text", - }, - { - id: "vendorCode", - label: "벤더 코드", - type: "text", - }, - { - id: "currency", - label: "통화", - type: "text", - }, - ], - [] - ) - - // 계산된 값들 메모이제이션 - const vendorsWithQuotations = useMemo(() => - details.filter(detail => detail.status === "Submitted").length, - [details] - ); - - // RFQ ID가 변경될 때 데이터 로드 - useEffect(() => { - async function loadRfqDetails() { - if (!selectedRfqId) { - setDetails([]) - return - } - - try { - setIsLoading(true) - - // 실제 벤더 견적 데이터 로딩 - const { getTechSalesVendorQuotationsWithJoin } = await import("@/lib/techsales-rfq/service") - - const result = await getTechSalesVendorQuotationsWithJoin({ - rfqId: selectedRfqId, - page: 1, - perPage: 1000, // 모든 데이터 가져오기 - }) - - // 데이터 변환 (procurement 패턴에 맞게) - const transformedData = result.data?.map(item => ({ - ...item, - detailId: item.id, - rfqId: selectedRfqId, - rfqCode: selectedRfq?.rfqCode || null, - vendorId: item.vendorId ? Number(item.vendorId) : undefined, - // 기타 필요한 필드 변환 - })) || [] - - setDetails(transformedData) - - // 읽지 않은 메시지 개수 로드 - await loadUnreadMessages(); - - } catch (error) { - console.error("RFQ 디테일 로드 오류:", error) - setDetails([]) - toast.error("RFQ 세부정보를 불러오는 중 오류가 발생했습니다") - } finally { - setIsLoading(false) - } - } - - loadRfqDetails() - }, [selectedRfqId, selectedRfq?.rfqCode, loadUnreadMessages]) - - // 주기적으로 읽지 않은 메시지 갱신 (60초마다) - 메모이제이션된 함수 사용 - useEffect(() => { - if (!selectedRfqId) return; - - const intervalId = setInterval(() => { - loadUnreadMessages(); - }, 60000); // 60초마다 갱신 - - return () => clearInterval(intervalId); - }, [selectedRfqId, loadUnreadMessages]); - - // rowAction 처리 - procurement 패턴 적용 (메모이제이션) - useEffect(() => { - if (!rowAction) return - - const handleRowAction = async () => { - try { - // 통신 액션인 경우 드로어 열기 - if (rowAction.type === "communicate") { - setSelectedVendor(rowAction.row.original); - setCommunicationDrawerOpen(true); - - // rowAction 초기화 - setRowAction(null); - return; - } - - // 삭제 액션인 경우 개별 벤더 삭제 - if (rowAction.type === "delete") { - const vendor = rowAction.row.original; - - if (!vendor.vendorId || !selectedRfqId) { - toast.error("벤더 정보가 없습니다."); - setRowAction(null); - return; - } - - // Draft 상태 체크 - if (vendor.status !== "Draft") { - toast.error("Draft 상태의 벤더만 삭제할 수 있습니다."); - setRowAction(null); - return; - } - - // 개별 벤더 삭제 - const { removeTechVendorFromTechSalesRfq } = await import("@/lib/techsales-rfq/service"); - - const result = await removeTechVendorFromTechSalesRfq({ - rfqId: selectedRfqId, - vendorId: vendor.vendorId - }); - - if (result.error) { - toast.error(result.error); - } else { - toast.success(`${vendor.vendorName || '벤더'}가 성공적으로 삭제되었습니다.`); - // 데이터 새로고침 - await handleRefreshData(); - } - - // rowAction 초기화 - setRowAction(null); - return; - } - } catch (error) { - console.error("액션 처리 오류:", error); - toast.error("작업을 처리하는 중 오류가 발생했습니다"); - } - }; - - handleRowAction(); - }, [rowAction, selectedRfqId, handleRefreshData]) - - // 선택된 행 변경 핸들러 메모이제이션 - const handleSelectedRowsChange = useCallback((selectedRowsData: RfqDetailView[]) => { - setSelectedRows(selectedRowsData); - }, []); - - // 커뮤니케이션 드로어 변경 핸들러 메모이제이션 - const handleCommunicationDrawerChange = useCallback((open: boolean) => { - setCommunicationDrawerOpen(open); - // 드로어가 닫힐 때 해당 벤더의 메시지를 읽음 처리하고 읽지 않은 메시지 개수 갱신 - if (!open && selectedVendor?.vendorId && selectedRfqId) { - // 메시지를 읽음으로 처리 - import("@/lib/techsales-rfq/service").then(({ markTechSalesMessagesAsRead }) => { - markTechSalesMessagesAsRead(selectedRfqId, selectedVendor.vendorId || undefined).catch(error => { - console.error("메시지 읽음 처리 오류:", error); - }); - }); - - // 해당 벤더의 읽지 않은 메시지를 0으로 즉시 업데이트 - setUnreadMessages(prev => ({ - ...prev, - [selectedVendor.vendorId!]: 0 - })); - - // 전체 읽지 않은 메시지 개수 갱신 - loadUnreadMessages(); - } - }, [selectedVendor, selectedRfqId, loadUnreadMessages]); - - if (!selectedRfq) { - return ( - <div className="flex items-center justify-center h-full text-muted-foreground"> - RFQ를 선택하세요 - </div> - ) - } - - // 로딩 중인 경우 - if (isLoading) { - return ( - <div className="p-4 space-y-4"> - <Skeleton className="h-8 w-1/2" /> - <Skeleton className="h-24 w-full" /> - <Skeleton className="h-48 w-full" /> - </div> - ) - } - - return ( - <div className="h-full overflow-hidden pt-4"> - {/* 테이블 또는 빈 상태 표시 */} - {details.length > 0 ? ( - <ClientDataTable - columns={columns} - data={details} - advancedFilterFields={advancedFilterFields} - maxHeight={maxHeight} - onSelectedRowsChange={handleSelectedRowsChange} - > - <div className="flex justify-between items-center"> - <div className="flex items-center gap-2 mr-2"> - {selectedRows.length > 0 && ( - <Badge variant="default" className="h-6"> - {selectedRows.length}개 선택됨 - </Badge> - )} - {/* {totalUnreadMessages > 0 && ( - <Badge variant="destructive" className="h-6"> - 읽지 않은 메시지: {totalUnreadMessages}건 - </Badge> - )} */} - {vendorsWithQuotations > 0 && ( - <Badge variant="outline" className="h-6"> - 견적 제출: {vendorsWithQuotations}개 벤더 - </Badge> - )} - </div> - <div className="flex gap-2"> - {/* 벤더 선택 버튼 */} - <Button - variant="default" - size="sm" - onClick={handleAcceptVendors} - disabled={selectedRows.length === 0 || isAcceptingVendors} - className="gap-2" - > - {isAcceptingVendors ? ( - <Loader2 className="size-4 animate-spin" aria-hidden="true" /> - ) : ( - <CheckCircle className="size-4" aria-hidden="true" /> - )} - <span>벤더 선택</span> - </Button> - - {/* RFQ 발송 버튼 */} - <Button - variant="outline" - size="sm" - onClick={handleSendRfq} - disabled={selectedRows.length === 0 || isSendingRfq} - className="gap-2" - > - {isSendingRfq ? ( - <Loader2 className="size-4 animate-spin" aria-hidden="true" /> - ) : ( - <Send className="size-4" aria-hidden="true" /> - )} - <span>RFQ 발송</span> - </Button> - - {/* 벤더 삭제 버튼 */} - <Button - variant="outline" - size="sm" - onClick={handleDeleteVendorsConfirm} - disabled={selectedRows.length === 0 || isDeletingVendors} - className="gap-2" - > - {isDeletingVendors ? ( - <Loader2 className="size-4 animate-spin" aria-hidden="true" /> - ) : ( - <Trash2 className="size-4" aria-hidden="true" /> - )} - <span>벤더 삭제</span> - </Button> - - {/* 벤더 추가 버튼 */} - <Button - variant="outline" - size="sm" - onClick={handleAddVendor} - disabled={isAdddialogLoading} - className="gap-2" - > - {isAdddialogLoading ? ( - <Loader2 className="size-4 animate-spin" aria-hidden="true" /> - ) : ( - <UserPlus className="size-4" aria-hidden="true" /> - )} - <span>벤더 추가</span> - </Button> - </div> - </div> - </ClientDataTable> - ) : ( - <div className="flex h-full items-center justify-center text-muted-foreground"> - <div className="text-center"> - <p className="text-lg font-medium">벤더가 없습니다</p> - <p className="text-sm">벤더를 추가하여 RFQ를 시작하세요</p> - <Button - variant="outline" - size="sm" - onClick={handleAddVendor} - disabled={isAdddialogLoading} - className="mt-4 gap-2" - > - {isAdddialogLoading ? ( - <Loader2 className="size-4 animate-spin" aria-hidden="true" /> - ) : ( - <UserPlus className="size-4" aria-hidden="true" /> - )} - <span>벤더 추가</span> - </Button> - </div> - </div> - )} - - {/* 다이얼로그들 */} - <AddVendorDialog - open={vendorDialogOpen} - onOpenChange={setVendorDialogOpen} - selectedRfq={selectedRfq as unknown as TechSalesRfq} - existingVendorIds={existingVendorIds} - onSuccess={handleRefreshData} - /> - - {/* 벤더 커뮤니케이션 드로어 */} - <VendorCommunicationDrawer - open={communicationDrawerOpen} - onOpenChange={handleCommunicationDrawerChange} - selectedRfq={selectedRfq} - selectedVendor={selectedVendor} - onSuccess={handleRefreshData} - /> - - {/* 다중 벤더 삭제 확인 다이얼로그 */} - <DeleteVendorsDialog - open={deleteConfirmDialogOpen} - onOpenChange={setDeleteConfirmDialogOpen} - vendors={selectedRows} - onConfirm={executeDeleteVendors} - isLoading={isDeletingVendors} - /> - - {/* 견적 히스토리 다이얼로그 */} - <QuotationHistoryDialog - open={historyDialogOpen} - onOpenChange={setHistoryDialogOpen} - quotationId={selectedQuotationId} - /> - - {/* 견적서 첨부파일 Sheet */} - <TechSalesQuotationAttachmentsSheet - open={quotationAttachmentsSheetOpen} - onOpenChange={setQuotationAttachmentsSheetOpen} - quotation={selectedQuotationInfo} - attachments={quotationAttachments} - isLoading={isLoadingAttachments} - /> - </div> - ) +"use client"
+
+import * as React from "react"
+import { useEffect, useState, useCallback, useMemo } from "react"
+import {
+ DataTableRowAction,
+ getRfqDetailColumns,
+ RfqDetailView
+} from "./rfq-detail-column"
+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, 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 { DeleteVendorDialog } 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"
+import { VendorContactSelectionDialog } from "./vendor-contact-selection-dialog"
+import { QuotationContactsViewDialog } from "./quotation-contacts-view-dialog"
+
+// 기본적인 RFQ 타입 정의
+interface TechSalesRfq {
+ id: number
+ rfqCode: string | null
+ status: string
+ materialCode?: string | null
+ itemName?: string | null
+ remark?: string | null
+ rfqSendDate?: Date | null
+ dueDate?: Date | null
+ createdByName?: string | null
+ rfqType: "SHIP" | "TOP" | "HULL" | null
+ ptypeNm?: string | null
+}
+
+// 프로퍼티 정의
+interface RfqDetailTablesProps {
+ selectedRfq: TechSalesRfq | null
+ maxHeight?: string | number
+}
+
+
+export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps) {
+ // console.log("selectedRfq", selectedRfq)
+
+ // 상태 관리
+ const [isLoading, setIsLoading] = useState(false)
+ const [details, setDetails] = useState<RfqDetailView[]>([])
+ const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false)
+
+ const [isAdddialogLoading, setIsAdddialogLoading] = useState(false)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDetailView> | null>(null)
+
+ // 벤더 커뮤니케이션 상태 관리
+ const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false)
+ const [selectedVendor, setSelectedVendor] = useState<RfqDetailView | null>(null)
+
+ // 읽지 않은 메시지 개수
+ const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({})
+
+ // 테이블 선택 상태 관리
+ const [selectedRows, setSelectedRows] = useState<RfqDetailView[]>([])
+ const [isSendingRfq, setIsSendingRfq] = useState(false)
+ const [isDeletingVendors, setIsDeletingVendors] = useState(false)
+
+ // 벤더 삭제 확인 다이얼로그 상태 추가
+ const [deleteConfirmDialogOpen, setDeleteConfirmDialogOpen] = useState(false)
+
+ // 견적 히스토리 다이얼로그 상태 관리
+ const [historyDialogOpen, setHistoryDialogOpen] = useState(false)
+ const [selectedQuotationId, setSelectedQuotationId] = useState<number | null>(null)
+
+ // 견적서 첨부파일 sheet 상태 관리
+ const [quotationAttachmentsSheetOpen, setQuotationAttachmentsSheetOpen] = useState(false)
+ const [selectedQuotationInfo, setSelectedQuotationInfo] = useState<QuotationInfo | null>(null)
+ const [quotationAttachments, setQuotationAttachments] = useState<QuotationAttachment[]>([])
+ const [isLoadingAttachments, setIsLoadingAttachments] = useState(false)
+
+ // 벤더 contact 선택 다이얼로그 상태 관리
+ const [contactSelectionDialogOpen, setContactSelectionDialogOpen] = useState(false)
+
+ // 담당자 조회 다이얼로그 상태 관리
+ const [contactsDialogOpen, setContactsDialogOpen] = useState(false)
+ const [selectedQuotationForContacts, setSelectedQuotationForContacts] = useState<{ id: number; vendorName?: string } | null>(null)
+
+ // selectedRfq ID 메모이제이션 (객체 참조 변경 방지)
+ const selectedRfqId = useMemo(() => selectedRfq?.id, [selectedRfq?.id])
+
+ // existingVendorIds 메모이제이션
+ const existingVendorIds = useMemo(() => {
+ return details.map(detail => Number(detail.vendorId)).filter(Boolean);
+ }, [details]);
+
+ // 읽지 않은 메시지 로드 함수 메모이제이션
+ const loadUnreadMessages = useCallback(async () => {
+ if (!selectedRfqId) return;
+
+ try {
+ // 기술영업용 읽지 않은 메시지 수 가져오기 함수 구현
+ const { getTechSalesUnreadMessageCounts } = await import("@/lib/techsales-rfq/service");
+ const unreadData = await getTechSalesUnreadMessageCounts(selectedRfqId);
+ setUnreadMessages(unreadData);
+ } catch (error) {
+ console.error("읽지 않은 메시지 로드 오류:", error);
+ setUnreadMessages({});
+ }
+ }, [selectedRfqId]);
+
+ // 데이터 새로고침 함수 메모이제이션
+ const handleRefreshData = useCallback(async () => {
+ if (!selectedRfqId) return
+
+ try {
+ // 실제 벤더 견적 데이터 다시 로딩
+ const { getTechSalesRfqTechVendors } = await import("@/lib/techsales-rfq/service")
+
+ const result = await getTechSalesRfqTechVendors(selectedRfqId)
+
+ // 데이터 변환
+ const transformedData = result.data?.map((item: any) => ({
+ ...item,
+ detailId: item.id,
+ rfqId: selectedRfqId,
+ rfqCode: selectedRfq?.rfqCode || null,
+ rfqType: selectedRfq?.rfqType || null,
+ ptypeNm: selectedRfq?.ptypeNm || null,
+ vendorId: item.vendorId ? Number(item.vendorId) : undefined,
+ })) || []
+
+ setDetails(transformedData)
+
+ // 읽지 않은 메시지 개수 업데이트
+ await loadUnreadMessages();
+
+ toast.success("데이터를 성공적으로 새로고침했습니다")
+ } catch (error) {
+ console.error("데이터 새로고침 오류:", error)
+ toast.error("데이터를 새로고침하는 중 오류가 발생했습니다")
+ }
+ }, [selectedRfqId, selectedRfq?.rfqCode, selectedRfq?.rfqType, selectedRfq?.ptypeNm, loadUnreadMessages])
+
+ // 벤더 추가 핸들러 메모이제이션
+ const handleAddVendor = useCallback(async () => {
+ try {
+ setIsAdddialogLoading(true)
+ setVendorDialogOpen(true)
+ } catch (error) {
+ console.error("데이터 로드 오류:", error)
+ toast.error("벤더 정보를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsAdddialogLoading(false)
+ }
+ }, [])
+
+ // RFQ 발송 핸들러 메모이제이션 - contact selection dialog 사용
+ const handleSendRfq = useCallback(async () => {
+ if (selectedRows.length === 0) {
+ toast.warning("발송할 벤더를 선택해주세요.");
+ return;
+ }
+
+ if (!selectedRfqId) {
+ toast.error("선택된 RFQ가 없습니다.");
+ return;
+ }
+
+ // 선택된 벤더들의 status가 모두 'Assigned'인지 확인
+ const nonAssignedVendors = selectedRows.filter(row => row.status !== "Assigned");
+ if (nonAssignedVendors.length > 0) {
+ toast.warning("Assigned 상태의 벤더만 RFQ를 발송할 수 있습니다.");
+ return;
+ }
+
+ // contact selection dialog 열기
+ setContactSelectionDialogOpen(true);
+ }, [selectedRows, selectedRfqId]);
+
+ // contact 기반 RFQ 발송 핸들러
+ const handleSendRfqWithContacts = useCallback(async (selectedContacts: Array<{
+ vendorId: number;
+ contactId: number;
+ contactEmail: string;
+ contactName: string;
+ }>) => {
+ if (!selectedRfqId) {
+ toast.error("선택된 RFQ가 없습니다.");
+ return;
+ }
+
+ try {
+ setIsSendingRfq(true);
+
+ // 기술영업 RFQ 발송 서비스 함수 호출 (contact 정보 포함)
+ const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean);
+ const { sendTechSalesRfqToVendors } = await import("@/lib/techsales-rfq/service");
+
+ const result = await sendTechSalesRfqToVendors({
+ rfqId: selectedRfqId,
+ vendorIds: vendorIds as number[],
+ selectedContacts: selectedContacts
+ });
+
+ if (result.success) {
+ toast.success(result.message || `${selectedContacts.length}명의 연락처에게 RFQ가 발송되었습니다.`);
+ } else {
+ toast.error(result.message || "RFQ 발송 중 오류가 발생했습니다.");
+ }
+
+ // 선택 해제
+ setSelectedRows([]);
+
+ // 데이터 새로고침
+ await handleRefreshData();
+
+ } catch (error) {
+ console.error("RFQ 발송 오류:", error);
+ toast.error("RFQ 발송 중 오류가 발생했습니다.");
+ } finally {
+ setIsSendingRfq(false);
+ }
+ }, [selectedRfqId, selectedRows, 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) {
+ toast.warning("삭제할 벤더를 선택해주세요.");
+ return;
+ }
+
+ if (!selectedRfqId) {
+ toast.error("선택된 RFQ가 없습니다.");
+ return;
+ }
+
+ try {
+ setIsDeletingVendors(true);
+
+ const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean) as number[];
+
+ if (vendorIds.length === 0) {
+ toast.error("유효한 벤더 ID가 없습니다.");
+ return;
+ }
+
+ // 서비스 함수 호출
+ const { removeTechVendorsFromTechSalesRfq } = await import("@/lib/techsales-rfq/service");
+
+ const result = await removeTechVendorsFromTechSalesRfq({
+ rfqId: selectedRfqId,
+ vendorIds: vendorIds
+ });
+
+ if (result.error) {
+ toast.error(result.error);
+ } else {
+ const successCount = result.data?.length || 0
+ toast.success(`${successCount}개의 벤더가 성공적으로 삭제되었습니다`);
+ }
+
+ // 선택 해제
+ setSelectedRows([]);
+
+ // 데이터 새로고침
+ await handleRefreshData();
+
+ } catch (error) {
+ console.error("벤더 삭제 오류:", error);
+ toast.error("벤더 삭제 중 오류가 발생했습니다.");
+ } finally {
+ setIsDeletingVendors(false);
+ }
+ }, [selectedRows, selectedRfqId, handleRefreshData]);
+
+ // 벤더 삭제 확인 핸들러
+ const handleDeleteVendorsConfirm = useCallback(() => {
+ if (selectedRows.length === 0) {
+ toast.warning("삭제할 벤더를 선택해주세요.");
+ return;
+ }
+ setDeleteConfirmDialogOpen(true);
+ }, [selectedRows]);
+
+ // 벤더 삭제 확정 실행
+ const executeDeleteVendors = useCallback(async () => {
+ setDeleteConfirmDialogOpen(false);
+ await handleDeleteVendors();
+ }, [handleDeleteVendors]);
+
+
+ // 견적 히스토리 다이얼로그 열기 핸들러 메모이제이션
+ const handleOpenHistoryDialog = useCallback((quotationId: number) => {
+ setSelectedQuotationId(quotationId);
+ setHistoryDialogOpen(true);
+ }, [])
+
+ // 견적서 첨부파일 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);
+ }
+ }, [])
+
+ // 담당자 조회 다이얼로그 열기 함수
+ const handleOpenContactsDialog = useCallback((quotationId: number, vendorName?: string) => {
+ setSelectedQuotationForContacts({ id: quotationId, vendorName })
+ setContactsDialogOpen(true)
+ }, [])
+
+ // 칼럼 정의 - unreadMessages 상태 전달 (메모이제이션)
+ const columns = useMemo(() =>
+ getRfqDetailColumns({
+ setRowAction,
+ unreadMessages,
+ onQuotationClick: handleOpenHistoryDialog,
+ openQuotationAttachmentsSheet: handleOpenQuotationAttachmentsSheet,
+ openContactsDialog: handleOpenContactsDialog
+ }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet, handleOpenContactsDialog])
+
+ // 필터 필드 정의 (메모이제이션)
+ const advancedFilterFields = useMemo(
+ () => [
+ {
+ id: "vendorName",
+ label: "벤더명",
+ type: "text",
+ },
+ {
+ id: "vendorCode",
+ label: "벤더 코드",
+ type: "text",
+ },
+ {
+ id: "currency",
+ label: "통화",
+ type: "text",
+ },
+ ],
+ []
+ )
+
+ // 계산된 값들 메모이제이션
+ const vendorsWithQuotations = useMemo(() =>
+ details.filter(detail => detail.status === "Submitted").length,
+ [details]
+ );
+
+ // RFQ ID가 변경될 때 데이터 로드
+ useEffect(() => {
+ async function loadRfqDetails() {
+ if (!selectedRfqId) {
+ setDetails([])
+ return
+ }
+
+ try {
+ setIsLoading(true)
+
+ // 실제 벤더 견적 데이터 로딩
+ const { getTechSalesVendorQuotationsWithJoin } = await import("@/lib/techsales-rfq/service")
+
+ const result = await getTechSalesVendorQuotationsWithJoin({
+ rfqId: selectedRfqId,
+ page: 1,
+ perPage: 1000, // 모든 데이터 가져오기
+ })
+
+ // 데이터 변환 (procurement 패턴에 맞게)
+ const transformedData = result.data?.map(item => ({
+ ...item,
+ detailId: item.id,
+ rfqId: selectedRfqId,
+ rfqCode: selectedRfq?.rfqCode || null,
+ vendorId: item.vendorId ? Number(item.vendorId) : undefined,
+ // 기타 필요한 필드 변환
+ })) || []
+
+ setDetails(transformedData)
+
+ // 읽지 않은 메시지 개수 로드
+ await loadUnreadMessages();
+
+ } catch (error) {
+ console.error("RFQ 디테일 로드 오류:", error)
+ setDetails([])
+ toast.error("RFQ 세부정보를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadRfqDetails()
+ }, [selectedRfqId, selectedRfq?.rfqCode, loadUnreadMessages])
+
+ // 주기적으로 읽지 않은 메시지 갱신 (60초마다) - 메모이제이션된 함수 사용
+ useEffect(() => {
+ if (!selectedRfqId) return;
+
+ const intervalId = setInterval(() => {
+ loadUnreadMessages();
+ }, 60000); // 60초마다 갱신
+
+ return () => clearInterval(intervalId);
+ }, [selectedRfqId, loadUnreadMessages]);
+
+ // rowAction 처리 - procurement 패턴 적용 (메모이제이션)
+ useEffect(() => {
+ if (!rowAction) return
+
+ const handleRowAction = async () => {
+ try {
+ // 통신 액션인 경우 드로어 열기
+ if (rowAction.type === "communicate") {
+ setSelectedVendor(rowAction.row.original);
+ setCommunicationDrawerOpen(true);
+
+ // rowAction 초기화
+ setRowAction(null);
+ return;
+ }
+
+ // 삭제 액션인 경우 개별 벤더 삭제
+ if (rowAction.type === "delete") {
+ const vendor = rowAction.row.original;
+
+ if (!vendor.vendorId || !selectedRfqId) {
+ toast.error("벤더 정보가 없습니다.");
+ setRowAction(null);
+ return;
+ }
+
+ // Draft 상태 체크
+ if (vendor.status !== "Draft") {
+ toast.error("Draft 상태의 벤더만 삭제할 수 있습니다.");
+ setRowAction(null);
+ return;
+ }
+
+ // 개별 벤더 삭제
+ const { removeTechVendorFromTechSalesRfq } = await import("@/lib/techsales-rfq/service");
+
+ const result = await removeTechVendorFromTechSalesRfq({
+ rfqId: selectedRfqId,
+ vendorId: vendor.vendorId
+ });
+
+ if (result.error) {
+ toast.error(result.error);
+ } else {
+ toast.success(`${vendor.vendorName || '벤더'}가 성공적으로 삭제되었습니다.`);
+ // 데이터 새로고침
+ await handleRefreshData();
+ }
+
+ // rowAction 초기화
+ setRowAction(null);
+ return;
+ }
+ } catch (error) {
+ console.error("액션 처리 오류:", error);
+ toast.error("작업을 처리하는 중 오류가 발생했습니다");
+ }
+ };
+
+ handleRowAction();
+ }, [rowAction, selectedRfqId, handleRefreshData])
+
+ // 선택된 행 변경 핸들러 메모이제이션
+ const handleSelectedRowsChange = useCallback((selectedRowsData: RfqDetailView[]) => {
+ setSelectedRows(selectedRowsData);
+ }, []);
+
+ // 커뮤니케이션 드로어 변경 핸들러 메모이제이션
+ const handleCommunicationDrawerChange = useCallback((open: boolean) => {
+ setCommunicationDrawerOpen(open);
+ // 드로어가 닫힐 때 해당 벤더의 메시지를 읽음 처리하고 읽지 않은 메시지 개수 갱신
+ if (!open && selectedVendor?.vendorId && selectedRfqId) {
+ // 메시지를 읽음으로 처리
+ import("@/lib/techsales-rfq/service").then(({ markTechSalesMessagesAsRead }) => {
+ markTechSalesMessagesAsRead(selectedRfqId, selectedVendor.vendorId || undefined).catch(error => {
+ console.error("메시지 읽음 처리 오류:", error);
+ });
+ });
+
+ // 해당 벤더의 읽지 않은 메시지를 0으로 즉시 업데이트
+ setUnreadMessages(prev => ({
+ ...prev,
+ [selectedVendor.vendorId!]: 0
+ }));
+
+ // 전체 읽지 않은 메시지 개수 갱신
+ loadUnreadMessages();
+ }
+ }, [selectedVendor, selectedRfqId, loadUnreadMessages]);
+
+ if (!selectedRfq) {
+ return (
+ <div className="flex items-center justify-center h-full text-muted-foreground">
+ RFQ를 선택하세요
+ </div>
+ )
+ }
+
+ // 로딩 중인 경우
+ if (isLoading) {
+ return (
+ <div className="p-4 space-y-4">
+ <Skeleton className="h-8 w-1/2" />
+ <Skeleton className="h-24 w-full" />
+ <Skeleton className="h-48 w-full" />
+ </div>
+ )
+ }
+
+ return (
+ <div className="h-full overflow-hidden pt-4">
+ {/* 테이블 또는 빈 상태 표시 */}
+ {details.length > 0 ? (
+ <ClientDataTable
+ columns={columns}
+ data={details}
+ advancedFilterFields={advancedFilterFields}
+ maxHeight={maxHeight}
+ onSelectedRowsChange={handleSelectedRowsChange}
+ >
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-2 mr-2">
+ {selectedRows.length > 0 && (
+ <Badge variant="default" className="h-6">
+ {selectedRows.length}개 선택됨
+ </Badge>
+ )}
+ {/* {totalUnreadMessages > 0 && (
+ <Badge variant="destructive" className="h-6">
+ 읽지 않은 메시지: {totalUnreadMessages}건
+ </Badge>
+ )} */}
+ {vendorsWithQuotations > 0 && (
+ <Badge variant="outline" className="h-6">
+ 견적 제출: {vendorsWithQuotations}개 벤더
+ </Badge>
+ )}
+ </div>
+ <div className="flex gap-2">
+ {/* 벤더 선택 버튼 */}
+ <Button
+ variant="default"
+ size="sm"
+ onClick={handleAcceptVendors}
+ disabled={
+ selectedRows.length === 0 ||
+ isAcceptingVendors ||
+ selectedRows.length > 1 ||
+ selectedRows.some(row => row.status !== "Submitted")
+ }
+ className="gap-2"
+ >
+ {isAcceptingVendors ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <CheckCircle className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 선택</span>
+ </Button>
+
+ {/* RFQ 발송 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSendRfq}
+ disabled={
+ selectedRows.length === 0 ||
+ isSendingRfq ||
+ selectedRows.some(row => row.status !== "Assigned")
+ }
+ className="gap-2"
+ >
+ {isSendingRfq ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <Send className="size-4" aria-hidden="true" />
+ )}
+ <span>RFQ 발송</span>
+ </Button>
+
+ {/* 벤더 삭제 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleDeleteVendorsConfirm}
+ disabled={selectedRows.length === 0 || isDeletingVendors}
+ className="gap-2"
+ >
+ {isDeletingVendors ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <Trash2 className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 삭제</span>
+ </Button>
+
+ {/* 벤더 추가 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleAddVendor}
+ disabled={isAdddialogLoading}
+ className="gap-2"
+ >
+ {isAdddialogLoading ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <UserPlus className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 추가</span>
+ </Button>
+ </div>
+ </div>
+ </ClientDataTable>
+ ) : (
+ <div className="flex h-full items-center justify-center text-muted-foreground">
+ <div className="text-center">
+ <p className="text-lg font-medium">벤더가 없습니다</p>
+ <p className="text-sm">벤더를 추가하여 RFQ를 시작하세요</p>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleAddVendor}
+ disabled={isAdddialogLoading}
+ className="mt-4 gap-2"
+ >
+ {isAdddialogLoading ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <UserPlus className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 추가</span>
+ </Button>
+ </div>
+ </div>
+ )}
+
+ {/* 다이얼로그들 */}
+ <AddVendorDialog
+ open={vendorDialogOpen}
+ onOpenChange={setVendorDialogOpen}
+ selectedRfq={selectedRfq as unknown as TechSalesRfq}
+ existingVendorIds={existingVendorIds}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 벤더 커뮤니케이션 드로어 */}
+ <VendorCommunicationDrawer
+ open={communicationDrawerOpen}
+ onOpenChange={handleCommunicationDrawerChange}
+ selectedRfq={selectedRfq}
+ selectedVendor={selectedVendor}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 다중 벤더 삭제 확인 다이얼로그 */}
+ <DeleteVendorDialog
+ open={deleteConfirmDialogOpen}
+ onOpenChange={setDeleteConfirmDialogOpen}
+ vendors={selectedRows}
+ onConfirm={executeDeleteVendors}
+ isLoading={isDeletingVendors}
+ />
+
+ {/* 견적 히스토리 다이얼로그 */}
+ <QuotationHistoryDialog
+ open={historyDialogOpen}
+ onOpenChange={setHistoryDialogOpen}
+ quotationId={selectedQuotationId}
+ />
+
+ {/* 견적서 첨부파일 Sheet */}
+ <TechSalesQuotationAttachmentsSheet
+ open={quotationAttachmentsSheetOpen}
+ onOpenChange={setQuotationAttachmentsSheetOpen}
+ quotation={selectedQuotationInfo}
+ attachments={quotationAttachments}
+ isLoading={isLoadingAttachments}
+ />
+
+ {/* 벤더 contact 선택 다이얼로그 */}
+ <VendorContactSelectionDialog
+ open={contactSelectionDialogOpen}
+ onOpenChange={setContactSelectionDialogOpen}
+ vendorIds={selectedRows.map(row => row.vendorId).filter(Boolean) as number[]}
+ onSendRfq={handleSendRfqWithContacts}
+ />
+
+ {/* 담당자 조회 다이얼로그 */}
+ <QuotationContactsViewDialog
+ open={contactsDialogOpen}
+ onOpenChange={setContactsDialogOpen}
+ quotationId={selectedQuotationForContacts?.id || null}
+ vendorName={selectedQuotationForContacts?.vendorName}
+ />
+ </div>
+ )
}
\ No newline at end of file |
