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.
- package/README.md +40 -0
- package/package.json +55 -0
- package/src/App.jsx +5 -0
- package/src/components/Button/Button.jsx +85 -0
- package/src/components/Chip/Chip.jsx +71 -0
- package/src/components/CrudPage.jsx +532 -0
- package/src/components/Details/Details.jsx +134 -0
- package/src/components/Filter/FilterDrawer.jsx +99 -0
- package/src/components/Form/Form.jsx +51 -0
- package/src/components/Form/components/Checkbox.jsx +119 -0
- package/src/components/Form/components/ImagePicker.jsx +128 -0
- package/src/components/Form/components/Input.jsx +71 -0
- package/src/components/Form/components/InputLabel.jsx +12 -0
- package/src/components/Form/components/PhoneInput.jsx +221 -0
- package/src/components/Form/components/RenderFields.jsx +181 -0
- package/src/components/Form/components/Select.jsx +191 -0
- package/src/components/Form/components/Switch.jsx +64 -0
- package/src/components/Form/components/TextArea.jsx +31 -0
- package/src/components/Form/components/TinyEditor.jsx +113 -0
- package/src/components/Loader/Spinner.jsx +21 -0
- package/src/components/Modal/Modal.jsx +152 -0
- package/src/components/Table/Table.jsx +554 -0
- package/src/components/Table/components/ImagePreview.jsx +58 -0
- package/src/components/Table/components/TableSkeleton.jsx +39 -0
- package/src/data/countries.js +252 -0
- package/src/data/teams.js +130 -0
- package/src/index.css +170 -0
- package/src/lib/utils.js +74 -0
- package/src/main.jsx +11 -0
|
@@ -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;
|