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,532 @@
1
+ import React, { useEffect, useMemo, useState } from "react";
2
+ import { Plus, X } from "lucide-react";
3
+ import Table from "./Table/Table";
4
+ import Modal from "./Modal/Modal";
5
+ import Form from "./Form/Form";
6
+ import Button from "./Button/Button";
7
+ import { Icon } from "@iconify/react";
8
+ import { enqueueSnackbar } from "notistack";
9
+ import Details from "./Details/Details";
10
+ import PropTypes from "prop-types";
11
+ import { SnackbarProvider } from "notistack";
12
+
13
+ const CrudPage = ({ config }) => {
14
+ const {
15
+ title,
16
+ fetchData = async () => {},
17
+ isStaticData = false,
18
+ tableConfig = {},
19
+ modalConfig = {},
20
+ filterConfig,
21
+ } = config;
22
+
23
+ const [loading, setLoading] = useState(true);
24
+ const [formLoading, setFormLoading] = useState(false);
25
+
26
+ const [listingData, setListingData] = useState([]);
27
+ const [paginationData, setPaginationData] = useState(null);
28
+ const [serverSidePaginationData, setServerSidePaginationData] = useState({
29
+ search: "",
30
+ rows_per_page: 50,
31
+ current_page: 1,
32
+ });
33
+ const [filterData, setFilterData] = useState({});
34
+ const [changeFilterData, setChangeFilterData] = useState(false);
35
+
36
+ const [showAdd, setShowAdd] = useState(false);
37
+ const [showEdit, setShowEdit] = useState(false);
38
+ const [showDelete, setShowDelete] = useState(false);
39
+ const [showView, setShowView] = useState(false);
40
+ const [selectedItem, setSelectedItem] = useState(null);
41
+
42
+ const handleMenuAction = (action, item) => {
43
+ if (action === "edit") {
44
+ setSelectedItem(item);
45
+ setShowEdit(true);
46
+ } else if (action === "view") {
47
+ setSelectedItem(item);
48
+ setShowView(true);
49
+ } else if (action === "delete") {
50
+ setSelectedItem(item);
51
+ setShowDelete(true);
52
+ }
53
+ };
54
+
55
+ const executeAction = async (
56
+ action,
57
+ onSuccess,
58
+ success_message = "",
59
+ error_message = "",
60
+ ) => {
61
+ setFormLoading(true);
62
+
63
+ try {
64
+ const resp = await action?.();
65
+ if (success_message || resp.message) {
66
+ enqueueSnackbar(success_message || resp.message, {
67
+ variant: "success",
68
+ });
69
+ }
70
+ onSuccess?.(resp);
71
+ } catch (error) {
72
+ if (error_message || error.message) {
73
+ enqueueSnackbar(error_message || error.message, { variant: "error" });
74
+ }
75
+ } finally {
76
+ setFormLoading(false);
77
+ }
78
+ };
79
+
80
+ const handleAddResult = (resp) => {
81
+ let newObj = resp.newObject;
82
+ if (isStaticData) {
83
+ setListingData((prev) => [newObj, ...prev]);
84
+ setPaginationData((prev) => ({
85
+ ...prev,
86
+ current_page: 1,
87
+ }));
88
+ } else {
89
+ setServerSidePaginationData((prev) => ({
90
+ ...prev,
91
+ current_page: 1,
92
+ }));
93
+ if (serverSidePaginationData.current_page == 1) {
94
+ handleGetListing();
95
+ }
96
+ }
97
+ setShowAdd(false);
98
+ };
99
+
100
+ const handleEditResult = (resp) => {
101
+ let updatedObj = resp.newObject;
102
+ let targetObj = resp.targetObject;
103
+ if (isStaticData) {
104
+ setListingData((prev) =>
105
+ prev.map((item) =>
106
+ item.id === targetObj.id ? { ...item, ...updatedObj } : item,
107
+ ),
108
+ );
109
+ } else {
110
+ handleGetListing();
111
+ }
112
+ setShowEdit(false);
113
+ };
114
+
115
+ const handleDeleteResult = (resp) => {
116
+ if (!resp) {
117
+ setShowDelete(false);
118
+ setSelectedItem(null);
119
+ return;
120
+ }
121
+
122
+ if (isStaticData) {
123
+ setListingData((prev) =>
124
+ prev.filter((item) => item.id !== resp.targetObject.id),
125
+ );
126
+ } else {
127
+ if (
128
+ listingData.length == 1 &&
129
+ serverSidePaginationData.current_page > 1
130
+ ) {
131
+ setServerSidePaginationData((prev) => ({
132
+ ...prev,
133
+ current_page: prev.current_page - 1,
134
+ }));
135
+ } else {
136
+ handleGetListing();
137
+ }
138
+ }
139
+ setShowDelete(false);
140
+ setSelectedItem(null);
141
+ };
142
+
143
+ const handleAddFormSubmit = (formData) =>
144
+ executeAction(
145
+ () => modalConfig?.addModal?.handleSubmit?.(formData),
146
+ handleAddResult,
147
+ );
148
+
149
+ const handleEditFormSubmit = (formData) =>
150
+ executeAction(
151
+ () => modalConfig?.editModal?.handleSubmit?.(formData, selectedItem),
152
+ handleEditResult,
153
+ );
154
+
155
+ const handleDeleteConfirm = () =>
156
+ executeAction(
157
+ () => modalConfig?.deleteModal?.action?.(selectedItem),
158
+ handleDeleteResult,
159
+ );
160
+
161
+ const handleGetListing = async () => {
162
+ setLoading(true);
163
+
164
+ fetchData?.({ ...serverSidePaginationData, ...filterData })
165
+ .then((resp) => {
166
+ setListingData(resp.data);
167
+ setPaginationData(resp.pagination);
168
+ })
169
+ .catch((error) => {
170
+ enqueueSnackbar(error.message, { variant: "error" });
171
+ })
172
+ .finally(() => {
173
+ setLoading(false);
174
+ });
175
+ };
176
+
177
+ const handleFilterData = (data) => {
178
+ setFilterData((prev) => ({
179
+ ...data,
180
+ }));
181
+ if (tableConfig?.filter?.useServerSideFilters) {
182
+ setChangeFilterData((prev) => !prev);
183
+ }
184
+ };
185
+
186
+ const filterByMatchingFields = (data, filters) => {
187
+ return data.filter((item) =>
188
+ Object.entries(filters).every(([key, value]) => item[key] === value),
189
+ );
190
+ };
191
+
192
+ const filteredData = useMemo(() => {
193
+ if (tableConfig?.filter?.useServerSideFilters) return data;
194
+ return filterByMatchingFields(listingData, filterData);
195
+ }, [listingData, filterData]);
196
+
197
+ useEffect(() => {
198
+ handleGetListing();
199
+ }, [
200
+ serverSidePaginationData.search,
201
+ serverSidePaginationData.rows_per_page,
202
+ serverSidePaginationData.current_page,
203
+ changeFilterData,
204
+ ]);
205
+
206
+ return (
207
+ <SnackbarProvider
208
+ maxSnack={3}
209
+ anchorOrigin={{
210
+ vertical: "bottom",
211
+ horizontal: "right",
212
+ }}
213
+ autoHideDuration={3000}
214
+ action={(snackbarKey) => (
215
+ <button
216
+ onClick={() => {
217
+ window.dispatchEvent(
218
+ new CustomEvent("closeSnackbar", { detail: snackbarKey }),
219
+ );
220
+ }}
221
+ className="p-1 hover:bg-white/20 rounded-full transition-colors duration-200 text-white flex items-center justify-center"
222
+ >
223
+ <X className="h-4 w-4" />
224
+ </button>
225
+ )}
226
+ >
227
+ <div>
228
+ {/* Header */}
229
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
230
+ <div>
231
+ <h1 className="text-2xl font-bold text-gray-900 dark:text-white">
232
+ {title}
233
+ </h1>
234
+ <p className="text-md text-gray-600 dark:text-gray-400 mt-2">
235
+ {config?.description}
236
+ </p>
237
+ </div>
238
+ <div className="flex items-center space-x-3">
239
+ <Button
240
+ onClick={() => setShowAdd(true)}
241
+ variant="contained"
242
+ color="primary"
243
+ >
244
+ <Plus className="w-4 h-4 mr-2" />
245
+ {config.buttonText || "Add New"}
246
+ </Button>
247
+ </div>
248
+ </div>
249
+
250
+ {/* Table */}
251
+ <Table
252
+ config={{
253
+ ...tableConfig,
254
+ pagination: {
255
+ ...tableConfig.pagination,
256
+ ...paginationData,
257
+ },
258
+ data: filteredData,
259
+ setServerSidePaginationData: setServerSidePaginationData,
260
+ onMenuAction: handleMenuAction,
261
+ filterConfig,
262
+ onFilterApply: handleFilterData,
263
+ loading,
264
+ }}
265
+ />
266
+
267
+ {/* Add Modal */}
268
+ <Modal
269
+ isOpen={showAdd}
270
+ onClose={() => setShowAdd(false)}
271
+ icon={modalConfig.addModal?.icon}
272
+ title={modalConfig.addModal?.title || "Add New"}
273
+ size={modalConfig.addModal?.size || "md"}
274
+ onFormSubmit={() =>
275
+ document.querySelector("#addForm")?.requestSubmit()
276
+ }
277
+ loadingBtn={formLoading}
278
+ actionButtons={modalConfig.addModal.actionButtons}
279
+ >
280
+ <Form
281
+ config={modalConfig?.addModal || []}
282
+ onSubmit={handleAddFormSubmit}
283
+ initialData={{}}
284
+ loading={formLoading}
285
+ />
286
+ </Modal>
287
+
288
+ {/* Edit Modal */}
289
+ <Modal
290
+ isOpen={showEdit}
291
+ onClose={() => setShowEdit(false)}
292
+ icon={modalConfig.editModal?.icon}
293
+ title={modalConfig.editModal?.title || "Edit"}
294
+ size={modalConfig.editModal?.size || "md"}
295
+ onFormSubmit={() =>
296
+ document.querySelector("#editForm")?.requestSubmit()
297
+ }
298
+ actionButtons={modalConfig.editModal.actionButtons}
299
+ loadingBtn={formLoading}
300
+ >
301
+ <Form
302
+ config={modalConfig.editModal || []}
303
+ onSubmit={handleEditFormSubmit}
304
+ initialData={selectedItem}
305
+ loading={formLoading}
306
+ />
307
+ </Modal>
308
+
309
+ {/* Delete Modal */}
310
+ {showDelete && (
311
+ <Modal
312
+ isOpen={showDelete}
313
+ onClose={(resp) => {
314
+ handleDeleteResult(resp);
315
+ }}
316
+ icon={
317
+ modalConfig.deleteModal?.icon || (
318
+ <Icon icon="ph:warning-bold" className="w-6 h-6 text-red-500" />
319
+ )
320
+ }
321
+ title={modalConfig.deleteModal?.title || "Confirm Delete"}
322
+ size={modalConfig.deleteModal?.size || "md"}
323
+ loading={formLoading}
324
+ actionButtons={modalConfig.deleteModal.actionButtons}
325
+ executeFunction={executeAction}
326
+ selectedItem={selectedItem}
327
+ >
328
+ <div className="flex items-center space-x-2 py-3">
329
+ <div>
330
+ <p className="text-md text-gray-700 dark:text-white">
331
+ {modalConfig.deleteModal?.confirmText ||
332
+ "Are you sure you want to delete this item?"}
333
+ </p>
334
+ {modalConfig.deleteModal?.referenceKey && (
335
+ <p className="text-md font-semibold text-gray-700 dark:text-white">
336
+ {selectedItem[modalConfig.deleteModal?.referenceKey]}
337
+ </p>
338
+ )}
339
+ </div>
340
+ </div>
341
+ </Modal>
342
+ )}
343
+
344
+ {/* View Detail Modal */}
345
+ {modalConfig.viewModal && (
346
+ <Modal
347
+ isOpen={showView}
348
+ onClose={() => {
349
+ setShowView(false);
350
+ setSelectedItem(null);
351
+ }}
352
+ icon={modalConfig.viewModal?.icon}
353
+ title={modalConfig.viewModal?.title || "View Details"}
354
+ size={modalConfig.viewModal?.size || "lg"}
355
+ footerConfig={modalConfig?.viewModal.footer}
356
+ >
357
+ {modalConfig.viewModal?.component ? (
358
+ <modalConfig.viewModal.component data={selectedItem} />
359
+ ) : (
360
+ <Details
361
+ data={selectedItem}
362
+ config={modalConfig.viewModal || {}}
363
+ />
364
+ )}
365
+ </Modal>
366
+ )}
367
+ </div>
368
+ </SnackbarProvider>
369
+ );
370
+ };
371
+
372
+ const optionType = PropTypes.shape({
373
+ value: PropTypes.oneOfType([
374
+ PropTypes.string,
375
+ PropTypes.number,
376
+ PropTypes.bool,
377
+ ]).isRequired,
378
+ label: PropTypes.string.isRequired,
379
+ color: PropTypes.string,
380
+ });
381
+
382
+ const actionButtonType = PropTypes.shape({
383
+ type: PropTypes.string.isRequired,
384
+ label: PropTypes.string.isRequired,
385
+ color: PropTypes.string,
386
+ variant: PropTypes.string,
387
+ onClick: PropTypes.func,
388
+ });
389
+
390
+ const menuActionType = PropTypes.shape({
391
+ title: PropTypes.string.isRequired,
392
+ type: PropTypes.string.isRequired,
393
+ variant: PropTypes.string,
394
+ icon: PropTypes.node,
395
+ });
396
+
397
+ const tableHeadType = PropTypes.shape({
398
+ key: PropTypes.string.isRequired,
399
+ title: PropTypes.string,
400
+ type: PropTypes.string,
401
+ imageKey: PropTypes.string,
402
+ titleKey: PropTypes.string,
403
+ subtitleKey: PropTypes.string,
404
+ onClickDetails: PropTypes.bool,
405
+ variant: PropTypes.string,
406
+ chipOptions: PropTypes.arrayOf(optionType),
407
+ defaultColor: PropTypes.string,
408
+ className: PropTypes.string,
409
+ format: PropTypes.string,
410
+ menuList: PropTypes.arrayOf(menuActionType),
411
+ });
412
+
413
+ const formFieldType = PropTypes.shape({
414
+ key: PropTypes.string.isRequired,
415
+ label: PropTypes.string,
416
+ type: PropTypes.string.isRequired,
417
+ required: PropTypes.bool,
418
+ minLength: PropTypes.number,
419
+ parentClass: PropTypes.string,
420
+ search: PropTypes.bool,
421
+ multiple: PropTypes.bool,
422
+ dropdownMaxHeight: PropTypes.string,
423
+ dragDrop: PropTypes.bool,
424
+ countriesList: PropTypes.bool,
425
+ defaultCountry: PropTypes.string,
426
+ placeholder: PropTypes.string,
427
+ rows: PropTypes.number,
428
+ text: PropTypes.string,
429
+ editorKey: PropTypes.string,
430
+ options: PropTypes.arrayOf(optionType),
431
+ });
432
+
433
+ const viewFieldType = PropTypes.shape({
434
+ key: PropTypes.string,
435
+ label: PropTypes.string,
436
+ type: PropTypes.string,
437
+ imageKey: PropTypes.string,
438
+ titleKey: PropTypes.string,
439
+ subtitleKey: PropTypes.string,
440
+ blockClass: PropTypes.string,
441
+ icon: PropTypes.node,
442
+ variant: PropTypes.string,
443
+ chipOptions: PropTypes.arrayOf(optionType),
444
+ defaultColor: PropTypes.string,
445
+ className: PropTypes.string,
446
+ format: PropTypes.string,
447
+ });
448
+
449
+ CrudPage.propTypes = {
450
+ config: PropTypes.shape({
451
+ title: PropTypes.string.isRequired,
452
+ description: PropTypes.string,
453
+ buttonText: PropTypes.string,
454
+ fetchData: PropTypes.func.isRequired,
455
+ isStaticData: PropTypes.bool,
456
+
457
+ /* ================= TABLE CONFIG ================= */
458
+
459
+ tableConfig: PropTypes.shape({
460
+ table_head: PropTypes.arrayOf(tableHeadType).isRequired,
461
+
462
+ search: PropTypes.shape({
463
+ enabled: PropTypes.bool,
464
+ useServerSideSearch: PropTypes.bool,
465
+ searchKeys: PropTypes.arrayOf(PropTypes.string),
466
+ }),
467
+
468
+ pagination: PropTypes.shape({
469
+ enabled: PropTypes.bool,
470
+ useServerSidePagination: PropTypes.bool,
471
+ }),
472
+
473
+ filter: PropTypes.shape({
474
+ enabled: PropTypes.bool,
475
+ useServerSideFilters: PropTypes.bool,
476
+ }),
477
+ }).isRequired,
478
+
479
+ /* ================= MODAL CONFIG ================= */
480
+
481
+ modalConfig: PropTypes.shape({
482
+ addModal: PropTypes.shape({
483
+ title: PropTypes.string.isRequired,
484
+ size: PropTypes.string,
485
+ formClass: PropTypes.string,
486
+ formFields: PropTypes.arrayOf(formFieldType),
487
+ handleSubmit: PropTypes.func.isRequired,
488
+ actionButtons: PropTypes.arrayOf(actionButtonType),
489
+ }),
490
+
491
+ editModal: PropTypes.shape({
492
+ title: PropTypes.string.isRequired,
493
+ size: PropTypes.string,
494
+ formClass: PropTypes.string,
495
+ formFields: PropTypes.arrayOf(formFieldType),
496
+ handleSubmit: PropTypes.func.isRequired,
497
+ actionButtons: PropTypes.arrayOf(actionButtonType),
498
+ }),
499
+
500
+ deleteModal: PropTypes.shape({
501
+ title: PropTypes.string.isRequired,
502
+ size: PropTypes.string,
503
+ confirmText: PropTypes.string,
504
+ referenceKey: PropTypes.string,
505
+ actionButtons: PropTypes.arrayOf(actionButtonType),
506
+ }),
507
+
508
+ viewModal: PropTypes.shape({
509
+ title: PropTypes.string.isRequired,
510
+ size: PropTypes.string,
511
+
512
+ // 👇 This covers your commented code:
513
+ component: PropTypes.elementType, // for custom component like TeamMemberDetail
514
+
515
+ fields: PropTypes.arrayOf(viewFieldType),
516
+
517
+ footer: PropTypes.shape({
518
+ cancelButton: PropTypes.bool,
519
+ cancelText: PropTypes.string,
520
+ }),
521
+ }),
522
+ }),
523
+
524
+ /* ================= FILTER CONFIG ================= */
525
+
526
+ filterConfig: PropTypes.shape({
527
+ fields: PropTypes.arrayOf(formFieldType),
528
+ }),
529
+ }).isRequired,
530
+ };
531
+
532
+ export default CrudPage;
@@ -0,0 +1,134 @@
1
+ import { User } from "lucide-react";
2
+ import React, { useState } from "react";
3
+ import Chip from "../Chip/Chip";
4
+ import { formatDate } from "../../lib/utils";
5
+ import ImagePreview from "../Table/components/ImagePreview";
6
+
7
+ export default function Details({ data, config }) {
8
+ const { fields, containerClass } = config;
9
+
10
+ // image preview
11
+ const [targetImage, setTargetImage] = useState(null);
12
+ const [isOpen, setIsOpen] = useState(false);
13
+
14
+ const openPreview = (image) => {
15
+ setTargetImage(image);
16
+ setIsOpen(true);
17
+ };
18
+
19
+ const DetailRow = ({ col }) => {
20
+ let Icon = col?.icon;
21
+ let label = col.label;
22
+ let value = data[col.key];
23
+ let type = col.type;
24
+ let variant = col.variant || "outline";
25
+ let color = col.defaultColor;
26
+
27
+ if (type == "chip" && col.chipOptions.length > 0) {
28
+ let chipObj = col?.chipOptions.find((obj) => obj.value == value);
29
+ if (chipObj) {
30
+ value = chipObj.label;
31
+ color = chipObj.color;
32
+ }
33
+ }
34
+
35
+ return (
36
+ <div
37
+ className={`col-span-12 flex items-center space-x-4 p-4 rounded-xl
38
+ bg-gray-100 dark:bg-gray-900 ${col.blockClass}`}
39
+ >
40
+ {Icon && <div className="flex-shrink-0">{Icon}</div>}
41
+ <div className="flex-1 min-w-0">
42
+ <p className="text-sm font-medium text-gray-500 dark:text-gray-400">
43
+ {label}
44
+ </p>
45
+ {type == "chip" ? (
46
+ <>
47
+ <Chip
48
+ label={value}
49
+ variant={variant}
50
+ color={color}
51
+ className="mt-1"
52
+ />
53
+ </>
54
+ ) : type == "tinyEditor" ? (
55
+ <p
56
+ className="mt-1 text-sm text-gray-900 dark:text-white break-words"
57
+ dangerouslySetInnerHTML={{
58
+ __html: value,
59
+ }}
60
+ ></p>
61
+ ) : (
62
+ <p className="mt-1 text-sm text-gray-900 dark:text-white break-words">
63
+ {type == "date" ? (
64
+ <span>{formatDate(value, col.format || "DD MMM YYYY")}</span>
65
+ ) : (
66
+ value || "N/A"
67
+ )}
68
+ </p>
69
+ )}
70
+ </div>
71
+ </div>
72
+ );
73
+ };
74
+
75
+ const GroupRow = ({ col }) => {
76
+ let title = data[col.titleKey];
77
+ let subtitle = data[col.subtitleKey];
78
+ let image = data[col.imageKey];
79
+ let fallback_icon = data[col.fallback_icon];
80
+
81
+ return (
82
+ <div
83
+ className={`col-span-12 flex items-center space-x-4 p-4 rounded-xl
84
+ bg-gray-100 dark:bg-gray-900
85
+ ${col.blockClass}`}
86
+ >
87
+ {image ? (
88
+ <img
89
+ src={image}
90
+ alt={title}
91
+ onClick={() => openPreview({ src: image, alt: title })}
92
+ className="w-16 h-16 cursor-pointer rounded-full object-cover border-2 border-gray-200 dark:border-gray-700"
93
+ />
94
+ ) : fallback_icon ? (
95
+ fallback_icon
96
+ ) : (
97
+ <div className="w-16 h-16 flex items-center justify-center rounded-full border-2 border-gray-300 dark:border-gray-700 bg-gray-200 dark:bg-gray-600">
98
+ <User className="w-8 h-8 text-gray-400" />
99
+ </div>
100
+ )}
101
+
102
+ <div>
103
+ <h3 className="text-xl font-semibold text-gray-900 dark:text-white">
104
+ {title}
105
+ </h3>
106
+ <p className="text-sm text-gray-500 dark:text-gray-400">{subtitle}</p>
107
+ </div>
108
+ </div>
109
+ );
110
+ };
111
+
112
+ return (
113
+ <>
114
+ {isOpen && (
115
+ <ImagePreview
116
+ src={targetImage.src}
117
+ alt={targetImage.alt}
118
+ isOpen={isOpen}
119
+ setIsOpen={setIsOpen}
120
+ />
121
+ )}
122
+
123
+ <div className={`grid grid-cols-12 gap-4 ${containerClass || ""}`}>
124
+ {fields.map((col) =>
125
+ col.type == "group" ? (
126
+ <GroupRow col={col} />
127
+ ) : (
128
+ <DetailRow col={col} />
129
+ ),
130
+ )}
131
+ </div>
132
+ </>
133
+ );
134
+ }