vasuzex 2.3.12 → 2.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
- import { Eye, Edit2, Trash2 } from "lucide-react";
1
+ import { Eye, Edit2, Trash2, RotateCcw, Flame } from "lucide-react";
2
2
 
3
3
  /**
4
4
  * ActionDefaults - Production Ready
5
5
  *
6
6
  * Default configurations for common DataTable actions
7
- * Provides sensible defaults for edit, view, delete, and switch actions
7
+ * Provides sensible defaults for edit, view, delete, hardDelete, restore and switch actions
8
8
  * Uses lucide-react icons matching RowActionsCell design
9
9
  *
10
10
  * @module components/DataTable/ActionDefaults
@@ -35,6 +35,24 @@ export const ACTION_DEFAULTS = {
35
35
  extraClass:
36
36
  "p-1.5 text-gray-600 hover:text-rose-600 hover:bg-rose-50 rounded-md transition-colors dark:text-gray-400 dark:hover:text-rose-400 dark:hover:bg-rose-950/30",
37
37
  },
38
+ /** Permanent / hard delete action — shown in trash-only mode */
39
+ hardDelete: {
40
+ type: "button",
41
+ label: "Permanently Delete",
42
+ icon: Flame,
43
+ title: "Permanently Delete",
44
+ extraClass:
45
+ "p-1.5 text-rose-500 hover:text-white hover:bg-rose-600 rounded-md transition-colors dark:text-rose-400 dark:hover:bg-rose-700",
46
+ },
47
+ /** Restore action — shown for trashed rows */
48
+ restore: {
49
+ type: "button",
50
+ label: "Restore",
51
+ icon: RotateCcw,
52
+ title: "Restore",
53
+ extraClass:
54
+ "p-1.5 text-blue-500 hover:text-white hover:bg-blue-600 rounded-md transition-colors dark:text-blue-400 dark:hover:bg-blue-700",
55
+ },
38
56
  switch: {
39
57
  type: "button",
40
58
  name: "switch",
@@ -73,6 +91,11 @@ export function applyActionDefaults(
73
91
  ...action,
74
92
  };
75
93
 
94
+ // For custom actions, use the user's label as tooltip title when no explicit title given
95
+ if (actionName === 'custom' && !action.title && action.label) {
96
+ mergedAction.title = action.label;
97
+ }
98
+
76
99
  // Auto-generate getHref for edit action if resourceName is provided
77
100
  if (actionName === "edit" && !mergedAction.getHref && resourceName) {
78
101
  mergedAction.getHref = (row) => `/${resourceName}/${row[resourceIdField]}/edit`;
@@ -177,3 +200,94 @@ export function createDeleteClickHandler(
177
200
  }
178
201
  };
179
202
  }
