422 lines
14 KiB
TypeScript
422 lines
14 KiB
TypeScript
import { ReactNode, memo, useState, useMemo } from 'react';
|
||
import { Text } from './Text';
|
||
import { Button } from './Button';
|
||
import { Input } from './Input';
|
||
import { Select } from './Select';
|
||
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||
|
||
interface Column<T> {
|
||
key: keyof T | string;
|
||
header: string;
|
||
render?: (item: T) => ReactNode;
|
||
sortable?: boolean;
|
||
filterable?: boolean;
|
||
filterType?: 'text' | 'select' | 'date' | 'number';
|
||
filterOptions?: { value: string; label: string }[];
|
||
className?: string;
|
||
width?: string;
|
||
}
|
||
|
||
interface FilterState {
|
||
[key: string]: string;
|
||
}
|
||
|
||
interface DataTableProps<T> {
|
||
data: T[];
|
||
columns: Column<T>[];
|
||
loading?: boolean;
|
||
emptyMessage?: string;
|
||
className?: string;
|
||
config?: Partial<LayoutConfig>;
|
||
onSort?: (key: string, direction: 'asc' | 'desc') => void;
|
||
sortKey?: string;
|
||
sortDirection?: 'asc' | 'desc';
|
||
searchable?: boolean;
|
||
searchPlaceholder?: string;
|
||
filterable?: boolean;
|
||
pagination?: {
|
||
enabled: boolean;
|
||
pageSize?: number;
|
||
currentPage?: number;
|
||
totalItems?: number;
|
||
onPageChange?: (page: number) => void;
|
||
};
|
||
actions?: {
|
||
label: string;
|
||
render: (item: T) => ReactNode;
|
||
};
|
||
}
|
||
|
||
export const DataTable = memo(function DataTable<T extends Record<string, any>>({
|
||
data,
|
||
columns,
|
||
loading = false,
|
||
emptyMessage = "لا توجد بيانات",
|
||
className = '',
|
||
config = {},
|
||
onSort,
|
||
sortKey,
|
||
sortDirection,
|
||
searchable = false,
|
||
searchPlaceholder = "البحث...",
|
||
filterable = false,
|
||
pagination,
|
||
actions,
|
||
}: DataTableProps<T>) {
|
||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [filters, setFilters] = useState<FilterState>({});
|
||
|
||
const handleSort = (key: string) => {
|
||
if (!onSort) return;
|
||
|
||
const newDirection = sortKey === key && sortDirection === 'asc' ? 'desc' : 'asc';
|
||
onSort(key, newDirection);
|
||
};
|
||
|
||
const handleFilterChange = (columnKey: string, value: string) => {
|
||
setFilters(prev => ({
|
||
...prev,
|
||
[columnKey]: value,
|
||
}));
|
||
};
|
||
|
||
// Filter and search data
|
||
const filteredData = useMemo(() => {
|
||
let result = [...data];
|
||
|
||
// Apply search
|
||
if (searchable && searchTerm) {
|
||
result = result.filter(item => {
|
||
return columns.some(column => {
|
||
const value = item[column.key];
|
||
if (value == null) return false;
|
||
return String(value).toLowerCase().includes(searchTerm.toLowerCase());
|
||
});
|
||
});
|
||
}
|
||
|
||
// Apply column filters
|
||
if (filterable) {
|
||
Object.entries(filters).forEach(([columnKey, filterValue]) => {
|
||
if (filterValue) {
|
||
result = result.filter(item => {
|
||
const value = item[columnKey];
|
||
if (value == null) return false;
|
||
return String(value).toLowerCase().includes(filterValue.toLowerCase());
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
return result;
|
||
}, [data, searchTerm, filters, columns, searchable, filterable]);
|
||
|
||
// Paginate data
|
||
const paginatedData = useMemo(() => {
|
||
if (!pagination?.enabled) return filteredData;
|
||
|
||
const pageSize = pagination.pageSize || 10;
|
||
const currentPage = pagination.currentPage || 1;
|
||
const startIndex = (currentPage - 1) * pageSize;
|
||
const endIndex = startIndex + pageSize;
|
||
|
||
return filteredData.slice(startIndex, endIndex);
|
||
}, [filteredData, pagination]);
|
||
|
||
const totalPages = pagination?.enabled
|
||
? Math.ceil(filteredData.length / (pagination.pageSize || 10))
|
||
: 1;
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className={`bg-white rounded-lg border border-gray-200 ${className}`} dir={layoutConfig.direction}>
|
||
<div className="p-8 text-center">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||
<Text color="secondary">جاري التحميل...</Text>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (data.length === 0) {
|
||
return (
|
||
<div className={`bg-white rounded-lg border border-gray-200 ${className}`} dir={layoutConfig.direction}>
|
||
<div className="p-8 text-center">
|
||
<svg
|
||
className="mx-auto h-12 w-12 text-gray-400 mb-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||
/>
|
||
</svg>
|
||
<Text size="lg" weight="medium" className="mb-2">
|
||
لا توجد بيانات
|
||
</Text>
|
||
<Text color="secondary">{emptyMessage}</Text>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={`bg-white rounded-lg border border-gray-200 overflow-hidden ${className}`} dir={layoutConfig.direction}>
|
||
{/* Search and Filters */}
|
||
{(searchable || filterable) && (
|
||
<div className="p-4 border-b border-gray-200 bg-gray-50">
|
||
<div className="flex flex-col space-y-4">
|
||
{/* Search */}
|
||
{searchable && (
|
||
<div className="flex-1">
|
||
<Input
|
||
placeholder={searchPlaceholder}
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
startIcon={
|
||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||
</svg>
|
||
}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Column Filters */}
|
||
{filterable && (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{columns
|
||
.filter(column => column.filterable)
|
||
.map(column => (
|
||
<div key={`filter-${column.key}`}>
|
||
{column.filterType === 'select' && column.filterOptions ? (
|
||
<Select
|
||
placeholder={`تصفية ${column.header}`}
|
||
value={filters[column.key as string] || ''}
|
||
onChange={(e) => handleFilterChange(column.key as string, e.target.value)}
|
||
options={[
|
||
{ value: '', label: `جميع ${column.header}` },
|
||
...column.filterOptions
|
||
]}
|
||
/>
|
||
) : (
|
||
<Input
|
||
placeholder={`تصفية ${column.header}`}
|
||
value={filters[column.key as string] || ''}
|
||
onChange={(e) => handleFilterChange(column.key as string, e.target.value)}
|
||
/>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Table */}
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full divide-y divide-gray-200">
|
||
<thead className="bg-gray-50">
|
||
<tr>
|
||
{columns.map((column) => (
|
||
<th
|
||
key={`header-${column.key}`}
|
||
className={`px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider ${column.className || ''}`}
|
||
style={column.width ? { width: column.width } : undefined}
|
||
>
|
||
{column.sortable && onSort ? (
|
||
<button
|
||
onClick={() => handleSort(column.key as string)}
|
||
className="group inline-flex items-center space-x-1 space-x-reverse hover:text-gray-700"
|
||
>
|
||
<span>{column.header}</span>
|
||
<span className="ml-2 flex-none rounded text-gray-400 group-hover:text-gray-500">
|
||
{sortKey === column.key ? (
|
||
sortDirection === 'asc' ? (
|
||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||
<path fillRule="evenodd" d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" clipRule="evenodd" />
|
||
</svg>
|
||
) : (
|
||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||
</svg>
|
||
)
|
||
) : (
|
||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||
<path d="M5 12a1 1 0 102 0V6.414l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L5 6.414V12zM15 8a1 1 0 10-2 0v5.586l-1.293-1.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L15 13.586V8z" />
|
||
</svg>
|
||
)}
|
||
</span>
|
||
</button>
|
||
) : (
|
||
column.header
|
||
)}
|
||
</th>
|
||
))}
|
||
{actions && (
|
||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
{actions.label}
|
||
</th>
|
||
)}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-200">
|
||
{paginatedData.map((item, rowIndex) => {
|
||
// Use item.id if available, otherwise fall back to rowIndex
|
||
const rowKey = item.id ? `row-${item.id}` : `row-${rowIndex}`;
|
||
return (
|
||
<tr key={rowKey} className="hover:bg-gray-50">
|
||
{columns.map((column) => (
|
||
<td
|
||
key={`${rowKey}-${column.key}`}
|
||
className={`px-6 py-4 whitespace-nowrap text-sm text-gray-900 ${column.className || ''}`}
|
||
>
|
||
{column.render
|
||
? column.render(item)
|
||
: String(item[column.key] || '')
|
||
}
|
||
</td>
|
||
))}
|
||
{actions && (
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||
{actions.render(item)}
|
||
</td>
|
||
)}
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
{pagination?.enabled && totalPages > 1 && (
|
||
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
|
||
<Pagination
|
||
currentPage={pagination.currentPage || 1}
|
||
totalPages={totalPages}
|
||
onPageChange={pagination.onPageChange || (() => {})}
|
||
config={config}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Results Summary */}
|
||
{(searchable || filterable || pagination?.enabled) && (
|
||
<div className="px-4 py-2 border-t border-gray-200 bg-gray-50">
|
||
<Text size="sm" color="secondary">
|
||
عرض {paginatedData.length} من {filteredData.length}
|
||
{filteredData.length !== data.length && ` (مفلتر من ${data.length})`}
|
||
</Text>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}) as <T extends Record<string, any>>(props: DataTableProps<T>) => JSX.Element;
|
||
|
||
interface PaginationProps {
|
||
currentPage: number;
|
||
totalPages: number;
|
||
onPageChange: (page: number) => void;
|
||
className?: string;
|
||
config?: Partial<LayoutConfig>;
|
||
}
|
||
|
||
export const Pagination = memo(function Pagination({
|
||
currentPage,
|
||
totalPages,
|
||
onPageChange,
|
||
className = '',
|
||
config = {},
|
||
}: PaginationProps) {
|
||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||
|
||
if (totalPages <= 1) return null;
|
||
|
||
const getPageNumbers = () => {
|
||
const pages = [];
|
||
const maxVisible = 5;
|
||
|
||
if (totalPages <= maxVisible) {
|
||
for (let i = 1; i <= totalPages; i++) {
|
||
pages.push(i);
|
||
}
|
||
} else {
|
||
if (currentPage <= 3) {
|
||
for (let i = 1; i <= 4; i++) {
|
||
pages.push(i);
|
||
}
|
||
pages.push('...');
|
||
pages.push(totalPages);
|
||
} else if (currentPage >= totalPages - 2) {
|
||
pages.push(1);
|
||
pages.push('...');
|
||
for (let i = totalPages - 3; i <= totalPages; i++) {
|
||
pages.push(i);
|
||
}
|
||
} else {
|
||
pages.push(1);
|
||
pages.push('...');
|
||
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
|
||
pages.push(i);
|
||
}
|
||
pages.push('...');
|
||
pages.push(totalPages);
|
||
}
|
||
}
|
||
|
||
return pages;
|
||
};
|
||
|
||
return (
|
||
<div className={`flex items-center justify-between ${className}`} dir={layoutConfig.direction}>
|
||
<div className="flex items-center space-x-2 space-x-reverse">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => onPageChange(currentPage - 1)}
|
||
disabled={currentPage === 1}
|
||
>
|
||
السابق
|
||
</Button>
|
||
|
||
<div className="flex items-center space-x-1 space-x-reverse">
|
||
{getPageNumbers().map((page, index) => (
|
||
<div key={index}>
|
||
{page === '...' ? (
|
||
<span className="px-3 py-2 text-gray-500">...</span>
|
||
) : (
|
||
<Button
|
||
variant={currentPage === page ? "primary" : "outline"}
|
||
size="sm"
|
||
onClick={() => onPageChange(page as number)}
|
||
>
|
||
{page}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => onPageChange(currentPage + 1)}
|
||
disabled={currentPage === totalPages}
|
||
>
|
||
التالي
|
||
</Button>
|
||
</div>
|
||
|
||
<Text color="secondary" size="sm">
|
||
صفحة {currentPage} من {totalPages}
|
||
</Text>
|
||
</div>
|
||
);
|
||
}); |