@visns-studio/visns-components 6.1.7 → 6.2.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.
@@ -38,6 +38,31 @@ import debugLog from '../utils/debugLog';
38
38
  import { saveAs } from 'file-saver';
39
39
  import * as XLSX from 'xlsx';
40
40
  import Swal from 'sweetalert2';
41
+ import useSemanticModel from './useSemanticModel';
42
+ import {
43
+ DEFAULT_SEMANTIC_MODEL_URL,
44
+ appendToFilterGroup,
45
+ buildDefinition,
46
+ collectParameters,
47
+ createFilterCondition,
48
+ createFilterGroup,
49
+ createSelection,
50
+ defaultSelectionLabel,
51
+ emptyParameterValue,
52
+ getEntityLabel,
53
+ isSemanticDefinition,
54
+ parseDefinition,
55
+ parametersSatisfied,
56
+ relationPathsFromDefinition,
57
+ resolveFieldPath,
58
+ updateFilterNode,
59
+ } from './reportSemantics';
60
+ import SemanticEntityStep from './reportSemanticSteps/SemanticEntityStep';
61
+ import SemanticRelationsStep from './reportSemanticSteps/SemanticRelationsStep';
62
+ import SemanticFieldsStep from './reportSemanticSteps/SemanticFieldsStep';
63
+ import SemanticFiltersStep from './reportSemanticSteps/SemanticFiltersStep';
64
+ import SemanticGroupingStep from './reportSemanticSteps/SemanticGroupingStep';
65
+ import SemanticPreviewStep from './reportSemanticSteps/SemanticPreviewStep';
41
66
  import styles from '../styles/GenericReport.module.scss';
42
67
  import '../styles/SweetAlert.module.css';
43
68
  import './groupedReport.css';
@@ -144,32 +169,6 @@ const getUniqueTableDisplayName = (tableName, allTables, definition) => {
144
169
  return `${baseDisplayName} (${tableName})`;
145
170
  };
146
171
 
147
- // Get structured table display name data for better styling
148
- const getStructuredTableDisplayName = (tableName, allTables, definition) => {
149
- const baseDisplayName = getTableDisplayName(tableName, definition);
150
-
151
- // Find all tables with the same display name
152
- const conflictingTables = allTables.filter((table) => {
153
- const otherTableName = typeof table === 'string' ? table : table.name;
154
- const otherDisplayName = getTableDisplayName(
155
- otherTableName,
156
- definition
157
- );
158
- return (
159
- otherDisplayName === baseDisplayName && otherTableName !== tableName
160
- );
161
- });
162
-
163
- return {
164
- main: baseDisplayName,
165
- disambiguation: conflictingTables.length > 0 ? tableName : null,
166
- full:
167
- conflictingTables.length > 0
168
- ? `${baseDisplayName} (${tableName})`
169
- : baseDisplayName,
170
- };
171
- };
172
-
173
172
  // Get table description from definition