203
+
204
+ /**
205
+ * Create a hard-delete (permanent) action onClick handler.
206
+ * Shows a severe "cannot be undone" confirmation before calling
207
+ * DELETE url?hardDelete=true.
208
+ */
209
+ export function createHardDeleteClickHandler(
210
+ api,
211
+ deleteUrl,
212
+ confirmMessage,
213
+ resourceIdField = "id",
214
+ options = {}
215
+ ) {
216
+ if (!api) {
217
+ throw new Error('createHardDeleteClickHandler requires "api" parameter');
218
+ }
219
+
220
+ return async (row) => {
221
+ try {
222
+ const Swal = window.Swal;
223
+ const defaultMsg = typeof confirmMessage === "function"
224
+ ? confirmMessage(row)
225
+ : (confirmMessage || "This will permanently remove the record from the database. This action cannot be undone.");
226
+
227
+ if (Swal) {
228
+ const result = await Swal.fire({
229
+ title: options?.confirmTitle || "Permanently Delete?",
230
+ text: defaultMsg,
231
+ icon: "error",
232
+ showCancelButton: true,
233
+ confirmButtonColor: "#991b1b",
234
+ cancelButtonColor: "#6b7280",
235
+ confirmButtonText: options?.confirmButtonText || "Yes, permanently delete!",
236
+ });
237
+ if (!result.isConfirmed) return;
238
+ } else {
239
+ if (!window.confirm(`⚠️ PERMANENT DELETE ⚠️\n\n${defaultMsg}`)) return;
240
+ }
241
+
242
+ // Build URL and append hardDelete=true
243
+ const baseUrl = typeof deleteUrl === "function"
244
+ ? deleteUrl(row)
245
+ : deleteUrl.replace(":id", row[resourceIdField]);
246
+ const separator = baseUrl.includes('?') ? '&' : '?';
247
+ await api.delete(`${baseUrl}${separator}hardDelete=true`);
248
+
249
+ const toast = (await import("react-toastify")).toast;
250
+ const successMsg = options?.successMessage
251
+ ? (typeof options.successMessage === "function" ? options.successMessage(row) : options.successMessage)
252
+ : "Permanently deleted";
253
+ toast.success(successMsg);
254
+
255
+ if (options?.onRefresh) options.onRefresh();
256
+ } catch (error) {
257
+ const toast = (await import("react-toastify")).toast;
258
+ toast.error(error.message || "Failed to permanently delete");
259
+ }
260
+ };
261
+ }
262
+
263
+ /**
264
+ * Create a restore action onClick handler.
265
+ * Calls PATCH restoreUrl to restore a soft-deleted record.
266
+ */
267
+ export function createRestoreClickHandler(
268
+ api,
269
+ restoreUrl,
270
+ resourceIdField = "id",
271
+ options = {}
272
+ ) {
273
+ if (!api) {
274
+ throw new Error('createRestoreClickHandler requires "api" parameter');
275
+ }
276
+
277
+ return async (row) => {
278
+ try {
279
+ const url = typeof restoreUrl === "function"
280
+ ? restoreUrl(row)
281
+ : restoreUrl.replace(":id", row[resourceIdField]);
282
+ await api.patch(url);
283
+
284
+ const toast = (await import("react-toastify")).toast;
285
+ toast.success(options?.successMessage || "Restored successfully");
286
+
287
+ if (options?.onRefresh) options.onRefresh();
288
+ } catch (error) {
289
+ const toast = (await import("react-toastify")).toast;
290
+ toast.error(error.message || "Failed to restore");
291
+ }
292
+ };
293
+ }
@@ -85,6 +85,9 @@ export function DataTable(props) {
85
85
  onToggle,
86
86
  api, // API client instance passed as prop
87
87
  persistState = true, // Enable URL-based state persistence by default
88
+ trashable = false, // Enable trash/restore UI (Live / With Trash / Trash tabs)
89
+ restoreUrl, // URL template for restore, e.g. "/products/:id/restore"
90
+ initialTrashed = 'without', // Initial trash mode: 'without' | 'with' | 'only'
88
91
  } = props;
89
92
 
90
93
  // Validate that api client is provided
@@ -169,9 +172,18 @@ export function DataTable(props) {
169
172
  const [statusFilter, setStatusFilter] = React.useState(urlState.statusFilter);
170
173
  const [limit, setLimit] = React.useState(urlState.limit);
171
174
  const [columnSearch, setColumnSearch] = React.useState(urlState.columnSearch);
172
-
175
+ // Debounced version of columnSearch — API is only called once the user pauses typing
176
+ const [debouncedColumnSearch, setDebouncedColumnSearch] = React.useState(urlState.columnSearch);
177
+ const columnSearchDebounceRef = React.useRef(null);
178
+
179
+ // Abort controller for in-flight requests — cancelled whenever new fetch params arrive
180
+ const abortControllerRef = React.useRef(null);
181
+
182
+ const [trashed, setTrashed] = React.useState(initialTrashed);
183
+
173
184
  const [data, setData] = React.useState([]);
174
- const [loading, setLoading] = React.useState(false);
185
+ // Start as true: skeleton shows immediately on first render before the initial fetch.
186
+ const [loading, setLoading] = React.useState(true);
175
187
  const [totalPages, setTotalPages] = React.useState(1);
176
188
  const [totalItems, setTotalItems] = React.useState(0);
177
189
 
@@ -283,6 +295,22 @@ export function DataTable(props) {
283
295
  }
284
296
  }, [location?.search, hasReactRouter, loadStateFromURL]);
285
297
 
