summaryrefslogtreecommitdiff
path: root/components/client-table-v2/client-virtual-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/client-table-v2/client-virtual-table.tsx')
-rw-r--r--components/client-table-v2/client-virtual-table.tsx626
1 files changed, 626 insertions, 0 deletions
diff --git a/components/client-table-v2/client-virtual-table.tsx b/components/client-table-v2/client-virtual-table.tsx
new file mode 100644
index 00000000..1713369f
--- /dev/null
+++ b/components/client-table-v2/client-virtual-table.tsx
@@ -0,0 +1,626 @@
+"use client"
+
+import * as React from "react"
+import { rankItem } from "@tanstack/match-sorter-utils"
+import {
+ useReactTable,
+ getCoreRowModel,
+ getSortedRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getGroupedRowModel,
+ getExpandedRowModel,
+ getFacetedRowModel,
+ getFacetedUniqueValues,
+ getFacetedMinMaxValues,
+ ColumnDef,
+ SortingState,
+ ColumnFiltersState,
+ flexRender,
+ PaginationState,
+ OnChangeFn,
+ ColumnOrderState,
+ VisibilityState,
+ ColumnPinningState,
+ FilterFn,
+ Table,
+ RowSelectionState,
+ Row,
+ Column,
+ GroupingState,
+ ExpandedState,
+} from "@tanstack/react-table"
+import { useVirtualizer } from "@tanstack/react-virtual"
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ DragEndEvent,
+} from "@dnd-kit/core"
+import {
+ arrayMove,
+ SortableContext,
+ horizontalListSortingStrategy,
+} from "@dnd-kit/sortable"
+import { cn } from "@/lib/utils"
+import { Loader2, ChevronRight, ChevronDown } from "lucide-react"
+
+import { ClientTableToolbar } from "../client-table/client-table-toolbar"
+import { exportToExcel } from "../client-table/export-utils"
+import { ClientDataTablePagination } from "@/components/client-data-table/data-table-pagination"
+import { ClientTableColumnHeader } from "./client-table-column-header"
+import { ClientTableViewOptions } from "../client-table/client-table-view-options"
+import { ClientTablePreset } from "./client-table-preset"
+
+// Moved outside for stability (Performance Optimization)
+const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
+ const itemRank = rankItem(row.getValue(columnId), value)
+ addMeta({ itemRank })
+ return itemRank.passed
+}
+
+export interface ClientVirtualTableProps<TData, TValue> {
+ data: TData[]
+ columns: ColumnDef<TData, TValue>[]
+ height?: string | number
+ estimateRowHeight?: number
+ className?: string
+ actions?: React.ReactNode
+ customToolbar?: React.ReactNode
+ enableExport?: boolean
+ onExport?: (data: TData[]) => void
+ isLoading?: boolean
+
+ /**
+ * 데이터 페칭 모드
+ * - client: 모든 데이터를 한번에 받아 클라이언트에서 처리 (기본값)
+ * - server: 서버에서 필터링/정렬/페이지네이션 된 데이터를 받음
+ */
+ fetchMode?: "client" | "server"
+
+ // --- User Preset Saving ---
+ enableUserPreset?: boolean
+ tableKey?: string
+
+ // --- State Control (Controlled or Uncontrolled) ---
+
+ // Pagination
+ enablePagination?: boolean
+ manualPagination?: boolean
+ pageCount?: number
+ rowCount?: number
+ pagination?: PaginationState
+ onPaginationChange?: OnChangeFn<PaginationState>
+
+ // Sorting
+ sorting?: SortingState
+ onSortingChange?: OnChangeFn<SortingState>
+
+ // Filtering
+ columnFilters?: ColumnFiltersState
+ onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>
+ globalFilter?: string
+ onGlobalFilterChange?: OnChangeFn<string>
+
+ // Visibility
+ columnVisibility?: VisibilityState
+ onColumnVisibilityChange?: OnChangeFn<VisibilityState>
+
+ // Pinning
+ columnPinning?: ColumnPinningState
+ onColumnPinningChange?: OnChangeFn<ColumnPinningState>
+
+ // Order
+ columnOrder?: ColumnOrderState
+ onColumnOrderChange?: OnChangeFn<ColumnOrderState>
+
+ // Selection
+ enableRowSelection?: boolean | ((row: Row<TData>) => boolean)
+ enableMultiRowSelection?: boolean | ((row: Row<TData>) => boolean)
+ rowSelection?: RowSelectionState
+ onRowSelectionChange?: OnChangeFn<RowSelectionState>
+
+ // Grouping
+ enableGrouping?: boolean
+ grouping?: GroupingState
+ onGroupingChange?: OnChangeFn<GroupingState>
+ expanded?: ExpandedState
+ onExpandedChange?: OnChangeFn<ExpandedState>
+
+ // --- Event Handlers ---
+ onRowClick?: (row: Row<TData>, event: React.MouseEvent) => void
+
+ // --- Styling ---
+ getRowClassName?: (originalRow: TData, index: number) => string
+
+ // --- Advanced ---
+ meta?: Record<string, any>
+ getRowId?: (originalRow: TData, index: number, parent?: any) => string
+
+ // Custom Header Visual Feedback
+ renderHeaderVisualFeedback?: (props: {
+ column: Column<TData, TValue>
+ isPinned: boolean | string
+ isSorted: boolean | string
+ isFiltered: boolean
+ isGrouped: boolean
+ }) => React.ReactNode
+}
+
+function ClientVirtualTableInner<TData, TValue>(
+ {
+ data,
+ columns,
+ height = "100%",
+ estimateRowHeight = 40,
+ className,
+ actions,
+ customToolbar,
+ enableExport = true,
+ onExport,
+ isLoading = false,
+ fetchMode = "client",
+
+ // User Preset Saving
+ enableUserPreset = false,
+ tableKey,
+
+ // Pagination
+ enablePagination = false,
+ manualPagination: propManualPagination,
+ pageCount,
+ rowCount,
+ pagination: propPagination,
+ onPaginationChange,
+
+ // Sorting
+ sorting: propSorting,
+ onSortingChange,
+
+ // Filtering
+ columnFilters: propColumnFilters,
+ onColumnFiltersChange,
+ globalFilter: propGlobalFilter,
+ onGlobalFilterChange,
+
+ // Visibility
+ columnVisibility: propColumnVisibility,
+ onColumnVisibilityChange,
+
+ // Pinning
+ columnPinning: propColumnPinning,
+ onColumnPinningChange,
+
+ // Order
+ columnOrder: propColumnOrder,
+ onColumnOrderChange,
+
+ // Selection
+ enableRowSelection,
+ enableMultiRowSelection,
+ rowSelection: propRowSelection,
+ onRowSelectionChange,
+
+ // Grouping
+ enableGrouping = false,
+ grouping: propGrouping,
+ onGroupingChange,
+ expanded: propExpanded,
+ onExpandedChange,
+
+ // Style defaults
+ getRowClassName,
+
+ // Meta & RowID
+ meta,
+ getRowId,
+
+ // Event Handlers
+ onRowClick,
+
+ // Custom Header Visual Feedback
+ renderHeaderVisualFeedback,
+ }: ClientVirtualTableProps<TData, TValue>,
+ ref: React.Ref<Table<TData>>
+) {
+ // Internal States (used when props are undefined)
+ const [internalSorting, setInternalSorting] = React.useState<SortingState>([])
+ const [internalColumnFilters, setInternalColumnFilters] = React.useState<ColumnFiltersState>([])
+ const [internalGlobalFilter, setInternalGlobalFilter] = React.useState("")
+ const [internalColumnVisibility, setInternalColumnVisibility] = React.useState<VisibilityState>({})
+ const [internalColumnPinning, setInternalColumnPinning] = React.useState<ColumnPinningState>({ left: [], right: [] })
+ const [internalColumnOrder, setInternalColumnOrder] = React.useState<ColumnOrderState>(
+ () => columns.map((c) => c.id || (c as any).accessorKey) as string[]
+ )
+ const [internalPagination, setInternalPagination] = React.useState<PaginationState>({
+ pageIndex: 0,
+ pageSize: 10,
+ })
+ const [internalRowSelection, setInternalRowSelection] = React.useState<RowSelectionState>({})
+ const [internalGrouping, setInternalGrouping] = React.useState<GroupingState>([])
+ const [internalExpanded, setInternalExpanded] = React.useState<ExpandedState>({})
+
+ // Effective States
+ const sorting = propSorting ?? internalSorting
+ const setSorting = onSortingChange ?? setInternalSorting
+
+ const columnFilters = propColumnFilters ?? internalColumnFilters
+ const setColumnFilters = onColumnFiltersChange ?? setInternalColumnFilters
+
+ const globalFilter = propGlobalFilter ?? internalGlobalFilter
+ const setGlobalFilter = onGlobalFilterChange ?? setInternalGlobalFilter
+
+ const columnVisibility = propColumnVisibility ?? internalColumnVisibility
+ const setColumnVisibility = onColumnVisibilityChange ?? setInternalColumnVisibility
+
+ const columnPinning = propColumnPinning ?? internalColumnPinning
+ const setColumnPinning = onColumnPinningChange ?? setInternalColumnPinning
+
+ const columnOrder = propColumnOrder ?? internalColumnOrder
+ const setColumnOrder = onColumnOrderChange ?? setInternalColumnOrder
+
+ const pagination = propPagination ?? internalPagination
+ const setPagination = onPaginationChange ?? setInternalPagination
+
+ const rowSelection = propRowSelection ?? internalRowSelection
+ const setRowSelection = onRowSelectionChange ?? setInternalRowSelection
+
+ const grouping = propGrouping ?? internalGrouping
+ const setGrouping = onGroupingChange ?? setInternalGrouping
+
+ const expanded = propExpanded ?? internalExpanded
+ const setExpanded = onExpandedChange ?? setInternalExpanded
+
+ // Server Mode Logic
+ const isServer = fetchMode === "server"
+ // If server mode is enabled, we default manual flags to true unless explicitly overridden
+ const manualPagination = propManualPagination ?? isServer
+ const manualSorting = isServer
+ const manualFiltering = isServer
+ const manualGrouping = isServer
+
+ // Table Instance
+ const table = useReactTable({
+ data,
+ columns,
+ state: {
+ sorting,
+ columnFilters,
+ globalFilter,
+ pagination,
+ columnVisibility,
+ columnPinning,
+ columnOrder,
+ rowSelection,
+ grouping,
+ expanded,
+ },
+ manualPagination,
+ manualSorting,
+ manualFiltering,
+ manualGrouping,
+ pageCount: manualPagination ? pageCount : undefined,
+ rowCount: manualPagination ? rowCount : undefined,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onGlobalFilterChange: setGlobalFilter,
+ onPaginationChange: setPagination,
+ onColumnVisibilityChange: setColumnVisibility,
+ onColumnPinningChange: setColumnPinning,
+ onColumnOrderChange: setColumnOrder,
+ onRowSelectionChange: setRowSelection,
+ onGroupingChange: setGrouping,
+ onExpandedChange: setExpanded,
+ enableRowSelection,
+ enableMultiRowSelection,
+ enableGrouping,
+ getCoreRowModel: getCoreRowModel(),
+
+ // Systematic Order of Operations:
+ // If server-side, we skip client-side processing models to avoid double processing
+ // and to ensure the table reflects exactly what the server returned.
+ getFilteredRowModel: !isServer ? getFilteredRowModel() : undefined,
+ getFacetedRowModel: !isServer ? getFacetedRowModel() : undefined,
+ getFacetedUniqueValues: !isServer ? getFacetedUniqueValues() : undefined,
+ getFacetedMinMaxValues: !isServer ? getFacetedMinMaxValues() : undefined,
+
+ getSortedRowModel: !isServer ? getSortedRowModel() : undefined,
+
+ getGroupedRowModel: (!isServer && enableGrouping) ? getGroupedRowModel() : undefined,
+ getExpandedRowModel: (!isServer && enableGrouping) ? getExpandedRowModel() : undefined,
+
+ getPaginationRowModel: (!isServer && enablePagination) ? getPaginationRowModel() : undefined,
+ columnResizeMode: "onChange",
+ filterFns: {
+ fuzzy: fuzzyFilter,
+ },
+ globalFilterFn: fuzzyFilter,
+ meta,
+ getRowId,
+ })
+
+ // Expose table instance via ref
+ React.useImperativeHandle(ref, () => table, [table])
+
+ // DnD Sensors
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ }),
+ useSensor(KeyboardSensor)
+ )
+
+ // Handle Drag End
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event
+ if (active && over && active.id !== over.id) {
+ const activeId = active.id as string
+ const overId = over.id as string
+
+ const activeColumn = table.getColumn(activeId)
+ const overColumn = table.getColumn(overId)
+
+ if (activeColumn && overColumn) {
+ const activePinState = activeColumn.getIsPinned()
+ const overPinState = overColumn.getIsPinned()
+
+ // If dragging between different pin states, update the pin state of the active column
+ if (activePinState !== overPinState) {
+ activeColumn.pin(overPinState)
+ }
+
+ // Reorder the columns
+ setColumnOrder((items) => {
+ const currentItems = Array.isArray(items) ? items : []
+ const oldIndex = items.indexOf(activeId)
+ const newIndex = items.indexOf(overId)
+ return arrayMove(items, oldIndex, newIndex)
+ })
+ }
+ }
+ }
+
+ // Virtualization
+ const tableContainerRef = React.useRef<HTMLDivElement>(null)
+ const { rows } = table.getRowModel()
+
+ const rowVirtualizer = useVirtualizer({
+ count: rows.length,
+ getScrollElement: () => tableContainerRef.current,
+ estimateSize: () => estimateRowHeight,
+ overscan: 10,
+ })
+
+ const virtualRows = rowVirtualizer.getVirtualItems()
+ const totalSize = rowVirtualizer.getTotalSize()
+
+ const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0
+ const paddingBottom =
+ virtualRows.length > 0
+ ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0)
+ : 0
+
+ // Export Handler
+ const handleExport = async () => {
+ if (onExport) {
+ onExport(data)
+ return
+ }
+ const currentData = table.getFilteredRowModel().rows.map((row) => row.original)
+ await exportToExcel(currentData, columns, `export-${new Date().toISOString().slice(0, 10)}.xlsx`)
+ }
+
+ return (
+ <div
+ className={`flex flex-col gap-4 ${className || ""}`}
+ style={{ height }}
+ >
+ <ClientTableToolbar
+ globalFilter={globalFilter}
+ setGlobalFilter={setGlobalFilter}
+ totalRows={manualPagination ? (rowCount ?? data.length) : data.length}
+ visibleRows={rows.length}
+ onExport={enableExport ? handleExport : undefined}
+ viewOptions={
+ <>
+ <ClientTableViewOptions table={table} />
+ {enableUserPreset && tableKey && (
+ <ClientTablePreset table={table} tableKey={tableKey} />
+ )}
+ </>
+ }
+ customToolbar={customToolbar}
+ actions={actions}
+ />
+
+ <div
+ ref={tableContainerRef}
+ className="relative border rounded-md overflow-auto bg-background flex-1 min-h-0"
+ >
+ {isLoading && (
+ <div className="absolute inset-0 z-50 flex items-center justify-center bg-background/50 backdrop-blur-sm">
+ <Loader2 className="h-8 w-8 animate-spin text-primary" />
+ </div>
+ )}
+
+ <DndContext
+ sensors={sensors}
+ collisionDetection={closestCenter}
+ onDragEnd={handleDragEnd}
+ >
+ <table
+ className="table-fixed border-collapse w-full min-w-full"
+ style={{ width: table.getTotalSize() }}
+ >
+ <thead className="sticky top-0 z-40 bg-muted">
+ {table.getHeaderGroups().map((headerGroup) => (
+ <tr key={headerGroup.id}>
+ <SortableContext
+ items={headerGroup.headers.map((h) => h.id)}
+ strategy={horizontalListSortingStrategy}
+ >
+ {headerGroup.headers.map((header) => (
+ <ClientTableColumnHeader
+ key={header.id}
+ header={header}
+ enableReordering={true}
+ renderHeaderVisualFeedback={renderHeaderVisualFeedback}
+ />
+ ))}
+ </SortableContext>
+ </tr>
+ ))}
+ </thead>
+ <tbody>
+ {paddingTop > 0 && (
+ <tr>
+ <td style={{ height: `${paddingTop}px` }} />
+ </tr>
+ )}
+ {virtualRows.length === 0 && !isLoading ? (
+ <tr>
+ <td colSpan={columns.length} className="h-24 text-center">
+ No results.
+ </td>
+ </tr>
+ ) : (
+ virtualRows.map((virtualRow) => {
+ const row = rows[virtualRow.index]
+
+ // --- Group Header Rendering ---
+ if (row.getIsGrouped()) {
+ const groupingColumnId = row.groupingColumnId ?? "";
+ const groupingValue = row.getGroupingValue(groupingColumnId);
+
+ return (
+ <tr
+ key={row.id}
+ className="hover:bg-muted/50 border-b bg-muted/30"
+ style={{ height: `${virtualRow.size}px` }}
+ >
+ <td
+ colSpan={columns.length}
+ className="px-4 py-2 text-left font-medium cursor-pointer"
+ onClick={row.getToggleExpandedHandler()}
+ >
+ <div className="flex items-center gap-2">
+ {row.getIsExpanded() ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ <span className="flex items-center gap-2">
+ <span className="font-bold capitalize">
+ {groupingColumnId}:
+ </span>
+ <span>
+ {String(groupingValue)}
+ </span>
+ <span className="text-muted-foreground text-sm font-normal">
+ ({row.subRows.length})
+ </span>
+ </span>
+ </div>
+ </td>
+ </tr>
+ )
+ }
+
+ // --- Normal Row Rendering ---
+ return (
+ <tr
+ key={row.id}
+ className={cn(
+ "hover:bg-muted/50 border-b last:border-0",
+ getRowClassName ? getRowClassName(row.original, row.index) : "",
+ onRowClick ? "cursor-pointer" : ""
+ )}
+ style={{ height: `${virtualRow.size}px` }}
+ onClick={(e) => onRowClick?.(row, e)}
+ >
+ {row.getVisibleCells().map((cell) => {
+ // Handle pinned cells
+ const isPinned = cell.column.getIsPinned()
+ const isGrouped = cell.column.getIsGrouped()
+
+ const style: React.CSSProperties = {
+ width: cell.column.getSize(),
+ }
+ if (isPinned === "left") {
+ style.position = "sticky"
+ style.left = `${cell.column.getStart("left")}px`
+ style.zIndex = 20
+ } else if (isPinned === "right") {
+ style.position = "sticky"
+ style.right = `${cell.column.getAfter("right")}px`
+ style.zIndex = 20
+ }
+
+ return (
+ <td
+ key={cell.id}
+ className={cn(
+ "px-2 py-0 text-sm truncate border-b bg-background",
+ isGrouped ? "bg-muted/20" : ""
+ )}
+ style={style}
+ >
+ {cell.getIsGrouped() ? (
+ // If this cell is grouped, usually we don't render it here if we have a group header row,
+ // but if we keep it, it acts as the expander for the next level (if multi-level grouping).
+ // Since we used a full-width row for the group header, this branch might not be hit for the group row itself,
+ // but for nested groups it might?
+ // Wait, row.getIsGrouped() is true for the group row.
+ // The cells inside the group row are not rendered because we return early above.
+ // The cells inside the "leaf" rows (normal rows) are rendered here.
+ // So cell.getIsGrouped() checks if the COLUMN is currently grouped.
+ // If the column is grouped, the cell value is usually redundant or hidden in normal rows.
+ // Standard practice: hide the cell content or dim it.
+ null
+ ) : cell.getIsAggregated() ? (
+ // If this cell is an aggregation of the group
+ flexRender(
+ cell.column.columnDef.aggregatedCell ??
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )
+ ) : (
+ // Normal cell
+ cell.getIsPlaceholder()
+ ? null
+ : flexRender(cell.column.columnDef.cell, cell.getContext())
+ )}
+ </td>
+ )
+ })}
+ </tr>
+ )
+ })
+ )}
+ {paddingBottom > 0 && (
+ <tr>
+ <td style={{ height: `${paddingBottom}px` }} />
+ </tr>
+ )}
+ </tbody>
+ </table>
+ </DndContext>
+ </div>
+
+ {enablePagination && (
+ <ClientDataTablePagination table={table} />
+ )}
+ </div>
+ )
+}
+
+export const ClientVirtualTable = React.memo(
+ React.forwardRef(ClientVirtualTableInner)
+) as <TData, TValue>(
+ props: ClientVirtualTableProps<TData, TValue> & { ref?: React.Ref<Table<TData>> }
+) => React.ReactElement