This commit is contained in:
2026-01-23 20:35:40 +03:00
parent cf3b0e48ec
commit 66c151653e
137 changed files with 41495 additions and 0 deletions

View File

@@ -0,0 +1,422 @@
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>
);
});