DataTable.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. import { useState } from 'react';
  2. import {
  3. ChevronLeft,
  4. ChevronRight,
  5. Search,
  6. Filter,
  7. MoreHorizontal,
  8. } from 'lucide-react';
  9. import { clsx } from 'clsx';
  10. interface Column<T> {
  11. key: keyof T;
  12. title: string;
  13. render?: (value: any, item: T) => React.ReactNode;
  14. sortable?: boolean;
  15. width?: string;
  16. }
  17. interface DataTableProps<T> {
  18. data: T[];
  19. columns: Column<T>[];
  20. loading?: boolean;
  21. pagination?: {
  22. page: number;
  23. limit: number;
  24. total: number;
  25. onPageChange: (page: number) => void;
  26. onLimitChange: (limit: number) => void;
  27. };
  28. searchable?: boolean;
  29. onSearch?: (query: string) => void;
  30. emptyMessage?: string;
  31. actions?: {
  32. render: (item: T) => React.ReactNode;
  33. };
  34. }
  35. function DataTable<T>({
  36. data,
  37. columns,
  38. loading,
  39. pagination,
  40. searchable,
  41. onSearch,
  42. emptyMessage = 'No data available',
  43. actions,
  44. }: DataTableProps<T>) {
  45. const [searchQuery, setSearchQuery] = useState('');
  46. const [sortColumn, setSortColumn] = useState<string | null>(null);
  47. const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
  48. const handleSort = (column: Column<T>) => {
  49. if (!column.sortable) return;
  50. const newDirection =
  51. sortColumn === column.key && sortDirection === 'asc' ? 'desc' : 'asc';
  52. setSortColumn(column.key as string);
  53. setSortDirection(newDirection);
  54. };
  55. const handleSearch = (query: string) => {
  56. setSearchQuery(query);
  57. onSearch?.(query);
  58. };
  59. const totalPages = pagination
  60. ? Math.ceil(pagination.total / pagination.limit)
  61. : 1;
  62. const renderCellValue = (column: Column<T>, item: T) => {
  63. const value = item[column.key];
  64. if (column.render) {
  65. return column.render(value, item);
  66. }
  67. return value !== null && value !== undefined ? String(value) : '-';
  68. };
  69. return (
  70. <div className="bg-white rounded-xl shadow-sm border border-gray-200">
  71. {/* Header */}
  72. <div className="px-6 py-4 border-b border-gray-200">
  73. <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
  74. {searchable && (
  75. <div className="relative flex-1 max-w-md">
  76. <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
  77. <input
  78. type="text"
  79. placeholder="Search..."
  80. value={searchQuery}
  81. onChange={(e) => handleSearch(e.target.value)}
  82. className="input-field pl-10"
  83. />
  84. </div>
  85. )}
  86. <div className="flex items-center gap-2">
  87. {pagination && (
  88. <select
  89. value={pagination.limit}
  90. onChange={(e) => pagination.onLimitChange(Number(e.target.value))}
  91. className="input-field py-1"
  92. >
  93. <option value={10}>10 per page</option>
  94. <option value={20}>20 per page</option>
  95. <option value={50}>50 per page</option>
  96. <option value={100}>100 per page</option>
  97. </select>
  98. )}
  99. </div>
  100. </div>
  101. </div>
  102. {/* Table */}
  103. <div className="overflow-x-auto">
  104. {loading ? (
  105. <div className="p-8 text-center">
  106. <div className="inline-flex items-center">
  107. <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
  108. <span className="ml-3 text-gray-600">Loading...</span>
  109. </div>
  110. </div>
  111. ) : data.length === 0 ? (
  112. <div className="p-8 text-center text-gray-500">
  113. {emptyMessage}
  114. </div>
  115. ) : (
  116. <table className="min-w-full divide-y divide-gray-200">
  117. <thead className="bg-gray-50">
  118. <tr>
  119. {columns.map((column) => (
  120. <th
  121. key={String(column.key)}
  122. style={{ width: column.width }}
  123. className={clsx(
  124. 'px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider',
  125. column.sortable && 'cursor-pointer hover:bg-gray-100'
  126. )}
  127. onClick={() => handleSort(column)}
  128. >
  129. <div className="flex items-center">
  130. {column.title}
  131. {column.sortable && sortColumn === column.key && (
  132. <span className="ml-1">
  133. {sortDirection === 'asc' ? '↑' : '↓'}
  134. </span>
  135. )}
  136. </div>
  137. </th>
  138. ))}
  139. {actions && <th className="px-6 py-3 w-12"></th>}
  140. </tr>
  141. </thead>
  142. <tbody className="bg-white divide-y divide-gray-200">
  143. {data.map((item, index) => (
  144. <tr key={index} className="hover:bg-gray-50">
  145. {columns.map((column) => (
  146. <td
  147. key={String(column.key)}
  148. className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"
  149. >
  150. {renderCellValue(column, item)}
  151. </td>
  152. ))}
  153. {actions && (
  154. <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
  155. {actions.render(item)}
  156. </td>
  157. )}
  158. </tr>
  159. ))}
  160. </tbody>
  161. </table>
  162. )}
  163. </div>
  164. {/* Pagination */}
  165. {pagination && totalPages > 1 && (
  166. <div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
  167. <div className="flex-1 flex justify-between sm:hidden">
  168. <button
  169. onClick={() => pagination.onPageChange(pagination.page - 1)}
  170. disabled={pagination.page === 1}
  171. 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"
  172. >
  173. Previous
  174. </button>
  175. <button
  176. onClick={() => pagination.onPageChange(pagination.page + 1)}
  177. disabled={pagination.page === totalPages}
  178. 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"
  179. >
  180. Next
  181. </button>
  182. </div>
  183. <div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
  184. <div>
  185. <p className="text-sm text-gray-700">
  186. Showing{' '}
  187. <span className="font-medium">
  188. {(pagination.page - 1) * pagination.limit + 1}
  189. </span>{' '}
  190. to{' '}
  191. <span className="font-medium">
  192. {Math.min(pagination.page * pagination.limit, pagination.total)}
  193. </span>{' '}
  194. of{' '}
  195. <span className="font-medium">{pagination.total}</span> results
  196. </p>
  197. </div>
  198. <div>
  199. <nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
  200. <button
  201. onClick={() => pagination.onPageChange(pagination.page - 1)}
  202. disabled={pagination.page === 1}
  203. 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"
  204. >
  205. <ChevronLeft className="h-5 w-5" />
  206. </button>
  207. <span className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
  208. {pagination.page} / {totalPages}
  209. </span>
  210. <button
  211. onClick={() => pagination.onPageChange(pagination.page + 1)}
  212. disabled={pagination.page === totalPages}
  213. 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"
  214. >
  215. <ChevronRight className="h-5 w-5" />
  216. </button>
  217. </nav>
  218. </div>
  219. </div>
  220. </div>
  221. )}
  222. </div>
  223. );
  224. }
  225. export default DataTable;