"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 = { table?: ReactTable; }; 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 = { posted: "ثبت‌شده", pending: "در انتظار پیگیری", review: "نیاز به بررسی", excluded: "مستثنا", completed: "تکمیل‌شده", }; const methodLabels: Record = { Transfer: "انتقال", Card: "کارت", Wire: "حواله", ACH: "ACH", Manual: "دستی", }; const statusStyles: Record = { 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[] = [ { id: "select", header: ({ table }) => (
table.toggleAllPageRowsSelected(!!value)} aria-label="انتخاب همه رکورد‌ها" />
), cell: ({ row }) => (
row.toggleSelected(!!value)} aria-label="انتخاب رکورد" />
), 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 }) => ( ), cell: ({ row }) => (
{formatDate(row.original.date)} {row.original.id}
), 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 }) => ( ), cell: ({ row }) => (
{row.original.name} {row.original.description}
), 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 }) => ( ), cell: ({ row }) => ( 0 ? "text-emerald-600 dark:text-emerald-400" : "text-foreground", )} > {formatCurrency(row.original.amount)} ), meta: { width: 150, minWidth: 150, } satisfies ColumnMeta, }, { accessorKey: "taxAmount", header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.taxAmount ? formatCurrency(row.original.taxAmount) : ""} ), meta: { width: 130, minWidth: 130, } satisfies ColumnMeta, }, { accessorKey: "category", header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.category} ), meta: { width: 200, minWidth: 200, } satisfies ColumnMeta, }, { accessorKey: "counterparty", header: ({ column }) => ( ), cell: ({ row }) => (

{row.original.counterparty}

{methodLabels[row.original.method] ?? row.original.method}

), meta: { width: 220, minWidth: 220, } satisfies ColumnMeta, }, { accessorKey: "tags", header: ({ column }) => ( ), cell: ({ row }) => (
{row.original.tags.length ? ( row.original.tags.map((tag) => ( {tag} )) ) : ( بدون برچسب )}
), enableSorting: false, meta: { width: 260, minWidth: 260, } satisfies ColumnMeta, }, { accessorKey: "account", header: ({ column }) => ( ), cell: ({ row }) => {row.original.account}, meta: { width: 220, minWidth: 220, } satisfies ColumnMeta, }, { accessorKey: "method", header: ({ column }) => ( ), cell: ({ row }) => ( {methodLabels[row.original.method] ?? row.original.method} ), meta: { width: 160, minWidth: 160, } satisfies ColumnMeta, }, { accessorKey: "assigned", header: ({ column }) => ( ), cell: ({ row }) => row.original.assigned ? ( {row.original.assigned} ) : ( بدون مسئول ), meta: { width: 200, minWidth: 200, } satisfies ColumnMeta, }, { accessorKey: "status", header: ({ column }) => ( ), cell: ({ row }) => ( {statusLabels[row.original.status]} ), meta: { width: 160, minWidth: 160, } satisfies ColumnMeta, }, { id: "actions", header: () => عملیات, cell: () => (
), 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({ table }: UseStickyColumnsOptions) { 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 = {}; 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(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(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 (
); }; type DataTableColumnHeaderProps = { column: Column; title: string; }; const DataTableColumnHeader = ({ column, title, }: DataTableColumnHeaderProps) => { const canSort = column.getCanSort(); const sorted = column.getIsSorted(); if (!canSort) { return (
{title}
); } return ( ); }; export const DataTable26 = ({ className }: { className?: string }) => { const [sorting, setSorting] = React.useState([ { id: "date", desc: true }, ]); const [rowSelection, setRowSelection] = React.useState({}); 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 (

جدول تراکنش‌ها با کنترل‌های افقی

ستون‌های اول چسبان، کنترل‌های پیمایش و نوار عملیات واکنش‌گرا، مرور داده‌های مالی متراکم را آسان می‌کنند.

{table.getHeaderGroups().map((headerGroup) => ( {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 ( {header.isPlaceholder ? null : header.column.id === "description" ? (
{flexRender( header.column.columnDef.header, header.getContext(), )}
) : ( flexRender( header.column.columnDef.header, header.getContext(), ) )}
); })}
))}
{table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( {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 ( {flexRender( cell.column.columnDef.cell, cell.getContext(), )} ); })} )) ) : ( تراکنشی یافت نشد. )}
); };