298
+ // Debounce columnSearch — user sees immediate input response, but API call waits 400ms
299
+ // Also syncs back if columnSearch is set programmatically (URL restore / browser back)
300
+ React.useEffect(() => {
301
+ if (columnSearchDebounceRef.current) {
302
+ clearTimeout(columnSearchDebounceRef.current);
303
+ }
304
+ columnSearchDebounceRef.current = setTimeout(() => {
305
+ setDebouncedColumnSearch(columnSearch);
306
+ }, 400);
307
+ return () => {
308
+ if (columnSearchDebounceRef.current) {
309
+ clearTimeout(columnSearchDebounceRef.current);
310
+ }
311
+ };
312
+ }, [columnSearch]);
313
+
286
314
  const handleStatusToggle = async (row) => {
287
315
  if (!toggleLink) return;
288
316
  try {
@@ -298,13 +326,40 @@ export function DataTable(props) {
298
326
  }
299
327
  };
300
328
 
301
- // Reset page to 1 when columnSearch changes
329
+ // Reset page to 1 when debouncedColumnSearch changes
302
330
  React.useEffect(() => {
303
331
  setPage(1);
304
- }, [columnSearch]);
332
+ }, [debouncedColumnSearch]);
333
+
334
+ // useLayoutEffect fires synchronously after DOM mutation, before paint.
335
+ // This guarantees the skeleton is committed to the DOM before ANY passive
336
+ // effect (useEffect) runs — which is where fetchData and the API call live.
337
+ // Without this, React 18 + createRoot batches setLoading(true) with the
338
+ // fast localhost API response into one render, so the skeleton never paints.
339
+ //
340
+ // Skip the very first run (initial mount) because loading already starts as
341
+ // true, and fetchData's own useEffect handles the first fetch.
342
+ const isFirstRender = React.useRef(true);
343
+ React.useLayoutEffect(() => {
344
+ if (isFirstRender.current) {
345
+ isFirstRender.current = false;
346
+ return;
347
+ }
348
+ setLoading(true);
349
+ // eslint-disable-next-line react-hooks/exhaustive-deps
350
+ }, [page, sortBy, sortOrder, statusFilter, limit, search, debouncedColumnSearch, trashable, trashed, refreshKey, refreshSignal]);
305
351
 