174
173
  const getTableDescription = (tableName, definition) => {
175
174
  if (definition && definition.tables) {
@@ -1013,6 +1012,110 @@ const wizardSteps = [
1013
1012
  },
1014
1013
  ];
1015
1014
 
1015
+ /**
1016
+ * Wizard steps used when the semantic model is available. Same six ids and the
1017
+ * same order as `wizardSteps` — only the wording changes, because in this mode
1018
+ * the user never sees a table, a column or a join.
1019
+ */
1020
+ const semanticWizardSteps = [
1021
+ {
1022
+ id: 'table',
1023
+ title: 'Choose Your Subject',
1024
+ icon: Data,
1025
+ description: 'What is this report about?',
1026
+ guidance: {
1027
+ summary: 'Pick the main thing you want to report on',
1028
+ howTo: [
1029
+ 'Each card is a kind of record your system keeps',
1030
+ 'Read the description to check it is the one you mean',
1031
+ 'Click it to make it the subject of your report',
1032
+ 'You can bring in connected information at the next step',
1033
+ ],
1034
+ tip: 'Pick the thing you want one row per — for example one row per client.',
1035
+ },
1036
+ },
1037
+ {
1038
+ id: 'relationships',
1039
+ title: 'Add Related Data',
1040
+ icon: LinkChain,
1041
+ description: 'Bring in connected information (optional)',
1042
+ guidance: {
1043
+ summary: 'Add areas connected to your subject',
1044
+ howTo: [
1045
+ 'Each card is an area of information already connected for you',
1046
+ 'Click one to make its details available in the next step',
1047
+ 'Areas marked "many per record" can repeat rows',
1048
+ 'Skip this step if the main subject has everything you need',
1049
+ ],
1050
+ tip: 'Summarising a "many per record" area (count, total) keeps one row per record.',
1051
+ },
1052
+ },
1053
+ {
1054
+ id: 'columns',
1055
+ title: 'Choose Information',
1056
+ icon: File,
1057
+ description: 'Pick the details you want to see',
1058
+ guidance: {
1059
+ summary: 'Tick the details to include as columns',
1060
+ howTo: [
1061
+ 'Details are grouped by the area they come from',
1062
+ 'Within each area they are sorted into kinds of information',
1063
+ 'Tick anything you want to see in the report',
1064
+ 'Use "Show as" to total, count or average a detail instead',
1065
+ ],
1066
+ tip: 'Start with how you identify a record, then add the numbers you care about.',
1067
+ },
1068
+ },
1069
+ {
1070
+ id: 'filters',
1071
+ title: 'Filter Results',
1072
+ icon: Filter,
1073
+ description: 'Narrow down to specific records (optional)',
1074
+ guidance: {
1075
+ summary: 'Keep only the records you care about',
1076
+ howTo: [
1077
+ 'Add a condition, then choose a detail and how to compare it',
1078
+ 'Switch a group between "all of these" and "any of these"',
1079
+ 'Nest a group to mix the two, e.g. active AND (email OR phone)',
1080
+ 'Tick "ask me each time" to be prompted for the value at run time',
1081
+ ],
1082
+ tip: 'Anything you might change week to week is better asked at run time than fixed here.',
1083
+ },
1084
+ },
1085
+ {
1086
+ id: 'grouping',
1087
+ title: 'Group & Order',
1088
+ icon: Data,
1089
+ description: 'Organise the results (optional)',
1090
+ guidance: {
1091
+ summary: 'Split the report into sections and set the row order',
1092
+ howTo: [
1093
+ 'Tick a column to break the report into sections by its value',
1094
+ 'Add a sort to control the order rows appear in',
1095
+ 'Only columns you chose to show can be used here',
1096
+ 'Skip this step for a plain list',
1097
+ ],
1098
+ tip: 'Grouping suits reports like "clients by adviser".',
1099
+ },
1100
+ },
1101
+ {
1102
+ id: 'preview',
1103
+ title: 'Preview & Save',
1104
+ icon: EyeOpen,
1105
+ description: 'Review your report and save it for reuse',
1106
+ guidance: {
1107
+ summary: 'Run, review, save and export',
1108
+ howTo: [
1109
+ 'Answer any run-time questions you set up',
1110
+ 'Click "Run report" to see your data',
1111
+ 'Save the report so you or your team can run it again',
1112
+ 'Export the results as Excel, CSV or PDF',
1113
+ ],
1114
+ tip: 'A saved report always runs against live data — save it once, run it whenever.',
1115
+ },
1116
+ },
1117
+ ];
1118
+
1016
1119
  // Icon mapping for business templates (moved from hardcoded templates)
1017
1120
  const getTemplateIcon = (iconName) => {
1018
1121
  const iconMap = {
@@ -1175,6 +1278,451 @@ const GenericReportImproved = ({
1175
1278
  exportUrl = '/ajax/reportBuilder/export',
1176
1279
  } = setting;
1177
1280
 
1281
+ // `semanticModelUrl: null` (or false) opts a host app out of the probe
1282
+ // entirely; omitting the key uses the contract default.
1283
+ const semanticModelUrl =
1284
+ 'semanticModelUrl' in setting
1285
+ ? setting.semanticModelUrl
1286
+ : DEFAULT_SEMANTIC_MODEL_URL;
1287
+
1288
+ const {
1289
+ model: semanticModel,
1290
+ status: semanticStatus,
1291
+ isSemantic,
1292
+ } = useSemanticModel(
1293
+ semanticModelUrl || DEFAULT_SEMANTIC_MODEL_URL,
1294
+ !!semanticModelUrl
1295
+ );
1296
+
1297
+ // ---- Semantic mode state -------------------------------------------
1298
+ // All of it is inert while `isSemantic` is false, which is what keeps
1299
+ // legacy mode byte-for-byte unchanged.
1300
+ const [semanticEntity, setSemanticEntity] = useState('');
1301
+ const [semanticRelations, setSemanticRelations] = useState([]);
1302
+ const [semanticSelections, setSemanticSelections] = useState([]);
1303
+ const [semanticFilterTree, setSemanticFilterTree] = useState(() =>
1304
+ createFilterGroup('and', [])
1305
+ );
1306
+ const [semanticParameters, setSemanticParameters] = useState([]);
1307
+ const [semanticParameterValues, setSemanticParameterValues] = useState({});
1308
+ const [semanticGroupBy, setSemanticGroupBy] = useState([]);
1309
+ const [semanticSort, setSemanticSort] = useState([]);
1310
+ const [semanticRows, setSemanticRows] = useState([]);
1311
+ const [semanticTotal, setSemanticTotal] = useState(0);
1312
+ const [semanticPage, setSemanticPage] = useState(1);
1313
+ const [semanticPageSize, setSemanticPageSize] = useState(25);
1314
+ const [semanticError, setSemanticError] = useState('');
1315
+ const [semanticHasRun, setSemanticHasRun] = useState(false);
1316
+
1317
+ const activeWizardSteps = isSemantic ? semanticWizardSteps : wizardSteps;
1318
+
1319
+ /* ---- Semantic mode handlers ---------------------------------------- */
1320
+
1321
+ const resetSemanticState = useCallback(() => {
1322
+ setSemanticEntity('');
1323
+ setSemanticRelations([]);
1324
+ setSemanticSelections([]);
1325
+ setSemanticFilterTree(createFilterGroup('and', []));
1326
+ setSemanticParameters([]);
1327
+ setSemanticParameterValues({});
1328
+ setSemanticGroupBy([]);
1329
+ setSemanticSort([]);
1330
+ setSemanticRows([]);
1331
+ setSemanticTotal(0);
1332
+ setSemanticPage(1);
1333
+ setSemanticError('');
1334
+ setSemanticHasRun(false);
1335
+ }, []);
1336
+
1337
+ const handleSemanticSelectEntity = (entityId) => {
1338
+ if (entityId === semanticEntity) return;
1339
+ // A different subject invalidates every path that was built on the
1340
+ // previous one, so start the semantic side of the wizard over.
1341
+ resetSemanticState();
1342
+ setSemanticEntity(entityId);
1343
+ };
1344
+
1345
+ const handleSemanticToggleRelation = (relationPath) => {
1346
+ const isRemoving = semanticRelations.includes(relationPath);
1347
+
1348
+ if (!isRemoving) {
1349
+ setSemanticRelations((previous) => [...previous, relationPath]);
1350
+ return;
1351
+ }
1352
+
1353
+ // Removing a relation also removes anything nested under it and every
1354
+ // selection, filter, grouping and sort that reached through it.
1355
+ const prefix = `${relationPath}.`;
1356
+ const isOrphaned = (path) =>
1357
+ path === relationPath || String(path).startsWith(prefix);
1358
+
1359
+ setSemanticRelations((previous) =>
1360
+ previous.filter((path) => !isOrphaned(path))
1361
+ );
1362
+ setSemanticSelections((previous) =>
1363
+ previous.filter((selection) => !isOrphaned(selection.path))
1364
+ );
1365
+ setSemanticGroupBy((previous) =>
1366
+ previous.filter((path) => !isOrphaned(path))
1367
+ );
1368
+ setSemanticSort((previous) =>
1369
+ previous.filter((entry) => !isOrphaned(entry.field))
1370
+ );
1371
+ setSemanticFilterTree((previous) => {
1372
+ const prune = (node) => {
1373
+ if (node.kind === 'group') {
1374
+ return {
1375
+ ...node,
1376
+ items: (node.items || [])
1377
+ .map(prune)
1378
+ .filter((child) => child !== null),
1379
+ };
1380
+ }
1381
+ return isOrphaned(node.field) ? null : node;
1382
+ };
1383
+ return prune(previous);
1384
+ });
1385
+ };
1386
+
1387
+ const handleSemanticToggleField = (path) => {
1388
+ setSemanticSelections((previous) => {
1389
+ const exists = previous.some(
1390
+ (selection) => selection.path === path
1391
+ );
1392
+ if (exists) {
1393
+ return previous.filter(
1394
+ (selection) => selection.path !== path
1395
+ );
1396
+ }
1397
+ const selection = createSelection(
1398
+ semanticModel,
1399
+ semanticEntity,
1400
+ path
1401
+ );
1402
+ return selection ? [...previous, selection] : previous;
1403
+ });
1404
+ };
1405
+
1406
+ const handleSemanticChangeAggregate = (path, agg) => {
1407
+ setSemanticSelections((previous) =>
1408
+ previous.map((selection) => {
1409
+ if (selection.path !== path) return selection;
1410
+ const resolved = resolveFieldPath(
1411
+ semanticModel,
1412
+ semanticEntity,
1413
+ path
1414
+ );
1415
+ if (!resolved) return selection;
1416
+ return {
1417
+ ...selection,
1418
+ agg: agg || '',
1419
+ label: defaultSelectionLabel(resolved, agg),
1420
+ };
1421
+ })
1422
+ );
1423
+
1424
+ // An aggregate cannot also be the grouping key.
1425
+ if (agg) {
1426
+ setSemanticGroupBy((previous) =>
1427
+ previous.filter((entry) => entry !== path)
1428
+ );
1429
+ }
1430
+ };
1431
+
1432
+ const handleSemanticRemoveSelection = (path, agg) => {
1433
+ setSemanticSelections((previous) =>
1434
+ previous.filter(
1435
+ (selection) =>
1436
+ !(
1437
+ selection.path === path &&
1438
+ (selection.agg || '') === (agg || '')
1439
+ )
1440
+ )
1441
+ );
1442
+ setSemanticGroupBy((previous) =>
1443
+ previous.filter((entry) => entry !== path)
1444
+ );
1445
+ setSemanticSort((previous) =>
1446
+ previous.filter((entry) => entry.field !== path)
1447
+ );
1448
+ };
1449
+
1450
+ const handleSemanticMoveSelection = (from, to) => {
1451
+ setSemanticSelections((previous) => {
1452
+ if (to < 0 || to >= previous.length) return previous;
1453
+ const next = [...previous];
1454
+ const [moved] = next.splice(from, 1);
1455
+ next.splice(to, 0, moved);
1456
+ return next;
1457
+ });
1458
+ };
1459
+
1460
+ const handleSemanticChangeFilterNode = (nodeId, patch) => {
1461
+ setSemanticFilterTree((previous) =>
1462
+ updateFilterNode(previous, nodeId, (node) => ({
1463
+ ...node,
1464
+ ...patch,
1465
+ }))
1466
+ );
1467
+ };
1468
+
1469
+ const handleSemanticRemoveFilterNode = (nodeId) => {
1470
+ setSemanticFilterTree((previous) =>
1471
+ updateFilterNode(previous, nodeId, () => null)
1472
+ );
1473
+ };
1474
+
1475
+ const handleSemanticAddFilterCondition = (groupId) => {
1476
+ setSemanticFilterTree((previous) =>
1477
+ appendToFilterGroup(previous, groupId, createFilterCondition())
1478
+ );
1479
+ };
1480
+
1481
+ const handleSemanticAddFilterGroup = (groupId) => {
1482
+ setSemanticFilterTree((previous) =>
1483
+ appendToFilterGroup(
1484
+ previous,
1485
+ groupId,
1486
+ createFilterGroup('or', [createFilterCondition()])
1487
+ )
1488
+ );
1489
+ };
1490
+
1491
+ const handleSemanticChangeParameter = (parameterId, patch) => {
1492
+ setSemanticParameters((previous) =>
1493
+ previous.map((parameter) =>
1494
+ parameter.id === parameterId
1495
+ ? { ...parameter, ...patch }
1496
+ : parameter
1497
+ )
1498
+ );
1499
+ };
1500
+
1501
+ const handleSemanticChangeParameterValue = (parameterId, value) => {
1502
+ setSemanticParameterValues((previous) => ({
1503
+ ...previous,
1504
+ [parameterId]: value,
1505
+ }));
1506
+ };
1507
+
1508
+ const handleSemanticToggleGroupBy = (path) => {
1509
+ setSemanticGroupBy((previous) =>
1510
+ previous.includes(path)
1511
+ ? previous.filter((entry) => entry !== path)
1512
+ : [...previous, path]
1513
+ );
1514
+ };
1515
+
1516
+ const handleSemanticAddSort = (field, dir) => {
1517
+ setSemanticSort((previous) => [...previous, { field, dir }]);
1518
+ };
1519
+
1520
+ const handleSemanticChangeSort = (index, patch) => {
1521
+ setSemanticSort((previous) =>
1522
+ previous.map((entry, entryIndex) =>
1523
+ entryIndex === index ? { ...entry, ...patch } : entry
1524
+ )
1525
+ );
1526
+ };
1527
+
1528
+ const handleSemanticRemoveSort = (index) => {
1529
+ setSemanticSort((previous) =>
1530
+ previous.filter((entry, entryIndex) => entryIndex !== index)
1531
+ );
1532
+ };
1533
+
1534
+ // Parameter definitions are derived from the filter tree: ticking "ask me
1535
+ // each time" creates one, unticking removes it. Edited labels survive.
1536
+ useEffect(() => {
1537
+ if (!isSemantic || !semanticEntity) return;
1538
+ setSemanticParameters((previous) => {
1539
+ const next = collectParameters(
1540
+ semanticModel,
1541
+ semanticEntity,
1542
+ semanticFilterTree,
1543
+ previous
1544
+ );
1545
+ const unchanged =
1546
+ next.length === previous.length &&
1547
+ next.every(
1548
+ (parameter, index) =>
1549
+ parameter.id === previous[index].id &&
1550
+ parameter.label === previous[index].label &&
1551
+ parameter.type === previous[index].type &&
1552
+ parameter.required === previous[index].required
1553
+ );
1554
+ return unchanged ? previous : next;
1555
+ });
1556
+ }, [isSemantic, semanticModel, semanticEntity, semanticFilterTree]);
1557
+
1558
+ // Seed a blank answer for each parameter so the inputs stay controlled.
1559
+ useEffect(() => {
1560
+ if (!isSemantic) return;
1561
+ setSemanticParameterValues((previous) => {
1562
+ const next = {};
1563
+ semanticParameters.forEach((parameter) => {
1564
+ next[parameter.id] =
1565
+ previous[parameter.id] ??
1566
+ emptyParameterValue(parameter.type);
1567
+ });
1568
+ return next;
1569
+ });
1570
+ }, [isSemantic, semanticParameters]);
1571
+
1572
+ /** The v2 document this wizard currently describes. */
1573
+ const semanticDefinition = useMemo(() => {
1574
+ if (!isSemantic || !semanticEntity) return null;
1575
+ return buildDefinition({
1576
+ model: semanticModel,
1577
+ entity: semanticEntity,
1578
+ selections: semanticSelections,
1579
+ filterTree: semanticFilterTree,
1580
+ parameters: semanticParameters,
1581
+ groupBy: semanticGroupBy,
1582
+ sort: semanticSort,
1583
+ });
1584
+ }, [
1585
+ isSemantic,
1586
+ semanticModel,
1587
+ semanticEntity,
1588
+ semanticSelections,
1589
+ semanticFilterTree,
1590
+ semanticParameters,
1591
+ semanticGroupBy,
1592
+ semanticSort,
1593
+ ]);
1594
+
1595
+ /**
1596
+ * The server's own words for a rejected definition.
1597
+ *
1598
+ * A bad definition comes back as HTTP 422 with
1599
+ * `{success:false, message, errors:[{path, message}]}` — but a non-2xx
1600
+ * response makes CustomFetch reject rather than resolve, so the body never
1601
+ * reaches the `success === false` branch below. "Unknown field [x] on
1602
+ * [clients]" is exactly what the user needs to fix the report, so dig it
1603
+ * off the error before falling back to a generic line.
1604
+ */
1605
+ const semanticErrorMessage = (error, fallback) => {
1606
+ const data = error?.response?.data;
1607
+ if (typeof data?.message === 'string' && data.message) {
1608
+ return data.message;
1609
+ }
1610
+ const first = Array.isArray(data?.errors) ? data.errors[0] : null;
1611
+ if (typeof first?.message === 'string' && first.message) {
1612
+ return first.message;
1613
+ }
1614
+ return fallback;
1615
+ };
1616
+
1617
+ /**
1618
+ * POST executeUrl {definition, parameters, limit, offset}
1619
+ * -> {success, data: [rows], total}
1620
+ */
1621
+ const runSemanticReport = async ({ page, size } = {}) => {
1622
+ if (!semanticDefinition || semanticSelections.length === 0) return;
1623
+
1624
+ const pageToLoad = page || semanticPage;
1625
+ const sizeToLoad = size || semanticPageSize;
1626
+
1627
+ if (!parametersSatisfied(semanticParameters, semanticParameterValues)) {
1628
+ setSemanticError(
1629
+ 'Answer every required question before running this report.'
1630
+ );
1631
+ return;
1632
+ }
1633
+
1634
+ setIsLoading(true);
1635
+ setSemanticError('');
1636
+
1637
+ try {
1638
+ const result = await CustomFetch(
1639
+ customUrls.executeUrl || executeUrl,
1640
+ 'POST',
1641
+ {
1642
+ definition: semanticDefinition,
1643
+ parameters: semanticParameterValues,
1644
+ limit: sizeToLoad,
1645
+ offset: (pageToLoad - 1) * sizeToLoad,
1646
+ },
1647
+ null,
1648
+ () => {},
1649
+ { cache: false }
1650
+ );
1651
+
1652
+ const body = result?.data;
1653
+
1654
+ if (body?.success === false) {
1655
+ setSemanticRows([]);
1656
+ setSemanticTotal(0);
1657
+ setSemanticError(
1658
+ body.message || 'The report could not be run.'
1659
+ );
1660
+ return;
1661
+ }
1662
+
1663
+ const rows = Array.isArray(body?.data) ? body.data : [];
1664
+ setSemanticRows(rows);
1665
+ setSemanticTotal(
1666
+ typeof body?.total === 'number' ? body.total : rows.length
1667
+ );
1668
+ setSemanticPage(pageToLoad);
1669
+ setSemanticPageSize(sizeToLoad);
1670
+ setSemanticHasRun(true);
1671
+ } catch (error) {
1672
+ debugLog('Semantic report execution failed:', error);
1673
+ setSemanticRows([]);
1674
+ setSemanticTotal(0);
1675
+ setSemanticError(
1676
+ semanticErrorMessage(
1677
+ error,
1678
+ 'We could not run this report. Please try again.'
1679
+ )
1680
+ );
1681
+ } finally {
1682
+ setIsLoading(false);
1683
+ }
1684
+ };
1685
+
1686
+ /** POST exportUrl {definition, parameters, format} -> blob */
1687
+ const exportSemanticReport = async (format) => {
1688
+ if (!semanticDefinition) return;
1689
+
1690
+ try {
1691
+ const result = await Download(
1692
+ customUrls.exportUrl || exportUrl,
1693
+ 'POST',
1694
+ {
1695
+ definition: semanticDefinition,
1696
+ parameters: semanticParameterValues,
1697
+ format,
1698
+ }
1699
+ );
1700
+
1701
+ if (result?.data) {
1702
+ const timestamp = moment().format('YYYY-MM-DD_HH-mm-ss');
1703
+ const baseName = (
1704
+ reportName ||
1705
+ getEntityLabel(semanticModel, semanticEntity) ||
1706
+ 'report'
1707
+ ).replace(/[^a-zA-Z0-9]/g, '_');
1708
+ saveAs(result.data, `${baseName}_${timestamp}.${format}`);
1709
+ toast.success(`Report exported as ${format.toUpperCase()}`);
1710
+ } else {
1711
+ toast.error(
1712
+ `Failed to export report as ${format.toUpperCase()}`
1713
+ );
1714
+ }
1715
+ } catch (error) {
1716
+ debugLog('Semantic export failed:', error);
1717
+ toast.error(
1718
+ semanticErrorMessage(
1719
+ error,
1720
+ `Failed to export report as ${format.toUpperCase()}`
1721
+ )
1722
+ );
1723
+ }
1724
+ };
1725
+
1178
1726
  // Simple icon helper for SweetAlert2 - returns HTML strings with SVG icons
1179
1727
  const getIcon = (iconName, color = '#6b7280', size = 16) => {
1180
1728
  const icons = {
@@ -1436,7 +1984,14 @@ const GenericReportImproved = ({
1436
1984
 
1437
1985
  // Initialize component
1438
1986
  useEffect(() => {
1439
- fetchDatabaseTables();
1987
+ // Wait for the semantic probe to settle before hitting the legacy
1988
+ // table endpoint: a semantic-only backend has no /getTables to call,
1989
+ // and asking anyway would surface an error the user cannot act on.
1990
+ if (semanticStatus === 'loading') return;
1991
+ if (semanticStatus !== 'ready') {
1992
+ fetchDatabaseTables();
1993
+ }
1994
+
1440
1995
  fetchSavedReports();
1441
1996
 
1442
1997
  // Show help on first load
@@ -1447,7 +2002,7 @@ const GenericReportImproved = ({
1447
2002
  localStorage.setItem('reportBuilder_hasSeenHelp', 'true');
1448
2003
  }, 500);
1449
2004
  }
1450
- }, [tableUrl, reportsUrl]); // Add dependencies to ensure re-fetch when URLs change
2005
+ }, [tableUrl, reportsUrl, semanticStatus]); // Re-fetch when URLs change
1451
2006
 
1452
2007
  // Fetch columns when table is selected (like advanced mode)
1453
2008
  useEffect(() => {
@@ -1522,6 +2077,7 @@ const GenericReportImproved = ({
1522
2077
  // Auto-execute report when user reaches step 5 (preview step)
1523
2078
  useEffect(() => {
1524
2079
  if (
2080
+ !isSemantic && // Semantic mode has its own run trigger below
1525
2081
  currentWizardStep === 5 && // Step 6 is index 5 (preview step)
1526
2082
  selectedTable &&
1527
2083
  selectedColumns.length > 0 &&
@@ -1532,6 +2088,7 @@ const GenericReportImproved = ({
1532
2088
  executeReport();
1533
2089
  }
1534
2090
  }, [
2091
+ isSemantic,
1535
2092
  currentWizardStep,
1536
2093
  selectedTable,
1537
2094
  selectedColumns.length,
@@ -1539,6 +2096,40 @@ const GenericReportImproved = ({
1539
2096
  hasAutoExecuted,
1540
2097
  ]);
1541
2098
 
2099
+ // Semantic mode: run once on arriving at the preview step, but only when
2100
+ // there is nothing to ask the user first — otherwise wait for their answers.
2101
+ useEffect(() => {
2102
+ if (!isSemantic) return;
2103
+ if (currentWizardStep !== 5) return;
2104
+ if (isLoading || semanticHasRun) return;
2105
+ if (semanticSelections.length === 0) return;
2106
+ if (!parametersSatisfied(semanticParameters, semanticParameterValues)) {
2107
+ return;
2108
+ }
2109
+ runSemanticReport({ page: 1 });
2110
+ }, [
2111
+ isSemantic,
2112
+ currentWizardStep,
2113
+ isLoading,
2114
+ semanticHasRun,
2115
+ semanticSelections.length,
2116
+ semanticParameters,
2117
+ semanticParameterValues,
2118
+ ]);
2119
+
2120
+ // Any change to the report design invalidates the results already on screen.
2121
+ useEffect(() => {
2122
+ if (!isSemantic) return;
2123
+ setSemanticHasRun(false);
2124
+ }, [
2125
+ isSemantic,
2126
+ semanticEntity,
2127
+ semanticSelections,
2128
+ semanticFilterTree,
2129
+ semanticGroupBy,
2130
+ semanticSort,
2131
+ ]);
2132
+
1542
2133
  // Escape closes whichever modal is open
1543
2134
  useEffect(() => {
1544
2135
  if (!showCalculatedFieldModal && !showRowExclusionModal) return undefined;
@@ -1724,6 +2315,9 @@ const GenericReportImproved = ({
1724
2315
  // Reset UI state
1725
2316
  setShowHiddenFields(false);
1726
2317
 
2318
+ // Reset semantic mode state (inert in legacy mode)
2319
+ resetSemanticState();
2320
+
1727
2321
  toast.info(
1728
2322
  'Report builder reset. You can start creating a new report.'
1729
2323
  );
@@ -1731,7 +2325,7 @@ const GenericReportImproved = ({
1731
2325
 
1732
2326
  // Wizard navigation
1733
2327
  const goToNextStep = () => {
1734
- if (currentWizardStep < wizardSteps.length - 1) {
2328
+ if (currentWizardStep < activeWizardSteps.length - 1) {
1735
2329
  setCurrentWizardStep(currentWizardStep + 1);
1736
2330
  }
1737
2331
  };
@@ -1744,6 +2338,25 @@ const GenericReportImproved = ({
1744
2338
 
1745
2339
  // Check if current step is complete
1746
2340
  const isStepComplete = (stepId) => {
2341
+ if (isSemantic) {
2342
+ switch (stepId) {
2343
+ case 'table':
2344
+ return !!semanticEntity;
2345
+ case 'relationships':
2346
+ return true; // Optional step
2347
+ case 'columns':
2348
+ return semanticSelections.length > 0;
2349
+ case 'filters':
2350
+ return true; // Optional step
2351
+ case 'grouping':
2352
+ return true; // Optional step
2353
+ case 'preview':
2354
+ return semanticHasRun;
2355
+ default:
2356
+ return false;
2357
+ }
2358
+ }
2359
+
1747
2360
  switch (stepId) {
1748
2361
  case 'table':
1749
2362
  return !!selectedTable;
@@ -1813,15 +2426,15 @@ const GenericReportImproved = ({
1813
2426
  return;
1814
2427
  }
1815
2428
 
1816
- // Filter out hidden tables and tables with underscores (like advanced mode)
2429
+ // Filter out hidden tables only. Snake_case names are ordinary
2430
+ // table names — dropping them silently hid most of the schema.
1817
2431
  let filteredTables = tableData
1818
2432
  .filter((table) => {
1819
2433
  const tableName =
1820
2434
  typeof table === 'string' ? table : table?.name;
1821
2435
  return (
1822
2436
  typeof tableName === 'string' &&
1823
- !hiddenTables.includes(tableName) &&
1824
- !tableName.includes('_')
2437
+ !hiddenTables.includes(tableName)
1825
2438
  );
1826
2439
  })
1827
2440
  .map((table) => {
@@ -2988,8 +3601,8 @@ const GenericReportImproved = ({
2988
3601
  }
2989
3602
  }
2990
3603
 
2991
- if (currentWizardStep < wizardSteps.length - 1) {
2992
- setCurrentWizardStep(wizardSteps.length - 1);
3604
+ if (currentWizardStep < activeWizardSteps.length - 1) {
3605
+ setCurrentWizardStep(activeWizardSteps.length - 1);
2993
3606
  }
2994
3607
 
2995
3608
  // Report executed successfully
@@ -3033,6 +3646,40 @@ const GenericReportImproved = ({
3033
3646
  return;
3034
3647
  }
3035
3648
 
3649
+ // A v2 document restores the semantic wizard. A v1 document (it has
3650
+ // `mainTable`) always falls through to the legacy path below, even
3651
+ // when the semantic model happens to be available.
3652
+ if (isSemanticDefinition(config)) {
3653
+ if (!isSemantic) {
3654
+ toast.error(
3655
+ 'This report needs the newer report engine, which is not available here.'
3656
+ );
3657
+ return;
3658
+ }
3659
+
3660
+ const parsed = parseDefinition(config);
3661
+ resetSemanticState();
3662
+ setSemanticEntity(parsed.entity);
3663
+ setSemanticSelections(parsed.selections);
3664
+ setSemanticFilterTree(parsed.filterTree);
3665
+ setSemanticParameters(parsed.parameters);
3666
+ setSemanticGroupBy(parsed.groupBy);
3667
+ setSemanticSort(parsed.sort);
3668
+ // Re-derive which related areas the saved paths reach through,
3669
+ // so the relations step shows them as already added.
3670
+ setSemanticRelations(
3671
+ relationPathsFromDefinition(parsed, semanticModel)
3672
+ );
3673
+
3674
+ setReportName(report.label || '');
3675
+ setIsPublic(!!report.is_public);
3676
+ setLoadedReportId(report.id);
3677
+ setShowTemplates(false);
3678
+ setCurrentWizardStep(0);
3679
+ toast.success(`Loaded report: ${report.label}`);
3680
+ return;
3681
+ }
3682
+
3036
3683
  if (config.mainTable) {
3037
3684
  setSelectedTable(config.mainTable);
3038
3685
  setAvailableTables([config.mainTable]);
@@ -3440,9 +4087,21 @@ const GenericReportImproved = ({
3440
4087
  <h3>Your Saved Reports ({savedReports.length})</h3>
3441
4088
  <div className={styles.reportsGrid}>
3442
4089
  {savedReports.map((report) => {
3443
- const reportMainTable =
3444
- parseReportDetail(report.detail)
3445
- ?.mainTable || '';
4090
+ const reportDetail = parseReportDetail(
4091
+ report.detail
4092
+ );
4093
+ // v1 reports show the table name; v2 ones
4094
+ // show the entity's business label.
4095
+ const reportSubject = isSemanticDefinition(
4096
+ reportDetail
4097
+ )
4098
+ ? getEntityLabel(
4099
+ semanticModel,
4100
+ reportDetail.entity
4101
+ )
4102
+ : formatName(
4103
+ reportDetail?.mainTable || ''
4104
+ );
3446
4105
  return (
3447
4106
  <div
3448
4107
  key={report.id}
@@ -3497,7 +4156,7 @@ const GenericReportImproved = ({
3497
4156
  </span>
3498
4157
  </div>
3499
4158
  </div>
3500
- {reportMainTable && (
4159
+ {reportSubject && (
3501
4160
  <div
3502
4161
  className={
3503
4162
  styles.reportTableInfo
@@ -3517,9 +4176,7 @@ const GenericReportImproved = ({
3517
4176
  'middle',
3518
4177
  }}
3519
4178
  />
3520
- {formatName(
3521
- reportMainTable
3522
- )}
4179
+ {reportSubject}
3523
4180
  </span>
3524
4181
  </div>
3525
4182
  )}
@@ -3571,14 +4228,14 @@ const GenericReportImproved = ({
3571
4228
 
3572
4229
  // Render wizard mode
3573
4230
  const renderWizardMode = () => {
3574
- const currentStep = wizardSteps[currentWizardStep];
4231
+ const currentStep = activeWizardSteps[currentWizardStep];
3575
4232
  const StepIcon = currentStep.icon;
3576
4233
 
3577
4234
  return (
3578
4235
  <div className={styles.wizardMode}>
3579
4236
  {/* Progress bar */}
3580
4237
  <div className={styles.wizardProgress}>
3581
- {wizardSteps.map((step, index) => {
4238
+ {activeWizardSteps.map((step, index) => {
3582
4239
  const StepIcon = step.icon;
3583
4240
  const isActive = index === currentWizardStep;
3584
4241
  const isComplete =
@@ -3696,7 +4353,7 @@ const GenericReportImproved = ({
3696
4353
  </button>
3697
4354
  )}
3698
4355
 
3699
- {currentWizardStep === wizardSteps.length - 1 ? (
4356
+ {currentWizardStep === activeWizardSteps.length - 1 ? (
3700
4357
  <button
3701
4358
  type="button"
3702
4359
  className={`${styles.btn} ${styles.btnStartAgain}`}
@@ -3720,8 +4377,107 @@ const GenericReportImproved = ({
3720
4377
  );
3721
4378
  };
3722
4379
 
4380
+ // Render the semantic-mode body for a step. Returns null in legacy mode so
4381
+ // the caller falls through to the original renderers untouched.
4382
+ const renderSemanticStepContent = (stepId) => {
4383
+ switch (stepId) {
4384
+ case 'table':
4385
+ return (
4386
+ <SemanticEntityStep
4387
+ model={semanticModel}
4388
+ selectedEntity={semanticEntity}
4389
+ onSelect={handleSemanticSelectEntity}
4390
+ />
4391
+ );
4392
+ case 'relationships':
4393
+ return (
4394
+ <SemanticRelationsStep
4395
+ model={semanticModel}
4396
+ entityId={semanticEntity}
4397
+ addedRelations={semanticRelations}
4398
+ onToggle={handleSemanticToggleRelation}
4399
+ />
4400
+ );
4401
+ case 'columns':
4402
+ return (
4403
+ <SemanticFieldsStep
4404
+ model={semanticModel}
4405
+ entityId={semanticEntity}
4406
+ addedRelations={semanticRelations}
4407
+ selections={semanticSelections}
4408
+ onToggleField={handleSemanticToggleField}
4409
+ onChangeAggregate={handleSemanticChangeAggregate}
4410
+ onRemoveSelection={handleSemanticRemoveSelection}
4411
+ onMoveSelection={handleSemanticMoveSelection}
4412
+ />
4413
+ );
4414
+ case 'filters':
4415
+ return (
4416
+ <SemanticFiltersStep
4417
+ model={semanticModel}
4418
+ entityId={semanticEntity}
4419
+ addedRelations={semanticRelations}
4420
+ filterTree={semanticFilterTree}
4421
+ parameters={semanticParameters}
4422
+ onChangeNode={handleSemanticChangeFilterNode}
4423
+ onRemoveNode={handleSemanticRemoveFilterNode}
4424
+ onAddCondition={handleSemanticAddFilterCondition}
4425
+ onAddGroup={handleSemanticAddFilterGroup}
4426
+ onChangeParameter={handleSemanticChangeParameter}
4427
+ />
4428
+ );
4429
+ case 'grouping':
4430
+ return (
4431
+ <SemanticGroupingStep
4432
+ model={semanticModel}
4433
+ entityId={semanticEntity}
4434
+ selections={semanticSelections}
4435
+ groupBy={semanticGroupBy}
4436
+ sort={semanticSort}
4437
+ onToggleGroupBy={handleSemanticToggleGroupBy}
4438
+ onAddSort={handleSemanticAddSort}
4439
+ onChangeSort={handleSemanticChangeSort}
4440
+ onRemoveSort={handleSemanticRemoveSort}
4441
+ />
4442
+ );
4443
+ case 'preview':
4444
+ return (
4445
+ <SemanticPreviewStep
4446
+ model={semanticModel}
4447
+ entityId={semanticEntity}
4448
+ selections={semanticSelections}
4449
+ filterTree={semanticFilterTree}
4450
+ parameters={semanticParameters}
4451
+ parameterValues={semanticParameterValues}
4452
+ groupBy={semanticGroupBy}
4453
+ rows={semanticRows}
4454
+ total={semanticTotal}
4455
+ page={semanticPage}
4456
+ pageSize={semanticPageSize}
4457
+ isLoading={isLoading}
4458
+ error={semanticError}
4459
+ onChangeParameterValue={handleSemanticChangeParameterValue}
4460
+ onRun={runSemanticReport}
4461
+ onChangePage={(page) => runSemanticReport({ page })}
4462
+ onChangePageSize={(size) =>
4463
+ runSemanticReport({ page: 1, size })
4464
+ }
4465
+ onExport={exportSemanticReport}
4466
+ onSave={() => saveReport(false)}
4467
+ canSave={semanticSelections.length > 0}
4468
+ />
4469
+ );
4470
+ default:
4471
+ return null;
4472
+ }
4473
+ };
4474
+
3723
4475
  // Render wizard step content
3724
4476
  const renderWizardStepContent = (stepId) => {
4477
+ if (isSemantic) {
4478
+ return renderSemanticStepContent(stepId);
4479
+ }
4480
+
3725
4481
  switch (stepId) {
3726
4482
  case 'table':
3727
4483
  return renderTableSelection();
@@ -7341,10 +8097,15 @@ const GenericReportImproved = ({
7341
8097
 
7342
8098
  // Save report configuration (with save/save-as functionality)
7343
8099
  const saveReport = async (saveAs = false) => {
8100
+ // In semantic mode the subject is an entity label, not a table name.
8101
+ const subjectName = isSemantic
8102
+ ? getEntityLabel(semanticModel, semanticEntity)
8103
+ : formatName(selectedTable);
8104
+
7344
8105
  if (!reportName.trim()) {
7345
- const defaultName = `${formatName(
7346
- selectedTable
7347
- )} Report - ${moment().format('YYYY-MM-DD')}`;
8106
+ const defaultName = `${subjectName} Report - ${moment().format(
8107
+ 'YYYY-MM-DD'
8108
+ )}`;
7348
8109
  setReportName(defaultName);
7349
8110
  }
7350
8111
 
@@ -7377,9 +8138,7 @@ const GenericReportImproved = ({
7377
8138
  saveAs
7378
8139
  ? `${reportName} (Copy)`
7379
8140
  : reportName ||
7380
- `${formatName(
7381
- selectedTable
7382
- )} Report - ${moment().format(
8141
+ `${subjectName} Report - ${moment().format(
7383
8142
  'YYYY-MM-DD'
7384
8143
  )}`
7385
8144
  )}"
@@ -7416,24 +8175,32 @@ const GenericReportImproved = ({
7416
8175
 
7417
8176
  if (result.isConfirmed) {
7418
8177
  try {
7419
- const reportConfig = {
7420
- label: result.value.name,
7421
- is_public: result.value.isPublic,
7422
- detail: {
7423
- mainTable: selectedTable,
7424
- columns: selectedColumns,
7425
- joins: joins,
7426
- filters: filterCriteria,
7427
- sorting: sortCriteria,
7428
- grouping: groupingConfig.enabled
7429
- ? groupingConfig
7430
- : null,
7431
- isGrouped: groupingConfig.enabled,
7432
- customCalculatedFields: customCalculatedFields,
7433
- customUrls: customUrls,
7434
- unique: uniqueFieldConfig,
7435
- },
7436
- };
8178
+ // Semantic reports save the v2 definition document verbatim;
8179
+ // legacy reports keep their existing v1 shape.
8180
+ const reportConfig = isSemantic
8181
+ ? {
8182
+ label: result.value.name,
8183
+ is_public: result.value.isPublic,
8184
+ detail: semanticDefinition,
8185
+ }
8186
+ : {
8187
+ label: result.value.name,
8188
+ is_public: result.value.isPublic,
8189
+ detail: {
8190
+ mainTable: selectedTable,
8191
+ columns: selectedColumns,
8192
+ joins: joins,
8193
+ filters: filterCriteria,
8194
+ sorting: sortCriteria,
8195
+ grouping: groupingConfig.enabled
8196
+ ? groupingConfig
8197
+ : null,
8198
+ isGrouped: groupingConfig.enabled,
8199
+ customCalculatedFields: customCalculatedFields,
8200
+ customUrls: customUrls,
8201
+ unique: uniqueFieldConfig,
8202
+ },
8203
+ };
7437
8204
 
7438
8205
  let saveResult;
7439
8206
  if (isExistingReport) {