summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-12-08 15:58:20 +0900
committerjoonhoekim <26rote@gmail.com>2025-12-08 15:58:20 +0900
commit137dc8abffcac7721890f320f183ab13eb30b790 (patch)
tree78a2362cfadbfb1297ce0f86608256dc932e95cc /components
parentad29c8d9dc5ce3f57d1e994e84603edcdb961c12 (diff)
parentd853ddc380bc03d968872e9ce53d7ea13a5304f8 (diff)
Merge branch 'table-v2' into dujinkim
Diffstat (limited to 'components')
-rw-r--r--components/client-table-v2/GUIDE-v2.md93
-rw-r--r--components/client-table-v2/GUIDE-v3-ko.md92
-rw-r--r--components/client-table-v2/GUIDE-v3.md93
-rw-r--r--components/client-table-v2/GUIDE.md178
-rw-r--r--components/client-table-v2/adapter/create-table-service.ts101
-rw-r--r--components/client-table-v2/adapter/drizzle-table-adapter.ts173
-rw-r--r--components/client-table-v2/client-table-preset.tsx3
-rw-r--r--components/client-table-v3/GUIDE.md99
-rw-r--r--components/client-table-v3/client-table-column-header.tsx237
-rw-r--r--components/client-table-v3/client-table-filter.tsx103
-rw-r--r--components/client-table-v3/client-table-preset.tsx189
-rw-r--r--components/client-table-v3/client-virtual-table.tsx309
-rw-r--r--components/client-table-v3/index.ts9
-rw-r--r--components/client-table-v3/preset-actions.ts84
-rw-r--r--components/client-table-v3/preset-types.ts15
-rw-r--r--components/client-table-v3/types.ts84
-rw-r--r--components/client-table-v3/use-client-table.ts283
17 files changed, 2145 insertions, 0 deletions
diff --git a/components/client-table-v2/GUIDE-v2.md b/components/client-table-v2/GUIDE-v2.md
new file mode 100644
index 00000000..930123fb
--- /dev/null
+++ b/components/client-table-v2/GUIDE-v2.md
@@ -0,0 +1,93 @@
+# ClientVirtualTable V2 — Server Fetching Guide
+
+This guide focuses on `fetchMode="server"` usage (Tabs 2, 2-B, 3 in `/[lng]/test/table-v2`). Client mode is unchanged from `GUIDE.md`.
+
+## Core Concepts
+- `fetchMode="server"` sets `manualPagination|manualSorting|manualFiltering|manualGrouping` to true. The table **renders what the server returns**; no client-side sorting/filtering/pagination is applied.
+- You must control table state (pagination, sorting, filters, grouping, globalFilter) in the parent and refetch on change.
+- Provide `rowCount` (and optionally `pageCount`) so the pagination footer is accurate.
+- Export uses the current row model; in server mode it only exports the loaded page unless you fetch everything yourself.
+
+## Minimal Wiring (Factory Service)
+```tsx
+const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
+const [sorting, setSorting] = useState([]);
+const [columnFilters, setColumnFilters] = useState([]);
+const [globalFilter, setGlobalFilter] = useState("");
+const [data, setData] = useState([]);
+const [rowCount, setRowCount] = useState(0);
+const [loading, setLoading] = useState(false);
+
+useEffect(() => {
+ const run = async () => {
+ setLoading(true);
+ const res = await getTableData({
+ pagination,
+ sorting,
+ columnFilters,
+ globalFilter,
+ });
+ setData(res.data);
+ setRowCount(res.totalRows);
+ setLoading(false);
+ };
+ run();
+}, [pagination, sorting, columnFilters, globalFilter]);
+
+<ClientVirtualTable
+ fetchMode="server"
+ data={data}
+ rowCount={rowCount}
+ columns={columns}
+ isLoading={loading}
+ enablePagination
+ pagination={pagination}
+ onPaginationChange={setPagination}
+ sorting={sorting}
+ onSortingChange={setSorting}
+ columnFilters={columnFilters}
+ onColumnFiltersChange={setColumnFilters}
+ globalFilter={globalFilter}
+ onGlobalFilterChange={setGlobalFilter}
+/>
+```
+
+## Using `createTableService` (Pattern 2)
+- Import `createTableService` in a server action and pass `columns` (accessorKey-based) plus schema/db.
+- The adapter maps `sorting`, `columnFilters`, `globalFilter`, `pagination` to Drizzle query parts.
+- Returned shape: `{ data, totalRows, pageCount }`. Always forward `totalRows` to the client.
+
+## Custom Service (Pattern 3)
+- Build custom joins manually; still read `tableState` for pagination/sorting/filtering if you need them.
+- For sorting: map `tableState.sorting` IDs to your joined columns; provide a default order if none is set.
+- Grouping in custom services requires manual implementation (see `getOrderTableDataGroupedByStatus` pattern).
+
+## Server Grouping (Pattern 2-B)
+- Only columns marked `meta.serverGroupable` in server column defs should be used.
+- Group headers are fetched via DB `GROUP BY`; expanded rows are fetched per group.
+- When grouping is active, the table may render a custom grouped view instead of the virtual table; ensure your fetcher returns either `{ groups }` or `{ data, totalRows }`.
+
+## Presets in Server Mode
+- Presets store: sorting, columnFilters, globalFilter, columnVisibility, columnPinning, columnOrder, grouping, pageSize.
+- Loading a preset triggers the table’s `set*` APIs; parent `on*Change` handlers refetch with the restored state.
+- The component resets pageIndex to 0 when applying a preset to avoid out-of-range requests after pageSize changes.
+- Use unique `tableKey` per screen to avoid clashing presets across pages.
+
+## Common Pitfalls
+- Forgetting `rowCount` → pagination shows wrong totals.
+- Not reacting to `sorting`/`filters`/`grouping` changes in your effect → UI toggles with no data change.
+- Mapping `sorting` IDs to columns incorrectly in custom services → server ignores the sort.
+- Mixing client-side models with server mode: do not enable client `getSortedRowModel`/`getFilteredRowModel` for server fetches (the component already skips them when `fetchMode="server"`).
+
+## Feature Matrix (Server Mode)
+- Sorting: Supported; must be implemented in the server fetcher.
+- Filtering: Supported; column filters/global filter forwarded; implement in server.
+- Pagination: Supported; manual; provide `rowCount`.
+- Grouping: Client grouping is off in server mode; implement via server `GROUP BY` or custom grouped view.
+- Column show/hide, pinning, reorder: Client-side only; state is preserved and sent to presets but does not affect server queries unless you opt to read it.
+- Export: Exports the currently loaded rows; fetch all data yourself for full exports.
+
+## Debug Checklist
+- Confirm `fetchMode="server"` and `rowCount` are set.
+- Verify the parent effect depends on `pagination`, `sorting`, `columnFilters`, `globalFilter`, and (if used) `grouping`.
+- In custom services, console/log the incoming `tableState` to confirm the UI is sending the intended state.
diff --git a/components/client-table-v2/GUIDE-v3-ko.md b/components/client-table-v2/GUIDE-v3-ko.md
new file mode 100644
index 00000000..9ec71065
--- /dev/null
+++ b/components/client-table-v2/GUIDE-v3-ko.md
@@ -0,0 +1,92 @@
+# ClientVirtualTable V3 가이드 (한국어)
+
+`components/client-table-v2` 테이블 컴포넌트와 `fetchMode="server"` 사용 시 주의점을 정리했습니다.
+
+## 모듈 맵
+- `client-virtual-table.tsx`: 코어 테이블(가상 스크롤, 컬럼 DnD, 핀/숨김, 프리셋, 툴바, 페이지네이션).
+- `client-table-column-header.tsx`: 헤더 셀(정렬 토글, 필터 UI, 컨텍스트 메뉴: 핀/숨김/그룹/재정렬).
+- `client-table-toolbar.tsx` (client-table): 검색, 내보내기, 뷰 옵션, 프리셋 엔트리.
+- `client-table-view-options.tsx` (client-table): 컬럼 표시/숨김 토글.
+- `client-table-filter.tsx`: 컬럼 필터 UI(text/select/boolean).
+- `client-table-preset.tsx`: `tableKey`+사용자별 프리셋 저장/불러오기/삭제.
+- 기타: `export-utils`, `import-utils`, `ClientDataTablePagination`(client-data-table).
+- 서버 헬퍼: `adapter/create-table-service.ts`, `adapter/drizzle-table-adapter.ts`.
+- 타입: `types.ts`, `preset-types.ts`.
+
+## 핵심 동작 (ClientVirtualTable)
+- 가상 스크롤: `height` 필수, `estimateRowHeight` 기본 40.
+- DnD: 컬럼 재배치, 핀 섹션 간 이동 시 핀 상태 동기화.
+- 핀/숨김/순서: 클라이언트 상태(`columnVisibility`, `columnPinning`, `columnOrder`).
+- 정렬/필터/페이지네이션/그룹핑
+ - `fetchMode="client"`: TanStack 모델 사용.
+ - `fetchMode="server"`: manual 플래그 on, 클라이언트 모델 skip → **서버가 정렬/필터/페이징된 결과를 반환해야 함**.
+- 내보내기: 현재 렌더된 행 기준. 서버 모드에서 전체 내보내기는 직접 `onExport`로 구현 필요.
+- 프리셋: `enableUserPreset`+`tableKey` 설정 시 표시. 불러올 때 pageIndex를 0으로 리셋해 서버 모드에서 범위 오류 방지.
+
+## 주요 Props
+- `fetchMode`: `"client"` | `"server"` (기본 `"client"`).
+- 데이터: `data`, `rowCount?`, `pageCount?`.
+- 상태/핸들러:
+ - 페이지: `pagination`, `onPaginationChange`, `enablePagination`, `manualPagination?`.
+ - 정렬: `sorting`, `onSortingChange`.
+ - 필터: `columnFilters`, `onColumnFiltersChange`, `globalFilter`, `onGlobalFilterChange`.
+ - 그룹핑: `grouping`, `onGroupingChange`, `expanded`, `onExpandedChange`, `enableGrouping`.
+ - 표시/핀/순서: `columnVisibility`, `columnPinning`, `columnOrder` 및 각 onChange.
+ - 선택: `enableRowSelection`, `enableMultiRowSelection`, `rowSelection`, `onRowSelectionChange`.
+- UX: `actions`, `customToolbar`, `enableExport`, `onExport`, `renderHeaderVisualFeedback`, `getRowClassName`, `onRowClick`.
+- 프리셋: `enableUserPreset`, `tableKey`.
+- 메타: `meta`, `getRowId`.
+
+## 서버 페칭 패턴
+### 패턴 1: 클라이언트 모드
+- `fetchMode="client"`, 전체 데이터 전달. 정렬/필터/그룹핑은 브라우저에서 처리.
+
+### 패턴 2: Factory Service (`createTableService`)
+- 서버 액션: `createTableService({ db, schema, columns, defaultWhere?, customQuery? })`.
+- 어댑터가 `sorting`, `columnFilters`, `globalFilter`, `pagination`, `grouping`을 Drizzle `where/orderBy/limit/offset/groupBy`로 변환.
+- 반환 `{ data, totalRows, pageCount }` → 클라이언트에서 `rowCount` 설정 필수.
+- 클라이언트: `pagination/sorting/columnFilters/globalFilter` 제어 후 deps로 `useEffect` 재호출.
+
+### 패턴 2-B: 서버 그룹핑
+- `getProductTableDataWithGrouping` 예시: `grouping` 없으면 일반 페칭, 있으면 DB `GROUP BY` 후 `{ groups }` 반환.
+- 서버 그룹핑 가능한 컬럼(`meta.serverGroupable`)만 사용.
+- 그룹 확장 시 그룹 키별 하위 행을 추가 조회, 그룹 변경 시 확장 상태 초기화.
+- 그룹뷰 렌더 시 가상 테이블 대신 커스텀 블록을 사용할 수 있음.
+
+### 패턴 3: 커스텀 서비스
+- 조인/파생 컬럼용. `tableState`를 읽어 정렬 ID를 조인 컬럼에 매핑, 정렬 없을 때 기본 정렬 제공.
+- 필터/글로벌 필터는 직접 구현해야 함.
+- 그룹핑도 수동 구현(`getOrderTableDataGroupedByStatus` 참고).
+
+## 상태 → 쿼리 매핑 (서버)
+- 정렬: `tableState.sorting`(id, desc) → DB 컬럼 매핑, 모르는 id는 무시.
+- 필터: 텍스트(ilike), 불리언, 숫자, 범위[min,max], 다중선택(IN) 지원.
+- 글로벌 필터: 매핑된 컬럼 OR ilike.
+- 페이지: pageIndex/pageSize → limit/offset, `rowCount` 반환.
+- 그룹핑: 지원 컬럼만 `GROUP BY`.
+
+## 프리셋 (서버 호환)
+- 저장: sorting, columnFilters, globalFilter, columnVisibility, columnPinning, columnOrder, grouping, pageSize.
+- 불러오기: `table.set*` 호출 + pageIndex 0 리셋 → 상위 `on*Change` 핸들러에서 재페칭.
+- 화면별 고유 `tableKey` 사용 권장. 세션 필요.
+
+## 기능 매트릭스 (서버 모드)
+- 정렬: 지원 (서버 구현 필요)
+- 필터: 지원 (서버 구현 필요)
+- 페이지네이션: 지원 (manual, `rowCount` 필요)
+- 그룹핑: 자동 미지원, 서버 그룹핑 또는 커스텀 뷰로 구현
+- 컬럼 숨김/핀/순서: 클라이언트 전용(시각용), 서버 쿼리에 자동 반영 안 함
+- 내보내기: 로드된 행만; 전체 내보내기는 커스텀 `onExport` 필요
+
+## 구현 팁
+- `fetchMode="server"`일 때 `rowCount` 꼭 설정.
+- `pagination/sorting/columnFilters/globalFilter/(grouping)` 변경 시마다 재페칭.
+- 정렬 없을 때 서버 기본 정렬을 지정.
+- 그룹 변경 시 확장 상태 초기화.
+- `height`를 항상 지정(가상 스크롤 컨테이너 필요).
+
+## 빠른 예시
+- 클라이언트: `fetchMode="client"`, 전체 데이터 전달, 그룹핑 옵션 사용 가능.
+- Factory 서버: `fetchMode="server"`, `createTableService`, 제어형 상태 + `rowCount`.
+- 서버 그룹핑: `grouping`에 따라 `{ groups }` vs `{ data }` 반환, `serverGroupable` 컬럼만 허용.
+- 커스텀 조인: 정렬 ID 직접 매핑, 필터/글로벌 직접 적용, `rowCount` 반환.
diff --git a/components/client-table-v2/GUIDE-v3.md b/components/client-table-v2/GUIDE-v3.md
new file mode 100644
index 00000000..21a1217d
--- /dev/null
+++ b/components/client-table-v2/GUIDE-v3.md
@@ -0,0 +1,93 @@
+# ClientVirtualTable V3 Guide
+
+This guide documents the table components in `components/client-table-v2`, with an emphasis on server fetching (`fetchMode="server"`) and how supporting components fit together.
+
+## Module Map
+- `client-virtual-table.tsx`: Core table (virtualized, DnD columns, pin/hide, presets hook point, toolbar, pagination).
+- `client-table-column-header.tsx`: Header cell with sort toggle, filter UI, context menu (hide/pin/group/reorder hook).
+- `client-table-toolbar.tsx` (from `components/client-table`): Search box, export button, view options, preset entry point.
+- `client-table-view-options.tsx` (from `components/client-table`): Column visibility toggles.
+- `client-table-filter.tsx`: Column filter UI (text/select/boolean).
+- `client-table-preset.tsx`: Save/load/delete presets per `tableKey` + user.
+- `client-table-save-view.tsx`, `client-table-preset.tsx`, `client-table-toolbar.tsx`: Preset and view controls.
+- `client-virtual-table` dependencies: `ClientDataTablePagination` (`components/client-data-table`), `export-utils`, `import-utils`.
+- Server helpers: `adapter/create-table-service.ts`, `adapter/drizzle-table-adapter.ts`.
+- Types: `types.ts`, `preset-types.ts`.
+
+## Core Behaviors (ClientVirtualTable)
+- Virtualization: `height` is required; `estimateRowHeight` defaults to 40.
+- Drag & Drop: Columns reorder across pin sections; drag between pin states updates pinning.
+- Pin/Hide/Reorder: Managed client-side; state is exposed via `columnVisibility`, `columnPinning`, `columnOrder`.
+- Sorting/Filtering/Pagination/Grouping:
+ - `fetchMode="client"`: uses TanStack models (`getSortedRowModel`, `getFilteredRowModel`, `getPaginationRowModel`, etc.).
+ - `fetchMode="server"`: sets manual flags true and skips client models; **server must return already-sorted/filtered/paged data**.
+- Export: Uses current row model; in server mode it exports only the loaded rows unless you supply all data yourself via `onExport`.
+- Presets: When `enableUserPreset` and `tableKey` are set, toolbar shows the preset control; loading a preset resets pageIndex to 0 to avoid invalid pages on server mode.
+
+## Key Props (ClientVirtualTable)
+- `fetchMode`: `"client"` | `"server"` (default `"client"`).
+- Data: `data`, `rowCount?`, `pageCount?`.
+- State + handlers (controlled or uncontrolled):
+ - Pagination: `pagination`, `onPaginationChange`, `enablePagination`, `manualPagination?`.
+ - Sorting: `sorting`, `onSortingChange`.
+ - Filters: `columnFilters`, `onColumnFiltersChange`, `globalFilter`, `onGlobalFilterChange`.
+ - Grouping: `grouping`, `onGroupingChange`, `expanded`, `onExpandedChange`, `enableGrouping`.
+ - Visibility/Pinning/Order: `columnVisibility`, `onColumnVisibilityChange`, `columnPinning`, `onColumnPinningChange`, `columnOrder`, `onColumnOrderChange`.
+ - Selection: `enableRowSelection`, `enableMultiRowSelection`, `rowSelection`, `onRowSelectionChange`.
+- UX: `actions`, `customToolbar`, `enableExport`, `onExport`, `renderHeaderVisualFeedback`, `getRowClassName`, `onRowClick`.
+- Presets: `enableUserPreset`, `tableKey`.
+- Meta: `meta`, `getRowId`.
+
+## Server Fetching Patterns
+### Pattern 1: Client-Side (baseline)
+- `fetchMode="client"`, pass full dataset; TanStack handles sorting/filtering/grouping locally.
+
+### Pattern 2: Factory Service (`createTableService`)
+- Server action: `createTableService({ db, schema, columns, defaultWhere?, customQuery? })`.
+- The adapter maps `sorting`, `columnFilters`, `globalFilter`, `pagination`, `grouping` → Drizzle `where`, `orderBy`, `limit`, `offset`, `groupBy`.
+- Returns `{ data, totalRows, pageCount }`; always forward `totalRows` to the client and wire `rowCount`.
+- Client wiring: control `pagination`, `sorting`, `columnFilters`, `globalFilter`; refetch in `useEffect` on those deps.
+
+### Pattern 2-B: Server Grouping
+- Uses `getProductTableDataWithGrouping` sample: if `grouping` is empty → normal server fetch; else returns `{ groups }` built from DB `GROUP BY`.
+- Columns must be marked `meta.serverGroupable` in server column defs.
+- Expanded groups fetch child rows per group key; grouping change clears expanded state.
+- UI may render a custom grouped view (not the virtual table) when grouped.
+
+### Pattern 3: Custom Service
+- For joins/derived columns: read `tableState` and manually map `sorting` IDs to joined columns; supply a default order when no sort is present.
+- Filtering/global filter are not automatic—implement them if needed.
+- Grouping is manual; see `getOrderTableDataGroupedByStatus` pattern for a grouped response shape.
+
+## State → Query Mapping (Server)
+- Sorting: `tableState.sorting` (id, desc) → map to DB columns; ignore unknown ids.
+- Filters: `columnFilters` supports text (ilike), boolean, number, range `[min,max]`, multi-select (IN).
+- Global filter: ilike OR across mapped columns.
+- Pagination: pageIndex/pageSize → limit/offset; return `rowCount`.
+- Grouping: `grouping` → `GROUP BY` for supported columns only.
+
+## Presets (Server-Friendly)
+- Saved keys: sorting, columnFilters, globalFilter, columnVisibility, columnPinning, columnOrder, grouping, pageSize.
+- On load: applies `table.set*` and resets pageIndex to 0; parent `on*Change` handlers should trigger refetch.
+- Use unique `tableKey` per screen to avoid collisions; requires authenticated session.
+
+## Feature Matrix (Server Mode)
+- Sorting: Yes—server implemented.
+- Filtering: Yes—server implemented.
+- Pagination: Yes—manual; provide `rowCount`.
+- Grouping: Not automatic; implement via server grouping or custom grouped view.
+- Column hide/pin/reorder: Client-only (visual); does not change server query unless you opt to read it.
+- Export: Only current rows unless you provide `onExport` with full data.
+
+## Implementation Tips
+- Always set `rowCount` when `fetchMode="server"`.
+- Refetch on `pagination`, `sorting`, `columnFilters`, `globalFilter`, and `grouping` (if used).
+- Provide a default sort on the server when `sorting` is empty.
+- Reset `expanded` or group expand state when grouping changes in server grouping flows.
+- Ensure `height` is set; virtualization needs a scroll container.
+
+## Quick Examples
+- Client: `fetchMode="client"` with `data` = full list; optional grouping enabled.
+- Factory server: `fetchMode="server"`, `createTableService` action, controlled state with `rowCount`.
+- Server grouping: `grouping` drives `{ groups }` vs `{ data }` response; only `serverGroupable` columns allowed.
+- Custom join: Manually map `sorting` ids; apply filters/global; return `rowCount`.
diff --git a/components/client-table-v2/GUIDE.md b/components/client-table-v2/GUIDE.md
new file mode 100644
index 00000000..4ccadfc7
--- /dev/null
+++ b/components/client-table-v2/GUIDE.md
@@ -0,0 +1,178 @@
+# Table Component & Data Fetching Guide
+
+이 가이드는 `ClientVirtualTable`을 사용하여 테이블을 구현하고, 데이터를 페칭하는 3가지 주요 패턴을 설명합니다.
+
+## 개요
+
+프로젝트의 복잡도와 요구사항에 따라 아래 3가지 패턴 중 하나를 선택하여 사용할 수 있습니다.
+
+| 모드 | 패턴 | 적합한 상황 | 특징 |
+|---|---|---|---|
+| **Client** | 1. Client-Side | 데이터가 적을 때 (< 1000건), 빠른 인터랙션 필요 | 전체 데이터 로드 후 브라우저에서 처리 |
+| **Server** | 2. Factory Service | 단순 CRUD, 마스터 테이블 | 코드 1줄로 서버 액션 생성, 빠른 개발 |
+| **Server** | 3. Custom Service | 복잡한 조인, 비즈니스 로직 | 완전한 쿼리 제어, Adapter를 도구로 사용 |
+
+---
+
+## 1. Client-Side (기본 모드)
+
+데이터가 많지 않을 때 가장 간단한 방법입니다. 모든 데이터를 한 번에 받아와 `data` prop으로 넘깁니다.
+
+### 사용법
+
+```tsx
+// page.tsx
+import { getAllUsers } from "@/lib/api/users";
+import { ClientVirtualTable } from "@/components/client-table-v2/client-virtual-table";
+import { columns } from "./columns";
+
+export default async function UsersPage() {
+ const users = await getAllUsers(); // 전체 목록 조회
+
+ return (
+ <ClientVirtualTable
+ fetchMode="client"
+ data={users}
+ columns={columns}
+ enablePagination
+ enableSorting
+ enableFiltering
+ />
+ );
+}
+```
+
+---
+
+## 2. Factory Service (추천 - 단순 조회용)
+
+`createTableService`를 사용하여 서버 사이드 페칭을 위한 액션을 자동으로 생성합니다.
+
+### 1) Server Action 생성
+
+```typescript
+// app/actions/user-table.ts
+"use server"
+
+import { db } from "@/lib/db";
+import { users } from "@/lib/db/schema";
+import { columns } from "@/components/users/columns";
+import { createTableService } from "@/components/client-table-v2/adapter/create-table-service";
+
+// 팩토리 함수로 액션 생성 (한 줄로 끝!)
+export const getUserTableData = createTableService({
+ db,
+ schema: users,
+ columns: columns
+});
+```
+
+### 2) 클라이언트 컴포넌트 연결
+
+```tsx
+// components/users/user-table.tsx
+"use client"
+
+import { getUserTableData } from "@/app/actions/user-table";
+import { ClientVirtualTable } from "@/components/client-table-v2/client-virtual-table";
+import { columns } from "./columns";
+import { useState, useEffect } from "react";
+
+export function UserTable() {
+ const [data, setData] = useState([]);
+ const [totalRows, setTotalRows] = useState(0);
+ const [loading, setLoading] = useState(false);
+
+ // 테이블 상태 관리
+ const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
+ const [sorting, setSorting] = useState([]);
+ const [columnFilters, setColumnFilters] = useState([]);
+ const [globalFilter, setGlobalFilter] = useState("");
+
+ // 데이터 페칭
+ useEffect(() => {
+ const fetchData = async () => {
+ setLoading(true);
+ const result = await getUserTableData({
+ pagination,
+ sorting,
+ columnFilters,
+ globalFilter
+ });
+ setData(result.data);
+ setTotalRows(result.totalRows);
+ setLoading(false);
+ };
+
+ fetchData();
+ }, [pagination, sorting, columnFilters, globalFilter]);
+
+ return (
+ <ClientVirtualTable
+ fetchMode="server"
+ data={data}
+ rowCount={totalRows}
+ columns={columns}
+ isLoading={loading}
+ // 상태 연결
+ pagination={pagination} onPaginationChange={setPagination}
+ sorting={sorting} onSortingChange={setSorting}
+ columnFilters={columnFilters} onColumnFiltersChange={setColumnFilters}
+ globalFilter={globalFilter} onGlobalFilterChange={setGlobalFilter}
+ />
+ );
+}
+```
+
+---
+
+## 3. Custom Service (복잡한 로직용)
+
+여러 테이블을 조인하거나, 특정 권한 체크 등 복잡한 로직이 필요할 때는 `DrizzleTableAdapter`를 직접 사용합니다.
+
+### 1) Custom Server Action 작성
+
+```typescript
+// app/actions/order-table.ts
+"use server"
+
+import { db } from "@/lib/db";
+import { orders, users } from "@/lib/db/schema";
+import { DrizzleTableAdapter } from "@/components/client-table-v2/adapter/drizzle-table-adapter";
+import { count, eq } from "drizzle-orm";
+
+export async function getOrderTableData(tableState) {
+ // 1. 어댑터로 조건절 생성
+ const adapter = new DrizzleTableAdapter(orders, columns);
+ const { where, orderBy, limit, offset } = adapter.getQueryParts(tableState);
+
+ // 2. 커스텀 쿼리 작성 (예: 유저 조인)
+ const data = await db
+ .select({
+ orderId: orders.id,
+ amount: orders.amount,
+ userName: users.name // 조인된 컬럼
+ })
+ .from(orders)
+ .leftJoin(users, eq(orders.userId, users.id))
+ .where(where)
+ .orderBy(...orderBy)
+ .limit(limit)
+ .offset(offset);
+
+ // 3. 카운트 쿼리
+ const total = await db
+ .select({ count: count() })
+ .from(orders)
+ .where(where);
+
+ return {
+ data,
+ totalRows: total[0]?.count ?? 0
+ };
+}
+```
+
+### 2) 클라이언트 연결
+
+Factory Service 방식과 동일하게 `useEffect`에서 `getOrderTableData`를 호출하면 됩니다.
diff --git a/components/client-table-v2/adapter/create-table-service.ts b/components/client-table-v2/adapter/create-table-service.ts
new file mode 100644
index 00000000..41c38906
--- /dev/null
+++ b/components/client-table-v2/adapter/create-table-service.ts
@@ -0,0 +1,101 @@
+import { PgTable } from "drizzle-orm/pg-core";
+import { DrizzleTableAdapter, DrizzleTableState } from "./drizzle-table-adapter";
+import { ColumnDef } from "@tanstack/react-table";
+import { SQL, and, count } from "drizzle-orm";
+
+// Define a minimal DB interface that we need
+// Adjust this to match your actual db instance type
+interface DbInstance {
+ select: (args?: any) => any;
+}
+
+export interface CreateTableServiceConfig<TData> {
+ /**
+ * Drizzle Database Instance
+ */
+ db: DbInstance;
+
+ /**
+ * Drizzle Table Schema (e.g. users, orders)
+ */
+ schema: PgTable; // Using PgTable as base, works for most Drizzle tables
+
+ /**
+ * React Table Columns Definition
+ * Used to map accessorKeys to DB columns
+ */
+ columns: ColumnDef<TData, any>[];
+
+ /**
+ * Optional: Custom WHERE clause to always apply (e.g. deleted_at IS NULL)
+ */
+ defaultWhere?: SQL;
+
+ /**
+ * Optional: Custom query modifier
+ * Allows joining other tables or selecting specific fields
+ */
+ customQuery?: (queryBuilder: any) => any;
+}
+
+/**
+ * Factory function to create a standardized server action for a table.
+ *
+ * @example
+ * export const getUsers = createTableService({
+ * db,
+ * schema: users,
+ * columns: userColumns
+ * });
+ */
+export function createTableService<TData>(config: CreateTableServiceConfig<TData>) {
+ const { db, schema, columns, defaultWhere, customQuery } = config;
+
+ // Return the actual Server Action function
+ return async function getTableData(tableState: DrizzleTableState) {
+ const adapter = new DrizzleTableAdapter(schema, columns);
+ const { where, orderBy, limit, offset, groupBy } = adapter.getQueryParts(tableState);
+
+ // Merge defaultWhere with dynamic where
+ const finalWhere = defaultWhere
+ ? (where ? and(defaultWhere, where) : defaultWhere)
+ : where;
+
+ // 1. Build Data Query
+ let dataQuery = db.select()
+ .from(schema)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .limit(limit)
+ .offset(offset);
+
+ if (groupBy && groupBy.length > 0) {
+ dataQuery = dataQuery.groupBy(...groupBy);
+ }
+
+ // Apply custom query modifications (joins, etc)
+ if (customQuery) {
+ dataQuery = customQuery(dataQuery);
+ }
+
+ // 2. Build Count Query
+ const countQuery = db.select({ count: count() })
+ .from(schema)
+ .where(finalWhere);
+
+ // Execute queries
+ // We use Promise.all to run them in parallel
+ const [data, countResult] = await Promise.all([
+ dataQuery,
+ countQuery
+ ]);
+
+ const totalRows = Number(countResult[0]?.count ?? 0);
+
+ return {
+ data: data as TData[],
+ totalRows,
+ pageCount: Math.ceil(totalRows / (tableState.pagination?.pageSize ?? 10))
+ };
+ };
+}
diff --git a/components/client-table-v2/adapter/drizzle-table-adapter.ts b/components/client-table-v2/adapter/drizzle-table-adapter.ts
new file mode 100644
index 00000000..05bf4c5f
--- /dev/null
+++ b/components/client-table-v2/adapter/drizzle-table-adapter.ts
@@ -0,0 +1,173 @@
+import {
+ ColumnFiltersState,
+ SortingState,
+ PaginationState,
+ GroupingState,
+ ColumnDef
+} from "@tanstack/react-table";
+import {
+ SQL,
+ and,
+ or,
+ eq,
+ ilike,
+ gt,
+ lt,
+ gte,
+ lte,
+ inArray,
+ asc,
+ desc,
+ getTableColumns,
+} from "drizzle-orm";
+import { PgTable, PgView, PgColumn } from "drizzle-orm/pg-core";
+
+// Helper to detect if value is empty or undefined
+const isEmpty = (value: any) => value === undefined || value === null || value === "";
+
+export interface DrizzleTableState {
+ sorting?: SortingState;
+ columnFilters?: ColumnFiltersState;
+ globalFilter?: string;
+ pagination?: PaginationState;
+ grouping?: GroupingState;
+}
+
+export class DrizzleTableAdapter<TData> {
+ private columnMap: Map<string, PgColumn>;
+
+ constructor(
+ private table: PgTable | PgView,
+ private columns: ColumnDef<TData, any>[]
+ ) {
+ // Create a map of accessorKey -> Drizzle Column for fast lookup
+ this.columnMap = new Map();
+ // @ts-ignore - getTableColumns works on views in newer drizzle versions or we can cast
+ const drizzleColumns = getTableColumns(table as any);
+
+ columns.forEach(col => {
+ // We currently only support accessorKey which maps directly to a DB column
+ if ('accessorKey' in col && typeof col.accessorKey === 'string') {
+ const dbCol = drizzleColumns[col.accessorKey];
+ if (dbCol) {
+ this.columnMap.set(col.accessorKey, dbCol);
+ }
+ }
+ });
+ }
+
+ private getColumn(columnId: string): PgColumn | undefined {
+ return this.columnMap.get(columnId);
+ }
+
+ /**
+ * Build the WHERE clause based on column filters and global filter
+ */
+ getWhere(columnFilters?: ColumnFiltersState, globalFilter?: string): SQL | undefined {
+ const conditions: SQL[] = [];
+
+ // 1. Column Filters
+ if (columnFilters) {
+ for (const filter of columnFilters) {
+ const column = this.getColumn(filter.id);
+ if (!column) continue;
+
+ const value = filter.value;
+ if (isEmpty(value)) continue;
+
+ // Handle Array (range or multiple select)
+ if (Array.isArray(value)) {
+ // Range filter (e.g. [min, max])
+ if (value.length === 2 && (typeof value[0] === 'number' || typeof value[0] === 'string')) {
+ const [min, max] = value;
+ if (!isEmpty(min) && !isEmpty(max)) {
+ conditions.push(and(gte(column, min), lte(column, max))!);
+ } else if (!isEmpty(min)) {
+ conditions.push(gte(column, min)!);
+ } else if (!isEmpty(max)) {
+ conditions.push(lte(column, max)!);
+ }
+ }
+ // Multi-select (IN)
+ else if (value.length > 0) {
+ conditions.push(inArray(column, value)!);
+ }
+ }
+ // Boolean
+ else if (typeof value === 'boolean') {
+ conditions.push(eq(column, value)!);
+ }
+ // Number
+ else if (typeof value === 'number') {
+ conditions.push(eq(column, value)!);
+ }
+ // String (Search)
+ else if (typeof value === 'string') {
+ conditions.push(ilike(column, `%${value}%`)!);
+ }
+ }
+ }
+
+ // 2. Global Filter
+ if (globalFilter) {
+ const searchConditions: SQL[] = [];
+ this.columnMap.forEach((column) => {
+ // Implicitly supports only text-compatible columns for ilike
+ // Drizzle might throw if type mismatch, so user should be aware
+ searchConditions.push(ilike(column, `%${globalFilter}%`));
+ });
+
+ if (searchConditions.length > 0) {
+ conditions.push(or(...searchConditions)!);
+ }
+ }
+
+ return conditions.length > 0 ? and(...conditions) : undefined;
+ }
+
+ /**
+ * Build the ORDER BY clause
+ */
+ getOrderBy(sorting?: SortingState): SQL[] {
+ if (!sorting || !sorting.length) return [];
+
+ return sorting.map((sort) => {
+ const column = this.getColumn(sort.id);
+ if (!column) return null;
+ return sort.desc ? desc(column) : asc(column);
+ }).filter(Boolean) as SQL[];
+ }
+
+ /**
+ * Build the GROUP BY clause
+ */
+ getGroupBy(grouping?: GroupingState): SQL[] {
+ if (!grouping || !grouping.length) return [];
+
+ return grouping.map(g => this.getColumn(g)).filter(Boolean) as SQL[];
+ }
+
+ /**
+ * Get Limit and Offset
+ */
+ getPagination(pagination?: PaginationState) {
+ if (!pagination) return { limit: 10, offset: 0 };
+ return {
+ limit: pagination.pageSize,
+ offset: pagination.pageIndex * pagination.pageSize,
+ };
+ }
+
+ /**
+ * Helper to apply all state to a query builder.
+ * Returns the modifier objects that can be passed to drizzle query builder.
+ */
+ getQueryParts(state: DrizzleTableState) {
+ return {
+ where: this.getWhere(state.columnFilters, state.globalFilter),
+ orderBy: this.getOrderBy(state.sorting),
+ groupBy: this.getGroupBy(state.grouping),
+ ...this.getPagination(state.pagination)
+ };
+ }
+}
diff --git a/components/client-table-v2/client-table-preset.tsx b/components/client-table-v2/client-table-preset.tsx
index 64930e7a..21486c9b 100644
--- a/components/client-table-v2/client-table-preset.tsx
+++ b/components/client-table-v2/client-table-preset.tsx
@@ -108,6 +108,9 @@ export function ClientTablePreset<TData>({
if (s.columnOrder) table.setColumnOrder(s.columnOrder);
if (s.grouping) table.setGrouping(s.grouping);
if (s.pagination?.pageSize) table.setPageSize(s.pagination.pageSize);
+ // Reset page index to avoid loading an out-of-range page after applying a preset,
+ // which is especially important in server-mode pagination.
+ table.setPageIndex(0);
toast.success(`Preset "${preset.name}" loaded`);
};
diff --git a/components/client-table-v3/GUIDE.md b/components/client-table-v3/GUIDE.md
new file mode 100644
index 00000000..05d7455e
--- /dev/null
+++ b/components/client-table-v3/GUIDE.md
@@ -0,0 +1,99 @@
+# ClientVirtualTable V3 Guide
+
+This version introduces the `useClientTable` hook to drastically reduce boilerplate code and improve Developer Experience (DX).
+
+## Key Changes from V2
+- **`useClientTable` Hook**: Manages all state (sorting, filtering, pagination, grouping) and data fetching (Client or Server).
+- **Cleaner Component**: `ClientVirtualTable` now accepts a `table` instance prop, making it a pure renderer.
+- **Better Separation**: Logic is in the hook; UI is in the component.
+
+## Usage
+
+### 1. Client-Side Mode
+Load all data once, let the hook handle the rest.
+
+```tsx
+import { useClientTable, ClientVirtualTable } from "@/components/client-table-v3";
+
+function MyTable() {
+ const [data, setData] = useState([]);
+
+ // Load data...
+
+ const { table, isLoading } = useClientTable({
+ fetchMode: "client",
+ data,
+ columns,
+ enablePagination: true, // Auto-enabled
+ });
+
+ return <ClientVirtualTable table={table} isLoading={isLoading} />;
+}
+```
+
+### 2. Server-Side Mode (Factory Service)
+Pass your server action as the `fetcher`. The hook handles debouncing and refetching.
+
+```tsx
+import { useClientTable, ClientVirtualTable } from "@/components/client-table-v3";
+import { myServerAction } from "./actions";
+
+function MyServerTable() {
+ const { table, isLoading } = useClientTable({
+ fetchMode: "server",
+ fetcher: myServerAction, // Must accept TableState
+ columns,
+ enablePagination: true,
+ });
+
+ return <ClientVirtualTable table={table} isLoading={isLoading} />;
+}
+```
+
+### 3. Server Grouping (Pattern 2-B)
+The hook detects server-side grouping responses and provides them separately.
+
+```tsx
+const { table, isLoading, isServerGrouped, serverGroups } = useClientTable({
+ fetchMode: "server",
+ fetcher: myGroupFetcher,
+ columns,
+ enableGrouping: true,
+});
+
+if (isServerGrouped) {
+ return <MyGroupRenderer groups={serverGroups} />;
+}
+
+return <ClientVirtualTable table={table} ... />;
+```
+
+## Hook Options (`useClientTable`)
+
+| Option | Type | Description |
+|--------|------|-------------|
+| `fetchMode` | `'client' \| 'server'` | Default `'client'`. |
+| `data` | `TData[]` | Data for client mode. |
+| `fetcher` | `(state) => Promise` | Server action for server mode. |
+| `columns` | `ColumnDef[]` | Column definitions. |
+| `initialState` | `object` | Initial sorting, filters, etc. |
+| `enablePagination` | `boolean` | Enable pagination logic. |
+| `enableGrouping` | `boolean` | Enable grouping logic. |
+
+## Component Props (`ClientVirtualTable`)
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `table` | `Table<TData>` | The table instance from the hook. |
+| `isLoading` | `boolean` | Shows loading overlay. |
+| `height` | `string` | Table height (required for virtualization). |
+| `enableUserPreset` | `boolean` | Enable saving/loading view presets. |
+| `tableKey` | `string` | Unique key for presets. |
+
+## Migration from V2
+
+1. Replace `<ClientVirtualTable ...props />` with `const { table } = useClientTable({...}); <ClientVirtualTable table={table} />`.
+2. Remove local state (`sorting`, `pagination`, `useEffect` for fetching) from your page component.
+3. Pass `fetcher` directly to the hook.
+
+
diff --git a/components/client-table-v3/client-table-column-header.tsx b/components/client-table-v3/client-table-column-header.tsx
new file mode 100644
index 00000000..3be20565
--- /dev/null
+++ b/components/client-table-v3/client-table-column-header.tsx
@@ -0,0 +1,237 @@
+"use client"
+
+import * as React from "react"
+import { Header, Column } 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,
+ Group,
+ Ungroup,
+} 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
+ renderHeaderVisualFeedback?: (props: {
+ column: Column<TData, TValue>
+ isPinned: boolean | string
+ isSorted: boolean | string
+ isFiltered: boolean
+ isGrouped: boolean
+ }) => React.ReactNode
+}
+
+export function ClientTableColumnHeader<TData, TValue>({
+ header,
+ enableReordering = true,
+ renderHeaderVisualFeedback,
+ className,
+ ...props
+}: ClientTableColumnHeaderProps<TData, TValue>) {
+ const column = header.column
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({
+ id: header.id,
+ disabled: !enableReordering || column.getIsResizing(),
+ })
+
+ // -- 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()
+ const isSorted = column.getIsSorted()
+ const isFiltered = column.getFilterValue() !== undefined
+ const isGrouped = column.getIsGrouped()
+
+ if (isPinned === "left") {
+ style.left = `${column.getStart("left")}px`
+ style.position = "sticky"
+ style.zIndex = 30 // Pinned columns needs to be higher than normal headers
+ } else if (isPinned === "right") {
+ style.right = `${column.getAfter("right")}px`
+ style.position = "sticky"
+ style.zIndex = 30 // Pinned columns needs to be higher than normal headers
+ }
+
+ // -- Handlers --
+ const handleHide = () => column.toggleVisibility(false)
+ const handlePinLeft = () => column.pin("left")
+ const handlePinRight = () => column.pin("right")
+ const handleUnpin = () => column.pin(false)
+ const handleToggleGrouping = () => column.toggleGrouping()
+
+ // -- 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>
+ )}
+ {isGrouped && <Group className="h-4 w-4 text-blue-500" />}
+ </div>
+
+ {/* Resize Handle */}
+ <div
+ onMouseDown={header.getResizeHandler()}
+ onTouchStart={header.getResizeHandler()}
+ onPointerDown={(e) => e.stopPropagation()}
+ 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} />}
+
+ {/* Visual Feedback Indicators */}
+ {renderHeaderVisualFeedback ? (
+ renderHeaderVisualFeedback({
+ column,
+ isPinned,
+ isSorted,
+ isFiltered,
+ isGrouped,
+ })
+ ) : (
+ (isPinned || isFiltered || isGrouped) && (
+ <div className="absolute top-0.5 right-1 flex gap-1 z-10 pointer-events-none">
+ {isPinned && <div className="h-1.5 w-1.5 rounded-full bg-blue-500" />}
+ {isFiltered && <div className="h-1.5 w-1.5 rounded-full bg-yellow-500" />}
+ {isGrouped && <div className="h-1.5 w-1.5 rounded-full bg-green-500" />}
+ </div>
+ )
+ )}
+ </>
+ )
+
+ 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 transition-colors",
+ isDragging ? "opacity-50 bg-accent" : "",
+ isPinned ? "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>
+
+ {column.getCanGroup() && (
+ <>
+ <ContextMenuSeparator />
+ <ContextMenuItem onClick={handleToggleGrouping}>
+ {isGrouped ? (
+ <>
+ <Ungroup className="mr-2 h-4 w-4" />
+ Ungroup
+ </>
+ ) : (
+ <>
+ <Group className="mr-2 h-4 w-4" />
+ Group by {column.id}
+ </>
+ )}
+ </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-v3/client-table-filter.tsx b/components/client-table-v3/client-table-filter.tsx
new file mode 100644
index 00000000..eaf6b31e
--- /dev/null
+++ b/components/client-table-v3/client-table-filter.tsx
@@ -0,0 +1,103 @@
+"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-v3/client-table-preset.tsx b/components/client-table-v3/client-table-preset.tsx
new file mode 100644
index 00000000..557e8493
--- /dev/null
+++ b/components/client-table-v3/client-table-preset.tsx
@@ -0,0 +1,189 @@
+"use client";
+
+import * as React from "react";
+import { Table } from "@tanstack/react-table";
+import { useSession } from "next-auth/react";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Bookmark, Save, Trash2 } from "lucide-react";
+import {
+ getPresets,
+ savePreset,
+ deletePreset,
+} from "./preset-actions";
+import { Preset } from "./preset-types";
+import { toast } from "sonner";
+
+interface ClientTablePresetProps<TData> {
+ table: Table<TData>;
+ tableKey: string;
+}
+
+export function ClientTablePreset<TData>({
+ table,
+ tableKey,
+}: ClientTablePresetProps<TData>) {
+ const { data: session } = useSession();
+ const [savedPresets, setSavedPresets] = React.useState<Preset[]>([]);
+ const [isPresetDialogOpen, setIsPresetDialogOpen] = React.useState(false);
+ const [newPresetName, setNewPresetName] = React.useState("");
+ const [isLoading, setIsLoading] = React.useState(false);
+
+ const fetchSettings = React.useCallback(async () => {
+ const userIdVal = session?.user?.id;
+ if (!userIdVal) return;
+
+ const userId = Number(userIdVal);
+ if (isNaN(userId)) return;
+
+ const res = await getPresets(tableKey, userId);
+ if (res.success && res.data) {
+ setSavedPresets(res.data);
+ }
+ }, [session, tableKey]);
+
+ React.useEffect(() => {
+ if (session) {
+ fetchSettings();
+ }
+ }, [fetchSettings, session]);
+
+ const handleSavePreset = async () => {
+ const userIdVal = session?.user?.id;
+ if (!newPresetName.trim() || !userIdVal) return;
+ const userId = Number(userIdVal);
+ if (isNaN(userId)) return;
+
+ setIsLoading(true);
+ const state = table.getState();
+ const settingToSave = {
+ sorting: state.sorting,
+ columnFilters: state.columnFilters,
+ globalFilter: state.globalFilter,
+ columnVisibility: state.columnVisibility,
+ columnPinning: state.columnPinning,
+ columnOrder: state.columnOrder,
+ grouping: state.grouping,
+ pagination: { pageSize: state.pagination.pageSize },
+ };
+
+ const res = await savePreset(userId, tableKey, newPresetName, settingToSave);
+ setIsLoading(false);
+
+ if (res.success) {
+ toast.success("Preset saved successfully");
+ setIsPresetDialogOpen(false);
+ setNewPresetName("");
+ fetchSettings();
+ } else {
+ toast.error("Failed to save preset");
+ }
+ };
+
+ const handleLoadPreset = (preset: Preset) => {
+ const s = preset.setting as Record<string, any>;
+ if (!s) return;
+
+ if (s.sorting) table.setSorting(s.sorting);
+ if (s.columnFilters) table.setColumnFilters(s.columnFilters);
+ if (s.globalFilter !== undefined) table.setGlobalFilter(s.globalFilter);
+ if (s.columnVisibility) table.setColumnVisibility(s.columnVisibility);
+ if (s.columnPinning) table.setColumnPinning(s.columnPinning);
+ if (s.columnOrder) table.setColumnOrder(s.columnOrder);
+ if (s.grouping) table.setGrouping(s.grouping);
+ if (s.pagination?.pageSize) table.setPageSize(s.pagination.pageSize);
+ // Reset page index to avoid loading an out-of-range page after applying a preset
+ table.setPageIndex(0);
+
+ toast.success(`Preset "${preset.name}" loaded`);
+ };
+
+ const handleDeletePreset = async (e: React.MouseEvent, id: string) => {
+ e.stopPropagation();
+ if (!confirm("Are you sure you want to delete this preset?")) return;
+
+ const res = await deletePreset(id);
+ if (res.success) {
+ toast.success("Preset deleted");
+ fetchSettings();
+ } else {
+ toast.error("Failed to delete preset");
+ }
+ };
+
+ if (!session) return null;
+
+ return (
+ <>
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" size="sm" className="ml-2 hidden h-8 lg:flex">
+ <Bookmark className="mr-2 h-4 w-4" />
+ Presets
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[200px]">
+ <DropdownMenuLabel>Saved Presets</DropdownMenuLabel>
+ <DropdownMenuSeparator />
+ {savedPresets.length === 0 ? (
+ <div className="p-2 text-sm text-muted-foreground text-center">No saved presets</div>
+ ) : (
+ savedPresets.map((preset) => (
+ <DropdownMenuItem key={preset.id} onClick={() => handleLoadPreset(preset)} className="flex justify-between cursor-pointer">
+ <span className="truncate flex-1">{preset.name}</span>
+ <Button variant="ghost" size="icon" className="h-4 w-4" onClick={(e) => handleDeletePreset(e, preset.id)}>
+ <Trash2 className="h-3 w-3 text-destructive" />
+ </Button>
+ </DropdownMenuItem>
+ ))
+ )}
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={() => setIsPresetDialogOpen(true)} className="cursor-pointer">
+ <Save className="mr-2 h-4 w-4" />
+ Save Current Preset
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ <Dialog open={isPresetDialogOpen} onOpenChange={setIsPresetDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Save Preset</DialogTitle>
+ <DialogDescription>
+ Save the current table configuration as a preset.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ <Input
+ placeholder="Preset Name"
+ value={newPresetName}
+ onChange={(e) => setNewPresetName(e.target.value)}
+ />
+ </div>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setIsPresetDialogOpen(false)}>Cancel</Button>
+ <Button onClick={handleSavePreset} disabled={isLoading}>Save</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ );
+}
+
+
diff --git a/components/client-table-v3/client-virtual-table.tsx b/components/client-table-v3/client-virtual-table.tsx
new file mode 100644
index 00000000..7a092326
--- /dev/null
+++ b/components/client-table-v3/client-virtual-table.tsx
@@ -0,0 +1,309 @@
+"use client";
+
+import * as React from "react";
+import { Table, flexRender } 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 { Loader2, ChevronRight, ChevronDown } from "lucide-react";
+
+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 { ClientTableViewOptions } from "../client-table/client-table-view-options";
+
+import { ClientTableColumnHeader } from "./client-table-column-header";
+import { ClientTablePreset } from "./client-table-preset";
+import { ClientVirtualTableProps } from "./types";
+
+export function ClientVirtualTable<TData>({
+ table,
+ isLoading = false,
+ height = "100%",
+ estimateRowHeight = 40,
+ className,
+ actions,
+ customToolbar,
+ enableExport = true,
+ onExport,
+ enableUserPreset = false,
+ tableKey,
+ getRowClassName,
+ onRowClick,
+ renderHeaderVisualFeedback,
+}: ClientVirtualTableProps<TData>) {
+ // --- DnD Sensors ---
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ }),
+ useSensor(KeyboardSensor)
+ );
+
+ // --- Drag Handler ---
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (active && over && active.id !== over.id) {
+ 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
+ if (activePinState !== overPinState) {
+ activeColumn.pin(overPinState);
+ }
+
+ // Reorder
+ const currentOrder = table.getState().columnOrder;
+ const oldIndex = currentOrder.indexOf(activeId);
+ const newIndex = currentOrder.indexOf(overId);
+
+ if (oldIndex !== -1 && newIndex !== -1) {
+ table.setColumnOrder(arrayMove(currentOrder, oldIndex, newIndex));
+ }
+ }
+ }
+ };
+
+ // --- Virtualization ---
+ const tableContainerRef = React.useRef<HTMLDivElement>(null);
+ const { rows } = table.getRowModel();
+
+ const rowVirtualizer = useVirtualizer({
+ count: rows.length,
+ getScrollElement: () => tableContainerRef.current,
+ estimateSize: () => estimateRowHeight,
+ 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 ---
+ const handleExport = async () => {
+ if (onExport) {
+ onExport(table.getFilteredRowModel().rows.map((r) => r.original));
+ return;
+ }
+ const currentData = table.getFilteredRowModel().rows.map((row) => row.original);
+ // Note: exportToExcel needs columns definition. table.getAllColumns() or visible columns?
+ // Using table.getAllLeafColumns() usually.
+ await exportToExcel(currentData, table.getAllLeafColumns(), `export-${new Date().toISOString().slice(0, 10)}.xlsx`);
+ };
+
+ const columns = table.getVisibleLeafColumns();
+ const data = table.getFilteredRowModel().rows; // or just rows which is from getRowModel
+
+ return (
+ <div
+ className={cn("flex flex-col gap-4", className)}
+ style={{ height }}
+ >
+ <ClientTableToolbar
+ globalFilter={table.getState().globalFilter ?? ""}
+ setGlobalFilter={table.setGlobalFilter}
+ totalRows={table.getRowCount()}
+ visibleRows={table.getRowModel().rows.length}
+ onExport={enableExport ? handleExport : undefined}
+ viewOptions={
+ <>
+ <ClientTableViewOptions table={table} />
+ {enableUserPreset && tableKey && (
+ <ClientTablePreset table={table} tableKey={tableKey} />
+ )}
+ </>
+ }
+ customToolbar={customToolbar}
+ actions={actions}
+ />
+
+ <div
+ ref={tableContainerRef}
+ 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}
+ onDragEnd={handleDragEnd}
+ >
+ <table
+ className="table-fixed border-collapse w-full min-w-full"
+ style={{ width: table.getTotalSize() }}
+ >
+ <thead className="sticky top-0 z-40 bg-muted">
+ {table.getHeaderGroups().map((headerGroup) => (
+ <tr key={headerGroup.id}>
+ <SortableContext
+ items={headerGroup.headers.map((h) => h.id)}
+ strategy={horizontalListSortingStrategy}
+ >
+ {headerGroup.headers.map((header) => (
+ <ClientTableColumnHeader
+ key={header.id}
+ header={header}
+ enableReordering={true}
+ renderHeaderVisualFeedback={renderHeaderVisualFeedback}
+ />
+ ))}
+ </SortableContext>
+ </tr>
+ ))}
+ </thead>
+ <tbody>
+ {paddingTop > 0 && (
+ <tr>
+ <td style={{ height: `${paddingTop}px` }} />
+ </tr>
+ )}
+ {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
+ colSpan={columns.length}
+ className="px-4 py-2 text-left font-medium cursor-pointer"
+ onClick={row.getToggleExpandedHandler()}
+ >
+ <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>
+ );
+ }
+
+ // --- 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) => {
+ 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() ? null : cell.getIsAggregated() ? (
+ flexRender(
+ cell.column.columnDef.aggregatedCell ??
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )
+ ) : cell.getIsPlaceholder() ? null : (
+ flexRender(cell.column.columnDef.cell, cell.getContext())
+ )}
+ </td>
+ );
+ })}
+ </tr>
+ );
+ })
+ )}
+ {paddingBottom > 0 && (
+ <tr>
+ <td style={{ height: `${paddingBottom}px` }} />
+ </tr>
+ )}
+ </tbody>
+ </table>
+ </DndContext>
+ </div>
+
+ <ClientDataTablePagination table={table} />
+ </div>
+ );
+}
+
+
diff --git a/components/client-table-v3/index.ts b/components/client-table-v3/index.ts
new file mode 100644
index 00000000..678a4757
--- /dev/null
+++ b/components/client-table-v3/index.ts
@@ -0,0 +1,9 @@
+export * from "./client-virtual-table";
+export * from "./use-client-table";
+export * from "./types";
+export * from "./client-table-column-header";
+export * from "./client-table-filter";
+export * from "./client-table-preset";
+export * from "./preset-types";
+
+
diff --git a/components/client-table-v3/preset-actions.ts b/components/client-table-v3/preset-actions.ts
new file mode 100644
index 00000000..3ef4d239
--- /dev/null
+++ b/components/client-table-v3/preset-actions.ts
@@ -0,0 +1,84 @@
+"use server";
+
+import db from "@/db/db";
+import { userCustomData } from "@/db/schema/user-custom-data/userCustomData";
+import { eq, and } from "drizzle-orm";
+import { Preset } from "./preset-types";
+
+export async function getPresets(tableKey: string, userId: number): Promise<{ success: boolean; data?: Preset[]; error?: string }> {
+ try {
+ const settings = await db
+ .select()
+ .from(userCustomData)
+ .where(
+ and(
+ eq(userCustomData.tableKey, tableKey),
+ eq(userCustomData.userId, userId)
+ )
+ )
+ .orderBy(userCustomData.createdDate);
+
+ const data: Preset[] = settings.map(s => ({
+ id: s.id,
+ name: s.customSettingName,
+ setting: s.customSetting,
+ createdAt: s.createdDate,
+ updatedAt: s.updatedDate,
+ }));
+
+ return { success: true, data };
+ } catch (error) {
+ console.error("Failed to fetch presets:", error);
+ return { success: false, error: "Failed to fetch presets" };
+ }
+}
+
+export async function savePreset(
+ userId: number,
+ tableKey: string,
+ name: string,
+ setting: any
+): Promise<{ success: boolean; error?: string }> {
+ try {
+ const existing = await db.query.userCustomData.findFirst({
+ where: and(
+ eq(userCustomData.userId, userId),
+ eq(userCustomData.tableKey, tableKey),
+ eq(userCustomData.customSettingName, name)
+ )
+ });
+
+ if (existing) {
+ await db.update(userCustomData)
+ .set({
+ customSetting: setting,
+ updatedDate: new Date()
+ })
+ .where(eq(userCustomData.id, existing.id));
+ } else {
+ await db.insert(userCustomData).values({
+ userId,
+ tableKey,
+ customSettingName: name,
+ customSetting: setting,
+ });
+ }
+
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to save preset:", error);
+ return { success: false, error: "Failed to save preset" };
+ }
+}
+
+export async function deletePreset(id: string): Promise<{ success: boolean; error?: string }> {
+ try {
+ await db.delete(userCustomData).where(eq(userCustomData.id, id));
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to delete preset:", error);
+ return { success: false, error: "Failed to delete preset" };
+ }
+}
+
+
diff --git a/components/client-table-v3/preset-types.ts b/components/client-table-v3/preset-types.ts
new file mode 100644
index 00000000..37177cff
--- /dev/null
+++ b/components/client-table-v3/preset-types.ts
@@ -0,0 +1,15 @@
+export interface Preset {
+ id: string;
+ name: string;
+ setting: any; // JSON object for table state
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export interface PresetRepository {
+ getPresets(tableKey: string, userId: number): Promise<{ success: boolean; data?: Preset[]; error?: string }>;
+ savePreset(userId: number, tableKey: string, name: string, setting: any): Promise<{ success: boolean; error?: string }>;
+ deletePreset(id: string): Promise<{ success: boolean; error?: string }>;
+}
+
+
diff --git a/components/client-table-v3/types.ts b/components/client-table-v3/types.ts
new file mode 100644
index 00000000..4f2d8c82
--- /dev/null
+++ b/components/client-table-v3/types.ts
@@ -0,0 +1,84 @@
+import {
+ ColumnDef,
+ RowData,
+ Table,
+ PaginationState,
+ SortingState,
+ ColumnFiltersState,
+ VisibilityState,
+ ColumnPinningState,
+ ColumnOrderState,
+ GroupingState,
+ ExpandedState,
+ RowSelectionState,
+ OnChangeFn,
+ Row,
+ Column,
+} from "@tanstack/react-table";
+
+// --- Column Meta ---
+export interface ClientTableColumnMeta {
+ filterType?: "text" | "select" | "boolean" | "date-range"; // Added date-range
+ filterOptions?: { label: string; value: string }[];
+ serverGroupable?: boolean; // For Pattern 2-B
+}
+
+export type ClientTableColumnDef<TData extends RowData, TValue = unknown> = ColumnDef<TData, TValue> & {
+ meta?: ClientTableColumnMeta;
+};
+
+// --- Fetcher Types ---
+export interface TableState {
+ pagination: PaginationState;
+ sorting: SortingState;
+ columnFilters: ColumnFiltersState;
+ globalFilter: string;
+ grouping: GroupingState;
+ expanded: ExpandedState;
+}
+
+export interface FetcherResult<TData> {
+ data: TData[];
+ totalRows: number;
+ pageCount?: number;
+ groups?: any[]; // For grouping response
+}
+
+export type TableFetcher<TData> = (
+ state: TableState,
+ additionalArgs?: any
+) => Promise<FetcherResult<TData>>;
+
+// --- Component Props ---
+export interface ClientVirtualTableProps<TData> {
+ table: Table<TData>;
+ isLoading?: boolean;
+ height?: string | number;
+ estimateRowHeight?: number;
+ className?: string;
+
+ // UI Features
+ actions?: React.ReactNode;
+ customToolbar?: React.ReactNode;
+ enableExport?: boolean;
+ onExport?: (data: TData[]) => void;
+
+ // Preset
+ enableUserPreset?: boolean;
+ tableKey?: string;
+
+ // Styling
+ getRowClassName?: (originalRow: TData, index: number) => string;
+ onRowClick?: (row: Row<TData>, event: React.MouseEvent) => void;
+
+ // Visuals
+ renderHeaderVisualFeedback?: (props: {
+ column: Column<TData, unknown>;
+ isPinned: boolean | string;
+ isSorted: boolean | string;
+ isFiltered: boolean;
+ isGrouped: boolean;
+ }) => React.ReactNode;
+}
+
+
diff --git a/components/client-table-v3/use-client-table.ts b/components/client-table-v3/use-client-table.ts
new file mode 100644
index 00000000..87ce8a78
--- /dev/null
+++ b/components/client-table-v3/use-client-table.ts
@@ -0,0 +1,283 @@
+import * as React from "react";
+import {
+ useReactTable,
+ getCoreRowModel,
+ getSortedRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getGroupedRowModel,
+ getExpandedRowModel,
+ getFacetedRowModel,
+ getFacetedUniqueValues,
+ getFacetedMinMaxValues,
+ Table,
+ ColumnDef,
+ SortingState,
+ ColumnFiltersState,
+ PaginationState,
+ VisibilityState,
+ ColumnPinningState,
+ ColumnOrderState,
+ GroupingState,
+ ExpandedState,
+ RowSelectionState,
+ OnChangeFn,
+ FilterFn,
+} from "@tanstack/react-table";
+import { rankItem } from "@tanstack/match-sorter-utils";
+import { TableFetcher, FetcherResult } from "./types";
+
+// --- Utils ---
+const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
+ const itemRank = rankItem(row.getValue(columnId), value);
+ addMeta({ itemRank });
+ return itemRank.passed;
+};
+
+// Simple debounce hook
+function useDebounce<T>(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = React.useState(value);
+ React.useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+ return () => clearTimeout(handler);
+ }, [value, delay]);
+ return debouncedValue;
+}
+
+// --- Props ---
+export interface UseClientTableProps<TData, TValue> {
+ // Data Source
+ data?: TData[]; // For client mode
+ fetcher?: TableFetcher<TData>; // For server mode
+ fetchMode?: "client" | "server";
+
+ // Columns
+ columns: ColumnDef<TData, TValue>[];
+
+ // Options
+ enableGrouping?: boolean;
+ enablePagination?: boolean;
+ enableRowSelection?: boolean | ((row: any) => boolean);
+ enableMultiRowSelection?: boolean | ((row: any) => boolean);
+
+ // Initial State (Optional overrides)
+ initialState?: {
+ pagination?: PaginationState;
+ sorting?: SortingState;
+ columnFilters?: ColumnFiltersState;
+ globalFilter?: string;
+ columnVisibility?: VisibilityState;
+ columnPinning?: ColumnPinningState;
+ columnOrder?: ColumnOrderState;
+ grouping?: GroupingState;
+ expanded?: ExpandedState;
+ rowSelection?: RowSelectionState;
+ };
+
+ // Callbacks
+ onDataChange?: (data: TData[]) => void;
+ onError?: (error: any) => void;
+
+ // Custom Row ID
+ getRowId?: (originalRow: TData, index: number, parent?: any) => string;
+}
+
+// --- Hook ---
+export function useClientTable<TData, TValue = unknown>({
+ data: initialData = [],
+ fetcher,
+ fetchMode = "client",
+ columns,
+ enableGrouping = false,
+ enablePagination = true,
+ enableRowSelection,
+ enableMultiRowSelection,
+ initialState,
+ onDataChange,
+ onError,
+ getRowId,
+}: UseClientTableProps<TData, TValue>) {
+ // 1. State Definitions
+ const [sorting, setSorting] = React.useState<SortingState>(initialState?.sorting ?? []);
+ const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(initialState?.columnFilters ?? []);
+ const [globalFilter, setGlobalFilter] = React.useState<string>(initialState?.globalFilter ?? "");
+ const [pagination, setPagination] = React.useState<PaginationState>(
+ initialState?.pagination ?? { pageIndex: 0, pageSize: 10 }
+ );
+ const [grouping, setGrouping] = React.useState<GroupingState>(initialState?.grouping ?? []);
+ const [expanded, setExpanded] = React.useState<ExpandedState>(initialState?.expanded ?? {});
+ const [rowSelection, setRowSelection] = React.useState<RowSelectionState>(initialState?.rowSelection ?? {});
+ const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(initialState?.columnVisibility ?? {});
+ const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>(
+ initialState?.columnPinning ?? { left: [], right: [] }
+ );
+ const [columnOrder, setColumnOrder] = React.useState<ColumnOrderState>(
+ initialState?.columnOrder ?? columns.map((c) => c.id || (c as any).accessorKey) as string[]
+ );
+
+ // 2. Data State
+ const [data, setData] = React.useState<TData[]>(initialData);
+ const [totalRows, setTotalRows] = React.useState<number>(initialData.length);
+ const [pageCount, setPageCount] = React.useState<number>(-1);
+ const [isLoading, setIsLoading] = React.useState<boolean>(false);
+
+ // Grouping specific data
+ // In Pattern 2-B, the server returns "groups" instead of flat data.
+ // We might need to store that separately or handle it within data if using TanStack's mix.
+ // For now, let's assume the fetcher returns a flat array or we handle groups manually in the component.
+ // But wait, client-virtual-table V2 handles groups by checking row.getIsGrouped().
+ // If fetchMode is server, and grouping is active, the server might return "groups".
+ // The V2 implementation handled this by setting `groups` state in the consumer and switching rendering.
+ // We want to encapsulate this.
+ const [serverGroups, setServerGroups] = React.useState<any[]>([]);
+ const [isServerGrouped, setIsServerGrouped] = React.useState(false);
+
+ const isServer = fetchMode === "server";
+
+ // Debounced states for fetching to avoid rapid-fire requests
+ const debouncedGlobalFilter = useDebounce(globalFilter, 300);
+ const debouncedColumnFilters = useDebounce(columnFilters, 300);
+ // Pagination and Sorting don't need debounce usually, but grouping might.
+
+ // 3. Data Fetching (Server Mode)
+ const refresh = React.useCallback(async () => {
+ if (!isServer || !fetcher) return;
+
+ setIsLoading(true);
+ try {
+ const result = await fetcher({
+ pagination,
+ sorting,
+ columnFilters: debouncedColumnFilters,
+ globalFilter: debouncedGlobalFilter,
+ grouping,
+ expanded,
+ });
+
+ if (result.groups) {
+ setServerGroups(result.groups);
+ setIsServerGrouped(true);
+ setData([]); // Clear flat data
+ } else {
+ setData(result.data);
+ setTotalRows(result.totalRows);
+ setPageCount(result.pageCount ?? -1);
+ setServerGroups([]);
+ setIsServerGrouped(false);
+ if (onDataChange) onDataChange(result.data);
+ }
+ } catch (err) {
+ console.error("Failed to fetch table data:", err);
+ if (onError) onError(err);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [
+ isServer,
+ fetcher,
+ pagination,
+ sorting,
+ debouncedColumnFilters,
+ debouncedGlobalFilter,
+ grouping,
+ expanded,
+ onDataChange,
+ onError,
+ ]);
+
+ // Initial fetch and refetch on state change
+ React.useEffect(() => {
+ if (isServer) {
+ refresh();
+ }
+ }, [refresh, isServer]);
+
+ // Update data when props change in Client Mode
+ React.useEffect(() => {
+ if (!isServer) {
+ setData(initialData);
+ setTotalRows(initialData.length);
+ }
+ }, [initialData, isServer]);
+
+ // 4. TanStack Table Instance
+ const table = useReactTable({
+ data,
+ columns,
+ state: {
+ sorting,
+ columnFilters,
+ globalFilter,
+ pagination,
+ columnVisibility,
+ columnPinning,
+ columnOrder,
+ rowSelection,
+ grouping,
+ expanded,
+ },
+ // Server-side Flags
+ manualPagination: isServer,
+ manualSorting: isServer,
+ manualFiltering: isServer,
+ manualGrouping: isServer,
+
+ // Counts
+ pageCount: isServer ? pageCount : undefined,
+ rowCount: isServer ? totalRows : undefined,
+
+ // Handlers
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onGlobalFilterChange: setGlobalFilter,
+ onPaginationChange: setPagination,
+ onColumnVisibilityChange: setColumnVisibility,
+ onColumnPinningChange: setColumnPinning,
+ onColumnOrderChange: setColumnOrder,
+ onRowSelectionChange: setRowSelection,
+ onGroupingChange: setGrouping,
+ onExpandedChange: setExpanded,
+
+ // Configs
+ enableRowSelection,
+ enableMultiRowSelection,
+ enableGrouping,
+ getCoreRowModel: getCoreRowModel(),
+
+ // Conditional Models (Client vs Server)
+ getFilteredRowModel: !isServer ? getFilteredRowModel() : undefined,
+ getFacetedRowModel: !isServer ? getFacetedRowModel() : undefined,
+ getFacetedUniqueValues: !isServer ? getFacetedUniqueValues() : undefined,
+ getFacetedMinMaxValues: !isServer ? getFacetedMinMaxValues() : undefined,
+ getSortedRowModel: !isServer ? getSortedRowModel() : undefined,
+ getGroupedRowModel: (!isServer && enableGrouping) ? getGroupedRowModel() : undefined,
+ getExpandedRowModel: (!isServer && enableGrouping) ? getExpandedRowModel() : undefined,
+ getPaginationRowModel: (!isServer && enablePagination) ? getPaginationRowModel() : undefined,
+
+ columnResizeMode: "onChange",
+ filterFns: {
+ fuzzy: fuzzyFilter,
+ },
+ globalFilterFn: fuzzyFilter,
+ getRowId,
+ });
+
+ return {
+ table,
+ data,
+ totalRows,
+ isLoading,
+ isServerGrouped,
+ serverGroups,
+ refresh,
+ // State setters if needed manually
+ setSorting,
+ setColumnFilters,
+ setPagination,
+ setGlobalFilter,
+ };
+}
+
+