"use client"; import * as React from "react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Plus, Send, Eye, Edit, Trash2, Building2, Calendar, DollarSign, FileText, RefreshCw, Mail, CheckCircle, Clock, XCircle, AlertCircle, Settings2, ClipboardList, Globe, Package, MapPin, Info, Loader2, Router, Shield, CheckSquare, GitCompare, Link } from "lucide-react"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; import { type ColumnDef } from "@tanstack/react-table"; import { Checkbox } from "@/components/ui/checkbox"; import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"; import { ClientDataTable } from "@/components/client-data-table/data-table"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import type { DataTableAdvancedFilterField } from "@/types/table"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { AddVendorDialog } from "./add-vendor-dialog"; import { BatchUpdateConditionsDialog } from "./batch-update-conditions-dialog"; import { SendRfqDialog } from "./send-rfq-dialog"; import { CancelVendorResponseDialog } from "./cancel-vendor-response-dialog"; import { ApprovalPreviewDialog } from "@/lib/approval/client"; import { requestRfqSendWithApproval } from "../approval-actions"; import { mapRfqSendToTemplateVariables } from "../approval-handlers"; import { useSession } from "next-auth/react"; import { ApplicationReasonDialog } from "./application-reason-dialog"; import { getRfqSendData, getSelectedVendorsWithEmails, sendRfqToVendors, updateShortList, type RfqSendData, type VendorEmailInfo } from "../service" import { VendorResponseDetailDialog } from "./vendor-detail-dialog"; import { DeleteVendorDialog } from "./delete-vendor-dialog"; import { useRouter } from "next/navigation" import { EditContractDialog } from "./edit-contract-dialog"; import { createFilterFn } from "@/components/client-data-table/table-filters"; import { AvlVendorDialog } from "./avl-vendor-dialog"; import { PriceAdjustmentDialog } from "./price-adjustment-dialog"; // 타입 정의 interface RfqDetail { detailId: number; vendorId: number | null; vendorName: string | null; vendorCode: string | null; vendorCountry: string | null; vendorEmail?: string | null; vendorCategory?: string | null; vendorGrade?: string | null; basicContract?: string | null; shortList: boolean; currency: string | null; paymentTermsCode: string | null; paymentTermsDescription: string | null; incotermsCode: string | null; incotermsDescription: string | null; incotermsDetail?: string | null; deliveryDate: Date | null; contractDuration: string | null; taxCode: string | null; placeOfShipping?: string | null; placeOfDestination?: string | null; materialPriceRelatedYn?: boolean | null; sparepartYn?: boolean | null; firstYn?: boolean | null; firstDescription?: string | null; sparepartDescription?: string | null; updatedAt?: Date | null; updatedByUserName?: string | null; emailSentAt: string | null; emailSentTo: string | null; // JSON string emailResentCount: number; lastEmailSentAt: string | null; emailStatus: string | null; } interface VendorResponse { id: number; rfqsLastId: number; rfqLastDetailsId: number; responseVersion: number; isLatest: boolean; status: "초대됨" | "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소"; vendor: { id: number; code: string | null; name: string; email: string; }; submission: { submittedAt: Date | null; submittedBy: string | null; submittedByName: string | null; }; pricing: { totalAmount: number | null; currency: string | null; vendorCurrency: string | null; }; vendorTerms: { paymentTermsCode: string | null; incotermsCode: string | null; deliveryDate: Date | null; contractDuration: string | null; }; additionalRequirements: { firstArticle: { required: boolean | null; acceptance: boolean | null; }; sparePart: { required: boolean | null; acceptance: boolean | null; }; }; counts: { quotedItems: number; attachments: number; }; remarks: { general: string | null; technical: string | null; }; timestamps: { createdAt: string; updatedAt: string; }; } // Props 타입 정의 interface RfqVendorTableProps { rfqId: number; rfqCode?: string; rfqDetails: RfqDetail[]; vendorResponses: VendorResponse[]; rfqInfo?: { rfqTitle: string; rfqType: string; projectCode?: string; projectName?: string; picName?: string; picCode?: string; picTeam?: string; packageNo?: string; packageName?: string; designPicName?: string; designTeam?: string; materialGroup?: string; materialGroupDesc?: string; dueDate: Date; quotationType?: string; evaluationApply?: boolean; contractType?: string; }; attachments?: Array<{ id: number; attachmentType: string; serialNo: string; currentRevision: string; description?: string; fileName?: string; fileSize?: number; uploadedAt?: Date; }>; } // 상태별 아이콘 반환 const getStatusIcon = (status: string) => { switch (status) { case "초대됨": return ; case "작성중": return ; case "제출완료": return ; case "수정요청": return ; case "최종확정": return ; case "취소": return ; default: return ; } }; // 상태별 색상 const getStatusVariant = (status: string) => { switch (status) { case "초대됨": return "secondary"; case "작성중": return "outline"; case "제출완료": return "default"; case "수정요청": return "warning"; case "최종확정": return "success"; case "취소": return "destructive"; default: return "outline"; } }; // 데이터 병합 (rfqDetails + vendorResponses) const mergeVendorData = ( rfqDetails: RfqDetail[], vendorResponses: VendorResponse[], rfqCode?: string ): (RfqDetail & { response?: VendorResponse; rfqCode?: string })[] => { return rfqDetails.map(detail => { const response = vendorResponses.find( r => r.vendor.id === detail.vendorId && r.isLatest ); return { ...detail, response, rfqCode }; }); }; // 추가 조건 포맷팅 const formatAdditionalConditions = (data: any) => { const conditions = []; if (data.firstYn) conditions.push("초도품"); if (data.materialPriceRelatedYn) conditions.push("연동제"); if (data.sparepartYn) conditions.push("스페어"); return conditions.length > 0 ? conditions.join(", ") : "-"; }; export function RfqVendorTable({ rfqId, rfqCode, rfqDetails, vendorResponses, rfqInfo, attachments, }: RfqVendorTableProps) { const [isRefreshing, setIsRefreshing] = React.useState(false); const [selectedRows, setSelectedRows] = React.useState([]); const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false); const [isBatchUpdateOpen, setIsBatchUpdateOpen] = React.useState(false); const [selectedVendor, setSelectedVendor] = React.useState(null); const [isSendDialogOpen, setIsSendDialogOpen] = React.useState(false); const [isLoadingSendData, setIsLoadingSendData] = React.useState(false); const [deleteVendorData, setDeleteVendorData] = React.useState<{ detailId: number; vendorId: number; vendorName: string; vendorCode?: string | null; hasResponse?: boolean; responseStatus?: string | null; } | null>(null); const [sendDialogData, setSendDialogData] = React.useState<{ rfqInfo: RfqSendData['rfqInfo'] | null; attachments: RfqSendData['attachments']; selectedVendors: VendorEmailInfo[]; }>({ rfqInfo: null, attachments: [], selectedVendors: [], }); const [editContractVendor, setEditContractVendor] = React.useState(null); const [isUpdatingShortList, setIsUpdatingShortList] = React.useState(false); const [isAvlDialogOpen, setIsAvlDialogOpen] = React.useState(false); const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<{ data: any; vendorName: string; } | null>(null); const [isCancelDialogOpen, setIsCancelDialogOpen] = React.useState(false); // 결재 관련 상태 const [showApplicationReasonDialog, setShowApplicationReasonDialog] = React.useState(false); const [showApprovalPreview, setShowApprovalPreview] = React.useState(false); const [approvalPreviewData, setApprovalPreviewData] = React.useState<{ vendors: any[]; attachments: any[]; attachmentIds: number[]; message?: string; generatedPdfs?: any[]; hasToSendEmail?: boolean; templateVariables?: Record; applicationReason?: string; } | null>(null); const { data: session } = useSession(); // AVL 연동 핸들러 const handleAvlIntegration = React.useCallback(() => { setIsAvlDialogOpen(true); }, []); const router = useRouter() // 데이터 병합 const mergedData = React.useMemo( () => mergeVendorData(rfqDetails, vendorResponses, rfqCode), [rfqDetails, vendorResponses, rfqCode] ); console.log(mergedData, "mergedData") console.log(rfqId, "rfqId") // Short List 확정 핸들러 const handleShortListConfirm = React.useCallback(async () => { try { setIsUpdatingShortList(true); // response가 있는 벤더들만 필터링 const vendorsWithResponse = selectedRows.filter(vendor => vendor.response && vendor.response.vendor&& vendor.response.isDocumentConfirmed ); if (vendorsWithResponse.length === 0) { toast.warning("응답이 있는 벤더를 선택해주세요."); return; } const vendorIds = vendorsWithResponse .map(vendor => vendor.vendorId) .filter(id => id != null); const result = await updateShortList(rfqId, vendorIds, true); if (result.success) { toast.success(`${result.updatedCount}개 벤더를 Short List로 확정했습니다.`); setSelectedRows([]); router.refresh(); } } catch (error) { console.error("Short List 확정 실패:", error); toast.error("Short List 확정에 실패했습니다."); } finally { setIsUpdatingShortList(false); } }, [selectedRows, rfqId, router]); // 견적 비교 핸들러 const handleQuotationCompare = React.useCallback(() => { // 취소되지 않은 벤더만 필터링 const nonCancelledRows = selectedRows.filter(row => { const isCancelled = row.response?.status === "취소" || row.cancelReason; return !isCancelled; }); const vendorsWithQuotation = nonCancelledRows.filter(row => row.response?.submission?.submittedAt ); if (vendorsWithQuotation.length === 0) { toast.warning("비교할 견적이 있는 벤더를 선택해주세요."); return; } // 견적 비교 페이지로 이동 또는 모달 열기 const vendorIds = vendorsWithQuotation .map(v => v.vendorId) .filter(id => id != null) .join(','); router.push(`/evcp/rfq-last/${rfqId}/compare?vendors=${vendorIds}`); }, [selectedRows, rfqId, router]); // 일괄 발송 핸들러 const handleBulkSend = React.useCallback(async () => { // 취소되지 않은 벤더만 필터링 const nonCancelledRows = selectedRows.filter(row => { const isCancelled = row.response?.status === "취소" || row.cancelReason; return !isCancelled; }); if (nonCancelledRows.length === 0) { toast.warning("발송할 벤더를 선택해주세요. (취소된 벤더는 제외됩니다)"); return; } try { setIsLoadingSendData(true); // 선택된 벤더 ID들 추출 (취소되지 않은 벤더만) const selectedVendorIds = rfqCode?.startsWith("I") ? nonCancelledRows // .filter(v => v.shortList) .map(row => row.vendorId) .filter(id => id != null) : nonCancelledRows .map(row => row.vendorId) .filter(id => id != null) if (selectedVendorIds.length === 0) { toast.error("유효한 벤더가 선택되지 않았습니다."); return; } // 병렬로 데이터 가져오기 (에러 처리 포함) const [rfqSendData, vendorEmailInfos] = await Promise.all([ getRfqSendData(rfqId), getSelectedVendorsWithEmails(rfqId, selectedVendorIds) ]); // 데이터 검증 if (!rfqSendData?.rfqInfo) { toast.error("RFQ 정보를 불러올 수 없습니다."); return; } if (!vendorEmailInfos || vendorEmailInfos.length === 0) { toast.error("선택된 벤더의 이메일 정보를 찾을 수 없습니다."); return; } const hasAttachments = rfqSendData.attachments && rfqSendData.attachments.length > 0; // 🔹 첨부파일이 있는 경우: 결재 프로세스 시작 if (hasAttachments) { // Knox EP ID 확인 if (!session?.user?.epId) { toast.error("Knox EP ID가 없습니다. 시스템 관리자에게 문의하세요."); setIsLoadingSendData(false); return; } // 첨부파일 정보 변환 const attachmentsForApproval = rfqSendData.attachments.map((att: any) => ({ fileName: att.fileName, fileSize: att.fileSize, })); // 결재 데이터 임시 저장 (신청사유 입력 전) setApprovalPreviewData({ vendors: vendorEmailInfos.map(v => ({ vendorId: v.vendorId, vendorName: v.vendorName, vendorCode: v.vendorCode, vendorCountry: v.vendorCountry, selectedMainEmail: v.primaryEmail || v.vendorEmail || '', additionalEmails: [], customEmails: [], currency: v.currency, contractRequirements: { ndaYn: v.ndaYn || false, generalGtcYn: v.generalGtcYn || false, projectGtcYn: v.projectGtcYn || false, agreementYn: v.agreementYn || false, }, isResend: v.sendVersion ? v.sendVersion > 0 : false, sendVersion: v.sendVersion, })), attachments: attachmentsForApproval, attachmentIds: rfqSendData.attachments.map((att: any) => att.id), message: undefined, generatedPdfs: undefined, hasToSendEmail: true, }); // 신청사유 입력 다이얼로그 먼저 열기 setShowApplicationReasonDialog(true); setIsLoadingSendData(false); return; } // 🔹 첨부파일이 없는 경우: 기존 로직 (바로 발송) // 다이얼로그 데이터 설정 setSendDialogData({ rfqInfo: rfqSendData.rfqInfo, attachments: rfqSendData.attachments || [], selectedVendors: vendorEmailInfos.map(v => ({ vendorId: v.vendorId, vendorName: v.vendorName, vendorCode: v.vendorCode, vendorCountry: v.vendorCountry, vendorEmail: v.vendorEmail, representativeEmail: v.representativeEmail, contacts: v.contacts || [], contactsByPosition: v.contactsByPosition || {}, primaryEmail: v.primaryEmail, currency: v.currency, ndaYn: v.ndaYn, generalGtcYn: v.generalGtcYn, projectGtcYn: v.projectGtcYn, agreementYn: v.agreementYn, sendVersion: v.sendVersion })), }); // 다이얼로그 열기 setIsSendDialogOpen(true); } catch (error) { console.error("RFQ 발송 데이터 로드 실패:", error); toast.error("데이터를 불러오는데 실패했습니다. 다시 시도해주세요."); } finally { setIsLoadingSendData(false); } }, [selectedRows, rfqId, session]); // 신청사유 입력 완료 핸들러 const handleApplicationReasonConfirm = React.useCallback(async (reason: string) => { if (!approvalPreviewData) { toast.error("결재 데이터가 없습니다."); return; } try { // 템플릿 변수 생성 (신청사유 포함) const templateVariables = await mapRfqSendToTemplateVariables({ attachments: approvalPreviewData.attachments, vendorNames: approvalPreviewData.vendors.map(v => v.vendorName), applicationReason: reason, }); // 결재 미리보기 데이터 업데이트 setApprovalPreviewData({ ...approvalPreviewData, templateVariables, applicationReason: reason, }); // 신청사유 다이얼로그 닫고 결재 미리보기 열기 setShowApplicationReasonDialog(false); setShowApprovalPreview(true); } catch (error) { console.error("템플릿 변수 생성 실패:", error); toast.error("결재 문서 생성에 실패했습니다."); } }, [approvalPreviewData]); // 결재 미리보기 확인 핸들러 const handleApprovalConfirm = React.useCallback(async (approvalData: { approvers: string[]; title: string; description?: string; }) => { if (!approvalPreviewData || !session?.user) { toast.error("결재 데이터가 없습니다."); return; } try { const result = await requestRfqSendWithApproval({ rfqId, rfqCode, vendors: approvalPreviewData.vendors, attachmentIds: approvalPreviewData.attachmentIds, attachments: approvalPreviewData.attachments, message: approvalPreviewData.message, generatedPdfs: approvalPreviewData.generatedPdfs, hasToSendEmail: approvalPreviewData.hasToSendEmail, applicationReason: approvalPreviewData.applicationReason || '', currentUser: { id: Number(session.user.id), epId: session.user.epId || null, name: session.user.name || undefined, email: session.user.email || undefined, }, approvers: approvalData.approvers, }); if (result.success) { toast.success(result.message); setShowApprovalPreview(false); setApprovalPreviewData(null); setSelectedRows([]); router.refresh(); } } catch (error) { console.error("결재 상신 실패:", error); toast.error(error instanceof Error ? error.message : "결재 상신에 실패했습니다."); } }, [approvalPreviewData, session, rfqId, rfqCode, router]); // RFQ 발송 핸들러 const handleSendRfq = React.useCallback(async (data: { vendors: Array<{ vendorId: number; vendorName: string; vendorCode?: string | null; vendorCountry?: string | null; selectedMainEmail: string; additionalEmails: string[]; customEmails?: Array<{ email: string; name?: string }>; currency?: string | null; contractRequirements?: { ndaYn: boolean; generalGtcYn: boolean; projectGtcYn: boolean; agreementYn: boolean; projectCode?: string; }; isResend: boolean; sendVersion?: number; }>; attachments: number[]; message?: string; generatedPdfs?: Array<{ // 타입 추가 key: string; buffer: number[]; fileName: string; }>; hasToSendEmail?: boolean; }) => { try { // 서버 액션 호출 const result = await sendRfqToVendors({ rfqId, rfqCode, vendors: data.vendors, attachmentIds: data.attachments, message: data.message, generatedPdfs: data.generatedPdfs, hasToSendEmail: data.hasToSendEmail, }); // 성공 후 처리 setSelectedRows([]); setSendDialogData({ rfqInfo: null, attachments: [], selectedVendors: [], }); // 기본계약 생성 결과 표시 let message = ""; if (result.contractResults && result.contractResults.length > 0) { const totalContracts = result.contractResults.reduce((acc, r) => acc + r.totalCreated, 0); message = `${data.vendors.length}개 업체에 RFQ를 발송하고 ${totalContracts}개의 기본계약을 생성했습니다.`; } else { message = `${data.vendors.length}개 업체에 RFQ를 발송했습니다.`; } // 성공 결과를 반환 return { success: true, message: message, totalSent: result.totalSent || data.vendors.length, totalFailed: result.totalFailed || 0, totalContracts: result.totalContracts || 0, totalTbeSessions: result.totalTbeSessions || 0 }; } catch (error) { console.error("RFQ 발송 실패:", error); // 실패 결과를 반환 return { success: false, message: error instanceof Error ? error.message : "RFQ 발송에 실패했습니다.", totalSent: 0, totalFailed: data.vendors.length, totalContracts: 0, totalTbeSessions: 0 }; } }, [rfqId, rfqCode, router]); // vendor status에 따른 category 분류 함수 const getVendorCategoryFromStatus = React.useCallback((status: string | null): string => { if (!status) return "미분류"; const categoryMap: Record = { "PENDING_REVIEW": "발굴업체", // 가입 신청 중 "IN_REVIEW": "잠재업체", // 심사 중 "REJECTED": "발굴업체", // 심사 거부됨 "IN_PQ": "잠재업체", // PQ 진행 중 "PQ_SUBMITTED": "잠재업체", // PQ 제출 "PQ_FAILED": "잠재업체", // PQ 실패 "PQ_APPROVED": "잠재업체", // PQ 통과 "APPROVED": "잠재업체", // 승인됨 "READY_TO_SEND": "잠재업체", // 정규등록검토 "ACTIVE": "정규업체", // 활성 상태 (실제 거래 중) "INACTIVE": "중지업체", // 비활성 상태 "BLACKLISTED": "중지업체", // 거래 금지 }; return categoryMap[status] || "미분류"; }, []); // vendorCountry를 보기 좋은 형태로 변환 const formatVendorCountry = React.useCallback((country: string | null): string => { if (!country) return "미지정"; // KR 또는 한국이면 내자, 그 외는 전부 외자 const isLocal = country === "KR" || country === "한국"; return isLocal ? `내자(${country})` : `외자(${country})`; }, []); // 액션 처리 const handleAction = React.useCallback(async (action: string, vendor: any) => { switch (action) { case "view": setSelectedVendor(vendor); break; case "send": // 개별 RFQ 발송 try { setIsLoadingSendData(true); const [rfqSendData, vendorEmailInfos] = await Promise.all([ getRfqSendData(rfqId), getSelectedVendorsWithEmails(rfqId, [vendor.vendorId]) ]); if (!rfqSendData?.rfqInfo || !vendorEmailInfos || vendorEmailInfos.length === 0) { toast.error("벤더 정보를 불러올 수 없습니다."); return; } const hasAttachments = rfqSendData.attachments && rfqSendData.attachments.length > 0; // 🔹 첨부파일이 있는 경우: 결재 프로세스 if (hasAttachments) { if (!session?.user?.epId) { toast.error("Knox EP ID가 없습니다. 시스템 관리자에게 문의하세요."); setIsLoadingSendData(false); return; } const attachmentsForApproval = rfqSendData.attachments.map((att: any) => ({ fileName: att.fileName, fileSize: att.fileSize, })); setApprovalPreviewData({ vendors: vendorEmailInfos.map(v => ({ vendorId: v.vendorId, vendorName: v.vendorName, vendorCode: v.vendorCode, vendorCountry: v.vendorCountry, selectedMainEmail: v.primaryEmail || v.vendorEmail || '', additionalEmails: [], customEmails: [], currency: v.currency, contractRequirements: { ndaYn: v.ndaYn || false, generalGtcYn: v.generalGtcYn || false, projectGtcYn: v.projectGtcYn || false, agreementYn: v.agreementYn || false, }, isResend: v.sendVersion ? v.sendVersion > 0 : false, sendVersion: v.sendVersion, })), attachments: attachmentsForApproval, attachmentIds: rfqSendData.attachments.map((att: any) => att.id), message: undefined, generatedPdfs: undefined, hasToSendEmail: true, }); setShowApplicationReasonDialog(true); setIsLoadingSendData(false); return; } // 🔹 첨부파일이 없는 경우: 기존 로직 setSendDialogData({ rfqInfo: rfqSendData.rfqInfo, attachments: rfqSendData.attachments || [], selectedVendors: vendorEmailInfos.map(v => ({ vendorId: v.vendorId, vendorName: v.vendorName, vendorCode: v.vendorCode, vendorCountry: v.vendorCountry, vendorEmail: v.vendorEmail, representativeEmail: v.representativeEmail, contacts: v.contacts || [], contactsByPosition: v.contactsByPosition || {}, primaryEmail: v.primaryEmail, currency: v.currency, ndaYn: v.ndaYn, generalGtcYn: v.generalGtcYn, projectGtcYn: v.projectGtcYn, agreementYn: v.agreementYn, sendVersion: v.sendVersion, })), }); setIsSendDialogOpen(true); } catch (error) { console.error("개별 발송 데이터 로드 실패:", error); toast.error("데이터를 불러오는데 실패했습니다."); } finally { setIsLoadingSendData(false); } break; case "edit": toast.info("수정 기능은 준비중입니다."); break; case "edit-contract": // 기본계약 수정 setEditContractVendor(vendor); break; case "delete": // quotationStatus 체크 const hasQuotation = !!vendor.quotationStatus; if (hasQuotation) { // 견적서가 있으면 즉시 에러 토스트 표시 toast.error("이미 발송된 벤더는 삭제할 수 없습니다."); return; } // 삭제 다이얼로그 열기 setDeleteVendorData({ detailId: vendor.detailId, vendorId: vendor.vendorId, vendorName: vendor.vendorName, vendorCode: vendor.vendorCode, hasQuotation: hasQuotation, }); break; case "response-detail": toast.info(`${vendor.vendorName}의 회신 상세를 확인합니다.`); break; case "price-adjustment": // 연동제 정보 다이얼로그 열기 const priceAdjustmentForm = vendor.response?.priceAdjustmentForm || vendor.response?.additionalRequirements?.materialPriceRelated?.priceAdjustmentForm; if (!priceAdjustmentForm) { toast.warning("연동제 정보가 없습니다."); return; } setPriceAdjustmentData({ data: priceAdjustmentForm, vendorName: vendor.vendorName, }); break; } }, [rfqId]); // 컬럼 정의 const columns: ColumnDef[] = React.useMemo(() => [ { id: "select", header: ({ table }) => ( table.toggleAllPageRowsSelected(!!v)} aria-label="select all" className="translate-y-0.5" /> ), cell: ({ row }) => ( row.toggleSelected(!!v)} aria-label="select row" className="translate-y-0.5" /> ), size: 40, enableSorting: false, enableHiding: false, }, { accessorKey: "rfqCode", header: ({ column }) => , filterFn: createFilterFn("text"), cell: ({ row }) => { return ( {row.original.rfqCode || "-"} ); }, size: 120, }, { accessorKey: "vendorName", header: ({ column }) => , filterFn: createFilterFn("text"), cell: ({ row }) => { const vendor = row.original; return (
{vendor.vendorName || "-"} {vendor.vendorCode}
); }, size: 180, }, { accessorKey: "vendorCategory", header: ({ column }) => , filterFn: createFilterFn("text"), cell: ({ row }) => { const status = row.original.status; const category = getVendorCategoryFromStatus(status); return ( {category} ); }, size: 100, }, { accessorKey: "vendorCountry", header: ({ column }) => , filterFn: createFilterFn("text"), cell: ({ row }) => { const country = row.original.vendorCountry; const formattedCountry = formatVendorCountry(country); const isLocal = country === "KR" || country === "한국"; return ( {formattedCountry} ); }, size: 100, }, { accessorKey: "vendorGrade", header: ({ column }) => , filterFn: createFilterFn("text"), cell: ({ row }) => { const grade = row.original.vendorGrade; if (!grade) return -; const gradeColor = { "A": "text-green-600", "B": "text-blue-600", "C": "text-yellow-600", "D": "text-red-600", }[grade] || "text-gray-600"; return {grade}; }, size: 100, }, { accessorKey: "tbeStatus", header: ({ column }) => ( ), filterFn: createFilterFn("text"), cell: ({ row }) => { const status = row.original.tbeStatus?.trim(); const rfqCode = row.original.rfqCode?.trim(); // 생성중/준비중은 대기 표시(비클릭) if (!status || status === "준비중") { return ( 대기 ); } const statusConfig = { "진행중": { variant: "default", icon: }, "검토중": { variant: "secondary", icon: }, "보류": { variant: "outline", icon: }, "완료": { variant: "success", icon: }, "취소": { variant: "destructive", icon: }, }[status] || { variant: "outline", icon: null, color: "text-gray-600" }; const isClickable = !!rfqCode; return ( { if (!isClickable) return; e.stopPropagation(); e.preventDefault(); router.push(`/evcp/tbe-last?search=${encodeURIComponent(rfqCode!)}`); // window.open( // `/evcp/tbe-last?search=${encodeURIComponent(rfqCode!)}`, // "_blank", // "noopener,noreferrer" // ); // 새 창으로 이동 }} title={isClickable ? `TBE로 이동: ${rfqCode}` : undefined} > {statusConfig.icon} {status} ); }, size: 100, }, { accessorKey: "tbeEvaluationResult", header: ({ column }) => ( ), filterFn: createFilterFn("text"), cell: ({ row }) => { const result = row.original.tbeEvaluationResult; const status = row.original.tbeStatus; // TBE가 완료되지 않았으면 표시하지 않음 if (status !== "완료" || !result) { return -; } const resultConfig = { "Acceptable": { variant: "success", icon: , text: "Acceptable", color: "bg-green-50 text-green-700 border-green-200" }, "Acceptable with Comment": { variant: "warning", icon: , text: "Acceptable with Comment", color: "bg-yellow-50 text-yellow-700 border-yellow-200" }, "Not Acceptable": { variant: "destructive", icon: , text: "Not Acceptable", color: "bg-red-50 text-red-700 border-red-200" }, }[result]; return ( {resultConfig?.icon} {resultConfig?.text}

{result}

{row.original.conditionalRequirements && (

조건: {row.original.conditionalRequirements}

)}
); }, size: 120, }, { accessorKey: "contractRequirements", header: ({ column }) => , filterFn: createFilterFn("text"), cell: ({ row }) => { const vendor = row.original; const isKorean = vendor.vendorCountry === "KR" || vendor.vendorCountry === "한국"; // 기본계약 상태 확인 const requirements = []; // 필수 계약들 if (vendor.agreementYn) { requirements.push({ name: "기술자료", icon: , color: "text-blue-500" }); } if (vendor.ndaYn) { requirements.push({ name: "NDA", icon: , color: "text-green-500" }); } // GTC (국외 업체만) if (!isKorean) { if (vendor.generalGtcYn || vendor.gtcType === "general") { requirements.push({ name: "General GTC", icon: , color: "text-purple-500" }); } else if (vendor.projectGtcYn || vendor.gtcType === "project") { requirements.push({ name: "Project GTC", icon: , color: "text-indigo-500" }); } } if (requirements.length === 0) { return 없음; } return (
{requirements.map((req, idx) => ( {req.icon} {req.name}

{req.name === "기술자료" && "기술자료 제공 동의서"} {req.name === "NDA" && "비밀유지 계약서"} {req.name === "General GTC" && "일반 거래 약관"} {req.name === "Project GTC" && "프로젝트별 거래 약관"}

))}
); }, size: 150, }, { accessorKey: "sendVersion", header: ({ column }) => , filterFn: createFilterFn("number"), cell: ({ row }) => { const version = row.original.sendVersion; return {version}; }, size: 80, }, { accessorKey: "emailStatus", header: "이메일 상태", filterFn: createFilterFn("text"), cell: ({ row }) => { const response = row.original; const emailSentAt = response?.emailSentAt; const emailResentCount = response?.emailResentCount || 0; const emailStatus = response?.emailStatus; const status = response?.status; if (!emailSentAt) { return ( 미발송 ); } // 이메일 상태 표시 (failed인 경우 특별 처리) const getEmailStatusBadge = () => { if (emailStatus === "failed") { return ( 발송 실패 ); } return ( {getStatusIcon(status || "")} {status} ); }; // emailSentTo JSON 파싱 let recipients = { to: [], cc: [], sentBy: "" }; try { if (response?.emailSentTo) { recipients = JSON.parse(response.emailSentTo); } } catch (e) { console.error("Failed to parse emailSentTo", e); } return (
{getEmailStatusBadge()} {emailResentCount > 1 && ( 재발송 {emailResentCount - 1}회 )}

최초 발송: {format(new Date(emailSentAt), "yyyy-MM-dd HH:mm")}

{response?.email?.lastEmailSentAt && (

최근 발송: {format(new Date(response.email.lastEmailSentAt), "yyyy-MM-dd HH:mm")}

)} {recipients.to.length > 0 && (

수신자: {recipients.to.join(", ")}

)} {recipients.cc.length > 0 && (

참조: {recipients.cc.join(", ")}

)} {recipients.sentBy && (

발신자: {recipients.sentBy}

)} {emailStatus === "failed" && (

⚠️ 이메일 발송 실패

)}
); }, size: 120, }, // { // accessorKey: "basicContract", // header: ({ column }) => , // cell: ({ row }) => row.original.basicContract || "-", // size: 100, // }, { accessorKey: "currency", header: ({ column }) => , filterFn: createFilterFn("text"), cell: ({ row }) => { const currency = row.original.currency; return currency ? ( {currency} ) : ( - ); }, size: 80, }, { accessorKey: "paymentTermsCode", header: ({ column }) => , filterFn: createFilterFn("text"), cell: ({ row }) => { const code = row.original.paymentTermsCode; const desc = row.original.paymentTermsDescription; return ( {code || "-"} {desc && (

