Files

1187 lines
36 KiB
TypeScript

"use client";
import {
type Column,
type ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
type RowSelectionState,
type SortingState,
type Table as ReactTable,
useReactTable,
} from "@tanstack/react-table";
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
ChevronsUpDown,
MoreHorizontal,
} from "lucide-react";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
type TransactionStatus =
| "posted"
| "pending"
| "review"
| "excluded"
| "completed";
type Transaction = {
id: string;
date: string;
name: string;
description: string;
amount: number;
taxAmount: number;
category: string;
counterparty: string;
tags: string[];
account: string;
method: string;
assigned: string | null;
status: TransactionStatus;
};
type ColumnMeta = {
headerClassName?: string;
cellClassName?: string;
width?: number | string;
minWidth?: number | string;
maxWidth?: number | string;
};
type UseTableScrollOptions = {
scrollAmount?: number;
useColumnWidths?: boolean;
startFromColumn?: number;
};
type UseStickyColumnsOptions<TData> = {
table?: ReactTable<TData>;
};
type HorizontalPaginationProps = {
canScrollLeft: boolean;
canScrollRight: boolean;
onScrollLeft: () => void;
onScrollRight: () => void;
className?: string;
};
const formatCurrency = (value: number) =>
new Intl.NumberFormat("fa-IR", {
style: "currency",
currency: "IRR",
}).format(value);
const dateFormatter = new Intl.DateTimeFormat("fa-IR", {
dateStyle: "medium",
});
const formatDate = (value: string) => {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : dateFormatter.format(date);
};
const statusLabels: Record<TransactionStatus, string> = {
posted: "ثبت‌شده",
pending: "در انتظار پیگیری",
review: "نیاز به بررسی",
excluded: "مستثنا",
completed: "تکمیل‌شده",
};
const methodLabels: Record<string, string> = {
Transfer: "انتقال",
Card: "کارت",
Wire: "حواله",
ACH: "ACH",
Manual: "دستی",
};
const statusStyles: Record<TransactionStatus, string> = {
posted:
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10",
pending:
"border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10",
review:
"border-slate-200 bg-slate-50 text-slate-700 dark:border-slate-500/40 dark:bg-slate-500/10",
excluded:
"border-rose-200 bg-rose-50 text-rose-600 dark:border-rose-500/40 dark:bg-rose-500/10",
completed:
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10",
};
const transactions: Transaction[] = [
{
id: "txn-2082",
date: "2024-05-14",
name: "واریز درگاه پرداخت",
description: "درآمد اشتراک برای دوره آوریل",
amount: 14825.32,
taxAmount: 0,
category: "درآمد",
counterparty: "Stripe Payments",
tags: ["اشتراک", "تکرارشونده"],
account: "حساب جاری بنیان‌گذاران · ۰۴۲۱",
method: "Transfer",
assigned: "حنا لی",
status: "posted",
},
{
id: "txn-2081",
date: "2024-05-13",
name: "Linear",
description: "اشتراک فضای کاری تیم محصول",
amount: -320,
taxAmount: 26.4,
category: "نرم‌افزار",
counterparty: "Linear HQ",
tags: ["محصول", "SaaS"],
account: "کارت کپیتال وان · ۵۵۲۲",
method: "Card",
assigned: "مارکوس لین",
status: "pending",
},
{
id: "txn-2080",
date: "2024-05-12",
name: "ری یو آی FX",
description: "تبدیل IRR به EUR برای فاکتورهای برلین",
amount: -184.35,
taxAmount: 0,
category: "کارمزد بانکی",
counterparty: "Mercury Bank",
tags: ["بین‌المللی"],
account: "خزانه · ۱۱۸۹",
method: "Wire",
assigned: "نوح پاتل",
status: "review",
},
{
id: "txn-2079",
date: "2024-05-10",
name: "حقوق Gusto",
description: "دوره ۴۲ · مهندسی و بازاریابی",
amount: -48600,
taxAmount: 8123.45,
category: "حقوق و دستمزد",
counterparty: "Gusto",
tags: ["حقوق", "آمریکا"],
account: "عملیاتی · ۷۷۳۰",
method: "ACH",
assigned: null,
status: "completed",
},
{
id: "txn-2078",
date: "2024-05-09",
name: "AWS",
description: "مصرف آوریل در کلاسترهای لبه و استنتاج",
amount: -9832.11,
taxAmount: 0,
category: "زیرساخت",
counterparty: "Amazon Web Services",
tags: ["زیرساخت", "مصرف"],
account: "خزانه · ۱۱۸۹",
method: "Transfer",
assigned: "حنا لی",
status: "posted",
},
{
id: "txn-2077",
date: "2024-05-08",
name: "Ramp",
description: "پرواز و هتل برای اردوی تیم",
amount: -6120.88,
taxAmount: 531.83,
category: "سفر",
counterparty: "Ramp Corporate",
tags: ["اردو", "Q2"],
account: "کارت کپیتال وان · ۵۵۲۲",
method: "Card",
assigned: "ایزابلا چو",
status: "review",
},
{
id: "txn-2076",
date: "2024-05-07",
name: "Notion",
description: "پلن سازمانی برای تیم GTM",
amount: -850,
taxAmount: 72.25,
category: "نرم‌افزار",
counterparty: "Notion Labs",
tags: ["GTM", "قرارداد"],
account: "عملیاتی · ۷۷۳۰",
method: "Card",
assigned: null,
status: "pending",
},
{
id: "txn-2075",
date: "2024-05-06",
name: "تعدیل دستی",
description: "تطبیق تعویض سخت‌افزار",
amount: 0,
taxAmount: 0,
category: "تطبیق",
counterparty: "داخلی",
tags: [],
account: "حساب جاری بنیان‌گذاران · ۰۴۲۱",
method: "Manual",
assigned: "مارکوس لین",
status: "excluded",
},
];
const columns: ColumnDef<Transaction>[] = [
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="انتخاب همه رکورد‌ها"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="انتخاب رکورد"
/>
</div>
),
enableSorting: false,
enableHiding: false,
meta: {
width: 48,
minWidth: 48,
headerClassName: "!px-0 !border-r-0",
cellClassName: "!px-0 !border-r-0",
} satisfies ColumnMeta,
},
{
accessorKey: "date",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="تاریخ به‌روزرسانی" />
),
cell: ({ row }) => (
<div className="flex flex-col">
<span className="font-medium text-foreground">
{formatDate(row.original.date)}
</span>
<span className="text-xs text-muted-foreground">{row.original.id}</span>
</div>
),
meta: {
width: 140,
minWidth: 140,
headerClassName:
"!border-r-0 before:absolute before:left-0 before:top-0 before:bottom-0 before:w-[0.5px] before:bg-border/60",
cellClassName:
"!border-r-0 before:absolute before:left-0 before:top-0 before:bottom-0 before:w-[0.5px] before:bg-border/60",
} satisfies ColumnMeta,
},
{
accessorKey: "description",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="شرح" />
),
cell: ({ row }) => (
<div className="relative flex flex-col gap-1">
<span className="font-medium text-foreground">{row.original.name}</span>
<span className="text-[0.8125rem] text-muted-foreground">
{row.original.description}
</span>
</div>
),
meta: {
width: 320,
minWidth: 320,
headerClassName:
"!border-r-0 before:absolute before:left-0 before:top-0 before:bottom-0 before:w-[0.5px] before:bg-border/60 after:absolute after:right-0 after:top-0 after:bottom-0 after:w-[0.5px] after:bg-border/60",
cellClassName:
"!border-r-0 before:absolute before:left-0 before:top-0 before:bottom-0 before:w-[0.5px] before:bg-border/60 after:absolute after:right-0 after:top-0 after:bottom-0 after:w-[0.5px] after:bg-border/60",
} satisfies ColumnMeta,
},
{
accessorKey: "amount",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="مبلغ معامله" />
),
cell: ({ row }) => (
<span
className={cn(
"font-semibold",
row.original.amount > 0
? "text-emerald-600 dark:text-emerald-400"
: "text-foreground",
)}
>
{formatCurrency(row.original.amount)}
</span>
),
meta: {
width: 150,
minWidth: 150,
} satisfies ColumnMeta,
},
{
accessorKey: "taxAmount",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="مالیات" />
),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.original.taxAmount ? formatCurrency(row.original.taxAmount) : ""}
</span>
),
meta: {
width: 130,
minWidth: 130,
} satisfies ColumnMeta,
},
{
accessorKey: "category",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="دسته‌بندی" />
),
cell: ({ row }) => (
<Badge variant="outline" className="font-medium">
{row.original.category}
</Badge>
),
meta: {
width: 200,
minWidth: 200,
} satisfies ColumnMeta,
},
{
accessorKey: "counterparty",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="از / به" />
),
cell: ({ row }) => (
<div>
<p className="text-[0.8125rem] font-medium">
{row.original.counterparty}
</p>
<p className="text-xs text-muted-foreground">
{methodLabels[row.original.method] ?? row.original.method}
</p>
</div>
),
meta: {
width: 220,
minWidth: 220,
} satisfies ColumnMeta,
},
{
accessorKey: "tags",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="برچسب‌ها" />
),
cell: ({ row }) => (
<div className="flex flex-wrap gap-2">
{row.original.tags.length ? (
row.original.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="rounded-full">
{tag}
</Badge>
))
) : (
<span className="text-xs text-muted-foreground">بدون برچسب</span>
)}
</div>
),
enableSorting: false,
meta: {
width: 260,
minWidth: 260,
} satisfies ColumnMeta,
},
{
accessorKey: "account",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="حساب" />
),
cell: ({ row }) => <span>{row.original.account}</span>,
meta: {
width: 220,
minWidth: 220,
} satisfies ColumnMeta,
},
{
accessorKey: "method",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="روش پرداخت" />
),
cell: ({ row }) => (
<Badge variant="outline">
{methodLabels[row.original.method] ?? row.original.method}
</Badge>
),
meta: {
width: 160,
minWidth: 160,
} satisfies ColumnMeta,
},
{
accessorKey: "assigned",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="مسئول" />
),
cell: ({ row }) =>
row.original.assigned ? (
<span className="font-medium">{row.original.assigned}</span>
) : (
<Badge variant="outline" className="text-muted-foreground">
بدون مسئول
</Badge>
),
meta: {
width: 200,
minWidth: 200,
} satisfies ColumnMeta,
},
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="وضعیت معامله" />
),
cell: ({ row }) => (
<Badge variant="outline" className={statusStyles[row.original.status]}>
{statusLabels[row.original.status]}
</Badge>
),
meta: {
width: 160,
minWidth: 160,
} satisfies ColumnMeta,
},
{
id: "actions",
header: () => <span className="sr-only">عملیات</span>,
cell: () => (
<div className="flex items-center justify-center">
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">باز کردن منو</span>
</Button>
</div>
),
enableSorting: false,
enableHiding: false,
meta: {
width: 48,
minWidth: 48,
headerClassName:
"!px-0 md:sticky md:right-0 z-20 before:absolute before:left-0 before:top-0 before:bottom-0 before:w-[0.5px] before:bg-border/60",
cellClassName:
"!px-0 md:sticky md:right-0 z-20 before:absolute before:left-0 before:top-0 before:bottom-0 before:w-[0.5px] before:bg-border/60",
} satisfies ColumnMeta,
},
];
function useStickyColumns<TData>({ table }: UseStickyColumnsOptions<TData>) {
const isVisible = React.useCallback(
(id: string) =>
table
?.getAllLeafColumns()
.some((column) => column.id === id && column.getIsVisible()) ?? true,
[table],
);
const stickyPositions = React.useMemo(() => {
let position = 0;
const positions: Record<string, number> = {};
positions.select = position;
position += 48;
if (isVisible("date")) {
positions.date = position;
position += 140;
}
if (isVisible("description")) {
positions.description = position;
position += 320;
}
return positions;
}, [isVisible]);
const getStickyStyle = React.useCallback(
(columnId: string) => {
const left = stickyPositions[columnId];
if (left === undefined) return undefined;
return {
"--stick-left": `${left}px`,
} as React.CSSProperties;
},
[stickyPositions],
);
const getStickyClassName = React.useCallback(
(columnId: string, baseClassName?: string, isHeader = false) => {
const stickyColumns = ["select", "date", "description"];
const isSticky = stickyColumns.includes(columnId);
return cn(
baseClassName,
isSticky && "md:sticky md:left-[var(--stick-left)]",
isSticky && "isolate",
isSticky && (isHeader ? "z-30" : "z-20"),
);
},
[],
);
return {
getStickyStyle,
getStickyClassName,
};
}
function useTableScroll(options: UseTableScrollOptions = {}) {
const {
scrollAmount = 120,
useColumnWidths = false,
startFromColumn = 0,
} = options;
const containerRef = React.useRef<HTMLDivElement | null>(null);
const [canScrollLeft, setCanScrollLeft] = React.useState(false);
const [canScrollRight, setCanScrollRight] = React.useState(false);
const [isScrollable, setIsScrollable] = React.useState(false);
const currentColumnIndex = React.useRef(startFromColumn);
const isScrollingProgrammatically = React.useRef(false);
const scrollTimeoutRef = React.useRef<number | undefined>(undefined);
const getColumnPositions = React.useCallback(() => {
const container = containerRef.current;
if (!container) return [];
const table = container.querySelector("table");
if (!table) return [];
const headerRow = table.querySelector("thead tr");
if (!headerRow) return [];
const columns = Array.from(headerRow.querySelectorAll("th"));
const positions: number[] = [];
let currentPosition = 0;
for (const column of columns) {
positions.push(currentPosition);
currentPosition += (column as HTMLElement).offsetWidth;
}
return positions;
}, []);
const syncColumnIndex = React.useCallback(() => {
const container = containerRef.current;
if (!container || !useColumnWidths || isScrollingProgrammatically.current)
return;
const allColumnPositions = getColumnPositions();
if (allColumnPositions.length === 0) return;
const currentScrollLeft = container.scrollLeft;
const maxScrollLeft = container.scrollWidth - container.clientWidth;
if (currentScrollLeft <= 10) {
currentColumnIndex.current = startFromColumn;
return;
}
if (currentScrollLeft >= maxScrollLeft - 10) {
currentColumnIndex.current = allColumnPositions.length - 1;
return;
}
let accumulatedDistance = 0;
let detectedColumn = startFromColumn;
for (let i = startFromColumn; i < allColumnPositions.length - 1; i++) {
const columnStart = allColumnPositions[i] ?? 0;
const columnEnd = allColumnPositions[i + 1] ?? 0;
const columnWidth = columnEnd - columnStart;
const nextDistance = accumulatedDistance + columnWidth;
if (
Math.abs(currentScrollLeft - accumulatedDistance) <=
Math.abs(currentScrollLeft - nextDistance)
) {
detectedColumn = i;
break;
}
accumulatedDistance = nextDistance;
detectedColumn = i + 1;
}
currentColumnIndex.current = Math.max(
startFromColumn,
Math.min(detectedColumn, allColumnPositions.length - 1),
);
}, [useColumnWidths, startFromColumn, getColumnPositions]);
const checkScrollability = React.useCallback(() => {
const container = containerRef.current;
if (!container) return;
const { scrollWidth, clientWidth } = container;
const isScrollableTable = scrollWidth > clientWidth;
if (useColumnWidths) {
syncColumnIndex();
const allColumnPositions = getColumnPositions();
const maxColumnIndex = allColumnPositions.length - 1;
const newCanScrollLeft =
currentColumnIndex.current > startFromColumn ||
container.scrollLeft > 10;
const newCanScrollRight = currentColumnIndex.current < maxColumnIndex;
setIsScrollable(isScrollableTable);
setCanScrollLeft(newCanScrollLeft);
setCanScrollRight(newCanScrollRight);
} else {
const { scrollLeft } = container;
setIsScrollable(isScrollableTable);
setCanScrollLeft(scrollLeft > 0);
setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1);
}
}, [useColumnWidths, startFromColumn, getColumnPositions, syncColumnIndex]);
const scrollLeft = React.useCallback(
(smooth = true) => {
const container = containerRef.current;
if (!container) return;
if (useColumnWidths) {
const allColumnPositions = getColumnPositions();
if (allColumnPositions.length === 0) return;
if (
currentColumnIndex.current <= startFromColumn &&
container.scrollLeft <= 0
) {
return;
}
if (
currentColumnIndex.current <= startFromColumn &&
container.scrollLeft > 0
) {
isScrollingProgrammatically.current = true;
container.scrollTo({
left: 0,
behavior: "smooth",
});
window.setTimeout(() => {
isScrollingProgrammatically.current = false;
syncColumnIndex();
checkScrollability();
}, 500);
return;
}
const originalColumnIndex = currentColumnIndex.current;
currentColumnIndex.current = currentColumnIndex.current - 1;
const currentScrollLeft = container.scrollLeft;
const maxScrollLeft = container.scrollWidth - container.clientWidth;
let targetScrollLeft: number;
if (Math.abs(currentScrollLeft - maxScrollLeft) < 10) {
const lastColumnStart = allColumnPositions[originalColumnIndex] ?? 0;
const lastColumnEnd =
allColumnPositions[originalColumnIndex + 1] ??
lastColumnStart + 150;
const lastColumnWidth = lastColumnEnd - lastColumnStart;
targetScrollLeft = Math.max(0, currentScrollLeft - lastColumnWidth);
} else {
targetScrollLeft = 0;
for (let i = startFromColumn; i < currentColumnIndex.current; i++) {
const columnStart = allColumnPositions[i] ?? 0;
const columnEnd = allColumnPositions[i + 1] ?? 0;
const columnWidth = columnEnd - columnStart;
targetScrollLeft += columnWidth;
}
}
isScrollingProgrammatically.current = true;
container.scrollTo({
left: targetScrollLeft,
behavior: "smooth",
});
window.setTimeout(() => {
isScrollingProgrammatically.current = false;
syncColumnIndex();
checkScrollability();
}, 500);
} else {
container.scrollBy({
left: -scrollAmount,
behavior: smooth ? "smooth" : "auto",
});
}
},
[
checkScrollability,
getColumnPositions,
scrollAmount,
startFromColumn,
syncColumnIndex,
useColumnWidths,
],
);
const scrollRight = React.useCallback(
(smooth = true) => {
const container = containerRef.current;
if (!container) return;
if (useColumnWidths) {
const allColumnPositions = getColumnPositions();
if (allColumnPositions.length === 0) return;
const maxColumnIndex = allColumnPositions.length - 1;
if (currentColumnIndex.current >= maxColumnIndex) {
return;
}
currentColumnIndex.current = currentColumnIndex.current + 1;
isScrollingProgrammatically.current = true;
if (currentColumnIndex.current === maxColumnIndex) {
container.scrollTo({
left: container.scrollWidth - container.clientWidth,
behavior: "smooth",
});
} else {
let targetScrollLeft = 0;
for (let i = startFromColumn; i < currentColumnIndex.current; i++) {
const columnStart = allColumnPositions[i] ?? 0;
const columnEnd = allColumnPositions[i + 1] ?? 0;
targetScrollLeft += columnEnd - columnStart;
}
container.scrollTo({
left: targetScrollLeft,
behavior: "smooth",
});
}
window.setTimeout(() => {
isScrollingProgrammatically.current = false;
syncColumnIndex();
checkScrollability();
}, 500);
} else {
container.scrollBy({
left: scrollAmount,
behavior: smooth ? "smooth" : "auto",
});
}
},
[
checkScrollability,
getColumnPositions,
scrollAmount,
startFromColumn,
syncColumnIndex,
useColumnWidths,
],
);
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
currentColumnIndex.current = startFromColumn;
checkScrollability();
const handleScroll = () => {
if (isScrollingProgrammatically.current) return;
if (scrollTimeoutRef.current !== undefined) {
window.clearTimeout(scrollTimeoutRef.current);
}
scrollTimeoutRef.current = window.setTimeout(() => {
checkScrollability();
}, 100);
};
const handleResize = () => {
currentColumnIndex.current = startFromColumn;
checkScrollability();
};
container.addEventListener("scroll", handleScroll);
window.addEventListener("resize", handleResize);
let resizeObserver: ResizeObserver | undefined;
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(() => {
currentColumnIndex.current = startFromColumn;
checkScrollability();
});
resizeObserver.observe(container);
}
return () => {
container.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleResize);
if (resizeObserver) {
resizeObserver.disconnect();
}
if (scrollTimeoutRef.current !== undefined) {
window.clearTimeout(scrollTimeoutRef.current);
}
};
}, [checkScrollability, startFromColumn]);
return {
containerRef,
canScrollLeft,
canScrollRight,
isScrollable,
scrollLeft,
scrollRight,
};
}
const HorizontalPagination = ({
canScrollLeft,
canScrollRight,
onScrollLeft,
onScrollRight,
className,
}: HorizontalPaginationProps) => {
return (
<div className={cn("flex items-center gap-2", className)}>
<Button
variant="outline"
size="icon"
disabled={!canScrollLeft}
className="h-7 w-7"
onClick={onScrollLeft}
aria-label="پیمایش به ستون‌های قبلی"
>
<ArrowLeft
className={cn(
"h-3.5 w-3.5",
canScrollLeft ? "text-primary" : "text-muted-foreground",
)}
/>
</Button>
<Button
variant="outline"
size="icon"
disabled={!canScrollRight}
className="h-7 w-7"
onClick={onScrollRight}
aria-label="پیمایش به ستون‌های بعدی"
>
<ArrowRight
className={cn(
"h-3.5 w-3.5",
canScrollRight ? "text-primary" : "text-muted-foreground",
)}
/>
</Button>
</div>
);
};
type DataTableColumnHeaderProps<TData, TValue> = {
column: Column<TData, TValue>;
title: string;
};
const DataTableColumnHeader = <TData, TValue>({
column,
title,
}: DataTableColumnHeaderProps<TData, TValue>) => {
const canSort = column.getCanSort();
const sorted = column.getIsSorted();
if (!canSort) {
return (
<div className="flex items-center gap-1.5 text-[0.8125rem] text-muted-foreground">
<span>{title}</span>
</div>
);
}
return (
<button
type="button"
className={cn(
"flex w-full items-center justify-between gap-1.5",
"py-2 text-[0.8125rem]",
"text-muted-foreground hover:text-foreground",
"cursor-pointer transition-colors outline-none",
)}
onClick={() => column.toggleSorting(sorted === "asc")}
>
<span>{title}</span>
<span className="ms-2 flex h-3.5 w-3.5 items-center justify-center">
{sorted === "desc" ? (
<ArrowDown className="h-3 w-3" />
) : sorted === "asc" ? (
<ArrowUp className="h-3 w-3" />
) : (
<ChevronsUpDown className="h-3 w-3 opacity-70" />
)}
</span>
</button>
);
};
export const DataTable26 = ({ className }: { className?: string }) => {
const [sorting, setSorting] = React.useState<SortingState>([
{ id: "date", desc: true },
]);
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const table = useReactTable({
data: transactions,
columns,
state: {
sorting,
rowSelection,
},
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
});
const { getStickyStyle, getStickyClassName } = useStickyColumns({ table });
const tableScroll = useTableScroll({
useColumnWidths: true,
startFromColumn: 3,
});
const visibleColumnCount = table.getVisibleLeafColumns().length;
const visibleColumnIds = table
.getVisibleLeafColumns()
.map((column) => column.id);
const actionsColumnIndex = visibleColumnIds.indexOf("actions");
const rightStickyDividerTargetId =
actionsColumnIndex > 0 ? visibleColumnIds[actionsColumnIndex - 1] : null;
return (
<section dir="rtl" lang="fa" className={cn("py-32", className)}>
<div className="container">
<div className="mb-8 text-start">
<h2 className="text-xl font-bold tracking-tight">
جدول تراکنش‌ها با کنترل‌های افقی
</h2>
<p className="mt-2 max-w-2xl text-muted-foreground">
ستون‌های اول چسبان، کنترل‌های پیمایش و نوار عملیات واکنش‌گرا، مرور
داده‌های مالی متراکم را آسان می‌کنند.
</p>
</div>
<div className="overflow-hidden rounded-lg border border-border/60 bg-card [--dt-cell-bg:var(--background)] [--dt-header-bg:var(--muted)] [--dt-row-hover-bg:color-mix(in_oklab,var(--background)_98%,var(--foreground)_2%)] [--dt-row-selected-bg:color-mix(in_oklab,var(--background)_98%,var(--foreground)_2%)] [--dt-row-selected-hover-bg:color-mix(in_oklab,var(--background)_96%,var(--foreground)_4%)] dark:[--dt-header-bg:var(--card)] dark:[--dt-row-hover-bg:var(--muted)] dark:[--dt-row-selected-bg:var(--muted)] dark:[--dt-row-selected-hover-bg:color-mix(in_oklab,var(--muted)_80%,var(--foreground)_20%)]">
<div
ref={tableScroll.containerRef}
className="overflow-x-auto overscroll-x-none"
>
<table className="w-full min-w-[1200px] caption-bottom text-[0.8125rem]">
<TableHeader className="sticky top-0 z-10 border-b-[0.5px] border-border/60 bg-[var(--dt-header-bg)]">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className="!border-0 hover:bg-transparent"
>
{headerGroup.headers.map((header) => {
const meta =
(header.column.columnDef.meta as ColumnMeta) ?? {};
const sizeStyle: React.CSSProperties = {};
if (meta.width !== undefined) {
sizeStyle.width =
typeof meta.width === "number"
? `${meta.width}px`
: meta.width;
}
if (meta.minWidth !== undefined) {
sizeStyle.minWidth =
typeof meta.minWidth === "number"
? `${meta.minWidth}px`
: meta.minWidth;
}
if (meta.maxWidth !== undefined) {
sizeStyle.maxWidth =
typeof meta.maxWidth === "number"
? `${meta.maxWidth}px`
: meta.maxWidth;
}
const baseClassName = cn(
"relative !h-11 border-r-[0.5px] border-border/60 bg-[var(--dt-header-bg)] !px-3 text-start align-middle !text-[0.8125rem] font-medium !text-muted-foreground whitespace-nowrap last:border-r-0",
header.column.id === rightStickyDividerTargetId &&
"!border-r-0",
meta.headerClassName,
);
const stickyStyle =
header.column.id === "actions"
? undefined
: getStickyStyle(header.column.id);
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={getStickyClassName(
header.column.id,
baseClassName,
true,
)}
style={{ ...sizeStyle, ...(stickyStyle ?? {}) }}
>
{header.isPlaceholder ? null : header.column.id ===
"description" ? (
<div className="flex items-center justify-between gap-2">
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
<HorizontalPagination
canScrollLeft={tableScroll.canScrollLeft}
canScrollRight={tableScroll.canScrollRight}
onScrollLeft={tableScroll.scrollLeft}
onScrollRight={tableScroll.scrollRight}
className="hidden md:flex"
/>
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext(),
)
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="group h-11 !border-b-[0.5px] border-border/60 bg-[var(--dt-row-bg)] transition-colors [--dt-row-bg:var(--dt-cell-bg)] last:border-b-0 hover:[--dt-row-bg:var(--dt-row-hover-bg)] data-[state=selected]:[--dt-row-bg:var(--dt-row-selected-bg)] data-[state=selected]:hover:[--dt-row-bg:var(--dt-row-selected-hover-bg)]"
>
{row.getVisibleCells().map((cell) => {
const meta =
(cell.column.columnDef.meta as ColumnMeta) ?? {};
const sizeStyle: React.CSSProperties = {};
if (meta.width !== undefined) {
sizeStyle.width =
typeof meta.width === "number"
? `${meta.width}px`
: meta.width;
}
if (meta.minWidth !== undefined) {
sizeStyle.minWidth =
typeof meta.minWidth === "number"
? `${meta.minWidth}px`
: meta.minWidth;
}
if (meta.maxWidth !== undefined) {
sizeStyle.maxWidth =
typeof meta.maxWidth === "number"
? `${meta.maxWidth}px`
: meta.maxWidth;
}
const baseClassName = cn(
"relative h-11 border-r-[0.5px] border-border/60 bg-[var(--dt-row-bg)] !px-3 !py-2 align-middle !text-[0.8125rem] text-foreground whitespace-nowrap last:border-r-0",
cell.column.id === rightStickyDividerTargetId &&
"!border-r-0",
meta.cellClassName,
);
const stickyStyle =
cell.column.id === "actions"
? undefined
: getStickyStyle(cell.column.id);
return (
<TableCell
key={cell.id}
className={getStickyClassName(
cell.column.id,
baseClassName,
false,
)}
style={{ ...sizeStyle, ...(stickyStyle ?? {}) }}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={visibleColumnCount}
className="h-24 text-center text-[0.8125rem] text-muted-foreground"
>
تراکنشی یافت نشد.
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</div>
</div>
</div>
</section>
);
};