diff options
Diffstat (limited to 'components/data-table/infinite-data-table.tsx')
| -rw-r--r-- | components/data-table/infinite-data-table.tsx | 294 |
1 files changed, 294 insertions, 0 deletions
diff --git a/components/data-table/infinite-data-table.tsx b/components/data-table/infinite-data-table.tsx new file mode 100644 index 00000000..fcac56ee --- /dev/null +++ b/components/data-table/infinite-data-table.tsx @@ -0,0 +1,294 @@ +"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 { getCommonPinningStyles } 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<TData> extends React.HTMLAttributes<HTMLDivElement> { + table: TanstackTable<TData> + floatingBar?: React.ReactNode | null + autoSizeColumns?: boolean + compact?: boolean + // 무한 스크롤 관련 props + hasNextPage?: boolean + isLoadingMore?: boolean + onLoadMore?: () => void + totalCount?: number | null + isEmpty?: boolean +} + +/** + * 무한 스크롤 지원 DataTable + */ +export function InfiniteDataTable<TData>({ + table, + floatingBar = null, + autoSizeColumns = true, + compact = false, + hasNextPage = false, + isLoadingMore = false, + onLoadMore, + totalCount = null, + isEmpty = false, + children, + className, + maxHeight, + ...props +}: InfiniteDataTableProps<TData> & { maxHeight?: string | number }) { + + useAutoSizeColumns(table, autoSizeColumns) + + // 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 compactStyles = compact ? { + row: "h-7", + cell: "py-1 px-2 text-sm", + groupRow: "py-1 bg-muted/20 text-sm", + emptyRow: "h-16", + } : { + row: "", + cell: "", + groupRow: "bg-muted/20", + emptyRow: "h-24", + } + + return ( + <div className={cn("w-full space-y-2.5 overflow-auto", className)} {...props}> + {children} + + {/* 총 개수 표시 */} + {totalCount !== null && ( + <div className="text-sm text-muted-foreground"> + 총 {totalCount.toLocaleString()}개 항목 + {table.getRowModel().rows.length > 0 && ( + <span className="ml-2"> + (현재 {table.getRowModel().rows.length.toLocaleString()}개 로드됨) + </span> + )} + </div> + )} + + <div + className="max-w-[100vw] overflow-auto" + style={{ maxHeight: maxHeight || '35rem' }} + > + <Table className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 table-fixed"> + {/* 테이블 헤더 */} + <TableHeader> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id} className={compact ? "h-8" : ""}> + {headerGroup.headers.map((header) => { + if (header.column.getIsGrouped()) { + return null + } + + return ( + <TableHead + key={header.id} + colSpan={header.colSpan} + data-column-id={header.column.id} + className={compact ? "py-1 px-2 text-sm" : ""} + style={{ + ...getCommonPinningStyles({ column: header.column }), + width: header.getSize(), + }} + > + <div style={{ position: "relative" }}> + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + {header.column.getCanResize() && ( + <DataTableResizer header={header} /> + )} + </div> + </TableHead> + ) + })} + </TableRow> + ))} + </TableHeader> + + {/* 테이블 바디 */} + <TableBody> + {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 ( + <TableRow + key={row.id} + className={compactStyles.groupRow} + data-state={row.getIsExpanded() && "expanded"} + > + <TableCell + colSpan={table.getVisibleFlatColumns().length} + className={compact ? "py-1 px-2" : ""} + > + {row.getCanExpand() && ( + <button + onClick={row.getToggleExpandedHandler()} + className="inline-flex items-center justify-center mr-2 w-5 h-5" + style={{ + marginLeft: `${row.depth * 1.5}rem`, + }} + > + {row.getIsExpanded() ? ( + <ChevronUp size={compact ? 14 : 16} /> + ) : ( + <ChevronRight size={compact ? 14 : 16} /> + )} + </button> + )} + + <span className="font-semibold"> + {columnLabel}: {row.getValue(groupingColumnId)} + </span> + <span className="ml-2 text-xs text-muted-foreground"> + ({row.subRows.length} rows) + </span> + </TableCell> + </TableRow> + ) + } + + // 일반 Row + return ( + <TableRow + key={row.id} + className={compactStyles.row} + data-state={row.getIsSelected() && "selected"} + > + {row.getVisibleCells().map((cell) => { + if (cell.column.getIsGrouped()) { + return null + } + + return ( + <TableCell + key={cell.id} + data-column-id={cell.column.id} + className={compactStyles.cell} + style={{ + ...getCommonPinningStyles({ column: cell.column }), + width: cell.column.getSize(), + }} + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + </TableCell> + ) + })} + </TableRow> + ) + })} + </> + ) : isEmpty ? ( + // 데이터가 없을 때 + <TableRow> + <TableCell + colSpan={table.getAllColumns().length} + className={compactStyles.emptyRow + " text-center"} + > + No results. + </TableCell> + </TableRow> + ) : null} + </TableBody> + </Table> + </div> + + {/* 무한 스크롤 로딩 영역 */} + <div className="flex flex-col items-center space-y-4 py-4"> + {hasNextPage && ( + <> + {/* Intersection Observer 타겟 */} + <div ref={loadMoreRef} className="h-1" /> + + {isLoadingMore && ( + <div className="flex items-center space-x-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + <span className="text-sm text-muted-foreground"> + 로딩 중... + </span> + </div> + )} + + {/* 수동 로드 버튼 (자동 로딩 실패 시 대안) */} + {!isLoadingMore && onLoadMore && ( + <Button + variant="outline" + onClick={onLoadMore} + className="w-full max-w-md" + > + 더 보기 + </Button> + )} + </> + )} + + {!hasNextPage && table.getRowModel().rows.length > 0 && ( + <p className="text-sm text-muted-foreground"> + 모든 데이터를 불러왔습니다. + </p> + )} + </div> + + <div className="flex flex-col gap-2.5"> + {/* 선택된 행 정보 */} + {table.getFilteredSelectedRowModel().rows.length > 0 && ( + <div className="text-sm text-muted-foreground"> + {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getRowModel().rows.length} row(s) selected. + </div> + )} + + {/* Floating Bar (선택된 행 있을 때) */} + {table.getFilteredSelectedRowModel().rows.length > 0 && floatingBar} + </div> + </div> + ) +}
\ No newline at end of file |
