Files
dmail-front/src/components/Table.tsx
T
2025-07-30 09:47:21 +03:30

569 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}
/>
);
};
*/