diff options
Diffstat (limited to 'components/client-table-v2/client-virtual-table.tsx')
| -rw-r--r-- | components/client-table-v2/client-virtual-table.tsx | 626 |
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 |
