@visns-studio/visns-components 5.10.10 → 5.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1534 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from 'react';
8
+ import { toast } from 'react-toastify';
9
+ import parse from 'html-react-parser';
10
+ import moment from 'moment';
11
+ import numeral from 'numeral';
12
+
13
+ import CustomFetch from '../Fetch';
14
+ import MultiSelect from '../MultiSelect';
15
+ import { showDatePickerDialog } from './DatePickerDialog';
16
+ import { showContactSelectorModal } from './ContactSelectorModal';
17
+ import styles from './styles/GenericIndex.module.scss';
18
+
19
+ // ContactTooltip component for enhanced contact display
20
+ const ContactTooltip = ({ contacts, children }) => {
21
+ const [isVisible, setIsVisible] = useState(false);
22
+ const [position, setPosition] = useState({ x: 0, y: 0 });
23
+ const tooltipRef = useRef(null);
24
+ const triggerRef = useRef(null);
25
+
26
+ const handleMouseEnter = (e) => {
27
+ if (!contacts || contacts.length === 0) return;
28
+
29
+ const rect = e.currentTarget.getBoundingClientRect();
30
+ setPosition({
31
+ x: rect.left + rect.width / 2,
32
+ y: rect.top - 10
33
+ });
34
+ setIsVisible(true);
35
+ };
36
+
37
+ const handleMouseLeave = () => {
38
+ setIsVisible(false);
39
+ };
40
+
41
+ const formatContact = (contact) => {
42
+ const name = contact.name || 'Unknown Contact';
43
+ const email = contact.email || '';
44
+ const type = 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>{contacts.length} Contact{contacts.length !== 1 ? 's' : ''}</strong>
74
+ </div>
75
+ <div className={styles.contactTooltipBody}>
76
+ {contacts.map((contact, index) => {
77
+ const { name, email, type } = formatContact(contact);
78
+ return (
79
+ <div key={index} className={styles.contactTooltipItem}>
80
+ <div className={styles.contactName}>{name}</div>
81
+ {email && <div className={styles.contactEmail}>{email}</div>}
82
+ <div className={styles.contactType}>{type}</div>
83
+ </div>
84
+ );
85
+ })}
86
+ </div>
87
+ <div className={styles.contactTooltipArrow}></div>
88
+ </div>
89
+ )}
90
+ </>
91
+ );
92
+ };
93
+
94
+ const GenericGrid = ({ config, userProfile, data, onDataChange, onRowClick }) => {
95
+ const [gridData, setGridData] = useState([]);
96
+ const [sortedData, setSortedData] = useState([]);
97
+ const [, setFilteredData] = useState([]);
98
+ const [filterValues, setFilterValues] = useState({});
99
+ const [settingsFilterValues, setSettingsFilterValues] = useState({});
100
+ const [filterOptions, setFilterOptions] = useState({});
101
+ const [loading, setLoading] = useState(false);
102
+ const [filtersCollapsed, setFiltersCollapsed] = useState(false);
103
+ const [sortConfig, setSortConfig] = useState({
104
+ key: null,
105
+ direction: 'asc',
106
+ });
107
+ const prevFetchParamsRef = useRef(null);
108
+
109
+ // Extract data and settings from config using useMemo to prevent unnecessary re-renders
110
+ const {
111
+ settings,
112
+ columns = [],
113
+ data: configData = [],
114
+ } = useMemo(
115
+ () => ({
116
+ settings: config.settings || {},
117
+ columns: config.columns || [],
118
+ data: config.data || data || [],
119
+ }),
120
+ [config.settings, config.columns, config.data, data]
121
+ );
122
+
123
+ // Create table headers from columns
124
+ const headers = useMemo(
125
+ () => columns.map((col) => col.header || col.name || ''),
126
+ [columns]
127
+ );
128
+
129
+ // Format cell content based on column type or value type
130
+ const formatCellContent = (value, column, row = null) => {
131
+ if (value === null || value === undefined) return '';
132
+ if (value === 0) return '0'; // Explicitly handle zero values
133
+ if (value === '') return '';
134
+
135
+ // Check if this is a date field with contacts (survey workflow)
136
+ if (column.type === 'date' && column.onClick?.type === 'date_with_contacts' && row) {
137
+ return formatDateWithContacts(value, column, row);
138
+ }
139
+
140
+ // Try to infer the type if not provided
141
+ const valueType = column.type || typeof value;
142
+
143
+ switch (valueType) {
144
+ case 'date':
145
+ return moment(value).isValid()
146
+ ? moment(value).format(column.format || 'DD/MM/YYYY')
147
+ : value;
148
+ case 'datetime':
149
+ return moment(value).isValid()
150
+ ? moment(value).format(column.format || 'DD/MM/YYYY HH:mm')
151
+ : value;
152
+ case 'currency':
153
+ return numeral(value).format(column.format || '$0,0.00');
154
+ case 'number':
155
+ // Check if it's actually a number before formatting
156
+ return !isNaN(parseFloat(value)) && isFinite(value)
157
+ ? numeral(value).format(column.format || '0,0')
158
+ : value;
159
+ case 'boolean':
160
+ if (
161
+ value === 1 ||
162
+ value === true ||
163
+ value === 'true' ||
164
+ value === 'yes' ||
165
+ value === 'Yes'
166
+ ) {
167
+ return 'Yes';
168
+ }
169
+ if (
170
+ value === 0 ||
171
+ value === false ||
172
+ value === 'false' ||
173
+ value === 'no' ||
174
+ value === 'No'
175
+ ) {
176
+ return 'No';
177
+ }
178
+ return value;
179
+ case 'year':
180
+ // Special handling for year values
181
+ return value.toString();
182
+ case 'quarter':
183
+ // Special handling for quarter values (Q1, Q2, etc.)
184
+ if (value && value.toString().startsWith('Q')) {
185
+ return value;
186
+ }
187
+ return value;
188
+ case 'html':
189
+ // Explicitly parse HTML content
190
+ return parse(String(value));
191
+ case 'richtext':
192
+ // Parse rich text content
193
+ return parse(String(value));
194
+ default:
195
+ // For text values, check if it contains HTML tags
196
+ if (
197
+ typeof value === 'string' &&
198
+ (value.includes('<br') ||
199
+ value.includes('<p>') ||
200
+ value.includes('<div') ||
201
+ value.includes('<span'))
202
+ ) {
203
+ // Ensure <br /> tags are properly handled
204
+ const processedValue = String(value)
205
+ .replace(/<br \/>/g, '<br>')
206
+ .replace(/<br\/>/g, '<br>');
207
+ return parse(processedValue);
208
+ }
209
+ return value;
210
+ }
211
+ };
212
+
213
+ // Format date field with associated contacts
214
+ const formatDateWithContacts = (dateValue, column, row) => {
215
+ const contactsField = column.key + '_contacts';
216
+ const contacts = row[contactsField];
217
+
218
+ // Format the date
219
+ const formattedDate = moment(dateValue).isValid()
220
+ ? moment(dateValue).format(column.format || 'DD/MM/YYYY')
221
+ : dateValue;
222
+
223
+ // If no contacts, just return the date
224
+ if (!contacts || !Array.isArray(contacts) || contacts.length === 0) {
225
+ return formattedDate;
226
+ }
227
+
228
+ // Create JSX element to display date + contacts with enhanced tooltip
229
+ return (
230
+ <div className={styles.dateWithContacts}>
231
+ <div className={styles.dateValue}>{formattedDate}</div>
232
+ <ContactTooltip contacts={contacts}>
233
+ <div className={styles.contactsSummary}>
234
+ 📧 {contacts.length} contact{contacts.length !== 1 ? 's' : ''}
235
+ </div>
236
+ </ContactTooltip>
237
+ </div>
238
+ );
239
+ };
240
+
241
+
242
+ // State for headers from API response
243
+ const [gridHeaders, setGridHeaders] = useState([]);
244
+
245
+ // Function to sort data
246
+ const sortData = useCallback(
247
+ (data, key, direction) => {
248
+ if (!key) return [...data];
249
+
250
+ return [...data].sort((a, b) => {
251
+ // Find the header config for this key to determine type
252
+ const headerConfig = gridHeaders.find((h) => h.key === key);
253
+ const type = headerConfig?.type || 'text';
254
+
255
+ // Get values, handling null/undefined
256
+ let aValue =
257
+ a[key] === null || a[key] === undefined ? '' : a[key];
258
+ let bValue =
259
+ b[key] === null || b[key] === undefined ? '' : b[key];
260
+
261
+ // Sort based on type
262
+ switch (type) {
263
+ case 'number':
264
+ case 'year':
265
+ aValue = parseFloat(aValue) || 0;
266
+ bValue = parseFloat(bValue) || 0;
267
+ break;
268
+ case 'date':
269
+ // Convert to timestamp for comparison
270
+ aValue = aValue ? new Date(aValue).getTime() : 0;
271
+ bValue = bValue ? new Date(bValue).getTime() : 0;
272
+ break;
273
+ default:
274
+ // For text, convert to lowercase strings
275
+ aValue = String(aValue).toLowerCase();
276
+ bValue = String(bValue).toLowerCase();
277
+ }
278
+
279
+ // Compare values
280
+ if (aValue < bValue) {
281
+ return direction === 'asc' ? -1 : 1;
282
+ }
283
+ if (aValue > bValue) {
284
+ return direction === 'asc' ? 1 : -1;
285
+ }
286
+ return 0;
287
+ });
288
+ },
289
+ [gridHeaders]
290
+ );
291
+
292
+ // Handle sort request
293
+ const requestSort = useCallback((key) => {
294
+ // If clicking the same column, toggle direction
295
+ // Otherwise, start with ascending sort for the new column
296
+ setSortConfig((prevConfig) => {
297
+ const newDirection =
298
+ prevConfig.key === key && prevConfig.direction === 'asc'
299
+ ? 'desc'
300
+ : 'asc';
301
+
302
+ return { key, direction: newDirection };
303
+ });
304
+ }, []);
305
+
306
+ // Function to filter data based on filter values
307
+ const filterData = useCallback(
308
+ (data) => {
309
+ // If no filter values are set, return the original data
310
+ if (Object.keys(filterValues).length === 0) {
311
+ return data;
312
+ }
313
+
314
+ // Filter the data based on the filter values
315
+ return data.filter((row) => {
316
+ // Check each filter
317
+ return Object.entries(filterValues).every(([key, value]) => {
318
+ // Skip empty filter values
319
+ if (!value) return true;
320
+
321
+ // Get the row value
322
+ const rowValue = row[key];
323
+ if (rowValue === null || rowValue === undefined)
324
+ return false;
325
+
326
+ // Find the header config for this key
327
+ const headerConfig = gridHeaders.find((h) => h.key === key);
328
+ if (!headerConfig || !headerConfig.filter) return true;
329
+
330
+ const { type, operator } = headerConfig.filter;
331
+
332
+ // Apply filter based on type and operator
333
+ switch (type) {
334
+ case 'string':
335
+ const rowStr = String(rowValue).toLowerCase();
336
+ const filterStr = String(value).toLowerCase();
337
+
338
+ switch (operator) {
339
+ case 'equals':
340
+ return rowStr === filterStr;
341
+ case 'contains':
342
+ return rowStr.includes(filterStr);
343
+ case 'startsWith':
344
+ return rowStr.startsWith(filterStr);
345
+ case 'endsWith':
346
+ return rowStr.endsWith(filterStr);
347
+ default:
348
+ return rowStr.includes(filterStr);
349
+ }
350
+
351
+ case 'number':
352
+ const rowNum = parseFloat(rowValue);
353
+ const filterNum = parseFloat(value);
354
+
355
+ if (isNaN(rowNum) || isNaN(filterNum)) return false;
356
+
357
+ switch (operator) {
358
+ case 'equals':
359
+ return rowNum === filterNum;
360
+ case 'greaterThan':
361
+ return rowNum > filterNum;
362
+ case 'lessThan':
363
+ return rowNum < filterNum;
364
+ case 'greaterThanOrEqual':
365
+ return rowNum >= filterNum;
366
+ case 'lessThanOrEqual':
367
+ return rowNum <= filterNum;
368
+ default:
369
+ return rowNum === filterNum;
370
+ }
371
+
372
+ case 'date':
373
+ const rowDate = new Date(rowValue);
374
+ const filterDate = new Date(value);
375
+
376
+ if (
377
+ isNaN(rowDate.getTime()) ||
378
+ isNaN(filterDate.getTime())
379
+ )
380
+ return false;
381
+
382
+ switch (operator) {
383
+ case 'equals':
384
+ return (
385
+ rowDate.toDateString() ===
386
+ filterDate.toDateString()
387
+ );
388
+ case 'after':
389
+ return rowDate > filterDate;
390
+ case 'before':
391
+ return rowDate < filterDate;
392
+ default:
393
+ return (
394
+ rowDate.toDateString() ===
395
+ filterDate.toDateString()
396
+ );
397
+ }
398
+
399
+ default:
400
+ return true;
401
+ }
402
+ });
403
+ });
404
+ },
405
+ [filterValues, gridHeaders]
406
+ );
407
+
408
+ // Handle filter change
409
+ const handleFilterChange = useCallback((key, value) => {
410
+ setFilterValues((prev) => ({
411
+ ...prev,
412
+ [key]: value,
413
+ }));
414
+ }, []);
415
+
416
+ // Effect to update filteredData and sortedData when gridData, filterValues, or sortConfig changes
417
+ useEffect(() => {
418
+ if (gridData.length > 0) {
419
+ // First apply filters
420
+ const filtered = filterData(gridData);
421
+ setFilteredData(filtered);
422
+
423
+ // Then sort the filtered data
424
+ const sorted = sortData(
425
+ filtered,
426
+ sortConfig.key,
427
+ sortConfig.direction
428
+ );
429
+ setSortedData(sorted);
430
+ } else {
431
+ setFilteredData([]);
432
+ setSortedData([]);
433
+ }
434
+ }, [gridData, filterValues, sortConfig, sortData, filterData]);
435
+
436
+ // Load data from API if gridSetting.fetch is provided
437
+ useEffect(() => {
438
+ const fetchData = async () => {
439
+ // Check if we should use static data instead of fetching
440
+ if (settings?.useStaticData && settings?.staticData) {
441
+ const staticData = settings.staticData;
442
+
443
+ if (staticData.header && staticData.rows) {
444
+ // Set the headers from the static data
445
+ const headerEntries = Object.entries(staticData.header);
446
+ setGridHeaders(
447
+ headerEntries.map(([key, config]) => ({
448
+ key,
449
+ label: config.label || key,
450
+ sort: config.sort || key,
451
+ type: config.type || 'text',
452
+ filter: config.filter || null,
453
+ onClick: config.onClick || null,
454
+ }))
455
+ );
456
+
457
+ // Initialize filter values
458
+ const initialFilterValues = {};
459
+ headerEntries.forEach(([key, config]) => {
460
+ if (config.filter) {
461
+ initialFilterValues[key] = '';
462
+ }
463
+ });
464
+ setFilterValues(initialFilterValues);
465
+
466
+ // Set the rows data
467
+ const newData = staticData.rows || [];
468
+ setGridData(newData);
469
+ setSortedData(newData);
470
+
471
+ // Notify parent component of data change
472
+ if (onDataChange) {
473
+ onDataChange(newData);
474
+ }
475
+ }
476
+ return;
477
+ }
478
+
479
+ // Only fetch if we have a URL
480
+ if (!settings?.fetch?.url) {
481
+ if (configData && configData.length > 0) {
482
+ // Use provided data if no fetch setting
483
+ setGridData(configData);
484
+ }
485
+ return;
486
+ }
487
+
488
+ const url = settings.fetch.url;
489
+ const method = settings.fetch.method || 'POST';
490
+
491
+ // Combine base params with settings filter values
492
+ const params = {
493
+ ...(settings.fetch.params || {}),
494
+ ...settingsFilterValues,
495
+ };
496
+
497
+ // Create a string representation of the fetch parameters for comparison
498
+ const fetchParamsString = JSON.stringify({ url, method, params });
499
+
500
+ // Skip fetch if the parameters haven't changed
501
+ if (prevFetchParamsRef.current === fetchParamsString) {
502
+ return;
503
+ }
504
+
505
+ // Update the ref with current parameters
506
+ prevFetchParamsRef.current = fetchParamsString;
507
+
508
+ setLoading(true);
509
+ try {
510
+ const response = await CustomFetch(url, method, params);
511
+
512
+ if (response.data.data) {
513
+ // Check if the response has the expected structure with header and rows
514
+ if (response.data.data.header && response.data.data.rows) {
515
+ // Set the headers from the response with the new structure
516
+ const headerEntries = Object.entries(
517
+ response.data.data.header
518
+ );
519
+ setGridHeaders(
520
+ headerEntries.map(([key, config]) => ({
521
+ key,
522
+ label: config.label || key,
523
+ sort: config.sort || key,
524
+ type: config.type || 'text',
525
+ filter: config.filter || null,
526
+ onClick: config.onClick || null,
527
+ }))
528
+ );
529
+
530
+ // Initialize filter values
531
+ const initialFilterValues = {};
532
+ headerEntries.forEach(([key, config]) => {
533
+ if (config.filter) {
534
+ initialFilterValues[key] = '';
535
+ }
536
+ });
537
+ setFilterValues(initialFilterValues);
538
+
539
+ // Set the rows data
540
+ const newData = response.data.data.rows || [];
541
+ setGridData(newData);
542
+ setSortedData(newData);
543
+
544
+ // Notify parent component of data change
545
+ if (onDataChange) {
546
+ onDataChange(newData);
547
+ }
548
+ } else {
549
+ // Fallback to the previous behavior if the structure is different
550
+ const newData = response.data.data.data || [];
551
+ setGridData(newData);
552
+ setSortedData(newData);
553
+
554
+ // Notify parent component of data change
555
+ if (onDataChange) {
556
+ onDataChange(newData);
557
+ }
558
+ }
559
+ }
560
+ } catch (error) {
561
+ console.error('Error fetching grid data:', error);
562
+ } finally {
563
+ setLoading(false);
564
+ }
565
+ };
566
+
567
+ fetchData();
568
+ }, [
569
+ // Only depend on the specific properties we need, not the entire objects
570
+ settings?.fetch?.url,
571
+ settings?.fetch?.method,
572
+ // Use JSON.stringify for the params to detect deep changes
573
+ JSON.stringify(settings?.fetch?.params),
574
+ JSON.stringify(settingsFilterValues),
575
+ // Only depend on data if we're not fetching
576
+ !settings?.fetch?.url ? JSON.stringify(configData) : null,
577
+ // Include static data dependencies
578
+ settings?.useStaticData,
579
+ JSON.stringify(settings?.staticData),
580
+ onDataChange,
581
+ ]);
582
+
583
+ // Generate year options for dropdown
584
+ const generateYearOptions = useCallback((startYear) => {
585
+ const currentYear = new Date().getFullYear();
586
+ const years = [];
587
+
588
+ // Start from the specified year and go up to current year
589
+ for (let year = startYear; year <= currentYear + 1; year++) {
590
+ years.push({ value: year.toString(), label: year.toString() });
591
+ }
592
+
593
+ return years;
594
+ }, []);
595
+
596
+ // Fetch filter options from URL
597
+ const fetchFilterOptions = useCallback(async (filter) => {
598
+ if (!filter.url) return;
599
+
600
+ try {
601
+ const response = await CustomFetch(filter.url, 'POST', {});
602
+ if (response.data && response.data.data) {
603
+ setFilterOptions(prev => ({
604
+ ...prev,
605
+ [filter.id]: response.data.data
606
+ }));
607
+ }
608
+ } catch (error) {
609
+ console.error(`Error fetching options for ${filter.id}:`, error);
610
+ toast.error(`Failed to load ${filter.label} options`);
611
+ }
612
+ }, []);
613
+
614
+ // Initialize settings filters and fetch options
615
+ useEffect(() => {
616
+ if (settings?.filters && settings.filters.length > 0) {
617
+ const initialValues = {};
618
+
619
+ settings.filters.forEach((filter) => {
620
+ if ((filter.type === 'dropdown' || filter.type === 'multiselect') && (filter.id === 'year' || filter.startYear)) {
621
+ // For year filters with 'now' value, set to current year
622
+ if (filter.value === 'now') {
623
+ const currentYear = new Date().getFullYear().toString();
624
+ // For multiselect, check isMulti to determine if it should be array or single value
625
+ if (filter.type === 'multiselect') {
626
+ const value = filter.isMulti === false ? currentYear : [currentYear];
627
+ initialValues[filter.id] = value;
628
+ } else {
629
+ initialValues[filter.id] = currentYear;
630
+ }
631
+ } else {
632
+ if (filter.type === 'multiselect') {
633
+ initialValues[filter.id] = filter.isMulti === false ? (filter.value || '') : (filter.value || []);
634
+ } else {
635
+ initialValues[filter.id] = filter.value || '';
636
+ }
637
+ }
638
+ } else if (filter.type === 'multiselect') {
639
+ // For multiselect, handle 'now' value and initialize based on isMulti
640
+ if (filter.value === 'now') {
641
+ const currentYear = new Date().getFullYear().toString();
642
+ initialValues[filter.id] = filter.isMulti === false ? currentYear : [currentYear];
643
+ } else {
644
+ if (filter.isMulti === false) {
645
+ // Single select mode - use single value
646
+ initialValues[filter.id] = Array.isArray(filter.value) ? filter.value[0] || '' : (filter.value || '');
647
+ } else {
648
+ // Multi select mode - use array
649
+ initialValues[filter.id] = Array.isArray(filter.value) ? filter.value : (filter.value ? [filter.value] : []);
650
+ }
651
+ }
652
+ } else {
653
+ // For other filter types
654
+ if (filter.value === 'now') {
655
+ initialValues[filter.id] = new Date().getFullYear().toString();
656
+ } else {
657
+ initialValues[filter.id] = filter.value || '';
658
+ }
659
+ }
660
+
661
+ // Fetch options for dropdowns with URL
662
+ if ((filter.type === 'dropdown' || filter.type === 'multiselect') && filter.url) {
663
+ fetchFilterOptions(filter);
664
+ }
665
+ });
666
+
667
+ setSettingsFilterValues(initialValues);
668
+ }
669
+ }, [settings?.filters, fetchFilterOptions]);
670
+
671
+ // Handle settings filter change
672
+ const handleSettingsFilterChange = useCallback(
673
+ (filterId, value) => {
674
+ setSettingsFilterValues((prev) => ({
675
+ ...prev,
676
+ [filterId]: value,
677
+ }));
678
+
679
+ // Update fetch params with the new filter value
680
+ if (settings?.fetch?.params) {
681
+ let processedValue = value;
682
+
683
+ // For multiselect, convert array to appropriate format for API
684
+ if (Array.isArray(value)) {
685
+ // Keep as array - most APIs expect this format for multiple values
686
+ processedValue = value;
687
+ }
688
+
689
+ const updatedParams = {
690
+ ...settings.fetch.params,
691
+ [filterId]: processedValue,
692
+ };
693
+
694
+ // Update the settings object
695
+ if (config.settings && config.settings.fetch) {
696
+ config.settings.fetch.params = updatedParams;
697
+ }
698
+
699
+ // Trigger a re-fetch with the new params
700
+ prevFetchParamsRef.current = null;
701
+ }
702
+ },
703
+ [settings, config]
704
+ );
705
+
706
+ // Apply custom styling from userProfile if available
707
+ const getCustomStyles = () => {
708
+ const customStyles = {};
709
+
710
+ if (userProfile?.settings?.style) {
711
+ const { style } = userProfile.settings;
712
+
713
+ if (style.hoverColor) {
714
+ customStyles.hoverColor = {
715
+ '--hover-color': style.hoverColor,
716
+ };
717
+ }
718
+ }
719
+
720
+ return customStyles;
721
+ };
722
+
723
+ // CSS for sort indicators
724
+ const getSortIndicatorStyle = (key) => {
725
+ if (sortConfig.key !== key) {
726
+ return { opacity: 0.3 }; // Dim the icon when not sorting by this column
727
+ }
728
+ return {}; // Full opacity when sorting by this column
729
+ };
730
+
731
+ // Handle row click
732
+ const handleRowClick = useCallback((row, rowIndex) => {
733
+ if (onRowClick) {
734
+ onRowClick(row, rowIndex);
735
+ }
736
+ }, [onRowClick]);
737
+
738
+ // Handle cell click with support for date picker and contact selection
739
+ const handleCellClick = useCallback(async (e, header, row) => {
740
+ e.stopPropagation(); // Prevent row click
741
+
742
+ try {
743
+ // Debug logging to check configuration
744
+ const dateOnlyConfig = header.onClick.dateOnly || userProfile?.settings?.dateOnly || false;
745
+ console.log('Cell click configuration:', {
746
+ column: header.label,
747
+ clickType: header.onClick.type || 'date',
748
+ hasContactSelection: !!header.onClick.contactSelection,
749
+ headerDateOnly: header.onClick.dateOnly,
750
+ userProfileDateOnly: userProfile?.settings?.dateOnly,
751
+ finalDateOnly: dateOnlyConfig
752
+ });
753
+
754
+ // Determine the interaction type
755
+ const clickType = header.onClick.type || 'date';
756
+
757
+ if (clickType === 'date_with_contacts') {
758
+ // Multi-step process: Date selection first, then contact selection
759
+ await handleDateWithContactsFlow(header, row, dateOnlyConfig);
760
+ } else {
761
+ // Simple date selection (backward compatibility)
762
+ await handleSimpleDateSelection(header, row, dateOnlyConfig);
763
+ }
764
+ } catch (error) {
765
+ console.error('Error handling cell click:', error);
766
+ toast.error('An error occurred while opening the dialog');
767
+ }
768
+ }, [settings, settingsFilterValues, onDataChange, userProfile]);
769
+
770
+ // Handle simple date selection (existing functionality)
771
+ const handleSimpleDateSelection = useCallback(async (header, row, dateOnlyConfig) => {
772
+ await showDatePickerDialog({
773
+ title: header.onClick.title || 'Select Date',
774
+ message: header.onClick.message || header.onClick.placeholder || `Please select a date for ${header.label}:`,
775
+ confirmLabel: 'Set Date',
776
+ cancelLabel: 'Cancel',
777
+ defaultDate: header.onClick.params?.value === 'now' ? 'now' : (row[header.key] || null),
778
+ data: { name: header.key, label: header.label, id: row.id },
779
+ // Timezone configuration - can be set via userProfile or header config
780
+ timezone: header.onClick.timezone || userProfile?.settings?.timezone || 'auto',
781
+ forcePerth: header.onClick.forcePerth || userProfile?.settings?.forcePerth || false,
782
+ // Date field type - set to true for DATE fields, false for DATETIME fields
783
+ dateOnly: dateOnlyConfig,
784
+ onConfirm: async (selectedDateISO) => {
785
+ await submitDateUpdate(header, row, selectedDateISO);
786
+ },
787
+ onCancel: () => {
788
+ // Do nothing when cancelled
789
+ }
790
+ });
791
+ }, [settings, settingsFilterValues, onDataChange, userProfile]);
792
+
793
+ // Handle date selection with contact selection flow
794
+ const handleDateWithContactsFlow = useCallback(async (header, row, dateOnlyConfig) => {
795
+ // Step 1: Date selection
796
+ await showDatePickerDialog({
797
+ title: header.onClick.title || 'Select Date',
798
+ message: header.onClick.message || header.onClick.placeholder || `Please select a date for ${header.label}:`,
799
+ confirmLabel: 'Next: Select Contacts',
800
+ cancelLabel: 'Cancel',
801
+ defaultDate: header.onClick.params?.value === 'now' ? 'now' : (row[header.key] || null),
802
+ data: { name: header.key, label: header.label, id: row.id },
803
+ timezone: header.onClick.timezone || userProfile?.settings?.timezone || 'auto',
804
+ forcePerth: header.onClick.forcePerth || userProfile?.settings?.forcePerth || false,
805
+ dateOnly: dateOnlyConfig,
806
+ onConfirm: async (selectedDateISO) => {
807
+ // Step 2: Contact selection
808
+ const contactFieldKey = header.key + '_contacts';
809
+ const existingContacts = row[contactFieldKey] || [];
810
+
811
+ console.log('Opening contact selector with row context:', {
812
+ rowKeys: Object.keys(row),
813
+ rowClientId: row.client_id,
814
+ urlParams: header.onClick.contactSelection?.urlParams,
815
+ fullRow: row
816
+ });
817
+
818
+ await showContactSelectorModal({
819
+ title: header.onClick.contactSelection?.title || 'Select Contacts',
820
+ message: header.onClick.contactSelection?.message || `Add contacts for ${header.label}:`,
821
+ confirmLabel: 'Save',
822
+ cancelLabel: 'Cancel',
823
+ existingContacts: Array.isArray(existingContacts) ? existingContacts : [],
824
+ contactConfig: {
825
+ contactsUrl: header.onClick.contactSelection?.contactsUrl || '/ajax/contacts/dropdown',
826
+ urlParams: header.onClick.contactSelection?.urlParams || {},
827
+ allowManualEntry: header.onClick.contactSelection?.allowManualEntry !== false,
828
+ fields: header.onClick.contactSelection?.fields || ['name', 'email'],
829
+ multiple: header.onClick.contactSelection?.multiple !== false,
830
+ maxContacts: header.onClick.contactSelection?.maxContacts || 10
831
+ },
832
+ data: { name: header.key, label: header.label, id: row.id },
833
+ rowContext: row,
834
+ onConfirm: async (selectedContacts) => {
835
+ // Submit both date and contacts
836
+ await submitDateAndContactsUpdate(header, row, selectedDateISO, selectedContacts);
837
+ },
838
+ onCancel: () => {
839
+ // User cancelled contact selection - do nothing
840
+ }
841
+ });
842
+ },
843
+ onCancel: () => {
844
+ // User cancelled date selection - do nothing
845
+ }
846
+ });
847
+ }, [settings, settingsFilterValues, onDataChange, userProfile]);
848
+
849
+ // Submit date update only
850
+ const submitDateUpdate = useCallback(async (header, row, selectedDateISO) => {
851
+ try {
852
+ // Replace {id} in the URL with the row's ID
853
+ const url = header.onClick.url.replace('{id}', row.id);
854
+
855
+ // Show loading toast
856
+ toast.info('Updating...', { autoClose: 1000 });
857
+
858
+ // Extract field and value from params
859
+ const { field, ...otherParams } = header.onClick.params;
860
+
861
+ // Create a new params object with the field as the key
862
+ const params = {
863
+ ...otherParams,
864
+ _method: header.onClick.method, // Add _method for PUT/DELETE requests
865
+ };
866
+
867
+ // Set the field as the key with the selected date value
868
+ params[field] = selectedDateISO;
869
+
870
+ // Make the API call
871
+ const response = await CustomFetch(url, header.onClick.method, params);
872
+
873
+ if (response.data.error) {
874
+ toast.error(response.data.error);
875
+ } else {
876
+ toast.success('Date updated successfully', { autoClose: 1000 });
877
+ await refreshGridData();
878
+ }
879
+ } catch (error) {
880
+ console.error('Error updating date:', error);
881
+ toast.error('An error occurred while updating');
882
+ }
883
+ }, []);
884
+
885
+ // Submit date and contacts update
886
+ const submitDateAndContactsUpdate = useCallback(async (header, row, selectedDateISO, selectedContacts) => {
887
+ try {
888
+ // Replace {id} in the URL with the row's ID
889
+ const url = header.onClick.url.replace('{id}', row.id);
890
+
891
+ // Show loading toast
892
+ toast.info('Saving date and contacts...', { autoClose: 1000 });
893
+
894
+ // Extract field and value from params
895
+ const { field, ...otherParams } = header.onClick.params;
896
+
897
+ // Create a new params object with both date and contacts
898
+ const params = {
899
+ ...otherParams,
900
+ _method: header.onClick.method, // Add _method for PUT/DELETE requests
901
+ };
902
+
903
+ // Set the date field
904
+ params[field] = selectedDateISO;
905
+
906
+ // Set the contacts field (field name + '_contacts')
907
+ const contactsField = field + '_contacts';
908
+ params[contactsField] = selectedContacts;
909
+
910
+ console.log('Submitting date and contacts:', params);
911
+
912
+ // Make the API call
913
+ const response = await CustomFetch(url, header.onClick.method, params);
914
+
915
+ if (response.data.error) {
916
+ toast.error(response.data.error);
917
+ } else {
918
+ toast.success('Date and contacts saved successfully', { autoClose: 1500 });
919
+ await refreshGridData();
920
+ }
921
+ } catch (error) {
922
+ console.error('Error updating date and contacts:', error);
923
+ toast.error('An error occurred while saving');
924
+ }
925
+ }, []);
926
+
927
+ // Refresh grid data helper function
928
+ const refreshGridData = useCallback(async () => {
929
+ try {
930
+ // Refresh the data
931
+ prevFetchParamsRef.current = null;
932
+
933
+ // Force a re-fetch
934
+ const fetchUrl = settings.fetch.url;
935
+ const fetchMethod = settings.fetch.method || 'POST';
936
+ const fetchParams = {
937
+ ...(settings.fetch.params || {}),
938
+ ...settingsFilterValues,
939
+ };
940
+
941
+ setLoading(true);
942
+ const fetchResponse = await CustomFetch(fetchUrl, fetchMethod, fetchParams);
943
+ if (fetchResponse.data.data) {
944
+ if (fetchResponse.data.data.header && fetchResponse.data.data.rows) {
945
+ const newData = fetchResponse.data.data.rows || [];
946
+ setGridData(newData);
947
+ setSortedData(newData);
948
+ if (onDataChange) {
949
+ onDataChange(newData);
950
+ }
951
+ } else {
952
+ const newData = fetchResponse.data.data.data || [];
953
+ setGridData(newData);
954
+ setSortedData(newData);
955
+ if (onDataChange) {
956
+ onDataChange(newData);
957
+ }
958
+ }
959
+ }
960
+ } catch (error) {
961
+ console.error('Error refreshing data:', error);
962
+ toast.error('Error refreshing data');
963
+ } finally {
964
+ setLoading(false);
965
+ }
966
+ }, [settings, settingsFilterValues, onDataChange]);
967
+
968
+ return (
969
+ <div className={styles.gridContainer}>
970
+ {/* Settings Filters */}
971
+ {settings?.filters && settings.filters.length > 0 && (
972
+ <div className={styles.settingsFiltersContainer}>
973
+ <div className={styles.settingsFiltersHeader}>
974
+ <div
975
+ className={styles.headerLeft}
976
+ onClick={() =>
977
+ setFiltersCollapsed(!filtersCollapsed)
978
+ }
979
+ >
980
+ <div className={styles.settingsFiltersTitle}>
981
+ <svg
982
+ width="16"
983
+ height="16"
984
+ viewBox="0 0 24 24"
985
+ fill="none"
986
+ xmlns="http://www.w3.org/2000/svg"
987
+ >
988
+ <path
989
+ d="M4 6H20M7 12H17M9 18H15"
990
+ stroke="currentColor"
991
+ strokeWidth="2"
992
+ strokeLinecap="round"
993
+ strokeLinejoin="round"
994
+ />
995
+ </svg>
996
+ Filters
997
+ {/* Show active filters count when collapsed */}
998
+ {filtersCollapsed && (
999
+ <div
1000
+ className={
1001
+ styles.activeFiltersIndicator
1002
+ }
1003
+ >
1004
+ {Object.values(
1005
+ settingsFilterValues
1006
+ ).some((value) => value !== '') && (
1007
+ <>
1008
+ <span>Active:</span>
1009
+ <span
1010
+ className={
1011
+ styles.filterBadge
1012
+ }
1013
+ >
1014
+ {
1015
+ Object.values(
1016
+ settingsFilterValues
1017
+ ).filter(
1018
+ (value) =>
1019
+ value !== ''
1020
+ ).length
1021
+ }
1022
+ </span>
1023
+ </>
1024
+ )}
1025
+ </div>
1026
+ )}
1027
+ </div>
1028
+
1029
+ {/* Toggle icon */}
1030
+ <svg
1031
+ className={`${styles.toggleIcon} ${
1032
+ filtersCollapsed ? styles.collapsed : ''
1033
+ }`}
1034
+ width="16"
1035
+ height="16"
1036
+ viewBox="0 0 24 24"
1037
+ fill="none"
1038
+ xmlns="http://www.w3.org/2000/svg"
1039
+ >
1040
+ <path
1041
+ d="M19 9l-7 7-7-7"
1042
+ stroke="currentColor"
1043
+ strokeWidth="2"
1044
+ strokeLinecap="round"
1045
+ strokeLinejoin="round"
1046
+ />
1047
+ </svg>
1048
+ </div>
1049
+
1050
+ {/* Clear filters button */}
1051
+ <button
1052
+ className={styles.clearFiltersButton}
1053
+ onClick={(e) => {
1054
+ e.stopPropagation(); // Prevent toggling when clicking the button
1055
+ // Initialize with default values
1056
+ const initialValues = {};
1057
+ settings.filters.forEach((filter) => {
1058
+ if (
1059
+ (filter.type === 'dropdown' || filter.type === 'multiselect') &&
1060
+ (filter.id === 'year' || filter.startYear) &&
1061
+ filter.value === 'now'
1062
+ ) {
1063
+ const currentYear = new Date()
1064
+ .getFullYear()
1065
+ .toString();
1066
+ if (filter.type === 'multiselect') {
1067
+ initialValues[filter.id] = filter.isMulti === false ? currentYear : [currentYear];
1068
+ } else {
1069
+ initialValues[filter.id] = currentYear;
1070
+ }
1071
+ } else if (filter.type === 'multiselect') {
1072
+ if (filter.value === 'now') {
1073
+ const currentYear = new Date().getFullYear().toString();
1074
+ initialValues[filter.id] = filter.isMulti === false ? currentYear : [currentYear];
1075
+ } else {
1076
+ if (filter.isMulti === false) {
1077
+ initialValues[filter.id] = Array.isArray(filter.value) ? filter.value[0] || '' : (filter.value || '');
1078
+ } else {
1079
+ initialValues[filter.id] = Array.isArray(filter.value) ? filter.value : (filter.value ? [filter.value] : []);
1080
+ }
1081
+ }
1082
+ } else {
1083
+ if (filter.value === 'now') {
1084
+ initialValues[filter.id] = new Date().getFullYear().toString();
1085
+ } else {
1086
+ initialValues[filter.id] = filter.value || '';
1087
+ }
1088
+ }
1089
+ });
1090
+ setSettingsFilterValues(initialValues);
1091
+
1092
+ // Reset fetch params
1093
+ prevFetchParamsRef.current = null;
1094
+ }}
1095
+ >
1096
+ <svg
1097
+ viewBox="0 0 24 24"
1098
+ fill="none"
1099
+ xmlns="http://www.w3.org/2000/svg"
1100
+ >
1101
+ <path
1102
+ d="M19 7l-7 7-7-7"
1103
+ stroke="currentColor"
1104
+ strokeWidth="2"
1105
+ strokeLinecap="round"
1106
+ strokeLinejoin="round"
1107
+ transform="rotate(90, 12, 12)"
1108
+ />
1109
+ </svg>
1110
+ Clear
1111
+ </button>
1112
+ </div>
1113
+
1114
+ <div
1115
+ className={`${styles.settingsFiltersContent} ${
1116
+ filtersCollapsed ? styles.collapsed : ''
1117
+ }`}
1118
+ >
1119
+ {settings.filters.map((filter, index) => (
1120
+ <div
1121
+ key={`settings-filter-${index}`}
1122
+ className={styles.settingsFilterItem}
1123
+ >
1124
+ <label className={styles.settingsFilterLabel}>
1125
+ {filter.label}
1126
+ </label>
1127
+ {filter.type === 'dropdown' &&
1128
+ filter.id === 'year' ? (
1129
+ <select
1130
+ className={
1131
+ styles.settingsFilterDropdown
1132
+ }
1133
+ value={
1134
+ settingsFilterValues[filter.id] ||
1135
+ ''
1136
+ }
1137
+ onChange={(e) =>
1138
+ handleSettingsFilterChange(
1139
+ filter.id,
1140
+ e.target.value
1141
+ )
1142
+ }
1143
+ >
1144
+ {generateYearOptions(
1145
+ filter.startYear || 2000
1146
+ ).map((option) => (
1147
+ <option
1148
+ key={option.value}
1149
+ value={option.value}
1150
+ >
1151
+ {option.label}
1152
+ </option>
1153
+ ))}
1154
+ </select>
1155
+ ) : filter.type === 'dropdown' && filter.url ? (
1156
+ <select
1157
+ className={
1158
+ styles.settingsFilterDropdown
1159
+ }
1160
+ value={
1161
+ settingsFilterValues[filter.id] ||
1162
+ ''
1163
+ }
1164
+ onChange={(e) =>
1165
+ handleSettingsFilterChange(
1166
+ filter.id,
1167
+ e.target.value
1168
+ )
1169
+ }
1170
+ >
1171
+ <option value="">Select {filter.label}</option>
1172
+ {filterOptions[filter.id] &&
1173
+ filterOptions[filter.id].map((option) => (
1174
+ <option
1175
+ key={option.value || option.id}
1176
+ value={option.value || option.id}
1177
+ >
1178
+ {option.label || option.name || option.title}
1179
+ </option>
1180
+ ))}
1181
+ </select>
1182
+ ) : filter.type === 'dropdown' ? (
1183
+ <select
1184
+ className={
1185
+ styles.settingsFilterDropdown
1186
+ }
1187
+ value={
1188
+ settingsFilterValues[filter.id] ||
1189
+ ''
1190
+ }
1191
+ onChange={(e) =>
1192
+ handleSettingsFilterChange(
1193
+ filter.id,
1194
+ e.target.value
1195
+ )
1196
+ }
1197
+ >
1198
+ <option value="">Select {filter.label}</option>
1199
+ {filter.options &&
1200
+ filter.options.map((option) => (
1201
+ <option
1202
+ key={option.value}
1203
+ value={option.value}
1204
+ >
1205
+ {option.label}
1206
+ </option>
1207
+ ))}
1208
+ </select>
1209
+ ) : filter.type === 'multiselect' ? (
1210
+ <div className={styles.multiselectContainer}>
1211
+ <MultiSelect
1212
+ options={
1213
+ (filter.id === 'year' || filter.startYear) ?
1214
+ generateYearOptions(filter.startYear || 2000) :
1215
+ filter.url ?
1216
+ (filterOptions[filter.id] || []).map(option => ({
1217
+ value: option.value || option.id,
1218
+ label: option.label || option.name || option.title
1219
+ })) :
1220
+ filter.options || []
1221
+ }
1222
+ inputValue={(() => {
1223
+ const currentValue = settingsFilterValues[filter.id];
1224
+ const availableOptions = (filter.id === 'year' || filter.startYear) ?
1225
+ generateYearOptions(filter.startYear || 2000) :
1226
+ filter.url ?
1227
+ (filterOptions[filter.id] || []).map(option => ({
1228
+ value: option.value || option.id,
1229
+ label: option.label || option.name || option.title
1230
+ })) :
1231
+ filter.options || [];
1232
+
1233
+ if (filter.isMulti === false) {
1234
+ // Single select mode - find the matching option object
1235
+ if (currentValue) {
1236
+ const matchingOption = availableOptions.find(opt => opt.value === currentValue || opt.value === currentValue.toString());
1237
+ return matchingOption || null;
1238
+ }
1239
+ return null;
1240
+ } else {
1241
+ // Multi select mode - return array of matching option objects
1242
+ if (Array.isArray(currentValue) && currentValue.length > 0) {
1243
+ return currentValue.map(val => {
1244
+ const matchingOption = availableOptions.find(opt => opt.value === val || opt.value === val.toString());
1245
+ return matchingOption || { value: val, label: val };
1246
+ }).filter(Boolean);
1247
+ }
1248
+ return [];
1249
+ }
1250
+ })()}
1251
+ onChange={(selectedOptions) => {
1252
+ if (filter.isMulti === false) {
1253
+ // Single select mode - extract value from the selected option object
1254
+ const value = selectedOptions ? selectedOptions.value : '';
1255
+ handleSettingsFilterChange(filter.id, value);
1256
+ } else {
1257
+ // Multi select mode - extract values from array of option objects
1258
+ const values = Array.isArray(selectedOptions)
1259
+ ? selectedOptions.map(opt => opt.value)
1260
+ : [];
1261
+ handleSettingsFilterChange(filter.id, values);
1262
+ }
1263
+ }}
1264
+ placeholder={`Select ${filter.label}`}
1265
+ multi={filter.isMulti !== false} // Default to true, false only if explicitly set
1266
+ settings={{
1267
+ id: filter.id,
1268
+ limit: filter.limit || undefined
1269
+ }}
1270
+ />
1271
+ </div>
1272
+ ) : filter.type === 'text' ? (
1273
+ <input
1274
+ type="text"
1275
+ className={styles.settingsFilterInput}
1276
+ value={
1277
+ settingsFilterValues[filter.id] ||
1278
+ ''
1279
+ }
1280
+ onChange={(e) =>
1281
+ handleSettingsFilterChange(
1282
+ filter.id,
1283
+ e.target.value
1284
+ )
1285
+ }
1286
+ placeholder={filter.placeholder || ''}
1287
+ />
1288
+ ) : null}
1289
+ </div>
1290
+ ))}
1291
+ </div>
1292
+ </div>
1293
+ )}
1294
+
1295
+ {loading ? (
1296
+ <div className={styles.loadingContainer}>
1297
+ <div className={styles.loadingSpinner}></div>
1298
+ <p>Loading data...</p>
1299
+ </div>
1300
+ ) : (
1301
+ <table className={styles.gridTable}>
1302
+ <thead>
1303
+ <tr>
1304
+ {/* Use gridHeaders if available, otherwise fall back to headers from columns */}
1305
+ {gridHeaders.length > 0
1306
+ ? gridHeaders.map((header, index) => (
1307
+ <th
1308
+ key={`header-${index}`}
1309
+ className={`${styles.gridHeader} ${styles.sortableHeader}`}
1310
+ onClick={() =>
1311
+ requestSort(header.key)
1312
+ }
1313
+ style={{ cursor: 'pointer' }}
1314
+ >
1315
+ <div className={styles.headerContent}>
1316
+ {header.label}
1317
+ <span
1318
+ className={
1319
+ styles.sortIndicator
1320
+ }
1321
+ style={getSortIndicatorStyle(
1322
+ header.key
1323
+ )}
1324
+ >
1325
+ {sortConfig.key ===
1326
+ header.key &&
1327
+ sortConfig.direction === 'asc'
1328
+ ? ' ▲'
1329
+ : ' ▼'}
1330
+ </span>
1331
+ </div>
1332
+ </th>
1333
+ ))
1334
+ : headers.map((header, index) => (
1335
+ <th
1336
+ key={`header-${index}`}
1337
+ className={styles.gridHeader}
1338
+ >
1339
+ {header}
1340
+ </th>
1341
+ ))}
1342
+ </tr>
1343
+ {/* Filter row */}
1344
+ {gridHeaders.some((header) => header.filter) && (
1345
+ <tr className={styles.filterRow}>
1346
+ {gridHeaders.map((header, index) => (
1347
+ <th
1348
+ key={`filter-${index}`}
1349
+ className={styles.filterCell}
1350
+ >
1351
+ {header.filter ? (
1352
+ <input
1353
+ type={
1354
+ header.filter.type ===
1355
+ 'date'
1356
+ ? 'date'
1357
+ : 'text'
1358
+ }
1359
+ className={styles.filterInput}
1360
+ placeholder={`Filter ${header.label}...`}
1361
+ value={
1362
+ filterValues[header.key] ||
1363
+ ''
1364
+ }
1365
+ onChange={(e) =>
1366
+ handleFilterChange(
1367
+ header.key,
1368
+ e.target.value
1369
+ )
1370
+ }
1371
+ />
1372
+ ) : null}
1373
+ </th>
1374
+ ))}
1375
+ </tr>
1376
+ )}
1377
+ </thead>
1378
+ <tbody>
1379
+ {sortedData && sortedData.length > 0 ? (
1380
+ sortedData.map((row, rowIndex) => (
1381
+ <tr
1382
+ key={`row-${rowIndex}`}
1383
+ className={`${styles.gridRow} ${onRowClick ? styles.clickableRow : ''}`}
1384
+ style={getCustomStyles().hoverColor}
1385
+ onClick={() => handleRowClick(row, rowIndex)}
1386
+ >
1387
+ {/* Use gridHeaders if available, otherwise fall back to columns */}
1388
+ {gridHeaders.length > 0
1389
+ ? gridHeaders.map(
1390
+ (header, colIndex) => (
1391
+ <td
1392
+ key={`cell-${rowIndex}-${colIndex}`}
1393
+ className={`${
1394
+ styles.gridCell
1395
+ } ${
1396
+ header.onClick &&
1397
+ !row[header.key]
1398
+ ? styles.clickableCell
1399
+ : ''
1400
+ }`}
1401
+ onClick={
1402
+ header.onClick &&
1403
+ !row[header.key]
1404
+ ? (e) => handleCellClick(e, header, row)
1405
+ : undefined
1406
+ }
1407
+ >
1408
+ {row[header.key] ? (
1409
+ header.onClick ? (
1410
+ <span
1411
+ className={
1412
+ styles.cellWithValue
1413
+ }
1414
+ >
1415
+ {formatCellContent(
1416
+ row[
1417
+ header
1418
+ .key
1419
+ ],
1420
+ {
1421
+ ...header,
1422
+ type:
1423
+ header.type ||
1424
+ (typeof row[
1425
+ header
1426
+ .key
1427
+ ] ===
1428
+ 'number'
1429
+ ? 'number'
1430
+ : ''),
1431
+ },
1432
+ row
1433
+ )}
1434
+ </span>
1435
+ ) : (
1436
+ <span
1437
+ className={
1438
+ styles.cellContent
1439
+ }
1440
+ data-type={
1441
+ header.type ||
1442
+ (typeof row[
1443
+ header
1444
+ .key
1445
+ ] ===
1446
+ 'number'
1447
+ ? 'number'
1448
+ : '')
1449
+ }
1450
+ >
1451
+ {formatCellContent(
1452
+ row[
1453
+ header
1454
+ .key
1455
+ ],
1456
+ {
1457
+ ...header,
1458
+ type:
1459
+ header.type ||
1460
+ (typeof row[
1461
+ header
1462
+ .key
1463
+ ] ===
1464
+ 'number'
1465
+ ? 'number'
1466
+ : ''),
1467
+ },
1468
+ row
1469
+ )}
1470
+ </span>
1471
+ )
1472
+ ) : header.onClick ? (
1473
+ <span
1474
+ className={
1475
+ styles.placeholderText
1476
+ }
1477
+ >
1478
+ {header.onClick
1479
+ .placeholder ||
1480
+ 'Click to set'}
1481
+ </span>
1482
+ ) : (
1483
+ ''
1484
+ )}
1485
+ </td>
1486
+ )
1487
+ )
1488
+ : columns.map((col, colIndex) => (
1489
+ <td
1490
+ key={`cell-${rowIndex}-${colIndex}`}
1491
+ className={styles.gridCell}
1492
+ >
1493
+ <span
1494
+ className={
1495
+ styles.cellContent
1496
+ }
1497
+ data-type={
1498
+ col.type ||
1499
+ (typeof row[
1500
+ col.name
1501
+ ] === 'number'
1502
+ ? 'number'
1503
+ : '')
1504
+ }
1505
+ >
1506
+ {formatCellContent(
1507
+ row[col.name],
1508
+ col
1509
+ )}
1510
+ </span>
1511
+ </td>
1512
+ ))}
1513
+ </tr>
1514
+ ))
1515
+ ) : (
1516
+ <tr>
1517
+ <td
1518
+ colSpan={
1519
+ gridHeaders.length || headers.length
1520
+ }
1521
+ className={styles.noData}
1522
+ >
1523
+ No data available
1524
+ </td>
1525
+ </tr>
1526
+ )}
1527
+ </tbody>
1528
+ </table>
1529
+ )}
1530
+ </div>
1531
+ );
1532
+ };
1533
+
1534
+ export default GenericGrid;