// lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx "use client" import * as React from "react" import { useSearchParams } from "next/navigation" import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table" import { useDataTable } from "@/hooks/use-data-table" import { DataTable } from "@/components/data-table/data-table" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" import { TechSalesVendorQuotations, TECH_SALES_QUOTATION_STATUSES, TECH_SALES_QUOTATION_STATUS_CONFIG } from "@/db/schema" import { useRouter } from "next/navigation" import { getColumns } from "./vendor-quotations-table-columns" import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "../../table/tech-sales-rfq-attachments-sheet" import { RfqItemsViewDialog } from "../../table/rfq-items-view-dialog" import { getTechSalesRfqAttachments, getVendorQuotations, rejectTechSalesVendorQuotations } from "@/lib/techsales-rfq/service" import { toast } from "sonner" import { Skeleton } from "@/components/ui/skeleton" import { Button } from "@/components/ui/button" import { X } from "lucide-react" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog" import { Textarea } from "@/components/ui/textarea" import { Label } from "@/components/ui/label" interface QuotationWithRfqCode extends TechSalesVendorQuotations { rfqCode?: string | null; materialCode?: string | null; dueDate?: Date; rfqStatus?: string; itemName?: string | null; projNm?: string | null; description?: string | null; attachmentCount?: number; itemCount?: number; pspid?: string | null; sector?: string | null; vendorName?: string | null; vendorCode?: string | null; createdByName?: string | null; updatedByName?: string | null; } interface VendorQuotationsTableProps { vendorId: string; rfqType?: "SHIP" | "TOP" | "HULL"; } // 로딩 스켈레톤 컴포넌트 function TableLoadingSkeleton() { return (
{/* 툴바 스켈레톤 */}
{/* 테이블 헤더 스켈레톤 */}
{/* 테이블 행 스켈레톤 */} {Array.from({ length: 5 }).map((_, index) => (
))}
{/* 페이지네이션 스켈레톤 */}
) } export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTableProps) { const searchParams = useSearchParams() const router = useRouter() // 첨부파일 시트 상태 const [attachmentsOpen, setAttachmentsOpen] = React.useState(false) const [selectedRfqForAttachments, setSelectedRfqForAttachments] = React.useState<{ id: number; rfqCode: string | null; status: string } | null>(null) const [attachmentsDefault, setAttachmentsDefault] = React.useState([]) // 아이템 다이얼로그 상태 const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false) const [selectedRfqForItems, setSelectedRfqForItems] = React.useState<{ id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; } | null>(null) // 거절 다이얼로그 상태 const [rejectDialogOpen, setRejectDialogOpen] = React.useState(false) const [rejectionReason, setRejectionReason] = React.useState("") const [isRejecting, setIsRejecting] = React.useState(false) // 데이터 로딩 상태 const [data, setData] = React.useState([]) const [pageCount, setPageCount] = React.useState(0) const [total, setTotal] = React.useState(0) const [isLoading, setIsLoading] = React.useState(true) const [isInitialLoad, setIsInitialLoad] = React.useState(true) // 최초 로딩 구분 // URL 파라미터에서 설정 읽기 const initialSettings = React.useMemo(() => ({ page: parseInt(searchParams?.get('page') || '1'), perPage: parseInt(searchParams?.get('perPage') || '10'), sort: searchParams?.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }], filters: searchParams?.get('filters') ? JSON.parse(searchParams.get('filters')!) : [], joinOperator: (searchParams?.get('joinOperator') as "and" | "or") || "and", basicFilters: searchParams?.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [], basicJoinOperator: (searchParams?.get('basicJoinOperator') as "and" | "or") || "and", search: searchParams?.get('search') || '', from: searchParams?.get('from') || '', to: searchParams?.get('to') || '', }), [searchParams]) // 데이터 로드 함수 const loadData = React.useCallback(async () => { try { setIsLoading(true) console.log('🔍 [VendorQuotationsTable] 데이터 로드 요청:', { vendorId, settings: initialSettings }) const result = await getVendorQuotations({ page: initialSettings.page, perPage: initialSettings.perPage, sort: initialSettings.sort, filters: initialSettings.filters, joinOperator: initialSettings.joinOperator, basicFilters: initialSettings.basicFilters, basicJoinOperator: initialSettings.basicJoinOperator, search: initialSettings.search, from: initialSettings.from, to: initialSettings.to, rfqType: rfqType, }, vendorId) console.log('🔍 [VendorQuotationsTable] 데이터 로드 결과:', { dataLength: result.data.length, pageCount: result.pageCount, total: result.total }) setData(result.data as QuotationWithRfqCode[]) setPageCount(result.pageCount) setTotal(result.total) } catch (error) { console.error('데이터 로드 오류:', error) toast.error('데이터를 불러오는 중 오류가 발생했습니다.') } finally { setIsLoading(false) setIsInitialLoad(false) } }, [vendorId, initialSettings, rfqType]) // URL 파라미터 변경 감지 및 데이터 재로드 (초기 로드 포함) React.useEffect(() => { loadData() }, [ searchParams?.get('page'), searchParams?.get('perPage'), searchParams?.get('sort'), searchParams?.get('filters'), searchParams?.get('joinOperator'), searchParams?.get('basicFilters'), searchParams?.get('basicJoinOperator'), searchParams?.get('search'), searchParams?.get('from'), searchParams?.get('to'), // vendorId와 rfqType 변경도 감지 vendorId, rfqType ]) // 데이터 안정성을 위한 메모이제이션 const stableData = React.useMemo(() => { return data; }, [data.length, data.map(item => `${item.id}-${item.status}-${item.updatedAt}`).join(',')]); // 첨부파일 시트 열기 함수 (벤더는 RFQ_COMMON 타입만 조회) const openAttachmentsSheet = React.useCallback(async (rfqId: number) => { try { // RFQ 정보 조회 (data에서 rfqId에 해당하는 데이터 찾기) const quotationWithRfq = data.find(item => item.rfqId === rfqId) if (!quotationWithRfq) { toast.error("RFQ 정보를 찾을 수 없습니다.") return } // 실제 첨부파일 목록 조회 API 호출 const result = await getTechSalesRfqAttachments(rfqId) if (result.error) { toast.error(result.error) return } // API 응답을 ExistingTechSalesAttachment 형식으로 변환하고 RFQ_COMMON 타입만 필터링 const attachments: ExistingTechSalesAttachment[] = result.data .filter(att => att.attachmentType === "RFQ_COMMON") // 벤더는 RFQ_COMMON 타입만 조회 .map(att => ({ id: att.id, techSalesRfqId: att.techSalesRfqId || rfqId, fileName: att.fileName, originalFileName: att.originalFileName, filePath: att.filePath, fileSize: att.fileSize || undefined, fileType: att.fileType || undefined, attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT", description: att.description || undefined, createdBy: att.createdBy, createdAt: att.createdAt, })) setAttachmentsDefault(attachments) setSelectedRfqForAttachments({ id: rfqId, rfqCode: quotationWithRfq.rfqCode || null, status: quotationWithRfq.rfqStatus || "Unknown" }) setAttachmentsOpen(true) } catch (error) { console.error("첨부파일 조회 오류:", error) toast.error("첨부파일 조회 중 오류가 발생했습니다.") } }, [data]) // 아이템 다이얼로그 열기 함수 const openItemsDialog = React.useCallback((rfq: { id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; }) => { setSelectedRfqForItems(rfq) setItemsDialogOpen(true) }, []) // 거절 처리 함수 const handleRejectQuotations = React.useCallback(async () => { if (!table) return; const selectedRows = table.getFilteredSelectedRowModel().rows; const quotationIds = selectedRows.map(row => row.original.id); if (quotationIds.length === 0) { toast.error("거절할 견적서를 선택해주세요."); return; } // 거절할 수 없는 상태의 견적서가 있는지 확인 const invalidStatuses = selectedRows.filter(row => row.original.status === "Accepted" || row.original.status === "Rejected" ); if (invalidStatuses.length > 0) { toast.error("이미 승인되었거나 거절된 견적서는 거절할 수 없습니다."); return; } setIsRejecting(true); try { const result = await rejectTechSalesVendorQuotations({ quotationIds, rejectionReason: rejectionReason.trim() || undefined, }); if (result.success) { toast.success(result.message); setRejectDialogOpen(false); setRejectionReason(""); table.resetRowSelection(); // 데이터 다시 로드 await loadData(); } else { toast.error(result.error || "견적서 거절 중 오류가 발생했습니다."); } } catch (error) { console.error("견적서 거절 오류:", error); toast.error("견적서 거절 중 오류가 발생했습니다."); } finally { setIsRejecting(false); } }, [rejectionReason, loadData]); // 테이블 컬럼 정의 const columns = React.useMemo(() => getColumns({ router, openAttachmentsSheet, openItemsDialog, }), [router, openAttachmentsSheet, openItemsDialog]) // 필터 필드 const filterFields = React.useMemo[]>(() => [ { id: "status", label: "상태", options: Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({ label: TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue].label, value: statusValue, })) }, { id: "rfqCode", label: "RFQ 번호", placeholder: "RFQ 번호 검색...", }, { id: "materialCode", label: "자재 그룹", placeholder: "자재 그룹 검색...", } ], []) // 고급 필터 필드 const advancedFilterFields = React.useMemo[]>(() => [ { id: "rfqCode", label: "RFQ 번호", type: "text", }, { id: "materialCode", label: "자재 그룹", type: "text", }, { id: "status", label: "상태", type: "multi-select", options: Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({ label: TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue].label, value: statusValue, })), }, { id: "validUntil", label: "유효기간", type: "date", }, { id: "submittedAt", label: "제출일", type: "date", }, ], []) // useDataTable 훅 사용 const { table } = useDataTable({ data: stableData, columns: columns as any, // 타입 오류 임시 해결 pageCount, rowCount: total, filterFields, enablePinning: true, enableAdvancedFilter: true, enableColumnResizing: true, columnResizeMode: 'onChange', enableRowSelection: true, // 행 선택 활성화 initialState: { sorting: initialSettings.sort, columnPinning: { right: ["actions", "items", "attachments"] }, }, getRowId: (originalRow) => String(originalRow.id), shallow: false, clearOnDefault: true, defaultColumn: { minSize: 50, maxSize: 500, }, }) // 최초 로딩 시 전체 스켈레톤 표시 if (isInitialLoad && isLoading) { return (
) } return (
{/* 선택된 행이 있을 때 거절 버튼 표시 */} {table && table.getFilteredSelectedRowModel().rows.length > 0 && ( 견적서 거절 선택한 {table.getFilteredSelectedRowModel().rows.length}개의 견적서를 거절하시겠습니까? 거절된 견적서는 다시 되돌릴 수 없습니다.