From 14f61e24947fb92dd71ec0a7196a6e815f8e66da Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 21 Jul 2025 07:54:26 +0000 Subject: (최겸)기술영업 RFQ 담당자 초대, 요구사항 반영 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table/detail-table/rfq-detail-table.tsx | 1483 ++++++++++---------- 1 file changed, 774 insertions(+), 709 deletions(-) (limited to 'lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx') 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([]) - const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false) - - const [isAdddialogLoading, setIsAdddialogLoading] = useState(false) - - const [rowAction, setRowAction] = React.useState | null>(null) - - // 벤더 커뮤니케이션 상태 관리 - const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false) - const [selectedVendor, setSelectedVendor] = useState(null) - - // 읽지 않은 메시지 개수 - const [unreadMessages, setUnreadMessages] = useState>({}) - - // 테이블 선택 상태 관리 - const [selectedRows, setSelectedRows] = useState([]) - const [isSendingRfq, setIsSendingRfq] = useState(false) - const [isDeletingVendors, setIsDeletingVendors] = useState(false) - - // 벤더 삭제 확인 다이얼로그 상태 추가 - 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]) - - // 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 ( -
- RFQ를 선택하세요 -
- ) - } - - // 로딩 중인 경우 - if (isLoading) { - return ( -
- - - -
- ) - } - - return ( -
- {/* 테이블 또는 빈 상태 표시 */} - {details.length > 0 ? ( - -
-
- {selectedRows.length > 0 && ( - - {selectedRows.length}개 선택됨 - - )} - {/* {totalUnreadMessages > 0 && ( - - 읽지 않은 메시지: {totalUnreadMessages}건 - - )} */} - {vendorsWithQuotations > 0 && ( - - 견적 제출: {vendorsWithQuotations}개 벤더 - - )} -
-
- {/* 벤더 선택 버튼 */} - - - {/* RFQ 발송 버튼 */} - - - {/* 벤더 삭제 버튼 */} - - - {/* 벤더 추가 버튼 */} - -
-
-
- ) : ( -
-
-

벤더가 없습니다

-

벤더를 추가하여 RFQ를 시작하세요

- -
-
- )} - - {/* 다이얼로그들 */} - - - {/* 벤더 커뮤니케이션 드로어 */} - - - {/* 다중 벤더 삭제 확인 다이얼로그 */} - - - {/* 견적 히스토리 다이얼로그 */} - - - {/* 견적서 첨부파일 Sheet */} - -
- ) +"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([]) + const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false) + + const [isAdddialogLoading, setIsAdddialogLoading] = useState(false) + + const [rowAction, setRowAction] = React.useState | null>(null) + + // 벤더 커뮤니케이션 상태 관리 + const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false) + const [selectedVendor, setSelectedVendor] = useState(null) + + // 읽지 않은 메시지 개수 + const [unreadMessages, setUnreadMessages] = useState>({}) + + // 테이블 선택 상태 관리 + const [selectedRows, setSelectedRows] = useState([]) + const [isSendingRfq, setIsSendingRfq] = useState(false) + const [isDeletingVendors, setIsDeletingVendors] = useState(false) + + // 벤더 삭제 확인 다이얼로그 상태 추가 + 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) + + // 벤더 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 ( +
+ RFQ를 선택하세요 +
+ ) + } + + // 로딩 중인 경우 + if (isLoading) { + return ( +
+ + + +
+ ) + } + + return ( +
+ {/* 테이블 또는 빈 상태 표시 */} + {details.length > 0 ? ( + +
+
+ {selectedRows.length > 0 && ( + + {selectedRows.length}개 선택됨 + + )} + {/* {totalUnreadMessages > 0 && ( + + 읽지 않은 메시지: {totalUnreadMessages}건 + + )} */} + {vendorsWithQuotations > 0 && ( + + 견적 제출: {vendorsWithQuotations}개 벤더 + + )} +
+
+ {/* 벤더 선택 버튼 */} + + + {/* RFQ 발송 버튼 */} + + + {/* 벤더 삭제 버튼 */} + + + {/* 벤더 추가 버튼 */} + +
+
+
+ ) : ( +
+
+

벤더가 없습니다

+

벤더를 추가하여 RFQ를 시작하세요

+ +
+
+ )} + + {/* 다이얼로그들 */} + + + {/* 벤더 커뮤니케이션 드로어 */} + + + {/* 다중 벤더 삭제 확인 다이얼로그 */} + + + {/* 견적 히스토리 다이얼로그 */} + + + {/* 견적서 첨부파일 Sheet */} + + + {/* 벤더 contact 선택 다이얼로그 */} + row.vendorId).filter(Boolean) as number[]} + onSendRfq={handleSendRfqWithContacts} + /> + + {/* 담당자 조회 다이얼로그 */} + +
+ ) } \ No newline at end of file -- cgit v1.2.3