306
352
  const fetchData = React.useCallback(async () => {
307
- setLoading(true);
353
+ // Cancel any in-flight request before starting a new one
354
+ if (abortControllerRef.current) {
355
+ abortControllerRef.current.abort();
356
+ }
357
+ abortControllerRef.current = new AbortController();
358
+ const signal = abortControllerRef.current.signal;
359
+
360
+ // loading=true is already set synchronously by useLayoutEffect before this
361
+ // effect runs. Do not set it here — that caused React 18 batching issues.
362
+ let wasAborted = false;
308
363
  try {
309
364
  const params = new URLSearchParams({
310
365
  page: page.toString(),
@@ -314,14 +369,17 @@ export function DataTable(props) {
314
369
  });
315
370
  if (statusFilter !== "all") params.append("isActive", statusFilter);
316
371
  if (search) params.append("search", search);
317
- // Add column search params
318
- Object.entries(columnSearch).forEach(([field, value]) => {
372
+ // Add debounced column search params
373
+ Object.entries(debouncedColumnSearch).forEach(([field, value]) => {
319
374
  if (value) params.append(`columnSearch[${field}]`, value);
320
375
  });
321
376
 
377
+ // Append trashed param when trashable mode is active
378
+ if (trashable) params.append('trashed', trashed);
379
+
322
380
  // Properly append params to apiUrl (check if apiUrl already has query params)
323
381
  const separator = apiUrl.includes('?') ? '&' : '?';
324
- const result = await api.get(`${apiUrl}${separator}${params}`);
382
+ const result = await api.get(`${apiUrl}${separator}${params}`, { signal });
325
383
 
326
384
  // Handle nested data structure: result.data.data OR result.data.items
327
385
  const items = Array.isArray(result.data)
@@ -333,32 +391,103 @@ export function DataTable(props) {
333
391
  setTotalPages(pagination?.totalPages || 1);
334
392
  setTotalItems(pagination?.total || 0);
335
393
  } catch (err) {
336
- setData([]);
337
- setTotalPages(1);
338
- setTotalItems(0);
394
+ // Abort errors are expected when a newer request supersedes this one — do not
395
+ // reset loading state because the new request is already in flight with loading=true.
396
+ if (err && (err.name === 'AbortError' || err.code === 'ERR_CANCELED')) {
397
+ wasAborted = true;
398
+ } else {
399
+ setData([]);
400
+ setTotalPages(1);
401
+ setTotalItems(0);
402
+ }
339
403
  } finally {
340
- setLoading(false);
404
+ // Only clear loading when this request completed normally (not aborted).
405
+ // An aborted request means a newer fetch is already running with loading=true.
406
+ if (!wasAborted) {
407
+ setLoading(false);
408
+ }
341
409
  }
342
- }, [api, apiUrl, page, sortBy, sortOrder, statusFilter, limit, search, columnSearch]);
343
-
344
- // Trigger fetchData for main params
410
+ }, [api, apiUrl, page, sortBy, sortOrder, statusFilter, limit, search, debouncedColumnSearch, trashable, trashed]);
411
+
412
+ // Always-current ref to fetchData lets refreshSignal and refreshKey effects
413
+ // call the latest fetchData without including fetchData in their dep arrays.
414
+ // This prevents double-fetches: if fetchData were in refreshSignal's deps, the
415
+ // effect would fire on EVERY sort/search (because fetchData recreates whenever
416
+ // its own deps change), causing two concurrent requests.
417
+ const fetchDataRef = React.useRef(fetchData);
418
+ fetchDataRef.current = fetchData;
419
+
420
+ // Trigger fetchData for main params.
421
+ //
422
+ // Why rAF + setTimeout(0):
423
+ // Browser frame sequence: macro-task → microtasks → rAF callbacks → layout+PAINT → next macro-task.
424
+ // useLayoutEffect commits loading=true (skeleton) synchronously during the commit phase.
425
+ // Passive effects (useEffect) run as a MessageChannel macro-task.
426
+ // Inside that macro-task we schedule rAF, which fires PRE-paint of the current frame.
427
+ // Inside rAF we schedule setTimeout(0), which queues as a MACRO-TASK — meaning it fires
428
+ // AFTER the browser completes layout+paint for the current frame.
429
+ // Result: skeleton is guaranteed to be painted to screen before fetchData() opens the XHR.
345
430
  React.useEffect(() => {
346
- fetchData();
431
+ let rafId;
432
+ let timerId;
433
+ rafId = requestAnimationFrame(() => {
434
+ timerId = setTimeout(() => {
435
+ fetchDataRef.current();
436
+ }, 0);
437
+ });
438
+ return () => {
439
+ cancelAnimationFrame(rafId);
440
+ clearTimeout(timerId);
441
+ };
347
442
  }, [fetchData]);
348
443
 
349
- // Trigger fetchData when refreshSignal changes
444
+ // Trigger fetchData when refreshSignal changes.
445
+ // fetchData is intentionally NOT in deps — we use fetchDataRef to avoid re-firing
446
+ // this effect on every sort/search (which would cause a double-fetch).
447
+ // eslint-disable-next-line react-hooks/exhaustive-deps
350
448
  React.useEffect(() => {
351
449
  if (typeof refreshSignal !== 'undefined') {
352
- fetchData();
450
+ let rafId;
451
+ let timerId;
452
+ rafId = requestAnimationFrame(() => {
453
+ timerId = setTimeout(() => {
454
+ fetchDataRef.current();
455
+ }, 0);
456
+ });
457
+ return () => {
458
+ cancelAnimationFrame(rafId);
459
+ clearTimeout(timerId);
460
+ };
353
461
  }
354
- }, [refreshSignal, fetchData]);
462
+ }, [refreshSignal]);
355
463
 
356
- // Internal refresh after status toggle
464
+ // Internal refresh after status toggle.
465
+ // fetchData is intentionally NOT in deps — same reason as refreshSignal above.
466
+ // eslint-disable-next-line react-hooks/exhaustive-deps
357
467
  React.useEffect(() => {
358
468
  if (refreshKey > 0) {
359
- fetchData();
469
+ let rafId;
470
+ let timerId;
471
+ rafId = requestAnimationFrame(() => {
472
+ timerId = setTimeout(() => {
473
+ fetchDataRef.current();
474
+ }, 0);
475
+ });
476
+ return () => {
477
+ cancelAnimationFrame(rafId);
478
+ clearTimeout(timerId);
479
+ };
360
480
  }
361
- }, [refreshKey, fetchData]);
481
+ }, [refreshKey]);
482
+
483
+ // Abort any in-flight request when the component unmounts
484
+ React.useEffect(() => {
485
+ return () => {
486
+ if (abortControllerRef.current) {
487
+ abortControllerRef.current.abort();
488
+ }
489
+ };
490
+ }, []);
362
491
 
