car_mms/app/components/ui/DataTable.tsx
2025-09-11 14:22:27 +03:00

422 lines
14 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 { 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>
);
});