diff options
31 files changed, 3937 insertions, 133 deletions
diff --git a/.cursor/rules/evcp-project-rules.mdc b/.cursor/rules/evcp-project-rules.mdc new file mode 100644 index 00000000..cbd3e44d --- /dev/null +++ b/.cursor/rules/evcp-project-rules.mdc @@ -0,0 +1,22 @@ +--- +alwaysApply: true +--- +1. tech stacks: nextjs 15, postgres 17 with drizzle-orm, shadcn-ui, react 18.3.1 full stack +2. user info: Intermediate English. Hardcoded text and comment can be written in English. + +specific: +- Above Nextjs 15, exported server action functions should be async function. +- Do not use shadcn ui ScrollArea Function in Dialog component. (It has error now.) +- Most packages are already installed. If package installation required, check package.json file first. + +design: +- If you can suppose some design patterns for solve the problem in prompt, notify it. +- Check component/common/* for shared components. +For these tasks, instruct the user rather than doing + +limit: +- About CLI task, just notify. User will operate CLI. For example, 'npx drizzle-kit generate & migrate', 'npm run dev'. +- You can't read and edit .env.* files. + +limit-solution: +- For limited tasks, instruct the user rather than doing them yourself.
\ No newline at end of file diff --git a/.cursor/rules/table-guide.mdc b/.cursor/rules/table-guide.mdc new file mode 100644 index 00000000..7b240413 --- /dev/null +++ b/.cursor/rules/table-guide.mdc @@ -0,0 +1,4 @@ +--- +alwaysApply: false +--- +If table management is required, see @/components/client-table-v2/GUIDE-v3.md
\ No newline at end of file diff --git a/app/[lng]/test/table-v2/actions.ts b/app/[lng]/test/table-v2/actions.ts new file mode 100644 index 00000000..f5fd5f66 --- /dev/null +++ b/app/[lng]/test/table-v2/actions.ts @@ -0,0 +1,326 @@ +"use server"; + +import db from "@/db/db"; +import { testProducts, testOrders, testCustomers } from "@/db/schema/test-table-v2"; +import { createTableService } from "@/components/client-table-v2/adapter/create-table-service"; +import { DrizzleTableState } from "@/components/client-table-v2/adapter/drizzle-table-adapter"; +import { productColumnDefs, OrderWithDetails, ServerColumnMeta } from "./column-defs"; +import { SQL, count, eq, desc, sql, asc } from "drizzle-orm"; +import { TestProduct } from "@/db/schema/test-table-v2"; + +// ============================================================ +// Pattern 1: Client-Side - 전체 데이터 로드 +// ============================================================ + +export async function getAllProducts() { + return await db.select().from(testProducts).orderBy(testProducts.id); +} + +// ============================================================ +// Pattern 2: Factory Service - 자동 생성된 서버 액션 +// ============================================================ + +// Server-side용 컬럼 정의 사용 (React 컴포넌트 없음) +export const getProductTableData = createTableService({ + db, + schema: testProducts, + columns: productColumnDefs, +}); + +// ============================================================ +// Pattern 2-B: Factory Service with Grouping Support +// ============================================================ + +/** + * 그룹 정보 타입 + */ +export interface GroupInfo { + groupKey: string; + groupValue: string | number | boolean | null; + count: number; + // 확장 시 로드된 하위 행들 + rows?: TestProduct[]; +} + +/** + * 그룹핑 응답 타입 + */ +export interface GroupedResponse { + groups: GroupInfo[]; + totalGroups: number; +} + +/** + * 일반 응답 타입 + */ +export interface NormalResponse { + data: TestProduct[]; + totalRows: number; + pageCount: number; +} + +/** + * 서버 사이드 그룹핑을 지원하는 상품 테이블 데이터 조회 + * + * @param tableState - 테이블 상태 (pagination, sorting, filters, grouping) + * @param expandedGroups - 확장된 그룹 키 목록 (예: ["category:Electronics", "status:active"]) + */ +export async function getProductTableDataWithGrouping( + tableState: DrizzleTableState, + expandedGroups: string[] = [] +): Promise<GroupedResponse | NormalResponse> { + const { grouping, pagination } = tableState; + + // 그룹핑이 없으면 일반 조회 + if (!grouping || grouping.length === 0) { + const result = await getProductTableData(tableState); + return result as NormalResponse; + } + + // 첫 번째 그룹핑 컬럼만 처리 (다중 그룹핑은 복잡도가 높음) + const groupColumnId = grouping[0]; + + // 서버 그룹핑 가능 여부 확인 + const columnDef = productColumnDefs.find( + col => 'accessorKey' in col && col.accessorKey === groupColumnId + ); + const meta = columnDef?.meta as ServerColumnMeta | undefined; + + if (!meta?.serverGroupable) { + // 서버 그룹핑 불가 - 전체 데이터 반환하여 클라이언트에서 처리 + console.warn(`Column "${groupColumnId}" does not support server grouping. Falling back to client-side.`); + const allData = await db.select().from(testProducts); + return { + data: allData, + totalRows: allData.length, + pageCount: 1, + }; + } + + // 그룹별 카운트 조회 + const groupColumn = getProductColumn(groupColumnId); + if (!groupColumn) { + throw new Error(`Unknown column: ${groupColumnId}`); + } + + const groupsResult = await db + .select({ + groupValue: groupColumn, + count: count(), + }) + .from(testProducts) + .groupBy(groupColumn) + .orderBy(asc(groupColumn)); + + // 그룹 정보 구성 + const groups: GroupInfo[] = await Promise.all( + groupsResult.map(async (g) => { + const groupKey = `${groupColumnId}:${g.groupValue}`; + const isExpanded = expandedGroups.includes(groupKey); + + let rows: TestProduct[] | undefined; + + // 확장된 그룹의 하위 행 로드 + if (isExpanded) { + rows = await db + .select() + .from(testProducts) + .where(eq(groupColumn, g.groupValue)) + .orderBy(testProducts.id) + .limit(pagination?.pageSize ?? 100); // 그룹 내 행 제한 + } + + return { + groupKey, + groupValue: g.groupValue, + count: Number(g.count), + rows, + }; + }) + ); + + return { + groups, + totalGroups: groups.length, + }; +} + +/** + * 컬럼 ID로 Drizzle 컬럼 객체 반환 + */ +function getProductColumn(columnId: string) { + const columnMap: Record<string, any> = { + id: testProducts.id, + sku: testProducts.sku, + name: testProducts.name, + category: testProducts.category, + price: testProducts.price, + stock: testProducts.stock, + status: testProducts.status, + isNew: testProducts.isNew, + createdAt: testProducts.createdAt, + updatedAt: testProducts.updatedAt, + }; + return columnMap[columnId]; +} + +// ============================================================ +// Pattern 3: Custom Service - 복잡한 조인 쿼리 +// ============================================================ + +export async function getOrderTableData(tableState: DrizzleTableState): Promise<{ + data: OrderWithDetails[]; + totalRows: number; + pageCount: number; +}> { + // Pattern 3에서는 DrizzleTableAdapter를 사용하지 않습니다. + // 조인된 결과의 컬럼들은 단일 테이블에 매핑되지 않기 때문입니다. + // 대신, 페이지네이션 값만 직접 계산합니다. + + const pageSize = tableState.pagination?.pageSize ?? 10; + const pageIndex = tableState.pagination?.pageIndex ?? 0; + const limit = pageSize; + const offset = pageIndex * pageSize; + + // Build ORDER BY clause based on sorting state + const orderByClauses = + tableState.sorting?.reduce<SQL<unknown>[]>((clauses, sort) => { + const columnMap: Record<string, any> = { + id: testOrders.id, + orderNumber: testOrders.orderNumber, + quantity: testOrders.quantity, + unitPrice: testOrders.unitPrice, + totalAmount: testOrders.totalAmount, + status: testOrders.status, + orderedAt: testOrders.orderedAt, + customerName: testCustomers.name, + customerEmail: testCustomers.email, + customerTier: testCustomers.tier, + productName: testProducts.name, + productSku: testProducts.sku, + }; + + const column = columnMap[sort.id]; + if (!column) return clauses; + + clauses.push(sort.desc ? desc(column) : asc(column)); + return clauses; + }, []) ?? []; + + // 커스텀 조인 쿼리 작성 + const data = await db + .select({ + id: testOrders.id, + orderNumber: testOrders.orderNumber, + quantity: testOrders.quantity, + unitPrice: testOrders.unitPrice, + totalAmount: testOrders.totalAmount, + status: testOrders.status, + orderedAt: testOrders.orderedAt, + // 고객 정보 조인 + customerName: testCustomers.name, + customerEmail: testCustomers.email, + customerTier: testCustomers.tier, + // 상품 정보 조인 + productName: testProducts.name, + productSku: testProducts.sku, + }) + .from(testOrders) + .leftJoin(testCustomers, eq(testOrders.customerId, testCustomers.id)) + .leftJoin(testProducts, eq(testOrders.productId, testProducts.id)) + .orderBy(...(orderByClauses.length > 0 ? orderByClauses : [desc(testOrders.orderedAt)])) + .limit(limit) + .offset(offset); + + // 총 개수 쿼리 + const totalResult = await db + .select({ count: count() }) + .from(testOrders); + + const totalRows = Number(totalResult[0]?.count ?? 0); + + return { + data: data as OrderWithDetails[], + totalRows, + pageCount: Math.ceil(totalRows / pageSize), + }; +} + +// ============================================================ +// Pattern 3-B: Custom Service with Grouping (Orders by Status) +// ============================================================ + +export interface OrderGroupInfo { + groupKey: string; + groupValue: string; + count: number; + totalAmount: number; + rows?: OrderWithDetails[]; +} + +/** + * 주문 데이터를 상태별로 그룹핑하여 조회 + */ +export async function getOrderTableDataGroupedByStatus( + expandedGroups: string[] = [] +): Promise<{ groups: OrderGroupInfo[]; totalGroups: number }> { + // 상태별 그룹 집계 + const groupsResult = await db + .select({ + status: testOrders.status, + count: count(), + totalAmount: sql<number>`SUM(${testOrders.totalAmount}::numeric)`, + }) + .from(testOrders) + .groupBy(testOrders.status) + .orderBy(testOrders.status); + + const groups: OrderGroupInfo[] = await Promise.all( + groupsResult.map(async (g) => { + const groupKey = `status:${g.status}`; + const isExpanded = expandedGroups.includes(groupKey); + + let rows: OrderWithDetails[] | undefined; + + if (isExpanded) { + // 확장된 그룹의 상세 주문 조회 (조인 포함) + const orderRows = await db + .select({ + id: testOrders.id, + orderNumber: testOrders.orderNumber, + quantity: testOrders.quantity, + unitPrice: testOrders.unitPrice, + totalAmount: testOrders.totalAmount, + status: testOrders.status, + orderedAt: testOrders.orderedAt, + customerName: testCustomers.name, + customerEmail: testCustomers.email, + customerTier: testCustomers.tier, + productName: testProducts.name, + productSku: testProducts.sku, + }) + .from(testOrders) + .leftJoin(testCustomers, eq(testOrders.customerId, testCustomers.id)) + .leftJoin(testProducts, eq(testOrders.productId, testProducts.id)) + .where(eq(testOrders.status, g.status)) + .orderBy(desc(testOrders.orderedAt)) + .limit(50); + + rows = orderRows as OrderWithDetails[]; + } + + return { + groupKey, + groupValue: g.status, + count: Number(g.count), + totalAmount: Number(g.totalAmount) || 0, + rows, + }; + }) + ); + + return { + groups, + totalGroups: groups.length, + }; +} diff --git a/app/[lng]/test/table-v2/column-defs.ts b/app/[lng]/test/table-v2/column-defs.ts new file mode 100644 index 00000000..3ece4287 --- /dev/null +++ b/app/[lng]/test/table-v2/column-defs.ts @@ -0,0 +1,60 @@ +/** + * Column Definitions for Server Actions + * + * 서버 액션에서 DrizzleTableAdapter가 사용할 컬럼 정의입니다. + * React 컴포넌트 없이 accessorKey만 정의합니다. + */ + +import { ColumnDef } from "@tanstack/react-table"; +import { TestProduct } from "@/db/schema/test-table-v2"; + +/** + * 서버 사이드 기능을 위한 컬럼 메타 정보 + */ +export interface ServerColumnMeta { + /** 서버에서 GROUP BY 가능 여부 (DB 컬럼에 직접 매핑되어야 함) */ + serverGroupable?: boolean; + /** 서버에서 정렬 가능 여부 */ + serverSortable?: boolean; + /** 서버에서 필터 가능 여부 */ + serverFilterable?: boolean; +} + +// === Product Columns (Server-side compatible) === +// DrizzleTableAdapter는 accessorKey만 사용하므로 cell renderer가 필요 없습니다. +// meta.serverGroupable로 서버 GROUP BY 지원 여부를 표시합니다. + +type ProductColumnDef = ColumnDef<TestProduct, any> & { meta?: ServerColumnMeta }; + +export const productColumnDefs: ProductColumnDef[] = [ + { accessorKey: "id", meta: { serverGroupable: false } }, // PK는 그룹핑 의미 없음 + { accessorKey: "sku", meta: { serverGroupable: false } }, // Unique 값 + { accessorKey: "name", meta: { serverGroupable: false } }, // 이름은 그룹핑 비효율 + { accessorKey: "category", meta: { serverGroupable: true } }, // ✅ 그룹핑 적합 + { accessorKey: "price", meta: { serverGroupable: false } }, + { accessorKey: "stock", meta: { serverGroupable: false } }, + { accessorKey: "status", meta: { serverGroupable: true } }, // ✅ 그룹핑 적합 + { accessorKey: "isNew", meta: { serverGroupable: true } }, // ✅ 그룹핑 적합 + { accessorKey: "createdAt", meta: { serverGroupable: false } }, + { accessorKey: "updatedAt", meta: { serverGroupable: false } }, +]; + +// === Order Columns for joined data (Pattern 3) === +// Custom Service에서는 DrizzleTableAdapter를 사용하지 않고 직접 쿼리합니다. +// 조인된 데이터의 컬럼은 단일 테이블에 매핑되지 않기 때문입니다. + +export type OrderWithDetails = { + id: number; + orderNumber: string; + quantity: number; + unitPrice: string; + totalAmount: string; + status: string; + orderedAt: Date; + customerName: string | null; + customerEmail: string | null; + customerTier: string | null; + productName: string | null; + productSku: string | null; +}; + diff --git a/app/[lng]/test/table-v2/columns.tsx b/app/[lng]/test/table-v2/columns.tsx new file mode 100644 index 00000000..703e9fd8 --- /dev/null +++ b/app/[lng]/test/table-v2/columns.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Badge } from "@/components/ui/badge"; +import { TestProduct } from "@/db/schema/test-table-v2"; +import { OrderWithDetails } from "./column-defs"; + +// === Product Columns (Pattern 1, 2) === +// meta.serverGroupable: 서버 사이드 GROUP BY 지원 여부 + +export const productColumns: ColumnDef<TestProduct>[] = [ + { + accessorKey: "id", + header: "ID", + size: 60, + enableGrouping: false, // 클라이언트 그룹핑도 비활성화 + meta: { serverGroupable: false }, + }, + { + accessorKey: "sku", + header: "SKU", + size: 100, + enableGrouping: false, + meta: { serverGroupable: false }, + }, + { + accessorKey: "name", + header: "Product Name", + size: 200, + enableGrouping: false, + meta: { serverGroupable: false }, + }, + { + accessorKey: "category", + header: "Category", + size: 120, + enableGrouping: true, // ✅ 그룹핑 가능 + meta: { serverGroupable: true }, + cell: ({ getValue }) => { + const category = getValue() as string; + return <Badge variant="outline">{category}</Badge>; + }, + }, + { + accessorKey: "price", + header: "Price", + size: 100, + enableGrouping: false, + meta: { serverGroupable: false }, + cell: ({ getValue }) => { + const price = parseFloat(getValue() as string); + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(price); + }, + }, + { + accessorKey: "stock", + header: "Stock", + size: 80, + enableGrouping: false, + meta: { serverGroupable: false }, + cell: ({ getValue }) => { + const stock = getValue() as number; + return ( + <span className={stock < 10 ? "text-red-500 font-medium" : ""}> + {stock} + </span> + ); + }, + }, + { + accessorKey: "status", + header: "Status", + size: 110, + enableGrouping: true, // ✅ 그룹핑 가능 + meta: { serverGroupable: true }, + cell: ({ getValue }) => { + const status = getValue() as string; + const variants: Record<string, "default" | "secondary" | "destructive"> = { + active: "default", + inactive: "secondary", + discontinued: "destructive", + }; + return <Badge variant={variants[status] || "secondary"}>{status}</Badge>; + }, + }, + { + accessorKey: "isNew", + header: "New", + size: 60, + enableGrouping: true, // ✅ 그룹핑 가능 + meta: { serverGroupable: true }, + cell: ({ getValue }) => { + const isNew = getValue() as boolean; + return isNew ? <Badge className="bg-emerald-500">NEW</Badge> : null; + }, + }, + { + accessorKey: "createdAt", + header: "Created", + size: 110, + enableGrouping: false, + meta: { serverGroupable: false }, + cell: ({ getValue }) => { + const date = getValue() as Date; + return date ? new Date(date).toLocaleDateString() : "-"; + }, + }, +]; + +// === Order Columns with joined data (Pattern 3 - Custom Service) === + +export const orderColumns: ColumnDef<OrderWithDetails>[] = [ + { + accessorKey: "id", + header: "ID", + size: 60, + }, + { + accessorKey: "orderNumber", + header: "Order #", + size: 140, + cell: ({ getValue }) => ( + <span className="font-mono text-xs">{getValue() as string}</span> + ), + }, + { + accessorKey: "customerName", + header: "Customer", + size: 150, + }, + { + accessorKey: "customerTier", + header: "Tier", + size: 90, + cell: ({ getValue }) => { + const tier = getValue() as string; + if (!tier) return "-"; + const colors: Record<string, string> = { + standard: "bg-gray-500", + premium: "bg-blue-500", + vip: "bg-amber-500", + }; + return ( + <Badge className={colors[tier] || "bg-gray-500"}> + {tier.toUpperCase()} + </Badge> + ); + }, + }, + { + accessorKey: "productName", + header: "Product", + size: 180, + }, + { + accessorKey: "quantity", + header: "Qty", + size: 60, + }, + { + accessorKey: "totalAmount", + header: "Total", + size: 100, + cell: ({ getValue }) => { + const amount = parseFloat(getValue() as string); + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(amount); + }, + }, + { + accessorKey: "status", + header: "Status", + size: 110, + cell: ({ getValue }) => { + const status = getValue() as string; + const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = { + pending: "outline", + processing: "secondary", + shipped: "default", + delivered: "default", + cancelled: "destructive", + }; + const colors: Record<string, string> = { + delivered: "bg-emerald-500", + shipped: "bg-blue-500", + }; + return ( + <Badge + variant={variants[status] || "secondary"} + className={colors[status] || ""} + > + {status} + </Badge> + ); + }, + }, + { + accessorKey: "orderedAt", + header: "Order Date", + size: 110, + cell: ({ getValue }) => { + const date = getValue() as Date; + return date ? new Date(date).toLocaleDateString() : "-"; + }, + }, +]; + diff --git a/app/[lng]/test/table-v2/page.tsx b/app/[lng]/test/table-v2/page.tsx new file mode 100644 index 00000000..65c0ee1d --- /dev/null +++ b/app/[lng]/test/table-v2/page.tsx @@ -0,0 +1,637 @@ +"use client"; + +import * as React from "react"; +import { PaginationState, SortingState, ColumnFiltersState, GroupingState } from "@tanstack/react-table"; +import { ClientVirtualTable } from "@/components/client-table-v2/client-virtual-table"; +import { TestProduct } from "@/db/schema/test-table-v2"; +import { productColumns, orderColumns } from "./columns"; +import { OrderWithDetails } from "./column-defs"; +import { + getAllProducts, + getProductTableData, + getOrderTableData, + getProductTableDataWithGrouping, + GroupInfo, +} from "./actions"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { ChevronDown, ChevronRight, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +// ============================================================ +// Reusable Loading Overlay Component +// ============================================================ + +function LoadingOverlay({ + isLoading, + children +}: { + isLoading: boolean; + children: React.ReactNode +}) { + return ( + <div className="relative"> + {children} + {isLoading && ( + <div className="absolute inset-0 z-50 flex items-center justify-center bg-background/60 backdrop-blur-[2px] transition-all duration-200"> + <div className="flex items-center gap-2 px-4 py-2 bg-background rounded-lg shadow-lg border"> + <Loader2 className="h-5 w-5 animate-spin text-primary" /> + <span className="text-sm text-muted-foreground">Loading...</span> + </div> + </div> + )} + </div> + ); +} + +// ============================================================ +// Pattern 1: Client-Side Table +// ============================================================ + +function ClientSideTable() { + const [data, setData] = React.useState<TestProduct[]>([]); + const [isLoading, setIsLoading] = React.useState(true); + + React.useEffect(() => { + const fetchData = async () => { + setIsLoading(true); + try { + const products = await getAllProducts(); + setData(products); + } catch (error) { + console.error("Failed to fetch products:", error); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, []); + + return ( + <Card> + <CardHeader> + <div className="flex items-center gap-2"> + <CardTitle>Pattern 1: Client-Side</CardTitle> + <Badge variant="outline">fetchMode="client"</Badge> + </div> + <CardDescription> + 모든 데이터를 한 번에 받아와 클라이언트에서 필터링/정렬/페이지네이션/그룹핑 처리합니다. + <br /> + <span className="text-muted-foreground"> + 적합: 데이터 1000건 이하, 빠른 인터랙션 필요 시 + </span> + <br /> + <span className="text-emerald-600 text-sm"> + ✅ 그룹핑: 헤더 우클릭 → Group by [Column] + </span> + </CardDescription> + </CardHeader> + <CardContent> + <LoadingOverlay isLoading={isLoading}> + <div className="h-[500px]"> + <ClientVirtualTable + fetchMode="client" + data={data} + columns={productColumns} + isLoading={false} // LoadingOverlay로 처리 + enablePagination + enableGrouping + height="100%" + enableUserPreset={true} + tableKey="test-table-v2-pattern1" + /> + </div> + </LoadingOverlay> + </CardContent> + </Card> + ); +} + +// ============================================================ +// Pattern 2: Factory Service (Server-Side) +// ============================================================ + +function FactoryServiceTable() { + const [data, setData] = React.useState<TestProduct[]>([]); + const [totalRows, setTotalRows] = React.useState(0); + const [isLoading, setIsLoading] = React.useState(true); + + // Table state + const [pagination, setPagination] = React.useState<PaginationState>({ + pageIndex: 0, + pageSize: 10, + }); + const [sorting, setSorting] = React.useState<SortingState>([]); + const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]); + const [globalFilter, setGlobalFilter] = React.useState(""); + + // Fetch data on state change + React.useEffect(() => { + const fetchData = async () => { + setIsLoading(true); + try { + const result = await getProductTableData({ + pagination, + sorting, + columnFilters, + globalFilter, + }); + setData(result.data); + setTotalRows(result.totalRows); + } catch (error) { + console.error("Failed to fetch products:", error); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, [pagination, sorting, columnFilters, globalFilter]); + + return ( + <Card> + <CardHeader> + <div className="flex items-center gap-2"> + <CardTitle>Pattern 2: Factory Service</CardTitle> + <Badge variant="outline">fetchMode="server"</Badge> + <Badge variant="secondary">createTableService</Badge> + </div> + <CardDescription> + <code>createTableService</code>로 서버 액션을 자동 생성합니다. + <br /> + <span className="text-muted-foreground"> + 적합: 단순 CRUD, 마스터 테이블 조회 + </span> + <br /> + <span className="text-amber-600 text-sm"> + ⚠️ 그룹핑: 서버 모드에서는 별도 구현 필요 (Pattern 2-B 참고) + </span> + </CardDescription> + </CardHeader> + <CardContent> + <LoadingOverlay isLoading={isLoading}> + <div className="h-[500px]"> + <ClientVirtualTable + fetchMode="server" + data={data} + rowCount={totalRows} + columns={productColumns} + isLoading={false} + enablePagination + enableGrouping={false} + height="100%" + pagination={pagination} + onPaginationChange={setPagination} + sorting={sorting} + onSortingChange={setSorting} + columnFilters={columnFilters} + onColumnFiltersChange={setColumnFilters} + globalFilter={globalFilter} + onGlobalFilterChange={setGlobalFilter} + enableUserPreset={true} + tableKey="test-table-v2-pattern-2-A" + /> + </div> + </LoadingOverlay> + </CardContent> + </Card> + ); +} + +// ============================================================ +// Pattern 2-B: Server-Side Grouping (Context Menu 방식) +// ============================================================ + +function ServerGroupingTable() { + const [grouping, setGrouping] = React.useState<GroupingState>([]); + const [expandedGroups, setExpandedGroups] = React.useState<string[]>([]); + const [groups, setGroups] = React.useState<GroupInfo[]>([]); + const [flatData, setFlatData] = React.useState<TestProduct[]>([]); + const [isGrouped, setIsGrouped] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(true); + const [totalRows, setTotalRows] = React.useState(0); + const [sorting, setSorting] = React.useState<SortingState>([]); + + const [pagination, setPagination] = React.useState<PaginationState>({ + pageIndex: 0, + pageSize: 10, + }); + + // 데이터 페칭 + React.useEffect(() => { + const fetchData = async () => { + setIsLoading(true); + try { + const result = await getProductTableDataWithGrouping( + { pagination, grouping, sorting }, + expandedGroups + ); + + if ('groups' in result) { + setGroups(result.groups); + setIsGrouped(true); + setFlatData([]); + } else { + setFlatData(result.data); + setTotalRows(result.totalRows); + setIsGrouped(false); + setGroups([]); + } + } catch (error) { + console.error("Failed to fetch:", error); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, [pagination, grouping, sorting, expandedGroups]); + + // 그룹 토글 + const toggleGroup = (groupKey: string) => { + setExpandedGroups(prev => + prev.includes(groupKey) + ? prev.filter(k => k !== groupKey) + : [...prev, groupKey] + ); + }; + + // 그룹핑 상태 변경 핸들러 (Context Menu에서 호출됨) + const handleGroupingChange = React.useCallback((updater: GroupingState | ((old: GroupingState) => GroupingState)) => { + const newGrouping = typeof updater === 'function' ? updater(grouping) : updater; + setGrouping(newGrouping); + setExpandedGroups([]); // 그룹핑 변경 시 확장 상태 초기화 + }, [grouping]); + + return ( + <Card> + <CardHeader> + <div className="flex items-center gap-2"> + <CardTitle>Pattern 2-B: Server-Side Grouping</CardTitle> + <Badge variant="outline">fetchMode="server"</Badge> + <Badge className="bg-emerald-500">GROUP BY</Badge> + </div> + <CardDescription> + 서버에서 GROUP BY + 집계 쿼리로 그룹 정보를 조회합니다. + <br /> + <span className="text-emerald-600 text-sm"> + ✅ 그룹핑: 헤더 우클릭 → Group by [Column] (category, status, isNew만 지원) + </span> + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {/* 현재 그룹핑 상태 표시 */} + {grouping.length > 0 && ( + <div className="flex items-center gap-2 text-sm"> + <span className="text-muted-foreground">Grouped by:</span> + {grouping.map((col) => ( + <Badge key={col} variant="secondary"> + {col} + <button + className="ml-1 hover:text-destructive" + onClick={() => setGrouping([])} + > + × + </button> + </Badge> + ))} + </div> + )} + + {/* Content with Loading Overlay */} + <LoadingOverlay isLoading={isLoading}> + <div className="border rounded-md min-h-[400px] max-h-[500px] overflow-auto"> + {isGrouped ? ( + // Grouped View - Custom Rendering + <div className="divide-y"> + {groups.length === 0 ? ( + <div className="flex items-center justify-center h-[400px] text-muted-foreground"> + No data + </div> + ) : ( + groups.map((group) => ( + <div key={group.groupKey}> + {/* Group Header */} + <button + className="w-full px-4 py-3 flex items-center gap-3 hover:bg-muted/50 transition-colors text-left" + onClick={() => toggleGroup(group.groupKey)} + > + {expandedGroups.includes(group.groupKey) ? ( + <ChevronDown className="w-4 h-4" /> + ) : ( + <ChevronRight className="w-4 h-4" /> + )} + <span className="font-medium"> + {grouping[0]}: <Badge variant="outline">{String(group.groupValue)}</Badge> + </span> + <span className="text-muted-foreground text-sm"> + ({group.count} items) + </span> + </button> + + {/* Expanded Rows */} + {expandedGroups.includes(group.groupKey) && group.rows && ( + <div className="bg-muted/20 border-t"> + <table className="w-full text-sm"> + <thead> + <tr className="border-b bg-muted/30"> + <th className="px-4 py-2 text-left">ID</th> + <th className="px-4 py-2 text-left">SKU</th> + <th className="px-4 py-2 text-left">Name</th> + <th className="px-4 py-2 text-left">Price</th> + <th className="px-4 py-2 text-left">Stock</th> + </tr> + </thead> + <tbody> + {group.rows.map((row) => ( + <tr key={row.id} className="border-b hover:bg-muted/30"> + <td className="px-4 py-2">{row.id}</td> + <td className="px-4 py-2 font-mono text-xs">{row.sku}</td> + <td className="px-4 py-2">{row.name}</td> + <td className="px-4 py-2"> + {new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(parseFloat(row.price))} + </td> + <td className="px-4 py-2">{row.stock}</td> + </tr> + ))} + </tbody> + </table> + </div> + )} + </div> + )) + )} + </div> + ) : ( + // Normal Table View with Context Menu Grouping + <ClientVirtualTable + fetchMode="server" + data={flatData} + rowCount={totalRows} + columns={productColumns} + enablePagination + enableGrouping // Context Menu에서 Group By 옵션 활성화 + height="400px" + pagination={pagination} + onPaginationChange={setPagination} + sorting={sorting} + onSortingChange={setSorting} + // 그룹핑 상태 연결 + grouping={grouping} + onGroupingChange={handleGroupingChange} + /> + )} + </div> + </LoadingOverlay> + </CardContent> + </Card> + ); +} + +// ============================================================ +// Pattern 3: Custom Service (Server-Side with Joins) +// ============================================================ + +function CustomServiceTable() { + const [data, setData] = React.useState<OrderWithDetails[]>([]); + const [totalRows, setTotalRows] = React.useState(0); + const [isLoading, setIsLoading] = React.useState(true); + + // Table state + const [pagination, setPagination] = React.useState<PaginationState>({ + pageIndex: 0, + pageSize: 10, + }); + const [sorting, setSorting] = React.useState<SortingState>([]); + const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]); + const [globalFilter, setGlobalFilter] = React.useState(""); + + // Fetch data on state change + React.useEffect(() => { + const fetchData = async () => { + setIsLoading(true); + try { + const result = await getOrderTableData({ + pagination, + sorting, + columnFilters, + globalFilter, + }); + setData(result.data); + setTotalRows(result.totalRows); + } catch (error) { + console.error("Failed to fetch orders:", error); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, [pagination, sorting, columnFilters, globalFilter]); + + return ( + <Card> + <CardHeader> + <div className="flex items-center gap-2"> + <CardTitle>Pattern 3: Custom Service</CardTitle> + <Badge variant="outline">fetchMode="server"</Badge> + <Badge variant="secondary">DrizzleTableAdapter</Badge> + </div> + <CardDescription> + <code>DrizzleTableAdapter</code>를 도구로 사용하여 복잡한 조인 쿼리를 직접 작성합니다. + <br /> + <span className="text-muted-foreground"> + 적합: 여러 테이블 조인, 복잡한 비즈니스 로직 + </span> + <br /> + <span className="text-amber-600 text-sm"> + ⚠️ 그룹핑: 가상 컬럼(조인 결과)은 서버 GROUP BY 불가 + </span> + </CardDescription> + </CardHeader> + <CardContent> + <LoadingOverlay isLoading={isLoading}> + <div className="h-[500px]"> + <ClientVirtualTable + fetchMode="server" + data={data} + rowCount={totalRows} + columns={orderColumns} + isLoading={false} + enablePagination + enableGrouping={false} + height="100%" + pagination={pagination} + onPaginationChange={setPagination} + sorting={sorting} + onSortingChange={setSorting} + columnFilters={columnFilters} + onColumnFiltersChange={setColumnFilters} + globalFilter={globalFilter} + onGlobalFilterChange={setGlobalFilter} + /> + </div> + </LoadingOverlay> + </CardContent> + </Card> + ); +} + +// ============================================================ +// Main Page +// ============================================================ + +export default function TableV2TestPage() { + return ( + <div className="container py-6 space-y-6"> + <div> + <h1 className="text-3xl font-bold tracking-tight"> + ClientVirtualTable V2 - 데이터 페칭 패턴 테스트 + </h1> + <p className="text-muted-foreground mt-2"> + GUIDE.md에 정의된 데이터 페칭 패턴과 그룹핑 처리 방법을 테스트합니다. + <br /> + 테스트 전 시딩이 필요합니다: <code className="bg-muted px-1 rounded">npx tsx db/seeds/test-table-v2.ts</code> + </p> + </div> + + <Tabs defaultValue="pattern1" className="space-y-4"> + <TabsList className="grid w-full grid-cols-4"> + <TabsTrigger value="pattern1"> + 1. Client-Side + </TabsTrigger> + <TabsTrigger value="pattern2"> + 2. Factory Service + </TabsTrigger> + <TabsTrigger value="pattern2b"> + 2-B. Server Grouping + </TabsTrigger> + <TabsTrigger value="pattern3"> + 3. Custom Service + </TabsTrigger> + </TabsList> + + <TabsContent value="pattern1"> + <ClientSideTable /> + </TabsContent> + + <TabsContent value="pattern2"> + <FactoryServiceTable /> + </TabsContent> + + <TabsContent value="pattern2b"> + <ServerGroupingTable /> + </TabsContent> + + <TabsContent value="pattern3"> + <CustomServiceTable /> + </TabsContent> + </Tabs> + + {/* Summary Table */} + <Card> + <CardHeader> + <CardTitle>패턴별 그룹핑 지원 현황</CardTitle> + </CardHeader> + <CardContent> + <div className="overflow-x-auto"> + <table className="w-full text-sm"> + <thead> + <tr className="border-b"> + <th className="text-left py-2 px-4">패턴</th> + <th className="text-left py-2 px-4">그룹핑 방식</th> + <th className="text-left py-2 px-4">가상 컬럼 지원</th> + <th className="text-left py-2 px-4">비고</th> + </tr> + </thead> + <tbody> + <tr className="border-b"> + <td className="py-2 px-4 font-medium">1. Client-Side</td> + <td className="py-2 px-4"> + <Badge className="bg-emerald-500">TanStack Grouping</Badge> + </td> + <td className="py-2 px-4"> + <Badge className="bg-emerald-500">✓ 지원</Badge> + </td> + <td className="py-2 px-4 text-muted-foreground"> + 메모리에서 처리, 전체 데이터 필요 + </td> + </tr> + <tr className="border-b"> + <td className="py-2 px-4 font-medium">2. Factory Service</td> + <td className="py-2 px-4"> + <Badge variant="outline">미지원</Badge> + </td> + <td className="py-2 px-4">-</td> + <td className="py-2 px-4 text-muted-foreground"> + 별도 구현 필요 (2-B 참고) + </td> + </tr> + <tr className="border-b"> + <td className="py-2 px-4 font-medium">2-B. Server Grouping</td> + <td className="py-2 px-4"> + <Badge className="bg-blue-500">DB GROUP BY</Badge> + </td> + <td className="py-2 px-4"> + <Badge variant="destructive">✗ 불가</Badge> + </td> + <td className="py-2 px-4 text-muted-foreground"> + serverGroupable 컬럼만 가능 + </td> + </tr> + <tr> + <td className="py-2 px-4 font-medium">3. Custom Service</td> + <td className="py-2 px-4"> + <Badge variant="secondary">커스텀 구현</Badge> + </td> + <td className="py-2 px-4"> + <Badge variant="secondary">선택적</Badge> + </td> + <td className="py-2 px-4 text-muted-foreground"> + 쿼리 설계에 따라 다름 + </td> + </tr> + </tbody> + </table> + </div> + </CardContent> + </Card> + + {/* Column Groupability Info */} + <Card> + <CardHeader> + <CardTitle>컬럼별 서버 그룹핑 지원 여부</CardTitle> + <CardDescription> + <code>meta.serverGroupable</code> 플래그로 DB GROUP BY 가능 여부를 표시합니다. + <br /> + 헤더 우클릭 시 "Group by [Column]" 메뉴가 표시됩니다. + </CardDescription> + </CardHeader> + <CardContent> + <div className="flex flex-wrap gap-2"> + {productColumns.map((col) => { + if (!('accessorKey' in col)) return null; + const meta = col.meta as { serverGroupable?: boolean } | undefined; + const isGroupable = meta?.serverGroupable; + return ( + <Badge + key={col.accessorKey as string} + variant={isGroupable ? "default" : "outline"} + className={isGroupable ? "bg-emerald-500" : ""} + > + {col.accessorKey as string} + {isGroupable && " ✓"} + </Badge> + ); + })} + </div> + </CardContent> + </Card> + </div> + ); +} diff --git a/app/[lng]/test/table-v3/page.tsx b/app/[lng]/test/table-v3/page.tsx new file mode 100644 index 00000000..eccf7cff --- /dev/null +++ b/app/[lng]/test/table-v3/page.tsx @@ -0,0 +1,188 @@ +"use client"; + +import * as React from "react"; +import { useClientTable, ClientVirtualTable } from "@/components/client-table-v3"; +import { productColumns } from "../table-v2/columns"; +import { + getProductTableData, + getAllProducts, + getProductTableDataWithGrouping, + GroupInfo +} from "../table-v2/actions"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { ChevronDown, ChevronRight } from "lucide-react"; + +// --- Components for Examples --- + +function ClientSideExample() { + const [products, setProducts] = React.useState<any[]>([]); + + // Load initial data once + React.useEffect(() => { + getAllProducts().then(setProducts); + }, []); + + // Hook handles table state + const { table, isLoading } = useClientTable({ + fetchMode: "client", + data: products, + columns: productColumns, + enablePagination: true, + enableGrouping: true, + }); + + return ( + <Card> + <CardHeader> + <CardTitle>Pattern 1: Client-Side (V3)</CardTitle> + <CardDescription> + Uses `useClientTable` hook for simplified state management. + </CardDescription> + </CardHeader> + <CardContent className="h-[500px]"> + <ClientVirtualTable + table={table} + isLoading={isLoading} + enableUserPreset + tableKey="v3-client-pattern" + /> + </CardContent> + </Card> + ); +} + +function ServerFactoryExample() { + // Hook handles everything: state, fetching, debouncing + const { table, isLoading } = useClientTable({ + fetchMode: "server", + fetcher: getProductTableData, + columns: productColumns, + enablePagination: true, + enableUserPreset: true, // We can enable this in options too? No, hook doesn't care. Component cares. + }); + + return ( + <Card> + <CardHeader> + <CardTitle>Pattern 2: Factory Service (V3)</CardTitle> + <CardDescription> + Zero boilerplate state management in the component. + </CardDescription> + </CardHeader> + <CardContent className="h-[500px]"> + <ClientVirtualTable + table={table} + isLoading={isLoading} + enableUserPreset + tableKey="v3-server-pattern" + /> + </CardContent> + </Card> + ); +} + +function ServerGroupingExample() { + // Adapter for V2 fetcher signature to work with V3 hook + // The V2 action expects (state, expandedGroups), but V3 hook passes (state). + // We wrap it to extract expandedGroups from state.expanded. + const fetcher = React.useCallback((state: any) => { + const expanded = state.expanded || {}; + // Convert TanStack ExpandedState { [key]: true } to string[] + const expandedKeys = Object.keys(expanded).filter(k => expanded[k]); + return getProductTableDataWithGrouping(state, expandedKeys); + }, []); + + // Pattern 2-B support + const { + table, + isLoading, + isServerGrouped, + serverGroups, + refresh, + } = useClientTable({ + fetchMode: "server", + fetcher, + columns: productColumns, + enablePagination: true, + enableGrouping: true, + }); + + // When serverGroups change (new grouping), reset expansion + // (In a real app, you might want to persist expansion logic in the fetcher wrapper or hook) + + return ( + <Card> + <CardHeader> + <CardTitle>Pattern 2-B: Server Grouping (V3)</CardTitle> + <CardDescription> + Hook manages state, Component manages Custom Rendering for Groups. + </CardDescription> + </CardHeader> + <CardContent className="h-[500px] flex flex-col"> + {/* We need the toolbar even in grouped mode */} + <div className="mb-4 p-2 border rounded bg-muted/20"> + <p className="text-sm text-muted-foreground"> + Group by a column (Category, Status, IsNew) to see server grouping. + </p> + </div> + + {isServerGrouped ? ( + <div className="overflow-auto border rounded-md p-4 space-y-2"> + {serverGroups.map((group: GroupInfo) => ( + <div key={group.groupKey} className="border rounded p-2"> + <div className="font-bold flex items-center gap-2"> + <Badge variant="outline">{String(group.groupValue)}</Badge> + <span>({group.count})</span> + </div> + {/* Rows would go here */} + </div> + ))} + </div> + ) : ( + <ClientVirtualTable + table={table} + isLoading={isLoading} + enableUserPreset + tableKey="v3-server-grouping" + /> + )} + </CardContent> + </Card> + ); +} + +export default function TableV3Page() { + return ( + <div className="container py-8 space-y-8"> + <div> + <h1 className="text-3xl font-bold">ClientVirtualTable V3 DX Demo</h1> + <p className="text-muted-foreground"> + Demonstrating the new `useClientTable` hook for improved Developer Experience. + </p> + </div> + + <Tabs defaultValue="client"> + <TabsList> + <TabsTrigger value="client">Client-Side</TabsTrigger> + <TabsTrigger value="server">Server Factory</TabsTrigger> + <TabsTrigger value="grouping">Server Grouping</TabsTrigger> + </TabsList> + + <TabsContent value="client"> + <ClientSideExample /> + </TabsContent> + + <TabsContent value="server"> + <ServerFactoryExample /> + </TabsContent> + + <TabsContent value="grouping"> + <ServerGroupingExample /> + </TabsContent> + </Tabs> + </div> + ); +} + 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, + }; +} + + @@ -3,8 +3,8 @@ import { Pool } from 'pg'; import * as schema from './schema'; const pool = new Pool({ - connectionString: process.env.DATABASE_URL as string, - // connectionString: "postgresql://dts:dujinDTS2@localhost:5432/evcp", + // connectionString: process.env.DATABASE_URL as string, + connectionString: "postgresql://dts:dujinDTS2@localhost:5432/evcp", max: Number(process.env.DB_POOL_MAX) || 4, }); diff --git a/db/schema/index.ts b/db/schema/index.ts index 022431cc..da17b069 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -95,4 +95,7 @@ export * from './avl/avl'; export * from './avl/vendor-pool'; // === Email Logs 스키마 === export * from './emailLogs'; -export * from './emailWhitelist';
\ No newline at end of file +export * from './emailWhitelist'; + +// === Test Table V2 (테스트용 스키마) === +export * from './test-table-v2';
\ No newline at end of file diff --git a/db/schema/test-table-v2.ts b/db/schema/test-table-v2.ts new file mode 100644 index 00000000..37ccccbd --- /dev/null +++ b/db/schema/test-table-v2.ts @@ -0,0 +1,139 @@ +/** + * Test Table Schema for client-table-v2 테스트용 스키마 + * + * 3가지 패턴 테스트를 위한 테이블: + * 1. testProducts - 기본 상품 테이블 (Client-Side, Factory Service 패턴용) + * 2. testOrders - 주문 테이블 (Custom Service 패턴용 - 조인 테스트) + * 3. testCustomers - 고객 테이블 (Custom Service 패턴용 - 조인 테스트) + */ + +import { + integer, + serial, + pgTable, + varchar, + timestamp, + pgEnum, + text, + numeric, + boolean, + index, +} from "drizzle-orm/pg-core"; + +// === Enums === + +export const testProductStatusEnum = pgEnum("test_product_status", [ + "active", + "inactive", + "discontinued", +]); + +export const testOrderStatusEnum = pgEnum("test_order_status", [ + "pending", + "processing", + "shipped", + "delivered", + "cancelled", +]); + +// === Tables === + +/** + * 상품 테이블 - Client-Side 및 Factory Service 패턴 테스트용 + */ +export const testProducts = pgTable( + "test_products", + { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + name: varchar("name", { length: 255 }).notNull(), + sku: varchar("sku", { length: 50 }).notNull().unique(), + description: text("description"), + category: varchar("category", { length: 100 }).notNull(), + price: numeric("price", { precision: 10, scale: 2 }).notNull(), + stock: integer("stock").notNull().default(0), + status: testProductStatusEnum("status").notNull().default("active"), + isNew: boolean("is_new").default(false), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => { + return { + categoryIdx: index("test_products_category_idx").on(table.category), + statusIdx: index("test_products_status_idx").on(table.status), + }; + } +); + +/** + * 고객 테이블 - Custom Service 패턴의 조인 테스트용 + */ +export const testCustomers = pgTable( + "test_customers", + { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + name: varchar("name", { length: 255 }).notNull(), + email: varchar("email", { length: 255 }).notNull().unique(), + phone: varchar("phone", { length: 40 }), + country: varchar("country", { length: 100 }), + tier: varchar("tier", { length: 40 }).notNull().default("standard"), // standard, premium, vip + totalOrders: integer("total_orders").default(0), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => { + return { + tierIdx: index("test_customers_tier_idx").on(table.tier), + }; + } +); + +/** + * 주문 테이블 - Custom Service 패턴용 (고객/상품 조인) + */ +export const testOrders = pgTable( + "test_orders", + { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + orderNumber: varchar("order_number", { length: 50 }).notNull().unique(), + customerId: integer("customer_id") + .references(() => testCustomers.id, { onDelete: "cascade" }) + .notNull(), + productId: integer("product_id") + .references(() => testProducts.id, { onDelete: "set null" }), + quantity: integer("quantity").notNull().default(1), + unitPrice: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), + totalAmount: numeric("total_amount", { precision: 10, scale: 2 }).notNull(), + status: testOrderStatusEnum("status").notNull().default("pending"), + notes: text("notes"), + orderedAt: timestamp("ordered_at", { withTimezone: true }) + .defaultNow() + .notNull(), + shippedAt: timestamp("shipped_at", { withTimezone: true }), + deliveredAt: timestamp("delivered_at", { withTimezone: true }), + }, + (table) => { + return { + customerIdx: index("test_orders_customer_idx").on(table.customerId), + productIdx: index("test_orders_product_idx").on(table.productId), + statusIdx: index("test_orders_status_idx").on(table.status), + orderedAtIdx: index("test_orders_ordered_at_idx").on(table.orderedAt), + }; + } +); + +// === Types === + +export type TestProduct = typeof testProducts.$inferSelect; +export type NewTestProduct = typeof testProducts.$inferInsert; + +export type TestCustomer = typeof testCustomers.$inferSelect; +export type NewTestCustomer = typeof testCustomers.$inferInsert; + +export type TestOrder = typeof testOrders.$inferSelect; +export type NewTestOrder = typeof testOrders.$inferInsert; + diff --git a/db/seeds/test-table-v2.ts b/db/seeds/test-table-v2.ts new file mode 100644 index 00000000..07bf8914 --- /dev/null +++ b/db/seeds/test-table-v2.ts @@ -0,0 +1,187 @@ +/** + * Test Table V2 Seeding Script + * + * 사용법: + * npx tsx db/seeds/test-table-v2.ts + */ + +import db from "@/db/db"; +import { faker } from "@faker-js/faker"; +import { + testProducts, + testCustomers, + testOrders, + NewTestProduct, + NewTestCustomer, + NewTestOrder, +} from "../schema/test-table-v2"; + +// === Generators === + +const CATEGORIES = [ + "Electronics", + "Clothing", + "Home & Garden", + "Sports", + "Books", + "Toys", + "Food", + "Health", +]; + +const PRODUCT_STATUSES = ["active", "inactive", "discontinued"] as const; +const ORDER_STATUSES = ["pending", "processing", "shipped", "delivered", "cancelled"] as const; +const CUSTOMER_TIERS = ["standard", "premium", "vip"] as const; +const COUNTRIES = ["Korea", "USA", "Japan", "Germany", "UK", "France", "China", "Singapore"]; + +function generateProduct(): NewTestProduct { + const category = faker.helpers.arrayElement(CATEGORIES); + const price = faker.number.float({ min: 10, max: 1000, fractionDigits: 2 }); + + return { + name: faker.commerce.productName(), + sku: faker.string.alphanumeric({ length: 8, casing: "upper" }), + description: faker.commerce.productDescription(), + category, + price: price.toString(), + stock: faker.number.int({ min: 0, max: 500 }), + status: faker.helpers.arrayElement(PRODUCT_STATUSES), + isNew: faker.datatype.boolean({ probability: 0.2 }), + createdAt: faker.date.past({ years: 2 }), + updatedAt: faker.date.recent({ days: 30 }), + }; +} + +function generateCustomer(): NewTestCustomer { + return { + name: faker.person.fullName(), + email: faker.internet.email(), + phone: faker.phone.number(), + country: faker.helpers.arrayElement(COUNTRIES), + tier: faker.helpers.arrayElement(CUSTOMER_TIERS), + totalOrders: faker.number.int({ min: 0, max: 100 }), + createdAt: faker.date.past({ years: 3 }), + }; +} + +function generateOrder( + customerId: number, + productId: number | null, + productPrice: number +): NewTestOrder { + const quantity = faker.number.int({ min: 1, max: 10 }); + const unitPrice = productPrice || faker.number.float({ min: 10, max: 500, fractionDigits: 2 }); + const totalAmount = quantity * unitPrice; + const status = faker.helpers.arrayElement(ORDER_STATUSES); + const orderedAt = faker.date.past({ years: 1 }); + + let shippedAt: Date | undefined; + let deliveredAt: Date | undefined; + + if (status === "shipped" || status === "delivered") { + shippedAt = faker.date.between({ from: orderedAt, to: new Date() }); + } + if (status === "delivered") { + deliveredAt = faker.date.between({ from: shippedAt || orderedAt, to: new Date() }); + } + + return { + orderNumber: `ORD-${faker.string.alphanumeric({ length: 8, casing: "upper" })}`, + customerId, + productId, + quantity, + unitPrice: unitPrice.toString(), + totalAmount: totalAmount.toString(), + status, + notes: faker.datatype.boolean({ probability: 0.3 }) ? faker.lorem.sentence() : null, + orderedAt, + shippedAt, + deliveredAt, + }; +} + +// === Main Seeding Function === + +export async function seedTestTableV2(options: { + productCount?: number; + customerCount?: number; + orderCount?: number; +} = {}) { + const { + productCount = 100, + customerCount = 50, + orderCount = 200, + } = options; + + console.log("🗑️ Clearing existing test data..."); + + // Delete in order (orders first due to FK) + await db.delete(testOrders); + await db.delete(testCustomers); + await db.delete(testProducts); + + console.log(`📦 Generating ${productCount} products...`); + const products: NewTestProduct[] = []; + for (let i = 0; i < productCount; i++) { + products.push(generateProduct()); + } + const insertedProducts = await db.insert(testProducts).values(products).returning(); + console.log(`✅ Inserted ${insertedProducts.length} products`); + + console.log(`👥 Generating ${customerCount} customers...`); + const customers: NewTestCustomer[] = []; + for (let i = 0; i < customerCount; i++) { + customers.push(generateCustomer()); + } + const insertedCustomers = await db.insert(testCustomers).values(customers).returning(); + console.log(`✅ Inserted ${insertedCustomers.length} customers`); + + console.log(`🛒 Generating ${orderCount} orders...`); + const orders: NewTestOrder[] = []; + for (let i = 0; i < orderCount; i++) { + const customer = faker.helpers.arrayElement(insertedCustomers); + const product = faker.helpers.arrayElement(insertedProducts); + orders.push(generateOrder(customer.id, product.id, parseFloat(product.price))); + } + const insertedOrders = await db.insert(testOrders).values(orders).returning(); + console.log(`✅ Inserted ${insertedOrders.length} orders`); + + console.log("🎉 Test table v2 seeding completed!"); + + return { + products: insertedProducts.length, + customers: insertedCustomers.length, + orders: insertedOrders.length, + }; +} + +// === CLI Runner === + +async function main() { + console.log("⏳ Starting test-table-v2 seed..."); + const start = Date.now(); + + try { + const result = await seedTestTableV2({ + productCount: 100, + customerCount: 50, + orderCount: 200, + }); + + const end = Date.now(); + console.log(`\n📊 Summary:`); + console.log(` Products: ${result.products}`); + console.log(` Customers: ${result.customers}`); + console.log(` Orders: ${result.orders}`); + console.log(`\n✅ Seed completed in ${end - start}ms`); + } catch (err) { + console.error("❌ Seed failed:", err); + process.exit(1); + } + + process.exit(0); +} + +// Run if called directly +main(); + diff --git a/lib/table/server-query-builder.ts b/lib/table/server-query-builder.ts deleted file mode 100644 index 7ea25313..00000000 --- a/lib/table/server-query-builder.ts +++ /dev/null @@ -1,129 +0,0 @@ -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); - } -} diff --git a/package.json b/package.json index 3f5d0f5e..92c49e03 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,7 @@ "next-i18next": "^15.4.1", "next-themes": "^0.4.4", "node-cron": "^4.1.1", - "nodemailer": "^7.0.7", + "nodemailer": "^7.0.11", "nuqs": "^2.2.3", "oracledb": "^6.8.0", "pg": "^8.13.1", @@ -191,6 +191,8 @@ "@types/pg": "^8.11.10", "@types/react": "^19", "@types/react-dom": "^19", + "@types/react": "^19", + "@types/react-dom": "^19", "@types/sharp": "^0.31.1", "@types/ua-parser-js": "^0.7.39", "@types/uuid": "^10.0.0", diff --git a/types/table.d.ts b/types/table.d.ts index 9fc96687..266470e8 100644 --- a/types/table.d.ts +++ b/types/table.d.ts @@ -74,5 +74,13 @@ declare module '@tanstack/react-table' { paddingFactor?: number minWidth?: number maxWidth?: number + + // Server-side feature flags + /** 서버에서 GROUP BY 가능 여부 (DB 컬럼에 직접 매핑되어야 함) */ + serverGroupable?: boolean + /** 서버에서 정렬 가능 여부 */ + serverSortable?: boolean + /** 서버에서 필터 가능 여부 */ + serverFilterable?: boolean } } |