{desc}

)}
); }, size: 100, }, { accessorKey: "taxCode", header: ({ column }) => , filterFn: createFilterFn("text"), cell: ({ row }) => row.original.taxCode || "-", size: 60, }, { accessorKey: "deliveryDate", header: ({ column }) => , filterFn: createFilterFn("text"), cell: ({ row }) => { const deliveryDate = row.original.deliveryDate; const contractDuration = row.original.contractDuration; return (
{deliveryDate && !rfqCode?.startsWith("F") && ( {format(new Date(deliveryDate), "yyyy-MM-dd")} )} {contractDuration && rfqCode?.startsWith("F") && ( {contractDuration} )} {!deliveryDate && !contractDuration && ( - )}
); }, size: 120, }, { accessorKey: "incotermsCode", header: ({ column }) => , filterFn: createFilterFn("text"), cell: ({ row }) => { const code = row.original.incotermsCode; const detail = row.original.incotermsDetail; return (
{code || "-"}
{detail && (

{detail}

)}
); }, size: 100, }, { accessorKey: "placeOfShipping", header: ({ column }) => , filterFn: createFilterFn("text"), cell: ({ row }) => { const place = row.original.placeOfShipping; return place ? (
{place}
) : ( - ); }, size: 100, }, { accessorKey: "placeOfDestination", header: ({ column }) => , filterFn: createFilterFn("text"), cell: ({ row }) => { const place = row.original.placeOfDestination; return place ? (
{place}
) : ( - ); }, size: 100, }, { id: "additionalConditions", header: "추가조건", filterFn: createFilterFn("text"), cell: ({ row }) => { const conditions = formatAdditionalConditions(row.original); if (conditions === "-") { return -; } const items = conditions.split(", "); return (
{items.map((item, idx) => ( {item} ))}
); }, size: 120, }, { accessorKey: "response.submission.submittedAt", header: ({ column }) => , filterFn: createFilterFn("text"), cell: ({ row }) => { const participationRepliedAt = row.original.response?.attend?.participationRepliedAt; if (!participationRepliedAt) { return 미응답; } const participationStatus = row.original.response?.attend?.participationStatus; return (
{participationStatus} {format(new Date(participationRepliedAt), "yyyy-MM-dd")}
); }, size: 100, }, { id: "responseDetail", header: "회신상세", cell: ({ row }) => { const hasResponse = !!row.original.response?.submission?.submittedAt; if (!hasResponse) { return -; } return ( ); }, size: 80, }, ...(!rfqCode?.startsWith("F") ? [{ accessorKey: "shortList", filterFn: createFilterFn("boolean"), // boolean으로 변경 header: ({ column }) => , cell: ({ row }) => ( row.original.shortList ? ( 선정 ) : ( 대기 ) ), size: 80, }] : []), { accessorKey: "updatedByUserName", filterFn: createFilterFn("text"), // 추가 header: ({ column }) => , cell: ({ row }) => { const name = row.original.updatedByUserName; return name ? ( {name} ) : ( - ); }, size: 100, }, { id: "actions", header: "작업", cell: ({ row }) => { const vendor = row.original; const hasResponse = !!vendor.response; const emailSentAt = vendor.response?.email?.emailSentAt; const emailResentCount = vendor.response?.email?.emailResentCount || 0; const hasQuotation = !!vendor.quotationStatus; const isKorean = vendor.vendorCountry === "KR" || vendor.vendorCountry === "한국"; // 연동제 정보는 최상위 레벨 또는 additionalRequirements에서 확인 const hasPriceAdjustment = !!( vendor.response?.priceAdjustmentForm || vendor.response?.additionalRequirements?.materialPriceRelated?.priceAdjustmentForm ); return ( handleAction("view", vendor)}> 상세보기 {/* 연동제 정보 메뉴 (연동제 정보가 있을 때만 표시) */} {hasPriceAdjustment && ( handleAction("price-adjustment", vendor)}> 연동제 정보 )} {/* 기본계약 수정 메뉴 추가 */} handleAction("edit-contract", vendor)}> 기본계약 수정 {emailSentAt && ( <> handleAction("resend", vendor)} disabled={isLoadingSendData} > 이메일 재발송 {emailResentCount > 0 && ( {emailResentCount} )} )} {/* {!emailSentAt && ( handleAction("send", vendor)} disabled={isLoadingSendData} > RFQ 발송 )} */} handleAction("delete", vendor)} className={cn( "text-red-600", hasQuotation && "opacity-50 cursor-not-allowed" )} disabled={hasQuotation} > 삭제 {hasQuotation && ( (불가) )} ); }, size: 60, } ], [handleAction, rfqCode, isLoadingSendData]); // advancedFilterFields 정의 - columns와 매칭되도록 정리 const advancedFilterFields: DataTableAdvancedFilterField[] = [ { id: "rfqCode", label: "ITB/RFQ/견적 No.", type: "text" }, { id: "vendorName", label: "협력업체명", type: "text" }, { id: "vendorCode", label: "협력업체코드", type: "text" }, { id: "vendorCategory", label: "업체분류", type: "select", options: [ { label: "제조업체", value: "제조업체" }, { label: "무역업체", value: "무역업체" }, { label: "대리점", value: "대리점" }, // 실제 카테고리에 맞게 추가 ] }, { id: "vendorCountry", label: "내외자(위치)", type: "select", options: [ { label: "한국(KR)", value: "KR" }, { label: "한국", value: "한국" }, { label: "중국(CN)", value: "CN" }, { label: "일본(JP)", value: "JP" }, { label: "미국(US)", value: "US" }, { label: "독일(DE)", value: "DE" }, // 필요한 국가 추가 ] }, { id: "vendorGrade", label: "AVL 등급", type: "select", options: [ { label: "A", value: "A" }, { label: "B", value: "B" }, { label: "C", value: "C" }, { label: "D", value: "D" }, ] }, { id: "tbeStatus", label: "TBE 상태", type: "select", options: [ { label: "대기", value: "준비중" }, { label: "진행중", value: "진행중" }, { label: "검토중", value: "검토중" }, { label: "보류", value: "보류" }, { label: "완료", value: "완료" }, { label: "취소", value: "취소" }, ] }, { id: "tbeEvaluationResult", label: "TBE 평가결과", type: "select", options: [ { label: "적합", value: "Acceptable" }, { label: "조건부 적합", value: "Acceptable with Comment" }, { label: "부적합", value: "Not Acceptable" }, ] }, { id: "sendVersion", label: "발송 회차", type: "number" }, { id: "emailStatus", label: "이메일 상태", type: "select", options: [ { label: "미발송", value: "미발송" }, { label: "발송됨", value: "sent" }, { label: "발송 실패", value: "failed" }, ] }, { id: "currency", label: "요청 통화", type: "select", options: [ { label: "KRW", value: "KRW" }, { label: "USD", value: "USD" }, { label: "EUR", value: "EUR" }, { label: "JPY", value: "JPY" }, { label: "CNY", value: "CNY" }, ] }, { id: "paymentTermsCode", label: "지급조건", type: "text" }, { id: "taxCode", label: "Tax", type: "text", }, { id: "deliveryDate", label: "계약납기일", type: "date" }, { id: "contractDuration", label: "계약기간", type: "text" }, { id: "incotermsCode", label: "Incoterms", type: "text", }, { id: "placeOfShipping", label: "선적지", type: "text" }, { id: "placeOfDestination", label: "도착지", type: "text" }, { id: "firstYn", label: "초도품", type: "boolean" }, { id: "materialPriceRelatedYn", label: "연동제", type: "boolean" }, { id: "sparepartYn", label: "스페어파트", type: "boolean" }, ...(!rfqCode?.startsWith("I") ? [{ id: "shortList", label: "Short List", type: "select", options: [ { label: "선정", value: "true" }, { label: "대기", value: "false" }, ] }] : []), { id: "updatedByUserName", label: "최신수정자", type: "text" } ]; // 선택된 벤더 정보 (BatchUpdate용) const selectedVendorsForBatch = React.useMemo(() => { // 취소되지 않은 벤더만 필터링 return selectedRows .filter(row => { const isCancelled = row.response?.status === "취소" || row.cancelReason; return !isCancelled; }) .map(row => ({ id: row.vendorId, vendorName: row.vendorName, vendorCode: row.vendorCode, })); }, [selectedRows]); // 추가 액션 버튼들 const additionalActions = React.useMemo(() => { // 취소되지 않은 벤더만 필터링 (취소된 벤더는 제외) const nonCancelledRows = selectedRows.filter(row => { const isCancelled = row.response?.status === "취소" || row.cancelReason; return !isCancelled; }); // 참여 의사가 있는 선택된 벤더 수 계산 const participatingCount = selectedRows.length; const shortListCount = selectedRows.filter(v => v.shortList).length; const vendorsWithResponseCount = selectedRows.filter(v => v.response && v.response.vendor && v.response.isDocumentConfirmed).length; // 견적서가 있는 선택된 벤더 수 계산 (취소되지 않은 벤더만) const quotationCount = nonCancelledRows.filter(row => row.response?.submission?.submittedAt ).length; return (
{(rfqCode?.startsWith("I") || rfqCode?.startsWith("R")) && ( )} {selectedRows.length > 0 && ( <> {/* 정보 일괄 입력 버튼 - 취소되지 않은 벤더만 */} {/* RFQ 발송 버튼 - 취소되지 않은 벤더만 */} {/* RFQ 취소 버튼 - RFQ 발송 후에만 표시 (emailSentAt이 있는 경우) 및 취소되지 않은 벤더만 */} {rfqDetails.some(detail => detail.emailSentAt) && nonCancelledRows.length > 0 && ( )} {/* Short List 확정 버튼 */} {!rfqCode?.startsWith("F") && } {/* 견적 비교 버튼 - 취소되지 않은 벤더만 */} )}
); }, [selectedRows, isRefreshing, isLoadingSendData, handleBulkSend, handleShortListConfirm, handleQuotationCompare, isUpdatingShortList, rfqInfo, rfqCode, handleAvlIntegration, rfqDetails]); return ( <> {additionalActions} {/* 벤더 추가 다이얼로그 */} { toast.success("벤더가 추가되었습니다."); setIsAddDialogOpen(false); }} /> {/* 조건 일괄 설정 다이얼로그 */} { toast.success("조건이 업데이트되었습니다."); setIsBatchUpdateOpen(false); setSelectedRows([]); }} /> {/* RFQ 발송 다이얼로그 */} {/* 벤더 상세 다이얼로그 */} {selectedVendor && ( !open && setSelectedVendor(null)} data={selectedVendor} rfqId={rfqId} /> )} {/* 삭제 다이얼로그 추가 */} {deleteVendorData && ( !open && setDeleteVendorData(null)} rfqId={rfqId} vendorData={deleteVendorData} onSuccess={() => { setDeleteVendorData(null); router.refresh(); // 데이터 새로고침 }} /> )} {/* 기본계약 수정 다이얼로그 - 새로 추가 */} {editContractVendor && ( !open && setEditContractVendor(null)} rfqId={rfqId} vendor={editContractVendor} onSuccess={() => { setEditContractVendor(null); router.refresh(); }} /> )} {/* AVL 벤더 조회 다이얼로그 */} {/* 연동제 정보 다이얼로그 */} {priceAdjustmentData && ( !open && setPriceAdjustmentData(null)} data={priceAdjustmentData.data} vendorName={priceAdjustmentData.vendorName} /> )} {/* RFQ 취소 다이얼로그 - 취소되지 않은 벤더만 전달 */} { const isCancelled = row.response?.status === "취소" || row.cancelReason; return !isCancelled; }) .map(row => ({ detailId: row.detailId, vendorId: row.vendorId, vendorName: row.vendorName || "", vendorCode: row.vendorCode, }))} onSuccess={() => { setIsCancelDialogOpen(false); setSelectedRows([]); router.refresh(); toast.success("RFQ 취소가 완료되었습니다."); }} /> {/* 신청사유 입력 다이얼로그 */} {approvalPreviewData && ( )} {/* 결재 미리보기 다이얼로그 */} {approvalPreviewData && session?.user?.epId && approvalPreviewData.templateVariables && ( )} ); }