diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-28 20:30:23 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-28 20:30:23 +0900 |
| commit | 9cabe879404f1ec05dbf4e65d55162b5573aeced (patch) | |
| tree | 942d54a4e21fa9ce757e92191060c88b7a4b241e /components | |
| parent | 748f68bb7b5d02450664651ae5025c9a38fb71a5 (diff) | |
(김준회) dynamic table - init
Diffstat (limited to 'components')
| -rw-r--r-- | components/client-table/client-table-column-header.tsx | 179 | ||||
| -rw-r--r-- | components/client-table/client-table-filter.tsx | 101 | ||||
| -rw-r--r-- | components/client-table/client-table-toolbar.tsx | 54 | ||||
| -rw-r--r-- | components/client-table/client-table-view-options.tsx | 60 | ||||
| -rw-r--r-- | components/client-table/client-virtual-table.tsx | 362 | ||||
| -rw-r--r-- | components/client-table/export-utils.ts | 136 | ||||
| -rw-r--r-- | components/client-table/import-utils.ts | 100 | ||||
| -rw-r--r-- | components/client-table/index.ts | 7 | ||||
| -rw-r--r-- | components/client-table/types.ts | 11 |
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 +} |
