summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--components/client-table-v2/README.md159
-rw-r--r--components/client-table-v2/types.ts11
-rw-r--r--lib/table/server-query-builder.ts129
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);
+ }
+}