summaryrefslogtreecommitdiff
path: root/components/client-table
diff options
context:
space:
mode:
Diffstat (limited to 'components/client-table')
-rw-r--r--components/client-table/client-table-column-header.tsx179
-rw-r--r--components/client-table/client-table-filter.tsx101
-rw-r--r--components/client-table/client-table-toolbar.tsx54
-rw-r--r--components/client-table/client-table-view-options.tsx60
-rw-r--r--components/client-table/client-virtual-table.tsx362
-rw-r--r--components/client-table/export-utils.ts136
-rw-r--r--components/client-table/import-utils.ts100
-rw-r--r--components/client-table/index.ts7
-rw-r--r--components/client-table/types.ts11
9 files changed, 1010 insertions, 0 deletions
diff --git a/components/client-table/client-table-column-header.tsx b/components/client-table/client-table-column-header.tsx
new file mode 100644
index 00000000..12dc57ac
--- /dev/null
+++ b/components/client-table/client-table-column-header.tsx
@@ -0,0 +1,179 @@
+"use client"
+
+import * as React from "react"
+import { Header } from "@tanstack/react-table"
+import { useSortable } from "@dnd-kit/sortable"
+import { CSS } from "@dnd-kit/utilities"
+import { flexRender } from "@tanstack/react-table"
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu"
+import {
+ ArrowDown,
+ ArrowUp,
+ ChevronsUpDown,
+ EyeOff,
+ PinOff,
+ MoveLeft,
+ MoveRight,
+} from "lucide-react"
+import { cn } from "@/lib/utils"
+import { ClientTableFilter } from "./client-table-filter"
+
+interface ClientTableColumnHeaderProps<TData, TValue>
+ extends React.HTMLAttributes<HTMLTableHeaderCellElement> {
+ header: Header<TData, TValue>
+ enableReordering?: boolean
+}
+
+export function ClientTableColumnHeader<TData, TValue>({
+ header,
+ enableReordering = true,
+ className,
+ ...props
+}: ClientTableColumnHeaderProps<TData, TValue>) {
+ const column = header.column
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({
+ id: header.id,
+ disabled: !enableReordering,
+ })
+
+ // -- Styles --
+ const style: React.CSSProperties = {
+ // Apply transform only if reordering is enabled and active
+ transform: enableReordering ? CSS.Translate.toString(transform) : undefined,
+ transition: enableReordering ? transition : undefined,
+ width: header.getSize(),
+ zIndex: isDragging ? 100 : 0,
+ position: "relative",
+ ...props.style,
+ }
+
+ // Pinning Styles
+ const isPinned = column.getIsPinned()
+ if (isPinned === "left") {
+ style.left = `${column.getStart("left")}px`
+ style.position = "sticky"
+ style.zIndex = 20
+ } else if (isPinned === "right") {
+ style.right = `${column.getAfter("right")}px`
+ style.position = "sticky"
+ style.zIndex = 20
+ }
+
+ // -- Handlers --
+ const handleHide = () => column.toggleVisibility(false)
+ const handlePinLeft = () => column.pin("left")
+ const handlePinRight = () => column.pin("right")
+ const handleUnpin = () => column.pin(false)
+
+ // -- Content --
+ const content = (
+ <>
+ <div
+ className={cn(
+ "flex items-center gap-2",
+ column.getCanSort() ? "cursor-pointer select-none" : ""
+ )}
+ onClick={column.getToggleSortingHandler()}
+ >
+ {flexRender(column.columnDef.header, header.getContext())}
+ {column.getCanSort() && (
+ <span className="flex items-center">
+ {column.getIsSorted() === "desc" ? (
+ <ArrowDown className="h-4 w-4" />
+ ) : column.getIsSorted() === "asc" ? (
+ <ArrowUp className="h-4 w-4" />
+ ) : (
+ <ChevronsUpDown className="h-4 w-4 opacity-50" />
+ )}
+ </span>
+ )}
+ </div>
+
+ {/* Resize Handle */}
+ <div
+ onMouseDown={header.getResizeHandler()}
+ onTouchStart={header.getResizeHandler()}
+ onClick={(e) => e.stopPropagation()} // Prevent sort trigger
+ className={cn(
+ "absolute right-0 top-0 h-full w-2 cursor-col-resize select-none touch-none z-10",
+ "after:absolute after:right-0 after:top-0 after:h-full after:w-[1px] after:bg-border", // 시각적 구분선
+ "hover:bg-primary/20 hover:w-4 hover:-right-2", // 호버 시 클릭 영역 확장
+ header.column.getIsResizing() ? "bg-primary/50 w-1" : "bg-transparent"
+ )}
+ />
+
+ {/* Filter */}
+ {column.getCanFilter() && <ClientTableFilter column={column} />}
+ </>
+ )
+
+ if (header.isPlaceholder) {
+ return (
+ <th
+ colSpan={header.colSpan}
+ style={style}
+ className={cn("border-b px-4 py-2 text-left text-sm font-medium bg-muted", className)}
+ {...props}
+ >
+ {null}
+ </th>
+ )
+ }
+
+ return (
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <th
+ ref={setNodeRef}
+ colSpan={header.colSpan}
+ style={style}
+ className={cn(
+ "border-b px-4 py-2 text-left text-sm font-medium bg-muted group",
+ isDragging ? "opacity-50 bg-accent" : "",
+ isPinned ? "bg-muted shadow-[0_0_10px_rgba(0,0,0,0.1)]" : "",
+ className
+ )}
+ {...attributes}
+ {...listeners}
+ {...props}
+ >
+ {content}
+ </th>
+ </ContextMenuTrigger>
+ <ContextMenuContent className="w-48">
+ <ContextMenuItem onClick={handleHide}>
+ <EyeOff className="mr-2 h-4 w-4" />
+ Hide Column
+ </ContextMenuItem>
+ <ContextMenuSeparator />
+ <ContextMenuItem onClick={handlePinLeft}>
+ <MoveLeft className="mr-2 h-4 w-4" />
+ Pin Left
+ </ContextMenuItem>
+ <ContextMenuItem onClick={handlePinRight}>
+ <MoveRight className="mr-2 h-4 w-4" />
+ Pin Right
+ </ContextMenuItem>
+ {isPinned && (
+ <ContextMenuItem onClick={handleUnpin}>
+ <PinOff className="mr-2 h-4 w-4" />
+ Unpin
+ </ContextMenuItem>
+ )}
+ </ContextMenuContent>
+ </ContextMenu>
+ )
+}
diff --git a/components/client-table/client-table-filter.tsx b/components/client-table/client-table-filter.tsx
new file mode 100644
index 00000000..138f77eb
--- /dev/null
+++ b/components/client-table/client-table-filter.tsx
@@ -0,0 +1,101 @@
+"use client"
+
+import * as React from "react"
+import { Column } from "@tanstack/react-table"
+import { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { ClientTableColumnMeta } from "./types"
+
+interface ClientTableFilterProps<TData, TValue> {
+ column: Column<TData, TValue>
+}
+
+export function ClientTableFilter<TData, TValue>({
+ column,
+}: ClientTableFilterProps<TData, TValue>) {
+ const columnFilterValue = column.getFilterValue()
+ // Cast meta to our local type
+ const meta = column.columnDef.meta as ClientTableColumnMeta | undefined
+
+ // Handle Boolean Filter
+ if (meta?.filterType === "boolean") {
+ return (
+ <div onClick={(e) => e.stopPropagation()} className="mt-2">
+ <Select
+ value={(columnFilterValue as string) ?? "all"}
+ onValueChange={(value) =>
+ column.setFilterValue(value === "all" ? undefined : value === "true")
+ }
+ >
+ <SelectTrigger className="h-8 w-full">
+ <SelectValue placeholder="All" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">All</SelectItem>
+ <SelectItem value="true">Yes</SelectItem>
+ <SelectItem value="false">No</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ )
+ }
+
+ // Handle Select Filter (for specific options)
+ if (meta?.filterType === "select" && meta.filterOptions) {
+ return (
+ <div onClick={(e) => e.stopPropagation()} className="mt-2">
+ <Select
+ value={(columnFilterValue as string) ?? "all"}
+ onValueChange={(value) =>
+ column.setFilterValue(value === "all" ? undefined : value)
+ }
+ >
+ <SelectTrigger className="h-8 w-full">
+ <SelectValue placeholder="All" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">All</SelectItem>
+ {meta.filterOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ )
+ }
+
+ // Default Text Filter
+ const [value, setValue] = React.useState(columnFilterValue)
+
+ React.useEffect(() => {
+ setValue(columnFilterValue)
+ }, [columnFilterValue])
+
+ React.useEffect(() => {
+ const timeout = setTimeout(() => {
+ column.setFilterValue(value)
+ }, 500)
+
+ return () => clearTimeout(timeout)
+ }, [value, column])
+
+ return (
+ <div onClick={(e) => e.stopPropagation()} className="mt-2">
+ <Input
+ type="text"
+ value={(value ?? "") as string}
+ onChange={(e) => setValue(e.target.value)}
+ placeholder="Search..."
+ className="h-8 w-full font-normal bg-background"
+ />
+ </div>
+ )
+}
diff --git a/components/client-table/client-table-toolbar.tsx b/components/client-table/client-table-toolbar.tsx
new file mode 100644
index 00000000..43b0a032
--- /dev/null
+++ b/components/client-table/client-table-toolbar.tsx
@@ -0,0 +1,54 @@
+"use client"
+
+import * as React from "react"
+import { Search, Download } from "lucide-react"
+import { Input } from "@/components/ui/input"
+import { Button } from "@/components/ui/button"
+
+interface ClientTableToolbarProps {
+ globalFilter: string
+ setGlobalFilter: (value: string) => void
+ totalRows: number
+ visibleRows: number
+ onExport?: () => void
+ actions?: React.ReactNode
+}
+
+export function ClientTableToolbar({
+ globalFilter,
+ setGlobalFilter,
+ totalRows,
+ visibleRows,
+ onExport,
+ actions,
+}: ClientTableToolbarProps) {
+ return (
+ <div className="flex items-center justify-between gap-4 p-1">
+ <div className="flex items-center gap-2 flex-1">
+ <div className="relative flex-1 max-w-sm">
+ <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+ <Input
+ placeholder="Search all columns..."
+ value={globalFilter ?? ""}
+ onChange={(e) => setGlobalFilter(e.target.value)}
+ className="pl-8"
+ />
+ </div>
+ <div className="text-sm text-muted-foreground whitespace-nowrap">
+ Showing {visibleRows} of {totalRows}
+ </div>
+ </div>
+
+ <div className="flex items-center gap-2">
+ {actions}
+ {onExport && (
+ <Button onClick={onExport} variant="outline" size="sm">
+ <Download className="mr-2 h-4 w-4" />
+ Export
+ </Button>
+ )}
+ </div>
+ </div>
+ )
+}
+
diff --git a/components/client-table/client-table-view-options.tsx b/components/client-table/client-table-view-options.tsx
new file mode 100644
index 00000000..b65049b4
--- /dev/null
+++ b/components/client-table/client-table-view-options.tsx
@@ -0,0 +1,60 @@
+"use client"
+
+import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"
+import { MixerHorizontalIcon } from "@radix-ui/react-icons"
+import { Table } from "@tanstack/react-table"
+
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+} from "@/components/ui/dropdown-menu"
+
+interface ClientTableViewOptionsProps<TData> {
+ table: Table<TData>
+}
+
+export function ClientTableViewOptions<TData>({
+ table,
+}: ClientTableViewOptionsProps<TData>) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="ml-auto hidden h-8 lg:flex"
+ >
+ <MixerHorizontalIcon className="mr-2 h-4 w-4" />
+ View
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[150px]">
+ <DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
+ <DropdownMenuSeparator />
+ {table
+ .getAllLeafColumns()
+ .filter(
+ (column) =>
+ typeof column.accessorFn !== "undefined" && column.getCanHide()
+ )
+ .map((column) => {
+ return (
+ <DropdownMenuCheckboxItem
+ key={column.id}
+ className="capitalize"
+ checked={column.getIsVisible()}
+ onCheckedChange={(value) => column.toggleVisibility(!!value)}
+ >
+ {column.id}
+ </DropdownMenuCheckboxItem>
+ )
+ })}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+}
+
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
diff --git a/components/client-table/export-utils.ts b/components/client-table/export-utils.ts
new file mode 100644
index 00000000..edcc8dff
--- /dev/null
+++ b/components/client-table/export-utils.ts
@@ -0,0 +1,136 @@
+import { ColumnDef } from "@tanstack/react-table"
+import ExcelJS from "exceljs"
+import { saveAs } from "file-saver"
+
+export async function exportToExcel<TData>(
+ data: TData[],
+ columns: ColumnDef<TData, any>[],
+ filename: string = "export.xlsx"
+) {
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Data")
+
+ // Filter out utility columns and resolve headers
+ const exportableColumns = columns.filter(
+ (col) =>
+ col.id !== "select" &&
+ col.id !== "actions" &&
+ // @ts-ignore - simple check for now
+ (typeof col.header === "string" || typeof col.accessorKey === "string")
+ )
+
+ // Setup columns
+ worksheet.columns = exportableColumns.map((col) => {
+ let headerText = ""
+ if (typeof col.header === "string") {
+ headerText = col.header
+ } else if (typeof col.accessorKey === "string") {
+ headerText = col.accessorKey
+ }
+
+ return {
+ header: headerText,
+ key: (col.accessorKey as string) || col.id,
+ width: 20,
+ }
+ })
+
+ // Add rows
+ data.forEach((row) => {
+ const rowData: any = {}
+ exportableColumns.forEach((col) => {
+ const key = (col.accessorKey as string) || col.id
+ if (key) {
+ const value = getValueByPath(row, key)
+ rowData[key] = value
+ }
+ })
+ worksheet.addRow(rowData)
+ })
+
+ worksheet.getRow(1).font = { bold: true }
+
+ const buffer = await workbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ saveAs(blob, filename)
+}
+
+function getValueByPath(obj: any, path: string) {
+ return path.split('.').reduce((acc, part) => acc && acc[part], obj)
+}
+
+interface CreateTemplateOptions<TData> {
+ columns: ColumnDef<TData, any>[]
+ filename?: string
+ includeColumns?: { key: string; header: string }[]
+ excludeColumns?: string[] // accessorKey or id to exclude
+}
+
+export async function createExcelTemplate<TData>({
+ columns,
+ filename = "template.xlsx",
+ includeColumns = [],
+ excludeColumns = [],
+}: CreateTemplateOptions<TData>) {
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Template")
+
+ // 1. Filter columns from definition
+ const baseColumns = columns.filter((col) => {
+ const key = (col.accessorKey as string) || col.id
+
+ // Skip system columns
+ if (col.id === "select" || col.id === "actions") return false
+
+ // Skip excluded columns
+ if (excludeColumns.includes(key!) || (col.id && excludeColumns.includes(col.id))) return false
+
+ return true
+ })
+
+ // 2. Map to ExcelJS columns
+ const excelColumns = baseColumns.map((col) => {
+ let headerText = ""
+ if (typeof col.header === "string") {
+ headerText = col.header
+ } else if (typeof col.accessorKey === "string") {
+ headerText = col.accessorKey
+ }
+
+ return {
+ header: headerText,
+ key: (col.accessorKey as string) || col.id,
+ width: 20
+ }
+ })
+
+ // 3. Add extra included columns
+ includeColumns.forEach((col) => {
+ excelColumns.push({
+ header: col.header,
+ key: col.key,
+ width: 20
+ })
+ })
+
+ worksheet.columns = excelColumns
+
+ // Style Header
+ const headerRow = worksheet.getRow(1)
+ headerRow.font = { bold: true }
+ headerRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFD3D3D3' } // Light Gray
+ }
+
+ // Add Data Validation or Comments if needed (future expansion)
+
+ const buffer = await workbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ saveAs(blob, filename)
+}
diff --git a/components/client-table/import-utils.ts b/components/client-table/import-utils.ts
new file mode 100644
index 00000000..bc7f4b44
--- /dev/null
+++ b/components/client-table/import-utils.ts
@@ -0,0 +1,100 @@
+import ExcelJS from "exceljs"
+
+interface ImportExcelOptions {
+ file: File
+ /**
+ * Map Excel header names to data keys.
+ * Example: { "Name": "name", "Age": "age" }
+ */
+ columnMapping?: Record<string, string>
+ /**
+ * Row offset to start reading data (0-based).
+ * Default: 1 (assuming row 0 is header)
+ */
+ dataStartRow?: number
+}
+
+export interface ExcelImportResult<T = any> {
+ data: T[]
+ errors: string[]
+}
+
+/**
+ * Generic function to read an Excel file and convert it to an array of objects.
+ * Does NOT handle database insertion or validation logic.
+ */
+export async function importFromExcel<T = any>({
+ file,
+ columnMapping = {},
+ dataStartRow = 1,
+}: ImportExcelOptions): Promise<ExcelImportResult<T>> {
+ const workbook = new ExcelJS.Workbook()
+ const arrayBuffer = await file.arrayBuffer()
+
+ try {
+ await workbook.xlsx.load(arrayBuffer)
+ } catch (error) {
+ return { data: [], errors: ["Failed to parse Excel file. Please ensure it is a valid .xlsx file."] }
+ }
+
+ const worksheet = workbook.worksheets[0] // Read the first sheet
+ const data: T[] = []
+ const errors: string[] = []
+
+ if (!worksheet) {
+ return { data: [], errors: ["No worksheet found in the Excel file."] }
+ }
+
+ // 1. Read Header Row (assumed to be row 1 for mapping if no explicit mapping provided,
+ // or we can use it to validate mapping)
+ const headers: string[] = []
+ const headerRow = worksheet.getRow(1)
+ headerRow.eachCell((cell, colNumber) => {
+ headers[colNumber] = String(cell.value).trim()
+ })
+
+ // 2. Iterate Data Rows
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber <= dataStartRow) return // Skip header/pre-header rows
+
+ const rowData: any = {}
+ let hasData = false
+
+ row.eachCell((cell, colNumber) => {
+ const headerText = headers[colNumber]
+ if (!headerText) return
+
+ // Determine the key to use for this column
+ // Priority: Explicit mapping -> Header text as key
+ const key = columnMapping[headerText] || headerText
+
+ let cellValue = cell.value
+
+ // Handle ExcelJS object values (e.g. formula results, hyperlinks)
+ if (cellValue && typeof cellValue === 'object') {
+ if ('result' in cellValue) {
+ // Formula result
+ cellValue = (cellValue as any).result
+ } else if ('text' in cellValue) {
+ // Hyperlink text
+ cellValue = (cellValue as any).text
+ } else if ('richText' in cellValue) {
+ // Rich text
+ cellValue = (cellValue as any).richText.map((t: any) => t.text).join('')
+ }
+ }
+
+ if (cellValue !== null && cellValue !== undefined && String(cellValue).trim() !== '') {
+ hasData = true
+ rowData[key] = cellValue
+ }
+ })
+
+ if (hasData) {
+ data.push(rowData as T)
+ }
+ })
+
+ return { data, errors }
+}
+
diff --git a/components/client-table/index.ts b/components/client-table/index.ts
new file mode 100644
index 00000000..2b3a1645
--- /dev/null
+++ b/components/client-table/index.ts
@@ -0,0 +1,7 @@
+export * from "./client-virtual-table"
+export * from "./client-table-column-header"
+export * from "./client-table-filter"
+export * from "./client-table-view-options"
+export * from "./export-utils"
+export * from "./import-utils"
+export * from "./types"
diff --git a/components/client-table/types.ts b/components/client-table/types.ts
new file mode 100644
index 00000000..b0752bfa
--- /dev/null
+++ b/components/client-table/types.ts
@@ -0,0 +1,11 @@
+import { ColumnDef, RowData } from "@tanstack/react-table"
+
+export interface ClientTableColumnMeta {
+ filterType?: "text" | "select" | "boolean"
+ filterOptions?: { label: string; value: string }[]
+}
+
+// Use this type instead of generic ColumnDef to get intellisense for 'meta'
+export type ClientTableColumnDef<TData extends RowData, TValue = unknown> = ColumnDef<TData, TValue> & {
+ meta?: ClientTableColumnMeta
+}