summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-30 17:15:44 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-30 17:15:44 +0900
commitd674b066a9a3195d764f693885fb9f25d66263ed (patch)
treed6f26b80c0b2010f47bcec9b12733d633a8064c4
parent12af09245b38da8cc3fdb851ebb03bc0de45c8be (diff)
parent81aa92fecc298d66eb420468316bcf7a7213171c (diff)
Merge branch 'dynamic-data-table' into bugfix-schema
-rw-r--r--app/[lng]/test/table/page.tsx168
-rw-r--r--components/client-table/client-table-column-header.tsx70
-rw-r--r--components/client-table/client-table-preset.tsx185
-rw-r--r--components/client-table/client-table-save-view.tsx185
-rw-r--r--components/client-table/client-table-toolbar.tsx23
-rw-r--r--components/client-table/client-table-view-options.tsx9
-rw-r--r--components/client-table/client-virtual-table.tsx457
-rw-r--r--components/client-table/preset-actions.ts87
-rw-r--r--components/client-table/preset-types.ts13
-rw-r--r--db/schema/index.ts2
-rw-r--r--db/schema/user-custom-data/userCustomData.ts16
11 files changed, 1093 insertions, 122 deletions
diff --git a/app/[lng]/test/table/page.tsx b/app/[lng]/test/table/page.tsx
new file mode 100644
index 00000000..88d050fc
--- /dev/null
+++ b/app/[lng]/test/table/page.tsx
@@ -0,0 +1,168 @@
+"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+import { ClientVirtualTable } from "@/components/client-table/client-virtual-table"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+
+// 1. Define the data type
+type TestData = {
+ id: string
+ name: string
+ email: string
+ role: "Admin" | "User" | "Guest"
+ status: "Active" | "Inactive" | "Pending"
+ lastLogin: string
+ amount: number
+}
+
+// 2. Generate dummy data
+const generateData = (count: number): TestData[] => {
+ const roles: TestData["role"][] = ["Admin", "User", "Guest"]
+ const statuses: TestData["status"][] = ["Active", "Inactive", "Pending"]
+
+ return Array.from({ length: count }).map((_, i) => ({
+ id: `ID-${i + 1}`,
+ name: `User ${i + 1}`,
+ email: `user${i + 1}@example.com`,
+ role: roles[Math.floor(Math.random() * roles.length)],
+ status: statuses[Math.floor(Math.random() * statuses.length)],
+ lastLogin: new Date(Date.now() - Math.floor(Math.random() * 10000000000)).toISOString().split('T')[0],
+ amount: Math.floor(Math.random() * 10000),
+ }))
+}
+
+export default function TestTablePage() {
+ // State for data
+ const [data, setData] = React.useState<TestData[]>([])
+ const [isLoading, setIsLoading] = React.useState(true)
+
+ // Load data on mount
+ React.useEffect(() => {
+ const timer = setTimeout(() => {
+ setData(generateData(100000)) // Generate 1000 rows
+ setIsLoading(false)
+ }, 500)
+ return () => clearTimeout(timer)
+ }, [])
+
+ // 3. Define columns
+ const columns: ColumnDef<TestData>[] = [
+ {
+ accessorKey: "id",
+ header: "ID",
+ size: 80,
+ },
+ {
+ accessorKey: "name",
+ header: "Name",
+ size: 150,
+ },
+ {
+ accessorKey: "email",
+ header: "Email",
+ size: 200,
+ },
+ {
+ accessorKey: "role",
+ header: "Role",
+ size: 100,
+ cell: ({ getValue }) => {
+ const role = getValue() as string
+ return (
+ <Badge variant={role === "Admin" ? "default" : "secondary"}>
+ {role}
+ </Badge>
+ )
+ }
+ },
+ {
+ accessorKey: "status",
+ header: "Status",
+ size: 100,
+ cell: ({ getValue }) => {
+ const status = getValue() as string
+ let color = "bg-gray-500"
+ if (status === "Active") color = "bg-green-500"
+ if (status === "Inactive") color = "bg-red-500"
+ if (status === "Pending") color = "bg-yellow-500"
+
+ return (
+ <div className="flex items-center gap-2">
+ <div className={`w-2 h-2 rounded-full ${color}`} />
+ <span>{status}</span>
+ </div>
+ )
+ }
+ },
+ {
+ accessorKey: "amount",
+ header: "Amount",
+ size: 200,
+ cell: ({ getValue }) => {
+ const amount = getValue() as number
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(amount)
+ },
+ meta: {
+ align: "right"
+ }
+ },
+ {
+ accessorKey: "lastLogin",
+ header: "Last Login",
+ size: 120,
+ },
+ {
+ id: "actions",
+ header: "Actions",
+ size: 100,
+ cell: () => (
+ <Button variant="ghost" size="sm">Edit</Button>
+ ),
+ enablePinning: true,
+ }
+ ]
+
+ return (
+ <div className="h-full flex flex-col p-6 space-y-4">
+ <div className="flex justify-between items-center">
+ <div>
+ <h1 className="text-2xl font-bold tracking-tight">Virtual Table Test</h1>
+ <p className="text-muted-foreground">
+ Testing the ClientVirtualTable component with 1000 generated rows.
+ </p>
+ </div>
+ <div className="flex gap-2">
+ <Button onClick={() => {
+ setIsLoading(true)
+ setTimeout(() => {
+ setData(generateData(5000))
+ setIsLoading(false)
+ }, 500)
+ }}>
+ Reload 5k Rows
+ </Button>
+ </div>
+ </div>
+
+ <div className="border rounded-lg overflow-auto h-[1000px]">
+ <ClientVirtualTable
+ data={data}
+ columns={columns}
+ height="100%"
+ isLoading={isLoading}
+ enablePagination={true}
+ enableRowSelection={true}
+ enableGrouping={true}
+ onRowClick={(row) => console.log("Row clicked:", row.original)}
+ enableUserPreset={true}
+ tableKey="test-table"
+ />
+ </div>
+ </div>
+ )
+}
diff --git a/components/client-table/client-table-column-header.tsx b/components/client-table/client-table-column-header.tsx
index 12dc57ac..2d8e5bce 100644
--- a/components/client-table/client-table-column-header.tsx
+++ b/components/client-table/client-table-column-header.tsx
@@ -1,7 +1,7 @@
"use client"
import * as React from "react"
-import { Header } from "@tanstack/react-table"
+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"
@@ -20,19 +20,29 @@ import {
PinOff,
MoveLeft,
MoveRight,
+ Group,
+ Ungroup,
} from "lucide-react"
import { cn } from "@/lib/utils"
-import { ClientTableFilter } from "./client-table-filter"
+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>) {
@@ -46,7 +56,7 @@ export function ClientTableColumnHeader<TData, TValue>({
isDragging,
} = useSortable({
id: header.id,
- disabled: !enableReordering,
+ disabled: !enableReordering || column.getIsResizing(),
})
// -- Styles --
@@ -62,14 +72,18 @@ export function ClientTableColumnHeader<TData, TValue>({
// 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 = 20
+ 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 = 20
+ style.zIndex = 30 // Pinned columns needs to be higher than normal headers
}
// -- Handlers --
@@ -77,6 +91,7 @@ export function ClientTableColumnHeader<TData, TValue>({
const handlePinLeft = () => column.pin("left")
const handlePinRight = () => column.pin("right")
const handleUnpin = () => column.pin(false)
+ const handleToggleGrouping = () => column.toggleGrouping()
// -- Content --
const content = (
@@ -100,12 +115,14 @@ export function ClientTableColumnHeader<TData, TValue>({
)}
</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",
@@ -117,6 +134,25 @@ export function ClientTableColumnHeader<TData, TValue>({
{/* 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>
+ )
+ )}
</>
)
@@ -141,9 +177,9 @@ export function ClientTableColumnHeader<TData, TValue>({
colSpan={header.colSpan}
style={style}
className={cn(
- "border-b px-4 py-2 text-left text-sm font-medium bg-muted group",
+ "border-b px-4 py-2 text-left text-sm font-medium bg-muted group transition-colors",
isDragging ? "opacity-50 bg-accent" : "",
- isPinned ? "bg-muted shadow-[0_0_10px_rgba(0,0,0,0.1)]" : "",
+ isPinned ? "shadow-[0_0_10px_rgba(0,0,0,0.1)]" : "",
className
)}
{...attributes}
@@ -158,6 +194,26 @@ export function ClientTableColumnHeader<TData, TValue>({
<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" />
diff --git a/components/client-table/client-table-preset.tsx b/components/client-table/client-table-preset.tsx
new file mode 100644
index 00000000..64930e7a
--- /dev/null
+++ b/components/client-table/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/client-table-save-view.tsx b/components/client-table/client-table-save-view.tsx
new file mode 100644
index 00000000..73935d00
--- /dev/null
+++ b/components/client-table/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/client-table-toolbar.tsx b/components/client-table/client-table-toolbar.tsx
index 43b0a032..089501e1 100644
--- a/components/client-table/client-table-toolbar.tsx
+++ b/components/client-table/client-table-toolbar.tsx
@@ -12,6 +12,8 @@ interface ClientTableToolbarProps {
visibleRows: number
onExport?: () => void
actions?: React.ReactNode
+ customToolbar?: React.ReactNode
+ viewOptions?: React.ReactNode
}
export function ClientTableToolbar({
@@ -21,11 +23,13 @@ export function ClientTableToolbar({
visibleRows,
onExport,
actions,
+ customToolbar,
+ viewOptions,
}: ClientTableToolbarProps) {
return (
- <div className="flex items-center justify-between gap-4 p-1">
- <div className="flex items-center gap-2 flex-1">
- <div className="relative flex-1 max-w-sm">
+ <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..."
@@ -37,18 +41,19 @@ export function ClientTableToolbar({
<div className="text-sm text-muted-foreground whitespace-nowrap">
Showing {visibleRows} of {totalRows}
</div>
- </div>
-
- <div className="flex items-center gap-2">
- {actions}
+ {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/client-table-view-options.tsx b/components/client-table/client-table-view-options.tsx
index b65049b4..3b659fcd 100644
--- a/components/client-table/client-table-view-options.tsx
+++ b/components/client-table/client-table-view-options.tsx
@@ -42,14 +42,21 @@ export function ClientTableViewOptions<TData>({
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.
>
- {column.id}
+ {label}
</DropdownMenuCheckboxItem>
)
})}
diff --git a/components/client-table/client-virtual-table.tsx b/components/client-table/client-virtual-table.tsx
index 4825741f..507057c7 100644
--- a/components/client-table/client-virtual-table.tsx
+++ b/components/client-table/client-virtual-table.tsx
@@ -8,6 +8,11 @@ import {
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
+ getGroupedRowModel,
+ getExpandedRowModel,
+ getFacetedRowModel,
+ getFacetedUniqueValues,
+ getFacetedMinMaxValues,
ColumnDef,
SortingState,
ColumnFiltersState,
@@ -21,6 +26,9 @@ import {
Table,
RowSelectionState,
Row,
+ Column,
+ GroupingState,
+ ExpandedState,
} from "@tanstack/react-table"
import { useVirtualizer } from "@tanstack/react-virtual"
import {
@@ -38,23 +46,41 @@ import {
horizontalListSortingStrategy,
} from "@dnd-kit/sortable"
import { cn } from "@/lib/utils"
+import { Loader2, ChevronRight, ChevronDown } from "lucide-react"
-import { ClientTableToolbar } from "./client-table-toolbar"
-import { exportToExcel } from "./export-utils"
+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-view-options"
+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
-
- // Pagination Props
+ isLoading?: boolean
+
+ // --- User Preset Saving ---
+ enableUserPreset?: boolean
+ tableKey?: string
+
+ // --- State Control (Controlled or Uncontrolled) ---
+
+ // Pagination
enablePagination?: boolean
manualPagination?: boolean
pageCount?: number
@@ -62,88 +88,184 @@ export interface ClientVirtualTableProps<TData, TValue> {
pagination?: PaginationState
onPaginationChange?: OnChangeFn<PaginationState>
- // Style Props
- getRowClassName?: (originalRow: TData, index: number) => string
+ // Sorting
+ sorting?: SortingState
+ onSortingChange?: OnChangeFn<SortingState>
- // Table Meta
- meta?: any
-
- // Row ID
- getRowId?: (originalRow: TData, index: number, parent?: any) => string
+ // Filtering
+ columnFilters?: ColumnFiltersState
+ onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>
+ globalFilter?: string
+ onGlobalFilterChange?: OnChangeFn<string>
+
+ // Visibility
+ columnVisibility?: VisibilityState
+ onColumnVisibilityChange?: OnChangeFn<VisibilityState>
+
+ // Pinning
+ columnPinning?: ColumnPinningState
+ onColumnPinningChange?: OnChangeFn<ColumnPinningState>
- // Selection Props
+ // 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 = "500px", // Default height
+ height = "100%",
+ estimateRowHeight = 40,
className,
actions,
+ customToolbar,
enableExport = true,
onExport,
-
- // Pagination defaults
+ isLoading = false,
+
+ // User Preset Saving
+ enableUserPreset = false,
+ tableKey,
+
+ // Pagination
enablePagination = false,
manualPagination = false,
pageCount,
rowCount,
- pagination: controlledPagination,
+ 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,
- // Selection defaults
- enableRowSelection,
- enableMultiRowSelection,
- rowSelection: controlledRowSelection,
- onRowSelectionChange,
+ // Event Handlers
+ onRowClick,
+
+ // Custom Header Visual Feedback
+ renderHeaderVisualFeedback,
}: ClientVirtualTableProps<TData, TValue>,
ref: React.Ref<Table<TData>>
) {
- // State
- const [sorting, setSorting] = React.useState<SortingState>([])
- const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
- const [globalFilter, setGlobalFilter] = React.useState("")
- const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
- const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>({ left: [], right: [] })
- const [columnOrder, setColumnOrder] = React.useState<ColumnOrderState>(
+ // 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[]
)
-
- // Internal Pagination State
const [internalPagination, setInternalPagination] = React.useState<PaginationState>({
pageIndex: 0,
- pageSize: 50,
+ pageSize: 10,
})
-
- // Internal Row Selection State
const [internalRowSelection, setInternalRowSelection] = React.useState<RowSelectionState>({})
+ const [internalGrouping, setInternalGrouping] = React.useState<GroupingState>([])
+ const [internalExpanded, setInternalExpanded] = React.useState<ExpandedState>({})
- // Fuzzy Filter
- const fuzzyFilter: FilterFn<TData> = (row, columnId, value, addMeta) => {
- const itemRank = rankItem(row.getValue(columnId), value)
- addMeta({ itemRank })
- return itemRank.passed
- }
+ // 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
- // Combine controlled and uncontrolled states
- const pagination = controlledPagination ?? internalPagination
+ 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 = controlledRowSelection ?? internalRowSelection
+
+ const rowSelection = propRowSelection ?? internalRowSelection
const setRowSelection = onRowSelectionChange ?? setInternalRowSelection
+ const grouping = propGrouping ?? internalGrouping
+ const setGrouping = onGroupingChange ?? setInternalGrouping
+
+ const expanded = propExpanded ?? internalExpanded
+ const setExpanded = onExpandedChange ?? setInternalExpanded
+
// Table Instance
const table = useReactTable({
data,
@@ -157,6 +279,8 @@ function ClientVirtualTableInner<TData, TValue>(
columnPinning,
columnOrder,
rowSelection,
+ grouping,
+ expanded,
},
manualPagination,
pageCount: manualPagination ? pageCount : undefined,
@@ -169,11 +293,28 @@ function ClientVirtualTableInner<TData, TValue>(
onColumnPinningChange: setColumnPinning,
onColumnOrderChange: setColumnOrder,
onRowSelectionChange: setRowSelection,
+ onGroupingChange: setGrouping,
+ onExpandedChange: setExpanded,
enableRowSelection,
enableMultiRowSelection,
+ enableGrouping,
getCoreRowModel: getCoreRowModel(),
- getSortedRowModel: getSortedRowModel(),
+
+ // Systematic Order of Operations:
+ // 1. Filtering (Rows are filtered first)
getFilteredRowModel: getFilteredRowModel(),
+ getFacetedRowModel: getFacetedRowModel(),
+ getFacetedUniqueValues: getFacetedUniqueValues(),
+ getFacetedMinMaxValues: getFacetedMinMaxValues(),
+
+ // 2. Sorting (Filtered rows are then sorted)
+ getSortedRowModel: getSortedRowModel(),
+
+ // 3. Grouping (Sorted rows are grouped)
+ getGroupedRowModel: enableGrouping ? getGroupedRowModel() : undefined,
+ getExpandedRowModel: enableGrouping ? getExpandedRowModel() : undefined,
+
+ // 4. Pagination (Final rows are paginated)
getPaginationRowModel: enablePagination ? getPaginationRowModel() : undefined,
columnResizeMode: "onChange",
filterFns: {
@@ -191,7 +332,7 @@ function ClientVirtualTableInner<TData, TValue>(
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
- distance: 8, // 8px movement required to start drag
+ distance: 8,
},
}),
useSensor(KeyboardSensor)
@@ -201,11 +342,29 @@ function ClientVirtualTableInner<TData, TValue>(
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (active && over && active.id !== over.id) {
- setColumnOrder((items) => {
- const oldIndex = items.indexOf(active.id as string)
- const newIndex = items.indexOf(over.id as string)
- return arrayMove(items, oldIndex, newIndex)
- })
+ 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)
+ })
+ }
}
}
@@ -216,7 +375,7 @@ function ClientVirtualTableInner<TData, TValue>(
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableContainerRef.current,
- estimateSize: () => 40, // Estimated row height
+ estimateSize: () => estimateRowHeight,
overscan: 10,
})
@@ -236,30 +395,42 @@ function ClientVirtualTableInner<TData, TValue>(
return
}
const currentData = table.getFilteredRowModel().rows.map((row) => row.original)
- await exportToExcel(currentData, columns, `export-${new Date().toISOString().slice(0,10)}.xlsx`)
+ await exportToExcel(currentData, columns, `export-${new Date().toISOString().slice(0, 10)}.xlsx`)
}
return (
- <div className={`flex flex-col gap-4 ${className || ""}`}>
+ <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}
- actions={
+ viewOptions={
<>
- {actions}
<ClientTableViewOptions table={table} />
+ {enableUserPreset && tableKey && (
+ <ClientTablePreset table={table} tableKey={tableKey} />
+ )}
</>
}
+ customToolbar={customToolbar}
+ actions={actions}
/>
<div
ref={tableContainerRef}
- className="relative border rounded-md overflow-auto"
- style={{ height }}
+ 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}
@@ -269,11 +440,11 @@ function ClientVirtualTableInner<TData, TValue>(
className="table-fixed border-collapse w-full min-w-full"
style={{ width: table.getTotalSize() }}
>
- <thead className="sticky top-0 z-10 bg-muted">
+ <thead className="sticky top-0 z-40 bg-muted">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
<SortableContext
- items={columnOrder}
+ items={headerGroup.headers.map((h) => h.id)}
strategy={horizontalListSortingStrategy}
>
{headerGroup.headers.map((header) => (
@@ -281,6 +452,7 @@ function ClientVirtualTableInner<TData, TValue>(
key={header.id}
header={header}
enableReordering={true}
+ renderHeaderVisualFeedback={renderHeaderVisualFeedback}
/>
))}
</SortableContext>
@@ -293,51 +465,126 @@ function ClientVirtualTableInner<TData, TValue>(
<td style={{ height: `${paddingTop}px` }} />
</tr>
)}
- {virtualRows.map((virtualRow) => {
- const row = rows[virtualRow.index]
- return (
- <tr
- key={row.id}
- className={cn(
- "hover:bg-muted/50 border-b last:border-0",
- getRowClassName ? getRowClassName(row.original, row.index) : ""
- )}
- style={{ height: `${virtualRow.size}px` }}
- >
- {row.getVisibleCells().map((cell) => {
- // Handle pinned cells
- const isPinned = cell.column.getIsPinned()
- const style: React.CSSProperties = {
- width: cell.column.getSize(),
- }
- if (isPinned === "left") {
- style.position = "sticky"
- style.left = `${cell.column.getStart("left")}px`
- style.zIndex = 10
- style.backgroundColor = "var(--background)" // Ensure opacity
- } else if (isPinned === "right") {
- style.position = "sticky"
- style.right = `${cell.column.getAfter("right")}px`
- style.zIndex = 10
- style.backgroundColor = "var(--background)"
- }
-
- return (
+ {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
- key={cell.id}
- className="px-4 py-2 text-sm truncate border-b"
- style={style}
+ colSpan={columns.length}
+ className="px-4 py-2 text-left font-medium cursor-pointer"
+ onClick={row.getToggleExpandedHandler()}
>
- {flexRender(
- cell.column.columnDef.cell,
- cell.getContext()
- )}
+ <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>
- )
- })}
+ </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` }} />
@@ -347,9 +594,9 @@ function ClientVirtualTableInner<TData, TValue>(
</table>
</DndContext>
</div>
-
+
{enablePagination && (
- <ClientDataTablePagination table={table} />
+ <ClientDataTablePagination table={table} />
)}
</div>
)
diff --git a/components/client-table/preset-actions.ts b/components/client-table/preset-actions.ts
new file mode 100644
index 00000000..0b8b3adb
--- /dev/null
+++ b/components/client-table/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/preset-types.ts b/components/client-table/preset-types.ts
new file mode 100644
index 00000000..072d918b
--- /dev/null
+++ b/components/client-table/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 }>;
+}
diff --git a/db/schema/index.ts b/db/schema/index.ts
index 7d433f7c..6463e0ec 100644
--- a/db/schema/index.ts
+++ b/db/schema/index.ts
@@ -55,6 +55,8 @@ export * from './permissions';
export * from './fileSystem';
+export * from './user-custom-data/userCustomData';
+
// 부서별 도메인 할당 관리
export * from './departmentDomainAssignments';
diff --git a/db/schema/user-custom-data/userCustomData.ts b/db/schema/user-custom-data/userCustomData.ts
new file mode 100644
index 00000000..bf529679
--- /dev/null
+++ b/db/schema/user-custom-data/userCustomData.ts
@@ -0,0 +1,16 @@
+/**
+ * user custom data
+ *
+ * */
+import { integer, json, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
+import { users } from "../users";
+
+export const userCustomData = pgTable("user_custom_data", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ userId: integer("user_id").references(() => users.id),
+ tableKey: varchar("table_key", { length: 255 }).notNull(),
+ customSettingName: varchar("custom_setting_name", { length: 255 }).notNull(),
+ customSetting: json("custom_setting"),
+ createdDate: timestamp("created_date", { withTimezone: true }).defaultNow().notNull(),
+ updatedDate: timestamp("updated_date", { withTimezone: true }).defaultNow().notNull(),
+});