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.
- package/CHANGELOG.md +117 -0
- package/README.md +505 -514
- package/framework/Database/Model.js +18 -5
- package/framework/Services/Media/MediaManager.js +213 -118
- package/frontend/react-ui/components/DataTable/ActionDefaults.jsx +116 -2
- package/frontend/react-ui/components/DataTable/DataTable.jsx +157 -22
- package/frontend/react-ui/components/DataTable/Filters.jsx +99 -56
- package/frontend/react-ui/components/DataTable/TableBody.jsx +85 -24
- package/frontend/react-ui/components/DataTable/TableState.jsx +42 -13
- package/jsconfig.json +1 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
329
|
+
// Reset page to 1 when debouncedColumnSearch changes
|
|
302
330
|
React.useEffect(() => {
|
|
303
331
|
setPage(1);
|
|
304
|
-
}, [
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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,
|
|
343
|
-
|
|
344
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
21
|
-
{/*
|
|
22
|
-
<div className="flex
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
);
|