1058 lines
32 KiB
TypeScript
1058 lines
32 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,
|
|
getPaginationRowModel,
|
|
getSortedRowModel,
|
|
type SortingState,
|
|
useReactTable,
|
|
} from "@tanstack/react-table";
|
|
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 { 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 {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const formatNumber = (value: number) => value.toLocaleString("fa-IR");
|
|
|
|
const DATA_URL =
|
|
"https://raw.githubusercontent.com/zerostaticthemes/ری یو آی-library-data/refs/heads/main/data/orders-ecommerce-sample.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;
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
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?.() ?? false;
|
|
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 { baseStyle, pinnedStyle, pinned, lastPinned, alignClass };
|
|
}
|
|
|
|
export function useDataTable<TData>(options: UseDataTableOptions<TData>) {
|
|
const {
|
|
data,
|
|
columns,
|
|
getRowId,
|
|
initialSorting = [],
|
|
|
|
initialSelection = {},
|
|
enableRowSelection = true,
|
|
initialColumnPinning = {},
|
|
} = options;
|
|
|
|
const [sorting, setSorting] = React.useState<SortingState>(initialSorting);
|
|
|
|
const [rowSelection, setRowSelection] =
|
|
React.useState<RowSelectionState>(initialSelection);
|
|
const [columnPinning, setColumnPinning] =
|
|
React.useState<ColumnPinningState>(initialColumnPinning);
|
|
|
|
const table = useReactTable({
|
|
data,
|
|
columns,
|
|
getRowId,
|
|
state: {
|
|
sorting,
|
|
|
|
columnPinning,
|
|
rowSelection,
|
|
},
|
|
onSortingChange: setSorting,
|
|
onRowSelectionChange: setRowSelection,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getSortedRowModel: getSortedRowModel(),
|
|
getFilteredRowModel: getFilteredRowModel(),
|
|
getPaginationRowModel: getPaginationRowModel(),
|
|
getFacetedRowModel: getFacetedRowModel(),
|
|
getFacetedUniqueValues: getFacetedUniqueValues(),
|
|
onColumnPinningChange: setColumnPinning,
|
|
enableRowSelection,
|
|
});
|
|
|
|
return {
|
|
table,
|
|
sorting,
|
|
setSorting,
|
|
rowSelection,
|
|
setRowSelection,
|
|
columnPinning,
|
|
setColumnPinning,
|
|
};
|
|
}
|
|
|
|
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 handleCopy = async () => {
|
|
const itemRecord = item as Record<string, unknown>;
|
|
const value = itemRecord[idField] ?? itemRecord.id;
|
|
if (!value) {
|
|
return;
|
|
}
|
|
if (
|
|
typeof navigator === "undefined" ||
|
|
!navigator.clipboard ||
|
|
typeof navigator.clipboard.writeText !== "function"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(String(value));
|
|
} catch (error) {
|
|
console.error("Could not copy to clipboard", 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>;
|
|
|
|
const orderStatusMap: Record<string, string> = {
|
|
Fulfilled: "تحویلشده",
|
|
"Ready for pickup": "آماده تحویل",
|
|
Unfulfilled: "تحویلنشده",
|
|
};
|
|
|
|
const paymentStatusMap: Record<string, string> = {
|
|
Paid: "پرداختشده",
|
|
Pending: "در انتظار پیگیری",
|
|
Refunded: "مسترد شده",
|
|
"Partially refunded": "مسترد جزئی",
|
|
};
|
|
|
|
const customerSegmentMap: Record<string, string> = {
|
|
Wholesale: "عمدهفروشی",
|
|
Marketplace: "بازارچه",
|
|
VIP: "ویژه",
|
|
Enterprise: "سازمانی",
|
|
Retail: "خردهفروشی",
|
|
};
|
|
|
|
const fulfillmentChannelMap: Record<string, string> = {
|
|
FBA: "FBA",
|
|
"3PL": "شخص ثالث",
|
|
Dropship: "دراپشیپ",
|
|
"Merchant Fulfilled": "ارسال فروشنده",
|
|
};
|
|
|
|
const shippingServiceMap: Record<string, string> = {
|
|
"Expedited Air": "هوایی سریع",
|
|
"Prime 1-Day": "تحویل یکروزه",
|
|
"Prime 2-Day": "تحویل دوروزه",
|
|
"Standard Ground": "ارسال عادی",
|
|
"Same-Day Local": "تحویل همانروز",
|
|
};
|
|
|
|
const returnStatusMap: Record<string, string> = {
|
|
Authorized: "تأییدشده",
|
|
Requested: "درخواستشده",
|
|
Received: "دریافتشده",
|
|
None: "ندارد",
|
|
};
|
|
|
|
const purchasedMap: Record<string, string> = {
|
|
"Leather Jacket": "کاپشن چرمی",
|
|
"Blue Sneakers": "کفش اسپرت آبی",
|
|
"Red Hoodie": "هودی قرمز",
|
|
"Black Sunglasses": "عینک آفتابی مشکی",
|
|
"Green Backpack": "کولهپشتی سبز",
|
|
"Silver Watch": "ساعت نقرهای",
|
|
"Gray Scarf": "شال خاکستری",
|
|
"Purple Dress": "پیراهن بنفش",
|
|
"White Hat": "کلاه سفید",
|
|
"Brown Boots": "بوت قهوهای",
|
|
"Yellow Tote Bag": "کیف دوشی زرد",
|
|
"Orange Gloves": "دستکش نارنجی",
|
|
"Pink Sweater": "پلیور صورتی",
|
|
"Black Belt": "کمربند مشکی",
|
|
"Teal Jacket": "کاپشن فیروزهای",
|
|
"Navy Shorts": "شلوارک سرمهای",
|
|
"Gold Necklace": "گردنبند طلا",
|
|
"Olive Pants": "شلوار زیتونی",
|
|
"Red Cap": "کلاه قرمز",
|
|
"Blue Jeans": "جین آبی",
|
|
"Green Shirt": "پیراهن سبز",
|
|
"Purple Skirt": "دامن بنفش",
|
|
"Black Wallet": "کیف پول مشکی",
|
|
"Yellow Scarf": "شال زرد",
|
|
"White Sneakers": "کفش سفید",
|
|
};
|
|
|
|
const customerMap: Record<string, string> = {
|
|
"#301456": "سارا احمدی",
|
|
"#789123": "رضا محمدی",
|
|
"#654321": "نیلوفر حسینی",
|
|
"#987654": "داوود کریمی",
|
|
"#432187": "لیلا رحیمی",
|
|
"#567890": "امیر نوری",
|
|
"#234567": "راحله موسوی",
|
|
"#876543": "توماس علوی",
|
|
"#345678": "آنا رحیمی",
|
|
"#456789": "حامد سنگری",
|
|
"#678901": "سوفیا تهرانی",
|
|
"#890123": "هانیه کلارک",
|
|
"#123456": "گلناز ایوانس",
|
|
"#543210": "دانیال میرزایی",
|
|
"#765432": "الهام سفید",
|
|
"#987012": "ایمان خاکپور",
|
|
"#210987": "مریم اسکات",
|
|
"#432109": "یاسین تپل",
|
|
"#6543210": "کلوئه یونگ",
|
|
"#8765432": "نوح لوئیس",
|
|
"#109876": "آوا شاهی",
|
|
"#321098": "لیام آدامز",
|
|
"#5432109": "ایزابلا بکر",
|
|
"#7654321": "مازیار سبز",
|
|
"#9876543": "الا مارتینز",
|
|
};
|
|
|
|
const assignedToMap: Record<string, string> = {
|
|
"Morgan Blythe": "مژگان بیت",
|
|
"Harper Singh": "هستی سینگ",
|
|
"Riley Nguyen": "رایلی نگوین",
|
|
"Taylor Chen": "تیلور چن",
|
|
"Jordan Patel": "جردن پاتل",
|
|
};
|
|
|
|
const mapValue = (map: Record<string, string>, value: string) =>
|
|
map[value] ?? value;
|
|
|
|
export const localizeOrder = (order: Order): Order => {
|
|
const customer = customerMap[order.id] ?? order.customer;
|
|
const shippingService = mapValue(shippingServiceMap, order.shipping_service);
|
|
|
|
return {
|
|
...order,
|
|
purchased: mapValue(purchasedMap, order.purchased),
|
|
customer,
|
|
customer_segment: mapValue(customerSegmentMap, order.customer_segment),
|
|
fulfillment_channel: mapValue(
|
|
fulfillmentChannelMap,
|
|
order.fulfillment_channel,
|
|
),
|
|
shipping_service: shippingService,
|
|
order_status: mapValue(orderStatusMap, order.order_status),
|
|
payment_status: mapValue(paymentStatusMap, order.payment_status),
|
|
assigned_to: mapValue(assignedToMap, order.assigned_to),
|
|
return_status: mapValue(returnStatusMap, order.return_status),
|
|
notes: `${customer} ارسال ${shippingService} را ترجیح میدهد.`,
|
|
};
|
|
};
|
|
|
|
export type StatusOption = {
|
|
label: string;
|
|
value: string;
|
|
icon?: React.ComponentType<{ className?: string }>;
|
|
};
|
|
|
|
export const orderStatuses: StatusOption[] = [
|
|
{ label: "تحویلشده", value: "تحویلشده", icon: CheckCircle },
|
|
{ label: "آماده تحویل", value: "آماده تحویل", icon: Package },
|
|
{ label: "تحویلنشده", value: "تحویلنشده", icon: Clock },
|
|
];
|
|
|
|
export const paymentStatuses: StatusOption[] = [
|
|
{ label: "پرداختشده", value: "پرداختشده", icon: CheckCheckIcon },
|
|
{ label: "در انتظار پیگیری", value: "در انتظار پیگیری", icon: LoaderIcon },
|
|
{ label: "مسترد شده", value: "مسترد شده", icon: RefreshCcw },
|
|
{
|
|
label: "مسترد جزئی",
|
|
value: "مسترد جزئی",
|
|
icon: PieChart,
|
|
},
|
|
];
|
|
|
|
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 "تحویلشده":
|
|
return "default";
|
|
case "آماده تحویل":
|
|
return "secondary";
|
|
case "تحویلنشده":
|
|
return "outline";
|
|
default:
|
|
return "outline";
|
|
}
|
|
};
|
|
|
|
export const getPaymentVariant = (status: string) => {
|
|
switch (status) {
|
|
case "پرداختشده":
|
|
return "default";
|
|
case "در انتظار پیگیری":
|
|
return "secondary";
|
|
case "مسترد شده":
|
|
return "outline";
|
|
case "مسترد جزئی":
|
|
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,
|
|
},
|
|
{
|
|
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,
|
|
},
|
|
{
|
|
accessorKey: "customer",
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title="مشتری" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<div className="max-w-[180px] truncate">{row.original.customer}</div>
|
|
),
|
|
},
|
|
{
|
|
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>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "customer_segment",
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title="بخش" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<Badge variant="outline">{row.original.customer_segment}</Badge>
|
|
),
|
|
meta: { className: "whitespace-nowrap" },
|
|
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">
|
|
{row.original.fulfillment_channel}
|
|
</span>
|
|
),
|
|
meta: { className: "whitespace-nowrap" },
|
|
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">
|
|
{row.original.shipping_service}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
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" },
|
|
},
|
|
{
|
|
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,
|
|
},
|
|
{
|
|
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,
|
|
},
|
|
{
|
|
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>
|
|
),
|
|
},
|
|
|
|
{
|
|
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" },
|
|
},
|
|
|
|
{
|
|
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" },
|
|
},
|
|
{
|
|
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" },
|
|
},
|
|
{
|
|
accessorKey: "return_status",
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title="وضعیت معامله مرجوعی" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<Badge variant="outline">{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)));
|
|
},
|
|
},
|
|
|
|
{
|
|
accessorKey: "coupon_code",
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title="کد تخفیف" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<span className="text-xs sm:text-sm">
|
|
{row.original.coupon_code ?? ""}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "gift",
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title="هدیه" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<Badge variant={row.original.gift ? "secondary" : "outline"}>
|
|
{row.original.gift ? "بله" : "خیر"}
|
|
</Badge>
|
|
),
|
|
},
|
|
|
|
{
|
|
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>
|
|
),
|
|
},
|
|
{
|
|
id: "actions",
|
|
cell: ({ row }) => (
|
|
<DataTableRowActions
|
|
row={row}
|
|
schema={schema}
|
|
itemName="سفارش"
|
|
idField="id"
|
|
/>
|
|
),
|
|
},
|
|
];
|
|
|
|
export const DataTable15 = ({ 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).map(localizeOrder);
|
|
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 } = useDataTable({
|
|
data,
|
|
columns,
|
|
getRowId: (row) => row.id.toString(),
|
|
initialColumnPinning: { right: ["select"], left: ["actions"] },
|
|
});
|
|
const visibleColumnCount = Math.max(table.getVisibleLeafColumns().length, 1);
|
|
const visibleColumnIds = table
|
|
.getVisibleLeafColumns()
|
|
.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;
|
|
|
|
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 className="relative">
|
|
{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="border-separate border-spacing-0 [&_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="bg-muted">
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<TableRow key={headerGroup.id}>
|
|
{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,
|
|
alignClass,
|
|
} = getColumnLayoutStyles(meta, columnWithPinning);
|
|
|
|
return (
|
|
<TableHead
|
|
key={header.id}
|
|
colSpan={header.colSpan}
|
|
style={{ ...baseStyle, ...pinnedStyle }}
|
|
className={cn(
|
|
"relative h-11 truncate border-e px-3 text-sm font-medium last:border-e-0 data-[pinned]:bg-muted/90 data-[pinned]:backdrop-blur-xs [&[data-pinned=left][data-last-col=left]]:border-e [&[data-pinned=right][data-last-col=right]]:border-s",
|
|
header.column.id === rightStickyDividerTargetId &&
|
|
"!border-e-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>
|
|
{error ? (
|
|
<TableRow className="border-b last:border-b-0">
|
|
<TableCell
|
|
colSpan={visibleColumnCount}
|
|
className="h-24 text-center text-sm text-destructive"
|
|
>
|
|
{error}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : table.getRowModel().rows?.length ? (
|
|
table.getRowModel().rows.map((row) => (
|
|
<TableRow
|
|
key={row.id}
|
|
data-state={row.getIsSelected() && "selected"}
|
|
className="border-b last:border-b-0"
|
|
>
|
|
{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,
|
|
alignClass,
|
|
} = getColumnLayoutStyles(meta, columnWithPinning);
|
|
|
|
return (
|
|
<TableCell
|
|
key={cell.id}
|
|
style={{ ...baseStyle, ...pinnedStyle }}
|
|
className={cn(
|
|
"truncate border-e p-3 py-2 text-sm last:border-e-0 data-[pinned]:bg-background/90 data-[pinned]:backdrop-blur-xs [&[data-pinned=left][data-last-col=left]]:border-e [&[data-pinned=right][data-last-col=right]]:border-s",
|
|
cell.column.id === rightStickyDividerTargetId &&
|
|
"!border-e-0",
|
|
meta.className,
|
|
meta.cellClassName,
|
|
alignClass,
|
|
)}
|
|
data-pinned={pinned || undefined}
|
|
data-last-col={lastPinned || undefined}
|
|
>
|
|
{flexRender(
|
|
cell.column.columnDef.cell,
|
|
cell.getContext(),
|
|
)}
|
|
</TableCell>
|
|
);
|
|
})}
|
|
</TableRow>
|
|
))
|
|
) : (
|
|
<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>
|
|
);
|
|
};
|