@visns-studio/visns-components 5.7.0 → 5.7.2
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 +1 -4
- package/src/components/cms/DataGrid.jsx +2 -2
- package/src/components/crm/Autocomplete.jsx +161 -127
- package/src/components/crm/DataGrid.jsx +2 -2
- package/src/components/crm/Download.jsx +15 -4
- package/src/components/crm/Fetch.jsx +69 -16
- package/src/components/crm/Notification.jsx +237 -106
- package/src/components/crm/QuickAction.jsx +318 -91
- package/src/components/crm/generic/GenericDetail.jsx +43 -127
- package/src/components/crm/generic/NotificationList.jsx +76 -7
- package/src/components/crm/generic/styles/NotificationList.module.scss +90 -2
- package/src/components/crm/styles/DataGrid.module.scss +5 -0
- package/src/components/crm/styles/Notification.module.scss +313 -96
- package/src/components/crm/styles/QuickAction.module.scss +168 -46
- package/src/components/styles/global.css +5 -0
- package/src/utils/fetchUtil.js +242 -0
|
@@ -1,29 +1,33 @@
|
|
|
1
|
-
import React, { useState, useEffect, useRef } from 'react';
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
2
2
|
import { useNavigate } from 'react-router-dom';
|
|
3
3
|
import moment from 'moment';
|
|
4
4
|
import parse from 'html-react-parser';
|
|
5
5
|
import { toast } from 'react-toastify';
|
|
6
|
-
import { Bell, CircleX } from 'akar-icons';
|
|
6
|
+
import { Bell, CircleX, Check } from 'akar-icons';
|
|
7
7
|
import CustomFetch from './Fetch';
|
|
8
8
|
import styles from './styles/Notification.module.scss';
|
|
9
9
|
|
|
10
10
|
function Notification(props) {
|
|
11
|
-
let interval;
|
|
12
11
|
const navigate = useNavigate();
|
|
13
12
|
const wrapperRef = useRef(null);
|
|
14
13
|
const [items, setItems] = useState([]);
|
|
15
14
|
const [show, setShow] = useState(false);
|
|
15
|
+
const [loading, setLoading] = useState(false);
|
|
16
|
+
const [processingIds, setProcessingIds] = useState([]);
|
|
16
17
|
const { setSystemAuth } = props;
|
|
17
18
|
|
|
18
|
-
const fetchNotification = () => {
|
|
19
|
+
const fetchNotification = useCallback(() => {
|
|
20
|
+
setLoading(true);
|
|
19
21
|
CustomFetch(
|
|
20
22
|
'/ajax/user/notifications',
|
|
21
23
|
'POST',
|
|
22
24
|
{},
|
|
23
25
|
function (result) {
|
|
24
26
|
setItems(result.data);
|
|
27
|
+
setLoading(false);
|
|
25
28
|
},
|
|
26
29
|
function (error) {
|
|
30
|
+
setLoading(false);
|
|
27
31
|
if (String(error) === 'CSRF token mismatch.') {
|
|
28
32
|
window.open('/login');
|
|
29
33
|
} else {
|
|
@@ -34,163 +38,290 @@ function Notification(props) {
|
|
|
34
38
|
}
|
|
35
39
|
}
|
|
36
40
|
);
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
const handleClick = (e) => {
|
|
40
|
-
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
|
|
41
|
-
setShow(false);
|
|
42
|
-
}
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
const markAsRead = (e) => {
|
|
46
|
-
e.preventDefault();
|
|
41
|
+
}, [setSystemAuth]);
|
|
47
42
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
43
|
+
const handleClickOutside = useCallback((e) => {
|
|
44
|
+
// Check if the click is outside the notification dropdown and not on the bell icon
|
|
45
|
+
const bellButton = document.querySelector(
|
|
46
|
+
`.${styles.notificationTrigger}`
|
|
47
|
+
);
|
|
48
|
+
const isClickOnBell = bellButton && bellButton.contains(e.target);
|
|
51
49
|
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
if (
|
|
51
|
+
wrapperRef.current &&
|
|
52
|
+
!wrapperRef.current.contains(e.target) &&
|
|
53
|
+
!isClickOnBell
|
|
54
|
+
) {
|
|
55
|
+
setShow(false);
|
|
55
56
|
}
|
|
57
|
+
}, []);
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
const markAsRead = useCallback(
|
|
60
|
+
(e) => {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
e.stopPropagation();
|
|
58
63
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
formData,
|
|
63
|
-
function () {
|
|
64
|
-
fetchNotification();
|
|
65
|
-
}
|
|
66
|
-
);
|
|
67
|
-
};
|
|
64
|
+
const target = e.currentTarget;
|
|
65
|
+
const id = target.dataset.id;
|
|
66
|
+
const index = parseInt(target.dataset.index);
|
|
68
67
|
|
|
69
|
-
|
|
70
|
-
e.preventDefault();
|
|
68
|
+
if (!id || processingIds.includes(id)) return;
|
|
71
69
|
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
// Optimistic UI update
|
|
71
|
+
setProcessingIds((prev) => [...prev, id]);
|
|
72
|
+
setItems((prevItems) =>
|
|
73
|
+
prevItems.filter((_, idx) => idx !== index)
|
|
74
|
+
);
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
let formData = { id: item.id };
|
|
76
|
+
const formData = { id };
|
|
77
77
|
|
|
78
78
|
CustomFetch(
|
|
79
79
|
'/ajax/user/notification/markasread',
|
|
80
80
|
'POST',
|
|
81
81
|
formData,
|
|
82
82
|
function () {
|
|
83
|
-
|
|
83
|
+
setProcessingIds((prev) =>
|
|
84
|
+
prev.filter((item) => item !== id)
|
|
85
|
+
);
|
|
86
|
+
},
|
|
87
|
+
function (error) {
|
|
88
|
+
setProcessingIds((prev) =>
|
|
89
|
+
prev.filter((item) => item !== id)
|
|
90
|
+
);
|
|
91
|
+
toast.error(
|
|
92
|
+
String(error) || 'Failed to mark notification as read'
|
|
93
|
+
);
|
|
94
|
+
fetchNotification(); // Refresh to get correct state
|
|
84
95
|
}
|
|
85
96
|
);
|
|
86
|
-
}
|
|
87
|
-
|
|
97
|
+
},
|
|
98
|
+
[fetchNotification, processingIds]
|
|
99
|
+
);
|
|
88
100
|
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
101
|
+
const markAllAsRead = useCallback(
|
|
102
|
+
(e) => {
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
|
|
105
|
+
if (items.length === 0 || loading) return;
|
|
106
|
+
|
|
107
|
+
setLoading(true);
|
|
108
|
+
// Optimistic UI update
|
|
109
|
+
setItems([]);
|
|
110
|
+
|
|
111
|
+
const promises = items.map((item) => {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
CustomFetch(
|
|
114
|
+
'/ajax/user/notification/markasread',
|
|
115
|
+
'POST',
|
|
116
|
+
{ id: item.id },
|
|
117
|
+
resolve,
|
|
118
|
+
reject
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
Promise.all(promises)
|
|
124
|
+
.then(() => {
|
|
125
|
+
toast.success('All notifications marked as read');
|
|
126
|
+
setLoading(false);
|
|
127
|
+
})
|
|
128
|
+
.catch(() => {
|
|
129
|
+
toast.error('Failed to mark all notifications as read');
|
|
130
|
+
fetchNotification();
|
|
131
|
+
setLoading(false);
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
[items, fetchNotification, loading]
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const toggleNotification = useCallback((e) => {
|
|
138
|
+
e.stopPropagation(); // Prevent event bubbling
|
|
139
|
+
setShow((prev) => !prev);
|
|
140
|
+
}, []);
|
|
92
141
|
|
|
93
|
-
const showAllNotifications = () => {
|
|
142
|
+
const showAllNotifications = useCallback(() => {
|
|
94
143
|
navigate('/notifications');
|
|
95
144
|
setShow(false);
|
|
96
|
-
};
|
|
145
|
+
}, [navigate]);
|
|
97
146
|
|
|
98
147
|
useEffect(() => {
|
|
99
148
|
fetchNotification();
|
|
100
149
|
|
|
101
|
-
interval = setInterval(() => {
|
|
150
|
+
const interval = setInterval(() => {
|
|
102
151
|
if (!document.hidden) {
|
|
103
152
|
fetchNotification();
|
|
104
153
|
}
|
|
105
154
|
}, 60000);
|
|
106
155
|
|
|
107
|
-
document.addEventListener('mousedown',
|
|
156
|
+
document.addEventListener('mousedown', handleClickOutside, false);
|
|
108
157
|
|
|
109
158
|
return function cleanup() {
|
|
110
159
|
clearInterval(interval);
|
|
111
|
-
document.removeEventListener(
|
|
160
|
+
document.removeEventListener(
|
|
161
|
+
'mousedown',
|
|
162
|
+
handleClickOutside,
|
|
163
|
+
false
|
|
164
|
+
);
|
|
112
165
|
};
|
|
113
|
-
}, []);
|
|
166
|
+
}, [fetchNotification, handleClickOutside]);
|
|
167
|
+
|
|
168
|
+
const formatTimeAgo = (date) => {
|
|
169
|
+
const now = moment();
|
|
170
|
+
const notificationTime = moment(date);
|
|
171
|
+
const diffHours = now.diff(notificationTime, 'hours');
|
|
172
|
+
|
|
173
|
+
if (diffHours < 24) {
|
|
174
|
+
return notificationTime.fromNow(); // e.g. "2 hours ago"
|
|
175
|
+
} else {
|
|
176
|
+
return notificationTime.format('DD MMM, HH:mm'); // e.g. "15 Jun, 14:30"
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const hasNotifications = items.length > 0;
|
|
114
181
|
|
|
115
182
|
return (
|
|
116
|
-
|
|
117
|
-
<
|
|
118
|
-
|
|
183
|
+
<div className={styles.notificationContainer}>
|
|
184
|
+
<button
|
|
185
|
+
className={styles.notificationTrigger}
|
|
119
186
|
onClick={toggleNotification}
|
|
120
187
|
data-tooltip-id="system-tooltip"
|
|
121
|
-
data-tooltip-content="
|
|
188
|
+
data-tooltip-content="Notifications"
|
|
189
|
+
aria-label="Notifications"
|
|
122
190
|
>
|
|
123
191
|
<Bell
|
|
124
|
-
size={
|
|
192
|
+
size={20}
|
|
125
193
|
strokeWidth={2}
|
|
126
|
-
className={
|
|
127
|
-
items.length > 0
|
|
128
|
-
? styles.noti
|
|
129
|
-
: styles['cis-bell-exclamation']
|
|
130
|
-
}
|
|
194
|
+
className={hasNotifications ? styles.noti : ''}
|
|
131
195
|
/>
|
|
132
|
-
|
|
196
|
+
{hasNotifications && (
|
|
197
|
+
<span className={styles.badge}>{items.length}</span>
|
|
198
|
+
)}
|
|
199
|
+
</button>
|
|
133
200
|
|
|
134
201
|
{show && (
|
|
135
|
-
<div ref={wrapperRef}>
|
|
136
|
-
{
|
|
137
|
-
<div className={styles.
|
|
138
|
-
<
|
|
139
|
-
|
|
202
|
+
<div className={styles.dropdownWrapper} ref={wrapperRef}>
|
|
203
|
+
<div className={styles.notibox}>
|
|
204
|
+
<div className={styles.notiboxHeader}>
|
|
205
|
+
<h3>Notifications</h3>
|
|
206
|
+
{hasNotifications && (
|
|
207
|
+
<button
|
|
208
|
+
className={styles.markAllReadBtn}
|
|
209
|
+
onClick={markAllAsRead}
|
|
210
|
+
disabled={loading}
|
|
211
|
+
>
|
|
212
|
+
<Check size={16} />
|
|
213
|
+
<span>Mark all as read</span>
|
|
214
|
+
</button>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<div className={styles.notiscrollwrap}>
|
|
219
|
+
{loading && items.length === 0 ? (
|
|
220
|
+
<div className={styles.loadingState}>
|
|
221
|
+
<div
|
|
222
|
+
className={styles.loadingSpinner}
|
|
223
|
+
></div>
|
|
224
|
+
<p>Loading notifications...</p>
|
|
225
|
+
</div>
|
|
226
|
+
) : hasNotifications ? (
|
|
227
|
+
items.map((item, index) => (
|
|
140
228
|
<div
|
|
141
|
-
className={styles
|
|
229
|
+
className={styles.notiboxItem}
|
|
142
230
|
key={item.id}
|
|
143
231
|
>
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
232
|
+
<div className={styles.notiContent}>
|
|
233
|
+
<div className={styles.notiIcon}>
|
|
234
|
+
<Bell
|
|
235
|
+
size={16}
|
|
236
|
+
strokeWidth={2}
|
|
237
|
+
/>
|
|
238
|
+
</div>
|
|
239
|
+
<div className={styles.notiMessage}>
|
|
240
|
+
<div
|
|
241
|
+
className={styles.notiText}
|
|
242
|
+
>
|
|
243
|
+
{parse(item.data.label)}
|
|
244
|
+
</div>
|
|
245
|
+
<div
|
|
246
|
+
className={styles.notiTime}
|
|
247
|
+
>
|
|
248
|
+
{formatTimeAgo(
|
|
249
|
+
item.created_at
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
<button
|
|
254
|
+
className={styles.notiDismiss}
|
|
255
|
+
onClick={markAsRead}
|
|
162
256
|
data-id={item.id}
|
|
163
257
|
data-index={index}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
258
|
+
aria-label="Dismiss notification"
|
|
259
|
+
>
|
|
260
|
+
<CircleX size={18} />
|
|
261
|
+
</button>
|
|
262
|
+
</div>
|
|
167
263
|
</div>
|
|
168
|
-
))
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
</div>
|
|
264
|
+
))
|
|
265
|
+
) : (
|
|
266
|
+
<div className={styles.emptyState}>
|
|
267
|
+
<div className={styles.emptyIcon}>
|
|
268
|
+
<Bell size={24} strokeWidth={2} />
|
|
269
|
+
</div>
|
|
270
|
+
<p>No new notifications</p>
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
178
273
|
</div>
|
|
179
|
-
|
|
180
|
-
<div className={styles.
|
|
181
|
-
<div className={styles
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
274
|
+
|
|
275
|
+
<div className={styles.notiboxActions}>
|
|
276
|
+
<div className={styles.buttonWrapper}>
|
|
277
|
+
<button
|
|
278
|
+
className={styles.viewAllBtn}
|
|
279
|
+
onClick={showAllNotifications}
|
|
280
|
+
aria-label="View all notifications"
|
|
281
|
+
type="button"
|
|
282
|
+
style={{
|
|
283
|
+
backgroundColor: 'var(--primary-color)',
|
|
284
|
+
color: 'var(--tertiary-color, white)',
|
|
285
|
+
padding: '10px 20px',
|
|
286
|
+
borderRadius: 'var(--br, 6px)',
|
|
287
|
+
border: 'none',
|
|
288
|
+
width: 'auto',
|
|
289
|
+
minWidth: '200px',
|
|
290
|
+
textAlign: 'center',
|
|
291
|
+
fontWeight: '500',
|
|
292
|
+
fontSize: '14px',
|
|
293
|
+
boxShadow:
|
|
294
|
+
'0 2px 4px rgba(0, 0, 0, 0.1)',
|
|
295
|
+
cursor: 'pointer',
|
|
296
|
+
transition: 'all 0.2s ease',
|
|
297
|
+
}}
|
|
298
|
+
onMouseOver={(e) =>
|
|
299
|
+
(e.target.style.backgroundColor =
|
|
300
|
+
'var(--secondary-color)')
|
|
301
|
+
}
|
|
302
|
+
onMouseOut={(e) =>
|
|
303
|
+
(e.target.style.backgroundColor =
|
|
304
|
+
'var(--primary-color)')
|
|
305
|
+
}
|
|
306
|
+
onFocus={(e) => {
|
|
307
|
+
e.target.style.backgroundColor =
|
|
308
|
+
'var(--primary-color)';
|
|
309
|
+
e.target.style.boxShadow =
|
|
310
|
+
'0 0 0 3px rgba(var(--primary-color-rgb, 0, 86, 179), 0.4)';
|
|
311
|
+
}}
|
|
312
|
+
onBlur={(e) => {
|
|
313
|
+
e.target.style.boxShadow =
|
|
314
|
+
'0 2px 4px rgba(0, 0, 0, 0.1)';
|
|
315
|
+
}}
|
|
316
|
+
>
|
|
317
|
+
View all notifications
|
|
187
318
|
</button>
|
|
188
319
|
</div>
|
|
189
320
|
</div>
|
|
190
|
-
|
|
321
|
+
</div>
|
|
191
322
|
</div>
|
|
192
323
|
)}
|
|
193
|
-
|
|
324
|
+
</div>
|
|
194
325
|
);
|
|
195
326
|
}
|
|
196
327
|
|