@visns-studio/visns-components 5.13.6 → 5.13.8
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/package.json
CHANGED
|
@@ -24,19 +24,20 @@
|
|
|
24
24
|
"dayjs": "^1.11.13",
|
|
25
25
|
"fabric": "^6.7.0",
|
|
26
26
|
"file-saver": "^2.0.5",
|
|
27
|
-
"framer-motion": "^12.23.
|
|
27
|
+
"framer-motion": "^12.23.1",
|
|
28
28
|
"html-react-parser": "^5.2.5",
|
|
29
29
|
"lodash": "^4.17.21",
|
|
30
30
|
"lodash.debounce": "^4.0.8",
|
|
31
31
|
"lucide-react": "^0.525.0",
|
|
32
32
|
"moment": "^2.30.1",
|
|
33
|
-
"motion": "^12.23.
|
|
33
|
+
"motion": "^12.23.1",
|
|
34
34
|
"numeral": "^2.0.6",
|
|
35
35
|
"pluralize": "^8.0.0",
|
|
36
36
|
"qrcode.react": "^4.2.0",
|
|
37
37
|
"quill-image-uploader": "^1.3.0",
|
|
38
38
|
"react-big-calendar": "^1.19.4",
|
|
39
39
|
"react-copy-to-clipboard": "^5.1.0",
|
|
40
|
+
"react-currency-input-field": "^3.10.0",
|
|
40
41
|
"react-datepicker": "^8.4.0",
|
|
41
42
|
"react-dropzone": "^14.3.8",
|
|
42
43
|
"react-grid-gallery": "^1.0.1",
|
|
@@ -87,7 +88,7 @@
|
|
|
87
88
|
"react-dom": "^17.0.0 || ^18.0.0"
|
|
88
89
|
},
|
|
89
90
|
"name": "@visns-studio/visns-components",
|
|
90
|
-
"version": "5.13.
|
|
91
|
+
"version": "5.13.8",
|
|
91
92
|
"description": "Various packages to assist in the development of our Custom Applications.",
|
|
92
93
|
"main": "src/index.js",
|
|
93
94
|
"files": [
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
SortableContainer,
|
|
4
4
|
SortableElement,
|
|
5
5
|
sortableHandle,
|
|
6
6
|
} from 'react-sortable-hoc';
|
|
7
7
|
import { GripVertical as ChevronVertical, Edit as Pencil, Trash2 as TrashCan } from 'lucide-react';
|
|
8
|
+
import ImageModal from './ImageModal';
|
|
8
9
|
import styles from './styles/Gallery.module.css';
|
|
9
10
|
|
|
10
11
|
// Drag handle component
|
|
@@ -24,7 +25,7 @@ const truncateText = (text, maxLength) => {
|
|
|
24
25
|
|
|
25
26
|
// Sortable gallery item
|
|
26
27
|
const SortableGalleryItem = SortableElement(
|
|
27
|
-
({ value, handleEdit, handleDelete, showDescription = true }) => {
|
|
28
|
+
({ value, handleEdit, handleDelete, showDescription = true, onImageClick }) => {
|
|
28
29
|
const imageUrl = value.image_url || value.file_url;
|
|
29
30
|
const title =
|
|
30
31
|
value.file_title || value.label || value.file_name || 'Untitled';
|
|
@@ -34,7 +35,11 @@ const SortableGalleryItem = SortableElement(
|
|
|
34
35
|
<div className={styles.galleryItem}>
|
|
35
36
|
<DragHandle />
|
|
36
37
|
|
|
37
|
-
<div
|
|
38
|
+
<div
|
|
39
|
+
className={styles.galleryImage}
|
|
40
|
+
onClick={() => onImageClick && onImageClick(value)}
|
|
41
|
+
style={{ cursor: onImageClick ? 'pointer' : 'default' }}
|
|
42
|
+
>
|
|
38
43
|
{imageUrl ? (
|
|
39
44
|
<img src={imageUrl} alt={title} />
|
|
40
45
|
) : (
|
|
@@ -95,6 +100,30 @@ const SortableGalleryItem = SortableElement(
|
|
|
95
100
|
|
|
96
101
|
// Sortable gallery container
|
|
97
102
|
const Gallery = SortableContainer(({ items, handleEdit, handleDelete, showDescription = true }) => {
|
|
103
|
+
const [modalOpen, setModalOpen] = React.useState(false);
|
|
104
|
+
const [currentImageIndex, setCurrentImageIndex] = React.useState(0);
|
|
105
|
+
|
|
106
|
+
const handleImageClick = (clickedImage) => {
|
|
107
|
+
const imageIndex = items.findIndex(item => item.id === clickedImage.id);
|
|
108
|
+
setCurrentImageIndex(imageIndex);
|
|
109
|
+
setModalOpen(true);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const handleNextImage = () => {
|
|
113
|
+
setCurrentImageIndex((prevIndex) =>
|
|
114
|
+
prevIndex < items.length - 1 ? prevIndex + 1 : 0
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const handlePrevImage = () => {
|
|
119
|
+
setCurrentImageIndex((prevIndex) =>
|
|
120
|
+
prevIndex > 0 ? prevIndex - 1 : items.length - 1
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const handleCloseModal = () => {
|
|
125
|
+
setModalOpen(false);
|
|
126
|
+
};
|
|
98
127
|
if (!items || items.length === 0) {
|
|
99
128
|
return (
|
|
100
129
|
<div className={styles.galleryEmpty}>
|
|
@@ -130,18 +159,29 @@ const Gallery = SortableContainer(({ items, handleEdit, handleDelete, showDescri
|
|
|
130
159
|
}
|
|
131
160
|
|
|
132
161
|
return (
|
|
133
|
-
|
|
134
|
-
{
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
162
|
+
<>
|
|
163
|
+
<div className={styles.galleryGrid}>
|
|
164
|
+
{items.map((value, index) => (
|
|
165
|
+
<SortableGalleryItem
|
|
166
|
+
key={`item-${index}`}
|
|
167
|
+
index={index}
|
|
168
|
+
value={value}
|
|
169
|
+
handleEdit={handleEdit}
|
|
170
|
+
handleDelete={handleDelete}
|
|
171
|
+
showDescription={showDescription}
|
|
172
|
+
onImageClick={handleImageClick}
|
|
173
|
+
/>
|
|
174
|
+
))}
|
|
175
|
+
</div>
|
|
176
|
+
<ImageModal
|
|
177
|
+
isOpen={modalOpen}
|
|
178
|
+
onClose={handleCloseModal}
|
|
179
|
+
images={items}
|
|
180
|
+
currentIndex={currentImageIndex}
|
|
181
|
+
onNext={handleNextImage}
|
|
182
|
+
onPrevious={handlePrevImage}
|
|
183
|
+
/>
|
|
184
|
+
</>
|
|
145
185
|
);
|
|
146
186
|
});
|
|
147
187
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import React, { useEffect, useCallback } from 'react';
|
|
2
|
+
import { X as CloseIcon, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
3
|
+
import styles from './styles/ImageModal.module.css';
|
|
4
|
+
|
|
5
|
+
const ImageModal = ({
|
|
6
|
+
isOpen,
|
|
7
|
+
onClose,
|
|
8
|
+
images,
|
|
9
|
+
currentIndex,
|
|
10
|
+
onNext,
|
|
11
|
+
onPrevious,
|
|
12
|
+
showNavigation = true
|
|
13
|
+
}) => {
|
|
14
|
+
const handleKeyDown = useCallback((e) => {
|
|
15
|
+
if (!isOpen) return;
|
|
16
|
+
|
|
17
|
+
switch (e.key) {
|
|
18
|
+
case 'Escape':
|
|
19
|
+
onClose();
|
|
20
|
+
break;
|
|
21
|
+
case 'ArrowLeft':
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
onPrevious();
|
|
24
|
+
break;
|
|
25
|
+
case 'ArrowRight':
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
onNext();
|
|
28
|
+
break;
|
|
29
|
+
default:
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}, [isOpen, onClose, onNext, onPrevious]);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (isOpen) {
|
|
36
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
37
|
+
document.body.style.overflow = 'hidden';
|
|
38
|
+
} else {
|
|
39
|
+
document.body.style.overflow = 'unset';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return () => {
|
|
43
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
44
|
+
document.body.style.overflow = 'unset';
|
|
45
|
+
};
|
|
46
|
+
}, [isOpen, handleKeyDown]);
|
|
47
|
+
|
|
48
|
+
if (!isOpen || !images || images.length === 0) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const currentImage = images[currentIndex];
|
|
53
|
+
const imageUrl = currentImage?.image_url || currentImage?.file_url;
|
|
54
|
+
const title = currentImage?.file_title || currentImage?.label || currentImage?.file_name || 'Untitled';
|
|
55
|
+
const description = currentImage?.file_description || '';
|
|
56
|
+
|
|
57
|
+
const handleBackdropClick = (e) => {
|
|
58
|
+
if (e.target === e.currentTarget) {
|
|
59
|
+
onClose();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className={styles.modalOverlay} onClick={handleBackdropClick}>
|
|
65
|
+
<div className={styles.modalContent}>
|
|
66
|
+
<button
|
|
67
|
+
className={styles.closeButton}
|
|
68
|
+
onClick={onClose}
|
|
69
|
+
aria-label="Close modal"
|
|
70
|
+
>
|
|
71
|
+
<CloseIcon size={24} />
|
|
72
|
+
</button>
|
|
73
|
+
|
|
74
|
+
{showNavigation && images.length > 1 && (
|
|
75
|
+
<>
|
|
76
|
+
<button
|
|
77
|
+
className={`${styles.navButton} ${styles.prevButton}`}
|
|
78
|
+
onClick={onPrevious}
|
|
79
|
+
aria-label="Previous image"
|
|
80
|
+
disabled={currentIndex === 0}
|
|
81
|
+
>
|
|
82
|
+
<ChevronLeft size={32} />
|
|
83
|
+
</button>
|
|
84
|
+
<button
|
|
85
|
+
className={`${styles.navButton} ${styles.nextButton}`}
|
|
86
|
+
onClick={onNext}
|
|
87
|
+
aria-label="Next image"
|
|
88
|
+
disabled={currentIndex === images.length - 1}
|
|
89
|
+
>
|
|
90
|
+
<ChevronRight size={32} />
|
|
91
|
+
</button>
|
|
92
|
+
</>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
<div className={styles.imageContainer}>
|
|
96
|
+
<img
|
|
97
|
+
src={imageUrl}
|
|
98
|
+
alt={title}
|
|
99
|
+
className={styles.modalImage}
|
|
100
|
+
loading="lazy"
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div className={styles.imageInfo}>
|
|
105
|
+
<h3 className={styles.imageTitle}>{title}</h3>
|
|
106
|
+
{description && (
|
|
107
|
+
<p className={styles.imageDescription}>{description}</p>
|
|
108
|
+
)}
|
|
109
|
+
{images.length > 1 && (
|
|
110
|
+
<div className={styles.imageCounter}>
|
|
111
|
+
{currentIndex + 1} of {images.length}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export default ImageModal;
|
|
@@ -5,6 +5,7 @@ import { toast } from 'react-toastify';
|
|
|
5
5
|
import { Trash2, ArrowUp, ArrowDown, Copy, Droplets } from 'lucide-react';
|
|
6
6
|
import { Compact } from '@uiw/react-color';
|
|
7
7
|
import { confirmDialog } from '../utils/ConfirmDialog';
|
|
8
|
+
import CurrencyInput from 'react-currency-input-field';
|
|
8
9
|
|
|
9
10
|
import CustomFetch from '../Fetch';
|
|
10
11
|
import MultiSelect from '../MultiSelect';
|
|
@@ -920,6 +921,27 @@ const GenericEditableTable = ({
|
|
|
920
921
|
// For other colour columns, picker is rendered in the action column
|
|
921
922
|
return null;
|
|
922
923
|
case 'currency':
|
|
924
|
+
return (
|
|
925
|
+
<CurrencyInput
|
|
926
|
+
id={`currency-${column.id}-${keyCounter}`}
|
|
927
|
+
name={column.id}
|
|
928
|
+
placeholder="$0.00"
|
|
929
|
+
defaultValue={entry[column.id] || ''}
|
|
930
|
+
decimalsLimit={2}
|
|
931
|
+
prefix="$"
|
|
932
|
+
decimalSeparator="."
|
|
933
|
+
groupSeparator=","
|
|
934
|
+
onValueChange={(value) =>
|
|
935
|
+
handleFieldChange(
|
|
936
|
+
value || '',
|
|
937
|
+
column.id,
|
|
938
|
+
keyCounter
|
|
939
|
+
)
|
|
940
|
+
}
|
|
941
|
+
style={{ textAlign: 'right', width: '100%' }}
|
|
942
|
+
readOnly={column.readOnly || false}
|
|
943
|
+
/>
|
|
944
|
+
);
|
|
923
945
|
case 'number':
|
|
924
946
|
return (
|
|
925
947
|
<input
|
|
@@ -1076,6 +1098,26 @@ const GenericEditableTable = ({
|
|
|
1076
1098
|
// For other colour columns, picker is rendered in the action column
|
|
1077
1099
|
return null;
|
|
1078
1100
|
case 'currency':
|
|
1101
|
+
return (
|
|
1102
|
+
<CurrencyInput
|
|
1103
|
+
id={`new-currency-${column.id}-${groupId || 'default'}`}
|
|
1104
|
+
name={column.id}
|
|
1105
|
+
placeholder="$0.00"
|
|
1106
|
+
defaultValue={value || ''}
|
|
1107
|
+
decimalsLimit={2}
|
|
1108
|
+
prefix="$"
|
|
1109
|
+
decimalSeparator="."
|
|
1110
|
+
groupSeparator=","
|
|
1111
|
+
onValueChange={(value) =>
|
|
1112
|
+
handleNewFieldChange(
|
|
1113
|
+
value || '',
|
|
1114
|
+
column.id,
|
|
1115
|
+
groupId
|
|
1116
|
+
)
|
|
1117
|
+
}
|
|
1118
|
+
style={{ textAlign: 'right', width: '100%' }}
|
|
1119
|
+
/>
|
|
1120
|
+
);
|
|
1079
1121
|
case 'number':
|
|
1080
1122
|
return (
|
|
1081
1123
|
<input
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
.modalOverlay {
|
|
2
|
+
position: fixed;
|
|
3
|
+
top: 0;
|
|
4
|
+
left: 0;
|
|
5
|
+
right: 0;
|
|
6
|
+
bottom: 0;
|
|
7
|
+
background-color: rgba(0, 0, 0, 0.9);
|
|
8
|
+
display: flex;
|
|
9
|
+
align-items: center;
|
|
10
|
+
justify-content: center;
|
|
11
|
+
z-index: 1000;
|
|
12
|
+
padding: 20px;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.modalContent {
|
|
16
|
+
position: relative;
|
|
17
|
+
max-width: 95vw;
|
|
18
|
+
max-height: 95vh;
|
|
19
|
+
width: 100%;
|
|
20
|
+
height: 100%;
|
|
21
|
+
display: flex;
|
|
22
|
+
flex-direction: column;
|
|
23
|
+
align-items: center;
|
|
24
|
+
justify-content: center;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.closeButton {
|
|
28
|
+
position: absolute;
|
|
29
|
+
top: 20px;
|
|
30
|
+
right: 20px;
|
|
31
|
+
background: rgba(255, 255, 255, 0.9);
|
|
32
|
+
border: none;
|
|
33
|
+
border-radius: 50%;
|
|
34
|
+
width: 44px;
|
|
35
|
+
height: 44px;
|
|
36
|
+
display: flex;
|
|
37
|
+
align-items: center;
|
|
38
|
+
justify-content: center;
|
|
39
|
+
cursor: pointer;
|
|
40
|
+
z-index: 1001;
|
|
41
|
+
transition: all 0.2s ease;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.closeButton:hover {
|
|
45
|
+
background: rgba(255, 255, 255, 1);
|
|
46
|
+
transform: scale(1.1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.navButton {
|
|
50
|
+
position: absolute;
|
|
51
|
+
top: 50%;
|
|
52
|
+
transform: translateY(-50%);
|
|
53
|
+
background: rgba(255, 255, 255, 0.9);
|
|
54
|
+
border: none;
|
|
55
|
+
border-radius: 50%;
|
|
56
|
+
width: 56px;
|
|
57
|
+
height: 56px;
|
|
58
|
+
display: flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
justify-content: center;
|
|
61
|
+
cursor: pointer;
|
|
62
|
+
z-index: 1001;
|
|
63
|
+
transition: all 0.2s ease;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.navButton:hover {
|
|
67
|
+
background: rgba(255, 255, 255, 1);
|
|
68
|
+
transform: translateY(-50%) scale(1.1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.navButton:disabled {
|
|
72
|
+
opacity: 0.5;
|
|
73
|
+
cursor: not-allowed;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.navButton:disabled:hover {
|
|
77
|
+
background: rgba(255, 255, 255, 0.9);
|
|
78
|
+
transform: translateY(-50%) scale(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.prevButton {
|
|
82
|
+
left: 20px;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.nextButton {
|
|
86
|
+
right: 20px;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.imageContainer {
|
|
90
|
+
flex: 1;
|
|
91
|
+
display: flex;
|
|
92
|
+
align-items: center;
|
|
93
|
+
justify-content: center;
|
|
94
|
+
max-width: 100%;
|
|
95
|
+
max-height: 100%;
|
|
96
|
+
overflow: hidden;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.modalImage {
|
|
100
|
+
max-width: 100%;
|
|
101
|
+
max-height: 100%;
|
|
102
|
+
object-fit: contain;
|
|
103
|
+
border-radius: 8px;
|
|
104
|
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.imageInfo {
|
|
108
|
+
position: absolute;
|
|
109
|
+
bottom: 20px;
|
|
110
|
+
left: 20px;
|
|
111
|
+
right: 20px;
|
|
112
|
+
background: rgba(0, 0, 0, 0.7);
|
|
113
|
+
color: white;
|
|
114
|
+
padding: 16px;
|
|
115
|
+
border-radius: 8px;
|
|
116
|
+
text-align: center;
|
|
117
|
+
max-width: 500px;
|
|
118
|
+
margin: 0 auto;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.imageTitle {
|
|
122
|
+
margin: 0 0 8px 0;
|
|
123
|
+
font-size: 18px;
|
|
124
|
+
font-weight: 600;
|
|
125
|
+
color: white;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.imageDescription {
|
|
129
|
+
margin: 0 0 8px 0;
|
|
130
|
+
font-size: 14px;
|
|
131
|
+
color: rgba(255, 255, 255, 0.9);
|
|
132
|
+
line-height: 1.4;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.imageCounter {
|
|
136
|
+
font-size: 12px;
|
|
137
|
+
color: rgba(255, 255, 255, 0.7);
|
|
138
|
+
margin: 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* Mobile responsive styles */
|
|
142
|
+
@media (max-width: 768px) {
|
|
143
|
+
.modalOverlay {
|
|
144
|
+
padding: 10px;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.closeButton {
|
|
148
|
+
top: 10px;
|
|
149
|
+
right: 10px;
|
|
150
|
+
width: 40px;
|
|
151
|
+
height: 40px;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.navButton {
|
|
155
|
+
width: 48px;
|
|
156
|
+
height: 48px;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.prevButton {
|
|
160
|
+
left: 10px;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.nextButton {
|
|
164
|
+
right: 10px;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.imageInfo {
|
|
168
|
+
bottom: 10px;
|
|
169
|
+
left: 10px;
|
|
170
|
+
right: 10px;
|
|
171
|
+
padding: 12px;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.imageTitle {
|
|
175
|
+
font-size: 16px;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.imageDescription {
|
|
179
|
+
font-size: 13px;
|
|
180
|
+
}
|
|
181
|
+
}
|
package/src/index.js
CHANGED
|
@@ -34,6 +34,7 @@ import DataGridSearch from './components/controls/DataGridSearch';
|
|
|
34
34
|
import AutoRefreshControls from './components/controls/AutoRefreshControls';
|
|
35
35
|
import AudioPlayer from './components/controls/AudioPlayer';
|
|
36
36
|
import GalleryModal from './components/modals/GalleryModal';
|
|
37
|
+
import ImageModal from './components/ImageModal';
|
|
37
38
|
import DatePickerPortal from './components/utils/DatePickerPortal';
|
|
38
39
|
export * from './components/columns/ColumnRenderers.jsx';
|
|
39
40
|
|
|
@@ -110,6 +111,7 @@ export {
|
|
|
110
111
|
Field,
|
|
111
112
|
Form,
|
|
112
113
|
GalleryModal,
|
|
114
|
+
ImageModal,
|
|
113
115
|
GenericAuth,
|
|
114
116
|
GenericClientPortal,
|
|
115
117
|
GenericDashboard,
|