react-admin-crud-manager 1.0.0

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.
@@ -0,0 +1,152 @@
1
+ import React from "react";
2
+ import { X } from "lucide-react";
3
+ import Button from "../Button/Button";
4
+
5
+ const Modal = ({
6
+ isOpen,
7
+ onClose,
8
+ icon,
9
+ title,
10
+ children,
11
+ size = "md",
12
+ actionButtons = [],
13
+ actions,
14
+ showDefaultClose = true,
15
+ footerConfig = null,
16
+ hideFooter = false,
17
+ onFormSubmit = () => {},
18
+ onCancel,
19
+ loadingBtn = false,
20
+ executeFunction = () => {},
21
+ selectedItem = null,
22
+ }) => {
23
+ if (!isOpen) return null;
24
+
25
+ // Size classes
26
+ const sizeClasses = {
27
+ sm: "max-w-md",
28
+ md: "max-w-lg",
29
+ lg: "max-w-2xl",
30
+ xl: "max-w-4xl",
31
+ full: "max-w-full",
32
+ };
33
+
34
+ return (
35
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
36
+ {/* Backdrop */}
37
+ <div
38
+ className="fixed inset-0 bg-gray-500 opacity-75"
39
+ onClick={() => onClose()}
40
+ ></div>
41
+
42
+ {/* Modal container */}
43
+ <div
44
+ className={`relative bg-white rounded-lg shadow-xl w-full ${
45
+ sizeClasses[size] || sizeClasses.md
46
+ } max-h-[90vh] flex flex-col dark:bg-gray-800`}
47
+ >
48
+ {/* Header */}
49
+ <div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
50
+ <div className="flex items-center gap-1">
51
+ {icon && <span>{icon}</span>}
52
+ <h3 className="text-lg font-medium text-gray-900 dark:text-white">
53
+ {title}
54
+ </h3>
55
+ </div>
56
+
57
+ <button
58
+ onClick={() => onClose()}
59
+ className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
60
+ >
61
+ <X className="w-6 h-6" />
62
+ </button>
63
+ </div>
64
+
65
+ {/* Content */}
66
+ <div className="flex-1 overflow-y-auto p-4">{children}</div>
67
+
68
+ {/* Footer */}
69
+ {/* {!hideFooter && (actions || footerConfig || showDefaultClose) && (
70
+ <div className="px-4 py-3 flex justify-end gap-3 border-t border-gray-200 dark:border-gray-700 sm:px-6">
71
+ {actions}
72
+
73
+ {footerConfig && (
74
+ <>
75
+ {footerConfig.cancelButton && (
76
+ <Button
77
+ onClick={() => (onCancel() || onClose())}
78
+ disabled={loadingBtn}
79
+ variant="contained"
80
+ color="default"
81
+ >
82
+ {footerConfig.cancelText || "Cancel"}
83
+ </Button>
84
+ )}
85
+
86
+ {footerConfig.submitButton && (
87
+ <Button
88
+ onClick={onFormSubmit}
89
+ disabled={loadingBtn}
90
+ variant="contained"
91
+ color={footerConfig.color || "primary"}
92
+ className="min-w-[100px]"
93
+ >
94
+ {loadingBtn ? (
95
+ <div className="flex items-center">
96
+ <div className="animate-spin rounded-full h-4 w-4 border-2 border-white/30 border-t-2 border-t-white mr-2"></div>
97
+ {footerConfig.submitText || "Submit"}...
98
+ </div>
99
+ ) : (
100
+ footerConfig.submitText || "Submit"
101
+ )}
102
+ </Button>
103
+ )}
104
+ </>
105
+ )}
106
+
107
+ {showDefaultClose && !actions && !footerConfig && (
108
+ <Button onClick={() => onClose()} variant="outlined">
109
+ Close
110
+ </Button>
111
+ )}
112
+ </div>
113
+ )} */}
114
+
115
+ {actionButtons.length > 0 && (
116
+ <div className="px-4 py-3 flex justify-end gap-3 border-t border-gray-200 dark:border-gray-700 sm:px-6">
117
+ {actionButtons.map((btn) => (
118
+ <Button
119
+ onClick={(e) => {
120
+ if (btn.type == "submit") {
121
+ onFormSubmit(e);
122
+ } else {
123
+ executeFunction(
124
+ () => btn?.onClick?.(e, selectedItem),
125
+ (resp) => onClose?.(resp),
126
+ );
127
+ }
128
+ }}
129
+ disabled={loadingBtn || btn.disabled}
130
+ variant={btn.variant || "contained"}
131
+ color={btn.color || "primary"}
132
+ className={`min-w-[100px] ${btn.className}`}
133
+ type={btn.type || "button"}
134
+ >
135
+ {loadingBtn ? (
136
+ <div className="flex items-center">
137
+ <div className="animate-spin rounded-full h-4 w-4 border-2 border-white/30 border-t-2 border-t-white mr-2"></div>
138
+ {btn.label || "Submit"}...
139
+ </div>
140
+ ) : (
141
+ btn.label || "Submit"
142
+ )}
143
+ </Button>
144
+ ))}
145
+ </div>
146
+ )}
147
+ </div>
148
+ </div>
149
+ );
150
+ };
151
+
152
+ export default Modal;
@@ -0,0 +1,554 @@
1
+ import React, { useState, useMemo, useRef, useEffect } from "react";
2
+ import {
3
+ ChevronLeft,
4
+ ChevronRight,
5
+ Search,
6
+ EllipsisVertical,
7
+ Filter,
8
+ User,
9
+ } from "lucide-react";
10
+ import { createPortal } from "react-dom";
11
+ import { formatDate, searchLocalData } from "../../lib/utils";
12
+ import Button from "../Button/Button";
13
+ import FilterDrawer from "../Filter/FilterDrawer";
14
+ import Chip from "../Chip/Chip";
15
+ import TableSkeleton from "./components/TableSkeleton";
16
+ import ImagePreview from "./components/ImagePreview";
17
+
18
+ const Table = ({ config }) => {
19
+ const {
20
+ data = [],
21
+ table_head = [],
22
+ loading = false,
23
+ search = {
24
+ enabled: false,
25
+ placeholder: "Search...",
26
+ useServerSideSearch: false,
27
+ },
28
+ filter = {
29
+ enabled: false,
30
+ useServerSideFilters: false,
31
+ },
32
+ pagination = {
33
+ enabled: false,
34
+ rows_per_page: 10,
35
+ useServerSidePagination: false,
36
+ },
37
+ emptyMessage = "No data available",
38
+ onMenuAction,
39
+ setServerSidePaginationData = () => {},
40
+ onFilterApply,
41
+ filterConfig = null,
42
+ } = config;
43
+
44
+ const [searchTerm, setSearchTerm] = useState("");
45
+ const [activeMenu, setActiveMenu] = useState(null);
46
+ const [menuList, setMenuList] = useState([]);
47
+ const [menuPosition, setMenuPosition] = useState({});
48
+ const [showFilters, setShowFilters] = useState(false);
49
+
50
+ // image preview
51
+ const [targetImage, setTargetImage] = useState(null);
52
+ const [isOpen, setIsOpen] = useState(false);
53
+
54
+ const filteredData = useMemo(() => {
55
+ if (!search.enabled || !searchTerm.trim()) return data;
56
+ if (search.useServerSideSearch) return data;
57
+ return searchLocalData(data, searchTerm, search.searchKeys || []);
58
+ }, [data, searchTerm, search]);
59
+
60
+ const [currentPage, setCurrentPage] = useState(1);
61
+ const [pageSize, setPageSize] = useState(pagination?.rows_per_page || 50);
62
+ const [totalRecords, setTotalRecords] = useState(filteredData.length || 0);
63
+
64
+ const totalPages = pagination?.useServerSidePagination
65
+ ? pagination.total_pages
66
+ : Math.ceil(filteredData.length / pageSize);
67
+
68
+ const paginatedData = useMemo(() => {
69
+ if (pagination.useServerSidePagination) return filteredData;
70
+ const start = (currentPage - 1) * pageSize;
71
+ return filteredData.slice(start, start + pageSize);
72
+ }, [filteredData, currentPage, pageSize]);
73
+
74
+ const menuRef = useRef(null);
75
+ const buttonRefs = useRef({});
76
+ const searchTimeoutRef = useRef(null);
77
+
78
+ const handleSearchChange = (value) => {
79
+ setSearchTerm(value);
80
+ setCurrentPage(1);
81
+
82
+ if (search.useServerSideSearch) {
83
+ if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
84
+
85
+ searchTimeoutRef.current = setTimeout(async () => {
86
+ try {
87
+ await setServerSidePaginationData((prev) => ({
88
+ ...prev,
89
+ search: value,
90
+ current_page: 1,
91
+ }));
92
+ } catch (error) {
93
+ console.error("Search error:", error);
94
+ }
95
+ }, 800);
96
+ }
97
+ };
98
+
99
+ const handleActionClick = (action, item, e) => {
100
+ e.stopPropagation();
101
+ setActiveMenu(null);
102
+ onMenuAction?.(action.type, item);
103
+ };
104
+
105
+ const handleMenuToggle = (itemId, e, menu_list) => {
106
+ e.stopPropagation();
107
+ setMenuList(menu_list);
108
+ const button = e.currentTarget;
109
+ buttonRefs.current[itemId] = button;
110
+ const rect = button.getBoundingClientRect();
111
+ const menuWidth = 192;
112
+ const menuHeight = menu_list.length * 40;
113
+ const viewportWidth = window.innerWidth;
114
+ const viewportHeight = window.innerHeight;
115
+
116
+ const openLeft = viewportWidth - rect.right < menuWidth;
117
+ const left = openLeft ? rect.left - menuWidth + rect.width : rect.left;
118
+
119
+ const openUp =
120
+ viewportHeight - rect.bottom < menuHeight && rect.top > menuHeight;
121
+ const top = openUp ? rect.top - menuHeight - 2 : rect.bottom + 2;
122
+
123
+ setMenuPosition({
124
+ top: Math.max(8, Math.min(top, viewportHeight - menuHeight - 8)),
125
+ left: Math.max(8, Math.min(left, viewportWidth - menuWidth - 8)),
126
+ });
127
+
128
+ setActiveMenu(activeMenu === itemId ? null : itemId);
129
+ };
130
+
131
+ const calculateRowNumber = (index) => {
132
+ return (currentPage - 1) * pageSize + index + 1;
133
+ };
134
+
135
+ const openPreview = (image) => {
136
+ setTargetImage(image);
137
+ setIsOpen(true);
138
+ };
139
+
140
+ const renderAvatar = (
141
+ imageSrc,
142
+ imageAlt,
143
+ className,
144
+ fallback_icon = null,
145
+ ) => {
146
+ return (
147
+ <>
148
+ {imageSrc ? (
149
+ <img
150
+ src={imageSrc}
151
+ alt={imageAlt || "Avatar"}
152
+ onClick={(e) => {
153
+ e.stopPropagation();
154
+ e.preventDefault();
155
+ openPreview({ src: imageSrc, alt: imageAlt });
156
+ }}
157
+ className={`w-10 h-10 cursor-pointer rounded-full object-cover border border-gray-200 dark:border-gray-700 ${className || ""}`}
158
+ />
159
+ ) : (
160
+ <>
161
+ {fallback_icon ? (
162
+ fallback_icon
163
+ ) : (
164
+ <div
165
+ className={`w-10 h-10 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-700 bg-gray-200 dark:bg-gray-600 ${className || ""}`}
166
+ >
167
+ <User className="w-6 h-6 text-gray-400 dark:text-gray-400" />
168
+ </div>
169
+ )}
170
+ </>
171
+ )}
172
+ </>
173
+ );
174
+ };
175
+
176
+ const renderGroupCell = (row, col) => {
177
+ return (
178
+ <div className={`flex items-center space-x-4 ${col.className || ""}`}>
179
+ {col.imageKey
180
+ ? renderAvatar(row[col.imageKey], row[col.titleKey], "group-avatar")
181
+ : ""}
182
+ <div>
183
+ <p className="font-medium text-gray-900 dark:text-white group-title">
184
+ {row[col.titleKey] || ""}
185
+ </p>
186
+ <p className="text-sm text-gray-500 dark:text-gray-400 group-sub-title">
187
+ {row[col.subtitleKey] || ""}
188
+ </p>
189
+ </div>
190
+ </div>
191
+ );
192
+ };
193
+
194
+ const handleRenderChip = (value, col) => {
195
+ let label = String(value);
196
+ const variant = col.variant || "contained";
197
+ let color = col.defaultColor;
198
+
199
+ if (col?.chipOptions?.length > 0) {
200
+ let chipObj = col?.chipOptions.find((obj) => obj.value == value);
201
+ if (chipObj) {
202
+ label = chipObj.label;
203
+ color = chipObj.color;
204
+ }
205
+ }
206
+
207
+ return (
208
+ <Chip
209
+ label={label}
210
+ variant={variant}
211
+ color={color}
212
+ className={col.className || ""}
213
+ />
214
+ );
215
+ };
216
+
217
+ const handleRenderCellValue = (col, row, index) => {
218
+ const value = row[col.key];
219
+ if (col.type === "menu_actions") {
220
+ return (
221
+ <div className={`text-center ${col.className || ""}`}>
222
+ <button
223
+ ref={(el) => (buttonRefs.current[row.id] = el)}
224
+ onClick={(e) => handleMenuToggle(row.id, e, col.menuList)}
225
+ className="p-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-full transition text-gray-700 dark:text-gray-300"
226
+ >
227
+ <EllipsisVertical className="h-4 w-4" />
228
+ </button>
229
+ </div>
230
+ );
231
+ } else if (col.type === "index") {
232
+ return (
233
+ <span className={col.className || ""}>{calculateRowNumber(index)}</span>
234
+ );
235
+ } else if (col.type === "group") {
236
+ return renderGroupCell(row, col);
237
+ } else if (col.type === "chip") {
238
+ return <>{handleRenderChip(value, col)}</>;
239
+ } else if (col.type === "date") {
240
+ return (
241
+ <span className={col.className || ""}>
242
+ {formatDate(value, col.format || "DD MMM YYYY")}
243
+ </span>
244
+ );
245
+ } else if (col.type === "avatar") {
246
+ return (
247
+ <>{renderAvatar(value, col.alt, col.className, col.fallback_icon)}</>
248
+ );
249
+ } else {
250
+ return <span className={col.className || ""}>{value || "N/A"}</span>;
251
+ }
252
+ };
253
+
254
+ // handle CLick on cell
255
+
256
+ const handleColumnClick = (col, row) => {
257
+ if (col.onClickDetails) {
258
+ return onMenuAction?.("view", row);
259
+ }
260
+
261
+ if (typeof col.handleClick === "function") {
262
+ return col.handleClick(row);
263
+ }
264
+ };
265
+
266
+ // Helper to check if column is clickable
267
+ const isColumnClickable = (col) =>
268
+ col.onClickDetails || typeof col.handleClick === "function";
269
+
270
+ // Close menu on scroll -------------------
271
+ useEffect(() => {
272
+ const handleScroll = () => {
273
+ if (activeMenu) setActiveMenu(null);
274
+ };
275
+
276
+ window.addEventListener("scroll", handleScroll, true);
277
+ return () => {
278
+ window.removeEventListener("scroll", handleScroll, true);
279
+ };
280
+ }, [activeMenu]);
281
+
282
+ useEffect(() => {
283
+ const handleClickOutside = (e) => {
284
+ if (menuRef.current && !menuRef.current.contains(e.target)) {
285
+ setActiveMenu(null);
286
+ }
287
+ };
288
+ document.addEventListener("click", handleClickOutside);
289
+ return () => document.removeEventListener("click", handleClickOutside);
290
+ }, []);
291
+
292
+ useEffect(() => {
293
+ if (pagination?.rows_per_page && pagination?.useServerSidePagination) {
294
+ setPageSize(pagination?.rows_per_page || 50);
295
+ }
296
+
297
+ if (pagination.current_page) {
298
+ setCurrentPage(pagination.current_page);
299
+ }
300
+ }, [
301
+ pagination.rows_per_page,
302
+ pagination?.useServerSidePagination,
303
+ pagination.current_page,
304
+ ]);
305
+
306
+ useEffect(() => {
307
+ setTotalRecords(
308
+ pagination?.useServerSidePagination
309
+ ? pagination.total_records
310
+ : filteredData.length,
311
+ );
312
+
313
+ if (
314
+ filteredData.length <= pageSize * (currentPage - 1) &&
315
+ !pagination?.useServerSidePagination
316
+ ) {
317
+ setCurrentPage((prev) => prev - 1 || 1);
318
+ }
319
+ }, [
320
+ filteredData.length,
321
+ pagination.total_records,
322
+ pagination?.useServerSidePagination,
323
+ ]);
324
+
325
+ if (loading) return <TableSkeleton rows={6} columns={6} />;
326
+
327
+ return (
328
+ <>
329
+ {/* Search Bar */}
330
+ <div className="flex justify-end items-center mb-6 gap-2">
331
+ {search.enabled && (
332
+ <div className="">
333
+ <div className="relative min-w-[300px]">
334
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-300" />
335
+ <input
336
+ type="text"
337
+ placeholder={search.placeholder || "Search..."}
338
+ value={searchTerm}
339
+ onChange={(e) => handleSearchChange(e.target.value)}
340
+ className="w-full h-[36px] pl-9 pr-4 py-3 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-300 dark:ring-blue-200 disabled:opacity-50"
341
+ />
342
+ </div>
343
+ </div>
344
+ )}
345
+
346
+ {filterConfig && filter.enabled && (
347
+ <Button onClick={() => setShowFilters(true)} variant="contained">
348
+ <Filter className="w-4 h-4 mr-2" />
349
+ Filters
350
+ </Button>
351
+ )}
352
+ </div>
353
+
354
+ {/* =========================== Table =========================== */}
355
+ <div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-700">
356
+ <div className="overflow-x-auto">
357
+ <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
358
+ <thead className="bg-gray-50 dark:bg-gray-700/60">
359
+ <tr>
360
+ {table_head.map((col) => (
361
+ <th
362
+ key={col.key}
363
+ className="px-6 py-4 text-left text-xs font-medium text-black dark:text-white uppercase tracking-wider min-w-max max-w-[180px] truncate"
364
+ >
365
+ {col.title}
366
+ </th>
367
+ ))}
368
+ </tr>
369
+ </thead>
370
+
371
+ <tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
372
+ {paginatedData.length === 0 ? (
373
+ <tr>
374
+ <td
375
+ colSpan={table_head.length}
376
+ className="text-center py-10 text-gray-500 dark:text-gray-400"
377
+ >
378
+ {emptyMessage}
379
+ </td>
380
+ </tr>
381
+ ) : (
382
+ paginatedData.map((row, index) => (
383
+ <tr
384
+ key={row.id || index}
385
+ className="hover:bg-gray-50 dark:hover:bg-blue-800/10 transition"
386
+ >
387
+ {table_head.map((col) => (
388
+ <td
389
+ key={col.key}
390
+ className={`px-6 py-4 text-sm text-gray-900 dark:text-gray-100 min-w-max max-w-[300px] truncate ${
391
+ isColumnClickable(col) ? "cursor-pointer" : ""
392
+ }`}
393
+ title={String(row[col.key] ?? "")}
394
+ onClick={() => handleColumnClick(col, row)}
395
+ >
396
+ {col.render
397
+ ? col.render(row, index)
398
+ : handleRenderCellValue(col, row, index)}
399
+ </td>
400
+ ))}
401
+ </tr>
402
+ ))
403
+ )}
404
+ </tbody>
405
+ </table>
406
+ </div>
407
+
408
+ {pagination?.enabled && filteredData.length > 0 && (
409
+ <div className=" bg-gray-50 dark:bg-gray-700/60 px-6 py-3 flex flex-wrap items-center justify-between border-t border-gray-200 dark:border-gray-600 gap-3">
410
+ <div className="text-sm text-gray-700 dark:text-gray-300">
411
+ Showing {(currentPage - 1) * pageSize + 1} to{" "}
412
+ {Math.min(currentPage * pageSize, totalRecords)} of {totalRecords}{" "}
413
+ results
414
+ </div>
415
+
416
+ <div className="flex items-center gap-4">
417
+ {/* Rows per page selector */}
418
+ <div className="flex items-center gap-2">
419
+ <span className="text-sm text-gray-700 dark:text-gray-300">
420
+ Rows per page:
421
+ </span>
422
+ <select
423
+ value={pageSize}
424
+ onChange={(e) => {
425
+ const newLimit = Number(e.target.value);
426
+ setPageSize(newLimit);
427
+ setCurrentPage(1);
428
+ if (pagination.useServerSidePagination) {
429
+ setServerSidePaginationData((prev) => ({
430
+ ...prev,
431
+ current_page: 1,
432
+ rows_per_page: newLimit,
433
+ }));
434
+ }
435
+ }}
436
+ className="border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm rounded-md px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500"
437
+ >
438
+ {[2, 10, 25, 50, 100].map((n) => (
439
+ <option key={n} value={n}>
440
+ {n}
441
+ </option>
442
+ ))}
443
+ </select>
444
+ </div>
445
+
446
+ {/* ============= Pagination ============= */}
447
+ <div className="flex items-center gap-2">
448
+ <button
449
+ onClick={() => {
450
+ if (currentPage > 1) {
451
+ const newPage = currentPage - 1;
452
+ setCurrentPage(newPage);
453
+ if (pagination.useServerSidePagination) {
454
+ setServerSidePaginationData((prev) => ({
455
+ ...prev,
456
+ current_page: newPage,
457
+ }));
458
+ }
459
+ }
460
+ }}
461
+ disabled={currentPage === 1}
462
+ className="p-2 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition text-gray-500 dark:text-gray-300 disabled:opacity-50"
463
+ >
464
+ <ChevronLeft className="h-4 w-4" />
465
+ </button>
466
+
467
+ <span className="text-sm text-gray-800 dark:text-gray-200">
468
+ Page {currentPage} of {totalPages}
469
+ </span>
470
+
471
+ <button
472
+ onClick={() => {
473
+ if (currentPage < totalPages) {
474
+ const newPage = currentPage + 1;
475
+ setCurrentPage(newPage);
476
+ if (pagination.useServerSidePagination) {
477
+ setServerSidePaginationData((prev) => ({
478
+ ...prev,
479
+ current_page: newPage,
480
+ }));
481
+ }
482
+ }
483
+ }}
484
+ disabled={currentPage === totalPages}
485
+ className="p-2 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition text-gray-500 dark:text-gray-300 disabled:opacity-50"
486
+ >
487
+ <ChevronRight className="h-4 w-4" />
488
+ </button>
489
+ </div>
490
+ </div>
491
+ </div>
492
+ )}
493
+ </div>
494
+
495
+ {/* Portal Menu */}
496
+ {activeMenu &&
497
+ createPortal(
498
+ <div
499
+ ref={menuRef}
500
+ style={{
501
+ position: "fixed",
502
+ top: `${menuPosition.top}px`,
503
+ left: `${menuPosition.left}px`,
504
+ zIndex: 9999,
505
+ }}
506
+ className="w-48 bg-white dark:bg-gray-700 rounded-md shadow-lg border border-gray-200 dark:border-gray-600"
507
+ >
508
+ {menuList.map((action, i) => (
509
+ <button
510
+ key={i}
511
+ onClick={(e) =>
512
+ handleActionClick(
513
+ action,
514
+ data.find((d) => d.id === activeMenu),
515
+ e,
516
+ )
517
+ }
518
+ className={`w-full flex items-center gap-2 px-4 py-2 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-600 ${
519
+ action.variant === "danger"
520
+ ? "text-red-600 dark:text-red-500"
521
+ : "text-gray-700 dark:text-gray-200"
522
+ }`}
523
+ >
524
+ {action.icon && <span className="shrink-0">{action.icon}</span>}
525
+ {action.title}
526
+ </button>
527
+ ))}
528
+ </div>,
529
+ document.body,
530
+ )}
531
+
532
+ {/* Filter Drawer */}
533
+ {filterConfig && (
534
+ <FilterDrawer
535
+ isOpen={showFilters}
536
+ onClose={() => setShowFilters(false)}
537
+ config={filterConfig}
538
+ onApply={onFilterApply}
539
+ />
540
+ )}
541
+
542
+ {isOpen && (
543
+ <ImagePreview
544
+ src={targetImage.src}
545
+ alt={targetImage.alt}
546
+ isOpen={isOpen}
547
+ setIsOpen={setIsOpen}
548
+ />
549
+ )}
550
+ </>
551
+ );
552
+ };
553
+
554
+ export default Table;