summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-12-05 21:44:54 +0900
committerjoonhoekim <26rote@gmail.com>2025-12-05 21:44:54 +0900
commitcad2b30fdd43dc8fb405c6fdf91eb46a378f821a (patch)
tree1958721460f6bee4afdb611fef5b9d63c4d8be10
parent5fea18182821dfcc3203c5ea4bb0548ec995718a (diff)
(김준회) v2 오류수정 및 테스트페이지, 시딩스크립트 추가
-rw-r--r--app/[lng]/test/table-v2/actions.ts301
-rw-r--r--app/[lng]/test/table-v2/column-defs.ts60
-rw-r--r--app/[lng]/test/table-v2/columns.tsx212
-rw-r--r--app/[lng]/test/table-v2/page.tsx630
-rw-r--r--db/db.ts4
-rw-r--r--db/schema/index.ts5
-rw-r--r--db/schema/test-table-v2.ts139
-rw-r--r--db/seeds/test-table-v2.ts187
-rw-r--r--types/table.d.ts8
9 files changed, 1543 insertions, 3 deletions
diff --git a/app/[lng]/test/table-v2/actions.ts b/app/[lng]/test/table-v2/actions.ts
new file mode 100644
index 00000000..e1737083
--- /dev/null
+++ b/app/[lng]/test/table-v2/actions.ts
@@ -0,0 +1,301 @@
+"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 { 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;
+
+ // 커스텀 조인 쿼리 작성
+ 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(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..e7fb5bdd
--- /dev/null
+++ b/app/[lng]/test/table-v2/page.tsx
@@ -0,0 +1,630 @@
+"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=&quot;client&quot;</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%"
+ />
+ </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=&quot;server&quot;</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}
+ />
+ </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 [pagination, setPagination] = React.useState<PaginationState>({
+ pageIndex: 0,
+ pageSize: 10,
+ });
+
+ // 데이터 페칭
+ React.useEffect(() => {
+ const fetchData = async () => {
+ setIsLoading(true);
+ try {
+ const result = await getProductTableDataWithGrouping(
+ { pagination, grouping },
+ 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, 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=&quot;server&quot;</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}
+ // 그룹핑 상태 연결
+ 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=&quot;server&quot;</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 />
+ 헤더 우클릭 시 &quot;Group by [Column]&quot; 메뉴가 표시됩니다.
+ </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/db/db.ts b/db/db.ts
index 4a51d870..95e3d89a 100644
--- a/db/db.ts
+++ b/db/db.ts
@@ -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 6463e0ec..459cc9e4 100644
--- a/db/schema/index.ts
+++ b/db/schema/index.ts
@@ -91,4 +91,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/types/table.d.ts b/types/table.d.ts
index d4053cf1..37f687e5 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
}
}