"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 = (row, columnId, value, addMeta) => { const itemRank = rankItem(row.getValue(columnId), value) addMeta({ itemRank }) return itemRank.passed } export interface ClientVirtualTableProps { data: TData[] columns: ColumnDef[] 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 // Sorting sorting?: SortingState onSortingChange?: OnChangeFn // Filtering columnFilters?: ColumnFiltersState onColumnFiltersChange?: OnChangeFn globalFilter?: string onGlobalFilterChange?: OnChangeFn // Visibility columnVisibility?: VisibilityState onColumnVisibilityChange?: OnChangeFn // Pinning columnPinning?: ColumnPinningState onColumnPinningChange?: OnChangeFn // Order columnOrder?: ColumnOrderState onColumnOrderChange?: OnChangeFn // Selection enableRowSelection?: boolean | ((row: Row) => boolean) enableMultiRowSelection?: boolean | ((row: Row) => boolean) rowSelection?: RowSelectionState onRowSelectionChange?: OnChangeFn // Grouping enableGrouping?: boolean grouping?: GroupingState onGroupingChange?: OnChangeFn expanded?: ExpandedState onExpandedChange?: OnChangeFn // --- Event Handlers --- onRowClick?: (row: Row, event: React.MouseEvent) => void // --- Styling --- getRowClassName?: (originalRow: TData, index: number) => string // --- Advanced --- meta?: Record getRowId?: (originalRow: TData, index: number, parent?: any) => string // Custom Header Visual Feedback renderHeaderVisualFeedback?: (props: { column: Column isPinned: boolean | string isSorted: boolean | string isFiltered: boolean isGrouped: boolean }) => React.ReactNode } function ClientVirtualTableInner( { 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, ref: React.Ref> ) { // Internal States (used when props are undefined) const [internalSorting, setInternalSorting] = React.useState([]) const [internalColumnFilters, setInternalColumnFilters] = React.useState([]) const [internalGlobalFilter, setInternalGlobalFilter] = React.useState("") const [internalColumnVisibility, setInternalColumnVisibility] = React.useState({}) const [internalColumnPinning, setInternalColumnPinning] = React.useState({ left: [], right: [] }) const [internalColumnOrder, setInternalColumnOrder] = React.useState( () => columns.map((c) => c.id || (c as any).accessorKey) as string[] ) const [internalPagination, setInternalPagination] = React.useState({ pageIndex: 0, pageSize: 10, }) const [internalRowSelection, setInternalRowSelection] = React.useState({}) const [internalGrouping, setInternalGrouping] = React.useState([]) const [internalExpanded, setInternalExpanded] = React.useState({}) // 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(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 (
{enableUserPreset && tableKey && ( )} } customToolbar={customToolbar} actions={actions} />
{isLoading && (
)} {table.getHeaderGroups().map((headerGroup) => ( h.id)} strategy={horizontalListSortingStrategy} > {headerGroup.headers.map((header) => ( ))} ))} {paddingTop > 0 && ( )} {virtualRows.length === 0 && !isLoading ? ( ) : ( virtualRows.map((virtualRow) => { const row = rows[virtualRow.index] // --- Group Header Rendering --- if (row.getIsGrouped()) { const groupingColumnId = row.groupingColumnId ?? ""; const groupingValue = row.getGroupingValue(groupingColumnId); return ( ) } // --- Normal Row Rendering --- return ( 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 ( ) })} ) }) )} {paddingBottom > 0 && ( )}
No results.
{row.getIsExpanded() ? ( ) : ( )} {groupingColumnId}: {String(groupingValue)} ({row.subRows.length})
{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()) )}
{enablePagination && ( )}
) } export const ClientVirtualTable = React.memo( React.forwardRef(ClientVirtualTableInner) ) as ( props: ClientVirtualTableProps & { ref?: React.Ref> } ) => React.ReactElement