diff options
| -rw-r--r-- | app/[lng]/test/table-v2/actions.ts | 301 | ||||
| -rw-r--r-- | app/[lng]/test/table-v2/column-defs.ts | 60 | ||||
| -rw-r--r-- | app/[lng]/test/table-v2/columns.tsx | 212 | ||||
| -rw-r--r-- | app/[lng]/test/table-v2/page.tsx | 630 | ||||
| -rw-r--r-- | db/db.ts | 4 | ||||
| -rw-r--r-- | db/schema/index.ts | 5 | ||||
| -rw-r--r-- | db/schema/test-table-v2.ts | 139 | ||||
| -rw-r--r-- | db/seeds/test-table-v2.ts | 187 | ||||
| -rw-r--r-- | types/table.d.ts | 8 |
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="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%" + /> + </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} + /> + </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="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} + // 그룹핑 상태 연결 + 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> + ); +} @@ -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 } } |
