1195 lines
41 KiB
TypeScript
1195 lines
41 KiB
TypeScript
"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,
|
||
CheckCheckIcon,
|
||
CheckCircle,
|
||
ChevronsUpDown,
|
||
Clock,
|
||
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 {
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
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 UseDataTableOptions<TData> = {
|
||
data: Array<TData>;
|
||
columns: Array<ColumnDef<TData, unknown>>;
|
||
getRowId?: (row: TData) => string;
|
||
initialSorting?: SortingState;
|
||
initialGlobalFilter?: string;
|
||
initialFilters?: ColumnFiltersState;
|
||
initialVisibility?: VisibilityState;
|
||
initialSelection?: RowSelectionState;
|
||
initialColumnPinning?: ColumnPinningState;
|
||
|
||
enableRowSelection?: boolean;
|
||
rowHeight?: number;
|
||
overscan?: number;
|
||
};
|
||
|
||
export function useDataTable<TData>(options: UseDataTableOptions<TData>) {
|
||
const {
|
||
data,
|
||
columns,
|
||
getRowId,
|
||
initialSorting = [],
|
||
|
||
initialSelection = {},
|
||
enableRowSelection = true,
|
||
initialColumnPinning = {},
|
||
rowHeight = DEFAULT_ROW_HEIGHT,
|
||
overscan = DEFAULT_OVERSCAN,
|
||
} = options;
|
||
|
||
const [sorting, setSorting] = React.useState<SortingState>(initialSorting);
|
||
|
||
const [rowSelection, setRowSelection] =
|
||
React.useState<RowSelectionState>(initialSelection);
|
||
const [columnPinning, setColumnPinning] =
|
||
React.useState<ColumnPinningState>(initialColumnPinning);
|
||
const tableContainerRef = React.useRef<HTMLDivElement>(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,
|
||
});
|
||
|
||
return {
|
||
table,
|
||
sorting,
|
||
setSorting,
|
||
rowSelection,
|
||
setRowSelection,
|
||
columnPinning,
|
||
setColumnPinning,
|
||
tableContainerRef,
|
||
rowVirtualizer,
|
||
};
|
||
}
|
||
|
||
type DataTableColumnHeaderProps<TData, TValue> = {
|
||
column: Column<TData, TValue>;
|
||
title: string;
|
||
};
|
||
|
||
export const DataTableColumnHeader = <TData, TValue>({
|
||
column,
|
||
title,
|
||
}: DataTableColumnHeaderProps<TData, TValue>) => {
|
||
const canSort = column.getCanSort();
|
||
const sorted = column.getIsSorted();
|
||
|
||
if (!canSort) {
|
||
return (
|
||
<span className="flex h-8 items-center text-sm font-medium text-foreground">
|
||
{title}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="flex h-8 items-center gap-2 px-0 text-sm font-medium text-foreground"
|
||
onClick={() => column.toggleSorting(sorted === "asc")}
|
||
>
|
||
<span>{title}</span>
|
||
{sorted === "desc" ? (
|
||
<ArrowDown className="h-4 w-4" />
|
||
) : sorted === "asc" ? (
|
||
<ArrowUp className="h-4 w-4" />
|
||
) : (
|
||
<ChevronsUpDown className="h-4 w-4 opacity-50" />
|
||
)}
|
||
</Button>
|
||
);
|
||
};
|
||
|
||
interface DataTableRowActionsProps<TData> {
|
||
row: Row<TData>;
|
||
schema: z.ZodSchema;
|
||
itemName: string;
|
||
idField?: string;
|
||
}
|
||
|
||
export function DataTableRowActions<TData>({
|
||
row,
|
||
schema,
|
||
itemName,
|
||
idField = "id",
|
||
}: DataTableRowActionsProps<TData>) {
|
||
const item = schema.parse(row.original) as TData;
|
||
const itemRecord = item as Record<string, unknown>;
|
||
const handleCopy = async () => {
|
||
const value = itemRecord[idField] ?? itemRecord.id;
|
||
if (!value) {
|
||
toast.error("شناسهای برای کپی موجود نیست");
|
||
return;
|
||
}
|
||
if (
|
||
typeof navigator === "undefined" ||
|
||
!navigator.clipboard ||
|
||
typeof navigator.clipboard.writeText !== "function"
|
||
) {
|
||
toast.error("کلیپبورد در این مرورگر در دسترس نیست");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(String(value));
|
||
toast.success("شناسه در کلیپبورد کپی شد");
|
||
} catch (error) {
|
||
console.error("Failed to copy ID", error);
|
||
toast.error("کپی در کلیپبورد انجام نشد");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="size-8 data-[state=open]:bg-muted"
|
||
>
|
||
<MoreHorizontal />
|
||
<span className="sr-only">باز کردن منو</span>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="start" className="w-[160px]">
|
||
<DropdownMenuLabel>عملیات</DropdownMenuLabel>
|
||
<DropdownMenuItem onClick={handleCopy}>
|
||
کپی شناسه {itemName}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem>ویرایش {itemName}</DropdownMenuItem>
|
||
<DropdownMenuItem>مشاهده جزئیات</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
);
|
||
}
|
||
|
||
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(),
|
||
payment_method: 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<typeof schema>;
|
||
|
||
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<string, string> = {
|
||
Enterprise: "سازمانی",
|
||
Marketplace: "بازارچه",
|
||
Retail: "خردهفروشی",
|
||
VIP: "ویژه",
|
||
Wholesale: "عمدهفروشی",
|
||
};
|
||
|
||
const fulfillmentLabels: Record<string, string> = {
|
||
"3PL": "لجستیک شخص ثالث",
|
||
Dropship: "دراپشیپ",
|
||
FBA: "FBA",
|
||
"Merchant Fulfilled": "ارسال توسط فروشنده",
|
||
};
|
||
|
||
const shippingLabels: Record<string, string> = {
|
||
"Expedited Air": "هوایی سریع",
|
||
"Prime 1-Day": "پرایم ۱ روزه",
|
||
"Prime 2-Day": "پرایم ۲ روزه",
|
||
"Same-Day Local": "تحویل همانروز",
|
||
"Standard Ground": "ارسال عادی",
|
||
};
|
||
|
||
const returnStatusLabels: Record<string, string> = {
|
||
Authorized: "تأییدشده",
|
||
None: "ندارد",
|
||
Received: "دریافتشده",
|
||
Requested: "درخواستشده",
|
||
};
|
||
|
||
const translateLabel = (map: Record<string, string>, value: string) =>
|
||
map[value] ?? value;
|
||
|
||
const createStatusLookup = (options: StatusOption[]) =>
|
||
options.reduce<Record<string, StatusOption>>((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<z.infer<typeof schema>>[] = [
|
||
{
|
||
id: "select",
|
||
header: ({ table }) => (
|
||
<Checkbox
|
||
checked={
|
||
table.getIsAllPageRowsSelected() ||
|
||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||
}
|
||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||
aria-label="انتخاب همه"
|
||
className="translate-y-[2px]"
|
||
/>
|
||
),
|
||
cell: ({ row }) => (
|
||
<Checkbox
|
||
checked={row.getIsSelected()}
|
||
onCheckedChange={(value) => 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 }) => (
|
||
<DataTableColumnHeader column={column} title="سفارش" />
|
||
),
|
||
cell: ({ row }) => (
|
||
<div className="w-[120px] font-mono">{row.original.id}</div>
|
||
),
|
||
enableSorting: true,
|
||
enableHiding: false,
|
||
meta: {
|
||
width: 120,
|
||
},
|
||
},
|
||
{
|
||
accessorKey: "purchased",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="محصول" />
|
||
),
|
||
cell: ({ row }) => {
|
||
const items = row.original.items;
|
||
|
||
return (
|
||
<div className="flex gap-2">
|
||
<div className="max-w-[320px] truncate font-medium">
|
||
{row.original.purchased}
|
||
</div>
|
||
{items ? (
|
||
<Badge variant="outline">{formatNumber(items)}</Badge>
|
||
) : null}
|
||
</div>
|
||
);
|
||
},
|
||
enableSorting: false,
|
||
enableHiding: false,
|
||
meta: {
|
||
width: 280,
|
||
},
|
||
},
|
||
{
|
||
accessorKey: "customer",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="مشتری" />
|
||
),
|
||
cell: ({ row }) => (
|
||
<div className="max-w-[180px] truncate">{row.original.customer}</div>
|
||
),
|
||
meta: {
|
||
width: 200,
|
||
},
|
||
},
|
||
{
|
||
accessorKey: "customer_email",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="ایمیل مشتری" />
|
||
),
|
||
cell: ({ row }) => (
|
||
<span className="max-w-[240px] truncate text-xs sm:text-sm">
|
||
{row.original.customer_email}
|
||
</span>
|
||
),
|
||
meta: {
|
||
width: 240,
|
||
},
|
||
},
|
||
{
|
||
accessorKey: "customer_segment",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="بخش مشتری" />
|
||
),
|
||
cell: ({ row }) => (
|
||
<Badge variant="outline">
|
||
{translateLabel(segmentLabels, row.original.customer_segment)}
|
||
</Badge>
|
||
),
|
||
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: "fulfillment_channel",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="تحویل" />
|
||
),
|
||
cell: ({ row }) => (
|
||
<span className="text-xs whitespace-nowrap sm:text-sm">
|
||
{translateLabel(fulfillmentLabels, row.original.fulfillment_channel)}
|
||
</span>
|
||
),
|
||
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: "shipping_service",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="سرویس ارسال" />
|
||
),
|
||
cell: ({ row }) => (
|
||
<span className="max-w-[240px] truncate text-xs sm:text-sm">
|
||
{translateLabel(shippingLabels, row.original.shipping_service)}
|
||
</span>
|
||
),
|
||
meta: { width: 220 },
|
||
},
|
||
{
|
||
accessorKey: "warehouse",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="انبار" />
|
||
),
|
||
cell: ({ row }) => (
|
||
<span className="text-xs whitespace-nowrap sm:text-sm">
|
||
{row.original.warehouse}
|
||
</span>
|
||
),
|
||
meta: { className: "whitespace-nowrap", width: 140 },
|
||
},
|
||
{
|
||
accessorKey: "order_status",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="وضعیت معامله سفارش" />
|
||
),
|
||
cell: ({ row }) => {
|
||
const status = row.original.order_status;
|
||
const statusMeta = getOrderStatusMeta(status);
|
||
const StatusIcon = statusMeta?.icon;
|
||
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<Badge
|
||
variant={getStatusVariant(status)}
|
||
className="gap-1 whitespace-nowrap"
|
||
>
|
||
{StatusIcon ? <StatusIcon className="size-3" /> : null}
|
||
<span>{statusMeta?.label ?? status}</span>
|
||
</Badge>
|
||
</div>
|
||
);
|
||
},
|
||
filterFn: (row, id, value) => {
|
||
if (!Array.isArray(value) || value.length === 0) {
|
||
return true;
|
||
}
|
||
const rowValue = String(row.getValue(id));
|
||
return value.includes(rowValue);
|
||
},
|
||
enableSorting: false,
|
||
meta: { width: 200 },
|
||
},
|
||
{
|
||
accessorKey: "payment_status",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="وضعیت معامله پرداخت" />
|
||
),
|
||
cell: ({ row }) => {
|
||
const status = row.original.payment_status;
|
||
const statusMeta = getPaymentStatusMeta(status);
|
||
const StatusIcon = statusMeta?.icon;
|
||
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<Badge
|
||
variant={getPaymentVariant(status)}
|
||
className="gap-1 whitespace-nowrap"
|
||
>
|
||
{StatusIcon ? <StatusIcon className="size-3" /> : null}
|
||
<span>{statusMeta?.label ?? status}</span>
|
||
</Badge>
|
||
</div>
|
||
);
|
||
},
|
||
filterFn: (row, id, value) => {
|
||
if (!Array.isArray(value) || value.length === 0) {
|
||
return true;
|
||
}
|
||
const rowValue = String(row.getValue(id));
|
||
return value.includes(rowValue);
|
||
},
|
||
enableSorting: false,
|
||
meta: { width: 200 },
|
||
},
|
||
{
|
||
accessorKey: "payment_method",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="روش پرداخت" />
|
||
),
|
||
cell: ({ row }) => (
|
||
<div className="font-mono text-xs sm:text-sm">
|
||
{row.original.payment_method}
|
||
</div>
|
||
),
|
||
meta: { width: 180 },
|
||
},
|
||
|
||
{
|
||
accessorKey: "items",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="تعداد" />
|
||
),
|
||
cell: ({ row }) => (
|
||
<span className="text-xs tabular-nums sm:text-sm">
|
||
{formatNumber(row.original.items)}
|
||
</span>
|
||
),
|
||
meta: { align: "center", className: "tabular-nums", width: 100 },
|
||
},
|
||
|
||
{
|
||
accessorKey: "tracking_number",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="پیگیری" />
|
||
),
|
||
cell: ({ row }) => (
|
||
<span className="font-mono text-xs sm:text-sm">
|
||
{row.original.tracking_number}
|
||
</span>
|
||
),
|
||
meta: { className: "whitespace-nowrap", width: 200 },
|
||
},
|
||
{
|
||
accessorKey: "assigned_to",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="مسئول" />
|
||
),
|
||
cell: ({ row }) => (
|
||
<span className="text-xs whitespace-nowrap sm:text-sm">
|
||
{row.original.assigned_to}
|
||
</span>
|
||
),
|
||
meta: { className: "whitespace-nowrap", width: 180 },
|
||
},
|
||
{
|
||
accessorKey: "return_status",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="وضعیت معامله مرجوعی" />
|
||
),
|
||
cell: ({ row }) => (
|
||
<Badge variant="outline">
|
||
{translateLabel(returnStatusLabels, row.original.return_status)}
|
||
</Badge>
|
||
),
|
||
filterFn: (row, id, value) => {
|
||
if (!Array.isArray(value) || value.length === 0) return true;
|
||
return value.includes(String(row.getValue(id)));
|
||
},
|
||
meta: { width: 180 },
|
||
},
|
||
|
||
{
|
||
accessorKey: "coupon_code",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="کد تخفیف" />
|
||
),
|
||
cell: ({ row }) => (
|
||
<span className="text-xs sm:text-sm">
|
||
{row.original.coupon_code ?? ""}
|
||
</span>
|
||
),
|
||
meta: { width: 140 },
|
||
},
|
||
{
|
||
accessorKey: "gift",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="هدیه" />
|
||
),
|
||
cell: ({ row }) => (
|
||
<Badge variant={row.original.gift ? "secondary" : "outline"}>
|
||
{row.original.gift ? "بله" : "خیر"}
|
||
</Badge>
|
||
),
|
||
meta: { width: 120 },
|
||
},
|
||
|
||
{
|
||
accessorKey: "notes",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="یادداشت" />
|
||
),
|
||
cell: ({ row }) => (
|
||
<span className="max-w-[240px] truncate text-xs sm:text-sm">
|
||
{row.original.notes}
|
||
</span>
|
||
),
|
||
meta: { width: 260 },
|
||
},
|
||
{
|
||
id: "actions",
|
||
cell: ({ row }) => (
|
||
<DataTableRowActions
|
||
row={row}
|
||
schema={schema}
|
||
itemName="سفارش"
|
||
idField="id"
|
||
/>
|
||
),
|
||
meta: {
|
||
width: 56,
|
||
minWidth: 48,
|
||
headerClassName: "px-2",
|
||
cellClassName: "px-2",
|
||
align: "center",
|
||
},
|
||
},
|
||
];
|
||
|
||
export const DataTable27 = ({ className }: { className?: string }) => {
|
||
const [data, setData] = React.useState<Order[]>([]);
|
||
const [isLoading, setIsLoading] = React.useState(true);
|
||
const [error, setError] = React.useState<string | null>(null);
|
||
|
||
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 } = useDataTable({
|
||
data,
|
||
columns,
|
||
getRowId: (row) => row.id.toString(),
|
||
initialColumnPinning: { left: ["select"], right: ["actions"] },
|
||
});
|
||
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 lastLoggedRowIndexRef = React.useRef<number>(-1);
|
||
|
||
// Demo-only logging: surface which virtualized rows get rendered as you scroll.
|
||
// Remove this if you don't need console noise in production.
|
||
React.useEffect(() => {
|
||
if (!hasRows || virtualRows.length === 0) return;
|
||
|
||
const currentMaxIndex = virtualRows[virtualRows.length - 1]?.index ?? -1;
|
||
if (currentMaxIndex > lastLoggedRowIndexRef.current) {
|
||
const start = Math.max(lastLoggedRowIndexRef.current + 1, 0);
|
||
const end = Math.min(currentMaxIndex, rowModel.length - 1);
|
||
const newlyLoadedRows = rowModel
|
||
.slice(start, end + 1)
|
||
.map((row) => row.original);
|
||
|
||
console.log(
|
||
`[DataTable27] Loaded virtualized rows ${start}–${end}`,
|
||
newlyLoadedRows,
|
||
);
|
||
|
||
lastLoggedRowIndexRef.current = currentMaxIndex;
|
||
}
|
||
}, [hasRows, rowModel, virtualRows]);
|
||
|
||
return (
|
||
<section dir="rtl" lang="fa" className={cn("py-32", className)}>
|
||
<div className="container">
|
||
<div className="w-full">
|
||
<div className="mb-8 text-start">
|
||
<h2 className="text-xl font-bold tracking-tight">
|
||
جدول داده با مجازیسازی
|
||
</h2>
|
||
<p className="mt-2 text-muted-foreground">
|
||
جدول داده با رکوردهای مجازی برای نمایش مجموعه داده بزرگ. با
|
||
پیمایش، رکوردهای بیشتری بارگذاری میشوند؛ کنسول مرورگر نشان
|
||
میدهد کدام رکوردها رندر شدهاند.
|
||
</p>
|
||
</div>
|
||
<div className="overflow-hidden rounded-lg border-2">
|
||
<div
|
||
ref={tableContainerRef}
|
||
className="relative max-h-[600px] overflow-auto"
|
||
>
|
||
{isLoading ? (
|
||
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground">
|
||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||
<span>در حال بارگذاری سفارشها…</span>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
<table className="w-full table-fixed caption-bottom border-separate border-spacing-0 text-sm [&_td]:border-border [&_tfoot_td]:border-t [&_th]:border-b [&_th]:border-border [&_tr]:border-none [&_tr:hover_td[data-pinned]]:bg-muted/50 [&_tr:not(:last-child)_td]:border-b [&_tr[data-state=selected]_td[data-pinned]]:bg-muted [&_tr[data-state=selected]:hover_td[data-pinned]]:bg-muted">
|
||
<TableHeader className="sticky top-0 z-20 bg-muted">
|
||
{table.getHeaderGroups().map((headerGroup) => (
|
||
<TableRow key={headerGroup.id}>
|
||
{headerGroup.headers.map((header) => {
|
||
const meta =
|
||
(header.column.columnDef.meta as {
|
||
align?: "left" | "center" | "right";
|
||
headerClassName?: string;
|
||
className?: string;
|
||
width?: number | string;
|
||
minWidth?: number | string;
|
||
maxWidth?: number | string;
|
||
}) ?? {};
|
||
const columnWithPinning = header.column as unknown as {
|
||
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;
|
||
};
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
const alignClass =
|
||
meta.align === "center"
|
||
? "text-center"
|
||
: meta.align === "right"
|
||
? "text-end"
|
||
: meta.align === "left"
|
||
? "text-start"
|
||
: undefined;
|
||
|
||
return (
|
||
<TableHead
|
||
key={header.id}
|
||
colSpan={header.colSpan}
|
||
style={{ ...baseStyle, ...pinnedStyle }}
|
||
className={cn(
|
||
"relative h-11 truncate border-r px-3 text-sm font-medium last:border-r-0 data-[pinned]:bg-muted/90 data-[pinned]:backdrop-blur-xs [&[data-pinned=left][data-last-col=left]]:border-r [&[data-pinned=right][data-last-col=right]]:border-l",
|
||
header.column.id === rightStickyDividerTargetId &&
|
||
"!border-r-0",
|
||
meta.className,
|
||
meta.headerClassName,
|
||
alignClass,
|
||
)}
|
||
data-pinned={pinned || undefined}
|
||
data-last-col={lastPinned || undefined}
|
||
>
|
||
{header.isPlaceholder
|
||
? null
|
||
: flexRender(
|
||
header.column.columnDef.header,
|
||
header.getContext(),
|
||
)}
|
||
</TableHead>
|
||
);
|
||
})}
|
||
</TableRow>
|
||
))}
|
||
</TableHeader>
|
||
<TableBody>
|
||
{isLoading ? (
|
||
<TableRow className="border-b last:border-b-0">
|
||
<TableCell
|
||
colSpan={visibleColumnCount}
|
||
className="h-24 text-center text-sm text-muted-foreground"
|
||
>
|
||
<div className="inline-flex items-center gap-2">
|
||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||
<span>در حال بارگذاری سفارشها…</span>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
) : error ? (
|
||
<TableRow className="border-b last:border-b-0">
|
||
<TableCell
|
||
colSpan={visibleColumnCount}
|
||
className="h-24 text-center text-sm text-destructive"
|
||
>
|
||
{error}
|
||
</TableCell>
|
||
</TableRow>
|
||
) : hasRows ? (
|
||
<>
|
||
{paddingTop > 0 ? (
|
||
<TableRow
|
||
className="pointer-events-none border-none"
|
||
aria-hidden="true"
|
||
style={{ height: paddingTop }}
|
||
>
|
||
{visibleColumns.map((column) => (
|
||
<TableCell
|
||
key={`pad-top-${column.id}`}
|
||
className="border-none p-0"
|
||
/>
|
||
))}
|
||
</TableRow>
|
||
) : null}
|
||
{virtualRows.map((virtualRow) => {
|
||
const row = rowModel[virtualRow.index];
|
||
if (!row) return null;
|
||
|
||
return (
|
||
<TableRow
|
||
key={row.id}
|
||
data-state={row.getIsSelected() && "selected"}
|
||
className="border-b last:border-b-0"
|
||
style={{ height: virtualRow.size }}
|
||
>
|
||
{row.getVisibleCells().map((cell) => {
|
||
const meta =
|
||
(cell.column.columnDef.meta as {
|
||
align?: "left" | "center" | "right";
|
||
cellClassName?: string;
|
||
className?: string;
|
||
width?: number | string;
|
||
minWidth?: number | string;
|
||
maxWidth?: number | string;
|
||
}) ?? {};
|
||
const columnWithPinning =
|
||
cell.column as unknown as {
|
||
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;
|
||
};
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
const alignClass =
|
||
meta.align === "center"
|
||
? "text-center"
|
||
: meta.align === "right"
|
||
? "text-end"
|
||
: meta.align === "left"
|
||
? "text-start"
|
||
: undefined;
|
||
|
||
return (
|
||
<TableCell
|
||
key={cell.id}
|
||
style={{ ...baseStyle, ...pinnedStyle }}
|
||
className={cn(
|
||
"truncate border-r p-3 py-2 text-sm last:border-r-0 data-[pinned]:bg-background/90 data-[pinned]:backdrop-blur-xs [&[data-pinned=left][data-last-col=left]]:border-r [&[data-pinned=right][data-last-col=right]]:border-l",
|
||
cell.column.id ===
|
||
rightStickyDividerTargetId &&
|
||
"!border-r-0",
|
||
meta.className,
|
||
meta.cellClassName,
|
||
alignClass,
|
||
)}
|
||
data-pinned={pinned || undefined}
|
||
data-last-col={lastPinned || undefined}
|
||
>
|
||
{flexRender(
|
||
cell.column.columnDef.cell,
|
||
cell.getContext(),
|
||
)}
|
||
</TableCell>
|
||
);
|
||
})}
|
||
</TableRow>
|
||
);
|
||
})}
|
||
{paddingBottom > 0 ? (
|
||
<TableRow
|
||
className="pointer-events-none border-none"
|
||
aria-hidden="true"
|
||
style={{ height: paddingBottom }}
|
||
>
|
||
{visibleColumns.map((column) => (
|
||
<TableCell
|
||
key={`pad-bottom-${column.id}`}
|
||
className="border-none p-0"
|
||
/>
|
||
))}
|
||
</TableRow>
|
||
) : null}
|
||
</>
|
||
) : (
|
||
<TableRow className="border-b last:border-b-0">
|
||
<TableCell
|
||
colSpan={visibleColumnCount}
|
||
className="h-24 text-center text-sm text-muted-foreground"
|
||
>
|
||
نتیجهای یافت نشد.
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</TableBody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
};
|