@visns-studio/visns-components 5.10.11 → 5.11.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.
Files changed (55) hide show
  1. package/README.md +85 -0
  2. package/package.json +7 -7
  3. package/src/components/cms/DataGrid.jsx +8 -8
  4. package/src/components/cms/DropZone.jsx +1 -1
  5. package/src/components/cms/Form.jsx +1 -1
  6. package/src/components/cms/Gallery.jsx +1 -1
  7. package/src/components/cms/sorting/Item.jsx +1 -1
  8. package/src/components/crm/Autocomplete.jsx +3 -3
  9. package/src/components/crm/Breadcrumb.jsx +4 -4
  10. package/src/components/crm/BusinessCardOcr.jsx +681 -95
  11. package/src/components/crm/DataGrid.jsx +34 -32
  12. package/src/components/crm/Field.jsx +12 -12
  13. package/src/components/crm/Form.jsx +2 -2
  14. package/src/components/crm/Navigation.jsx +11 -11
  15. package/src/components/crm/Notification.jsx +2 -2
  16. package/src/components/crm/QuickAction.jsx +3 -3
  17. package/src/components/crm/SectionHeader.jsx +1 -1
  18. package/src/components/crm/SwitchAccount.jsx +4 -4
  19. package/src/components/crm/auth/ClientLogin.jsx +2 -2
  20. package/src/components/crm/auth/ClientOTPVerify.jsx +2 -2
  21. package/src/components/crm/auth/Login.jsx +3 -3
  22. package/src/components/crm/auth/Reset.jsx +2 -2
  23. package/src/components/crm/auth/TwoFactorAuth.jsx +2 -2
  24. package/src/components/crm/columns/ColumnRenderers.jsx +320 -259
  25. package/src/components/crm/controls/DataGridSearch.jsx +5 -5
  26. package/src/components/crm/generic/AlternativeActionModal.jsx +100 -0
  27. package/src/components/crm/generic/ContactSelectorModal.jsx +423 -0
  28. package/src/components/crm/generic/DatePickerDialog.jsx +302 -0
  29. package/src/components/crm/generic/DateRangeSelectorModal.jsx +181 -0
  30. package/src/components/crm/generic/GenericClientPortal.jsx +1 -1
  31. package/src/components/crm/generic/GenericDashboard.jsx +4 -4
  32. package/src/components/crm/generic/GenericDetail.jsx +6 -6
  33. package/src/components/crm/generic/GenericDynamic.jsx +3 -3
  34. package/src/components/crm/generic/GenericEditableTable.jsx +4 -4
  35. package/src/components/crm/generic/GenericFormBuilder.jsx +6 -6
  36. package/src/components/crm/generic/GenericGrid.jsx +2888 -0
  37. package/src/components/crm/generic/GenericIndex.jsx +5 -1255
  38. package/src/components/crm/generic/GenericReport.jsx +966 -646
  39. package/src/components/crm/generic/GenericReportForm.jsx +194 -0
  40. package/src/components/crm/generic/NotificationList.jsx +1 -1
  41. package/src/components/crm/generic/ReasonCollectorModal.jsx +194 -0
  42. package/src/components/crm/generic/SortableQuoteItems.jsx +3 -3
  43. package/src/components/crm/generic/shared/formatters.js +231 -0
  44. package/src/components/crm/generic/shared/gridUtils.js +194 -0
  45. package/src/components/crm/generic/styles/AlternativeActionModal.css +127 -0
  46. package/src/components/crm/generic/styles/ContactSelectorModal.css +367 -0
  47. package/src/components/crm/generic/styles/DatePickerDialog.css +84 -0
  48. package/src/components/crm/generic/styles/DateRangeSelectorModal.css +115 -0
  49. package/src/components/crm/generic/styles/GenericIndex.module.scss +382 -0
  50. package/src/components/crm/generic/styles/GenericReport.module.scss +96 -0
  51. package/src/components/crm/generic/styles/ReasonCollectorModal.css +217 -0
  52. package/src/components/crm/modals/GalleryModal.jsx +2 -2
  53. package/src/components/crm/sorting/Item.jsx +3 -3
  54. package/src/components/crm/styles/BusinessCardOcr.module.scss +197 -4
  55. package/src/index.js +4 -0
