@visns-studio/visns-components 5.4.19 → 5.4.20

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,2158 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { toast } from 'react-toastify';
3
+ import moment from 'moment';
4
+ import ReactDataGrid from '@inovua/reactdatagrid-community';
5
+ import { CircleX } from 'akar-icons';
6
+ import CustomFetch from '../Fetch';
7
+ import Download from '../Download';
8
+ import { saveAs } from 'file-saver';
9
+ import styles from './styles/GenericReport.module.scss';
10
+
11
+ // Utility function to format names from camelCase or snake_case to Title Case with spaces
12
+ const formatName = (name) => {
13
+ if (!name) return '';
14
+
15
+ // Replace underscores and hyphens with spaces
16
+ let formatted = name.replace(/[_-]/g, ' ');
17
+
18
+ // Insert space before capital letters (for camelCase)
19
+ formatted = formatted.replace(/([A-Z])/g, ' $1');
20
+
21
+ // Trim extra spaces, capitalize first letter of each word
22
+ return formatted
23
+ .trim()
24
+ .split(' ')
25
+ .map(
26
+ (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
27
+ )
28
+ .join(' ');
29
+ };
30
+
31
+ // Format relationship description to be more user-friendly
32
+ const formatRelationshipDescription = (
33
+ description,
34
+ simplified = false,
35
+ joinData = null
36
+ ) => {
37
+ if (!description) return '';
38
+
39
+ // For tooltip (full description)
40
+ if (!simplified) {
41
+ // Replace technical terms with more user-friendly ones
42
+ let formatted = description
43
+ // Replace table.column references with formatted names
44
+ .replace(/([a-z_]+)\.([a-z_]+)/gi, (_, table, column) => {
45
+ return `${formatName(table)}.${formatName(column)}`;
46
+ })
47
+ // Replace "references" with "links to"
48
+ .replace(/references/gi, 'links to')
49
+ // Replace "_id" with empty string in column names
50
+ .replace(/(\s)Id/g, '');
51
+
52
+ return formatted;
53
+ }
54
+
55
+ // For simplified display in the UI
56
+ // If we have the full join data, use it to create a more descriptive name
57
+ if (joinData) {
58
+ const { sourceTable, targetTable, joinType, isFirstPartOfManyToMany } =
59
+ joinData;
60
+
61
+ // Handle many-to-many relationships with pivot tables
62
+ if (isFirstPartOfManyToMany && joinData.secondJoin) {
63
+ const finalTargetTable = joinData.secondJoin.targetTable;
64
+ return `${formatName(sourceTable)} ↔ ${formatName(
65
+ finalTargetTable
66
+ )}`;
67
+ }
68
+
69
+ // Handle regular joins based on join type
70
+ if (joinType.includes('LEFT')) {
71
+ return `${formatName(sourceTable)} → ${formatName(targetTable)}`;
72
+ } else if (joinType.includes('RIGHT')) {
73
+ return `${formatName(sourceTable)} ← ${formatName(targetTable)}`;
74
+ } else if (joinType.includes('INNER')) {
75
+ return `${formatName(sourceTable)} ⟷ ${formatName(targetTable)}`;
76
+ }
77
+
78
+ // Default case - just show the relationship
79
+ return `${formatName(sourceTable)} - ${formatName(targetTable)}`;
80
+ }
81
+
82
+ // Extract table names from the description if no join data
83
+ const sourceTablePattern = /([a-z_]+)\./i;
84
+ const targetTablePattern = /references\s+([a-z_]+)\./i;
85
+
86
+ const sourceMatch = description.match(sourceTablePattern);
87
+ const targetMatch = description.match(targetTablePattern);
88
+
89
+ if (sourceMatch && targetMatch) {
90
+ const sourceTable = sourceMatch[1];
91
+ const targetTable = targetMatch[1];
92
+
93
+ // Check if this is a pivot table description
94
+ if (description.includes('pivot table')) {
95
+ return `${formatName(sourceTable)} ↔ ${formatName(targetTable)}`;
96
+ }
97
+
98
+ // Regular relationship
99
+ return `${formatName(sourceTable)} → ${formatName(targetTable)}`;
100
+ }
101
+
102
+ // Fallback to just the table name if we can't parse the relationship
103
+ const tablePattern = /([a-z_]+)\.(client_id|id)/gi;
104
+ const matches = [...description.matchAll(tablePattern)];
105
+
106
+ if (matches.length >= 1) {
107
+ // Get the first table name (source table)
108
+ const sourceTable = matches[0][1];
109
+ return formatName(sourceTable);
110
+ }
111
+
112
+ // Final fallback to the standard formatting if all patterns don't match
113
+ return description
114
+ .replace(/([a-z_]+)\.([a-z_]+)/gi, (_, table) => {
115
+ return `${formatName(table)}`;
116
+ })
117
+ .replace(/references.+/gi, '')
118
+ .replace(/links to.+/gi, '')
119
+ .trim();
120
+ };
121
+
122
+ // Format snake_case to human readable text
123
+ const formatJsonKey = (key) => {
124
+ if (!key) return '';
125
+
126
+ // Replace underscores with spaces and capitalize each word
127
+ return key
128
+ .split('_')
129
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
130
+ .join(' ');
131
+ };
132
+
133
+ // Transform JSON object to have human-readable keys
134
+ const transformJsonForDisplay = (json) => {
135
+ if (!json || typeof json !== 'object') return json;
136
+
137
+ // For arrays, transform each item
138
+ if (Array.isArray(json)) {
139
+ return json.map((item) => transformJsonForDisplay(item));
140
+ }
141
+
142
+ // For objects, transform keys
143
+ const result = {};
144
+
145
+ for (const key in json) {
146
+ if (Object.prototype.hasOwnProperty.call(json, key)) {
147
+ const value = json[key];
148
+ const formattedKey = formatJsonKey(key);
149
+
150
+ // Recursively transform nested objects
151
+ result[formattedKey] =
152
+ typeof value === 'object' && value !== null
153
+ ? transformJsonForDisplay(value)
154
+ : value;
155
+ }
156
+ }
157
+
158
+ return result;
159
+ };
160
+
161
+ // Check if a value is JSON
162
+ const isJsonValue = (value) => {
163
+ if (!value) return false;
164
+
165
+ // Check if it's a string that looks like JSON
166
+ if (typeof value === 'string') {
167
+ const trimmed = value.trim();
168
+ return (
169
+ (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
170
+ (trimmed.startsWith('[') && trimmed.endsWith(']'))
171
+ );
172
+ }
173
+
174
+ // Check if it's already an object (parsed JSON)
175
+ return typeof value === 'object' && value !== null;
176
+ };
177
+
178
+ const GenericReport = ({ setting = {} }) => {
179
+ // Extract settings with defaults
180
+ const { tableUrl, columnUrl } = setting;
181
+
182
+ // No mock data - component will rely on API responses
183
+
184
+ // State for database schema
185
+ const [tables, setTables] = useState([]);
186
+ const [selectedTable, setSelectedTable] = useState(null);
187
+ const [tableColumns, setTableColumns] = useState([]);
188
+ const [isLoadingTables, setIsLoadingTables] = useState(false);
189
+ const [isLoadingJoinColumns, setIsLoadingJoinColumns] = useState(false);
190
+
191
+ // State for report configuration
192
+ const [selectedColumns, setSelectedColumns] = useState([]);
193
+ const [availableTables, setAvailableTables] = useState([]);
194
+ const [joins, setJoins] = useState([]);
195
+ const [reportName, setReportName] = useState('');
196
+ const [isPublic, setIsPublic] = useState(false);
197
+
198
+ // State for saved reports
199
+ const [savedReports, setSavedReports] = useState([]);
200
+ const [selectedReport, setSelectedReport] = useState(null);
201
+ const [isLoadingReports, setIsLoadingReports] = useState(false);
202
+
203
+ // State for suggested joins
204
+ const [suggestedJoins, setSuggestedJoins] = useState({});
205
+ const [isLoadingSuggestedJoins, setIsLoadingSuggestedJoins] = useState({});
206
+
207
+ // State for report execution
208
+ const [totalResults, setTotalResults] = useState(0);
209
+ const [executedSql, setExecutedSql] = useState('');
210
+
211
+ // State for report preview
212
+ const [previewData, setPreviewData] = useState([]);
213
+ const [gridColumns, setGridColumns] = useState([]);
214
+ const [isLoading, setIsLoading] = useState(false);
215
+
216
+ // Extract additional settings with defaults
217
+ const {
218
+ reportsUrl = '/ajax/reportBuilder/reports',
219
+ executeUrl = '/ajax/reportBuilder/execute',
220
+ suggestedJoinsUrl = '/ajax/reportBuilder/getSuggestedJoins',
221
+ } = setting;
222
+
223
+ // Fetch all database tables and saved reports on component mount
224
+ useEffect(() => {
225
+ fetchDatabaseTables();
226
+ fetchSavedReports();
227
+ }, []);
228
+
229
+ // Fetch table columns when a table is selected
230
+ useEffect(() => {
231
+ if (selectedTable) {
232
+ fetchTableColumns(selectedTable);
233
+ fetchSuggestedJoins(selectedTable, 'main');
234
+ }
235
+ }, [selectedTable]);
236
+
237
+ // Update available tables for joins
238
+ useEffect(() => {
239
+ if (tables.length > 0 && selectedTable) {
240
+ const availableTablesToJoin = tables.filter(
241
+ (table) => table.name !== selectedTable
242
+ );
243
+ setAvailableTables(availableTablesToJoin);
244
+ }
245
+ }, [tables, selectedTable]);
246
+
247
+ // Fetch columns for all joined tables whenever joins change
248
+ useEffect(() => {
249
+ if (joins.length > 0) {
250
+ joins.forEach((join, index) => {
251
+ if (
252
+ join.targetTable &&
253
+ (!join.availableColumns ||
254
+ join.availableColumns.length === 0)
255
+ ) {
256
+ fetchJoinTableColumns(join.targetTable, index);
257
+ }
258
+
259
+ // Fetch suggested joins for the target table if we haven't already
260
+ if (join.targetTable && !suggestedJoins[join.targetTable]) {
261
+ fetchSuggestedJoins(join.targetTable, join.targetTable);
262
+ }
263
+ });
264
+ }
265
+ }, [joins, suggestedJoins]);
266
+
267
+ // Fetch database tables from the API
268
+ const fetchDatabaseTables = async () => {
269
+ setIsLoadingTables(true);
270
+
271
+ if (tableUrl) {
272
+ try {
273
+ // Use CustomFetch to make a POST request to the API
274
+ const res = await CustomFetch(tableUrl, 'POST', {});
275
+
276
+ // Check if the response has a data property
277
+ const tablesData = res.data?.data || res.data;
278
+
279
+ if (Array.isArray(tablesData)) {
280
+ setTables(tablesData);
281
+ } else {
282
+ console.error('Invalid tables data format:', tablesData);
283
+ setTables([]);
284
+ toast.error(
285
+ 'Failed to load tables. Please check API configuration.'
286
+ );
287
+ }
288
+ } catch (error) {
289
+ console.error('Error fetching tables:', error);
290
+ setTables([]);
291
+ toast.error(
292
+ 'Failed to load tables. Please check API connection.'
293
+ );
294
+ } finally {
295
+ setIsLoadingTables(false);
296
+ }
297
+ } else {
298
+ // No tableUrl provided
299
+ setTables([]);
300
+ toast.error(
301
+ 'Table URL not configured. Please set the tableUrl property.'
302
+ );
303
+ setIsLoadingTables(false);
304
+ }
305
+ };
306
+
307
+ // Fetch suggested joins for a table
308
+ const fetchSuggestedJoins = async (tableName, tableKey) => {
309
+ // Update loading state for this specific table
310
+ setIsLoadingSuggestedJoins((prev) => ({
311
+ ...prev,
312
+ [tableKey]: true,
313
+ }));
314
+
315
+ try {
316
+ const res = await CustomFetch(suggestedJoinsUrl, 'POST', {
317
+ table: tableName,
318
+ });
319
+
320
+ if (res.data?.success && res.data?.data?.suggestedJoins) {
321
+ // Update suggested joins for this specific table
322
+ setSuggestedJoins((prev) => ({
323
+ ...prev,
324
+ [tableKey]: res.data.data.suggestedJoins,
325
+ }));
326
+ } else {
327
+ // Set empty array for this table
328
+ setSuggestedJoins((prev) => ({
329
+ ...prev,
330
+ [tableKey]: [],
331
+ }));
332
+ console.warn(
333
+ `No suggested joins found for table ${tableName} or invalid response format`
334
+ );
335
+ }
336
+ } catch (error) {
337
+ console.error(
338
+ `Error fetching suggested joins for table ${tableName}:`,
339
+ error
340
+ );
341
+ // Set empty array for this table on error
342
+ setSuggestedJoins((prev) => ({
343
+ ...prev,
344
+ [tableKey]: [],
345
+ }));
346
+ toast.error(
347
+ `Failed to load suggested joins for table ${formatName(
348
+ tableName
349
+ )}`
350
+ );
351
+ } finally {
352
+ // Update loading state for this specific table
353
+ setIsLoadingSuggestedJoins((prev) => ({
354
+ ...prev,
355
+ [tableKey]: false,
356
+ }));
357
+ }
358
+ };
359
+
360
+ // Fetch table columns from the API or use mock data
361
+ const fetchTableColumns = async (tableName) => {
362
+ setIsLoading(true);
363
+
364
+ if (tableUrl) {
365
+ try {
366
+ // Use the columnUrl if provided, otherwise construct it from tableUrl
367
+ let columnsUrl;
368
+ if (columnUrl) {
369
+ columnsUrl = columnUrl;
370
+ } else {
371
+ // Fallback to constructing URL from tableUrl
372
+ const baseUrl = tableUrl.split('/').slice(0, -1).join('/');
373
+ columnsUrl = `${baseUrl}/getColumns/${tableName}`;
374
+ }
375
+
376
+ // Use CustomFetch to make a POST request to the API
377
+ const res = await CustomFetch(columnsUrl, 'POST', {
378
+ table: tableName,
379
+ });
380
+
381
+ // Handle the specific response format from getTableColumns
382
+ // Example: {"success":true,"data":{"table":"contacts_tags","columns":[...]}}
383
+ if (res.data?.success && res.data?.data?.columns) {
384
+ // Extract columns from the nested structure
385
+ setTableColumns(res.data.data.columns);
386
+ } else {
387
+ // Fallback to previous format handling
388
+ const columnsData = res.data?.data || res.data;
389
+
390
+ if (Array.isArray(columnsData)) {
391
+ setTableColumns(columnsData);
392
+ } else {
393
+ console.error(
394
+ 'Invalid columns data format:',
395
+ columnsData
396
+ );
397
+ setTableColumns([]);
398
+ toast.error(
399
+ 'Failed to load columns. Please check API response format.'
400
+ );
401
+ }
402
+ }
403
+ } catch (error) {
404
+ console.error('Error fetching columns:', error);
405
+ setTableColumns([]);
406
+ toast.error(
407
+ 'Failed to load columns. Please check API connection.'
408
+ );
409
+ } finally {
410
+ setIsLoading(false);
411
+ }
412
+ } else {
413
+ // No tableUrl provided
414
+ setTableColumns([]);
415
+ toast.error(
416
+ 'Column URL not configured. Please set the tableUrl or columnUrl property.'
417
+ );
418
+ setIsLoading(false);
419
+ }
420
+ };
421
+
422
+ // Reset the report form
423
+ const handleResetForm = () => {
424
+ setSelectedTable(null);
425
+ setSelectedColumns([]);
426
+ setJoins([]);
427
+ setReportName('');
428
+ setIsPublic(false);
429
+ setSelectedReport(null);
430
+ setPreviewData([]);
431
+ setExecutedSql('');
432
+ setTotalResults(0);
433
+ };
434
+
435
+ // Handle table selection
436
+ const handleTableSelect = (tableName) => {
437
+ // If a report is currently selected, clear it
438
+ if (selectedReport) {
439
+ setSelectedReport(null);
440
+ setReportName('');
441
+ setIsPublic(false);
442
+ }
443
+
444
+ setSelectedTable(tableName);
445
+ setSelectedColumns([]);
446
+ setJoins([]);
447
+ };
448
+
449
+ // Handle column selection
450
+ const handleColumnSelect = (column, tableName = selectedTable) => {
451
+ const columnWithTable = {
452
+ table: tableName,
453
+ column: column.name,
454
+ displayName: `${formatName(tableName)}.${formatName(column.name)}`,
455
+ rawDisplayName: `${tableName}.${column.name}`,
456
+ dataType: column.type,
457
+ };
458
+
459
+ // Check if column is already selected
460
+ const isAlreadySelected = selectedColumns.some(
461
+ (col) => col.table === tableName && col.column === column.name
462
+ );
463
+
464
+ if (!isAlreadySelected) {
465
+ setSelectedColumns([...selectedColumns, columnWithTable]);
466
+ }
467
+ };
468
+
469
+ // Handle removing a selected column
470
+ const handleRemoveColumn = (index) => {
471
+ const updatedColumns = [...selectedColumns];
472
+ updatedColumns.splice(index, 1);
473
+ setSelectedColumns(updatedColumns);
474
+ };
475
+
476
+ // State for drag and drop
477
+ const [draggedItemIndex, setDraggedItemIndex] = useState(null);
478
+
479
+ // Handle drag start for column reordering
480
+ const handleDragStart = (e, index) => {
481
+ setDraggedItemIndex(index);
482
+ e.dataTransfer.setData('text/plain', index);
483
+ e.dataTransfer.effectAllowed = 'move';
484
+
485
+ // Add a delay to apply the dragging class
486
+ setTimeout(() => {
487
+ e.target.classList.add(styles.dragging);
488
+ }, 0);
489
+ };
490
+
491
+ // Handle drag end
492
+ const handleDragEnd = (e) => {
493
+ setDraggedItemIndex(null);
494
+ e.target.classList.remove(styles.dragging);
495
+ };
496
+
497
+ // Handle drag over for column reordering
498
+ const handleDragOver = (e) => {
499
+ e.preventDefault();
500
+ e.dataTransfer.dropEffect = 'move';
501
+ e.currentTarget.classList.add(styles.dragOver);
502
+ };
503
+
504
+ // Handle drag leave
505
+ const handleDragLeave = (e) => {
506
+ e.currentTarget.classList.remove(styles.dragOver);
507
+ };
508
+
509
+ // Handle drop for column reordering
510
+ const handleDrop = (e, targetIndex) => {
511
+ e.preventDefault();
512
+ e.currentTarget.classList.remove(styles.dragOver);
513
+
514
+ const sourceIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
515
+
516
+ if (sourceIndex === targetIndex) return;
517
+
518
+ const updatedColumns = [...selectedColumns];
519
+ const [movedColumn] = updatedColumns.splice(sourceIndex, 1);
520
+ updatedColumns.splice(targetIndex, 0, movedColumn);
521
+
522
+ setSelectedColumns(updatedColumns);
523
+ };
524
+
525
+ // Add a new join
526
+ const handleAddJoin = () => {
527
+ const newJoin = {
528
+ sourceTable: selectedTable,
529
+ targetTable: '',
530
+ sourceColumn: '',
531
+ targetColumn: '',
532
+ joinType: 'INNER JOIN',
533
+ };
534
+
535
+ setJoins([...joins, newJoin]);
536
+ };
537
+
538
+ // Add a suggested join
539
+ const handleAddSuggestedJoin = (suggestedJoin) => {
540
+ // Check if this is a many-to-many relationship with a pivot table
541
+ if (suggestedJoin.isFirstPartOfManyToMany && suggestedJoin.secondJoin) {
542
+ // Add two joins for many-to-many relationships
543
+ const firstJoin = {
544
+ sourceTable: suggestedJoin.sourceTable,
545
+ sourceColumn: suggestedJoin.sourceColumn,
546
+ targetTable: suggestedJoin.targetTable,
547
+ targetColumn: suggestedJoin.targetColumn,
548
+ joinType: suggestedJoin.joinType,
549
+ description: suggestedJoin.description,
550
+ };
551
+
552
+ const secondJoin = {
553
+ sourceTable: suggestedJoin.secondJoin.sourceTable,
554
+ sourceColumn: suggestedJoin.secondJoin.sourceColumn,
555
+ targetTable: suggestedJoin.secondJoin.targetTable,
556
+ targetColumn: suggestedJoin.secondJoin.targetColumn,
557
+ joinType: suggestedJoin.secondJoin.joinType,
558
+ description: suggestedJoin.secondJoin.description,
559
+ };
560
+
561
+ setJoins([...joins, firstJoin, secondJoin]);
562
+ toast.success('Added many-to-many relationship with pivot table');
563
+
564
+ // Fetch columns for both target tables
565
+ fetchJoinTableColumns(suggestedJoin.targetTable, joins.length);
566
+ if (suggestedJoin.secondJoin.targetTable) {
567
+ fetchJoinTableColumns(
568
+ suggestedJoin.secondJoin.targetTable,
569
+ joins.length + 1
570
+ );
571
+ }
572
+ } else {
573
+ // Add a single join for regular relationships
574
+ const newJoin = {
575
+ sourceTable: suggestedJoin.sourceTable,
576
+ sourceColumn: suggestedJoin.sourceColumn,
577
+ targetTable: suggestedJoin.targetTable,
578
+ targetColumn: suggestedJoin.targetColumn,
579
+ joinType: suggestedJoin.joinType,
580
+ description: suggestedJoin.description,
581
+ };
582
+
583
+ setJoins([...joins, newJoin]);
584
+ toast.success('Added suggested join');
585
+
586
+ // Fetch columns for the target table
587
+ if (suggestedJoin.targetTable) {
588
+ fetchJoinTableColumns(suggestedJoin.targetTable, joins.length);
589
+ }
590
+ }
591
+ };
592
+
593
+ // Update join configuration
594
+ const handleJoinChange = (index, field, value) => {
595
+ const updatedJoins = [...joins];
596
+ updatedJoins[index][field] = value;
597
+ setJoins(updatedJoins);
598
+
599
+ // If target table changed, fetch its columns
600
+ if (field === 'targetTable' && value) {
601
+ fetchJoinTableColumns(value, index);
602
+ }
603
+ };
604
+
605
+ // Fetch columns for a join table
606
+ const fetchJoinTableColumns = async (tableName, joinIndex) => {
607
+ setIsLoadingJoinColumns(true);
608
+
609
+ if (tableUrl) {
610
+ try {
611
+ // Use the columnUrl if provided, otherwise construct it from tableUrl
612
+ let columnsUrl;
613
+ if (columnUrl) {
614
+ columnsUrl = columnUrl;
615
+ } else {
616
+ // Fallback to constructing URL from tableUrl
617
+ const baseUrl = tableUrl.split('/').slice(0, -1).join('/');
618
+ columnsUrl = `${baseUrl}/getColumns/${tableName}`;
619
+ }
620
+
621
+ // Use CustomFetch to make a POST request to the API
622
+ const res = await CustomFetch(columnsUrl, 'POST', {
623
+ table: tableName,
624
+ });
625
+
626
+ // Store columns in the join object
627
+ const updatedJoins = [...joins];
628
+
629
+ // Handle the specific response format from getTableColumns
630
+ // Example: {"success":true,"data":{"table":"contacts_tags","columns":[...]}}
631
+ if (res.data?.success && res.data?.data?.columns) {
632
+ // Extract columns from the nested structure
633
+ updatedJoins[joinIndex].availableColumns =
634
+ res.data.data.columns;
635
+ } else {
636
+ // Fallback to previous format handling
637
+ const columnsData = res.data?.data || res.data;
638
+
639
+ if (Array.isArray(columnsData)) {
640
+ updatedJoins[joinIndex].availableColumns = columnsData;
641
+ } else {
642
+ console.error(
643
+ 'Invalid columns data format:',
644
+ columnsData
645
+ );
646
+ updatedJoins[joinIndex].availableColumns = [];
647
+ toast.error(
648
+ 'Failed to load join columns. Please check API response format.'
649
+ );
650
+ }
651
+ }
652
+
653
+ setJoins(updatedJoins);
654
+ } catch (error) {
655
+ console.error('Error fetching join columns:', error);
656
+ const updatedJoins = [...joins];
657
+ updatedJoins[joinIndex].availableColumns = [];
658
+ toast.error(
659
+ 'Failed to load join columns. Please check API connection.'
660
+ );
661
+ setJoins(updatedJoins);
662
+ } finally {
663
+ setIsLoadingJoinColumns(false);
664
+ }
665
+ } else {
666
+ // No tableUrl provided
667
+ const updatedJoins = [...joins];
668
+ updatedJoins[joinIndex].availableColumns = [];
669
+ toast.error(
670
+ 'Join column URL not configured. Please set the tableUrl or columnUrl property.'
671
+ );
672
+ setJoins(updatedJoins);
673
+ setIsLoadingJoinColumns(false);
674
+ }
675
+ };
676
+
677
+ // Remove a join
678
+ const handleRemoveJoin = (index) => {
679
+ const updatedJoins = [...joins];
680
+ updatedJoins.splice(index, 1);
681
+ setJoins(updatedJoins);
682
+ };
683
+
684
+ // Delete a saved report
685
+ const handleDeleteReport = async (reportId) => {
686
+ if (!reportId) return;
687
+
688
+ setIsLoading(true);
689
+
690
+ try {
691
+ const res = await CustomFetch(
692
+ `${reportsUrl}/${reportId}`,
693
+ 'DELETE'
694
+ );
695
+
696
+ if (res.data?.success) {
697
+ toast.success('Report deleted successfully');
698
+
699
+ // Refresh saved reports list
700
+ fetchSavedReports();
701
+
702
+ // If the deleted report is currently selected, reset the form
703
+ if (selectedReport && selectedReport.id === reportId) {
704
+ setSelectedReport(null);
705
+ setReportName('');
706
+ setIsPublic(false);
707
+ setSelectedColumns([]);
708
+ setJoins([]);
709
+ }
710
+ } else {
711
+ toast.error(
712
+ 'Failed to delete report: ' +
713
+ (res.data?.message || 'Unknown error')
714
+ );
715
+ }
716
+ } catch (error) {
717
+ console.error('Error deleting report:', error);
718
+ toast.error('Failed to delete report');
719
+ } finally {
720
+ setIsLoading(false);
721
+ }
722
+ };
723
+
724
+ // Export report as CSV or Excel
725
+ const handleExportReport = async (format = 'csv') => {
726
+ if (selectedColumns.length === 0) {
727
+ toast.error('Please select at least one column for your report');
728
+ return;
729
+ }
730
+
731
+ if (!reportName) {
732
+ toast.error('Please enter a name for your report');
733
+ return;
734
+ }
735
+
736
+ setIsLoading(true);
737
+ const toastId = toast.loading(
738
+ `Exporting report as ${format.toUpperCase()}...`
739
+ );
740
+
741
+ try {
742
+ // Prepare query configuration
743
+ const queryConfig = {
744
+ mainTable: selectedTable,
745
+ columns: selectedColumns.map((col) => ({
746
+ table: col.table,
747
+ column: col.column,
748
+ alias: col.alias || col.displayName,
749
+ })),
750
+ joins: joins.map((join) => ({
751
+ joinType: join.joinType,
752
+ targetTable: join.targetTable,
753
+ sourceColumn: join.sourceColumn,
754
+ targetColumn: join.targetColumn,
755
+ })),
756
+ filters: [],
757
+ sorting: [],
758
+ };
759
+
760
+ // Prepare export request data
761
+ const exportData = {
762
+ query: queryConfig,
763
+ format: format,
764
+ };
765
+
766
+ // If we have a selected report, include its ID
767
+ if (selectedReport && selectedReport.id) {
768
+ exportData.report_id = selectedReport.id;
769
+ }
770
+
771
+ // Use Download utility for file downloads
772
+ const res = await Download(
773
+ '/ajax/reportBuilder/export',
774
+ 'POST',
775
+ exportData
776
+ );
777
+
778
+ if (res.data instanceof Blob) {
779
+ // Create a date-based filename if not using a saved report
780
+ const date = moment().format('YYYYMMDD');
781
+ const fileName = `${date}_${reportName}.${format}`;
782
+
783
+ // Use FileSaver.js to save the file
784
+ saveAs(res.data, fileName);
785
+
786
+ toast.update(toastId, {
787
+ render: `Report exported successfully as ${format.toUpperCase()}`,
788
+ type: 'success',
789
+ isLoading: false,
790
+ autoClose: 5000,
791
+ closeButton: true,
792
+ });
793
+ } else {
794
+ throw new Error('Invalid response format');
795
+ }
796
+ } catch (error) {
797
+ console.error('Error exporting report:', error);
798
+ toast.update(toastId, {
799
+ render: `Failed to export report: ${
800
+ error.message || 'Unknown error'
801
+ }`,
802
+ type: 'error',
803
+ isLoading: false,
804
+ autoClose: 5000,
805
+ closeButton: true,
806
+ });
807
+ } finally {
808
+ setIsLoading(false);
809
+ }
810
+ };
811
+
812
+ // Fetch saved reports from the API
813
+ const fetchSavedReports = async () => {
814
+ setIsLoadingReports(true);
815
+
816
+ try {
817
+ const res = await CustomFetch(reportsUrl, 'GET');
818
+ const reportsData = res.data?.data || [];
819
+ setSavedReports(reportsData);
820
+ } catch (error) {
821
+ console.error('Error fetching saved reports:', error);
822
+ toast.error('Failed to load saved reports');
823
+ } finally {
824
+ setIsLoadingReports(false);
825
+ }
826
+ };
827
+
828
+ // Load a saved report
829
+ const handleLoadReport = async (reportId) => {
830
+ setIsLoading(true);
831
+
832
+ try {
833
+ const res = await CustomFetch(`${reportsUrl}/${reportId}`, 'GET');
834
+ const reportData = res.data?.data;
835
+
836
+ if (reportData) {
837
+ setSelectedReport(reportData);
838
+ setReportName(reportData.label);
839
+ setIsPublic(reportData.is_public);
840
+
841
+ // Load report configuration
842
+ const detail = reportData.detail || {};
843
+
844
+ // Set main table
845
+ const mainTable = detail.mainTable;
846
+ if (mainTable) {
847
+ setSelectedTable(mainTable);
848
+ await fetchTableColumns(mainTable);
849
+ }
850
+
851
+ // Set columns
852
+ if (detail.columns && Array.isArray(detail.columns)) {
853
+ // Map the columns to match our expected format
854
+ const formattedColumns = detail.columns.map((col) => ({
855
+ table: col.table,
856
+ column: col.column,
857
+ displayName:
858
+ col.alias ||
859
+ `${formatName(col.table)}.${formatName(
860
+ col.column
861
+ )}`,
862
+ rawDisplayName: `${col.table}.${col.column}`,
863
+ dataType: col.type || 'string',
864
+ }));
865
+ setSelectedColumns(formattedColumns);
866
+ }
867
+
868
+ // Set joins
869
+ if (detail.joins && Array.isArray(detail.joins)) {
870
+ setJoins(detail.joins);
871
+ }
872
+
873
+ toast.success(
874
+ `Report "${reportData.label}" loaded successfully`
875
+ );
876
+ } else {
877
+ toast.error('Failed to load report: Invalid data format');
878
+ }
879
+ } catch (error) {
880
+ console.error('Error loading report:', error);
881
+ toast.error('Failed to load report');
882
+ } finally {
883
+ setIsLoading(false);
884
+ }
885
+ };
886
+
887
+ // Execute a report query
888
+ const handleExecuteQuery = async (reportId) => {
889
+ // If a reportId is provided and it's different from the currently selected report,
890
+ // load that report first
891
+ if (
892
+ typeof reportId === 'number' &&
893
+ (!selectedReport || selectedReport.id !== reportId)
894
+ ) {
895
+ try {
896
+ // First check if the report is already in the savedReports list
897
+ const reportToLoad = savedReports.find(
898
+ (report) => report.id === reportId
899
+ );
900
+
901
+ if (reportToLoad) {
902
+ // If we have the report data already, just select it
903
+ setSelectedReport(reportToLoad);
904
+ setReportName(reportToLoad.label);
905
+ setIsPublic(reportToLoad.is_public);
906
+
907
+ // Load report configuration
908
+ const detail = reportToLoad.detail || {};
909
+
910
+ // Set main table
911
+ const mainTable = detail.mainTable;
912
+ if (mainTable) {
913
+ setSelectedTable(mainTable);
914
+ await fetchTableColumns(mainTable);
915
+ }
916
+
917
+ // Set columns
918
+ if (detail.columns && Array.isArray(detail.columns)) {
919
+ // Map the columns to match our expected format
920
+ const formattedColumns = detail.columns.map((col) => ({
921
+ table: col.table,
922
+ column: col.column,
923
+ displayName:
924
+ col.alias ||
925
+ `${formatName(col.table)}.${formatName(
926
+ col.column
927
+ )}`,
928
+ rawDisplayName: `${col.table}.${col.column}`,
929
+ dataType: col.type || 'string',
930
+ }));
931
+ setSelectedColumns(formattedColumns);
932
+ }
933
+
934
+ // Set joins
935
+ if (detail.joins && Array.isArray(detail.joins)) {
936
+ setJoins(detail.joins);
937
+ }
938
+
939
+ // Wait a moment for state to update
940
+ await new Promise((resolve) => setTimeout(resolve, 300));
941
+ } else {
942
+ // If we don't have the report data, fetch it
943
+ await handleLoadReport(reportId);
944
+ // Wait a moment for state to update
945
+ await new Promise((resolve) => setTimeout(resolve, 300));
946
+ }
947
+ } catch (error) {
948
+ console.error('Error loading report before execution:', error);
949
+ toast.error('Failed to load report for execution');
950
+ return;
951
+ }
952
+ }
953
+
954
+ if (selectedColumns.length === 0) {
955
+ toast.error('Please select at least one column for your report');
956
+ return;
957
+ }
958
+
959
+ setIsLoading(true);
960
+ toast.info('Executing query...');
961
+
962
+ try {
963
+ // Prepare query configuration
964
+ const queryConfig = {
965
+ mainTable: selectedTable,
966
+ columns: selectedColumns.map((col) => ({
967
+ table: col.table,
968
+ column: col.column,
969
+ alias: col.alias || col.displayName,
970
+ })),
971
+ joins: joins.map((join) => ({
972
+ joinType: join.joinType,
973
+ targetTable: join.targetTable,
974
+ sourceColumn: join.sourceColumn,
975
+ targetColumn: join.targetColumn,
976
+ })),
977
+ filters: [],
978
+ sorting: [],
979
+ };
980
+
981
+ // Execute query
982
+ const res = await CustomFetch(executeUrl, 'POST', {
983
+ query: queryConfig,
984
+ limit: 100,
985
+ offset: 0,
986
+ });
987
+
988
+ if (res.data?.success) {
989
+ setTotalResults(res.data.total || 0);
990
+ setExecutedSql(res.data.sql || '');
991
+ setPreviewData(res.data.data || []);
992
+
993
+ // Generate columns for the data grid
994
+ if (res.data.data && res.data.data.length > 0) {
995
+ const firstRow = res.data.data[0];
996
+ const gridCols = Object.keys(firstRow).map((key) => {
997
+ // Check if any value in this column is JSON
998
+ const hasJsonValues = res.data.data.some((row) => {
999
+ return isJsonValue(row[key]);
1000
+ });
1001
+
1002
+ return {
1003
+ name: key,
1004
+ header: key,
1005
+ defaultFlex: 1,
1006
+ minWidth: hasJsonValues ? 250 : 120,
1007
+ // Add a custom renderer for JSON values
1008
+ render: ({ value }) => {
1009
+ if (isJsonValue(value)) {
1010
+ try {
1011
+ // For JSON objects/arrays, format them nicely
1012
+ let jsonValue;
1013
+
1014
+ // Parse the JSON if it's a string
1015
+ if (typeof value === 'string') {
1016
+ try {
1017
+ jsonValue = JSON.parse(value);
1018
+ } catch (e) {
1019
+ return String(value);
1020
+ }
1021
+ } else {
1022
+ jsonValue = value;
1023
+ }
1024
+
1025
+ // Transform JSON to have human-readable keys
1026
+ const transformedJson =
1027
+ transformJsonForDisplay(jsonValue);
1028
+
1029
+ // Create a more compact representation for display
1030
+ const formattedJson = JSON.stringify(
1031
+ transformedJson,
1032
+ null,
1033
+ 2
1034
+ );
1035
+
1036
+ // Truncate if too long
1037
+ const isTruncated =
1038
+ formattedJson.length > 300;
1039
+ const displayJson = isTruncated
1040
+ ? formattedJson.substring(0, 300) +
1041
+ '...\n}'
1042
+ : formattedJson;
1043
+
1044
+ // Apply syntax highlighting to JSON
1045
+ // This function adds CSS classes to different parts of the JSON
1046
+ // which are styled in GenericReport.module.scss
1047
+ const highlightJson = (json) => {
1048
+ // Simple regex-based JSON syntax highlighting
1049
+ return (
1050
+ json
1051
+ // Highlight keys (before the colon) - blue color
1052
+ .replace(
1053
+ /"([^"]+)"\s*:/g,
1054
+ '"<span class="json-key">$1</span>":'
1055
+ )
1056
+ // Highlight string values - green color
1057
+ .replace(
1058
+ /:\s*"([^"]*)"/g,
1059
+ ': "<span class="json-string">$1</span>"'
1060
+ )
1061
+ // Highlight numeric values - purple color
1062
+ .replace(
1063
+ /:\s*([0-9]+(\.[0-9]+)?)/g,
1064
+ ': <span class="json-number">$1</span>'
1065
+ )
1066
+ // Highlight boolean values - amber color
1067
+ .replace(
1068
+ /:\s*(true|false)/g,
1069
+ ': <span class="json-boolean">$1</span>'
1070
+ )
1071
+ // Highlight null values - red color
1072
+ .replace(
1073
+ /:\s*(null)/g,
1074
+ ': <span class="json-null">$1</span>'
1075
+ )
1076
+ );
1077
+ };
1078
+
1079
+ return (
1080
+ <div className={styles.jsonPreview}>
1081
+ <pre
1082
+ dangerouslySetInnerHTML={{
1083
+ __html: highlightJson(
1084
+ displayJson
1085
+ ),
1086
+ }}
1087
+ />
1088
+ {isTruncated && (
1089
+ <div
1090
+ className={
1091
+ styles.jsonTruncated
1092
+ }
1093
+ >
1094
+ (truncated)
1095
+ </div>
1096
+ )}
1097
+ </div>
1098
+ );
1099
+ } catch (e) {
1100
+ return String(value);
1101
+ }
1102
+ }
1103
+ return value;
1104
+ },
1105
+ };
1106
+ });
1107
+ setGridColumns(gridCols);
1108
+ }
1109
+
1110
+ toast.success('Query executed successfully');
1111
+ } else {
1112
+ toast.error(
1113
+ 'Failed to execute query: ' +
1114
+ (res.data?.message || 'Unknown error')
1115
+ );
1116
+ }
1117
+ } catch (error) {
1118
+ console.error('Error executing query:', error);
1119
+ toast.error('Failed to execute query');
1120
+ } finally {
1121
+ setIsLoading(false);
1122
+ }
1123
+ };
1124
+
1125
+ // Save report configuration to the API
1126
+ const handleSaveReport = async () => {
1127
+ if (selectedColumns.length === 0) {
1128
+ toast.error('Please select at least one column for your report');
1129
+ return;
1130
+ }
1131
+
1132
+ if (!reportName) {
1133
+ toast.error('Please enter a name for your report');
1134
+ return;
1135
+ }
1136
+
1137
+ setIsLoading(true);
1138
+
1139
+ try {
1140
+ // Prepare report configuration
1141
+ const reportConfig = {
1142
+ mainTable: selectedTable,
1143
+ columns: selectedColumns.map((col) => ({
1144
+ table: col.table,
1145
+ column: col.column,
1146
+ alias: col.alias || col.displayName,
1147
+ })),
1148
+ joins: joins.map((join) => ({
1149
+ joinType: join.joinType,
1150
+ targetTable: join.targetTable,
1151
+ sourceColumn: join.sourceColumn,
1152
+ targetColumn: join.targetColumn,
1153
+ })),
1154
+ filters: [],
1155
+ sorting: [],
1156
+ };
1157
+
1158
+ // Determine if we're updating or creating a new report
1159
+ let url = reportsUrl;
1160
+ let method = 'POST';
1161
+ let data = {
1162
+ label: reportName,
1163
+ detail: reportConfig,
1164
+ is_public: isPublic,
1165
+ };
1166
+
1167
+ if (selectedReport && selectedReport.id) {
1168
+ url = `${reportsUrl}/${selectedReport.id}`;
1169
+ method = 'PUT';
1170
+ }
1171
+
1172
+ // Save report
1173
+ const res = await CustomFetch(url, method, data);
1174
+
1175
+ if (res.data?.success) {
1176
+ toast.success(
1177
+ `Report "${reportName}" ${
1178
+ selectedReport ? 'updated' : 'saved'
1179
+ } successfully`
1180
+ );
1181
+
1182
+ // Refresh saved reports list
1183
+ fetchSavedReports();
1184
+
1185
+ // If it's a new report, update selectedReport
1186
+ if (!selectedReport && res.data.data) {
1187
+ setSelectedReport(res.data.data);
1188
+ }
1189
+ } else {
1190
+ toast.error(
1191
+ 'Failed to save report: ' +
1192
+ (res.data?.message || 'Unknown error')
1193
+ );
1194
+ }
1195
+ } catch (error) {
1196
+ console.error('Error saving report:', error);
1197
+ toast.error('Failed to save report');
1198
+ } finally {
1199
+ setIsLoading(false);
1200
+ }
1201
+ };
1202
+
1203
+ return (
1204
+ <>
1205
+ <div className={styles.grid__full}>
1206
+ <div className={styles.crmtitle}>
1207
+ <h1>Report Builder</h1>
1208
+ <div className={styles.titleInfo}>
1209
+ <span>
1210
+ Create custom reports by selecting tables, columns,
1211
+ and configuring joins
1212
+ </span>
1213
+ </div>
1214
+ </div>
1215
+ </div>
1216
+
1217
+ <div className={styles.grid__subrow}>
1218
+ <div className={styles.grid__subnav}>
1219
+ <div className={styles.sectionTitle}>Available Tables</div>
1220
+ <div className={styles.tableList}>
1221
+ {isLoadingTables ? (
1222
+ <div className={styles.emptyMessage}>
1223
+ Loading tables...
1224
+ </div>
1225
+ ) : tables && tables.length > 0 ? (
1226
+ tables.map((table) => (
1227
+ <div
1228
+ key={table.name}
1229
+ className={`${styles.tableItem} ${
1230
+ selectedTable === table.name
1231
+ ? styles.tableItemSelected
1232
+ : ''
1233
+ }`}
1234
+ onClick={() =>
1235
+ handleTableSelect(table.name)
1236
+ }
1237
+ >
1238
+ {formatName(table.name)}
1239
+ </div>
1240
+ ))
1241
+ ) : (
1242
+ <div className={styles.emptyMessage}>
1243
+ No tables available
1244
+ </div>
1245
+ )}
1246
+ </div>
1247
+
1248
+ {selectedTable && (
1249
+ <>
1250
+ <div className={styles.sectionTitle}>
1251
+ Main Table Columns
1252
+ </div>
1253
+ {isLoading ? (
1254
+ <p className={styles.emptyMessage}>
1255
+ Loading columns...
1256
+ </p>
1257
+ ) : tableColumns && tableColumns.length > 0 ? (
1258
+ <div className={styles.columnList}>
1259
+ {tableColumns.map((column) => (
1260
+ <div
1261
+ key={`${selectedTable}-${column.name}`}
1262
+ className={styles.columnItem}
1263
+ onClick={() =>
1264
+ handleColumnSelect(column)
1265
+ }
1266
+ >
1267
+ <input
1268
+ type="checkbox"
1269
+ checked={selectedColumns.some(
1270
+ (col) =>
1271
+ col.table ===
1272
+ selectedTable &&
1273
+ col.column ===
1274
+ column.name
1275
+ )}
1276
+ readOnly
1277
+ />
1278
+ <span>
1279
+ {formatName(column.name)}
1280
+ </span>
1281
+ <small>
1282
+ ({column.type || column.label})
1283
+ </small>
1284
+ </div>
1285
+ ))}
1286
+ </div>
1287
+ ) : (
1288
+ <p className={styles.emptyMessage}>
1289
+ No columns available for this table.
1290
+ </p>
1291
+ )}
1292
+
1293
+ {/* Joined Tables Columns */}
1294
+ {joins.length > 0 && (
1295
+ <>
1296
+ <div className={styles.sectionTitle}>
1297
+ Joined Tables Columns
1298
+ </div>
1299
+ {isLoadingJoinColumns ? (
1300
+ <p className={styles.emptyMessage}>
1301
+ Loading joined table columns...
1302
+ </p>
1303
+ ) : (
1304
+ joins.map((join, joinIndex) =>
1305
+ join.targetTable &&
1306
+ join.availableColumns &&
1307
+ join.availableColumns.length > 0 ? (
1308
+ <div
1309
+ key={`join-${joinIndex}`}
1310
+ className={
1311
+ styles.joinedTableSection
1312
+ }
1313
+ >
1314
+ <div
1315
+ className={
1316
+ styles.joinedTableHeader
1317
+ }
1318
+ >
1319
+ {formatName(
1320
+ join.targetTable
1321
+ )}
1322
+ </div>
1323
+ <div
1324
+ className={
1325
+ styles.columnList
1326
+ }
1327
+ >
1328
+ {join.availableColumns.map(
1329
+ (column) => (
1330
+ <div
1331
+ key={`${join.targetTable}-${column.name}`}
1332
+ className={
1333
+ styles.columnItem
1334
+ }
1335
+ onClick={() =>
1336
+ handleColumnSelect(
1337
+ column,
1338
+ join.targetTable
1339
+ )
1340
+ }
1341
+ >
1342
+ <input
1343
+ type="checkbox"
1344
+ checked={selectedColumns.some(
1345
+ (
1346
+ col
1347
+ ) =>
1348
+ col.table ===
1349
+ join.targetTable &&
1350
+ col.column ===
1351
+ column.name
1352
+ )}
1353
+ readOnly
1354
+ />
1355
+ <span>
1356
+ {formatName(
1357
+ column.name
1358
+ )}
1359
+ </span>
1360
+ <small>
1361
+ (
1362
+ {column.type ||
1363
+ column.label}
1364
+ )
1365
+ </small>
1366
+ </div>
1367
+ )
1368
+ )}
1369
+ </div>
1370
+ </div>
1371
+ ) : null
1372
+ )
1373
+ )}
1374
+ </>
1375
+ )}
1376
+ </>
1377
+ )}
1378
+ </div>
1379
+
1380
+ <div className={styles.grid__subcontent}>
1381
+ <div className={styles.reportBuilder}>
1382
+ <div className={styles.reportSection}>
1383
+ <div className={styles.formGroup}>
1384
+ <label htmlFor="reportName">Report Name</label>
1385
+ <input
1386
+ id="reportName"
1387
+ type="text"
1388
+ className={styles.formControl}
1389
+ value={reportName}
1390
+ onChange={(e) =>
1391
+ setReportName(e.target.value)
1392
+ }
1393
+ placeholder="Enter a name for your report"
1394
+ />
1395
+ </div>
1396
+
1397
+ <div className={styles.formGroup}>
1398
+ <div className={styles.checkboxContainer}>
1399
+ <input
1400
+ id="isPublic"
1401
+ type="checkbox"
1402
+ checked={isPublic}
1403
+ onChange={(e) =>
1404
+ setIsPublic(e.target.checked)
1405
+ }
1406
+ />
1407
+ <label htmlFor="isPublic">
1408
+ Make report public
1409
+ </label>
1410
+ </div>
1411
+ </div>
1412
+
1413
+ <div className={styles.savedReportsSection}>
1414
+ <div className={styles.sectionHeader}>
1415
+ <h3>Saved Reports</h3>
1416
+ <button
1417
+ className={`${styles.btn} ${styles.btnSmall}`}
1418
+ onClick={handleResetForm}
1419
+ >
1420
+ New Report
1421
+ </button>
1422
+ </div>
1423
+ {isLoadingReports ? (
1424
+ <p className={styles.emptyMessage}>
1425
+ Loading reports...
1426
+ </p>
1427
+ ) : savedReports.length > 0 ? (
1428
+ <div className={styles.savedReportsList}>
1429
+ {savedReports.map((report) => (
1430
+ <div
1431
+ key={report.id}
1432
+ className={`${
1433
+ styles.savedReportItem
1434
+ } ${
1435
+ selectedReport &&
1436
+ selectedReport.id ===
1437
+ report.id
1438
+ ? styles.selectedReportItem
1439
+ : ''
1440
+ }`}
1441
+ >
1442
+ <div
1443
+ className={
1444
+ styles.reportInfo
1445
+ }
1446
+ onClick={() =>
1447
+ handleLoadReport(
1448
+ report.id
1449
+ )
1450
+ }
1451
+ >
1452
+ <span
1453
+ className={
1454
+ styles.reportName
1455
+ }
1456
+ >
1457
+ {report.label}
1458
+ </span>
1459
+ {report.is_public && (
1460
+ <span
1461
+ className={
1462
+ styles.publicBadge
1463
+ }
1464
+ >
1465
+ Public
1466
+ </span>
1467
+ )}
1468
+ </div>
1469
+ <div
1470
+ className={
1471
+ styles.reportActions
1472
+ }
1473
+ >
1474
+ <button
1475
+ className={
1476
+ styles.actionBtn
1477
+ }
1478
+ onClick={(e) => {
1479
+ e.stopPropagation(); // Prevent triggering the load report action
1480
+ handleExecuteQuery(
1481
+ report.id
1482
+ );
1483
+ }}
1484
+ title="Execute Report"
1485
+ >
1486
+
1487
+ </button>
1488
+ <button
1489
+ className={`${styles.actionBtn} ${styles.deleteBtn}`}
1490
+ onClick={(e) => {
1491
+ e.stopPropagation(); // Prevent triggering the load report action
1492
+ handleDeleteReport(
1493
+ report.id
1494
+ );
1495
+ }}
1496
+ title="Delete Report"
1497
+ >
1498
+ ×
1499
+ </button>
1500
+ </div>
1501
+ </div>
1502
+ ))}
1503
+ </div>
1504
+ ) : (
1505
+ <p className={styles.emptyMessage}>
1506
+ No saved reports found.
1507
+ </p>
1508
+ )}
1509
+ </div>
1510
+
1511
+ <h3>Selected Columns</h3>
1512
+ {selectedColumns.length > 0 ? (
1513
+ <div className={styles.selectedColumns}>
1514
+ {selectedColumns.map((column, index) => (
1515
+ <div
1516
+ key={index}
1517
+ className={`${
1518
+ styles.selectedColumnItem
1519
+ } ${
1520
+ draggedItemIndex === index
1521
+ ? styles.dragging
1522
+ : ''
1523
+ }`}
1524
+ draggable
1525
+ onDragStart={(e) =>
1526
+ handleDragStart(e, index)
1527
+ }
1528
+ onDragEnd={handleDragEnd}
1529
+ onDragOver={handleDragOver}
1530
+ onDragLeave={handleDragLeave}
1531
+ onDrop={(e) => handleDrop(e, index)}
1532
+ >
1533
+ <div
1534
+ className={styles.dragHandle}
1535
+ title="Drag to reorder"
1536
+ >
1537
+ ⋮⋮
1538
+ </div>
1539
+ <span>{column.displayName}</span>
1540
+ <div
1541
+ className={styles.columnActions}
1542
+ >
1543
+ <button
1544
+ className={styles.removeBtn}
1545
+ onClick={() =>
1546
+ handleRemoveColumn(
1547
+ index
1548
+ )
1549
+ }
1550
+ aria-label="Remove column"
1551
+ title="Remove"
1552
+ >
1553
+ <CircleX size={16} />
1554
+ </button>
1555
+ </div>
1556
+ </div>
1557
+ ))}
1558
+ </div>
1559
+ ) : (
1560
+ <p className={styles.emptyMessage}>
1561
+ No columns selected. Select a table and its
1562
+ columns from the left panel.
1563
+ </p>
1564
+ )}
1565
+ </div>
1566
+
1567
+ {joins.length > 0 && (
1568
+ <div className={styles.reportSection}>
1569
+ <h3>Join Tables</h3>
1570
+ {selectedTable &&
1571
+ joins.map((join, index) => (
1572
+ <div
1573
+ key={index}
1574
+ className={styles.joinSection}
1575
+ >
1576
+ <div className={styles.joinHeader}>
1577
+ <h4>
1578
+ {join.targetTable
1579
+ ? `${formatName(
1580
+ join.sourceTable
1581
+ )} ${join.joinType.replace(
1582
+ ' JOIN',
1583
+ ''
1584
+ )} ${formatName(
1585
+ join.targetTable
1586
+ )}`
1587
+ : `Join #${index + 1}`}
1588
+ </h4>
1589
+ <button
1590
+ className={
1591
+ styles.removeJoinBtn
1592
+ }
1593
+ onClick={() =>
1594
+ handleRemoveJoin(index)
1595
+ }
1596
+ title="Remove Join"
1597
+ >
1598
+ ×
1599
+ </button>
1600
+ </div>
1601
+
1602
+ <div className={styles.joinGrid}>
1603
+ <div
1604
+ className={
1605
+ styles.joinGridColumn
1606
+ }
1607
+ >
1608
+ <div
1609
+ className={
1610
+ styles.joinField
1611
+ }
1612
+ >
1613
+ <label>
1614
+ Join Type:
1615
+ </label>
1616
+ <select
1617
+ className={
1618
+ styles.selectControl
1619
+ }
1620
+ value={
1621
+ join.joinType
1622
+ }
1623
+ onChange={(e) =>
1624
+ handleJoinChange(
1625
+ index,
1626
+ 'joinType',
1627
+ e.target
1628
+ .value
1629
+ )
1630
+ }
1631
+ >
1632
+ <option value="INNER JOIN">
1633
+ INNER JOIN
1634
+ </option>
1635
+ <option value="LEFT JOIN">
1636
+ LEFT JOIN
1637
+ </option>
1638
+ <option value="RIGHT JOIN">
1639
+ RIGHT JOIN
1640
+ </option>
1641
+ <option value="FULL JOIN">
1642
+ FULL JOIN
1643
+ </option>
1644
+ </select>
1645
+ </div>
1646
+
1647
+ <div
1648
+ className={
1649
+ styles.joinField
1650
+ }
1651
+ >
1652
+ <label>
1653
+ Source Table:
1654
+ </label>
1655
+ <input
1656
+ type="text"
1657
+ className={
1658
+ styles.formControl
1659
+ }
1660
+ value={formatName(
1661
+ join.sourceTable
1662
+ )}
1663
+ readOnly
1664
+ />
1665
+ </div>
1666
+
1667
+ <div
1668
+ className={
1669
+ styles.joinField
1670
+ }
1671
+ >
1672
+ <label>
1673
+ Source Column:
1674
+ </label>
1675
+ <select
1676
+ className={
1677
+ styles.selectControl
1678
+ }
1679
+ value={
1680
+ join.sourceColumn
1681
+ }
1682
+ onChange={(e) =>
1683
+ handleJoinChange(
1684
+ index,
1685
+ 'sourceColumn',
1686
+ e.target
1687
+ .value
1688
+ )
1689
+ }
1690
+ >
1691
+ <option value="">
1692
+ Select Column
1693
+ </option>
1694
+ {tableColumns.map(
1695
+ (column) => (
1696
+ <option
1697
+ key={
1698
+ column.name
1699
+ }
1700
+ value={
1701
+ column.name
1702
+ }
1703
+ >
1704
+ {formatName(
1705
+ column.name
1706
+ )}
1707
+ </option>
1708
+ )
1709
+ )}
1710
+ </select>
1711
+ </div>
1712
+ </div>
1713
+
1714
+ <div
1715
+ className={
1716
+ styles.joinGridColumn
1717
+ }
1718
+ >
1719
+ <div
1720
+ className={
1721
+ styles.joinField
1722
+ }
1723
+ >
1724
+ <label>
1725
+ Target Table:
1726
+ </label>
1727
+ <select
1728
+ className={
1729
+ styles.selectControl
1730
+ }
1731
+ value={
1732
+ join.targetTable
1733
+ }
1734
+ onChange={(e) =>
1735
+ handleJoinChange(
1736
+ index,
1737
+ 'targetTable',
1738
+ e.target
1739
+ .value
1740
+ )
1741
+ }
1742
+ >
1743
+ <option value="">
1744
+ Select Table
1745
+ </option>
1746
+ {availableTables.map(
1747
+ (table) => (
1748
+ <option
1749
+ key={
1750
+ table.name
1751
+ }
1752
+ value={
1753
+ table.name
1754
+ }
1755
+ >
1756
+ {formatName(
1757
+ table.name
1758
+ )}
1759
+ </option>
1760
+ )
1761
+ )}
1762
+ </select>
1763
+ </div>
1764
+
1765
+ {join.targetTable &&
1766
+ join.availableColumns && (
1767
+ <div
1768
+ className={
1769
+ styles.joinField
1770
+ }
1771
+ >
1772
+ <label>
1773
+ Target
1774
+ Column:
1775
+ </label>
1776
+ <select
1777
+ className={
1778
+ styles.selectControl
1779
+ }
1780
+ value={
1781
+ join.targetColumn
1782
+ }
1783
+ onChange={(
1784
+ e
1785
+ ) =>
1786
+ handleJoinChange(
1787
+ index,
1788
+ 'targetColumn',
1789
+ e
1790
+ .target
1791
+ .value
1792
+ )
1793
+ }
1794
+ >
1795
+ <option value="">
1796
+ Select
1797
+ Column
1798
+ </option>
1799
+ {join.availableColumns.map(
1800
+ (
1801
+ column
1802
+ ) => (
1803
+ <option
1804
+ key={
1805
+ column.name
1806
+ }
1807
+ value={
1808
+ column.name
1809
+ }
1810
+ >
1811
+ {formatName(
1812
+ column.name
1813
+ )}
1814
+ </option>
1815
+ )
1816
+ )}
1817
+ </select>
1818
+ </div>
1819
+ )}
1820
+ </div>
1821
+ </div>
1822
+ </div>
1823
+ ))}
1824
+ </div>
1825
+ )}
1826
+
1827
+ <div className={styles.addJoinButtonContainer}>
1828
+ {selectedTable && (
1829
+ <>
1830
+ <button
1831
+ className={styles.btn}
1832
+ onClick={handleAddJoin}
1833
+ >
1834
+ Add Manual Relationship
1835
+ </button>
1836
+
1837
+ {/* Main table suggested relationships */}
1838
+ {suggestedJoins['main'] &&
1839
+ suggestedJoins['main'].length > 0 && (
1840
+ <div
1841
+ className={
1842
+ styles.suggestedJoinsSection
1843
+ }
1844
+ >
1845
+ <h4>
1846
+ Suggested Relationships for{' '}
1847
+ {formatName(selectedTable)}
1848
+ </h4>
1849
+ <p className={styles.helpText}>
1850
+ Auto-detected based on
1851
+ database structure.
1852
+ <span
1853
+ className={
1854
+ styles.ratingInfo
1855
+ }
1856
+ >
1857
+ ⭐⭐⭐ = High confidence
1858
+ | ⭐⭐ = Medium
1859
+ confidence | ⭐ = Low
1860
+ confidence
1861
+ </span>
1862
+ </p>
1863
+ <div
1864
+ className={
1865
+ styles.suggestedJoinsList
1866
+ }
1867
+ >
1868
+ {suggestedJoins['main'].map(
1869
+ (join, index) => (
1870
+ <div
1871
+ key={index}
1872
+ className={
1873
+ styles.suggestedJoinItem
1874
+ }
1875
+ title={formatRelationshipDescription(
1876
+ join.description,
1877
+ false,
1878
+ join
1879
+ )}
1880
+ >
1881
+ <div
1882
+ className={
1883
+ styles.suggestedJoinInfo
1884
+ }
1885
+ >
1886
+ <span
1887
+ className={
1888
+ styles.suggestedJoinDescription
1889
+ }
1890
+ >
1891
+ {formatRelationshipDescription(
1892
+ join.description,
1893
+ true,
1894
+ join
1895
+ )}
1896
+ </span>
1897
+ <span
1898
+ className={
1899
+ styles.confidenceBadge
1900
+ }
1901
+ >
1902
+ {join.confidence ===
1903
+ 'high'
1904
+ ? '⭐⭐⭐'
1905
+ : join.confidence ===
1906
+ 'medium'
1907
+ ? '⭐⭐'
1908
+ : '⭐'}
1909
+ </span>
1910
+ </div>
1911
+ <button
1912
+ className={
1913
+ styles.addSuggestedJoinBtn
1914
+ }
1915
+ onClick={() =>
1916
+ handleAddSuggestedJoin(
1917
+ join
1918
+ )
1919
+ }
1920
+ title="Add this relationship"
1921
+ >
1922
+ Add
1923
+ </button>
1924
+ </div>
1925
+ )
1926
+ )}
1927
+ </div>
1928
+ </div>
1929
+ )}
1930
+
1931
+ {isLoadingSuggestedJoins['main'] && (
1932
+ <div className={styles.loadingMessage}>
1933
+ Loading suggested relationships for{' '}
1934
+ {formatName(selectedTable)}...
1935
+ </div>
1936
+ )}
1937
+
1938
+ {/* Joined tables suggested relationships */}
1939
+ {joins.map(
1940
+ (join, joinIndex) =>
1941
+ join.targetTable && (
1942
+ <div
1943
+ key={`suggested-joins-${joinIndex}`}
1944
+ >
1945
+ {suggestedJoins[
1946
+ join.targetTable
1947
+ ] &&
1948
+ suggestedJoins[
1949
+ join.targetTable
1950
+ ].length > 0 && (
1951
+ <div
1952
+ className={
1953
+ styles.suggestedJoinsSection
1954
+ }
1955
+ >
1956
+ <h4>
1957
+ Suggested
1958
+ Relationships
1959
+ for{' '}
1960
+ {formatName(
1961
+ join.targetTable
1962
+ )}
1963
+ </h4>
1964
+ <div
1965
+ className={
1966
+ styles.suggestedJoinsList
1967
+ }
1968
+ >
1969
+ {suggestedJoins[
1970
+ join
1971
+ .targetTable
1972
+ ].map(
1973
+ (
1974
+ suggestedJoin,
1975
+ index
1976
+ ) => (
1977
+ <div
1978
+ key={
1979
+ index
1980
+ }
1981
+ className={
1982
+ styles.suggestedJoinItem
1983
+ }
1984
+ title={formatRelationshipDescription(
1985
+ suggestedJoin.description,
1986
+ false,
1987
+ suggestedJoin
1988
+ )}
1989
+ >
1990
+ <div
1991
+ className={
1992
+ styles.suggestedJoinInfo
1993
+ }
1994
+ >
1995
+ <span
1996
+ className={
1997
+ styles.suggestedJoinDescription
1998
+ }
1999
+ >
2000
+ {formatRelationshipDescription(
2001
+ suggestedJoin.description,
2002
+ true,
2003
+ suggestedJoin
2004
+ )}
2005
+ </span>
2006
+ <span
2007
+ className={
2008
+ styles.confidenceBadge
2009
+ }
2010
+ >
2011
+ {suggestedJoin.confidence ===
2012
+ 'high'
2013
+ ? '⭐⭐⭐'
2014
+ : suggestedJoin.confidence ===
2015
+ 'medium'
2016
+ ? '⭐⭐'
2017
+ : '⭐'}
2018
+ </span>
2019
+ </div>
2020
+ <button
2021
+ className={
2022
+ styles.addSuggestedJoinBtn
2023
+ }
2024
+ onClick={() =>
2025
+ handleAddSuggestedJoin(
2026
+ suggestedJoin
2027
+ )
2028
+ }
2029
+ title="Add this relationship"
2030
+ >
2031
+ Add
2032
+ </button>
2033
+ </div>
2034
+ )
2035
+ )}
2036
+ </div>
2037
+ </div>
2038
+ )}
2039
+
2040
+ {isLoadingSuggestedJoins[
2041
+ join.targetTable
2042
+ ] && (
2043
+ <div
2044
+ className={
2045
+ styles.loadingMessage
2046
+ }
2047
+ >
2048
+ Loading suggested
2049
+ relationships for{' '}
2050
+ {formatName(
2051
+ join.targetTable
2052
+ )}
2053
+ ...
2054
+ </div>
2055
+ )}
2056
+ </div>
2057
+ )
2058
+ )}
2059
+ </>
2060
+ )}
2061
+ </div>
2062
+
2063
+ <div className={styles.actionButtons}>
2064
+ <button
2065
+ className={styles.btn}
2066
+ onClick={handleExecuteQuery}
2067
+ disabled={
2068
+ isLoading || selectedColumns.length === 0
2069
+ }
2070
+ >
2071
+ Execute Query
2072
+ </button>
2073
+
2074
+ <button
2075
+ className={`${styles.btn} ${styles.btnSecondary}`}
2076
+ onClick={() => handleExportReport('csv')}
2077
+ disabled={
2078
+ isLoading ||
2079
+ selectedColumns.length === 0 ||
2080
+ !reportName
2081
+ }
2082
+ >
2083
+ Export as CSV
2084
+ </button>
2085
+ <button
2086
+ className={`${styles.btn} ${styles.btnSecondary}`}
2087
+ onClick={() => handleExportReport('xlsx')}
2088
+ disabled={
2089
+ isLoading ||
2090
+ selectedColumns.length === 0 ||
2091
+ !reportName
2092
+ }
2093
+ >
2094
+ Export as Excel
2095
+ </button>
2096
+ <button
2097
+ className={`${styles.btn} ${styles.btnPrimary}`}
2098
+ onClick={handleSaveReport}
2099
+ disabled={
2100
+ isLoading ||
2101
+ selectedColumns.length === 0 ||
2102
+ !reportName
2103
+ }
2104
+ >
2105
+ {selectedReport
2106
+ ? 'Update Report'
2107
+ : 'Save Report'}
2108
+ </button>
2109
+ {selectedReport && (
2110
+ <button
2111
+ className={`${styles.btn} ${styles.btnDanger}`}
2112
+ onClick={() =>
2113
+ handleDeleteReport(selectedReport.id)
2114
+ }
2115
+ disabled={isLoading}
2116
+ >
2117
+ Delete Report
2118
+ </button>
2119
+ )}
2120
+ </div>
2121
+ {previewData.length > 0 && (
2122
+ <div className={styles.previewSection}>
2123
+ <h3>
2124
+ Report Preview{' '}
2125
+ {totalResults > 0 &&
2126
+ `(${previewData.length} of ${totalResults} results)`}
2127
+ </h3>
2128
+ <ReactDataGrid
2129
+ idProperty="id"
2130
+ columns={gridColumns}
2131
+ dataSource={previewData}
2132
+ style={{ minHeight: 600 }}
2133
+ pagination
2134
+ limit={15}
2135
+ rowHeight={null}
2136
+ estimatedRowHeight={60}
2137
+ showCellBorders="horizontal"
2138
+ showZebraRows
2139
+ />
2140
+ </div>
2141
+ )}
2142
+
2143
+ {executedSql && (
2144
+ <div className={styles.sqlSection}>
2145
+ <h3>Executed SQL</h3>
2146
+ <div className={styles.sqlContainer}>
2147
+ <pre>{executedSql}</pre>
2148
+ </div>
2149
+ </div>
2150
+ )}
2151
+ </div>
2152
+ </div>
2153
+ </div>
2154
+ </>
2155
+ );
2156
+ };
2157
+
2158
+ export default GenericReport;