summaryrefslogtreecommitdiff
path: root/components/data-table/infinite-data-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/data-table/infinite-data-table.tsx')
-rw-r--r--components/data-table/infinite-data-table.tsx294
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