@visns-studio/visns-components 5.14.8 → 5.14.10

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,91 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import SectionGroupedReport from './SectionGroupedReport';
4
+ import './groupedReport.css';
5
+
6
+ const GroupedReportRenderer = ({ data, config, columns, onExport }) => {
7
+ const renderByType = () => {
8
+ switch (config.type) {
9
+ case 'sections':
10
+ return (
11
+ <SectionGroupedReport
12
+ data={data}
13
+ config={config}
14
+ columns={columns}
15
+ onExport={onExport}
16
+ />
17
+ );
18
+ // Future grouping types can be added here
19
+ case 'accordion':
20
+ case 'cards':
21
+ case 'nested_table':
22
+ default:
23
+ return (
24
+ <SectionGroupedReport
25
+ data={data}
26
+ config={config}
27
+ columns={columns}
28
+ onExport={onExport}
29
+ />
30
+ );
31
+ }
32
+ };
33
+
34
+ if (!data.grouped || !data.groups) {
35
+ return (
36
+ <div className="grouped-report-error">
37
+ <p>Error: Invalid grouped data structure</p>
38
+ </div>
39
+ );
40
+ }
41
+
42
+ return (
43
+ <div className={`grouped-report grouped-report--${config.type || 'sections'}`}>
44
+ {data.groups.length === 0 ? (
45
+ <div className="grouped-report-empty">
46
+ <p>No data available for the selected criteria.</p>
47
+ </div>
48
+ ) : (
49
+ renderByType()
50
+ )}
51
+ </div>
52
+ );
53
+ };
54
+
55
+ GroupedReportRenderer.propTypes = {
56
+ data: PropTypes.shape({
57
+ grouped: PropTypes.bool.isRequired,
58
+ groups: PropTypes.arrayOf(PropTypes.shape({
59
+ groupName: PropTypes.string.isRequired,
60
+ groupDisplayName: PropTypes.string.isRequired,
61
+ rows: PropTypes.array.isRequired,
62
+ emptyRows: PropTypes.array.isRequired,
63
+ totalRows: PropTypes.number.isRequired,
64
+ maxRows: PropTypes.number.isRequired,
65
+ hasData: PropTypes.bool.isRequired
66
+ })).isRequired,
67
+ totalGroups: PropTypes.number.isRequired,
68
+ totalRecords: PropTypes.number.isRequired
69
+ }).isRequired,
70
+ config: PropTypes.shape({
71
+ type: PropTypes.string,
72
+ groupDisplayName: PropTypes.string,
73
+ showGroupTotals: PropTypes.bool,
74
+ groupHeaderStyle: PropTypes.string,
75
+ emptyRowStyle: PropTypes.string
76
+ }).isRequired,
77
+ columns: PropTypes.arrayOf(PropTypes.shape({
78
+ table: PropTypes.string.isRequired,
79
+ column: PropTypes.string.isRequired,
80
+ displayName: PropTypes.string.isRequired,
81
+ required: PropTypes.bool,
82
+ type: PropTypes.string
83
+ })).isRequired,
84
+ onExport: PropTypes.func
85
+ };
86
+
87
+ GroupedReportRenderer.defaultProps = {
88
+ onExport: null
89
+ };
90
+
91
+ export default GroupedReportRenderer;
@@ -0,0 +1,388 @@
1
+ import React, { useState } from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { ChevronDown, ChevronRight, MessageSquare } from 'lucide-react';
4
+ import { formatCellValue, getNestedValue } from './shared/groupingUtils';
5
+ import './groupedReport.css';
6
+
7
+ const SectionGroupedReport = ({ data, config, columns, onExport }) => {
8
+ const [collapsedGroups, setCollapsedGroups] = useState(new Set());
9
+
10
+ const toggleGroupCollapse = (groupName) => {
11
+ const newCollapsed = new Set(collapsedGroups);
12
+ if (newCollapsed.has(groupName)) {
13
+ newCollapsed.delete(groupName);
14
+ } else {
15
+ newCollapsed.add(groupName);
16
+ }
17
+ setCollapsedGroups(newCollapsed);
18
+ };
19
+ // Helper function to get nested object property
20
+ const getNestedProperty = (obj, path) => {
21
+ return path.split('.').reduce((current, key) => {
22
+ return current && current[key] !== undefined ? current[key] : undefined;
23
+ }, obj);
24
+ };
25
+
26
+ const renderCellValue = (row, column) => {
27
+ if (row.__empty_row__) {
28
+ return '';
29
+ }
30
+
31
+ // Try multiple field access patterns to find the data
32
+ let value;
33
+
34
+ // First try displayName with backticks (as seen in debug)
35
+ if (column.displayName && row[`\`${column.displayName}\``] !== undefined) {
36
+ value = row[`\`${column.displayName}\``];
37
+ }
38
+ // Then try displayName without backticks
39
+ else if (column.displayName && row[column.displayName] !== undefined) {
40
+ value = row[column.displayName];
41
+ }
42
+ // Then try table_column format
43
+ else if (column.table && row[`${column.table}_${column.column}`] !== undefined) {
44
+ value = row[`${column.table}_${column.column}`];
45
+ }
46
+ // Try column with table prefix and backticks
47
+ else if (column.table && row[`\`${column.table}_${column.column}\``] !== undefined) {
48
+ value = row[`\`${column.table}_${column.column}\``];
49
+ }
50
+ // Then try just column name
51
+ else if (row[column.column] !== undefined) {
52
+ value = row[column.column];
53
+ }
54
+ // Try column with backticks
55
+ else if (row[`\`${column.column}\``] !== undefined) {
56
+ value = row[`\`${column.column}\``];
57
+ }
58
+ // Handle calculated fields
59
+ else if (column.id && row[column.id] !== undefined) {
60
+ value = row[column.id];
61
+ }
62
+ // Try nested property access based on common patterns
63
+ let nestedPatterns = [];
64
+ if (column.table && column.column) {
65
+ // Try patterns like lead.client.name, users.name, etc.
66
+ nestedPatterns = [
67
+ `${column.table}.${column.column}`,
68
+ `${column.table}.${column.column}.name`,
69
+ `${column.table}.${column.column}.label`,
70
+ column.displayName === 'User' ? 'lead.client.name' : null,
71
+ column.displayName === 'Project' ? 'lead.project_name' : null,
72
+ column.displayName === 'Start Date' ? 'lead.start_date' : null,
73
+ column.displayName === 'End Date' ? 'lead.end_date' : null,
74
+ column.displayName === 'Database Updated' ? 'lead.updated_at' : null,
75
+ column.displayName === 'Comments' ? 'lead.comments' : null,
76
+ // Try alternative field patterns
77
+ column.column === 'name' && column.table === 'users' ? 'lead.client.name' : null,
78
+ column.column === 'updated_at' && column.table === 'leads' ? 'lead.updated_at' : null
79
+ ].filter(Boolean);
80
+
81
+ for (const pattern of nestedPatterns) {
82
+ const nestedValue = getNestedProperty(row, pattern);
83
+ if (nestedValue !== undefined) {
84
+ value = nestedValue;
85
+ break;
86
+ }
87
+ }
88
+ }
89
+
90
+ // Special handling for Comments field (appears in multiple formats)
91
+ if (value === undefined && column.displayName === 'Comments') {
92
+ value = row['Comments'] || row['comments'] || row['latest_comment'] || '';
93
+ }
94
+
95
+ // Format Comments field with enhanced styling
96
+ if (column.displayName === 'Comments' && value) {
97
+ const comments = value.split('• ').filter(comment => comment.trim());
98
+
99
+ return (
100
+ <div style={{
101
+ display: 'flex',
102
+ alignItems: 'flex-start',
103
+ gap: '8px',
104
+ maxWidth: '300px'
105
+ }}>
106
+ <MessageSquare
107
+ size={16}
108
+ style={{
109
+ marginTop: '4px',
110
+ color: '#3b82f6',
111
+ flexShrink: 0
112
+ }}
113
+ />
114
+ <div style={{
115
+ flex: 1,
116
+ fontSize: '13px',
117
+ lineHeight: '1.5'
118
+ }}>
119
+ {comments.length > 1 ? (
120
+ <div>
121
+ {comments.map((comment, index) => (
122
+ <div
123
+ key={index}
124
+ style={{
125
+ marginBottom: index < comments.length - 1 ? '8px' : '0',
126
+ padding: '6px 10px',
127
+ backgroundColor: '#f8fafc',
128
+ border: '1px solid #e2e8f0',
129
+ borderRadius: '6px',
130
+ fontSize: '12px',
131
+ color: '#475569'
132
+ }}
133
+ >
134
+ {comment.trim()}
135
+ </div>
136
+ ))}
137
+ </div>
138
+ ) : (
139
+ <div style={{
140
+ padding: '6px 10px',
141
+ backgroundColor: '#f8fafc',
142
+ border: '1px solid #e2e8f0',
143
+ borderRadius: '6px',
144
+ fontSize: '12px',
145
+ color: '#475569'
146
+ }}>
147
+ {value}
148
+ </div>
149
+ )}
150
+ </div>
151
+ </div>
152
+ );
153
+ }
154
+
155
+ // Try to find any key that contains the column name
156
+ if (value === undefined) {
157
+ const possibleKeys = Object.keys(row).filter(key =>
158
+ key.toLowerCase().includes(column.column?.toLowerCase()) ||
159
+ key.toLowerCase().includes(column.displayName?.toLowerCase())
160
+ );
161
+ if (possibleKeys.length > 0) {
162
+ value = row[possibleKeys[0]];
163
+ }
164
+ }
165
+
166
+ // Debug specific failing columns
167
+ if (value === undefined && !row.__empty_row__ && Object.keys(row).length > 0) {
168
+ const logKey = column.displayName || column.column;
169
+ if (['User', 'Database Updated', 'Comments'].includes(logKey)) {
170
+ if (!window.debugLogCount) window.debugLogCount = {};
171
+ if (!window.debugLogCount[logKey] || window.debugLogCount[logKey] < 2) {
172
+ window.debugLogCount[logKey] = (window.debugLogCount[logKey] || 0) + 1;
173
+ console.log(`❌ [DEBUG] ${logKey} field missing:`, {
174
+ column: logKey,
175
+ columnTable: column.table,
176
+ columnColumn: column.column,
177
+ availableKeys: Object.keys(row),
178
+ testedPatterns: nestedPatterns || 'No nested patterns tried'
179
+ });
180
+ }
181
+ }
182
+ }
183
+
184
+ return formatCellValue(value, column);
185
+ };
186
+
187
+ const getRowClassName = (row, config) => {
188
+ if (row.__empty_row__) {
189
+ return `empty-row empty-row--${config.emptyRowStyle || 'light'}`;
190
+ }
191
+ return 'data-row';
192
+ };
193
+
194
+ const renderGroupStats = (group, config) => {
195
+ if (!config.showGroupTotals) return null;
196
+
197
+ return (
198
+ <span className="group-stats">
199
+ ({group.totalRows}/{group.maxRows})
200
+ </span>
201
+ );
202
+ };
203
+
204
+ const renderExportButton = () => {
205
+ if (!onExport) return null;
206
+
207
+ return (
208
+ <div className="grouped-report-actions">
209
+ <button
210
+ className="btn btn-export"
211
+ onClick={() => onExport(data)}
212
+ title="Export grouped report"
213
+ >
214
+ Export
215
+ </button>
216
+ </div>
217
+ );
218
+ };
219
+
220
+ return (
221
+ <div className="section-grouped-report">
222
+ <div className="grouped-report-header">
223
+ <div className="grouped-report-summary">
224
+ <span className="summary-text">
225
+ {data.totalRecords} records across {data.totalGroups} {config.groupDisplayName?.toLowerCase() || 'group'}(s)
226
+ </span>
227
+ </div>
228
+ {renderExportButton()}
229
+ </div>
230
+
231
+ <div className="grouped-report-content">
232
+ {data.groups.map((group, groupIndex) => (
233
+ <GroupSection
234
+ key={group.groupName}
235
+ group={group}
236
+ config={config}
237
+ columns={columns}
238
+ index={groupIndex}
239
+ renderCellValue={renderCellValue}
240
+ isCollapsed={collapsedGroups.has(group.groupName)}
241
+ onToggleCollapse={() => toggleGroupCollapse(group.groupName)}
242
+ />
243
+ ))}
244
+ </div>
245
+ </div>
246
+ );
247
+ };
248
+
249
+ const GroupSection = ({ group, config, columns, index, renderCellValue, isCollapsed, onToggleCollapse }) => {
250
+ const allRows = [...group.rows, ...group.emptyRows];
251
+ const headerStyleClass = `group-section--${config.groupHeaderStyle || 'primary'}`;
252
+
253
+ const getRowClassName = (row, config) => {
254
+ if (row.__empty_row__) {
255
+ return `empty-row empty-row--${config.emptyRowStyle || 'light'}`;
256
+ }
257
+ return 'data-row';
258
+ };
259
+
260
+ // Only log the first group to avoid spam
261
+ if (index === 0) {
262
+ console.log('🔍 [DEBUG] First group sample data:', {
263
+ groupName: group.groupName,
264
+ availableFields: group.rows[0] ? Object.keys(group.rows[0]) : [],
265
+ sampleValues: group.rows[0] ? Object.entries(group.rows[0]).slice(0, 5).reduce((obj, [key, value]) => ({ ...obj, [key]: value }), {}) : 'No data'
266
+ });
267
+ }
268
+
269
+ return (
270
+ <div className={`group-section ${headerStyleClass}`}>
271
+ <div
272
+ className="group-header"
273
+ onClick={onToggleCollapse}
274
+ style={{
275
+ cursor: 'pointer',
276
+ userSelect: 'none',
277
+ display: 'flex',
278
+ alignItems: 'center',
279
+ padding: '12px 16px',
280
+ backgroundColor: '#4a5568',
281
+ color: 'white',
282
+ borderRadius: '4px',
283
+ marginBottom: isCollapsed ? '0' : '8px'
284
+ }}
285
+ >
286
+ <div style={{ marginRight: '8px' }}>
287
+ {isCollapsed ? <ChevronRight size={20} /> : <ChevronDown size={20} />}
288
+ </div>
289
+ <div className="group-header-content" style={{ display: 'flex', alignItems: 'center', flex: 1 }}>
290
+ <span className="group-title" style={{ fontWeight: 'bold' }}>
291
+ {config.groupDisplayName || 'Group'}: {group.groupDisplayName}
292
+ </span>
293
+ </div>
294
+ </div>
295
+
296
+ {!isCollapsed && (
297
+ <div className="group-content">
298
+ <div className="table-container" style={{
299
+ overflowX: 'auto',
300
+ overflowY: 'auto',
301
+ maxHeight: '80vh',
302
+ border: '1px solid #ddd',
303
+ borderRadius: '4px'
304
+ }}>
305
+ <table className="group-table" style={{
306
+ minWidth: '100%',
307
+ width: 'max-content'
308
+ }}>
309
+ <thead style={{
310
+ position: 'sticky',
311
+ top: 0,
312
+ backgroundColor: '#f8f9fa',
313
+ zIndex: 10,
314
+ boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
315
+ }}>
316
+ <tr>
317
+ {columns.map((col, colIndex) => (
318
+ <th style={{
319
+ padding: '12px 8px',
320
+ borderBottom: '2px solid #dee2e6',
321
+ fontWeight: 'bold',
322
+ whiteSpace: 'nowrap',
323
+ backgroundColor: '#f8f9fa'
324
+ }}
325
+ key={`${col.table}_${col.column}_${colIndex}`}
326
+ className={col.required ? 'required-column' : ''}
327
+ >
328
+ {col.displayName}
329
+ </th>
330
+ ))}
331
+ </tr>
332
+ </thead>
333
+ <tbody>
334
+ {allRows.map((row, rowIndex) => (
335
+ <tr
336
+ key={row.id || `${group.groupName}_row_${rowIndex}`}
337
+ className={getRowClassName(row, config)}
338
+ >
339
+ {columns.map((col, colIndex) => (
340
+ <td
341
+ key={`${col.table}_${col.column}_${rowIndex}_${colIndex}`}
342
+ className={col.required ? 'required-column' : ''}
343
+ >
344
+ {renderCellValue(row, col)}
345
+ </td>
346
+ ))}
347
+ </tr>
348
+ ))}
349
+ </tbody>
350
+ </table>
351
+ </div>
352
+ </div>
353
+ )}
354
+ </div>
355
+ );
356
+ };
357
+
358
+ SectionGroupedReport.propTypes = {
359
+ data: PropTypes.shape({
360
+ groups: PropTypes.array.isRequired,
361
+ totalGroups: PropTypes.number.isRequired,
362
+ totalRecords: PropTypes.number.isRequired
363
+ }).isRequired,
364
+ config: PropTypes.shape({
365
+ groupDisplayName: PropTypes.string,
366
+ showGroupTotals: PropTypes.bool,
367
+ groupHeaderStyle: PropTypes.string,
368
+ emptyRowStyle: PropTypes.string
369
+ }).isRequired,
370
+ columns: PropTypes.array.isRequired,
371
+ onExport: PropTypes.func
372
+ };
373
+
374
+ GroupSection.propTypes = {
375
+ group: PropTypes.shape({
376
+ groupName: PropTypes.string.isRequired,
377
+ groupDisplayName: PropTypes.string.isRequired,
378
+ rows: PropTypes.array.isRequired,
379
+ emptyRows: PropTypes.array.isRequired,
380
+ totalRows: PropTypes.number.isRequired,
381
+ maxRows: PropTypes.number.isRequired
382
+ }).isRequired,
383
+ config: PropTypes.object.isRequired,
384
+ columns: PropTypes.array.isRequired,
385
+ index: PropTypes.number.isRequired
386
+ };
387
+
388
+ export default SectionGroupedReport;
@@ -0,0 +1,119 @@
1
+ import React from 'react';
2
+ import Popup from 'reactjs-popup';
3
+ import { X } from 'lucide-react';
4
+ import formStyles from '../styles/Form.module.scss';
5
+
6
+ /**
7
+ * StandardModal Component
8
+ *
9
+ * A reusable modal component that standardizes the look and feel across all components.
10
+ * Uses the same styles as GenericIndex, GenericDetail, and DataGrid via Form.module.scss.
11
+ *
12
+ * @param {Object} props
13
+ * @param {boolean} props.isOpen - Whether the modal is open
14
+ * @param {Function} props.onClose - Function to call when modal should close
15
+ * @param {string} props.title - Modal title (only used if showHeader is true)
16
+ * @param {React.ReactNode} props.children - Modal content
17
+ * @param {string} props.size - Modal size: 'small', 'medium', 'large' (default: 'medium')
18
+ * @param {boolean} props.closeOnDocumentClick - Whether to close on outside click (default: true)
19
+ * @param {boolean} props.closeOnEscape - Whether to close on escape key (default: true)
20
+ * @param {string} props.verticalAlign - Vertical alignment: 'center', 'top' (default: 'center')
21
+ * @param {boolean} props.showHeader - Whether to show modal header (default: false, because Form components have their own headers)
22
+ * @param {Object} props.customStyles - Custom styles to override defaults
23
+ */
24
+ const StandardModal = ({
25
+ isOpen,
26
+ onClose,
27
+ title,
28
+ children,
29
+ size = 'medium',
30
+ closeOnDocumentClick = true,
31
+ closeOnEscape = true,
32
+ verticalAlign = 'center',
33
+ showHeader = false,
34
+ customStyles = {}
35
+ }) => {
36
+ // Apply vertical alignment class if needed
37
+ const getModalClasses = () => {
38
+ let classes = formStyles.modal;
39
+ if (verticalAlign === 'top') {
40
+ classes += ' modal-top-aligned';
41
+ }
42
+ return classes;
43
+ };
44
+
45
+ // Get size-based styles for the popup content
46
+ const getContentStyle = () => {
47
+ const baseStyle = {
48
+ ...customStyles.modal
49
+ };
50
+
51
+ // Apply size-based styles
52
+ switch (size) {
53
+ case 'small':
54
+ return {
55
+ ...baseStyle,
56
+ width: '400px',
57
+ minWidth: '400px',
58
+ maxWidth: '600px'
59
+ };
60
+ case 'large':
61
+ return {
62
+ ...baseStyle,
63
+ width: '95vw',
64
+ minWidth: '800px',
65
+ maxWidth: '1600px'
66
+ };
67
+ case 'medium':
68
+ default:
69
+ // Use the same dimensions as modalWide class (original modal sizing)
70
+ return {
71
+ ...baseStyle,
72
+ width: '90vw',
73
+ minWidth: '600px',
74
+ maxWidth: '1400px'
75
+ };
76
+ }
77
+ };
78
+
79
+ return (
80
+ <Popup
81
+ open={isOpen}
82
+ onClose={onClose}
83
+ modal
84
+ closeOnDocumentClick={closeOnDocumentClick}
85
+ closeOnEscape={closeOnEscape}
86
+ contentStyle={getContentStyle()}
87
+ >
88
+ <div className={formStyles.modalwrap}>
89
+ <div
90
+ className={getModalClasses()}
91
+ >
92
+ {showHeader && (
93
+ <div className={formStyles.modal__header} style={customStyles.header}>
94
+ <h1 style={customStyles.title}>{title}</h1>
95
+ <button
96
+ className={formStyles.modal__close}
97
+ onClick={onClose}
98
+ style={customStyles.closeButton}
99
+ type="button"
100
+ aria-label="Close modal"
101
+ >
102
+ <X strokeWidth={1} size={24} />
103
+ </button>
104
+ </div>
105
+ )}
106
+ {showHeader ? (
107
+ <div className={formStyles.modal__content} style={customStyles.content}>
108
+ {children}
109
+ </div>
110
+ ) : (
111
+ children
112
+ )}
113
+ </div>
114
+ </div>
115
+ </Popup>
116
+ );
117
+ };
118
+
119
+ export default StandardModal;