"use client" import * as React from "react" import { flexRender, type Table as TanstackTable } from "@tanstack/react-table" import { ChevronRight, ChevronUp, Loader2 } from "lucide-react" import { useIntersection } from "@mantine/hooks" import { cn } from "@/lib/utils" import { getCommonPinningStylesWithBorder } from "@/lib/data-table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table" import { Button } from "@/components/ui/button" import { DataTableResizer } from "@/components/data-table/data-table-resizer" import { useAutoSizeColumns } from "@/hooks/useAutoSizeColumns" interface InfiniteDataTableProps extends React.HTMLAttributes { table: TanstackTable floatingBar?: React.ReactNode | null autoSizeColumns?: boolean compact?: boolean // 무한 스크롤 관련 props hasNextPage?: boolean isLoadingMore?: boolean onLoadMore?: () => void totalCount?: number | null isEmpty?: boolean } /** * 무한 스크롤 지원 DataTable */ export function InfiniteDataTable({ table, floatingBar = null, autoSizeColumns = true, compact = false, hasNextPage = false, isLoadingMore = false, onLoadMore, totalCount = null, isEmpty = false, children, className, maxHeight, ...props }: InfiniteDataTableProps & { maxHeight?: string | number }) { useAutoSizeColumns(table, autoSizeColumns) // 🎯 스크롤 상태 감지 추가 const [isScrolled, setIsScrolled] = React.useState(false) // Intersection Observer for infinite scroll const { ref: loadMoreRef, entry } = useIntersection({ threshold: 0.1, }) // 자동 로딩 트리거 React.useEffect(() => { if (entry?.isIntersecting && hasNextPage && !isLoadingMore && onLoadMore) { onLoadMore() } }, [entry?.isIntersecting, hasNextPage, isLoadingMore, onLoadMore]) // 🎯 스크롤 핸들러 추가 const handleScroll = (e: React.UIEvent) => { const scrollLeft = e.currentTarget.scrollLeft setIsScrolled(scrollLeft > 0) } // 🎯 동적 핀 스타일 함수 (width 중복 제거) const getPinnedStyle = (column: any, isHeader: boolean = false) => { const baseStyle = getCommonPinningStylesWithBorder({ column }) const pinnedSide = column.getIsPinned() // width를 제외한 나머지 스타일만 반환 const { width, ...restBaseStyle } = baseStyle return { ...restBaseStyle, // 헤더는 핀 여부와 관계없이 항상 배경 유지 (sticky로 고정되어 있기 때문) ...(isHeader && { background: "hsl(var(--background))", transition: "none", }), // 바디 셀은 핀된 경우에만 스크롤 상태에 따라 동적 변경 ...(!isHeader && pinnedSide && { background: isScrolled ? "hsl(var(--background))" : "transparent", transition: "background-color 0.15s ease-out", }), } } // 🎯 테이블 총 너비 계산 const getTableWidth = React.useCallback(() => { const totalSize = table.getCenterTotalSize() + table.getLeftTotalSize() + table.getRightTotalSize() return Math.max(totalSize, 800) // 최소 800px 보장 }, [table]) // 컴팩트 모드를 위한 클래스 정의 const compactStyles = compact ? { row: "h-7", cell: "py-1 px-2 text-sm", header: "py-1 px-2 text-sm", // 헤더 스타일 추가 headerRow: "h-8", // 헤더 행 높이 추가 groupRow: "py-1 bg-muted/20 text-sm", emptyRow: "h-16", } : { row: "", cell: "", header: "", // 헤더 스타일 추가 headerRow: "", // 헤더 행 높이 추가 groupRow: "bg-muted/20", emptyRow: "h-24", } return (
{children} {/* 총 개수 표시 */} {totalCount !== null && (
총 {totalCount.toLocaleString()}개 항목 {table.getRowModel().rows.length > 0 && ( (현재 {table.getRowModel().rows.length.toLocaleString()}개 로드됨) )}
)}
{/* 테이블 헤더 */} {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { if (header.column.getIsGrouped()) { return null } return (
{header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} {/* 부모 그룹 헤더는 리사이즈 불가, 자식 헤더만 리사이즈 가능 */} {header.column.getCanResize() && !('columns' in header.column.columnDef) && ( )}
) })}
))}
{/* 테이블 바디 */} {table.getRowModel().rows?.length ? ( <> {table.getRowModel().rows.map((row) => { // 그룹핑 헤더 Row if (row.getIsGrouped()) { const groupingColumnId = row.groupingColumnId ?? "" const groupingColumn = table.getColumn(groupingColumnId) let columnLabel = groupingColumnId if (groupingColumn) { const headerDef = groupingColumn.columnDef.meta?.excelHeader if (typeof headerDef === "string") { columnLabel = headerDef } } return ( {row.getCanExpand() && ( )} {columnLabel}: {row.getValue(groupingColumnId)} ({row.subRows.length} rows) ) } // 일반 Row return ( {row.getVisibleCells().map((cell) => { if (cell.column.getIsGrouped()) { return null } return ( {flexRender( cell.column.columnDef.cell, cell.getContext() )} ) })} ) })} ) : isEmpty ? ( // 데이터가 없을 때 No results. ) : null}
{/* 무한 스크롤 로딩 영역 */}
{hasNextPage && ( <> {/* Intersection Observer 타겟 */}
{isLoadingMore && (
로딩 중...
)} {/* 수동 로드 버튼 (자동 로딩 실패 시 대안) */} {!isLoadingMore && onLoadMore && ( )} )} {!hasNextPage && table.getRowModel().rows.length > 0 && (

모든 데이터를 불러왔습니다.

)}
{/* 선택된 행 정보 */} {table.getFilteredSelectedRowModel().rows.length > 0 && (
{table.getFilteredSelectedRowModel().rows.length} of{" "} {table.getRowModel().rows.length} row(s) selected.
)} {/* Floating Bar (선택된 행 있을 때) */} {table.getFilteredSelectedRowModel().rows.length > 0 && floatingBar}
) }