From 81aa92fecc298d66eb420468316bcf7a7213171c Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Sun, 30 Nov 2025 17:12:17 +0900
Subject: (김준회) group 기능 추가, 기타 버그 수정, preset 기능추가, 테스트
 페이지 추가
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 components/client-table/client-virtual-table.tsx | 457 +++++++++++++++++------
 1 file changed, 352 insertions(+), 105 deletions(-)

(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
index 4825741f..507057c7 100644
--- a/components/client-table/client-virtual-table.tsx
+++ b/components/client-table/client-virtual-table.tsx
@@ -8,6 +8,11 @@ import {
   getSortedRowModel,
   getFilteredRowModel,
   getPaginationRowModel,
+  getGroupedRowModel,
+  getExpandedRowModel,
+  getFacetedRowModel,
+  getFacetedUniqueValues,
+  getFacetedMinMaxValues,
   ColumnDef,
   SortingState,
   ColumnFiltersState,
@@ -21,6 +26,9 @@ import {
   Table,
   RowSelectionState,
   Row,
+  Column,
+  GroupingState,
+  ExpandedState,
 } from "@tanstack/react-table"
 import { useVirtualizer } from "@tanstack/react-virtual"
 import {
@@ -38,23 +46,41 @@ import {
   horizontalListSortingStrategy,
 } from "@dnd-kit/sortable"
 import { cn } from "@/lib/utils"
+import { Loader2, ChevronRight, ChevronDown } from "lucide-react"
 
-import { ClientTableToolbar } from "./client-table-toolbar"
-import { exportToExcel } from "./export-utils"
+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-view-options"
+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
-  
-  // Pagination Props
+  isLoading?: boolean
+
+  // --- User Preset Saving ---
+  enableUserPreset?: boolean
+  tableKey?: string
+
+  // --- State Control (Controlled or Uncontrolled) ---
+
+  // Pagination
   enablePagination?: boolean
   manualPagination?: boolean
   pageCount?: number
@@ -62,88 +88,184 @@ export interface ClientVirtualTableProps<TData, TValue> {
   pagination?: PaginationState
   onPaginationChange?: OnChangeFn<PaginationState>
 
-  // Style Props
-  getRowClassName?: (originalRow: TData, index: number) => string
+  // Sorting
+  sorting?: SortingState
+  onSortingChange?: OnChangeFn<SortingState>
 
-  // Table Meta
-  meta?: any
-  
-  // Row ID
-  getRowId?: (originalRow: TData, index: number, parent?: any) => string
+  // Filtering
+  columnFilters?: ColumnFiltersState
+  onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>
+  globalFilter?: string
+  onGlobalFilterChange?: OnChangeFn<string>
+
+  // Visibility
+  columnVisibility?: VisibilityState
+  onColumnVisibilityChange?: OnChangeFn<VisibilityState>
+
+  // Pinning
+  columnPinning?: ColumnPinningState
+  onColumnPinningChange?: OnChangeFn<ColumnPinningState>
 
-  // Selection Props
+  // 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 = "500px", // Default height
+    height = "100%",
+    estimateRowHeight = 40,
     className,
     actions,
+    customToolbar,
     enableExport = true,
     onExport,
-    
-    // Pagination defaults
+    isLoading = false,
+
+    // User Preset Saving
+    enableUserPreset = false,
+    tableKey,
+
+    // Pagination
     enablePagination = false,
     manualPagination = false,
     pageCount,
     rowCount,
-    pagination: controlledPagination,
+    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,
 
-    // Selection defaults
-    enableRowSelection,
-    enableMultiRowSelection,
-    rowSelection: controlledRowSelection,
-    onRowSelectionChange,
+    // Event Handlers
+    onRowClick,
+
+    // Custom Header Visual Feedback
+    renderHeaderVisualFeedback,
   }: 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>(
+  // 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[]
   )
-  
-  // Internal Pagination State
   const [internalPagination, setInternalPagination] = React.useState<PaginationState>({
     pageIndex: 0,
-    pageSize: 50,
+    pageSize: 10,
   })
-
-  // Internal Row Selection State
   const [internalRowSelection, setInternalRowSelection] = React.useState<RowSelectionState>({})
+  const [internalGrouping, setInternalGrouping] = React.useState<GroupingState>([])
+  const [internalExpanded, setInternalExpanded] = React.useState<ExpandedState>({})
 
-  // Fuzzy Filter
-  const fuzzyFilter: FilterFn<TData> = (row, columnId, value, addMeta) => {
-    const itemRank = rankItem(row.getValue(columnId), value)
-    addMeta({ itemRank })
-    return itemRank.passed
-  }
+  // 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
 
-  // Combine controlled and uncontrolled states
-  const pagination = controlledPagination ?? internalPagination
+  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 = controlledRowSelection ?? internalRowSelection
+
+  const rowSelection = propRowSelection ?? internalRowSelection
   const setRowSelection = onRowSelectionChange ?? setInternalRowSelection
 
+  const grouping = propGrouping ?? internalGrouping
+  const setGrouping = onGroupingChange ?? setInternalGrouping
+
+  const expanded = propExpanded ?? internalExpanded
+  const setExpanded = onExpandedChange ?? setInternalExpanded
+
   // Table Instance
   const table = useReactTable({
     data,
@@ -157,6 +279,8 @@ function ClientVirtualTableInner<TData, TValue>(
       columnPinning,
       columnOrder,
       rowSelection,
+      grouping,
+      expanded,
     },
     manualPagination,
     pageCount: manualPagination ? pageCount : undefined,
@@ -169,11 +293,28 @@ function ClientVirtualTableInner<TData, TValue>(
     onColumnPinningChange: setColumnPinning,
     onColumnOrderChange: setColumnOrder,
     onRowSelectionChange: setRowSelection,
+    onGroupingChange: setGrouping,
+    onExpandedChange: setExpanded,
     enableRowSelection,
     enableMultiRowSelection,
+    enableGrouping,
     getCoreRowModel: getCoreRowModel(),
-    getSortedRowModel: getSortedRowModel(),
+
+    // Systematic Order of Operations:
+    // 1. Filtering (Rows are filtered first)
     getFilteredRowModel: getFilteredRowModel(),
+    getFacetedRowModel: getFacetedRowModel(),
+    getFacetedUniqueValues: getFacetedUniqueValues(),
+    getFacetedMinMaxValues: getFacetedMinMaxValues(),
+
+    // 2. Sorting (Filtered rows are then sorted)
+    getSortedRowModel: getSortedRowModel(),
+
+    // 3. Grouping (Sorted rows are grouped)
+    getGroupedRowModel: enableGrouping ? getGroupedRowModel() : undefined,
+    getExpandedRowModel: enableGrouping ? getExpandedRowModel() : undefined,
+
+    // 4. Pagination (Final rows are paginated)
     getPaginationRowModel: enablePagination ? getPaginationRowModel() : undefined,
     columnResizeMode: "onChange",
     filterFns: {
@@ -191,7 +332,7 @@ function ClientVirtualTableInner<TData, TValue>(
   const sensors = useSensors(
     useSensor(PointerSensor, {
       activationConstraint: {
-        distance: 8, // 8px movement required to start drag
+        distance: 8,
       },
     }),
     useSensor(KeyboardSensor)
@@ -201,11 +342,29 @@ function ClientVirtualTableInner<TData, TValue>(
   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)
-      })
+      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)
+        })
+      }
     }
   }
 
@@ -216,7 +375,7 @@ function ClientVirtualTableInner<TData, TValue>(
   const rowVirtualizer = useVirtualizer({
     count: rows.length,
     getScrollElement: () => tableContainerRef.current,
-    estimateSize: () => 40, // Estimated row height
+    estimateSize: () => estimateRowHeight,
     overscan: 10,
   })
 
@@ -236,30 +395,42 @@ function ClientVirtualTableInner<TData, TValue>(
       return
     }
     const currentData = table.getFilteredRowModel().rows.map((row) => row.original)
-    await exportToExcel(currentData, columns, `export-${new Date().toISOString().slice(0,10)}.xlsx`)
+    await exportToExcel(currentData, columns, `export-${new Date().toISOString().slice(0, 10)}.xlsx`)
   }
 
   return (
-    <div className={`flex flex-col gap-4 ${className || ""}`}>
+    <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}
-        actions={
+        viewOptions={
           <>
-            {actions}
             <ClientTableViewOptions table={table} />
+            {enableUserPreset && tableKey && (
+              <ClientTablePreset table={table} tableKey={tableKey} />
+            )}
           </>
         }
+        customToolbar={customToolbar}
+        actions={actions}
       />
 
       <div
         ref={tableContainerRef}
-        className="relative border rounded-md overflow-auto"
-        style={{ height }}
+        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}
@@ -269,11 +440,11 @@ function ClientVirtualTableInner<TData, TValue>(
             className="table-fixed border-collapse w-full min-w-full"
             style={{ width: table.getTotalSize() }}
           >
-            <thead className="sticky top-0 z-10 bg-muted">
+            <thead className="sticky top-0 z-40 bg-muted">
               {table.getHeaderGroups().map((headerGroup) => (
                 <tr key={headerGroup.id}>
                   <SortableContext
-                    items={columnOrder}
+                    items={headerGroup.headers.map((h) => h.id)}
                     strategy={horizontalListSortingStrategy}
                   >
                     {headerGroup.headers.map((header) => (
@@ -281,6 +452,7 @@ function ClientVirtualTableInner<TData, TValue>(
                         key={header.id}
                         header={header}
                         enableReordering={true}
+                        renderHeaderVisualFeedback={renderHeaderVisualFeedback}
                       />
                     ))}
                   </SortableContext>
@@ -293,51 +465,126 @@ function ClientVirtualTableInner<TData, TValue>(
                   <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 (
+              {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
-                          key={cell.id}
-                          className="px-4 py-2 text-sm truncate border-b"
-                          style={style}
+                          colSpan={columns.length}
+                          className="px-4 py-2 text-left font-medium cursor-pointer"
+                          onClick={row.getToggleExpandedHandler()}
                         >
-                          {flexRender(
-                            cell.column.columnDef.cell,
-                            cell.getContext()
-                          )}
+                          <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>
-                )
-              })}
+                      </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` }} />
@@ -347,9 +594,9 @@ function ClientVirtualTableInner<TData, TValue>(
           </table>
         </DndContext>
       </div>
-      
+
       {enablePagination && (
-         <ClientDataTablePagination table={table} />
+        <ClientDataTablePagination table={table} />
       )}
     </div>
   )
-- 
cgit v1.2.3

