From 9cabe879404f1ec05dbf4e65d55162b5573aeced Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Fri, 28 Nov 2025 20:30:23 +0900
Subject: (김준회) dynamic table - init
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 components/client-table/client-virtual-table.tsx | 362 +++++++++++++++++++++++
 1 file changed, 362 insertions(+)
 create mode 100644 components/client-table/client-virtual-table.tsx

(limited to 'components/client-table/client-virtual-table.tsx')

diff --git a/components/client-table/client-virtual-table.tsx b/components/client-table/client-virtual-table.tsx
new file mode 100644
index 00000000..4825741f
--- /dev/null
+++ b/components/client-table/client-virtual-table.tsx
@@ -0,0 +1,362 @@
+"use client"
+
+import * as React from "react"
+import { rankItem } from "@tanstack/match-sorter-utils"
+import {
+  useReactTable,
+  getCoreRowModel,
+  getSortedRowModel,
+  getFilteredRowModel,
+  getPaginationRowModel,
+  ColumnDef,
+  SortingState,
+  ColumnFiltersState,
+  flexRender,
+  PaginationState,
+  OnChangeFn,
+  ColumnOrderState,
+  VisibilityState,
+  ColumnPinningState,
+  FilterFn,
+  Table,
+  RowSelectionState,
+  Row,
+} 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 { ClientTableToolbar } from "./client-table-toolbar"
+import { exportToExcel } from "./export-utils"
+import { ClientDataTablePagination } from "@/components/client-data-table/data-table-pagination"
+import { ClientTableColumnHeader } from "./client-table-column-header"
+import { ClientTableViewOptions } from "./client-table-view-options"
+
+export interface ClientVirtualTableProps<TData, TValue> {
+  data: TData[]
+  columns: ColumnDef<TData, TValue>[]
+  height?: string | number
+  className?: string
+  actions?: React.ReactNode
+  enableExport?: boolean
+  onExport?: (data: TData[]) => void
+  
+  // Pagination Props
+  enablePagination?: boolean
+  manualPagination?: boolean
+  pageCount?: number
+  rowCount?: number
+  pagination?: PaginationState
+  onPaginationChange?: OnChangeFn<PaginationState>
+
+  // Style Props
+  getRowClassName?: (originalRow: TData, index: number) => string
+
+  // Table Meta
+  meta?: any
+  
+  // Row ID
+  getRowId?: (originalRow: TData, index: number, parent?: any) => string
+
+  // Selection Props
+  enableRowSelection?: boolean | ((row: Row<TData>) => boolean)
+  enableMultiRowSelection?: boolean | ((row: Row<TData>) => boolean)
+  rowSelection?: RowSelectionState
+  onRowSelectionChange?: OnChangeFn<RowSelectionState>
+}
+
+function ClientVirtualTableInner<TData, TValue>(
+  {
+    data,
+    columns,
+    height = "500px", // Default height
+    className,
+    actions,
+    enableExport = true,
+    onExport,
+    
+    // Pagination defaults
+    enablePagination = false,
+    manualPagination = false,
+    pageCount,
+    rowCount,
+    pagination: controlledPagination,
+    onPaginationChange,
+
+    // Style defaults
+    getRowClassName,
+    
+    // Meta & RowID
+    meta,
+    getRowId,
+
+    // Selection defaults
+    enableRowSelection,
+    enableMultiRowSelection,
+    rowSelection: controlledRowSelection,
+    onRowSelectionChange,
+  }: ClientVirtualTableProps<TData, TValue>,
+  ref: React.Ref<Table<TData>>
+) {
+  // State
+  const [sorting, setSorting] = React.useState<SortingState>([])
+  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
+  const [globalFilter, setGlobalFilter] = React.useState("")
+  const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
+  const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>({ left: [], right: [] })
+  const [columnOrder, setColumnOrder] = React.useState<ColumnOrderState>(
+    () => columns.map((c) => c.id || (c as any).accessorKey) as string[]
+  )
+  
+  // Internal Pagination State
+  const [internalPagination, setInternalPagination] = React.useState<PaginationState>({
+    pageIndex: 0,
+    pageSize: 50,
+  })
+
+  // Internal Row Selection State
+  const [internalRowSelection, setInternalRowSelection] = React.useState<RowSelectionState>({})
+
+  // Fuzzy Filter
+  const fuzzyFilter: FilterFn<TData> = (row, columnId, value, addMeta) => {
+    const itemRank = rankItem(row.getValue(columnId), value)
+    addMeta({ itemRank })
+    return itemRank.passed
+  }
+
+  // Combine controlled and uncontrolled states
+  const pagination = controlledPagination ?? internalPagination
+  const setPagination = onPaginationChange ?? setInternalPagination
+  
+  const rowSelection = controlledRowSelection ?? internalRowSelection
+  const setRowSelection = onRowSelectionChange ?? setInternalRowSelection
+
+  // Table Instance
+  const table = useReactTable({
+    data,
+    columns,
+    state: {
+      sorting,
+      columnFilters,
+      globalFilter,
+      pagination,
+      columnVisibility,
+      columnPinning,
+      columnOrder,
+      rowSelection,
+    },
+    manualPagination,
+    pageCount: manualPagination ? pageCount : undefined,
+    rowCount: manualPagination ? rowCount : undefined,
+    onSortingChange: setSorting,
+    onColumnFiltersChange: setColumnFilters,
+    onGlobalFilterChange: setGlobalFilter,
+    onPaginationChange: setPagination,
+    onColumnVisibilityChange: setColumnVisibility,
+    onColumnPinningChange: setColumnPinning,
+    onColumnOrderChange: setColumnOrder,
+    onRowSelectionChange: setRowSelection,
+    enableRowSelection,
+    enableMultiRowSelection,
+    getCoreRowModel: getCoreRowModel(),
+    getSortedRowModel: getSortedRowModel(),
+    getFilteredRowModel: getFilteredRowModel(),
+    getPaginationRowModel: 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, // 8px movement required to start drag
+      },
+    }),
+    useSensor(KeyboardSensor)
+  )
+
+  // Handle Drag End
+  const handleDragEnd = (event: DragEndEvent) => {
+    const { active, over } = event
+    if (active && over && active.id !== over.id) {
+      setColumnOrder((items) => {
+        const oldIndex = items.indexOf(active.id as string)
+        const newIndex = items.indexOf(over.id as string)
+        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: () => 40, // Estimated row height
+    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 || ""}`}>
+      <ClientTableToolbar
+        globalFilter={globalFilter}
+        setGlobalFilter={setGlobalFilter}
+        totalRows={manualPagination ? (rowCount ?? data.length) : data.length}
+        visibleRows={rows.length}
+        onExport={enableExport ? handleExport : undefined}
+        actions={
+          <>
+            {actions}
+            <ClientTableViewOptions table={table} />
+          </>
+        }
+      />
+
+      <div
+        ref={tableContainerRef}
+        className="relative border rounded-md overflow-auto"
+        style={{ height }}
+      >
+        <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-10 bg-muted">
+              {table.getHeaderGroups().map((headerGroup) => (
+                <tr key={headerGroup.id}>
+                  <SortableContext
+                    items={columnOrder}
+                    strategy={horizontalListSortingStrategy}
+                  >
+                    {headerGroup.headers.map((header) => (
+                      <ClientTableColumnHeader
+                        key={header.id}
+                        header={header}
+                        enableReordering={true}
+                      />
+                    ))}
+                  </SortableContext>
+                </tr>
+              ))}
+            </thead>
+            <tbody>
+              {paddingTop > 0 && (
+                <tr>
+                  <td style={{ height: `${paddingTop}px` }} />
+                </tr>
+              )}
+              {virtualRows.map((virtualRow) => {
+                const row = rows[virtualRow.index]
+                return (
+                  <tr
+                    key={row.id}
+                    className={cn(
+                        "hover:bg-muted/50 border-b last:border-0",
+                        getRowClassName ? getRowClassName(row.original, row.index) : ""
+                    )}
+                    style={{ height: `${virtualRow.size}px` }}
+                  >
+                    {row.getVisibleCells().map((cell) => {
+                      // Handle pinned cells
+                      const isPinned = cell.column.getIsPinned()
+                      const style: React.CSSProperties = {
+                        width: cell.column.getSize(),
+                      }
+                      if (isPinned === "left") {
+                         style.position = "sticky"
+                         style.left = `${cell.column.getStart("left")}px`
+                         style.zIndex = 10
+                         style.backgroundColor = "var(--background)" // Ensure opacity
+                      } else if (isPinned === "right") {
+                         style.position = "sticky"
+                         style.right = `${cell.column.getAfter("right")}px`
+                         style.zIndex = 10
+                         style.backgroundColor = "var(--background)"
+                      }
+
+                      return (
+                        <td
+                          key={cell.id}
+                          className="px-4 py-2 text-sm truncate border-b"
+                          style={style}
+                        >
+                          {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
-- 
cgit v1.2.3

