"use client"; import type { ColumnPinningState, Row, RowSelectionState, } 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, CheckCheckIcon, CheckCircle, ChevronsUpDown, Clock, Copy, LoaderIcon, MoreHorizontal, Package, PieChart, RefreshCcw, } from "lucide-react"; import type { CSSProperties } from "react"; import * as React from "react"; import { toast } from "sonner"; import { z } from "zod"; import { Badge } from "@/components/ui/badge"; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; const DEFAULT_ROW_HEIGHT = 56; const DEFAULT_OVERSCAN = 8; const DATA_URL = "https://raw.githubusercontent.com/zerostaticthemes/ShadcnBlocks-library-data/refs/heads/main/data/orders-ecommerce-1000.json"; type TextEditorConfig = { type: "text"; multiline?: boolean; placeholder?: string; }; type NumberEditorConfig = { type: "number"; min?: number; max?: number; step?: number; allowEmpty?: boolean; }; type SelectOption = { label: string; value: string; }; type SelectEditorConfig = { type: "select"; options: Array; placeholder?: string; }; type BooleanEditorConfig = { type: "boolean"; }; type ColumnEditorConfig = | TextEditorConfig | NumberEditorConfig | SelectEditorConfig | BooleanEditorConfig; type DataTableColumnMeta = { width?: number | string; minWidth?: number | string; maxWidth?: number | string; headerClassName?: string; cellClassName?: string; className?: string; align?: "left" | "center" | "right"; editor?: ColumnEditorConfig; clearValue?: unknown; }; 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 ColumnStyleResult = { style: CSSProperties; pinned: "left" | "right" | false; lastPinned?: "left" | "right"; }; const getColumnStyles = ( meta: DataTableColumnMeta, column: ColumnWithPinning, ): ColumnStyleResult => { 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 column.getSize === "function") { const computedWidth = column.getSize(); if (typeof computedWidth === "number" && Number.isFinite(computedWidth)) { baseStyle.width = `${computedWidth}px`; } } const pinned = column.getIsPinned?.() ?? false; const pinnedStyle: CSSProperties = {}; let lastPinned: "left" | "right" | undefined; if (pinned) { pinnedStyle.position = "sticky"; pinnedStyle.zIndex = 1; if (pinned === "left") { const start = column.getStart?.("left") ?? 0; pinnedStyle.left = `${start}px`; if (column.getIsLastColumn?.("left")) { lastPinned = "left"; } } if (pinned === "right") { const after = column.getAfter?.("right") ?? 0; pinnedStyle.right = `${after}px`; if (column.getIsFirstColumn?.("right")) { lastPinned = "right"; } } const resolvedWidth = baseStyle.width ?? (typeof column.getSize === "function" ? column.getSize() : undefined); if (resolvedWidth !== undefined) { pinnedStyle.width = typeof resolvedWidth === "number" ? `${resolvedWidth}px` : resolvedWidth; } } return { style: { ...baseStyle, ...pinnedStyle }, pinned, lastPinned }; }; type CellPosition = { rowId: string; columnId: string; }; type ContextMenuState = { open: boolean; x: number; y: number; cell: CellPosition | null; }; type UseDataTableOptions = { data: Array; columns: Array>; getRowId?: (row: TData) => string; initialSorting?: SortingState; initialSelection?: RowSelectionState; initialColumnPinning?: ColumnPinningState; enableRowSelection?: boolean; rowHeight?: number; overscan?: number; onUpdateCells?: ( updates: Array<{ rowId: string; columnId: string; value: unknown }>, ) => void; }; export function useDataTable(options: UseDataTableOptions) { const { data, columns, getRowId, initialSorting = [], initialSelection = {}, enableRowSelection = true, initialColumnPinning = {}, rowHeight = DEFAULT_ROW_HEIGHT, overscan = DEFAULT_OVERSCAN, onUpdateCells, } = options; const [sorting, setSorting] = React.useState(initialSorting); const [rowSelection, setRowSelection] = React.useState(initialSelection); const [columnPinning, setColumnPinning] = React.useState(initialColumnPinning); const tableContainerRef = React.useRef(null); const [editingCell, setEditingCell] = React.useState( null, ); const [editingValue, setEditingValue] = React.useState(""); const [editingConfig, setEditingConfig] = React.useState(null); const [contextMenu, setContextMenu] = React.useState({ open: false, x: 0, y: 0, cell: 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 columnMetaMap = React.useMemo(() => { const map = new Map(); columns.forEach((column) => { const id = column.id ?? ("accessorKey" in column && typeof column.accessorKey === "string" ? column.accessorKey : undefined); if (!id) return; map.set(id, (column.meta as DataTableColumnMeta) ?? {}); }); return map; }, [columns]); const getEditorConfig = React.useCallback( (columnId: string) => columnMetaMap.get(columnId)?.editor, [columnMetaMap], ); const getInitialEditorValue = React.useCallback( (editor: ColumnEditorConfig | undefined, value: unknown) => { if (!editor) { return value === undefined || value === null ? "" : String(value); } switch (editor.type) { case "number": if (value === undefined || value === null) return ""; return typeof value === "number" ? value.toString() : String(value); case "boolean": return Boolean(value); case "select": return typeof value === "string" ? value : ""; case "text": default: return value === undefined || value === null ? "" : String(value); } }, [], ); const coerceEditorValue = React.useCallback( ( editor: ColumnEditorConfig | null, draft: unknown, ): { value: unknown; valid: boolean } => { if (!editor) { return { value: draft === undefined || draft === null ? "" : draft, valid: true, }; } switch (editor.type) { case "number": { const raw = typeof draft === "number" ? draft.toString() : draft === undefined || draft === null ? "" : String(draft); const trimmed = raw.trim(); if (trimmed === "") { if (editor.allowEmpty) { return { value: null, valid: true }; } return { value: 0, valid: true }; } const parsed = Number(trimmed); if (Number.isNaN(parsed)) { return { value: draft, valid: false }; } return { value: parsed, valid: true }; } case "boolean": return { value: Boolean(draft), valid: true }; case "select": return { value: typeof draft === "string" ? draft : "", valid: true, }; case "text": default: return { value: draft === undefined || draft === null ? "" : String(draft), valid: true, }; } }, [], ); 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 applyRowUpdates = React.useCallback( (updates: Array<{ rowId: string; columnId: string; value: unknown }>) => { if (!onUpdateCells) return; const existingRowIds = new Set( table.getRowModel().rows.map((row) => row.id), ); const normalized = updates.filter(({ rowId }) => existingRowIds.has(rowId), ); if (normalized.length > 0) { onUpdateCells(normalized); } }, [table, onUpdateCells], ); const startEditing = React.useCallback( (rowId: string, columnId: string) => { const editor = getEditorConfig(columnId); if (!editor) return; const rows = table.getRowModel().rows; const row = rows.find((item) => item.id === rowId); if (!row) return; const cell = row.getVisibleCells().find((c) => c.column.id === columnId); const value = cell?.getValue(); setEditingCell({ rowId, columnId }); setEditingConfig(editor); setEditingValue(getInitialEditorValue(editor, value)); }, [table, getEditorConfig, getInitialEditorValue], ); const cancelEditing = React.useCallback(() => { setEditingCell(null); setEditingValue(""); setEditingConfig(null); }, []); const commitEditing = React.useCallback( (draftValue?: unknown) => { if (!editingCell) return; const { value: nextValue, valid } = coerceEditorValue( editingConfig, draftValue ?? editingValue, ); if (!valid) { return; } applyRowUpdates([ { rowId: editingCell.rowId, columnId: editingCell.columnId, value: nextValue, }, ]); setEditingCell(null); setEditingValue(""); setEditingConfig(null); }, [ editingCell, editingValue, editingConfig, applyRowUpdates, coerceEditorValue, ], ); const copyCellToClipboard = React.useCallback( async (cell: CellPosition) => { const row = table .getRowModel() .rows.find((item) => item.id === cell.rowId); if (!row) { return; } if ( typeof navigator === "undefined" || !navigator.clipboard || typeof navigator.clipboard.writeText !== "function" ) { toast.error("کلیپ‌بورد در این مرورگر در دسترس نیست"); return; } const value = row.getValue(cell.columnId); const text = value === null || value === undefined ? "" : typeof value === "string" ? value : String(value); try { await navigator.clipboard.writeText(text); toast.success("مقدار سلول در کلیپ‌بورد کپی شد"); } catch (error) { console.error("Failed to copy cell", error); toast.error("کپی در کلیپ‌بورد انجام نشد"); } }, [table], ); const handleCellDoubleClick = React.useCallback( (rowId: string, columnId: string) => { startEditing(rowId, columnId); }, [startEditing], ); const handleCellContextMenu = React.useCallback( ( rowId: string, columnId: string, event: React.MouseEvent, ) => { event.preventDefault(); event.stopPropagation(); if (editingCell) { commitEditing(); } setContextMenu({ open: true, x: event.clientX, y: event.clientY, cell: { rowId, columnId }, }); }, [editingCell, commitEditing], ); const closeContextMenu = React.useCallback(() => { setContextMenu((prev) => ({ ...prev, open: false })); }, []); return { table, sorting, setSorting, rowSelection, setRowSelection, columnPinning, setColumnPinning, tableContainerRef, rowVirtualizer, editingCell, editingValue, setEditingValue, handleCellDoubleClick, commitEditing, cancelEditing, handleCellContextMenu, contextMenu, closeContextMenu, copyCellToClipboard, }; } 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; itemName: string; idField?: string; } export function DataTableRowActions>({ row, itemName, idField = "id", }: DataTableRowActionsProps) { const item = row.original as Record; const resolvedId = (item[idField] as string | number | undefined) ?? (item.id as string | number | undefined); const idText = resolvedId != null ? String(resolvedId) : ""; const handleCopy = async () => { if (!idText) { toast.error("شناسه‌ای برای کپی موجود نیست"); return; } if ( typeof navigator === "undefined" || !navigator.clipboard || typeof navigator.clipboard.writeText !== "function" ) { toast.error("کلیپ‌بورد در این مرورگر در دسترس نیست"); return; } try { await navigator.clipboard.writeText(idText); toast.success("شناسه در کلیپ‌بورد کپی شد"); } catch (error) { console.error("Failed to copy ID", error); toast.error("کپی در کلیپ‌بورد انجام نشد"); } }; return ( عملیات کپی شناسه {itemName} ویرایش {itemName} مشاهده جزئیات ); } export const schema = z.object({ id: z.string(), purchased: z.string(), items: z.number(), customer: z.string(), customer_email: z.string(), customer_segment: z.string(), fulfillment_channel: z.string(), shipping_service: z.string(), warehouse: z.string(), order_status: z.string(), payment_status: z.string(), tracking_number: z.string(), assigned_to: z.string(), return_status: z.string(), coupon_code: z.string().nullable(), gift: z.boolean(), notes: z.string(), }); export type Order = z.infer; export type StatusOption = { label: string; value: string; icon?: React.ComponentType<{ className?: string }>; }; export const orderStatuses: StatusOption[] = [ { label: "تحویل‌شده", value: "Fulfilled", icon: CheckCircle }, { label: "آماده تحویل", value: "Ready for pickup", icon: Package }, { label: "تحویل‌نشده", value: "Unfulfilled", icon: Clock }, ]; export const paymentStatuses: StatusOption[] = [ { label: "پرداخت‌شده", value: "Paid", icon: CheckCheckIcon }, { label: "در انتظار پیگیری", value: "Pending", icon: LoaderIcon }, { label: "مسترد شده", value: "Refunded", icon: RefreshCcw }, { label: "مسترد جزئی", value: "Partially refunded", icon: PieChart }, ]; const formatNumber = (value: number) => value.toLocaleString("fa-IR"); const segmentLabels: Record = { Enterprise: "سازمانی", Marketplace: "بازارچه", Retail: "خرده‌فروشی", VIP: "ویژه", Wholesale: "عمده‌فروشی", }; const fulfillmentLabels: Record = { "3PL": "لجستیک شخص ثالث", Dropship: "دراپ‌شیپ", FBA: "FBA", "Merchant Fulfilled": "ارسال توسط فروشنده", }; const shippingLabels: Record = { "Expedited Air": "هوایی سریع", "Prime 1-Day": "پرایم ۱ روزه", "Prime 2-Day": "پرایم ۲ روزه", "Same-Day Local": "تحویل همان‌روز", "Standard Ground": "ارسال عادی", }; const returnStatusLabels: Record = { Authorized: "تأیید‌شده", None: "ندارد", Received: "دریافت‌شده", Requested: "درخواست‌شده", }; const translateLabel = (map: Record, value: string) => map[value] ?? value; const createStatusLookup = (options: StatusOption[]) => options.reduce>((acc, option) => { acc[option.value] = option; return acc; }, {}); const orderStatusLookup = createStatusLookup(orderStatuses); const paymentStatusLookup = createStatusLookup(paymentStatuses); export const getOrderStatusMeta = (status: string) => orderStatusLookup[status]; export const getPaymentStatusMeta = (status: string) => paymentStatusLookup[status]; export const getStatusVariant = (status: string) => { switch (status) { case "Fulfilled": return "default"; case "Ready for pickup": return "secondary"; case "Unfulfilled": return "outline"; default: return "outline"; } }; export const getPaymentVariant = (status: string) => { switch (status) { case "Paid": return "default"; case "Pending": return "secondary"; case "Refunded": return "outline"; case "Partially refunded": return "outline"; default: return "outline"; } }; 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, editor: { type: "text" }, }, }, { accessorKey: "purchased", header: ({ column }) => ( ), cell: ({ row }) => { return (
{row.original.purchased}
); }, enableSorting: false, enableHiding: false, meta: { width: 280, editor: { type: "text" }, }, }, { accessorKey: "customer", header: ({ column }) => ( ), cell: ({ row }) => (
{row.original.customer}
), meta: { width: 200, editor: { type: "text" }, }, }, { accessorKey: "customer_email", header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.customer_email} ), meta: { width: 240, editor: { type: "text" }, }, }, { accessorKey: "customer_segment", header: ({ column }) => ( ), cell: ({ row }) => ( {translateLabel(segmentLabels, row.original.customer_segment)} ), meta: { className: "whitespace-nowrap", width: 160, editor: { type: "select", options: [ { label: "عمده‌فروشی", value: "Wholesale" }, { label: "خرده‌فروشی", value: "Retail" }, { label: "سازمانی", value: "Enterprise" }, { label: "بازارچه", value: "Marketplace" }, { label: "ویژه", value: "VIP" }, ], placeholder: "انتخاب کنید…", }, }, filterFn: (row, id, value) => { if (!Array.isArray(value) || value.length === 0) return true; return value.includes(String(row.getValue(id))); }, }, { accessorKey: "fulfillment_channel", header: ({ column }) => ( ), cell: ({ row }) => ( {translateLabel(fulfillmentLabels, row.original.fulfillment_channel)} ), meta: { className: "whitespace-nowrap", width: 160, editor: { type: "text" }, }, filterFn: (row, id, value) => { if (!Array.isArray(value) || value.length === 0) return true; return value.includes(String(row.getValue(id))); }, }, { accessorKey: "shipping_service", header: ({ column }) => ( ), cell: ({ row }) => ( {translateLabel(shippingLabels, row.original.shipping_service)} ), meta: { width: 220, editor: { type: "text" } }, }, { accessorKey: "warehouse", header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.warehouse} ), meta: { className: "whitespace-nowrap", width: 140, editor: { type: "text" }, }, }, { accessorKey: "order_status", header: ({ column }) => ( ), cell: ({ row }) => { const status = row.original.order_status; const statusMeta = getOrderStatusMeta(status); const StatusIcon = statusMeta?.icon; return (
{StatusIcon ? : null} {statusMeta?.label ?? status}
); }, filterFn: (row, id, value) => { if (!Array.isArray(value) || value.length === 0) return true; return value.includes(String(row.getValue(id))); }, enableSorting: false, meta: { width: 200, editor: { type: "select", options: orderStatuses.map((status) => ({ label: status.label, value: status.value, })), }, }, }, { accessorKey: "payment_status", header: ({ column }) => ( ), cell: ({ row }) => { const status = row.original.payment_status; const statusMeta = getPaymentStatusMeta(status); const StatusIcon = statusMeta?.icon; return (
{StatusIcon ? : null} {statusMeta?.label ?? status}
); }, filterFn: (row, id, value) => { if (!Array.isArray(value) || value.length === 0) return true; return value.includes(String(row.getValue(id))); }, enableSorting: false, meta: { width: 200, editor: { type: "select", options: paymentStatuses.map((status) => ({ label: status.label, value: status.value, })), }, }, }, { accessorKey: "items", header: ({ column }) => ( ), cell: ({ row }) => ( {formatNumber(row.original.items)} ), meta: { align: "center", className: "tabular-nums", width: 100, editor: { type: "number", min: 0, step: 1 }, }, }, { accessorKey: "tracking_number", header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.tracking_number} ), meta: { className: "whitespace-nowrap", width: 200, editor: { type: "text" }, }, }, { accessorKey: "assigned_to", header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.assigned_to} ), meta: { className: "whitespace-nowrap", width: 180, editor: { type: "text" }, }, }, { accessorKey: "return_status", header: ({ column }) => ( ), cell: ({ row }) => ( {translateLabel(returnStatusLabels, row.original.return_status)} ), filterFn: (row, id, value) => { if (!Array.isArray(value) || value.length === 0) return true; return value.includes(String(row.getValue(id))); }, meta: { width: 180, editor: { type: "text" }, }, }, { accessorKey: "coupon_code", header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.coupon_code ?? ""} ), meta: { width: 140, editor: { type: "text" } }, }, { accessorKey: "gift", header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.gift ? "بله" : "خیر"} ), meta: { width: 120, editor: { type: "boolean" } }, }, { accessorKey: "notes", header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.notes} ), meta: { width: 260, editor: { type: "text", multiline: true }, }, }, { id: "actions", cell: ({ row }) => ( ), meta: { width: 56, minWidth: 48, headerClassName: "px-2", cellClassName: "px-2", align: "center", }, }, ]; type CellEditorProps = { editor: ColumnEditorConfig; value: unknown; onChange: (value: unknown) => void; onCommit: (value?: unknown) => void; onCancel: () => void; }; const CellEditor = React.memo(function CellEditor({ editor, value, onChange, onCommit, onCancel, }: CellEditorProps) { const handleKeyDown = (e: React.KeyboardEvent) => { e.stopPropagation(); if (e.key === "Escape") { e.preventDefault(); onCancel(); } }; if (editor.type === "number") { return ( onChange(e.target.value)} onBlur={() => onCommit()} onClick={(e) => e.stopPropagation()} onKeyDown={(e) => { handleKeyDown(e); if (e.key === "Enter") { e.preventDefault(); onCommit(); } }} /> ); } if (editor.type === "select") { return ( ); } if (editor.type === "boolean") { const boolValue = Boolean(value); return (
{ const nextValue = Boolean(checked); onChange(nextValue); onCommit(nextValue); }} /> {boolValue ? "بله" : "خیر"}
); } if (editor.type === "text" && editor.multiline) { return (