@@ -0,0 +1,2888 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { toast } from 'react-toastify';
3
+ import parse from 'html-react-parser';
4
+ import moment from 'moment';
5
+ import numeral from 'numeral';
6
+ import { Calendar, X, Mail, FileText, Users } from 'lucide-react';
7
+ import { Tooltip } from 'react-tooltip';
8
+
9
+ import CustomFetch from '../Fetch';
10
+ import MultiSelect from '../MultiSelect';
11
+ import { showDatePickerDialog } from './DatePickerDialog';
12
+ import { showContactSelectorModal } from './ContactSelectorModal';
13
+ import { showDateRangeSelectorModal } from './DateRangeSelectorModal';
14
+ import { showAlternativeActionModal } from './AlternativeActionModal';
15
+ import { showReasonCollectorModal } from './ReasonCollectorModal';
16
+ import styles from './styles/GenericIndex.module.scss';
17
+
18
+ // ContactTooltip component for enhanced contact display
19
+ const ContactTooltip = ({ contacts, children }) => {
20
+ const [isVisible, setIsVisible] = useState(false);
21
+ const [position, setPosition] = useState({ x: 0, y: 0 });
22
+ const tooltipRef = useRef(null);
23
+ const triggerRef = useRef(null);
24
+
25
+ const handleMouseEnter = (e) => {
26
+ if (!contacts || contacts.length === 0) return;
27
+
28
+ const rect = e.currentTarget.getBoundingClientRect();
29
+ setPosition({
30
+ x: rect.left + rect.width / 2,
31
+ y: rect.top - 10,
32
+ });
33
+ setIsVisible(true);
34
+ };
35
+
36
+ const handleMouseLeave = () => {
37
+ setIsVisible(false);
38
+ };
39
+
40
+ const formatContact = (contact) => {
41
+ const name = contact.name || 'Unknown Contact';
42
+ const email = contact.email || '';
43
+ const type =
44
+ contact.type === 'contact' ? 'Existing Contact' : 'Manual Entry';
45
+
46
+ return { name, email, type };
47
+ };
48
+
49
+ return (
50
+ <>
51
+ <div
52
+ ref={triggerRef}
53
+ onMouseEnter={handleMouseEnter}
54
+ onMouseLeave={handleMouseLeave}
55
+ style={{ position: 'relative' }}
56
+ >
57
+ {children}
58
+ </div>
59
+
60
+ {isVisible && contacts && contacts.length > 0 && (
61
+ <div
62
+ ref={tooltipRef}
63
+ className={styles.contactTooltip}
64
+ style={{
65
+ position: 'fixed',
66
+ left: `${position.x}px`,
67
+ top: `${position.y}px`,
68
+ transform: 'translateX(-50%) translateY(-100%)',
69
+ zIndex: 9999,
70
+ }}
71
+ >
72
+ <div className={styles.contactTooltipHeader}>
73
+ <strong>
74
+ {contacts.length} Contact
75
+ {contacts.length !== 1 ? 's' : ''}
76
+ </strong>
77
+ </div>
78
+ <div className={styles.contactTooltipBody}>
79
+ {contacts.map((contact, index) => {
80
+ const { name, email, type } =
81
+ formatContact(contact);
82
+ return (
83
+ <div
84
+ key={index}
85
+ className={styles.contactTooltipItem}
86
+ >
87
+ <div className={styles.contactName}>
88
+ {name}
89
+ </div>
90
+ {email && (
91
+ <div className={styles.contactEmail}>
92
+ {email}
93
+ </div>
94
+ )}
95
+ <div className={styles.contactType}>
96
+ {type}
97
+ </div>
98
+ </div>
99
+ );
100
+ })}
101
+ </div>
102
+ <div className={styles.contactTooltipArrow}></div>
103
+ </div>
104
+ )}
105
+ </>
106
+ );
107
+ };
108
+
109
+ const GenericGrid = ({
110
+ config,
111
+ userProfile,
112
+ data,
113
+ onDataChange,
114
+ onRowClick,
115
+ }) => {
116
+ const [gridData, setGridData] = useState([]);
117
+ const [sortedData, setSortedData] = useState([]);
118
+ const [, setFilteredData] = useState([]);
119
+ const [filterValues, setFilterValues] = useState({});
120
+ const [settingsFilterValues, setSettingsFilterValues] = useState({});
121
+ const [filterOptions, setFilterOptions] = useState({});
122
+ const [loading, setLoading] = useState(false);
123
+ const [filtersCollapsed, setFiltersCollapsed] = useState(false);
124
+ const [sortConfig, setSortConfig] = useState({
125
+ key: null,
126
+ direction: 'asc',
127
+ });
128
+ const prevFetchParamsRef = useRef(null);
129
+
130
+ // Extract data and settings from config using useMemo to prevent unnecessary re-renders
131
+ const {
132
+ settings,
133
+ columns = [],
134
+ data: configData = [],
135
+ } = useMemo(
136
+ () => ({
137
+ settings: config.settings || {},
138
+ columns: config.columns || [],
139
+ data: config.data || data || [],
140
+ }),
141
+ [config.settings, config.columns, config.data, data]
142
+ );
143
+
144
+ // Create table headers from columns
145
+ const headers = useMemo(
146
+ () => columns.map((col) => col.header || col.name || ''),
147
+ [columns]
148
+ );
149
+
150
+ // Format cell content based on column type or value type
151
+ const formatCellContent = (value, column, row = null) => {
152
+ // Check for quarter columns with status FIRST (before null checks)
153
+ if (
154
+ column.key &&
155
+ (column.key.startsWith('quarter_') ||
156
+ column.key === 'anniversary') &&
157
+ column.onClick?.type === 'date_with_alternatives' &&
158
+ row
159
+ ) {
160
+ return formatQuarterWithStatus(value, column, row);
161
+ }
162
+
163
+ if (value === null || value === undefined) return '';
164
+ if (value === 0) return '0'; // Explicitly handle zero values
165
+ if (value === '') return '';
166
+
167
+ // Check if this is a date field with contacts (survey workflow)
168
+ if (
169
+ column.type === 'date' &&
170
+ column.onClick?.type === 'date_with_contacts' &&
171
+ row
172
+ ) {
173
+ return formatDateWithContacts(value, column, row);
174
+ }
175
+
176
+ // Check if this is a date range field (agreement period)
177
+ if (column.type === 'daterange' && row) {
178
+ return formatDateRange(value, column, row);
179
+ }
180
+
181
+ // Check if this is a status field with reasons
182
+ if (column.type === 'status' && row) {
183
+ return formatStatusWithReason(value, column, row);
184
+ }
185
+
186
+ // Try to infer the type if not provided
187
+ const valueType = column.type || typeof value;
188
+
189
+ switch (valueType) {
190
+ case 'date':
191
+ return moment(value).isValid()
192
+ ? moment(value).format(column.format || 'DD/MM/YYYY')
193
+ : value;
194
+ case 'datetime':
195
+ return moment(value).isValid()
196
+ ? moment(value).format(column.format || 'DD/MM/YYYY HH:mm')
197
+ : value;
198
+ case 'currency':
199
+ return numeral(value).format(column.format || '$0,0.00');
200
+ case 'number':
201
+ // Check if it's actually a number before formatting
202
+ return !isNaN(parseFloat(value)) && isFinite(value)
203
+ ? numeral(value).format(column.format || '0,0')
204
+ : value;
205
+ case 'boolean':
206
+ if (
207
+ value === 1 ||
208
+ value === true ||
209
+ value === 'true' ||
210
+ value === 'yes' ||
211
+ value === 'Yes'
212
+ ) {
213
+ return 'Yes';
214
+ }
215
+ if (
216
+ value === 0 ||
217
+ value === false ||
218
+ value === 'false' ||
219
+ value === 'no' ||
220
+ value === 'No'
221
+ ) {
222
+ return 'No';
223
+ }
224
+ return value;
225
+ case 'year':
226
+ // Special handling for year values
227
+ return value.toString();
228
+ case 'quarter':
229
+ // Special handling for quarter values (Q1, Q2, etc.)
230
+ if (value && value.toString().startsWith('Q')) {
231
+ return value;
232
+ }
233
+ return value;
234
+ case 'html':
235
+ // Explicitly parse HTML content
236
+ return parse(String(value));
237
+ case 'richtext':
238
+ // Parse rich text content
239
+ return parse(String(value));
240
+ default:
241
+ // For text values, check if it contains HTML tags
242
+ if (
243
+ typeof value === 'string' &&
244
+ (value.includes('<br') ||
245
+ value.includes('<p>') ||
246
+ value.includes('<div') ||
247
+ value.includes('<span'))
248
+ ) {
249
+ // Ensure <br /> tags are properly handled
250
+ const processedValue = String(value)
251
+ .replace(/<br \/>/g, '<br>')
252
+ .replace(/<br\/>/g, '<br>');
253
+ return parse(processedValue);
254
+ }
255
+ return value;
256
+ }
257
+ };
258
+
259
+ // Format date field with associated contacts
260
+ const formatDateWithContacts = (dateValue, column, row) => {
261
+ const contactsField = column.key + '_contacts';
262
+ const contacts = row[contactsField];
263
+
264
+ // Format the date
265
+ const formattedDate = moment(dateValue).isValid()
266
+ ? moment(dateValue).format(column.format || 'DD/MM/YYYY')
267
+ : dateValue;
268
+
269
+ // If no contacts, just return the date
270
+ if (!contacts || !Array.isArray(contacts) || contacts.length === 0) {
271
+ return formattedDate;
272
+ }
273
+
274
+ // Create JSX element to display date + contacts with enhanced tooltip
275
+ return (
276
+ <div className={styles.dateWithContacts}>
277
+ <div className={styles.dateValue}>{formattedDate}</div>
278
+ <ContactTooltip contacts={contacts}>
279
+ <div className={styles.contactsSummary}>
280
+ 📧 {contacts.length} contact
281
+ {contacts.length !== 1 ? 's' : ''}
282
+ </div>
283
+ </ContactTooltip>
284
+ </div>
285
+ );
286
+ };
287
+
288
+ // Format date range field (start and end dates)
289
+ const formatDateRange = (_, column, row) => {
290
+ // For agreement period, use the fetched agreement dates
291
+ const startField =
292
+ column.key === 'agreement_period'
293
+ ? 'agreement_start_date'
294
+ : column.onClick?.startField || 'start_date';
295
+ const endField =
296
+ column.key === 'agreement_period'
297
+ ? 'agreement_end_date'
298
+ : column.onClick?.endField || 'end_date';
299
+ const contactsField = column.key + '_contacts';
300
+
301
+ const startDate = row[startField];
302
+ const endDate = row[endField];
303
+ const contacts = row[contactsField];
304
+
305
+ // Format dates
306
+ const formattedStart =
307
+ startDate && moment(startDate).isValid()
308
+ ? moment(startDate).format(column.format || 'DD/MM/YYYY')
309
+ : '';
310
+ const formattedEnd =
311
+ endDate && moment(endDate).isValid()
312
+ ? moment(endDate).format(column.format || 'DD/MM/YYYY')
313
+ : '';
314
+
315
+ let dateDisplay = '';
316
+ if (formattedStart && formattedEnd) {
317
+ dateDisplay = `${formattedStart} - ${formattedEnd}`;
318
+ } else if (formattedStart) {
319
+ dateDisplay = `From ${formattedStart}`;
320
+ } else if (formattedEnd) {
321
+ dateDisplay = `Until ${formattedEnd}`;
322
+ } else {
323
+ dateDisplay = 'Not set';
324
+ }
325
+
326
+ // If no contacts, just return the date range
327
+ if (!contacts || !Array.isArray(contacts) || contacts.length === 0) {
328
+ return dateDisplay;
329
+ }
330
+
331
+ // Create JSX element to display date range + contacts
332
+ return (
333
+ <div className={styles.dateWithContacts}>
334
+ <div className={styles.dateValue}>{dateDisplay}</div>
335
+ <ContactTooltip contacts={contacts}>
336
+ <div className={styles.contactsSummary}>
337
+ 📧 {contacts.length} contact
338
+ {contacts.length !== 1 ? 's' : ''}
339
+ </div>
340
+ </ContactTooltip>
341
+ </div>
342
+ );
343
+ };
344
+
345
+ // Format status field with reason
346
+ const formatStatusWithReason = (statusValue, column, row) => {
347
+ const reasonField = column.key + '_reason';
348
+ const reason = row[reasonField];
349
+
350
+ // Status display mapping
351
+ const statusMap = {
352
+ sent: { label: 'Sent', icon: '✅', color: '#10b981' },
353
+ not_sent: { label: 'Not Sent', icon: '❌', color: '#ef4444' },
354
+ pending: { label: 'Pending', icon: '⏳', color: '#f59e0b' },
355
+ };
356
+
357
+ const status = statusMap[statusValue] || {
358
+ label: statusValue || 'Unknown',
359
+ icon: '❓',
360
+ color: '#6b7280',
361
+ };
362
+
363
+ return (
364
+ <div className={styles.statusWithReason}>
365
+ <div
366
+ className={styles.statusValue}
367
+ style={{ color: status.color }}
368
+ >
369
+ {status.icon} {status.label}
370
+ </div>
371
+ {reason && (
372
+ <div className={styles.statusReason} title={reason}>
373
+ 📝{' '}
374
+ {reason.length > 30
375
+ ? reason.substring(0, 30) + '...'
376
+ : reason}
377
+ </div>
378
+ )}
379
+ </div>
380
+ );
381
+ };
382
+
383
+ // Helper function to check if a quarter cell should be clickable
384
+ const isQuarterCellClickable = (column, row) => {
385
+ // Check if this is a quarter column with alternatives
386
+ if (
387
+ !column.key ||
388
+ (!column.key.startsWith('quarter_') &&
389
+ column.key !== 'anniversary') ||
390
+ column.onClick?.type !== 'date_with_alternatives'
391
+ ) {
392
+ return true; // Not a quarter column, use default logic
393
+ }
394
+
395
+ const statusField = column.key + '_status';
396
+ const status = row[statusField];
397
+
398
+ // Not clickable if status is 'not_sent'
399
+ return status !== 'not_sent';
400
+ };
401
+
402
+ // Helper function to check if a quarter cell has content (for display purposes)
403
+ const quarterCellHasContent = (column, row) => {
404
+ if (
405
+ !column.key ||
406
+ (!column.key.startsWith('quarter_') &&
407
+ column.key !== 'anniversary') ||
408
+ column.onClick?.type !== 'date_with_alternatives'
409
+ ) {
410
+ return !!row[column.key]; // Not a quarter column, use default logic
411
+ }
412
+
413
+ const statusField = column.key + '_status';
414
+ const status = row[statusField];
415
+
416
+ // Has content if there's a date value OR if status is not_sent
417
+ return !!row[column.key] || status === 'not_sent';
418
+ };
419
+
420
+ // Format quarter columns with status, contacts, and reasons with react-tooltip
421
+ const formatQuarterWithStatus = (value, column, row) => {
422
+ const statusField = column.key + '_status';
423
+ const contactsField = column.key + '_contacts';
424
+ const reasonField = column.key + '_reason';
425
+
426
+ const status = row[statusField] || 'pending';
427
+ const contacts = row[contactsField] || [];
428
+ const reason = row[reasonField];
429
+
430
+ console.log('Quarter formatting called for:', column.key, {
431
+ status,
432
+ hasValue: !!value,
433
+ hasReason: !!reason,
434
+ contactCount: contacts.length,
435
+ 'will show not_sent': status === 'not_sent',
436
+ 'will show sent': status === 'sent' && value,
437
+ 'will show legacy':
438
+ value && status !== 'sent' && status !== 'not_sent',
439
+ });
440
+
441
+ // Generate unique ID for tooltip
442
+ const tooltipId = `quarter-tooltip-${column.key}-${row.id}`;
443
+
444
+ // If status is 'sent' and we have a date
445
+ if (status === 'sent' && value) {
446
+ const formattedDate = moment(value).isValid()
447
+ ? moment(value).format('DD/MM/YYYY')
448
+ : value;
449
+
450
+ const contactCount = Array.isArray(contacts) ? contacts.length : 0;
451
+
452
+ // Build rich tooltip content
453
+ const tooltipContent = (
454
+ <div style={{ padding: '8px', maxWidth: '300px' }}>
455
+ <div
456
+ style={{
457
+ display: 'flex',
458
+ alignItems: 'center',
459
+ marginBottom: '8px',
460
+ color: '#059669',
461
+ fontWeight: 'bold',
462
+ }}
463
+ >
464
+ <Calendar size={16} style={{ marginRight: '6px' }} />
465
+ Sent on {formattedDate}
466
+ </div>
467
+ {contactCount > 0 && (
468
+ <div>
469
+ <div
470
+ style={{
471
+ display: 'flex',
472
+ alignItems: 'center',
473
+ marginBottom: '6px',
474
+ color: '#1d4ed8',
475
+ fontWeight: '500',
476
+ }}
477
+ >
478
+ <Users
479
+ size={14}
480
+ style={{ marginRight: '6px' }}
481
+ />
482
+ {contactCount} Contact
483
+ {contactCount !== 1 ? 's' : ''}
484
+ </div>
485
+ <div style={{ fontSize: '12px', color: '#6b7280' }}>
486
+ {contacts.map((contact, index) => (
487
+ <div
488
+ key={index}
489
+ style={{
490
+ display: 'flex',
491
+ alignItems: 'center',
492
+ marginBottom: '4px',
493
+ }}
494
+ >
495
+ <Mail
496
+ size={12}
497
+ style={{
498
+ marginRight: '6px',
499
+ color: '#9ca3af',
500
+ }}
501
+ />
502
+ <span>
503
+ {contact.name || 'Unknown'}
504
+ {contact.email && (
505
+ <span
506
+ style={{ color: '#9ca3af' }}
507
+ >
508
+ {' '}
509
+ ({contact.email})
510
+ </span>
511
+ )}
512
+ {contact.notes && (
513
+ <div
514
+ style={{
515
+ fontSize: '11px',
516
+ fontStyle: 'italic',
517
+ marginTop: '2px',
518
+ }}
519
+ >
520
+ {contact.notes}
521
+ </div>
522
+ )}
523
+ </span>
524
+ </div>
525
+ ))}
526
+ </div>
527
+ </div>
528
+ )}
529
+ </div>
530
+ );
531
+
532
+ return (
533
+ <>
534
+ <span
535
+ data-tooltip-id={tooltipId}
536
+ style={{
537
+ color: '#059669',
538
+ fontWeight: '500',
539
+ cursor: 'help',
540
+ display: 'inline-flex',
541
+ alignItems: 'center',
542
+ gap: '4px',
543
+ }}
544
+ className="quarter-cell-with-tooltip"
545
+ >
546
+ <Calendar size={14} />
547
+ {formattedDate}
548
+ {contactCount > 0 && (
549
+ <span
550
+ style={{ color: '#1d4ed8', marginLeft: '4px' }}
551
+ >
552
+ ({contactCount})
553
+ </span>
554
+ )}
555
+ </span>
556
+ <Tooltip
557
+ id={tooltipId}
558
+ place="top"
559
+ style={{
560
+ backgroundColor: '#fff',
561
+ color: '#000',
562
+ border: '1px solid #e5e7eb',
563
+ borderRadius: '8px',
564
+ boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
565
+ }}
566
+ >
567
+ {tooltipContent}
568
+ </Tooltip>
569
+ </>
570
+ );
571
+ }
572
+
573
+ // If status is 'not_sent' (with or without reason)
574
+ if (status === 'not_sent') {
575
+ const tooltipContent = (
576
+ <div style={{ padding: '8px', maxWidth: '250px' }}>
577
+ <div
578
+ style={{
579
+ display: 'flex',
580
+ alignItems: 'center',
581
+ marginBottom: '8px',
582
+ color: '#dc2626',
583
+ fontWeight: 'bold',
584
+ }}
585
+ >
586
+ <X size={16} style={{ marginRight: '6px' }} />
587
+ Not Sent
588
+ </div>
589
+ {reason && (
590
+ <div
591
+ style={{
592
+ display: 'flex',
593
+ alignItems: 'flex-start',
594
+ color: '#6b7280',
595
+ }}
596
+ >
597
+ <FileText
598
+ size={14}
599
+ style={{
600
+ marginRight: '6px',
601
+ marginTop: '2px',
602
+ flexShrink: 0,
603
+ }}
604
+ />
605
+ <span style={{ fontSize: '13px' }}>{reason}</span>
606
+ </div>
607
+ )}
608
+ </div>
609
+ );
610
+
611
+ return (
612
+ <>
613
+ <span
614
+ data-tooltip-id={tooltipId}
615
+ style={{
616
+ color: '#dc2626',
617
+ fontWeight: '500',
618
+ cursor: 'help',
619
+ display: 'inline-flex',
620
+ alignItems: 'center',
621
+ gap: '4px',
622
+ }}
623
+ className="quarter-cell-with-tooltip"
624
+ >
625
+ <X size={14} />
626
+ Not Sent
627
+ </span>
628
+ <Tooltip
629
+ id={tooltipId}
630
+ place="top"
631
+ style={{
632
+ backgroundColor: '#fff',
633
+ color: '#000',
634
+ border: '1px solid #e5e7eb',
635
+ borderRadius: '8px',
636
+ boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
637
+ }}
638
+ >
639
+ {tooltipContent}
640
+ </Tooltip>
641
+ </>
642
+ );
643
+ }
644
+
645
+ // If we have a date but no clear status (legacy data)
646
+ if (value) {
647
+ const formattedDate = moment(value).isValid()
648
+ ? moment(value).format('DD/MM/YYYY')
649
+ : value;
650
+
651
+ const contactCount = Array.isArray(contacts) ? contacts.length : 0;
652
+
653
+ const tooltipContent = (
654
+ <div style={{ padding: '8px', maxWidth: '300px' }}>
655
+ <div
656
+ style={{
657
+ display: 'flex',
658
+ alignItems: 'center',
659
+ marginBottom: '8px',
660
+ color: '#374151',
661
+ fontWeight: 'bold',
662
+ }}
663
+ >
664
+ <Calendar size={16} style={{ marginRight: '6px' }} />
665
+ {formattedDate}
666
+ </div>
667
+ {contactCount > 0 && (
668
+ <div>
669
+ <div
670
+ style={{
671
+ display: 'flex',
672
+ alignItems: 'center',
673
+ marginBottom: '6px',
674
+ color: '#1d4ed8',
675
+ fontWeight: '500',
676
+ }}
677
+ >
678
+ <Users
679
+ size={14}
680
+ style={{ marginRight: '6px' }}
681
+ />
682
+ {contactCount} Contact
683
+ {contactCount !== 1 ? 's' : ''}
684
+ </div>
685
+ <div style={{ fontSize: '12px', color: '#6b7280' }}>
686
+ {contacts.map((contact, index) => (
687
+ <div
688
+ key={index}
689
+ style={{
690
+ display: 'flex',
691
+ alignItems: 'center',
692
+ marginBottom: '4px',
693
+ }}
694
+ >
695
+ <Mail
696
+ size={12}
697
+ style={{
698
+ marginRight: '6px',
699
+ color: '#9ca3af',
700
+ }}
701
+ />
702
+ <span>
703
+ {contact.name || 'Unknown'}
704
+ {contact.email && (
705
+ <span
706
+ style={{ color: '#9ca3af' }}
707
+ >
708
+ {' '}
709
+ ({contact.email})
710
+ </span>
711
+ )}
712
+ {contact.notes && (
713
+ <div
714
+ style={{
715
+ fontSize: '11px',
716
+ fontStyle: 'italic',
717
+ marginTop: '2px',
718
+ }}
719
+ >
720
+ {contact.notes}
721
+ </div>
722
+ )}
723
+ </span>
724
+ </div>
725
+ ))}
726
+ </div>
727
+ </div>
728
+ )}
729
+ </div>
730
+ );
731
+
732
+ return (
733
+ <>
734
+ <span
735
+ data-tooltip-id={tooltipId}
736
+ style={{
737
+ color: '#374151',
738
+ cursor: 'help',
739
+ display: 'inline-flex',
740
+ alignItems: 'center',
741
+ gap: '4px',
742
+ }}
743
+ className="quarter-cell-with-tooltip"
744
+ >
745
+ <Calendar size={14} />
746
+ {formattedDate}
747
+ {contactCount > 0 && (
748
+ <span
749
+ style={{ color: '#1d4ed8', marginLeft: '4px' }}
750
+ >
751
+ ({contactCount})
752
+ </span>
753
+ )}
754
+ </span>
755
+ <Tooltip
756
+ id={tooltipId}
757
+ place="top"
758
+ style={{
759
+ backgroundColor: '#fff',
760
+ color: '#000',
761
+ border: '1px solid #e5e7eb',
762
+ borderRadius: '8px',
763
+ boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
764
+ }}
765
+ >
766
+ {tooltipContent}
767
+ </Tooltip>
768
+ </>
769
+ );
770
+ }
771
+
772
+ // Default case - return empty or pending
773
+ return '';
774
+ };
775
+
776
+ // State for headers from API response
777
+ const [gridHeaders, setGridHeaders] = useState([]);
778
+
779
+ // Function to sort data
780
+ const sortData = useCallback(
781
+ (data, key, direction) => {
782
+ if (!key) return [...data];
783
+
784
+ return [...data].sort((a, b) => {
785
+ // Find the header config for this key to determine type
786
+ const headerConfig = gridHeaders.find((h) => h.key === key);
787
+ const type = headerConfig?.type || 'text';
788
+
789
+ // Get values, handling null/undefined
790
+ let aValue =
791
+ a[key] === null || a[key] === undefined ? '' : a[key];
792
+ let bValue =
793
+ b[key] === null || b[key] === undefined ? '' : b[key];
794
+
795
+ // Sort based on type
796
+ switch (type) {
797
+ case 'number':
798
+ case 'year':
799
+ aValue = parseFloat(aValue) || 0;
800
+ bValue = parseFloat(bValue) || 0;
801
+ break;
802
+ case 'date':
803
+ // Convert to timestamp for comparison
804
+ aValue = aValue ? new Date(aValue).getTime() : 0;
805
+ bValue = bValue ? new Date(bValue).getTime() : 0;
806
+ break;
807
+ default:
808
+ // For text, convert to lowercase strings
809
+ aValue = String(aValue).toLowerCase();
810
+ bValue = String(bValue).toLowerCase();
811
+ }
812
+
813
+ // Compare values
814
+ if (aValue < bValue) {
815
+ return direction === 'asc' ? -1 : 1;
816
+ }
817
+ if (aValue > bValue) {
818
+ return direction === 'asc' ? 1 : -1;
819
+ }
820
+ return 0;
821
+ });
822
+ },
823
+ [gridHeaders]
824
+ );
825
+
826
+ // Handle sort request
827
+ const requestSort = useCallback((key) => {
828
+ // If clicking the same column, toggle direction
829
+ // Otherwise, start with ascending sort for the new column
830
+ setSortConfig((prevConfig) => {
831
+ const newDirection =
832
+ prevConfig.key === key && prevConfig.direction === 'asc'
833
+ ? 'desc'
834
+ : 'asc';
835
+
836
+ return { key, direction: newDirection };
837
+ });
838
+ }, []);
839
+
840
+ // Function to filter data based on filter values
841
+ const filterData = useCallback(
842
+ (data) => {
843
+ // If no filter values are set, return the original data
844
+ if (Object.keys(filterValues).length === 0) {
845
+ return data;
846
+ }
847
+
848
+ // Filter the data based on the filter values
849
+ return data.filter((row) => {
850
+ // Check each filter
851
+ return Object.entries(filterValues).every(([key, value]) => {
852
+ // Skip empty filter values
853
+ if (!value) return true;
854
+
855
+ // Get the row value
856
+ const rowValue = row[key];
857
+ if (rowValue === null || rowValue === undefined)
858
+ return false;
859
+
860
+ // Find the header config for this key
861
+ const headerConfig = gridHeaders.find((h) => h.key === key);
862
+ if (!headerConfig || !headerConfig.filter) return true;
863
+
864
+ const { type, operator } = headerConfig.filter;
865
+
866
+ // Apply filter based on type and operator
867
+ switch (type) {
868
+ case 'string':
869
+ const rowStr = String(rowValue).toLowerCase();
870
+ const filterStr = String(value).toLowerCase();
871
+
872
+ switch (operator) {
873
+ case 'equals':
874
+ return rowStr === filterStr;
875
+ case 'contains':
876
+ return rowStr.includes(filterStr);
877
+ case 'startsWith':
878
+ return rowStr.startsWith(filterStr);
879
+ case 'endsWith':
880
+ return rowStr.endsWith(filterStr);
881
+ default:
882
+ return rowStr.includes(filterStr);
883
+ }
884
+
885
+ case 'number':
886
+ const rowNum = parseFloat(rowValue);
887
+ const filterNum = parseFloat(value);
888
+
889
+ if (isNaN(rowNum) || isNaN(filterNum)) return false;
890
+
891
+ switch (operator) {
892
+ case 'equals':
893
+ return rowNum === filterNum;
894
+ case 'greaterThan':
895
+ return rowNum > filterNum;
896
+ case 'lessThan':
897
+ return rowNum < filterNum;
898
+ case 'greaterThanOrEqual':
899
+ return rowNum >= filterNum;
900
+ case 'lessThanOrEqual':
901
+ return rowNum <= filterNum;
902
+ default:
903
+ return rowNum === filterNum;
904
+ }
905
+
906
+ case 'date':
907
+ const rowDate = new Date(rowValue);
908
+ const filterDate = new Date(value);
909
+
910
+ if (
911
+ isNaN(rowDate.getTime()) ||
912
+ isNaN(filterDate.getTime())
913
+ )
914
+ return false;
915
+
916
+ switch (operator) {
917
+ case 'equals':
918
+ return (
919
+ rowDate.toDateString() ===
920
+ filterDate.toDateString()
921
+ );
922
+ case 'after':
923
+ return rowDate > filterDate;
924
+ case 'before':
925
+ return rowDate < filterDate;
926
+ default:
927
+ return (
928
+ rowDate.toDateString() ===
929
+ filterDate.toDateString()
930
+ );
931
+ }
932
+
933
+ default:
934
+ return true;
935
+ }
936
+ });
937
+ });
938
+ },
939
+ [filterValues, gridHeaders]
940
+ );
941
+
942
+ // Handle filter change
943
+ const handleFilterChange = useCallback((key, value) => {
944
+ setFilterValues((prev) => ({
945
+ ...prev,
946
+ [key]: value,
947
+ }));
948
+ }, []);
949
+
950
+ // Effect to update filteredData and sortedData when gridData, filterValues, or sortConfig changes
951
+ useEffect(() => {
952
+ if (gridData.length > 0) {
953
+ // First apply filters
954
+ const filtered = filterData(gridData);
955
+ setFilteredData(filtered);
956
+
957
+ // Then sort the filtered data
958
+ const sorted = sortData(
959
+ filtered,
960
+ sortConfig.key,
961
+ sortConfig.direction
962
+ );
963
+ setSortedData(sorted);
964
+ } else {
965
+ setFilteredData([]);
966
+ setSortedData([]);
967
+ }
968
+ }, [gridData, filterValues, sortConfig, sortData, filterData]);
969
+
970
+ // Load data from API if gridSetting.fetch is provided
971
+ useEffect(() => {
972
+ const fetchData = async () => {
973
+ // Check if we should use static data instead of fetching
974
+ if (settings?.useStaticData && settings?.staticData) {
975
+ const staticData = settings.staticData;
976
+
977
+ if (staticData.header && staticData.rows) {
978
+ // Set the headers from the static data
979
+ const headerEntries = Object.entries(staticData.header);
980
+ setGridHeaders(
981
+ headerEntries.map(([key, config]) => ({
982
+ key,
983
+ label: config.label || key,
984
+ sort: config.sort || key,
985
+ type: config.type || 'text',
986
+ filter: config.filter || null,
987
+ onClick: config.onClick || null,
988
+ }))
989
+ );
990
+
991
+ // Initialize filter values
992
+ const initialFilterValues = {};
993
+ headerEntries.forEach(([key, config]) => {
994
+ if (config.filter) {
995
+ initialFilterValues[key] = '';
996
+ }
997
+ });
998
+ setFilterValues(initialFilterValues);
999
+
1000
+ // Set the rows data
1001
+ const newData = staticData.rows || [];
1002
+ setGridData(newData);
1003
+ setSortedData(newData);
1004
+
1005
+ // Notify parent component of data change
1006
+ if (onDataChange) {
1007
+ onDataChange(newData);
1008
+ }
1009
+ }
1010
+ return;
1011
+ }
1012
+
1013
+ // Only fetch if we have a URL
1014
+ if (!settings?.fetch?.url) {
1015
+ if (configData && configData.length > 0) {
1016
+ // Use provided data if no fetch setting
1017
+ setGridData(configData);
1018
+ }
1019
+ return;
1020
+ }
1021
+
1022
+ const url = settings.fetch.url;
1023
+ const method = settings.fetch.method || 'POST';
1024
+
1025
+ // Combine base params with settings filter values
1026
+ const params = {
1027
+ ...(settings.fetch.params || {}),
1028
+ ...settingsFilterValues,
1029
+ };
1030
+
1031
+ // Create a string representation of the fetch parameters for comparison
1032
+ const fetchParamsString = JSON.stringify({ url, method, params });
1033
+
1034
+ // Skip fetch if the parameters haven't changed
1035
+ if (prevFetchParamsRef.current === fetchParamsString) {
1036
+ return;
1037
+ }
1038
+
1039
+ // Update the ref with current parameters
1040
+ prevFetchParamsRef.current = fetchParamsString;
1041
+
1042
+ setLoading(true);
1043
+ try {
1044
+ const response = await CustomFetch(url, method, params);
1045
+
1046
+ if (response.data.data) {
1047
+ // Check if the response has the expected structure with header and rows
1048
+ if (response.data.data.header && response.data.data.rows) {
1049
+ // Set the headers from the response with the new structure
1050
+ const headerEntries = Object.entries(
1051
+ response.data.data.header
1052
+ );
1053
+ setGridHeaders(
1054
+ headerEntries.map(([key, config]) => ({
1055
+ key,
1056
+ label: config.label || key,
1057
+ sort: config.sort || key,
1058
+ type: config.type || 'text',
1059
+ filter: config.filter || null,
1060
+ onClick: config.onClick || null,
1061
+ }))
1062
+ );
1063
+
1064
+ // Initialize filter values
1065
+ const initialFilterValues = {};
1066
+ headerEntries.forEach(([key, config]) => {
1067
+ if (config.filter) {
1068
+ initialFilterValues[key] = '';
1069
+ }
1070
+ });
1071
+ setFilterValues(initialFilterValues);
1072
+
1073
+ // Set the rows data
1074
+ const newData = response.data.data.rows || [];
1075
+ setGridData(newData);
1076
+ setSortedData(newData);
1077
+
1078
+ // Notify parent component of data change
1079
+ if (onDataChange) {
1080
+ onDataChange(newData);
1081
+ }
1082
+ } else {
1083
+ // Fallback to the previous behavior if the structure is different
1084
+ const newData = response.data.data.data || [];
1085
+ setGridData(newData);
1086
+ setSortedData(newData);
1087
+
1088
+ // Notify parent component of data change
1089
+ if (onDataChange) {
1090
+ onDataChange(newData);
1091
+ }
1092
+ }
1093
+ }
1094
+ } catch (error) {
1095
+ console.error('Error fetching grid data:', error);
1096
+ } finally {
1097
+ setLoading(false);
1098
+ }
1099
+ };
1100
+
1101
+ fetchData();
1102
+ }, [
1103
+ // Only depend on the specific properties we need, not the entire objects
1104
+ settings?.fetch?.url,
1105
+ settings?.fetch?.method,
1106
+ // Use JSON.stringify for the params to detect deep changes
1107
+ JSON.stringify(settings?.fetch?.params),
1108
+ JSON.stringify(settingsFilterValues),
1109
+ // Only depend on data if we're not fetching
1110
+ !settings?.fetch?.url ? JSON.stringify(configData) : null,
1111
+ // Include static data dependencies
1112
+ settings?.useStaticData,
1113
+ JSON.stringify(settings?.staticData),
1114
+ onDataChange,
1115
+ ]);
1116
+
1117
+ // Generate year options for dropdown
1118
+ const generateYearOptions = useCallback((startYear) => {
1119
+ const currentYear = new Date().getFullYear();
1120
+ const years = [];
1121
+
1122
+ // Start from the specified year and go up to current year
1123
+ for (let year = startYear; year <= currentYear + 1; year++) {
1124
+ years.push({ value: year.toString(), label: year.toString() });
1125
+ }
1126
+
1127
+ return years;
1128
+ }, []);
1129
+
1130
+ // Fetch filter options from URL
1131
+ const fetchFilterOptions = useCallback(async (filter) => {
1132
+ if (!filter.url) return;
1133
+
1134
+ try {
1135
+ const response = await CustomFetch(filter.url, 'POST', {});
1136
+ if (response.data && response.data.data) {
1137
+ setFilterOptions((prev) => ({
1138
+ ...prev,
1139
+ [filter.id]: response.data.data,
1140
+ }));
1141
+ }
1142
+ } catch (error) {
1143
+ console.error(`Error fetching options for ${filter.id}:`, error);
1144
+ toast.error(`Failed to load ${filter.label} options`);
1145
+ }
1146
+ }, []);
1147
+
1148
+ // Initialize settings filters and fetch options
1149
+ useEffect(() => {
1150
+ if (settings?.filters && settings.filters.length > 0) {
1151
+ const initialValues = {};
1152
+
1153
+ settings.filters.forEach((filter) => {
1154
+ if (
1155
+ (filter.type === 'dropdown' ||
1156
+ filter.type === 'multiselect') &&
1157
+ (filter.id === 'year' || filter.startYear)
1158
+ ) {
1159
+ // For year filters with 'now' value, set to current year
1160
+ if (filter.value === 'now') {
1161
+ const currentYear = new Date().getFullYear().toString();
1162
+ // For multiselect, check isMulti to determine if it should be array or single value
1163
+ if (filter.type === 'multiselect') {
1164
+ const value =
1165
+ filter.isMulti === false
1166
+ ? currentYear
1167
+ : [currentYear];
1168
+ initialValues[filter.id] = value;
1169
+ } else {
1170
+ initialValues[filter.id] = currentYear;
1171
+ }
1172
+ } else {
1173
+ if (filter.type === 'multiselect') {
1174
+ initialValues[filter.id] =
1175
+ filter.isMulti === false
1176
+ ? filter.value || ''
1177
+ : filter.value || [];
1178
+ } else {
1179
+ initialValues[filter.id] = filter.value || '';
1180
+ }
1181
+ }
1182
+ } else if (filter.type === 'multiselect') {
1183
+ // For multiselect, handle 'now' value and initialize based on isMulti
1184
+ if (filter.value === 'now') {
1185
+ const currentYear = new Date().getFullYear().toString();
1186
+ initialValues[filter.id] =
1187
+ filter.isMulti === false
1188
+ ? currentYear
1189
+ : [currentYear];
1190
+ } else {
1191
+ if (filter.isMulti === false) {
1192
+ // Single select mode - use single value
1193
+ initialValues[filter.id] = Array.isArray(
1194
+ filter.value
1195
+ )
1196
+ ? filter.value[0] || ''
1197
+ : filter.value || '';
1198
+ } else {
1199
+ // Multi select mode - use array
1200
+ initialValues[filter.id] = Array.isArray(
1201
+ filter.value
1202
+ )
1203
+ ? filter.value
1204
+ : filter.value
1205
+ ? [filter.value]
1206
+ : [];
1207
+ }
1208
+ }
1209
+ } else {
1210
+ // For other filter types
1211
+ if (filter.value === 'now') {
1212
+ initialValues[filter.id] = new Date()
1213
+ .getFullYear()
1214
+ .toString();
1215
+ } else {
1216
+ initialValues[filter.id] = filter.value || '';
1217
+ }
1218
+ }
1219
+
1220
+ // Fetch options for dropdowns with URL
1221
+ if (
1222
+ (filter.type === 'dropdown' ||
1223
+ filter.type === 'multiselect') &&
1224
+ filter.url
1225
+ ) {
1226
+ fetchFilterOptions(filter);
1227
+ }
1228
+ });
1229
+
1230
+ setSettingsFilterValues(initialValues);
1231
+ }
1232
+ }, [settings?.filters, fetchFilterOptions]);
1233
+
1234
+ // Handle settings filter change
1235
+ const handleSettingsFilterChange = useCallback(
1236
+ (filterId, value) => {
1237
+ setSettingsFilterValues((prev) => ({
1238
+ ...prev,
1239
+ [filterId]: value,
1240
+ }));
1241
+
1242
+ // Update fetch params with the new filter value
1243
+ if (settings?.fetch?.params) {
1244
+ let processedValue = value;
1245
+
1246
+ // For multiselect, convert array to appropriate format for API
1247
+ if (Array.isArray(value)) {
1248
+ // Keep as array - most APIs expect this format for multiple values
1249
+ processedValue = value;
1250
+ }
1251
+
1252
+ const updatedParams = {
1253
+ ...settings.fetch.params,
1254
+ [filterId]: processedValue,
1255
+ };
1256
+
1257
+ // Update the settings object
1258
+ if (config.settings && config.settings.fetch) {
1259
+ config.settings.fetch.params = updatedParams;
1260
+ }
1261
+
1262
+ // Trigger a re-fetch with the new params
1263
+ prevFetchParamsRef.current = null;
1264
+ }
1265
+ },
1266
+ [settings, config]
1267
+ );
1268
+
1269
+ // Apply custom styling from userProfile if available
1270
+ const getCustomStyles = () => {
1271
+ const customStyles = {};
1272
+
1273
+ if (userProfile?.settings?.style) {
1274
+ const { style } = userProfile.settings;
1275
+
1276
+ if (style.hoverColor) {
1277
+ customStyles.hoverColor = {
1278
+ '--hover-color': style.hoverColor,
1279
+ };
1280
+ }
1281
+ }
1282
+
1283
+ return customStyles;
1284
+ };
1285
+
1286
+ // CSS for sort indicators
1287
+ const getSortIndicatorStyle = (key) => {
1288
+ if (sortConfig.key !== key) {
1289
+ return { opacity: 0.3 }; // Dim the icon when not sorting by this column
1290
+ }
1291
+ return {}; // Full opacity when sorting by this column
1292
+ };
1293
+
1294
+ // Handle row click
1295
+ const handleRowClick = useCallback(
1296
+ (row, rowIndex) => {
1297
+ if (onRowClick) {
1298
+ onRowClick(row, rowIndex);
1299
+ }
1300
+ },
1301
+ [onRowClick]
1302
+ );
1303
+
1304
+ // Handle cell click with support for date picker and contact selection
1305
+ const handleCellClick = useCallback(
1306
+ async (e, header, row) => {
1307
+ e.stopPropagation(); // Prevent row click
1308
+
1309
+ try {
1310
+ // Debug logging to check configuration
1311
+ const dateOnlyConfig =
1312
+ header.onClick.dateOnly ||
1313
+ userProfile?.settings?.dateOnly ||
1314
+ false;
1315
+ console.log('=== CELL CLICK DEBUG ===');
1316
+ console.log('Column:', header.label);
1317
+ console.log('Column Key:', header.key);
1318
+ console.log('Full Header:', header);
1319
+ console.log('onClick Config:', header.onClick);
1320
+ console.log('onClick Type:', header.onClick?.type);
1321
+ console.log(
1322
+ 'Has Alternatives:',
1323
+ !!header.onClick?.alternatives
1324
+ );
1325
+ console.log('Alternatives:', header.onClick?.alternatives);
1326
+ console.log('========================');
1327
+
1328
+ // Determine the interaction type
1329
+ const clickType = header.onClick.type || 'date';
1330
+
1331
+ switch (clickType) {
1332
+ case 'date_with_contacts':
1333
+ // Multi-step process: Date selection first, then contact selection
1334
+ await handleDateWithContactsFlow(
1335
+ header,
1336
+ row,
1337
+ dateOnlyConfig
1338
+ );
1339
+ break;
1340
+ case 'date_range_with_contacts':
1341
+ // Date range selection with contacts
1342
+ await handleDateRangeWithContactsFlow(
1343
+ header,
1344
+ row,
1345
+ dateOnlyConfig
1346
+ );
1347
+ break;
1348
+ case 'date_with_alternatives':
1349
+ // Choice between different workflows (send survey vs not sent)
1350
+ await handleDateWithAlternativesFlow(
1351
+ header,
1352
+ row,
1353
+ dateOnlyConfig
1354
+ );
1355
+ break;
1356
+ case 'contacts_only':
1357
+ // Contact selection only (no date selection)
1358
+ await handleContactsOnlyFlow(header, row);
1359
+ break;
1360
+ default:
1361
+ // Simple date selection (backward compatibility)
1362
+ await handleSimpleDateSelection(
1363
+ header,
1364
+ row,
1365
+ dateOnlyConfig
1366
+ );
1367
+ break;
1368
+ }
1369
+ } catch (error) {
1370
+ console.error('Error handling cell click:', error);
1371
+ toast.error('An error occurred while opening the dialog');
1372
+ }
1373
+ },
1374
+ [settings, settingsFilterValues, onDataChange, userProfile]
1375
+ );
1376
+
1377
+ // Handle simple date selection (existing functionality)
1378
+ const handleSimpleDateSelection = useCallback(
1379
+ async (header, row, dateOnlyConfig) => {
1380
+ await showDatePickerDialog({
1381
+ title: header.onClick.title || 'Select Date',
1382
+ message:
1383
+ header.onClick.message ||
1384
+ header.onClick.placeholder ||
1385
+ `Please select a date for ${header.label}:`,
1386
+ confirmLabel: 'Set Date',
1387
+ cancelLabel: 'Cancel',
1388
+ defaultDate:
1389
+ header.onClick.params?.value === 'now'
1390
+ ? 'now'
1391
+ : row[header.key] || null,
1392
+ data: { name: header.key, label: header.label, id: row.id },
1393
+ // Timezone configuration - can be set via userProfile or header config
1394
+ timezone:
1395
+ header.onClick.timezone ||
1396
+ userProfile?.settings?.timezone ||
1397
+ 'auto',
1398
+ forcePerth:
1399
+ header.onClick.forcePerth ||
1400
+ userProfile?.settings?.forcePerth ||
1401
+ false,
1402
+ // Date field type - set to true for DATE fields, false for DATETIME fields
1403
+ dateOnly: dateOnlyConfig,
1404
+ onConfirm: async (selectedDateISO) => {
1405
+ await submitDateUpdate(header, row, selectedDateISO);
1406
+ },
1407
+ onCancel: () => {
1408
+ // Do nothing when cancelled
1409
+ },
1410
+ });
1411
+ },
1412
+ [settings, settingsFilterValues, onDataChange, userProfile]
1413
+ );
1414
+
1415
+ // Handle date selection with contact selection flow
1416
+ const handleDateWithContactsFlow = useCallback(
1417
+ async (header, row, dateOnlyConfig) => {
1418
+ // Step 1: Date selection
1419
+ await showDatePickerDialog({
1420
+ title: header.onClick.title || 'Select Date',
1421
+ message:
1422
+ header.onClick.message ||
1423
+ header.onClick.placeholder ||
1424
+ `Please select a date for ${header.label}:`,
1425
+ confirmLabel: 'Next: Select Contacts',
1426
+ cancelLabel: 'Cancel',
1427
+ defaultDate:
1428
+ header.onClick.params?.value === 'now'
1429
+ ? 'now'
1430
+ : row[header.key] || null,
1431
+ data: { name: header.key, label: header.label, id: row.id },
1432
+ timezone:
1433
+ header.onClick.timezone ||
1434
+ userProfile?.settings?.timezone ||
1435
+ 'auto',
1436
+ forcePerth:
1437
+ header.onClick.forcePerth ||
1438
+ userProfile?.settings?.forcePerth ||
1439
+ false,
1440
+ dateOnly: dateOnlyConfig,
1441
+ onConfirm: async (selectedDateISO) => {
1442
+ // Step 2: Contact selection
1443
+ const contactFieldKey = header.key + '_contacts';
1444
+ const existingContacts = row[contactFieldKey] || [];
1445
+
1446
+ console.log('Opening contact selector with row context:', {
1447
+ rowKeys: Object.keys(row),
1448
+ rowClientId: row.client_id,
1449
+ urlParams: header.onClick.contactSelection?.urlParams,
1450
+ fullRow: row,
1451
+ });
1452
+
1453
+ await showContactSelectorModal({
1454
+ title:
1455
+ header.onClick.contactSelection?.title ||
1456
+ 'Select Contacts',
1457
+ message:
1458
+ header.onClick.contactSelection?.message ||
1459
+ `Add contacts for ${header.label}:`,
1460
+ confirmLabel: 'Save',
1461
+ cancelLabel: 'Cancel',
1462
+ existingContacts: Array.isArray(existingContacts)
1463
+ ? existingContacts
1464
+ : [],
1465
+ contactConfig: {
1466
+ contactsUrl:
1467
+ header.onClick.contactSelection?.contactsUrl ||
1468
+ '/ajax/contacts/dropdown',
1469
+ urlParams:
1470
+ header.onClick.contactSelection?.urlParams ||
1471
+ {},
1472
+ allowManualEntry:
1473
+ header.onClick.contactSelection
1474
+ ?.allowManualEntry !== false,
1475
+ fields: header.onClick.contactSelection?.fields || [
1476
+ 'name',
1477
+ 'email',
1478
+ ],
1479
+ multiple:
1480
+ header.onClick.contactSelection?.multiple !==
1481
+ false,
1482
+ maxContacts:
1483
+ header.onClick.contactSelection?.maxContacts ||
1484
+ 10,
1485
+ },
1486
+ data: {
1487
+ name: header.key,
1488
+ label: header.label,
1489
+ id: row.id,
1490
+ },
1491
+ rowContext: row,
1492
+ onConfirm: async (selectedContacts) => {
1493
+ // Submit both date and contacts
1494
+ await submitDateAndContactsUpdate(
1495
+ header,
1496
+ row,
1497
+ selectedDateISO,
1498
+ selectedContacts
1499
+ );
1500
+ },
1501
+ onCancel: () => {
1502
+ // User cancelled contact selection - do nothing
1503
+ },
1504
+ });
1505
+ },
1506
+ onCancel: () => {
1507
+ // User cancelled date selection - do nothing
1508
+ },
1509
+ });
1510
+ },
1511
+ [settings, settingsFilterValues, onDataChange, userProfile]
1512
+ );
1513
+
1514
+ // Submit date update only
1515
+ const submitDateUpdate = useCallback(
1516
+ async (header, row, selectedDateISO) => {
1517
+ try {
1518
+ // Replace {id} in the URL with the row's ID
1519
+ const url = header.onClick.url.replace('{id}', row.id);
1520
+
1521
+ // Show loading toast
1522
+ toast.info('Updating...', { autoClose: 1000 });
1523
+
1524
+ // Extract field and value from params
1525
+ const { field, ...otherParams } = header.onClick.params;
1526
+
1527
+ // Create a new params object with the field as the key
1528
+ const params = {
1529
+ ...otherParams,
1530
+ _method: header.onClick.method, // Add _method for PUT/DELETE requests
1531
+ };
1532
+
1533
+ // Set the field as the key with the selected date value
1534
+ params[field] = selectedDateISO;
1535
+
1536
+ // Make the API call
1537
+ const response = await CustomFetch(
1538
+ url,
1539
+ header.onClick.method,
1540
+ params
1541
+ );
1542
+
1543
+ if (response.data.error) {
1544
+ toast.error(response.data.error);
1545
+ } else {
1546
+ toast.success('Date updated successfully', {
1547
+ autoClose: 1000,
1548
+ });
1549
+ await refreshGridData();
1550
+ }
1551
+ } catch (error) {
1552
+ console.error('Error updating date:', error);
1553
+ toast.error('An error occurred while updating');
1554
+ }
1555
+ },
1556
+ []
1557
+ );
1558
+
1559
+ // Submit date and contacts update
1560
+ const submitDateAndContactsUpdate = useCallback(
1561
+ async (header, row, selectedDateISO, selectedContacts) => {
1562
+ try {
1563
+ // Replace {id} in the URL with the row's ID
1564
+ const url = header.onClick.url.replace('{id}', row.id);
1565
+
1566
+ // Show loading toast
1567
+ toast.info('Saving date and contacts...', { autoClose: 1000 });
1568
+
1569
+ // Extract field and value from params
1570
+ const { field, ...otherParams } = header.onClick.params;
1571
+
1572
+ // Create a new params object with both date and contacts
1573
+ const params = {
1574
+ ...otherParams,
1575
+ _method: header.onClick.method, // Add _method for PUT/DELETE requests
1576
+ };
1577
+
1578
+ // Set the date field
1579
+ params[field] = selectedDateISO;
1580
+
1581
+ // Set the contacts field (field name + '_contacts')
1582
+ const contactsField = field + '_contacts';
1583
+ params[contactsField] = selectedContacts;
1584
+
1585
+ console.log('Submitting date and contacts:', params);
1586
+
1587
+ // Make the API call
1588
+ const response = await CustomFetch(
1589
+ url,
1590
+ header.onClick.method,
1591
+ params
1592
+ );
1593
+
1594
+ if (response.data.error) {
1595
+ toast.error(response.data.error);
1596
+ } else {
1597
+ toast.success('Date and contacts saved successfully', {
1598
+ autoClose: 1500,
1599
+ });
1600
+ await refreshGridData();
1601
+ }
1602
+ } catch (error) {
1603
+ console.error('Error updating date and contacts:', error);
1604
+ toast.error('An error occurred while saving');
1605
+ }
1606
+ },
1607
+ []
1608
+ );
1609
+
1610
+ // Handle date range selection with contact selection flow
1611
+ const handleDateRangeWithContactsFlow = useCallback(
1612
+ async (header, row, dateOnlyConfig) => {
1613
+ const startField = header.onClick?.startField || 'start_date';
1614
+ const endField = header.onClick?.endField || 'end_date';
1615
+
1616
+ // Step 1: Date range selection
1617
+ await showDateRangeSelectorModal({
1618
+ title: header.onClick.title || 'Select Agreement Period',
1619
+ message:
1620
+ header.onClick.message ||
1621
+ `Please select the ${header.label} period:`,
1622
+ confirmLabel: 'Next: Select Contacts',
1623
+ cancelLabel: 'Cancel',
1624
+ defaultStartDate: row[startField] || null,
1625
+ defaultEndDate: row[endField] || null,
1626
+ data: { name: header.key, label: header.label, id: row.id },
1627
+ timezone:
1628
+ header.onClick.timezone ||
1629
+ userProfile?.settings?.timezone ||
1630
+ 'auto',
1631
+ forcePerth:
1632
+ header.onClick.forcePerth ||
1633
+ userProfile?.settings?.forcePerth ||
1634
+ false,
1635
+ dateOnly: dateOnlyConfig,
1636
+ onConfirm: async (selectedStartDateISO, selectedEndDateISO) => {
1637
+ // Step 2: Contact selection
1638
+ const contactsField = header.key + '_contacts';
1639
+ const existingContacts = row[contactsField] || [];
1640
+
1641
+ await showContactSelectorModal({
1642
+ title:
1643
+ header.onClick.contactSelection?.title ||
1644
+ 'Select Contacts for Agreement',
1645
+ message:
1646
+ header.onClick.contactSelection?.message ||
1647
+ `Add contacts for ${header.label}:`,
1648
+ confirmLabel: 'Save',
1649
+ cancelLabel: 'Cancel',
1650
+ existingContacts: Array.isArray(existingContacts)
1651
+ ? existingContacts
1652
+ : [],
1653
+ contactConfig: {
1654
+ contactsUrl:
1655
+ header.onClick.contactSelection?.contactsUrl ||
1656
+ '/ajax/contacts/dropdown',
1657
+ urlParams:
1658
+ header.onClick.contactSelection?.urlParams ||
1659
+ {},
1660
+ allowManualEntry:
1661
+ header.onClick.contactSelection
1662
+ ?.allowManualEntry !== false,
1663
+ fields: header.onClick.contactSelection?.fields || [
1664
+ 'name',
1665
+ 'email',
1666
+ ],
1667
+ fieldTypes:
1668
+ header.onClick.contactSelection?.fieldTypes ||
1669
+ {},
1670
+ fieldLabels:
1671
+ header.onClick.contactSelection?.fieldLabels ||
1672
+ {},
1673
+ validation:
1674
+ header.onClick.contactSelection?.validation ||
1675
+ {},
1676
+ multiple:
1677
+ header.onClick.contactSelection?.multiple !==
1678
+ false,
1679
+ maxContacts:
1680
+ header.onClick.contactSelection?.maxContacts ||
1681
+ 10,
1682
+ },
1683
+ data: {
1684
+ name: header.key,
1685
+ label: header.label,
1686
+ id: row.id,
1687
+ },
1688
+ rowContext: row,
1689
+ onConfirm: async (selectedContacts) => {
1690
+ // Submit date range and contacts
1691
+ await submitDateRangeAndContactsUpdate(
1692
+ header,
1693
+ row,
1694
+ selectedStartDateISO,
1695
+ selectedEndDateISO,
1696
+ selectedContacts
1697
+ );
1698
+ },
1699
+ onCancel: () => {
1700
+ // User cancelled contact selection - do nothing
1701
+ },
1702
+ });
1703
+ },
1704
+ onCancel: () => {
1705
+ // User cancelled date range selection - do nothing
1706
+ },
1707
+ });
1708
+ },
1709
+ [settings, settingsFilterValues, onDataChange, userProfile]
1710
+ );
1711
+
1712
+ // Handle alternatives workflow (send vs not sent)
1713
+ const handleDateWithAlternativesFlow = useCallback(
1714
+ async (header, row, dateOnlyConfig) => {
1715
+ console.log('=== ALTERNATIVES FLOW CALLED ===');
1716
+ console.log('Header alternatives:', header.onClick.alternatives);
1717
+
1718
+ const alternatives = header.onClick.alternatives || [
1719
+ {
1720
+ id: 'date_contacts',
1721
+ label: 'Send Survey',
1722
+ icon: '📅',
1723
+ workflow: 'date_with_contacts',
1724
+ description: 'Mark date and add contacts',
1725
+ },
1726
+ {
1727
+ id: 'not_sent',
1728
+ label: 'Mark as Not Sent',
1729
+ icon: '❌',
1730
+ workflow: 'reason_only',
1731
+ description: 'Record reason for not sending',
1732
+ },
1733
+ ];
1734
+
1735
+ await showAlternativeActionModal({
1736
+ title: header.onClick.title || 'Choose Action',
1737
+ message:
1738
+ header.onClick.message ||
1739
+ `What would you like to do for ${header.label}?`,
1740
+ alternatives: alternatives,
1741
+ data: { name: header.key, label: header.label, id: row.id },
1742
+ onSelect: async (selectedAlternative) => {
1743
+ if (selectedAlternative.workflow === 'date_with_contacts') {
1744
+ // Handle date + contacts workflow
1745
+ await handleDateWithContactsWorkflow(
1746
+ header,
1747
+ row,
1748
+ dateOnlyConfig,
1749
+ selectedAlternative
1750
+ );
1751
+ } else if (selectedAlternative.workflow === 'reason_only') {
1752
+ // Handle not sent reason workflow
1753
+ await handleNotSentReasonWorkflow(
1754
+ header,
1755
+ row,
1756
+ selectedAlternative
1757
+ );
1758
+ }
1759
+ },
1760
+ onCancel: () => {
1761
+ // User cancelled - do nothing
1762
+ },
1763
+ });
1764
+ },
1765
+ [settings, settingsFilterValues, onDataChange, userProfile]
1766
+ );
1767
+
1768
+ // Handle date with contacts workflow (from alternatives)
1769
+ const handleDateWithContactsWorkflow = useCallback(
1770
+ async (header, row, dateOnlyConfig, alternative) => {
1771
+ // Step 1: Date selection
1772
+ await showDatePickerDialog({
1773
+ title:
1774
+ alternative.title || header.onClick.title || 'Select Date',
1775
+ message:
1776
+ alternative.message ||
1777
+ header.onClick.message ||
1778
+ `Please select a date for ${header.label}:`,
1779
+ confirmLabel: 'Next: Select Contacts',
1780
+ cancelLabel: 'Cancel',
1781
+ defaultDate:
1782
+ header.onClick.params?.value === 'now'
1783
+ ? 'now'
1784
+ : row[header.key] || null,
1785
+ data: { name: header.key, label: header.label, id: row.id },
1786
+ timezone:
1787
+ header.onClick.timezone ||
1788
+ userProfile?.settings?.timezone ||
1789
+ 'auto',
1790
+ forcePerth:
1791
+ header.onClick.forcePerth ||
1792
+ userProfile?.settings?.forcePerth ||
1793
+ false,
1794
+ dateOnly: dateOnlyConfig,
1795
+ onConfirm: async (selectedDateISO) => {
1796
+ // Step 2: Contact selection
1797
+ const contactsField = header.key + '_contacts';
1798
+ const existingContacts = row[contactsField] || [];
1799
+
1800
+ const contactSelection =
1801
+ alternative.contactSelection ||
1802
+ header.onClick.contactSelection ||
1803
+ {};
1804
+
1805
+ await showContactSelectorModal({
1806
+ title: contactSelection.title || 'Select Contacts',
1807
+ message:
1808
+ contactSelection.message ||
1809
+ `Add contacts for ${header.label}:`,
1810
+ confirmLabel: 'Save',
1811
+ cancelLabel: 'Cancel',
1812
+ existingContacts: Array.isArray(existingContacts)
1813
+ ? existingContacts
1814
+ : [],
1815
+ contactConfig: {
1816
+ contactsUrl:
1817
+ contactSelection.contactsUrl ||
1818
+ '/ajax/contacts/dropdown',
1819
+ urlParams: contactSelection.urlParams || {},
1820
+ allowManualEntry:
1821
+ contactSelection.allowManualEntry !== false,
1822
+ fields: contactSelection.fields || [
1823
+ 'name',
1824
+ 'email',
1825
+ ],
1826
+ fieldTypes: contactSelection.fieldTypes || {},
1827
+ fieldLabels: contactSelection.fieldLabels || {},
1828
+ validation: contactSelection.validation || {},
1829
+ multiple: contactSelection.multiple !== false,
1830
+ maxContacts: contactSelection.maxContacts || 10,
1831
+ },
1832
+ data: {
1833
+ name: header.key,
1834
+ label: header.label,
1835
+ id: row.id,
1836
+ },
1837
+ rowContext: row,
1838
+ onConfirm: async (selectedContacts) => {
1839
+ // Submit both date and contacts with status
1840
+ await submitDateContactsAndStatusUpdate(
1841
+ header,
1842
+ row,
1843
+ selectedDateISO,
1844
+ selectedContacts,
1845
+ 'sent'
1846
+ );
1847
+ },
1848
+ onCancel: () => {
1849
+ // User cancelled contact selection - do nothing
1850
+ },
1851
+ });
1852
+ },
1853
+ onCancel: () => {
1854
+ // User cancelled date selection - do nothing
1855
+ },
1856
+ });
1857
+ },
1858
+ [settings, settingsFilterValues, onDataChange, userProfile]
1859
+ );
1860
+
1861
+ // Handle not sent reason workflow
1862
+ const handleNotSentReasonWorkflow = useCallback(
1863
+ async (header, row, alternative) => {
1864
+ const reasonField =
1865
+ alternative.reasonField || header.key + '_reason';
1866
+ const statusField =
1867
+ alternative.statusField || header.key + '_status';
1868
+
1869
+ await showReasonCollectorModal({
1870
+ title: alternative.title || 'Provide Reason',
1871
+ message:
1872
+ alternative.message ||
1873
+ `Why wasn't the ${header.label} sent?`,
1874
+ confirmLabel: 'Save Reason',
1875
+ cancelLabel: 'Cancel',
1876
+ reasonOptions: alternative.reasonOptions || [
1877
+ 'Survey not applicable',
1878
+ 'Client does not want survey',
1879
+ 'Other',
1880
+ ],
1881
+ allowCustomReason: alternative.allowCustomReason !== false,
1882
+ reasonRequired: alternative.reasonRequired !== false,
1883
+ placeholder:
1884
+ alternative.placeholder || 'Enter custom reason...',
1885
+ defaultReason: row[reasonField] || '',
1886
+ data: { name: header.key, label: header.label, id: row.id },
1887
+ onConfirm: async (reason) => {
1888
+ // Submit reason and status
1889
+ await submitReasonAndStatusUpdate(
1890
+ header,
1891
+ row,
1892
+ reason,
1893
+ 'not_sent',
1894
+ reasonField,
1895
+ statusField
1896
+ );
1897
+ },
1898
+ onCancel: () => {
1899
+ // User cancelled - do nothing
1900
+ },
1901
+ });
1902
+ },
1903
+ [settings, settingsFilterValues, onDataChange, userProfile]
1904
+ );
1905
+
1906
+ // Submit date range and contacts update
1907
+ const submitDateRangeAndContactsUpdate = useCallback(
1908
+ async (header, row, startDateISO, endDateISO, selectedContacts) => {
1909
+ try {
1910
+ const url = header.onClick.url.replace('{id}', row.id);
1911
+ const startField = header.onClick?.startField || 'start_date';
1912
+ const endField = header.onClick?.endField || 'end_date';
1913
+ const contactsField = header.key + '_contacts';
1914
+
1915
+ toast.info('Saving date range and contacts...', {
1916
+ autoClose: 1000,
1917
+ });
1918
+
1919
+ const params = {
1920
+ ...header.onClick.params,
1921
+ _method: header.onClick.method,
1922
+ [startField]: startDateISO,
1923
+ [endField]: endDateISO,
1924
+ [contactsField]: selectedContacts,
1925
+ };
1926
+
1927
+ const response = await CustomFetch(
1928
+ url,
1929
+ header.onClick.method,
1930
+ params
1931
+ );
1932
+
1933
+ if (response.data.error) {
1934
+ toast.error(response.data.error);
1935
+ } else {
1936
+ toast.success(
1937
+ 'Date range and contacts saved successfully',
1938
+ { autoClose: 1500 }
1939
+ );
1940
+ await refreshGridData();
1941
+ }
1942
+ } catch (error) {
1943
+ console.error('Error updating date range and contacts:', error);
1944
+ toast.error('An error occurred while saving');
1945
+ }
1946
+ },
1947
+ []
1948
+ );
1949
+
1950
+ // Submit date, contacts, and status update
1951
+ const submitDateContactsAndStatusUpdate = useCallback(
1952
+ async (header, row, selectedDateISO, selectedContacts, status) => {
1953
+ try {
1954
+ const url = header.onClick.url.replace('{id}', row.id);
1955
+ const { field, ...otherParams } = header.onClick.params;
1956
+ const contactsField = field + '_contacts';
1957
+ const statusField = field + '_status';
1958
+
1959
+ toast.info('Saving date, contacts, and status...', {
1960
+ autoClose: 1000,
1961
+ });
1962
+
1963
+ const params = {
1964
+ ...otherParams,
1965
+ _method: header.onClick.method,
1966
+ [field]: selectedDateISO,
1967
+ [contactsField]: selectedContacts,
1968
+ [statusField]: status,
1969
+ };
1970
+
1971
+ const response = await CustomFetch(
1972
+ url,
1973
+ header.onClick.method,
1974
+ params
1975
+ );
1976
+
1977
+ if (response.data.error) {
1978
+ toast.error(response.data.error);
1979
+ } else {
1980
+ toast.success('Survey data saved successfully', {
1981
+ autoClose: 1500,
1982
+ });
1983
+ await refreshGridData();
1984
+ }
1985
+ } catch (error) {
1986
+ console.error('Error updating survey data:', error);
1987
+ toast.error('An error occurred while saving');
1988
+ }
1989
+ },
1990
+ []
1991
+ );
1992
+
1993
+ // Submit reason and status update
1994
+ const submitReasonAndStatusUpdate = useCallback(
1995
+ async (header, row, reason, status, reasonField, statusField) => {
1996
+ try {
1997
+ const url = header.onClick.url.replace('{id}', row.id);
1998
+
1999
+ toast.info('Saving reason...', { autoClose: 1000 });
2000
+
2001
+ const params = {
2002
+ ...header.onClick.params,
2003
+ _method: header.onClick.method,
2004
+ [reasonField]: reason,
2005
+ [statusField]: status,
2006
+ };
2007
+
2008
+ const response = await CustomFetch(
2009
+ url,
2010
+ header.onClick.method,
2011
+ params
2012
+ );
2013
+
2014
+ if (response.data.error) {
2015
+ toast.error(response.data.error);
2016
+ } else {
2017
+ toast.success('Reason saved successfully', {
2018
+ autoClose: 1500,
2019
+ });
2020
+ await refreshGridData();
2021
+ }
2022
+ } catch (error) {
2023
+ console.error('Error saving reason:', error);
2024
+ toast.error('An error occurred while saving');
2025
+ }
2026
+ },
2027
+ []
2028
+ );
2029
+
2030
+ // Handle contacts only flow (no date selection)
2031
+ const handleContactsOnlyFlow = useCallback(
2032
+ async (header, row) => {
2033
+ const contactsField = header.key + '_contacts';
2034
+ const existingContacts = row[contactsField] || [];
2035
+
2036
+ await showContactSelectorModal({
2037
+ title:
2038
+ header.onClick.contactSelection?.title ||
2039
+ header.onClick.title ||
2040
+ 'Select Contacts',
2041
+ message:
2042
+ header.onClick.contactSelection?.message ||
2043
+ header.onClick.message ||
2044
+ `Add contacts for ${header.label}:`,
2045
+ confirmLabel: 'Save',
2046
+ cancelLabel: 'Cancel',
2047
+ existingContacts: Array.isArray(existingContacts)
2048
+ ? existingContacts
2049
+ : [],
2050
+ contactConfig: {
2051
+ contactsUrl:
2052
+ header.onClick.contactSelection?.contactsUrl ||
2053
+ '/ajax/contacts/dropdown',
2054
+ urlParams: header.onClick.contactSelection?.urlParams || {},
2055
+ allowManualEntry:
2056
+ header.onClick.contactSelection?.allowManualEntry !==
2057
+ false,
2058
+ fields: header.onClick.contactSelection?.fields || [
2059
+ 'name',
2060
+ 'email',
2061
+ ],
2062
+ fieldTypes:
2063
+ header.onClick.contactSelection?.fieldTypes || {},
2064
+ fieldLabels:
2065
+ header.onClick.contactSelection?.fieldLabels || {},
2066
+ validation:
2067
+ header.onClick.contactSelection?.validation || {},
2068
+ multiple:
2069
+ header.onClick.contactSelection?.multiple !== false,
2070
+ maxContacts:
2071
+ header.onClick.contactSelection?.maxContacts || 10,
2072
+ },
2073
+ data: { name: header.key, label: header.label, id: row.id },
2074
+ rowContext: row,
2075
+ onConfirm: async (selectedContacts) => {
2076
+ // Submit only contacts (no date)
2077
+ await submitContactsOnlyUpdate(
2078
+ header,
2079
+ row,
2080
+ selectedContacts
2081
+ );
2082
+ },
2083
+ onCancel: () => {
2084
+ // User cancelled contact selection - do nothing
2085
+ },
2086
+ });
2087
+ },
2088
+ [settings, settingsFilterValues, onDataChange, userProfile]
2089
+ );
2090
+
2091
+ // Submit contacts only update
2092
+ const submitContactsOnlyUpdate = useCallback(
2093
+ async (header, row, selectedContacts) => {
2094
+ try {
2095
+ const url = header.onClick.url.replace('{id}', row.id);
2096
+ const contactsField = header.key + '_contacts';
2097
+
2098
+ toast.info('Saving contacts...', { autoClose: 1000 });
2099
+
2100
+ const params = {
2101
+ ...header.onClick.params,
2102
+ _method: header.onClick.method,
2103
+ [contactsField]: selectedContacts,
2104
+ };
2105
+
2106
+ console.log('Submitting contacts only:', params);
2107
+
2108
+ const response = await CustomFetch(
2109
+ url,
2110
+ header.onClick.method,
2111
+ params
2112
+ );
2113
+
2114
+ if (response.data.error) {
2115
+ toast.error(response.data.error);
2116
+ } else {
2117
+ toast.success('Contacts saved successfully', {
2118
+ autoClose: 1500,
2119
+ });
2120
+ await refreshGridData();
2121
+ }
2122
+ } catch (error) {
2123
+ console.error('Error updating contacts:', error);
2124
+ toast.error('An error occurred while saving');
2125
+ }
2126
+ },
2127
+ []
2128
+ );
2129
+
2130
+ // Refresh grid data helper function
2131
+ const refreshGridData = useCallback(async () => {
2132
+ try {
2133
+ // Refresh the data
2134
+ prevFetchParamsRef.current = null;
2135
+
2136
+ // Force a re-fetch
2137
+ const fetchUrl = settings.fetch.url;
2138
+ const fetchMethod = settings.fetch.method || 'POST';
2139
+ const fetchParams = {
2140
+ ...(settings.fetch.params || {}),
2141
+ ...settingsFilterValues,
2142
+ };
2143
+
2144
+ setLoading(true);
2145
+ const fetchResponse = await CustomFetch(
2146
+ fetchUrl,
2147
+ fetchMethod,
2148
+ fetchParams
2149
+ );
2150
+ if (fetchResponse.data.data) {
2151
+ if (
2152
+ fetchResponse.data.data.header &&
2153
+ fetchResponse.data.data.rows
2154
+ ) {
2155
+ const newData = fetchResponse.data.data.rows || [];
2156
+ setGridData(newData);
2157
+ setSortedData(newData);
2158
+ if (onDataChange) {
2159
+ onDataChange(newData);
2160
+ }
2161
+ } else {
2162
+ const newData = fetchResponse.data.data.data || [];
2163
+ setGridData(newData);
2164
+ setSortedData(newData);
2165
+ if (onDataChange) {
2166
+ onDataChange(newData);
2167
+ }
2168
+ }
2169
+ }
2170
+ } catch (error) {
2171
+ console.error('Error refreshing data:', error);
2172
+ toast.error('Error refreshing data');
2173
+ } finally {
2174
+ setLoading(false);
2175
+ }
2176
+ }, [settings, settingsFilterValues, onDataChange]);
2177
+
2178
+ return (
2179
+ <div className={styles.gridContainer}>
2180
+ {/* Settings Filters */}
2181
+ {settings?.filters && settings.filters.length > 0 && (
2182
+ <div className={styles.settingsFiltersContainer}>
2183
+ <div className={styles.settingsFiltersHeader}>
2184
+ <div
2185
+ className={styles.headerLeft}
2186
+ onClick={() =>
2187
+ setFiltersCollapsed(!filtersCollapsed)
2188
+ }
2189
+ >
2190
+ <div className={styles.settingsFiltersTitle}>
2191
+ <svg
2192
+ width="16"
2193
+ height="16"
2194
+ viewBox="0 0 24 24"
2195
+ fill="none"
2196
+ xmlns="http://www.w3.org/2000/svg"
2197
+ >
2198
+ <path
2199
+ d="M4 6H20M7 12H17M9 18H15"
2200
+ stroke="currentColor"
2201
+ strokeWidth="2"
2202
+ strokeLinecap="round"
2203
+ strokeLinejoin="round"
2204
+ />
2205
+ </svg>
2206
+ Filters
2207
+ {/* Show active filters count when collapsed */}
2208
+ {filtersCollapsed && (
2209
+ <div
2210
+ className={
2211
+ styles.activeFiltersIndicator
2212
+ }
2213
+ >
2214
+ {Object.values(
2215
+ settingsFilterValues
2216
+ ).some((value) => value !== '') && (
2217
+ <>
2218
+ <span>Active:</span>
2219
+ <span
2220
+ className={
2221
+ styles.filterBadge
2222
+ }
2223
+ >
2224
+ {
2225
+ Object.values(
2226
+ settingsFilterValues
2227
+ ).filter(
2228
+ (value) =>
2229
+ value !== ''
2230
+ ).length
2231
+ }
2232
+ </span>
2233
+ </>
2234
+ )}
2235
+ </div>
2236
+ )}
2237
+ </div>
2238
+
2239
+ {/* Toggle icon */}
2240
+ <svg
2241
+ className={`${styles.toggleIcon} ${
2242
+ filtersCollapsed ? styles.collapsed : ''
2243
+ }`}
2244
+ width="16"
2245
+ height="16"
2246
+ viewBox="0 0 24 24"
2247
+ fill="none"
2248
+ xmlns="http://www.w3.org/2000/svg"
2249
+ >
2250
+ <path
2251
+ d="M19 9l-7 7-7-7"
2252
+ stroke="currentColor"
2253
+ strokeWidth="2"
2254
+ strokeLinecap="round"
2255
+ strokeLinejoin="round"
2256
+ />
2257
+ </svg>
2258
+ </div>
2259
+
2260
+ {/* Clear filters button */}
2261
+ <button
2262
+ className={styles.clearFiltersButton}
2263
+ onClick={(e) => {
2264
+ e.stopPropagation(); // Prevent toggling when clicking the button
2265
+ // Initialize with default values
2266
+ const initialValues = {};
2267
+ settings.filters.forEach((filter) => {
2268
+ if (
2269
+ (filter.type === 'dropdown' ||
2270
+ filter.type === 'multiselect') &&
2271
+ (filter.id === 'year' ||
2272
+ filter.startYear) &&
2273
+ filter.value === 'now'
2274
+ ) {
2275
+ const currentYear = new Date()
2276
+ .getFullYear()
2277
+ .toString();
2278
+ if (filter.type === 'multiselect') {
2279
+ initialValues[filter.id] =
2280
+ filter.isMulti === false
2281
+ ? currentYear
2282
+ : [currentYear];
2283
+ } else {
2284
+ initialValues[filter.id] =
2285
+ currentYear;
2286
+ }
2287
+ } else if (filter.type === 'multiselect') {
2288
+ if (filter.value === 'now') {
2289
+ const currentYear = new Date()
2290
+ .getFullYear()
2291
+ .toString();
2292
+ initialValues[filter.id] =
2293
+ filter.isMulti === false
2294
+ ? currentYear
2295
+ : [currentYear];
2296
+ } else {
2297
+ if (filter.isMulti === false) {
2298
+ initialValues[filter.id] =
2299
+ Array.isArray(filter.value)
2300
+ ? filter.value[0] || ''
2301
+ : filter.value || '';
2302
+ } else {
2303
+ initialValues[filter.id] =
2304
+ Array.isArray(filter.value)
2305
+ ? filter.value
2306
+ : filter.value
2307
+ ? [filter.value]
2308
+ : [];
2309
+ }
2310
+ }
2311
+ } else {
2312
+ if (filter.value === 'now') {
2313
+ initialValues[filter.id] =
2314
+ new Date()
2315
+ .getFullYear()
2316
+ .toString();
2317
+ } else {
2318
+ initialValues[filter.id] =
2319
+ filter.value || '';
2320
+ }
2321
+ }
2322
+ });
2323
+ setSettingsFilterValues(initialValues);
2324
+
2325
+ // Reset fetch params
2326
+ prevFetchParamsRef.current = null;
2327
+ }}
2328
+ >
2329
+ <svg
2330
+ viewBox="0 0 24 24"
2331
+ fill="none"
2332
+ xmlns="http://www.w3.org/2000/svg"
2333
+ >
2334
+ <path
2335
+ d="M19 7l-7 7-7-7"
2336
+ stroke="currentColor"
2337
+ strokeWidth="2"
2338
+ strokeLinecap="round"
2339
+ strokeLinejoin="round"
2340
+ transform="rotate(90, 12, 12)"
2341
+ />
2342
+ </svg>
2343
+ Clear
2344
+ </button>
2345
+ </div>
2346
+
2347
+ <div
2348
+ className={`${styles.settingsFiltersContent} ${
2349
+ filtersCollapsed ? styles.collapsed : ''
2350
+ }`}
2351
+ >
2352
+ {settings.filters.map((filter, index) => (
2353
+ <div
2354
+ key={`settings-filter-${index}`}
2355
+ className={styles.settingsFilterItem}
2356
+ >
2357
+ <label className={styles.settingsFilterLabel}>
2358
+ {filter.label}
2359
+ </label>
2360
+ {filter.type === 'dropdown' &&
2361
+ filter.id === 'year' ? (
2362
+ <select
2363
+ className={
2364
+ styles.settingsFilterDropdown
2365
+ }
2366
+ value={
2367
+ settingsFilterValues[filter.id] ||
2368
+ ''
2369
+ }
2370
+ onChange={(e) =>
2371
+ handleSettingsFilterChange(
2372
+ filter.id,
2373
+ e.target.value
2374
+ )
2375
+ }
2376
+ >
2377
+ {generateYearOptions(
2378
+ filter.startYear || 2000
2379
+ ).map((option) => (
2380
+ <option
2381
+ key={option.value}
2382
+ value={option.value}
2383
+ >
2384
+ {option.label}
2385
+ </option>
2386
+ ))}
2387
+ </select>
2388
+ ) : filter.type === 'dropdown' && filter.url ? (
2389
+ <select
2390
+ className={
2391
+ styles.settingsFilterDropdown
2392
+ }
2393
+ value={
2394
+ settingsFilterValues[filter.id] ||
2395
+ ''
2396
+ }
2397
+ onChange={(e) =>
2398
+ handleSettingsFilterChange(
2399
+ filter.id,
2400
+ e.target.value
2401
+ )
2402
+ }
2403
+ >
2404
+ <option value="">
2405
+ Select {filter.label}
2406
+ </option>
2407
+ {filterOptions[filter.id] &&
2408
+ filterOptions[filter.id].map(
2409
+ (option) => (
2410
+ <option
2411
+ key={
2412
+ option.value ||
2413
+ option.id
2414
+ }
2415
+ value={
2416
+ option.value ||
2417
+ option.id
2418
+ }
2419
+ >
2420
+ {option.label ||
2421
+ option.name ||
2422
+ option.title}
2423
+ </option>
2424
+ )
2425
+ )}
2426
+ </select>
2427
+ ) : filter.type === 'dropdown' ? (
2428
+ <select
2429
+ className={
2430
+ styles.settingsFilterDropdown
2431
+ }
2432
+ value={
2433
+ settingsFilterValues[filter.id] ||
2434
+ ''
2435
+ }
2436
+ onChange={(e) =>
2437
+ handleSettingsFilterChange(
2438
+ filter.id,
2439
+ e.target.value
2440
+ )
2441
+ }
2442
+ >
2443
+ <option value="">
2444
+ Select {filter.label}
2445
+ </option>
2446
+ {filter.options &&
2447
+ filter.options.map((option) => (
2448
+ <option
2449
+ key={option.value}
2450
+ value={option.value}
2451
+ >
2452
+ {option.label}
2453
+ </option>
2454
+ ))}
2455
+ </select>
2456
+ ) : filter.type === 'multiselect' ? (
2457
+ <div
2458
+ className={styles.multiselectContainer}
2459
+ >
2460
+ <MultiSelect
2461
+ options={
2462
+ filter.id === 'year' ||
2463
+ filter.startYear
2464
+ ? generateYearOptions(
2465
+ filter.startYear ||
2466
+ 2000
2467
+ )
2468
+ : filter.url
2469
+ ? (
2470
+ filterOptions[
2471
+ filter.id
2472
+ ] || []
2473
+ ).map((option) => ({
2474
+ value:
2475
+ option.value ||
2476
+ option.id,
2477
+ label:
2478
+ option.label ||
2479
+ option.name ||
2480
+ option.title,
2481
+ }))
2482
+ : filter.options || []
2483
+ }
2484
+ inputValue={(() => {
2485
+ const currentValue =
2486
+ settingsFilterValues[
2487
+ filter.id
2488
+ ];
2489
+ const availableOptions =
2490
+ filter.id === 'year' ||
2491
+ filter.startYear
2492
+ ? generateYearOptions(
2493
+ filter.startYear ||
2494
+ 2000
2495
+ )
2496
+ : filter.url
2497
+ ? (
2498
+ filterOptions[
2499
+ filter.id
2500
+ ] || []
2501
+ ).map((option) => ({
2502
+ value:
2503
+ option.value ||
2504
+ option.id,
2505
+ label:
2506
+ option.label ||
2507
+ option.name ||
2508
+ option.title,
2509
+ }))
2510
+ : filter.options || [];
2511
+
2512
+ if (filter.isMulti === false) {
2513
+ // Single select mode - find the matching option object
2514
+ if (currentValue) {
2515
+ const matchingOption =
2516
+ availableOptions.find(
2517
+ (opt) =>
2518
+ opt.value ===
2519
+ currentValue ||
2520
+ opt.value ===
2521
+ currentValue.toString()
2522
+ );
2523
+ return (
2524
+ matchingOption ||
2525
+ null
2526
+ );
2527
+ }
2528
+ return null;
2529
+ } else {
2530
+ // Multi select mode - return array of matching option objects
2531
+ if (
2532
+ Array.isArray(
2533
+ currentValue
2534
+ ) &&
2535
+ currentValue.length > 0
2536
+ ) {
2537
+ return currentValue
2538
+ .map((val) => {
2539
+ const matchingOption =
2540
+ availableOptions.find(
2541
+ (opt) =>
2542
+ opt.value ===
2543
+ val ||
2544
+ opt.value ===
2545
+ val.toString()
2546
+ );
2547
+ return (
2548
+ matchingOption || {
2549
+ value: val,
2550
+ label: val,
2551
+ }
2552
+ );
2553
+ })
2554
+ .filter(Boolean);
2555
+ }
2556
+ return [];
2557
+ }
2558
+ })()}
2559
+ onChange={(selectedOptions) => {
2560
+ if (filter.isMulti === false) {
2561
+ // Single select mode - extract value from the selected option object
2562
+ const value =
2563
+ selectedOptions
2564
+ ? selectedOptions.value
2565
+ : '';
2566
+ handleSettingsFilterChange(
2567
+ filter.id,
2568
+ value
2569
+ );
2570
+ } else {
2571
+ // Multi select mode - extract values from array of option objects
2572
+ const values =
2573
+ Array.isArray(
2574
+ selectedOptions
2575
+ )
2576
+ ? selectedOptions.map(
2577
+ (opt) =>
2578
+ opt.value
2579
+ )
2580
+ : [];
2581
+ handleSettingsFilterChange(
2582
+ filter.id,
2583
+ values
2584
+ );
2585
+ }
2586
+ }}
2587
+ placeholder={`Select ${filter.label}`}
2588
+ multi={filter.isMulti !== false} // Default to true, false only if explicitly set
2589
+ settings={{
2590
+ id: filter.id,
2591
+ limit:
2592
+ filter.limit || undefined,
2593
+ }}
2594
+ />
2595
+ </div>
2596
+ ) : filter.type === 'text' ? (
2597
+ <input
2598
+ type="text"
2599
+ className={styles.settingsFilterInput}
2600
+ value={
2601
+ settingsFilterValues[filter.id] ||
2602
+ ''
2603
+ }
2604
+ onChange={(e) =>
2605
+ handleSettingsFilterChange(
2606
+ filter.id,
2607
+ e.target.value
2608
+ )
2609
+ }
2610
+ placeholder={filter.placeholder || ''}
2611
+ />
2612
+ ) : null}
2613
+ </div>
2614
+ ))}
2615
+ </div>
2616
+ </div>
2617
+ )}
2618
+
2619
+ {loading ? (
2620
+ <div className={styles.loadingContainer}>
2621
+ <div className={styles.loadingSpinner}></div>
2622
+ <p>Loading data...</p>
2623
+ </div>
2624
+ ) : (
2625
+ <table className={styles.gridTable}>
2626
+ <thead>
2627
+ <tr>
2628
+ {/* Use gridHeaders if available, otherwise fall back to headers from columns */}
2629
+ {gridHeaders.length > 0
2630
+ ? gridHeaders.map((header, index) => (
2631
+ <th
2632
+ key={`header-${index}`}
2633
+ className={`${styles.gridHeader} ${styles.sortableHeader}`}
2634
+ onClick={() =>
2635
+ requestSort(header.key)
2636
+ }
2637
+ style={{ cursor: 'pointer' }}
2638
+ >
2639
+ <div className={styles.headerContent}>
2640
+ {header.label}
2641
+ <span
2642
+ className={
2643
+ styles.sortIndicator
2644
+ }
2645
+ style={getSortIndicatorStyle(
2646
+ header.key
2647
+ )}
2648
+ >
2649
+ {sortConfig.key ===
2650
+ header.key &&
2651
+ sortConfig.direction === 'asc'
2652
+ ? ' ▲'
2653
+ : ' ▼'}
2654
+ </span>
2655
+ </div>
2656
+ </th>
2657
+ ))
2658
+ : headers.map((header, index) => (
2659
+ <th
2660
+ key={`header-${index}`}
2661
+ className={styles.gridHeader}
2662
+ >
2663
+ {header}
2664
+ </th>
2665
+ ))}
2666
+ </tr>
2667
+ {/* Filter row */}
2668
+ {gridHeaders.some((header) => header.filter) && (
2669
+ <tr className={styles.filterRow}>
2670
+ {gridHeaders.map((header, index) => (
2671
+ <th
2672
+ key={`filter-${index}`}
2673
+ className={styles.filterCell}
2674
+ >
2675
+ {header.filter ? (
2676
+ <input
2677
+ type={
2678
+ header.filter.type ===
2679
+ 'date'
2680
+ ? 'date'
2681
+ : 'text'
2682
+ }
2683
+ className={styles.filterInput}
2684
+ placeholder={`Filter ${header.label}...`}
2685
+ value={
2686
+ filterValues[header.key] ||
2687
+ ''
2688
+ }
2689
+ onChange={(e) =>
2690
+ handleFilterChange(
2691
+ header.key,
2692
+ e.target.value
2693
+ )
2694
+ }
2695
+ />
2696
+ ) : null}
2697
+ </th>
2698
+ ))}
2699
+ </tr>
2700
+ )}
2701
+ </thead>
2702
+ <tbody>
2703
+ {sortedData && sortedData.length > 0 ? (
2704
+ sortedData.map((row, rowIndex) => (
2705
+ <tr
2706
+ key={`row-${rowIndex}`}
2707
+ className={`${styles.gridRow} ${
2708
+ onRowClick ? styles.clickableRow : ''
2709
+ }`}
2710
+ style={getCustomStyles().hoverColor}
2711
+ onClick={() =>
2712
+ handleRowClick(row, rowIndex)
2713
+ }
2714
+ >
2715
+ {/* Use gridHeaders if available, otherwise fall back to columns */}
2716
+ {gridHeaders.length > 0
2717
+ ? gridHeaders.map(
2718
+ (header, colIndex) => (
2719
+ <td
2720
+ key={`cell-${rowIndex}-${colIndex}`}
2721
+ className={`${
2722
+ styles.gridCell
2723
+ } ${
2724
+ header.onClick &&
2725
+ !quarterCellHasContent(
2726
+ header,
2727
+ row
2728
+ ) &&
2729
+ isQuarterCellClickable(
2730
+ header,
2731
+ row
2732
+ )
2733
+ ? styles.clickableCell
2734
+ : ''
2735
+ }`}
2736
+ onClick={
2737
+ header.onClick &&
2738
+ !quarterCellHasContent(
2739
+ header,
2740
+ row
2741
+ ) &&
2742
+ isQuarterCellClickable(
2743
+ header,
2744
+ row
2745
+ )
2746
+ ? (e) =>
2747
+ handleCellClick(
2748
+ e,
2749
+ header,
2750
+ row
2751
+ )
2752
+ : undefined
2753
+ }
2754
+ >
2755
+ {quarterCellHasContent(
2756
+ header,
2757
+ row
2758
+ ) ? (
2759
+ header.onClick ? (
2760
+ <span
2761
+ className={
2762
+ styles.cellWithValue
2763
+ }
2764
+ >
2765
+ {formatCellContent(
2766
+ row[
2767
+ header
2768
+ .key
2769
+ ],
2770
+ {
2771
+ ...header,
2772
+ type:
2773
+ header.type ||
2774
+ (typeof row[
2775
+ header
2776
+ .key
2777
+ ] ===
2778
+ 'number'
2779
+ ? 'number'
2780
+ : ''),
2781
+ },
2782
+ row
2783
+ )}
2784
+ </span>
2785
+ ) : (
2786
+ <span
2787
+ className={
2788
+ styles.cellContent
2789
+ }
2790
+ data-type={
2791
+ header.type ||
2792
+ (typeof row[
2793
+ header
2794
+ .key
2795
+ ] ===
2796
+ 'number'
2797
+ ? 'number'
2798
+ : '')
2799
+ }
2800
+ >
2801
+ {formatCellContent(
2802
+ row[
2803
+ header
2804
+ .key
2805
+ ],
2806
+ {
2807
+ ...header,
2808
+ type:
2809
+ header.type ||
2810
+ (typeof row[
2811
+ header
2812
+ .key
2813
+ ] ===
2814
+ 'number'
2815
+ ? 'number'
2816
+ : ''),
2817
+ },
2818
+ row
2819
+ )}
2820
+ </span>
2821
+ )
2822
+ ) : header.onClick &&
2823
+ isQuarterCellClickable(
2824
+ header,
2825
+ row
2826
+ ) ? (
2827
+ <span
2828
+ className={
2829
+ styles.placeholderText
2830
+ }
2831
+ >
2832
+ {header.onClick
2833
+ .placeholder ||
2834
+ 'Click to set'}
2835
+ </span>
2836
+ ) : (
2837
+ ''
2838
+ )}
2839
+ </td>
2840
+ )
2841
+ )
2842
+ : columns.map((col, colIndex) => (
2843
+ <td
2844
+ key={`cell-${rowIndex}-${colIndex}`}
2845
+ className={styles.gridCell}
2846
+ >
2847
+ <span
2848
+ className={
2849
+ styles.cellContent
2850
+ }
2851
+ data-type={
2852
+ col.type ||
2853
+ (typeof row[
2854
+ col.name
2855
+ ] === 'number'
2856
+ ? 'number'
2857
+ : '')
2858
+ }
2859
+ >
2860
+ {formatCellContent(
2861
+ row[col.name],
2862
+ col
2863
+ )}
2864
+ </span>
2865
+ </td>
2866
+ ))}
2867
+ </tr>
2868
+ ))
2869
+ ) : (
2870
+ <tr>
2871
+ <td
2872
+ colSpan={
2873
+ gridHeaders.length || headers.length
2874
+ }
2875
+ className={styles.noData}
2876
+ >
2877
+ No data available
2878
+ </td>
2879
+ </tr>
2880
+ )}
2881
+ </tbody>
2882
+ </table>
2883
+ )}
2884
+ </div>
2885
+ );
2886
+ };
2887
+
2888
+ export default GenericGrid;