"use client"; import type { ColumnFiltersState, ColumnPinningState, Row, RowSelectionState, VisibilityState, } from "@tanstack/react-table"; import { type Column, type ColumnDef, flexRender, getCoreRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getSortedRowModel, type SortingState, useReactTable, } from "@tanstack/react-table"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ArrowDown, ArrowUp, ChevronsUpDown, LoaderIcon, MoreHorizontal, Search, X, } from "lucide-react"; import type { CSSProperties } from "react"; import * as React from "react"; import { toast } from "sonner"; import { z } from "zod"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { cn } from "@/lib/utils"; const DEFAULT_ROW_HEIGHT = 56; const DEFAULT_OVERSCAN = 8; const SEARCH_SHORTCUT_KEY = "k"; const DATA_URL = "https://raw.githubusercontent.com/zerostaticthemes/ShadcnBlocks-library-data/refs/heads/main/data/orders-ecommerce-1000.json"; type CellMatch = { rowIndex: number; columnId: string; }; type ColumnStyleMeta = { align?: "left" | "center" | "right"; headerClassName?: string; cellClassName?: string; className?: string; width?: number | string; minWidth?: number | string; maxWidth?: number | string; }; type ColumnWithPinning = { getIsPinned?: () => "left" | "right" | false; getIsLastColumn?: (position: "left" | "right") => boolean; getIsFirstColumn?: (position: "left" | "right") => boolean; getStart?: (position: "left" | "right") => number; getAfter?: (position: "left" | "right") => number; getSize?: () => number; }; type UseDataTableOptions = { data: Array; columns: Array>; getRowId?: (row: TData) => string; initialSorting?: SortingState; initialGlobalFilter?: string; initialFilters?: ColumnFiltersState; initialVisibility?: VisibilityState; initialSelection?: RowSelectionState; initialColumnPinning?: ColumnPinningState; enableRowSelection?: boolean; rowHeight?: number; overscan?: number; }; function getColumnLayoutStyles( meta: ColumnStyleMeta, columnWithPinning: ColumnWithPinning, ) { const baseStyle: CSSProperties = {}; if (meta.width !== undefined) { baseStyle.width = typeof meta.width === "number" ? `${meta.width}px` : meta.width; } if (meta.minWidth !== undefined) { baseStyle.minWidth = typeof meta.minWidth === "number" ? `${meta.minWidth}px` : meta.minWidth; } if (meta.maxWidth !== undefined) { baseStyle.maxWidth = typeof meta.maxWidth === "number" ? `${meta.maxWidth}px` : meta.maxWidth; } if ( baseStyle.width === undefined && typeof columnWithPinning.getSize === "function" ) { const computedWidth = columnWithPinning.getSize(); if (typeof computedWidth === "number" && Number.isFinite(computedWidth)) { baseStyle.width = `${computedWidth}px`; } } const pinned = columnWithPinning.getIsPinned?.(); const pinnedStyle: CSSProperties = {}; let lastPinned: "left" | "right" | undefined; if (pinned) { pinnedStyle.position = "sticky"; pinnedStyle.zIndex = 1; if (pinned === "left") { const start = columnWithPinning.getStart?.("left") ?? 0; pinnedStyle.left = `${start}px`; if (columnWithPinning.getIsLastColumn?.("left")) { lastPinned = "left"; } } if (pinned === "right") { const after = columnWithPinning.getAfter?.("right") ?? 0; pinnedStyle.right = `${after}px`; if (columnWithPinning.getIsFirstColumn?.("right")) { lastPinned = "right"; } } const resolvedWidth = baseStyle.width ?? (typeof columnWithPinning.getSize === "function" ? columnWithPinning.getSize() : undefined); if (resolvedWidth !== undefined) { pinnedStyle.width = typeof resolvedWidth === "number" ? `${resolvedWidth}px` : resolvedWidth; } } return { baseStyle, pinnedStyle, pinned, lastPinned }; } export function useDataTable(options: UseDataTableOptions) { const { data, columns, getRowId, initialSorting = [], initialSelection = {}, enableRowSelection = true, initialColumnPinning = {}, rowHeight = DEFAULT_ROW_HEIGHT, overscan = DEFAULT_OVERSCAN, } = options; const [sorting, setSorting] = React.useState(initialSorting); const [rowSelection, setRowSelection] = React.useState(initialSelection); const [columnPinning, setColumnPinning] = React.useState(initialColumnPinning); const [searchOpen, setSearchOpen] = React.useState(false); const [searchQuery, setSearchQuery] = React.useState(""); const [searchMatches, setSearchMatches] = React.useState([]); const [matchIndex, setMatchIndex] = React.useState(-1); const tableContainerRef = React.useRef(null); const table = useReactTable({ data, columns, getRowId, state: { sorting, columnPinning, rowSelection, }, onSortingChange: setSorting, onRowSelectionChange: setRowSelection, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), getFacetedRowModel: getFacetedRowModel(), getFacetedUniqueValues: getFacetedUniqueValues(), onColumnPinningChange: setColumnPinning, enableRowSelection, }); const rowVirtualizer = useVirtualizer({ count: table.getRowModel().rows.length, getScrollElement: () => tableContainerRef.current, estimateSize: () => rowHeight, overscan, measureElement: typeof window !== "undefined" && navigator.userAgent.indexOf("Firefox") === -1 ? (element) => element?.getBoundingClientRect().height : undefined, }); const tableState = table.getState(); const tableSorting = tableState.sorting; const tableColumnFilters = tableState.columnFilters; const tableColumnVisibility = tableState.columnVisibility; const searchMatchKey = React.useCallback( (rowIndex: number, columnId: string) => `${rowIndex}::${columnId}`, [], ); React.useEffect(() => { if (!searchQuery.trim()) { setSearchMatches([]); setMatchIndex(-1); return; } const lowerQuery = searchQuery.toLowerCase(); const rows = table.getRowModel().rows; const matches: CellMatch[] = []; for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { const row = rows[rowIndex]; if (!row) continue; const cells = row.getVisibleCells(); for (let i = 0; i < cells.length; i++) { const cell = cells[i]; const value = cell.getValue(); const stringValue = value === undefined || value === null ? "" : String(value); if (stringValue.toLowerCase().includes(lowerQuery)) { matches.push({ rowIndex, columnId: cell.column.id }); } } } setSearchMatches(matches); setMatchIndex(matches.length > 0 ? 0 : -1); if (matches.length > 0) { const firstMatch = matches[0]; requestAnimationFrame(() => { rowVirtualizer.scrollToIndex(firstMatch.rowIndex, { align: "center", }); }); } }, [ searchQuery, table, rowVirtualizer, data, tableSorting, tableColumnFilters, tableColumnVisibility, ]); const openSearch = React.useCallback(() => { setSearchOpen(true); }, []); const closeSearch = React.useCallback(() => { setSearchOpen(false); setSearchQuery(""); setSearchMatches([]); setMatchIndex(-1); }, []); const goToMatch = React.useCallback( (targetIndex: number) => { const match = searchMatches[targetIndex]; if (!match) return; setMatchIndex(targetIndex); rowVirtualizer.scrollToIndex(match.rowIndex, { align: "center" }); }, [rowVirtualizer, searchMatches], ); const goToNextMatch = React.useCallback(() => { if (searchMatches.length === 0) return; const nextIndex = (matchIndex + 1) % searchMatches.length; goToMatch(nextIndex); }, [goToMatch, matchIndex, searchMatches.length]); const goToPrevMatch = React.useCallback(() => { if (searchMatches.length === 0) return; const prevIndex = matchIndex - 1 < 0 ? searchMatches.length - 1 : matchIndex - 1; goToMatch(prevIndex); }, [goToMatch, matchIndex, searchMatches.length]); const searchMatchSet = React.useMemo(() => { const set = new Set(); for (const match of searchMatches) { set.add(searchMatchKey(match.rowIndex, match.columnId)); } return set; }, [searchMatches, searchMatchKey]); const activeMatchKey = React.useMemo(() => { if (matchIndex < 0 || !searchMatches[matchIndex]) return null; const match = searchMatches[matchIndex]; return searchMatchKey(match.rowIndex, match.columnId); }, [matchIndex, searchMatches, searchMatchKey]); const isSearchMatch = React.useCallback( (rowIndex: number, columnId: string) => searchMatchSet.has(searchMatchKey(rowIndex, columnId)), [searchMatchKey, searchMatchSet], ); const isActiveSearchMatch = React.useCallback( (rowIndex: number, columnId: string) => activeMatchKey === searchMatchKey(rowIndex, columnId), [activeMatchKey, searchMatchKey], ); const updateSearchQuery = React.useCallback((value: string) => { setSearchQuery(value); }, []); React.useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; const { key, ctrlKey, metaKey, shiftKey } = event; const isCmdOrCtrl = ctrlKey || metaKey; const isSearchShortcut = isCmdOrCtrl && key.toLowerCase() === SEARCH_SHORTCUT_KEY; const target = event.target as HTMLElement | null; const container = tableContainerRef.current; const isInsideTable = container?.contains(target) ?? false; const isInsideSearch = target?.closest("[data-table-search-overlay]") !== null; const isInputLike = target?.tagName === "INPUT" || target?.tagName === "TEXTAREA" || target?.isContentEditable; if (isSearchShortcut) { if (isInsideTable || isInsideSearch || !isInputLike) { event.preventDefault(); openSearch(); } return; } if (!searchOpen) return; if (key === "Escape") { event.preventDefault(); closeSearch(); return; } if (key === "Enter") { const isWithinSearchContext = isInsideSearch || isInsideTable; if (!isWithinSearchContext) return; event.preventDefault(); if (shiftKey) { goToPrevMatch(); } else { goToNextMatch(); } } }; window.addEventListener("keydown", onKeyDown, true); return () => window.removeEventListener("keydown", onKeyDown, true); }, [ closeSearch, goToNextMatch, goToPrevMatch, openSearch, searchOpen, tableContainerRef, ]); return { table, sorting, setSorting, rowSelection, setRowSelection, columnPinning, setColumnPinning, tableContainerRef, rowVirtualizer, searchOpen, searchQuery, searchMatches, matchIndex, openSearch, closeSearch, goToNextMatch, goToPrevMatch, updateSearchQuery, isSearchMatch, isActiveSearchMatch, }; } type DataTableColumnHeaderProps = { column: Column; title: string; }; export const DataTableColumnHeader = ({ column, title, }: DataTableColumnHeaderProps) => { const canSort = column.getCanSort(); const sorted = column.getIsSorted(); if (!canSort) { return ( {title} ); } return ( ); }; interface DataTableRowActionsProps { row: Row; schema: z.ZodSchema; itemName: string; idField?: string; } export function DataTableRowActions({ row, schema, itemName, idField = "id", }: DataTableRowActionsProps) { const item = schema.parse(row.original) as TData; const itemRecord = item as Record; const handleCopy = async () => { const value = itemRecord[idField] ?? itemRecord.id; if (!value) { toast.error("شناسه‌ای برای کپی موجود نیست"); return; } if (!navigator.clipboard?.writeText) { toast.error("کلیپ‌بورد در این مرورگر در دسترس نیست"); return; } try { await navigator.clipboard.writeText(String(value)); toast.success("شناسه در کلیپ‌بورد کپی شد"); } catch (error) { console.error("Error copying to clipboard", error); toast.error("کپی در کلیپ‌بورد انجام نشد"); } }; return ( عملیات کپی شناسه {itemName} ویرایش {itemName} مشاهده جزئیات ); } type SearchOverlayProps = { searchOpen: boolean; searchQuery: string; matchIndex: number; totalMatches: number; onSearchQueryChange: (value: string) => void; onClose: () => void; onNextMatch: () => void; onPrevMatch: () => void; className?: string; }; const SearchOverlay = ({ searchOpen, searchQuery, matchIndex, totalMatches, onSearchQueryChange, onClose, onNextMatch, onPrevMatch, className, }: SearchOverlayProps) => { const inputRef = React.useRef(null); const hasMatches = totalMatches > 0; React.useEffect(() => { if (searchOpen) { requestAnimationFrame(() => { inputRef.current?.focus(); inputRef.current?.select(); }); } }, [searchOpen]); if (!searchOpen) return null; return (
onSearchQueryChange(event.target.value)} placeholder="جستجو در جدول" className="h-8 w-52" role="searchbox" /> {hasMatches ? `${(matchIndex + 1).toLocaleString("fa-IR")} / ${totalMatches.toLocaleString("fa-IR")}` : "۰ / ۰"}
); }; export const schema = z.object({ id: z.string(), purchased: z.string(), items: z.number(), customer: z.string(), customer_email: z.string(), fulfillment_channel: z.string(), notes: z.string(), }); export type Order = z.infer; const formatNumber = (value: number) => value.toLocaleString("fa-IR"); const fulfillmentLabels: Record = { "3PL": "لجستیک شخص ثالث", Dropship: "دراپ‌شیپ", FBA: "FBA", "Merchant Fulfilled": "ارسال توسط فروشنده", }; const translateLabel = (map: Record, value: string) => map[value] ?? value; export const columns: ColumnDef>[] = [ { id: "select", header: ({ table }) => ( table.toggleAllPageRowsSelected(!!value)} aria-label="انتخاب همه" className="translate-y-[2px]" /> ), cell: ({ row }) => ( row.toggleSelected(!!value)} aria-label="انتخاب رکورد" className="translate-y-[2px]" /> ), enableSorting: false, enableHiding: false, meta: { width: 56, minWidth: 48, headerClassName: "px-2", cellClassName: "px-2", align: "center", }, }, { accessorKey: "id", header: ({ column }) => ( ), cell: ({ row }) => (
{row.original.id}
), enableSorting: true, enableHiding: false, meta: { width: 120, }, }, { accessorKey: "purchased", header: ({ column }) => ( ), cell: ({ row }) => (
{row.original.purchased}
), enableSorting: false, enableHiding: false, meta: { width: 280, }, }, { accessorKey: "items", header: ({ column }) => ( ), cell: ({ row }) => ( {formatNumber(row.original.items)} ), meta: { align: "center", className: "tabular-nums", width: 100 }, }, { accessorKey: "customer", header: ({ column }) => ( ), cell: ({ row }) => (
{row.original.customer}
), meta: { width: 200, }, }, { accessorKey: "customer_email", header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.customer_email} ), meta: { width: 240, }, }, { accessorKey: "fulfillment_channel", header: ({ column }) => ( ), cell: ({ row }) => ( {translateLabel(fulfillmentLabels, row.original.fulfillment_channel)} ), meta: { className: "whitespace-nowrap", width: 160 }, filterFn: (row, id, value) => { if (!Array.isArray(value) || value.length === 0) return true; return value.includes(String(row.getValue(id))); }, }, { accessorKey: "notes", header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.notes} ), meta: { width: 260 }, }, { id: "actions", cell: ({ row }) => ( ), meta: { width: 56, minWidth: 48, headerClassName: "px-2", cellClassName: "px-2", align: "center", }, }, ]; export const DataTable30 = ({ className }: { className?: string }) => { const [data, setData] = React.useState([]); const [isLoading, setIsLoading] = React.useState(true); const [error, setError] = React.useState(null); const openSearchRef = React.useRef<(() => void) | null>(null); const columnsWithSearch: ColumnDef[] = React.useMemo( () => columns.map( (column): ColumnDef => column.id === "actions" ? { ...column, id: column.id ?? "actions", header: () => ( ), } : column, ), [], ); React.useEffect(() => { let active = true; const fetchData = async () => { setError(null); setIsLoading(true); try { const res = await fetch(DATA_URL); if (!res.ok) { const message = `بارگذاری داده سفارش‌ها نام مخاطبوفق بود (${res.status})`; console.error(message, res.statusText); if (active) setError(message); return; } const json = await res.json(); const parsed = schema.array().parse(json); if (active) { setData(parsed); } } catch (error) { console.error("Error fetching orders data", error); if (active) setError("خطا در بارگذاری داده سفارش‌ها"); } finally { if (active) setIsLoading(false); } }; fetchData(); return () => { active = false; }; }, []); const { table, tableContainerRef, rowVirtualizer, searchOpen, searchQuery, searchMatches, matchIndex, updateSearchQuery, closeSearch, goToNextMatch, goToPrevMatch, isSearchMatch, isActiveSearchMatch, openSearch, } = useDataTable({ data, columns: columnsWithSearch, getRowId: (row) => row.id.toString(), initialColumnPinning: { left: ["select"], right: ["actions"] }, }); React.useEffect(() => { openSearchRef.current = openSearch; }, [openSearch]); const visibleColumns = table.getVisibleLeafColumns(); const visibleColumnCount = Math.max(visibleColumns.length, 1); const visibleColumnIds = visibleColumns.map((column) => column.id); const rightPinnedColumnIdSet = new Set( table.getState().columnPinning.right ?? [], ); const firstRightPinnedIndex = visibleColumnIds.findIndex((columnId) => rightPinnedColumnIdSet.has(columnId), ); const rightStickyDividerTargetId = firstRightPinnedIndex > 0 ? visibleColumnIds[firstRightPinnedIndex - 1] : null; const rowModel = table.getRowModel().rows; 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; const hasRows = rowModel.length > 0; const totalMatches = searchMatches.length; return (

جدول مجازی با جستجو

با Cmd/Ctrl+K یا آیکن جستجو، در جدول جستجو کنید و نتایج برجسته می‌شوند. با اینتر یا Shift+اینتر بین نتایج جابه‌جا شوید.

{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { const meta = (header.column.columnDef.meta as ColumnStyleMeta) ?? {}; const columnWithPinning = header.column as unknown as ColumnWithPinning; const { baseStyle, pinnedStyle, pinned, lastPinned } = getColumnLayoutStyles(meta, columnWithPinning); const alignClass = meta.align === "center" ? "text-center" : meta.align === "right" ? "text-end" : meta.align === "left" ? "text-start" : undefined; return ( {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext(), )} ); })} ))} {isLoading ? (
در حال بارگذاری سفارش‌ها…
) : error ? ( {error} ) : hasRows ? ( <> {paddingTop > 0 ? ( ) : null} {virtualRows.map((virtualRow) => { const row = rowModel[virtualRow.index]; if (!row) return null; return ( {row.getVisibleCells().map((cell) => { const meta = (cell.column.columnDef .meta as ColumnStyleMeta) ?? {}; const columnWithPinning = cell.column as unknown as ColumnWithPinning; const { baseStyle, pinnedStyle, pinned, lastPinned, } = getColumnLayoutStyles( meta, columnWithPinning, ); const isMatch = isSearchMatch( virtualRow.index, cell.column.id, ); const isActiveMatch = isActiveSearchMatch( virtualRow.index, cell.column.id, ); const highlightClass = isActiveMatch ? "bg-amber-200/90 text-foreground dark:bg-amber-300/30" : isMatch ? "bg-amber-100/80 text-foreground dark:bg-amber-200/20" : undefined; const alignClass = meta.align === "center" ? "text-center" : meta.align === "right" ? "text-end" : meta.align === "left" ? "text-start" : undefined; return (
{flexRender( cell.column.columnDef.cell, cell.getContext(), )}
); })}
); })} {paddingBottom > 0 ? ( ) : null} ) : ( نتیجه‌ای یافت نشد. )}
); };