summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-12-01 15:22:38 +0900
committerjoonhoekim <26rote@gmail.com>2025-12-01 15:22:38 +0900
commitffb8e2e99e1d0c105b1c545ff7ab4d3149ec6c48 (patch)
tree1af87b9c19bc56ed1192a5b5947d22fa5f4dbd98
parentd674b066a9a3195d764f693885fb9f25d66263ed (diff)
(김준회) 서버측 where, order by 절 지원을 위한 v2 임시작업
-rw-r--r--components/client-table-v2/client-table-column-header.tsx235
-rw-r--r--components/client-table-v2/client-table-filter.tsx101
-rw-r--r--components/client-table-v2/client-table-preset.tsx185
-rw-r--r--components/client-table-v2/client-table-save-view.tsx185
-rw-r--r--components/client-table-v2/client-table-toolbar.tsx59
-rw-r--r--components/client-table-v2/client-table-view-options.tsx67
-rw-r--r--components/client-table-v2/client-virtual-table.tsx626
-rw-r--r--components/client-table-v2/export-utils.ts136
-rw-r--r--components/client-table-v2/import-utils.ts100
-rw-r--r--components/client-table-v2/index.ts7
-rw-r--r--components/client-table-v2/preset-actions.ts87
-rw-r--r--components/client-table-v2/preset-types.ts13
12 files changed, 1801 insertions, 0 deletions
diff --git a/components/client-table-v2/client-table-column-header.tsx b/components/client-table-v2/client-table-column-header.tsx
new file mode 100644
index 00000000..2d8e5bce
--- /dev/null
+++ b/components/client-table-v2/client-table-column-header.tsx
@@ -0,0 +1,235 @@
+"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/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-v2/client-table-filter.tsx b/components/client-table-v2/client-table-filter.tsx
new file mode 100644
index 00000000..138f77eb
--- /dev/null
+++ b/components/client-table-v2/client-table-filter.tsx
@@ -0,0 +1,101 @@
+"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-v2/client-table-preset.tsx b/components/client-table-v2/client-table-preset.tsx
new file mode 100644
index 00000000..64930e7a
--- /dev/null
+++ b/components/client-table-v2/client-table-preset.tsx
@@ -0,0 +1,185 @@
+"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);
+
+ 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-v2/client-table-save-view.tsx b/components/client-table-v2/client-table-save-view.tsx
new file mode 100644
index 00000000..73935d00
--- /dev/null
+++ b/components/client-table-v2/client-table-save-view.tsx
@@ -0,0 +1,185 @@
+"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 {
+ getUserCustomSettings,
+ saveUserCustomSetting,
+ deleteUserCustomSetting,
+} from "@/actions/user-custom-data";
+import { toast } from "sonner";
+
+interface ClientTableSaveViewProps<TData> {
+ table: Table<TData>;
+ tableKey: string;
+}
+
+export function ClientTableSaveView<TData>({
+ table,
+ tableKey,
+}: ClientTableSaveViewProps<TData>) {
+ const { data: session } = useSession();
+ const [savedViews, setSavedViews] = React.useState<{ id: string; customSettingName: string; customSetting: Record<string, any> }[]>([]);
+ const [isSaveDialogOpen, setIsSaveDialogOpen] = React.useState(false);
+ const [newViewName, setNewViewName] = 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 getUserCustomSettings(tableKey, userId);
+ if (res.success && res.data) {
+ // @ts-ignore - data from DB might need casting
+ setSavedViews(res.data);
+ }
+ }, [session, tableKey]);
+
+ React.useEffect(() => {
+ if (session) {
+ fetchSettings();
+ }
+ }, [fetchSettings, session]);
+
+ const handleSaveView = async () => {
+ const userIdVal = session?.user?.id;
+ if (!newViewName.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 saveUserCustomSetting(userId, tableKey, newViewName, settingToSave);
+ setIsLoading(false);
+
+ if (res.success) {
+ toast.success("View saved successfully");
+ setIsSaveDialogOpen(false);
+ setNewViewName("");
+ fetchSettings();
+ } else {
+ toast.error("Failed to save view");
+ }
+ };
+
+ const handleLoadView = (setting: { customSetting: Record<string, any> | unknown; customSettingName: string }) => {
+ const s = setting.customSetting 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);
+
+ toast.success(`View "${setting.customSettingName}" loaded`);
+ };
+
+ const handleDeleteView = async (e: React.MouseEvent, id: string) => {
+ e.stopPropagation();
+ if (!confirm("Are you sure you want to delete this view?")) return;
+
+ const res = await deleteUserCustomSetting(id);
+ if (res.success) {
+ toast.success("View deleted");
+ fetchSettings();
+ } else {
+ toast.error("Failed to delete view");
+ }
+ };
+
+ 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" />
+ Views
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[200px]">
+ <DropdownMenuLabel>Saved Views</DropdownMenuLabel>
+ <DropdownMenuSeparator />
+ {savedViews.length === 0 ? (
+ <div className="p-2 text-sm text-muted-foreground text-center">No saved views</div>
+ ) : (
+ savedViews.map((view) => (
+ <DropdownMenuItem key={view.id} onClick={() => handleLoadView(view)} className="flex justify-between cursor-pointer">
+ <span className="truncate flex-1">{view.customSettingName}</span>
+ <Button variant="ghost" size="icon" className="h-4 w-4" onClick={(e) => handleDeleteView(e, view.id)}>
+ <Trash2 className="h-3 w-3 text-destructive" />
+ </Button>
+ </DropdownMenuItem>
+ ))
+ )}
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={() => setIsSaveDialogOpen(true)} className="cursor-pointer">
+ <Save className="mr-2 h-4 w-4" />
+ Save Current View
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ <Dialog open={isSaveDialogOpen} onOpenChange={setIsSaveDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Save View</DialogTitle>
+ <DialogDescription>
+ Save the current table configuration as a preset.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ <Input
+ placeholder="View Name"
+ value={newViewName}
+ onChange={(e) => setNewViewName(e.target.value)}
+ />
+ </div>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setIsSaveDialogOpen(false)}>Cancel</Button>
+ <Button onClick={handleSaveView} disabled={isLoading}>Save</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ );
+}
diff --git a/components/client-table-v2/client-table-toolbar.tsx b/components/client-table-v2/client-table-toolbar.tsx
new file mode 100644
index 00000000..089501e1
--- /dev/null
+++ b/components/client-table-v2/client-table-toolbar.tsx
@@ -0,0 +1,59 @@
+"use client"
+
+import * as React from "react"
+import { Search, Download } from "lucide-react"
+import { Input } from "@/components/ui/input"
+import { Button } from "@/components/ui/button"
+
+interface ClientTableToolbarProps {
+ globalFilter: string
+ setGlobalFilter: (value: string) => void
+ totalRows: number
+ visibleRows: number
+ onExport?: () => void
+ actions?: React.ReactNode
+ customToolbar?: React.ReactNode
+ viewOptions?: React.ReactNode
+}
+
+export function ClientTableToolbar({
+ globalFilter,
+ setGlobalFilter,
+ totalRows,
+ visibleRows,
+ onExport,
+ actions,
+ customToolbar,
+ viewOptions,
+}: ClientTableToolbarProps) {
+ return (
+ <div className="flex w-full items-center justify-between gap-4 p-1 overflow-x-auto">
+ <div className="flex items-center gap-2">
+ <div className="relative max-w-sm min-w-[200px]">
+ <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+ <Input
+ placeholder="Search all columns..."
+ value={globalFilter ?? ""}
+ onChange={(e) => setGlobalFilter(e.target.value)}
+ className="pl-8"
+ />
+ </div>
+ <div className="text-sm text-muted-foreground whitespace-nowrap">
+ Showing {visibleRows} of {totalRows}
+ </div>
+ {viewOptions}
+ {onExport && (
+ <Button onClick={onExport} variant="outline" size="sm">
+ <Download className="mr-2 h-4 w-4" />
+ Export
+ </Button>
+ )}
+ </div>
+
+ <div className="flex items-center gap-2 shrink-0">
+ {customToolbar}
+ {actions}
+ </div>
+ </div>
+ )
+}
diff --git a/components/client-table-v2/client-table-view-options.tsx b/components/client-table-v2/client-table-view-options.tsx
new file mode 100644
index 00000000..3b659fcd
--- /dev/null
+++ b/components/client-table-v2/client-table-view-options.tsx
@@ -0,0 +1,67 @@
+"use client"
+
+import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"
+import { MixerHorizontalIcon } from "@radix-ui/react-icons"
+import { Table } from "@tanstack/react-table"
+
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+} from "@/components/ui/dropdown-menu"
+
+interface ClientTableViewOptionsProps<TData> {
+ table: Table<TData>
+}
+
+export function ClientTableViewOptions<TData>({
+ table,
+}: ClientTableViewOptionsProps<TData>) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="ml-auto hidden h-8 lg:flex"
+ >
+ <MixerHorizontalIcon className="mr-2 h-4 w-4" />
+ View
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[150px]">
+ <DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
+ <DropdownMenuSeparator />
+ {table
+ .getAllLeafColumns()
+ .filter(
+ (column) =>
+ typeof column.accessorFn !== "undefined" && column.getCanHide()
+ )
+ .map((column) => {
+ const header = column.columnDef.header
+ let label = column.id
+ if (typeof header === "string") {
+ label = header
+ }
+
+ return (
+ <DropdownMenuCheckboxItem
+ key={column.id}
+ className="capitalize"
+ checked={column.getIsVisible()}
+ onCheckedChange={(value) => column.toggleVisibility(!!value)}
+ onSelect={(e) => e.preventDefault()} // default action close the select menu.
+ >
+ {label}
+ </DropdownMenuCheckboxItem>
+ )
+ })}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+}
+
diff --git a/components/client-table-v2/client-virtual-table.tsx b/components/client-table-v2/client-virtual-table.tsx
new file mode 100644
index 00000000..1713369f
--- /dev/null
+++ b/components/client-table-v2/client-virtual-table.tsx
@@ -0,0 +1,626 @@
+"use client"
+
+import * as React from "react"
+import { rankItem } from "@tanstack/match-sorter-utils"
+import {
+ useReactTable,
+ getCoreRowModel,
+ getSortedRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getGroupedRowModel,
+ getExpandedRowModel,
+ getFacetedRowModel,
+ getFacetedUniqueValues,
+ getFacetedMinMaxValues,
+ ColumnDef,
+ SortingState,
+ ColumnFiltersState,
+ flexRender,
+ PaginationState,
+ OnChangeFn,
+ ColumnOrderState,
+ VisibilityState,
+ ColumnPinningState,
+ FilterFn,
+ Table,
+ RowSelectionState,
+ Row,
+ Column,
+ GroupingState,
+ ExpandedState,
+} 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 { ClientTableColumnHeader } from "./client-table-column-header"
+import { ClientTableViewOptions } from "../client-table/client-table-view-options"
+import { ClientTablePreset } from "./client-table-preset"
+
+// Moved outside for stability (Performance Optimization)
+const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
+ const itemRank = rankItem(row.getValue(columnId), value)
+ addMeta({ itemRank })
+ return itemRank.passed
+}
+
+export interface ClientVirtualTableProps<TData, TValue> {
+ data: TData[]
+ columns: ColumnDef<TData, TValue>[]
+ height?: string | number
+ estimateRowHeight?: number
+ className?: string
+ actions?: React.ReactNode
+ customToolbar?: React.ReactNode
+ enableExport?: boolean
+ onExport?: (data: TData[]) => void
+ isLoading?: boolean
+
+ /**
+ * 데이터 페칭 모드
+ * - client: 모든 데이터를 한번에 받아 클라이언트에서 처리 (기본값)
+ * - server: 서버에서 필터링/정렬/페이지네이션 된 데이터를 받음
+ */
+ fetchMode?: "client" | "server"
+
+ // --- User Preset Saving ---
+ enableUserPreset?: boolean
+ tableKey?: string
+
+ // --- State Control (Controlled or Uncontrolled) ---
+
+ // Pagination
+ enablePagination?: boolean
+ manualPagination?: boolean
+ pageCount?: number
+ rowCount?: number
+ pagination?: PaginationState
+ onPaginationChange?: OnChangeFn<PaginationState>
+
+ // Sorting
+ sorting?: SortingState
+ onSortingChange?: OnChangeFn<SortingState>
+
+ // Filtering
+ columnFilters?: ColumnFiltersState
+ onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>
+ globalFilter?: string
+ onGlobalFilterChange?: OnChangeFn<string>
+
+ // Visibility
+ columnVisibility?: VisibilityState
+ onColumnVisibilityChange?: OnChangeFn<VisibilityState>
+
+ // Pinning
+ columnPinning?: ColumnPinningState
+ onColumnPinningChange?: OnChangeFn<ColumnPinningState>
+
+ // Order
+ columnOrder?: ColumnOrderState
+ onColumnOrderChange?: OnChangeFn<ColumnOrderState>
+
+ // Selection
+ enableRowSelection?: boolean | ((row: Row<TData>) => boolean)
+ enableMultiRowSelection?: boolean | ((row: Row<TData>) => boolean)
+ rowSelection?: RowSelectionState
+ onRowSelectionChange?: OnChangeFn<RowSelectionState>
+
+ // Grouping
+ enableGrouping?: boolean
+ grouping?: GroupingState
+ onGroupingChange?: OnChangeFn<GroupingState>
+ expanded?: ExpandedState
+ onExpandedChange?: OnChangeFn<ExpandedState>
+
+ // --- Event Handlers ---
+ onRowClick?: (row: Row<TData>, event: React.MouseEvent) => void
+
+ // --- Styling ---
+ getRowClassName?: (originalRow: TData, index: number) => string
+
+ // --- Advanced ---
+ meta?: Record<string, any>
+ getRowId?: (originalRow: TData, index: number, parent?: any) => string
+
+ // Custom Header Visual Feedback
+ renderHeaderVisualFeedback?: (props: {
+ column: Column<TData, TValue>
+ isPinned: boolean | string
+ isSorted: boolean | string
+ isFiltered: boolean
+ isGrouped: boolean
+ }) => React.ReactNode
+}
+
+function ClientVirtualTableInner<TData, TValue>(
+ {
+ data,
+ columns,
+ height = "100%",
+ estimateRowHeight = 40,
+ className,
+ actions,
+ customToolbar,
+ enableExport = true,
+ onExport,
+ isLoading = false,
+ fetchMode = "client",
+
+ // User Preset Saving
+ enableUserPreset = false,
+ tableKey,
+
+ // Pagination
+ enablePagination = false,
+ manualPagination: propManualPagination,
+ pageCount,
+ rowCount,
+ pagination: propPagination,
+ onPaginationChange,
+
+ // Sorting
+ sorting: propSorting,
+ onSortingChange,
+
+ // Filtering
+ columnFilters: propColumnFilters,
+ onColumnFiltersChange,
+ globalFilter: propGlobalFilter,
+ onGlobalFilterChange,
+
+ // Visibility
+ columnVisibility: propColumnVisibility,
+ onColumnVisibilityChange,
+
+ // Pinning
+ columnPinning: propColumnPinning,
+ onColumnPinningChange,
+
+ // Order
+ columnOrder: propColumnOrder,
+ onColumnOrderChange,
+
+ // Selection
+ enableRowSelection,
+ enableMultiRowSelection,
+ rowSelection: propRowSelection,
+ onRowSelectionChange,
+
+ // Grouping
+ enableGrouping = false,
+ grouping: propGrouping,
+ onGroupingChange,
+ expanded: propExpanded,
+ onExpandedChange,
+
+ // Style defaults
+ getRowClassName,
+
+ // Meta & RowID
+ meta,
+ getRowId,
+
+ // Event Handlers
+ onRowClick,
+
+ // Custom Header Visual Feedback
+ renderHeaderVisualFeedback,
+ }: ClientVirtualTableProps<TData, TValue>,
+ ref: React.Ref<Table<TData>>
+) {
+ // Internal States (used when props are undefined)
+ const [internalSorting, setInternalSorting] = React.useState<SortingState>([])
+ const [internalColumnFilters, setInternalColumnFilters] = React.useState<ColumnFiltersState>([])
+ const [internalGlobalFilter, setInternalGlobalFilter] = React.useState("")
+ const [internalColumnVisibility, setInternalColumnVisibility] = React.useState<VisibilityState>({})
+ const [internalColumnPinning, setInternalColumnPinning] = React.useState<ColumnPinningState>({ left: [], right: [] })
+ const [internalColumnOrder, setInternalColumnOrder] = React.useState<ColumnOrderState>(
+ () => columns.map((c) => c.id || (c as any).accessorKey) as string[]
+ )
+ const [internalPagination, setInternalPagination] = React.useState<PaginationState>({
+ pageIndex: 0,
+ pageSize: 10,
+ })
+ const [internalRowSelection, setInternalRowSelection] = React.useState<RowSelectionState>({})
+ const [internalGrouping, setInternalGrouping] = React.useState<GroupingState>([])
+ const [internalExpanded, setInternalExpanded] = React.useState<ExpandedState>({})
+
+ // Effective States
+ const sorting = propSorting ?? internalSorting
+ const setSorting = onSortingChange ?? setInternalSorting
+
+ const columnFilters = propColumnFilters ?? internalColumnFilters
+ const setColumnFilters = onColumnFiltersChange ?? setInternalColumnFilters
+
+ const globalFilter = propGlobalFilter ?? internalGlobalFilter
+ const setGlobalFilter = onGlobalFilterChange ?? setInternalGlobalFilter
+
+ const columnVisibility = propColumnVisibility ?? internalColumnVisibility
+ const setColumnVisibility = onColumnVisibilityChange ?? setInternalColumnVisibility
+
+ const columnPinning = propColumnPinning ?? internalColumnPinning
+ const setColumnPinning = onColumnPinningChange ?? setInternalColumnPinning
+
+ const columnOrder = propColumnOrder ?? internalColumnOrder
+ const setColumnOrder = onColumnOrderChange ?? setInternalColumnOrder
+
+ const pagination = propPagination ?? internalPagination
+ const setPagination = onPaginationChange ?? setInternalPagination
+
+ const rowSelection = propRowSelection ?? internalRowSelection
+ const setRowSelection = onRowSelectionChange ?? setInternalRowSelection
+
+ const grouping = propGrouping ?? internalGrouping
+ const setGrouping = onGroupingChange ?? setInternalGrouping
+
+ const expanded = propExpanded ?? internalExpanded
+ const setExpanded = onExpandedChange ?? setInternalExpanded
+
+ // Server Mode Logic
+ const isServer = fetchMode === "server"
+ // If server mode is enabled, we default manual flags to true unless explicitly overridden
+ const manualPagination = propManualPagination ?? isServer
+ const manualSorting = isServer
+ const manualFiltering = isServer
+ const manualGrouping = isServer
+
+ // Table Instance
+ const table = useReactTable({
+ data,
+ columns,
+ state: {
+ sorting,
+ columnFilters,
+ globalFilter,
+ pagination,
+ columnVisibility,
+ columnPinning,
+ columnOrder,
+ rowSelection,
+ grouping,
+ expanded,
+ },
+ manualPagination,
+ manualSorting,
+ manualFiltering,
+ manualGrouping,
+ pageCount: manualPagination ? pageCount : undefined,
+ rowCount: manualPagination ? rowCount : undefined,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onGlobalFilterChange: setGlobalFilter,
+ onPaginationChange: setPagination,
+ onColumnVisibilityChange: setColumnVisibility,
+ onColumnPinningChange: setColumnPinning,
+ onColumnOrderChange: setColumnOrder,
+ onRowSelectionChange: setRowSelection,
+ onGroupingChange: setGrouping,
+ onExpandedChange: setExpanded,
+ enableRowSelection,
+ enableMultiRowSelection,
+ enableGrouping,
+ getCoreRowModel: getCoreRowModel(),
+
+ // Systematic Order of Operations:
+ // If server-side, we skip client-side processing models to avoid double processing
+ // and to ensure the table reflects exactly what the server returned.
+ 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,
+ meta,
+ getRowId,
+ })
+
+ // Expose table instance via ref
+ React.useImperativeHandle(ref, () => table, [table])
+
+ // DnD Sensors
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ }),
+ useSensor(KeyboardSensor)
+ )
+
+ // Handle Drag End
+ 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 of the active column
+ if (activePinState !== overPinState) {
+ activeColumn.pin(overPinState)
+ }
+
+ // Reorder the columns
+ setColumnOrder((items) => {
+ const currentItems = Array.isArray(items) ? items : []
+ const oldIndex = items.indexOf(activeId)
+ const newIndex = items.indexOf(overId)
+ return arrayMove(items, 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 Handler
+ const handleExport = async () => {
+ if (onExport) {
+ onExport(data)
+ return
+ }
+ const currentData = table.getFilteredRowModel().rows.map((row) => row.original)
+ await exportToExcel(currentData, columns, `export-${new Date().toISOString().slice(0, 10)}.xlsx`)
+ }
+
+ return (
+ <div
+ className={`flex flex-col gap-4 ${className || ""}`}
+ style={{ height }}
+ >
+ <ClientTableToolbar
+ globalFilter={globalFilter}
+ setGlobalFilter={setGlobalFilter}
+ totalRows={manualPagination ? (rowCount ?? data.length) : data.length}
+ visibleRows={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) => {
+ // Handle pinned cells
+ 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() ? (
+ // If this cell is grouped, usually we don't render it here if we have a group header row,
+ // but if we keep it, it acts as the expander for the next level (if multi-level grouping).
+ // Since we used a full-width row for the group header, this branch might not be hit for the group row itself,
+ // but for nested groups it might?
+ // Wait, row.getIsGrouped() is true for the group row.
+ // The cells inside the group row are not rendered because we return early above.
+ // The cells inside the "leaf" rows (normal rows) are rendered here.
+ // So cell.getIsGrouped() checks if the COLUMN is currently grouped.
+ // If the column is grouped, the cell value is usually redundant or hidden in normal rows.
+ // Standard practice: hide the cell content or dim it.
+ null
+ ) : cell.getIsAggregated() ? (
+ // If this cell is an aggregation of the group
+ flexRender(
+ cell.column.columnDef.aggregatedCell ??
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )
+ ) : (
+ // Normal cell
+ 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>
+
+ {enablePagination && (
+ <ClientDataTablePagination table={table} />
+ )}
+ </div>
+ )
+}
+
+export const ClientVirtualTable = React.memo(
+ React.forwardRef(ClientVirtualTableInner)
+) as <TData, TValue>(
+ props: ClientVirtualTableProps<TData, TValue> & { ref?: React.Ref<Table<TData>> }
+) => React.ReactElement
diff --git a/components/client-table-v2/export-utils.ts b/components/client-table-v2/export-utils.ts
new file mode 100644
index 00000000..edcc8dff
--- /dev/null
+++ b/components/client-table-v2/export-utils.ts
@@ -0,0 +1,136 @@
+import { ColumnDef } from "@tanstack/react-table"
+import ExcelJS from "exceljs"
+import { saveAs } from "file-saver"
+
+export async function exportToExcel<TData>(
+ data: TData[],
+ columns: ColumnDef<TData, any>[],
+ filename: string = "export.xlsx"
+) {
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Data")
+
+ // Filter out utility columns and resolve headers
+ const exportableColumns = columns.filter(
+ (col) =>
+ col.id !== "select" &&
+ col.id !== "actions" &&
+ // @ts-ignore - simple check for now
+ (typeof col.header === "string" || typeof col.accessorKey === "string")
+ )
+
+ // Setup columns
+ worksheet.columns = exportableColumns.map((col) => {
+ let headerText = ""
+ if (typeof col.header === "string") {
+ headerText = col.header
+ } else if (typeof col.accessorKey === "string") {
+ headerText = col.accessorKey
+ }
+
+ return {
+ header: headerText,
+ key: (col.accessorKey as string) || col.id,
+ width: 20,
+ }
+ })
+
+ // Add rows
+ data.forEach((row) => {
+ const rowData: any = {}
+ exportableColumns.forEach((col) => {
+ const key = (col.accessorKey as string) || col.id
+ if (key) {
+ const value = getValueByPath(row, key)
+ rowData[key] = value
+ }
+ })
+ worksheet.addRow(rowData)
+ })
+
+ worksheet.getRow(1).font = { bold: true }
+
+ const buffer = await workbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ saveAs(blob, filename)
+}
+
+function getValueByPath(obj: any, path: string) {
+ return path.split('.').reduce((acc, part) => acc && acc[part], obj)
+}
+
+interface CreateTemplateOptions<TData> {
+ columns: ColumnDef<TData, any>[]
+ filename?: string
+ includeColumns?: { key: string; header: string }[]
+ excludeColumns?: string[] // accessorKey or id to exclude
+}
+
+export async function createExcelTemplate<TData>({
+ columns,
+ filename = "template.xlsx",
+ includeColumns = [],
+ excludeColumns = [],
+}: CreateTemplateOptions<TData>) {
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Template")
+
+ // 1. Filter columns from definition
+ const baseColumns = columns.filter((col) => {
+ const key = (col.accessorKey as string) || col.id
+
+ // Skip system columns
+ if (col.id === "select" || col.id === "actions") return false
+
+ // Skip excluded columns
+ if (excludeColumns.includes(key!) || (col.id && excludeColumns.includes(col.id))) return false
+
+ return true
+ })
+
+ // 2. Map to ExcelJS columns
+ const excelColumns = baseColumns.map((col) => {
+ let headerText = ""
+ if (typeof col.header === "string") {
+ headerText = col.header
+ } else if (typeof col.accessorKey === "string") {
+ headerText = col.accessorKey
+ }
+
+ return {
+ header: headerText,
+ key: (col.accessorKey as string) || col.id,
+ width: 20
+ }
+ })
+
+ // 3. Add extra included columns
+ includeColumns.forEach((col) => {
+ excelColumns.push({
+ header: col.header,
+ key: col.key,
+ width: 20
+ })
+ })
+
+ worksheet.columns = excelColumns
+
+ // Style Header
+ const headerRow = worksheet.getRow(1)
+ headerRow.font = { bold: true }
+ headerRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFD3D3D3' } // Light Gray
+ }
+
+ // Add Data Validation or Comments if needed (future expansion)
+
+ const buffer = await workbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ saveAs(blob, filename)
+}
diff --git a/components/client-table-v2/import-utils.ts b/components/client-table-v2/import-utils.ts
new file mode 100644
index 00000000..bc7f4b44
--- /dev/null
+++ b/components/client-table-v2/import-utils.ts
@@ -0,0 +1,100 @@
+import ExcelJS from "exceljs"
+
+interface ImportExcelOptions {
+ file: File
+ /**
+ * Map Excel header names to data keys.
+ * Example: { "Name": "name", "Age": "age" }
+ */
+ columnMapping?: Record<string, string>
+ /**
+ * Row offset to start reading data (0-based).
+ * Default: 1 (assuming row 0 is header)
+ */
+ dataStartRow?: number
+}
+
+export interface ExcelImportResult<T = any> {
+ data: T[]
+ errors: string[]
+}
+
+/**
+ * Generic function to read an Excel file and convert it to an array of objects.
+ * Does NOT handle database insertion or validation logic.
+ */
+export async function importFromExcel<T = any>({
+ file,
+ columnMapping = {},
+ dataStartRow = 1,
+}: ImportExcelOptions): Promise<ExcelImportResult<T>> {
+ const workbook = new ExcelJS.Workbook()
+ const arrayBuffer = await file.arrayBuffer()
+
+ try {
+ await workbook.xlsx.load(arrayBuffer)
+ } catch (error) {
+ return { data: [], errors: ["Failed to parse Excel file. Please ensure it is a valid .xlsx file."] }
+ }
+
+ const worksheet = workbook.worksheets[0] // Read the first sheet
+ const data: T[] = []
+ const errors: string[] = []
+
+ if (!worksheet) {
+ return { data: [], errors: ["No worksheet found in the Excel file."] }
+ }
+
+ // 1. Read Header Row (assumed to be row 1 for mapping if no explicit mapping provided,
+ // or we can use it to validate mapping)
+ const headers: string[] = []
+ const headerRow = worksheet.getRow(1)
+ headerRow.eachCell((cell, colNumber) => {
+ headers[colNumber] = String(cell.value).trim()
+ })
+
+ // 2. Iterate Data Rows
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber <= dataStartRow) return // Skip header/pre-header rows
+
+ const rowData: any = {}
+ let hasData = false
+
+ row.eachCell((cell, colNumber) => {
+ const headerText = headers[colNumber]
+ if (!headerText) return
+
+ // Determine the key to use for this column
+ // Priority: Explicit mapping -> Header text as key
+ const key = columnMapping[headerText] || headerText
+
+ let cellValue = cell.value
+
+ // Handle ExcelJS object values (e.g. formula results, hyperlinks)
+ if (cellValue && typeof cellValue === 'object') {
+ if ('result' in cellValue) {
+ // Formula result
+ cellValue = (cellValue as any).result
+ } else if ('text' in cellValue) {
+ // Hyperlink text
+ cellValue = (cellValue as any).text
+ } else if ('richText' in cellValue) {
+ // Rich text
+ cellValue = (cellValue as any).richText.map((t: any) => t.text).join('')
+ }
+ }
+
+ if (cellValue !== null && cellValue !== undefined && String(cellValue).trim() !== '') {
+ hasData = true
+ rowData[key] = cellValue
+ }
+ })
+
+ if (hasData) {
+ data.push(rowData as T)
+ }
+ })
+
+ return { data, errors }
+}
+
diff --git a/components/client-table-v2/index.ts b/components/client-table-v2/index.ts
new file mode 100644
index 00000000..2b3a1645
--- /dev/null
+++ b/components/client-table-v2/index.ts
@@ -0,0 +1,7 @@
+export * from "./client-virtual-table"
+export * from "./client-table-column-header"
+export * from "./client-table-filter"
+export * from "./client-table-view-options"
+export * from "./export-utils"
+export * from "./import-utils"
+export * from "./types"
diff --git a/components/client-table-v2/preset-actions.ts b/components/client-table-v2/preset-actions.ts
new file mode 100644
index 00000000..0b8b3adb
--- /dev/null
+++ b/components/client-table-v2/preset-actions.ts
@@ -0,0 +1,87 @@
+"use server";
+
+import db from "@/db/db";
+import { userCustomData } from "@/db/schema/user-custom-data/userCustomData";
+import { eq, and } from "drizzle-orm";
+import { Preset, PresetRepository } from "./preset-types";
+
+// Drizzle Implementation of PresetRepository
+// This file acts as the concrete repository implementation.
+// To swap DBs, you would replace the logic here or create a new implementation file.
+
+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);
+
+ // Map DB entity to domain model
+ 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-v2/preset-types.ts b/components/client-table-v2/preset-types.ts
new file mode 100644
index 00000000..072d918b
--- /dev/null
+++ b/components/client-table-v2/preset-types.ts
@@ -0,0 +1,13 @@
+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 }>;
+}