diff options
Diffstat (limited to 'lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx')
| -rw-r--r-- | lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx | 239 |
1 files changed, 209 insertions, 30 deletions
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx index e1b82579..e98d6bdc 100644 --- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx +++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx @@ -2,6 +2,7 @@ "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" @@ -10,41 +11,192 @@ import { TechSalesVendorQuotations, TECH_SALES_QUOTATION_STATUSES, TECH_SALES_QU import { useRouter } from "next/navigation" import { getColumns } from "./vendor-quotations-table-columns" import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "../../table/tech-sales-rfq-attachments-sheet" -import { getTechSalesRfqAttachments } from "@/lib/techsales-rfq/service" +import { getTechSalesRfqAttachments, getVendorQuotations } from "@/lib/techsales-rfq/service" import { toast } from "sonner" +import { Skeleton } from "@/components/ui/skeleton" interface QuotationWithRfqCode extends TechSalesVendorQuotations { - rfqCode?: string; - materialCode?: string; + rfqCode?: string | null; + materialCode?: string | null; dueDate?: Date; rfqStatus?: string; - itemName?: string; - projNm?: string; + itemName?: string | null; + projNm?: string | null; quotationCode?: string | null; - quotationVersion: number | null; + quotationVersion?: number | null; rejectionReason?: string | null; acceptedAt?: Date | null; attachmentCount?: number; } interface VendorQuotationsTableProps { - promises: Promise<[{ data: QuotationWithRfqCode[], pageCount: number, total?: number }]>; + vendorId: string; } -export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps) { +// 로딩 스켈레톤 컴포넌트 +function TableLoadingSkeleton() { + return ( + <div className="w-full space-y-3"> + {/* 툴바 스켈레톤 */} + <div className="flex items-center justify-between"> + <div className="flex items-center space-x-2"> + <Skeleton className="h-10 w-[250px]" /> + <Skeleton className="h-10 w-[100px]" /> + </div> + <div className="flex items-center space-x-2"> + <Skeleton className="h-10 w-[120px]" /> + <Skeleton className="h-10 w-[100px]" /> + </div> + </div> + + {/* 테이블 헤더 스켈레톤 */} + <div className="rounded-md border"> + <div className="border-b p-4"> + <div className="flex items-center space-x-4"> + <Skeleton className="h-4 w-[100px]" /> + <Skeleton className="h-4 w-[150px]" /> + <Skeleton className="h-4 w-[120px]" /> + <Skeleton className="h-4 w-[100px]" /> + <Skeleton className="h-4 w-[130px]" /> + <Skeleton className="h-4 w-[100px]" /> + <Skeleton className="h-4 w-[80px]" /> + </div> + </div> + + {/* 테이블 행 스켈레톤 */} + {Array.from({ length: 5 }).map((_, index) => ( + <div key={index} className="border-b p-4 last:border-b-0"> + <div className="flex items-center space-x-4"> + <Skeleton className="h-4 w-[100px]" /> + <Skeleton className="h-4 w-[150px]" /> + <Skeleton className="h-4 w-[120px]" /> + <Skeleton className="h-4 w-[100px]" /> + <Skeleton className="h-4 w-[130px]" /> + <Skeleton className="h-4 w-[100px]" /> + <Skeleton className="h-4 w-[80px]" /> + </div> + </div> + ))} + </div> + + {/* 페이지네이션 스켈레톤 */} + <div className="flex items-center justify-between"> + <Skeleton className="h-8 w-[200px]" /> + <div className="flex items-center space-x-2"> + <Skeleton className="h-8 w-[100px]" /> + <Skeleton className="h-8 w-[60px]" /> + <Skeleton className="h-8 w-[100px]" /> + </div> + </div> + </div> + ) +} - // TODO: 안정화 이후 삭제 - console.log("렌더링 사이클 점검용 로그: VendorQuotationsTable 렌더링됨"); +// 중앙 로딩 인디케이터 컴포넌트 +function CenterLoadingIndicator() { + return ( + <div className="flex flex-col items-center justify-center py-12 space-y-4"> + <div className="relative"> + <div className="w-12 h-12 border-4 border-gray-200 border-t-blue-600 rounded-full animate-spin"></div> + </div> + <div className="text-center space-y-1"> + <p className="text-sm font-medium text-gray-900">데이터를 불러오는 중...</p> + <p className="text-xs text-gray-500">잠시만 기다려주세요.</p> + </div> + </div> + ) +} - const [{ data, pageCount }] = React.use(promises); - const router = useRouter(); +export function VendorQuotationsTable({ vendorId }: 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<ExistingTechSalesAttachment[]>([]) - // 데이터 안정성을 위한 메모이제이션 - 핵심 속성만 비교 + // 데이터 로딩 상태 + const [data, setData] = React.useState<QuotationWithRfqCode[]>([]) + 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, + }, 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]) + + // 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 변경도 감지 + vendorId + ]) + + // 데이터 안정성을 위한 메모이제이션 const stableData = React.useMemo(() => { return data; }, [data.length, data.map(item => `${item.id}-${item.status}-${item.updatedAt}`).join(',')]); @@ -95,13 +247,13 @@ export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps) } }, [data]) - // 테이블 컬럼 정의 - router는 안정적이므로 한 번만 생성 + // 테이블 컬럼 정의 const columns = React.useMemo(() => getColumns({ router, openAttachmentsSheet, - }), [router, openAttachmentsSheet]); + }), [router, openAttachmentsSheet]) - // 필터 필드 - 중앙화된 상태 상수 사용 + // 필터 필드 const filterFields = React.useMemo<DataTableFilterField<QuotationWithRfqCode>[]>(() => [ { id: "status", @@ -121,9 +273,9 @@ export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps) label: "자재 코드", placeholder: "자재 코드 검색...", } - ], []); + ], []) - // 고급 필터 필드 - 중앙화된 상태 상수 사용 + // 고급 필터 필드 const advancedFilterFields = React.useMemo<DataTableAdvancedFilterField<QuotationWithRfqCode>[]>(() => [ { id: "rfqCode", @@ -154,20 +306,21 @@ export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps) label: "제출일", type: "date", }, - ], []); + ], []) // useDataTable 훅 사용 const { table } = useDataTable({ data: stableData, columns, pageCount, + rowCount: total, filterFields, enablePinning: true, enableAdvancedFilter: true, enableColumnResizing: true, columnResizeMode: 'onChange', initialState: { - sorting: [{ id: "updatedAt", desc: true }], + sorting: initialSettings.sort, columnPinning: { right: ["actions"] }, }, getRowId: (originalRow) => String(originalRow.id), @@ -177,22 +330,48 @@ export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps) minSize: 50, maxSize: 500, }, - }); + }) + + // 최초 로딩 시 전체 스켈레톤 표시 + if (isInitialLoad && isLoading) { + return ( + <div className="w-full"> + <div className="overflow-x-auto"> + <TableLoadingSkeleton /> + </div> + </div> + ) + } return ( <div className="w-full"> <div className="overflow-x-auto"> - <DataTable - table={table} - className="min-w-full" - > - <DataTableAdvancedToolbar + <div className="relative"> + {/* 로딩 오버레이 (재로딩 시) */} + {/* {!isInitialLoad && isLoading && ( + <div className="absolute h-full w-full inset-0 bg-background/90 backdrop-blur-md z-10 flex items-center justify-center"> + <CenterLoadingIndicator /> + </div> + )} */} + + <DataTable table={table} - filterFields={advancedFilterFields} - shallow={false} + className="min-w-full" > - </DataTableAdvancedToolbar> - </DataTable> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + {!isInitialLoad && isLoading && ( + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" /> + 데이터 업데이트 중... + </div> + )} + </DataTableAdvancedToolbar> + </DataTable> + </div> </div> {/* 첨부파일 관리 시트 (읽기 전용) */} |
