569 lines
24 KiB
TypeScript
569 lines
24 KiB
TypeScript
import React, { Fragment, useState, useRef } from 'react';
|
||
import DefaultTableSkeleton from './DefaultTableSkeleton';
|
||
import Td from './Td';
|
||
import { TableProps, RowDataType, ColumnType } from './types/TableTypes';
|
||
import { Checkbox } from './ui/checkbox';
|
||
import RowActionsDropdown from './RowActionsDropdown';
|
||
import Pagination from './Pagination';
|
||
import NoData from '@/assets/images/empty.svg';
|
||
|
||
const Table = <T extends RowDataType>({
|
||
columns,
|
||
data,
|
||
isLoading = false,
|
||
onRowClick,
|
||
className = '',
|
||
rowClassName = '',
|
||
noDataMessage = 'هیچ دادهای یافت نشد.',
|
||
headerClassName = 'bg-gray-50',
|
||
emptyRowsCount = 5,
|
||
showHeader = true,
|
||
actions = null,
|
||
actionsPosition = 'top',
|
||
selectable = false,
|
||
selectedActions = null,
|
||
onSelectionChange,
|
||
selectedRows: externalSelectedRows,
|
||
clearSelection = false,
|
||
rowActions,
|
||
inlineActions,
|
||
pagination,
|
||
}: TableProps<T>): React.ReactElement => {
|
||
|
||
const [internalSelectedRows, setInternalSelectedRows] = useState<T[]>([]);
|
||
const [mobileSelectionMode, setMobileSelectionMode] = useState(false);
|
||
const longPressTimer = useRef<number | null>(null);
|
||
const [longPressActiveId, setLongPressActiveId] = useState<string | number | null>(null);
|
||
|
||
// استفاده از selectedRows خارجی اگر موجود باشد، در غیر این صورت از internal استفاده کن
|
||
const selectedRows = externalSelectedRows !== undefined ? externalSelectedRows : internalSelectedRows;
|
||
|
||
// پاک کردن انتخابها وقتی clearSelection تغییر کند
|
||
React.useEffect(() => {
|
||
if (clearSelection && externalSelectedRows === undefined) {
|
||
setInternalSelectedRows([]);
|
||
}
|
||
// خروج از حالت انتخاب موبایل هم
|
||
if (clearSelection) {
|
||
setMobileSelectionMode(false);
|
||
}
|
||
}, [clearSelection, externalSelectedRows]);
|
||
|
||
// تمیز کردن timer هنگام unmount
|
||
React.useEffect(() => {
|
||
return () => {
|
||
if (longPressTimer.current) {
|
||
clearTimeout(longPressTimer.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
// خروج از حالت انتخاب اگر هیچ آیتمی انتخاب نشده، ورود اگر آیتمی انتخاب شده
|
||
React.useEffect(() => {
|
||
if (selectedRows.length === 0 && mobileSelectionMode) {
|
||
setMobileSelectionMode(false);
|
||
} else if (selectedRows.length > 0 && !mobileSelectionMode) {
|
||
setMobileSelectionMode(true);
|
||
}
|
||
}, [selectedRows.length, mobileSelectionMode]);
|
||
|
||
const handleLongPressStart = (item: T) => {
|
||
if (!selectable || mobileSelectionMode) return;
|
||
|
||
setLongPressActiveId(item.id);
|
||
longPressTimer.current = window.setTimeout(() => {
|
||
setMobileSelectionMode(true);
|
||
// انتخاب آیتم فعلی
|
||
handleSingleRowSelect(item, true);
|
||
setLongPressActiveId(null);
|
||
}, 500); // 500ms برای long press
|
||
};
|
||
|
||
const handleLongPressEnd = () => {
|
||
if (longPressTimer.current) {
|
||
clearTimeout(longPressTimer.current);
|
||
longPressTimer.current = null;
|
||
}
|
||
setLongPressActiveId(null);
|
||
};
|
||
|
||
const handleMobileRowClick = (item: T) => {
|
||
if (mobileSelectionMode) {
|
||
// در حالت انتخاب، آیتم را انتخاب/عدم انتخاب کن
|
||
const isSelected = isRowSelected(item);
|
||
handleSingleRowSelect(item, !isSelected);
|
||
} else if (longPressActiveId !== item.id) {
|
||
// در حالت عادی، row click عادی را اجرا کن
|
||
handleRowClick(item);
|
||
}
|
||
};
|
||
|
||
const getRowClassName = (item: T, index: number): string => {
|
||
if (typeof rowClassName === 'function') {
|
||
return rowClassName(item, index);
|
||
}
|
||
return rowClassName;
|
||
};
|
||
|
||
const getCellClassName = (column: ColumnType<T>): string => {
|
||
const alignClass = column.align ? `text-${column.align}` : '';
|
||
return `px-3 md:px-6 py-3 md:py-4 whitespace-nowrap text-xs ${alignClass} ${column.className || ''}`.trim();
|
||
};
|
||
|
||
const handleRowClick = (item: T) => {
|
||
if (onRowClick) {
|
||
onRowClick(item.id, item);
|
||
}
|
||
};
|
||
|
||
const handleSelectAll = () => {
|
||
const newSelectedRows = selectedRows.length === data.length ? [] : [...data];
|
||
|
||
if (externalSelectedRows !== undefined) {
|
||
// اگر selectedRows از خارج کنترل میشود، فقط callback را فراخوانی کن
|
||
if (onSelectionChange) {
|
||
onSelectionChange(newSelectedRows);
|
||
}
|
||
} else {
|
||
// در غیر این صورت internal state را بهروزرسانی کن
|
||
setInternalSelectedRows(newSelectedRows);
|
||
if (onSelectionChange) {
|
||
onSelectionChange(newSelectedRows);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleSingleRowSelect = (item: T, checked: boolean) => {
|
||
const newSelectedRows = checked
|
||
? [...selectedRows, item]
|
||
: selectedRows.filter((row) => row.id !== item.id);
|
||
|
||
if (externalSelectedRows !== undefined) {
|
||
// اگر selectedRows از خارج کنترل میشود، فقط callback را فراخوانی کن
|
||
if (onSelectionChange) {
|
||
onSelectionChange(newSelectedRows);
|
||
}
|
||
} else {
|
||
// در غیر این صورت internal state را بهروزرسانی کن
|
||
setInternalSelectedRows(newSelectedRows);
|
||
if (onSelectionChange) {
|
||
onSelectionChange(newSelectedRows);
|
||
}
|
||
}
|
||
};
|
||
|
||
const isRowSelected = (item: T): boolean => {
|
||
return selectedRows.some((row) => row.id === item.id);
|
||
};
|
||
|
||
// Gmail-style layout برای وقتی header نمایش داده نمیشود
|
||
const renderGmailStyleRows = () => {
|
||
const hasActions = (actionsPosition === 'top' || actionsPosition === 'above-header') && (actions || (selectable && selectedRows.length > 0 && selectedActions));
|
||
const roundedClass = hasActions ? 'rounded-b-2xl' : 'rounded-2xl';
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className={`bg-white ${roundedClass} overflow-hidden`}>
|
||
{Array.from({ length: emptyRowsCount }).map((_, index) => (
|
||
<div key={index} className={`h-[48px] bg-gray-100 animate-pulse ${index !== 0 ? 'border-t border-[#EAEDF5]' : ''}`}></div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (data.length === 0) {
|
||
return (
|
||
<div className={`bg-white ${roundedClass} text-center text-gray-500`}>
|
||
<img src={NoData} alt='no data' className='w-[100px] mx-auto pt-[70px]' />
|
||
<div className='-mt-5 pb-[70px]'>{noDataMessage}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={`bg-white ${roundedClass} overflow-hidden`}>
|
||
{data.map((item, rowIndex) => {
|
||
// Gmail-style: خوانده نشده = پسزمینه سفید، خوانده شده = پسزمینه خاکستری
|
||
const isUnread = 'seen' in item && !item.seen;
|
||
const bgColor = isUnread ? 'bg-white' : 'bg-gray-50';
|
||
const hoverColor = isUnread ? 'hover:bg-gray-50' : 'hover:bg-gray-100';
|
||
const fontWeight = isUnread ? 'font-semibold' : 'font-normal';
|
||
|
||
return (
|
||
<div
|
||
key={item.id}
|
||
className={`w-full min-h-[85px] ${bgColor} ${hoverColor} ${mobileSelectionMode || !onRowClick ? '' : 'cursor-pointer'} ${getRowClassName(item, rowIndex)} px-4 py-3 flex items-center gap-3 ${rowIndex !== 0 ? 'border-t border-[#EAEDF5]' : ''} ${longPressActiveId === item.id ? 'bg-blue-50' : ''}`}
|
||
onClick={() => handleMobileRowClick(item)}
|
||
onTouchStart={() => handleLongPressStart(item)}
|
||
onTouchEnd={handleLongPressEnd}
|
||
onTouchCancel={handleLongPressEnd}
|
||
onMouseDown={() => handleLongPressStart(item)}
|
||
onMouseUp={handleLongPressEnd}
|
||
onMouseLeave={handleLongPressEnd}
|
||
>
|
||
{selectable && mobileSelectionMode && (
|
||
<div
|
||
className="flex-shrink-0"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<Checkbox
|
||
checked={isRowSelected(item)}
|
||
onCheckedChange={(checked) => handleSingleRowSelect(item, !!checked)}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Inline Actions - بعد از checkbox */}
|
||
|
||
|
||
<div className="flex-1 min-w-0">
|
||
{/* محتوای اصلی - ستون اول */}
|
||
<div className={`flex items-center mb-0.5 text-sm ${fontWeight}`}>
|
||
{columns[0]?.render ? columns[0].render(item) : (item[columns[0]?.key] as React.ReactNode)}
|
||
</div>
|
||
|
||
{/* اطلاعات اضافی - سه ستون آخر در یک خط */}
|
||
{columns.length > 1 && (
|
||
<div className={`flex items-center gap-3 text-xs text-gray-500 ${fontWeight}`}>
|
||
{columns.slice(1).map((col) => (
|
||
<div key={col.key} className="truncate">
|
||
{col.render ? col.render(item) : (item[col.key] as React.ReactNode)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{rowActions && (
|
||
<div
|
||
className="flex-shrink-0 "
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<RowActionsDropdown actions={rowActions(item)} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderTableBody = () => {
|
||
if (isLoading) {
|
||
return (
|
||
<Fragment>
|
||
<DefaultTableSkeleton tdCount={columns.length + (selectable ? 1 : 0) + (inlineActions ? 1 : 0) + (rowActions ? 1 : 0)} trCount={emptyRowsCount} />
|
||
</Fragment>
|
||
);
|
||
}
|
||
|
||
if (data.length === 0) {
|
||
return (
|
||
<tr>
|
||
<td colSpan={columns.length + (selectable ? 1 : 0) + (inlineActions ? 1 : 0) + (rowActions ? 1 : 0)} className="px-3 md:px-6 pb-6 md:pb-14 border-t border-border text-center text-gray-500">
|
||
<img src={NoData} alt='no data' className='w-[100px] mx-auto pt-14' />
|
||
<div className='-mt-5'>{noDataMessage}</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
return data.map((item, rowIndex) => {
|
||
// Gmail-style: خوانده نشده = پسزمینه سفید، خوانده شده = پسزمینه خاکستری
|
||
const isUnread = 'seen' in item && !item.seen;
|
||
const bgColor = isUnread ? 'bg-white' : 'bg-gray-50';
|
||
const hoverColor = isUnread ? 'hover:bg-gray-50' : 'hover:bg-gray-100';
|
||
const fontWeight = isUnread ? 'font-semibold' : 'font-normal';
|
||
|
||
return (
|
||
<tr
|
||
key={item.id}
|
||
className={`w-full md:h-[64px] !h-[74px] ${bgColor} border-t border-[#EAEDF5] ${hoverColor} ${onRowClick ? 'cursor-pointer' : ''} ${getRowClassName(item, rowIndex)}`}
|
||
onClick={() => handleRowClick(item)}
|
||
>
|
||
{selectable && (
|
||
<td
|
||
className="px-3 md:px-6 py-3 md:py-4 w-8 md:w-10 "
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className='flex items-center gap-3'>
|
||
<Checkbox
|
||
checked={isRowSelected(item)}
|
||
onCheckedChange={(checked) => handleSingleRowSelect(item, !!checked)}
|
||
/>
|
||
{
|
||
inlineActions && (
|
||
<div className="flex items-center gap-2">
|
||
{inlineActions(item).map((action, actionIndex) => (
|
||
<div
|
||
key={actionIndex}
|
||
className={`cursor-pointer ${action.className || ''}`}
|
||
onClick={action.onClick}
|
||
title={action.tooltip}
|
||
>
|
||
{action.icon}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
</div>
|
||
|
||
</td>
|
||
)}
|
||
|
||
{columns.map((col) => (
|
||
<td
|
||
key={`${item.id}-${col.key}`}
|
||
className={`${getCellClassName(col)} ${fontWeight}`}
|
||
style={{ width: col.width }}
|
||
>
|
||
{col.render ? col.render(item) : item[col.key] as React.ReactNode}
|
||
</td>
|
||
))}
|
||
{rowActions && (
|
||
<td
|
||
className="px-3 md:px-6 py-3 md:py-4 w-8 md:w-10 "
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<RowActionsDropdown actions={rowActions(item)} />
|
||
</td>
|
||
)}
|
||
</tr>
|
||
);
|
||
});
|
||
};
|
||
|
||
const renderHeader = () => {
|
||
if (!showHeader || actionsPosition === 'header-replace') return null;
|
||
|
||
return (
|
||
<thead className={`h-[60px] md:h-[69px] bg-white text-sm text-[#8C90A3] ${headerClassName}`}>
|
||
<tr>
|
||
{selectable && (
|
||
<th
|
||
className="px-3 md:px-6 py-3 md:py-4 w-8 md:w-10 first:rounded-tr-2xl md:first:rounded-tr-3xl"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<Checkbox
|
||
checked={data.length > 0 && selectedRows.length === data.length}
|
||
onCheckedChange={handleSelectAll}
|
||
/>
|
||
</th>
|
||
)}
|
||
{inlineActions && (
|
||
<th className="px-3 md:px-6 py-3 md:py-4 text-center text-sm font-medium">عملیات</th>
|
||
)}
|
||
{columns.map((col) => (
|
||
<Td key={col.key} text={col.title} />
|
||
))}
|
||
{rowActions && (
|
||
<th className="px-3 md:px-6 py-3 md:py-4 w-8 md:w-10 rounded-tl-2xl md:rounded-tl-3xl"></th>
|
||
)}
|
||
</tr>
|
||
</thead >
|
||
);
|
||
};
|
||
|
||
const renderActions = () => {
|
||
if (!actions && (!selectable || selectedRows.length === 0 || !selectedActions)) return null;
|
||
|
||
return (
|
||
<div className="h-[64px] rounded-t-2xl md:rounded-t-3xl md:h-[74px] bg-white flex items-center px-3 md:px-6">
|
||
<div className='flex items-center gap-4 md:gap-4'>
|
||
{actions}
|
||
{selectable && selectedRows.length > 0 && selectedActions && selectedActions}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// اگر header نشان داده نمیشود، از Gmail-style layout فقط در موبایل استفاده کن
|
||
if (!showHeader) {
|
||
return (
|
||
<div className={`relative mt-6 md:mt-9 w-full ${className}`}>
|
||
{/* نسخه موبایل - Gmail style */}
|
||
<div className={`md:hidden`}>
|
||
{(actionsPosition === 'top' || actionsPosition === 'above-header') && (
|
||
<div className="h-[64px] bg-white flex items-center justify-between px-4 rounded-t-2xl border-b border-[#EAEDF5]">
|
||
<div className='flex items-center gap-4 md:gap-2'>
|
||
{actions}
|
||
{selectable && selectedRows.length > 0 && selectedActions && selectedActions}
|
||
</div>
|
||
{mobileSelectionMode && (
|
||
<button
|
||
onClick={() => {
|
||
setMobileSelectionMode(false);
|
||
// پاک کردن انتخابها
|
||
if (externalSelectedRows !== undefined) {
|
||
if (onSelectionChange) {
|
||
onSelectionChange([]);
|
||
}
|
||
} else {
|
||
setInternalSelectedRows([]);
|
||
if (onSelectionChange) {
|
||
onSelectionChange([]);
|
||
}
|
||
}
|
||
}}
|
||
className="text-sm text-gray-600 px-3 py-1"
|
||
>
|
||
انصراف
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
{renderGmailStyleRows()}
|
||
</div>
|
||
|
||
{/* نسخه دسکتاپ - Table معمولی */}
|
||
<div className={`hidden md:block`}>
|
||
{(actionsPosition === 'top' || actionsPosition === 'above-header') && (
|
||
<div className="h-[74px] bg-white flex items-center px-6 rounded-t-3xl">
|
||
<div className='flex items-center gap-4'>
|
||
{actions}
|
||
{selectable && selectedRows.length > 0 && selectedActions && selectedActions}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className={`overflow-x-auto overflow-hidden ${(actionsPosition === 'top' || actionsPosition === 'above-header') && (actions || (selectable && selectedRows.length > 0 && selectedActions)) ? 'rounded-b-3xl' : 'rounded-3xl'} bg-white`}>
|
||
<table className="w-full text-sm border-collapse min-w-[600px]">
|
||
<tbody>
|
||
{renderTableBody()}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{pagination && (
|
||
<Pagination
|
||
hasNext={!!pagination.nextCursor}
|
||
nextCursor={pagination.nextCursor}
|
||
previousCursor={pagination.previousCursor}
|
||
onNext={pagination.onNext}
|
||
onPrevious={pagination.onPrevious}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={`relative mt-6 md:mt-9 w-full ${className}`}>
|
||
{(actionsPosition === 'top' || actionsPosition === 'above-header') && renderActions()}
|
||
|
||
<div className={`overflow-x-auto overflow-hidden ${showHeader && actionsPosition !== 'header-replace' ? 'rounded-2xl md:rounded-3xl' : 'rounded-b-2xl md:rounded-b-3xl'} bg-white`}>
|
||
<table className="w-full text-sm border-collapse min-w-[600px]">
|
||
{renderHeader()}
|
||
{actionsPosition === 'header-replace' && (
|
||
<thead>
|
||
<tr>
|
||
<th colSpan={columns.length + (selectable ? 1 : 0) + (inlineActions ? 1 : 0) + (rowActions ? 1 : 0)} className="p-0">
|
||
{renderActions()}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
)}
|
||
<tbody>
|
||
{renderTableBody()}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{pagination && (() => {
|
||
return (
|
||
<Pagination
|
||
hasNext={pagination.hasNext}
|
||
nextCursor={pagination.nextCursor}
|
||
previousCursor={pagination.previousCursor}
|
||
onNext={pagination.onNext}
|
||
onPrevious={pagination.onPrevious}
|
||
/>
|
||
);
|
||
})()}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Table;
|
||
|
||
/*
|
||
مثال استفاده با pagination:
|
||
|
||
import Table from '@/components/Table';
|
||
import { useState } from 'react';
|
||
|
||
const MyComponent = () => {
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const totalPages = 10;
|
||
|
||
const columns = [
|
||
{ title: 'نام', key: 'name' },
|
||
{ title: 'ایمیل', key: 'email' },
|
||
];
|
||
|
||
const data = [
|
||
{ id: 1, name: 'علی', email: 'ali@example.com' },
|
||
{ id: 2, name: 'فاطمه', email: 'fateme@example.com' },
|
||
];
|
||
|
||
const handlePageChange = (page: number) => {
|
||
setCurrentPage(page);
|
||
// اینجا میتوانید API call جدید برای دریافت دادههای صفحه جدید بزنید
|
||
};
|
||
|
||
return (
|
||
<Table
|
||
columns={columns}
|
||
data={data}
|
||
pagination={{
|
||
currentPage,
|
||
totalPages,
|
||
onPageChange: handlePageChange
|
||
}}
|
||
/>
|
||
);
|
||
};
|
||
|
||
مثال استفاده با inline actions:
|
||
|
||
import Table from '@/components/Table';
|
||
import { Star1, Archive } from 'iconsax-react';
|
||
|
||
const MyEmailComponent = () => {
|
||
const columns = [
|
||
{ title: 'موضوع', key: 'subject' },
|
||
{ title: 'فرستنده', key: 'from' },
|
||
];
|
||
|
||
const data = [
|
||
{ id: 1, subject: 'سلام', from: 'ali@example.com', flagged: false },
|
||
{ id: 2, subject: 'خبرنامه', from: 'newsletter@example.com', flagged: true },
|
||
];
|
||
|
||
const getInlineActions = (item) => [
|
||
{
|
||
icon: <Star1
|
||
variant={item.flagged ? 'Bold' : 'Outline'}
|
||
size={18}
|
||
color={item.flagged ? '#FFC107' : '#8C90A3'}
|
||
/>,
|
||
onClick: () => handleFavoriteToggle(item.id),
|
||
tooltip: item.flagged ? 'حذف از علاقهمندیها' : 'اضافه به علاقهمندیها'
|
||
},
|
||
{
|
||
icon: <Archive size={18} color="#8C90A3" />,
|
||
onClick: () => handleArchive(item.id),
|
||
tooltip: 'بایگانی'
|
||
}
|
||
];
|
||
|
||
return (
|
||
<Table
|
||
columns={columns}
|
||
data={data}
|
||
inlineActions={getInlineActions}
|
||
/>
|
||
);
|
||
};
|
||
*/ |