| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237 |
- import { useState } from 'react';
- import {
- ChevronLeft,
- ChevronRight,
- Search,
- Filter,
- MoreHorizontal,
- } from 'lucide-react';
- import { clsx } from 'clsx';
- interface Column<T> {
- key: keyof T;
- title: string;
- render?: (value: any, item: T) => React.ReactNode;
- sortable?: boolean;
- width?: string;
- }
- interface DataTableProps<T> {
- data: T[];
- columns: Column<T>[];
- loading?: boolean;
- pagination?: {
- page: number;
- limit: number;
- total: number;
- onPageChange: (page: number) => void;
- onLimitChange: (limit: number) => void;
- };
- searchable?: boolean;
- onSearch?: (query: string) => void;
- emptyMessage?: string;
- actions?: {
- render: (item: T) => React.ReactNode;
- };
- }
- function DataTable<T>({
- data,
- columns,
- loading,
- pagination,
- searchable,
- onSearch,
- emptyMessage = 'No data available',
- actions,
- }: DataTableProps<T>) {
- const [searchQuery, setSearchQuery] = useState('');
- const [sortColumn, setSortColumn] = useState<string | null>(null);
- const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
- const handleSort = (column: Column<T>) => {
- if (!column.sortable) return;
- const newDirection =
- sortColumn === column.key && sortDirection === 'asc' ? 'desc' : 'asc';
- setSortColumn(column.key as string);
- setSortDirection(newDirection);
- };
- const handleSearch = (query: string) => {
- setSearchQuery(query);
- onSearch?.(query);
- };
- const totalPages = pagination
- ? Math.ceil(pagination.total / pagination.limit)
- : 1;
- const renderCellValue = (column: Column<T>, item: T) => {
- const value = item[column.key];
- if (column.render) {
- return column.render(value, item);
- }
- return value !== null && value !== undefined ? String(value) : '-';
- };
- return (
- <div className="bg-white rounded-xl shadow-sm border border-gray-200">
- {/* Header */}
- <div className="px-6 py-4 border-b border-gray-200">
- <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
- {searchable && (
- <div className="relative flex-1 max-w-md">
- <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
- <input
- type="text"
- placeholder="Search..."
- value={searchQuery}
- onChange={(e) => handleSearch(e.target.value)}
- className="input-field pl-10"
- />
- </div>
- )}
- <div className="flex items-center gap-2">
- {pagination && (
- <select
- value={pagination.limit}
- onChange={(e) => pagination.onLimitChange(Number(e.target.value))}
- className="input-field py-1"
- >
- <option value={10}>10 per page</option>
- <option value={20}>20 per page</option>
- <option value={50}>50 per page</option>
- <option value={100}>100 per page</option>
- </select>
- )}
- </div>
- </div>
- </div>
- {/* Table */}
- <div className="overflow-x-auto">
- {loading ? (
- <div className="p-8 text-center">
- <div className="inline-flex items-center">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
- <span className="ml-3 text-gray-600">Loading...</span>
- </div>
- </div>
- ) : data.length === 0 ? (
- <div className="p-8 text-center text-gray-500">
- {emptyMessage}
- </div>
- ) : (
- <table className="min-w-full divide-y divide-gray-200">
- <thead className="bg-gray-50">
- <tr>
- {columns.map((column) => (
- <th
- key={String(column.key)}
- style={{ width: column.width }}
- className={clsx(
- 'px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider',
- column.sortable && 'cursor-pointer hover:bg-gray-100'
- )}
- onClick={() => handleSort(column)}
- >
- <div className="flex items-center">
- {column.title}
- {column.sortable && sortColumn === column.key && (
- <span className="ml-1">
- {sortDirection === 'asc' ? '↑' : '↓'}
- </span>
- )}
- </div>
- </th>
- ))}
- {actions && <th className="px-6 py-3 w-12"></th>}
- </tr>
- </thead>
- <tbody className="bg-white divide-y divide-gray-200">
- {data.map((item, index) => (
- <tr key={index} className="hover:bg-gray-50">
- {columns.map((column) => (
- <td
- key={String(column.key)}
- className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"
- >
- {renderCellValue(column, item)}
- </td>
- ))}
- {actions && (
- <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
- {actions.render(item)}
- </td>
- )}
- </tr>
- ))}
- </tbody>
- </table>
- )}
- </div>
- {/* Pagination */}
- {pagination && totalPages > 1 && (
- <div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
- <div className="flex-1 flex justify-between sm:hidden">
- <button
- onClick={() => pagination.onPageChange(pagination.page - 1)}
- disabled={pagination.page === 1}
- className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
- >
- Previous
- </button>
- <button
- onClick={() => pagination.onPageChange(pagination.page + 1)}
- disabled={pagination.page === totalPages}
- className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
- >
- Next
- </button>
- </div>
- <div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
- <div>
- <p className="text-sm text-gray-700">
- Showing{' '}
- <span className="font-medium">
- {(pagination.page - 1) * pagination.limit + 1}
- </span>{' '}
- to{' '}
- <span className="font-medium">
- {Math.min(pagination.page * pagination.limit, pagination.total)}
- </span>{' '}
- of{' '}
- <span className="font-medium">{pagination.total}</span> results
- </p>
- </div>
- <div>
- <nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
- <button
- onClick={() => pagination.onPageChange(pagination.page - 1)}
- disabled={pagination.page === 1}
- className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
- >
- <ChevronLeft className="h-5 w-5" />
- </button>
- <span className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
- {pagination.page} / {totalPages}
- </span>
- <button
- onClick={() => pagination.onPageChange(pagination.page + 1)}
- disabled={pagination.page === totalPages}
- className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
- >
- <ChevronRight className="h-5 w-5" />
- </button>
- </nav>
- </div>
- </div>
- </div>
- )}
- </div>
- );
- }
- export default DataTable;
|