363
492
  const handleSort = (field) => {
364
493
  if (sortBy === field) {
@@ -388,6 +517,10 @@ export function DataTable(props) {
388
517
  setLimit={setLimit}
389
518
  dataLength={data.length}
390
519
  totalItems={totalItems}
520
+ onRefresh={() => setRefreshKey((k) => k + 1)}
521
+ trashable={trashable}
522
+ trashed={trashed}
523
+ setTrashed={(val) => { setTrashed(val); setPage(1); }}
391
524
  />
392
525
  </div>
393
526
  <table className="min-w-full w-full divide-y divide-gray-200 dark:divide-gray-700">
@@ -419,6 +552,8 @@ export function DataTable(props) {
419
552
  resourceName={resourceName}
420
553
  resourceIdField={resourceIdField}
421
554
  onRefresh={() => setRefreshKey((k) => k + 1)}
555
+ trashMode={trashed}
556
+ restoreUrl={restoreUrl}
422
557
  />
423
558
  )}
424
559
  </tbody>
@@ -1,10 +1,12 @@
1
1
  import React from "react";
2
+ import { RefreshCw, Trash2 } from "lucide-react";
2
3
 
3
4
  /**
4
5
  * Filters Component - Production Ready
5
- *
6
- * Status filters (All/Active/Inactive), rows per page selector, and showing info
7
- *
6
+ *
7
+ * Status filters (All/Active/Inactive), rows per page selector, trash tabs,
8
+ * and a refresh button.
9
+ *
8
10
  * @module components/DataTable/Filters
9
11
  */
10
12
  export const Filters = ({
@@ -16,61 +18,102 @@ export const Filters = ({
16
18
  setLimit,
17
19
  dataLength,
18
20
  totalItems,
21
+ onRefresh,
22
+ // Trash support
23
+ trashable,
24
+ trashed,
25
+ setTrashed,
19
26
  }) => (
20
- <div className="flex items-start justify-between px-2 py-2 text-xs text-gray-600 w-full">
21
- {/* Left: Rows per page and Showing info */}
22
- <div className="flex flex-col items-start gap-1">
23
- <div className="flex items-center gap-1 whitespace-nowrap">
24
- <label className="text-xs">Rows per page:</label>
25
- <select
26
- value={limit}
27
- onChange={(e) => {
28
- setLimit(Number(e.target.value));
29
- setPage(1);
30
- }}
31
- className="rounded border border-gray-300 bg-transparent px-2 py-1 text-xs outline-none dark:border-gray-700 dark:bg-gray-900"
32
- >
33
- <option value="5">5</option>
34
- <option value="10">10</option>
35
- <option value="25">25</option>
36
- <option value="50">50</option>
37
- <option value="100">100</option>
38
- </select>
27
+ <div className="flex flex-col gap-2 px-2 py-2 text-xs text-gray-600 w-full">
28
+ {/* Top row: rows-per-page + showing info + trash tabs + refresh */}
29
+ <div className="flex items-start justify-between w-full gap-2">
30
+ {/* Left: Rows per page and Showing info */}
31
+ <div className="flex flex-col items-start gap-1">
32
+ <div className="flex items-center gap-1 whitespace-nowrap">
33
+ <label className="text-xs">Rows per page:</label>
34
+ <select
35
+ value={limit}
36
+ onChange={(e) => {
37
+ setLimit(Number(e.target.value));
38
+ setPage(1);
39
+ }}
40
+ className="rounded border border-gray-300 bg-transparent px-2 py-1 text-xs outline-none dark:border-gray-700 dark:bg-gray-900"
41
+ >
42
+ <option value="5">5</option>
43
+ <option value="10">10</option>
44
+ <option value="25">25</option>
45
+ <option value="50">50</option>
46
+ <option value="100">100</option>
47
+ </select>
48
+ </div>
49
+ <span className="whitespace-nowrap mt-1">
50
+ Showing {totalItems === 0 ? 0 : (page - 1) * limit + 1} to{" "}
51
+ {Math.min(page * limit, totalItems)} of {totalItems} items
52
+ </span>
53
+ </div>
54
+
55
+ {/* Right: Status filters + Trash tabs + Refresh */}
56
+ <div className="flex flex-wrap items-center justify-end gap-2">
57
+ {/* Status filter buttons — hidden when viewing only-trash */}
58
+ {(!trashable || trashed !== 'only') && (
59
+ <div className="flex items-center gap-1">
60
+ <button
61
+ onClick={() => { setStatusFilter("all"); setPage(1); }}
62
+ className={`rounded-lg px-3 py-1.5 text-sm font-medium transition ${statusFilter === "all" ? "bg-brand-600 text-white shadow-theme-xs" : "bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700"}`}
63
+ >
64
+ All
65
+ </button>
66
+ <button
67
+ onClick={() => { setStatusFilter("true"); setPage(1); }}
68
+ className={`rounded-lg px-3 py-1.5 text-sm font-medium transition ${statusFilter === "true" ? "bg-brand-600 text-white shadow-theme-xs" : "bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700"}`}
69
+ >
70
+ Active
71
+ </button>
72
+ <button
73
+ onClick={() => { setStatusFilter("false"); setPage(1); }}
74
+ className={`rounded-lg px-3 py-1.5 text-sm font-medium transition ${statusFilter === "false" ? "bg-brand-600 text-white shadow-theme-xs" : "bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700"}`}
75
+ >
76
+ Inactive
77
+ </button>
78
+ </div>
79
+ )}
80
+
81
+ {/* Trash tabs (only shown when trashable=true) */}
82
+ {trashable && (
83
+ <div className="flex items-center gap-1 border-l pl-2 border-gray-200 dark:border-gray-700">
84
+ <Trash2 className="h-3 w-3 text-gray-400 mr-0.5" />
85
+ <button
86
+ onClick={() => { setTrashed('without'); setPage(1); }}
87
+ className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${trashed === 'without' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}`}
88
+ >
89
+ Live
90
+ </button>
91
+ <button
92
+ onClick={() => { setTrashed('with'); setPage(1); }}
93
+ className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${trashed === 'with' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}`}
94
+ >
95
+ With Trash
96
+ </button>
97
+ <button
98
+ onClick={() => { setTrashed('only'); setPage(1); }}
99
+ className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${trashed === 'only' ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}`}
100
+ >
101
+ Trash
102
+ </button>
103
+ </div>
104
+ )}
105
+
106
+ {/* Refresh button — always shown */}
107
+ {onRefresh && (
108
+ <button
109
+ onClick={onRefresh}
110
+ title="Refresh"
111
+ className="p-1.5 rounded-md text-gray-500 hover:text-brand-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
112
+ >
113
+ <RefreshCw className="h-4 w-4" />
114
+ </button>
115
+ )}
39
116
  </div>
40
- <span className="whitespace-nowrap mt-1">
41
- Showing {totalItems === 0 ? 0 : (page - 1) * limit + 1} to{" "}
42
- {Math.min(page * limit, totalItems)} of {totalItems} items
43
- </span>
44
- </div>
45
- {/* Right: Filter buttons */}
46
- <div className="flex-shrink-0 flex gap-2">
47
- <button
48
- onClick={() => {
49
- setStatusFilter("all");
50
- setPage(1);
51
- }}
52
- className={`rounded-lg px-4 py-2 text-sm font-medium transition ${statusFilter === "all" ? "bg-brand-600 text-white shadow-theme-xs" : "bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700"}`}
53
- >
54
- All
55
- </button>
56
- <button
57
- onClick={() => {
58
- setStatusFilter("true");
59
- setPage(1);
60
- }}
61
- className={`rounded-lg px-4 py-2 text-sm font-medium transition ${statusFilter === "true" ? "bg-brand-600 text-white shadow-theme-xs" : "bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700"}`}
62
- >
63
- Active
64
- </button>
65
- <button
66
- onClick={() => {
67
- setStatusFilter("false");
68
- setPage(1);
69
- }}
70
- className={`rounded-lg px-4 py-2 text-sm font-medium transition ${statusFilter === "false" ? "bg-brand-600 text-white shadow-theme-xs" : "bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700"}`}
71
- >
72
- Inactive
73
- </button>
74
117
  </div>
75
118
  </div>
76
119
  );