@visns-studio/visns-components 5.4.19 → 5.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/crm/auth/styles/TwoFactorAuth.module.scss +16 -2
- package/src/components/crm/generic/GenericReport.jsx +2158 -0
- package/src/components/crm/generic/styles/GenericFormBuilder.module.scss +3 -3
- package/src/components/crm/generic/styles/GenericReport.module.scss +905 -0
- package/src/components/crm/styles/Field.module.scss +3 -3
- package/src/components/styles/global.css +6 -6
- package/src/index.js +2 -0
|
@@ -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;
|