diff options
| -rw-r--r-- | components/client-table-v2/README.md | 159 | ||||
| -rw-r--r-- | components/client-table-v2/types.ts | 11 | ||||
| -rw-r--r-- | lib/table/server-query-builder.ts | 129 |
3 files changed, 299 insertions, 0 deletions
diff --git a/components/client-table-v2/README.md b/components/client-table-v2/README.md new file mode 100644 index 00000000..053175d4 --- /dev/null +++ b/components/client-table-v2/README.md @@ -0,0 +1,159 @@ +# Client Table Components + +A set of reusable, virtualized table components for client-side data rendering, built on top of `@tanstack/react-table` and `@tanstack/react-virtual`. + +## Features + +- **Virtualization**: Efficiently renders large datasets (50,000+ rows) by only rendering visible rows. +- **Sorting**: Built-in column sorting (Ascending/Descending). +- **Filtering**: + - Global search (all columns). + - Column-specific filters: Text (default), Select, Boolean. +- **Pagination**: Supports both client-side and server-side (manual) pagination. +- **Column Management**: + - **Reordering**: Drag and drop columns to change order. + - **Hiding**: Right-click header to hide columns. + - **Pinning**: Right-click header to pin columns (Left/Right). +- **Excel Export**: Export current table view or custom datasets to `.xlsx`. +- **Excel Import**: Utility to parse Excel files into JSON objects. +- **Template Generation**: Create Excel templates for users to fill out and import. + +## Installation / Usage + +The components are located in `@/components/client-table`. + +### 1. Basic Usage + +```tsx +import { ClientVirtualTable, ClientTableColumnDef } from "@/components/client-table" + +// 1. Define Data Type +interface User { + id: string + name: string + role: string + active: boolean +} + +// 2. Define Columns +const columns: ClientTableColumnDef<User>[] = [ + { + accessorKey: "name", + header: "Name", + // Default filter is text + }, + { + accessorKey: "role", + header: "Role", + meta: { + filterType: "select", + filterOptions: [ + { label: "Admin", value: "admin" }, + { label: "User", value: "user" }, + ] + } + }, + { + accessorKey: "active", + header: "Active", + meta: { + filterType: "boolean" + } + } +] + +// 3. Render Component +export default function UserTable({ data }: { data: User[] }) { + return ( + <ClientVirtualTable + data={data} + columns={columns} + height="600px" // Required for virtualization + enableExport={true} // Shows export button + enablePagination={true} // Shows pagination footer + /> + ) +} +``` + +### 2. Server-Side Pagination + +For very large datasets where you don't want to fetch everything at once. + +```tsx +<ClientVirtualTable + data={currentData} // Only the current page data + columns={columns} + manualPagination={true} + pageCount={totalPages} + rowCount={totalRows} + pagination={{ pageIndex, pageSize }} + onPaginationChange={setPaginationState} + enablePagination={true} +/> +``` + +### 3. Excel Utilities + +#### Exporting Data + +Automatic export is available via the `enableExport` prop. For custom export logic: + +```tsx +import { exportToExcel } from "@/components/client-table" + +await exportToExcel(data, columns, "my-data.xlsx") +``` + +#### Creating Import Templates + +Generate a blank Excel file with headers for users to fill in. + +```tsx +import { createExcelTemplate } from "@/components/client-table" + +await createExcelTemplate({ + columns, + filename: "user-import-template.xlsx", + excludeColumns: ["id", "createdAt"], // Columns to skip + includeColumns: [{ key: "notes", header: "Notes" }] // Extra columns +}) +``` + +#### Importing Data + +Parses an uploaded Excel file into a raw JSON array. Does **not** handle validation or DB insertion. + +```tsx +import { importFromExcel } from "@/components/client-table" + +const handleFileUpload = async (file: File) => { + const { data, errors } = await importFromExcel({ + file, + columnMapping: { "Name": "name", "Role": "role" } // Optional header mapping + }) + + if (errors.length > 0) { + console.error(errors) + return + } + + // Send `data` to your API/Service for validation and insertion + await saveUsers(data) +} +``` + +## Types + +We use a custom column definition type to support our extended `meta` properties. + +```typescript +import { ClientTableColumnDef } from "@/components/client-table" + +const columns: ClientTableColumnDef<MyData>[] = [ ... ] +``` + +Supported `meta` properties: + +- `filterType`: `"text" | "select" | "boolean"` +- `filterOptions`: `{ label: string; value: string }[]` (Required for `select`) diff --git a/components/client-table-v2/types.ts b/components/client-table-v2/types.ts new file mode 100644 index 00000000..b0752bfa --- /dev/null +++ b/components/client-table-v2/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 +} diff --git a/lib/table/server-query-builder.ts b/lib/table/server-query-builder.ts new file mode 100644 index 00000000..7ea25313 --- /dev/null +++ b/lib/table/server-query-builder.ts @@ -0,0 +1,129 @@ +import { + ColumnFiltersState, + SortingState, + PaginationState, + GroupingState +} from "@tanstack/react-table"; +import { + SQL, + and, + or, + eq, + ilike, + like, + gt, + lt, + gte, + lte, + inArray, + asc, + desc, + not, + sql +} from "drizzle-orm"; +import { PgTable } from "drizzle-orm/pg-core"; + +/** + * Table State를 Drizzle Query 조건으로 변환하는 유틸리티 + */ +export class TableQueryBuilder { + private table: PgTable; + private searchableColumns: string[]; + + constructor(table: PgTable, searchableColumns: string[] = []) { + this.table = table; + this.searchableColumns = searchableColumns; + } + + /** + * Pagination State -> Limit/Offset + */ + getPagination(pagination: PaginationState) { + return { + limit: pagination.pageSize, + offset: pagination.pageIndex * pagination.pageSize, + }; + } + + /** + * Sorting State -> Order By + */ + getOrderBy(sorting: SortingState) { + if (!sorting.length) return []; + + return sorting.map((sort) => { + // 컬럼 이름이 테이블에 존재하는지 확인 + const column = this.table[sort.id as keyof typeof this.table]; + if (!column) return null; + + return sort.desc ? desc(column) : asc(column); + }).filter(Boolean) as SQL[]; + } + + /** + * Column Filters -> Where Clause + */ + getWhere(columnFilters: ColumnFiltersState, globalFilter?: string) { + const conditions: SQL[] = []; + + // 1. Column Filters + for (const filter of columnFilters) { + const column = this.table[filter.id as keyof typeof this.table]; + if (!column) continue; + + const value = filter.value; + + // 값의 타입에 따라 적절한 연산자 선택 (기본적인 예시) + if (Array.isArray(value)) { + // 범위 필터 (예: 날짜, 숫자 범위) + if (value.length === 2) { + const [min, max] = value; + if (min !== null && max !== null) { + conditions.push(and(gte(column, min), lte(column, max))!); + } else if (min !== null) { + conditions.push(gte(column, min)!); + } else if (max !== null) { + conditions.push(lte(column, max)!); + } + } + // 다중 선택 (Select) + else { + conditions.push(inArray(column, value)!); + } + } else if (typeof value === 'string') { + // 텍스트 검색 (Partial Match) + conditions.push(ilike(column, `%${value}%`)!); + } else if (typeof value === 'boolean') { + conditions.push(eq(column, value)!); + } else if (typeof value === 'number') { + conditions.push(eq(column, value)!); + } + } + + // 2. Global Filter (검색창) + if (globalFilter && this.searchableColumns.length > 0) { + const searchConditions = this.searchableColumns.map(colName => { + const column = this.table[colName as keyof typeof this.table]; + if (!column) return null; + return ilike(column, `%${globalFilter}%`); + }).filter(Boolean) as SQL[]; + + if (searchConditions.length > 0) { + conditions.push(or(...searchConditions)!); + } + } + + return conditions.length > 0 ? and(...conditions) : undefined; + } + + /** + * Grouping State -> Group By & Select + * 주의: Group By 사용 시 집계 함수가 필요할 수 있음 + */ + getGroupBy(grouping: GroupingState) { + return grouping.map(g => { + const column = this.table[g as keyof typeof this.table]; + return column; + }).filter(Boolean); + } +} |
