"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" import { ApprovalPreviewDialog } from "@/lib/approval/client" import { ApplicationReasonDialog } from "@/lib/rfq-last/vendor/application-reason-dialog" import { requestTechSalesRfqSendWithApproval } from "@/lib/techsales-rfq/approval-actions" import { mapTechSalesRfqSendToTemplateVariables } from "@/lib/techsales-rfq/approval-handlers" import { useSession } from "next-auth/react" // 기본적인 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 session = useSession() // 상태 관리 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) // 결재 관련 상태 관리 const [showApplicationReasonDialog, setShowApplicationReasonDialog] = useState(false) const [showApprovalPreview, setShowApprovalPreview] = useState(false) const [approvalPreviewData, setApprovalPreviewData] = useState<{ vendors: Array<{ vendorId: number vendorName: string }> drmAttachments: Array<{ fileName?: string | null fileSize?: number | null }> drmAttachmentIds: number[] selectedContacts?: Array<{ vendorId: number contactId: number contactEmail: string contactName: string }> templateVariables?: Record applicationReason?: 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; } // 선택된 벤더들의 담당자 존재 여부 확인 try { const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean) as number[]; if (vendorIds.length === 0) { toast.error("유효한 벤더가 선택되지 않았습니다."); return; } // 벤더별 담당자 조회 const { getTechVendorsContacts } = await import("@/lib/techsales-rfq/service"); const contactsResult = await getTechVendorsContacts(vendorIds); if (contactsResult.error) { toast.error("벤더 담당자 정보를 불러오는 중 오류가 발생했습니다."); return; } // 담당자가 없는 벤더 확인 const vendorsWithoutContacts = vendorIds.filter(vendorId => { const vendorContacts = contactsResult.data[vendorId]; return !vendorContacts || vendorContacts.contacts.length === 0; }); if (vendorsWithoutContacts.length > 0) { toast.error("담당자가 지정되지 않은 협력업체가 있습니다."); return; } // contact selection dialog 열기 setContactSelectionDialogOpen(true); } catch (error) { console.error("벤더 담당자 확인 오류:", error); toast.error("벤더 담당자 정보를 확인하는 중 오류가 발생했습니다."); } }, [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); // DRM 해제 여부 확인 const { checkTechSalesRfqHasDrmAttachments, sendTechSalesRfqToVendors } = await import("@/lib/techsales-rfq/service"); const drmCheck = await checkTechSalesRfqHasDrmAttachments(selectedRfqId); // DRM 파일이 걸려있으면 결재 프로세스 진행 if (drmCheck.hasDrm) { // 결재 데이터 저장 setApprovalPreviewData({ vendors: selectedRows.map(row => ({ vendorId: row.vendorId!, vendorName: row.vendorName || "", })), drmAttachments: drmCheck.drmAttachments, drmAttachmentIds: drmCheck.drmAttachmentIds, selectedContacts: selectedContacts, }); // 신청사유 입력 다이얼로그 표시 setShowApplicationReasonDialog(true); setIsSendingRfq(false); return; } // DRM 해제가 안 걸려있으면 바로 발송 const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean); const result = await sendTechSalesRfqToVendors({ rfqId: selectedRfqId, vendorIds: vendorIds as number[], selectedContacts: selectedContacts, currentUser: { id: Number(session.data.user.id), epId: session.data.user.epId || null, name: session.data.user.name || undefined, email: session.data.user.email || undefined, }, }); 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, session.data?.user]); // 벤더 선택 핸들러 추가 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) }, []) // 신청사유 입력 완료 핸들러 const handleApplicationReasonConfirm = useCallback(async (reason: string) => { if (!approvalPreviewData) { toast.error("결재 데이터가 없습니다."); return; } try { // 템플릿 변수 생성 (신청사유 포함) const templateVariables = await mapTechSalesRfqSendToTemplateVariables({ attachments: approvalPreviewData.drmAttachments, vendorNames: approvalPreviewData.vendors.map(v => v.vendorName), applicationReason: reason, }); // 결재 미리보기 데이터 업데이트 setApprovalPreviewData({ ...approvalPreviewData, templateVariables, applicationReason: reason, }); // 결재 미리보기 열기 setShowApprovalPreview(true); } catch (error) { console.error("템플릿 변수 생성 실패:", error); toast.error("결재 문서 생성에 실패했습니다."); } }, [approvalPreviewData]); // 결재 미리보기 확인 핸들러 const handleApprovalConfirm = useCallback(async (approvalData: { approvers: string[]; title: string; description?: string; }) => { if (!approvalPreviewData || !selectedRfq || !session.data?.user) { toast.error("결재 데이터가 없습니다."); return; } if (!session.data.user.epId) { toast.error("Knox EP ID가 필요합니다."); return; } try { const result = await requestTechSalesRfqSendWithApproval({ rfqId: selectedRfq.id, rfqCode: selectedRfq.rfqCode || undefined, rfqType: selectedRfq.rfqType || "SHIP", vendorIds: approvalPreviewData.vendors.map(v => v.vendorId), selectedContacts: approvalPreviewData.selectedContacts, drmAttachmentIds: approvalPreviewData.drmAttachmentIds, drmAttachments: approvalPreviewData.drmAttachments, applicationReason: approvalPreviewData.applicationReason || '', currentUser: { id: Number(session.data.user.id), epId: session.data.user.epId || null, name: session.data.user.name || undefined, email: session.data.user.email || undefined, }, approvers: approvalData.approvers, }); if (result.success) { toast.success(result.message); setShowApprovalPreview(false); setApprovalPreviewData(null); setSelectedRows([]); await handleRefreshData(); } } catch (error) { console.error("결재 상신 실패:", error); toast.error(error instanceof Error ? error.message : "결재 상신에 실패했습니다."); } }, [approvalPreviewData, selectedRfq, session, handleRefreshData]); // 칼럼 정의 - 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 !== "Assigned") { toast.error("Assigned 상태의 벤더만 삭제할 수 있습니다."); 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[]} rfqId={selectedRfqId} onSendRfq={handleSendRfqWithContacts} /> {/* 담당자 조회 다이얼로그 */} {/* 신청사유 입력 다이얼로그 */} {approvalPreviewData && ( )} {/* 결재 미리보기 다이얼로그 */} {approvalPreviewData && approvalPreviewData.templateVariables && ( )}
) }