// 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}개)
견적서 거절
선택한 {table.getFilteredSelectedRowModel().rows.length}개의 견적서를 거절하시겠습니까?
거절된 견적서는 다시 되돌릴 수 없습니다.
취소
{isRejecting ? "처리 중..." : "거절"}
)}
{!isInitialLoad && isLoading && (
)}
{/* 첨부파일 관리 시트 (읽기 전용) */}
{/* 아이템 보기 다이얼로그 */}
);
}