summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:54:26 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:54:26 +0000
commit14f61e24947fb92dd71ec0a7196a6e815f8e66da (patch)
tree317c501d64662d05914330628f867467fba78132 /lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
parent194bd4bd7e6144d5c09c5e3f5476d254234dce72 (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.tsx1483
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