diff options
| -rw-r--r-- | components/client-table-v2/client-table-column-header.tsx | 235 | ||||
| -rw-r--r-- | components/client-table-v2/client-table-filter.tsx | 101 | ||||
| -rw-r--r-- | components/client-table-v2/client-table-preset.tsx | 185 | ||||
| -rw-r--r-- | components/client-table-v2/client-table-save-view.tsx | 185 | ||||
| -rw-r--r-- | components/client-table-v2/client-table-toolbar.tsx | 59 | ||||
| -rw-r--r-- | components/client-table-v2/client-table-view-options.tsx | 67 | ||||
| -rw-r--r-- | components/client-table-v2/client-virtual-table.tsx | 626 | ||||
| -rw-r--r-- | components/client-table-v2/export-utils.ts | 136 | ||||
| -rw-r--r-- | components/client-table-v2/import-utils.ts | 100 | ||||
| -rw-r--r-- | components/client-table-v2/index.ts | 7 | ||||
| -rw-r--r-- | components/client-table-v2/preset-actions.ts | 87 | ||||
| -rw-r--r-- | components/client-table-v2/preset-types.ts | 13 |
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 }>; +} |
