@bygd/nc-report-ui 0.1.40 → 0.1.42

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.
@@ -44,7 +44,7 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
44
44
  import SaveIcon from '@mui/icons-material/Save';
45
45
  import DownloadIcon from '@mui/icons-material/Download';
46
46
  import { useSensors, useSensor, PointerSensor, KeyboardSensor, DndContext, closestCenter } from '@dnd-kit/core';
47
- import { sortableKeyboardCoordinates, SortableContext, verticalListSortingStrategy, arrayMove, useSortable } from '@dnd-kit/sortable';
47
+ import { sortableKeyboardCoordinates, SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from '@dnd-kit/sortable';
48
48
  import { CSS } from '@dnd-kit/utilities';
49
49
  import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
50
50
  import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
@@ -64,6 +64,7 @@ import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
64
64
  import { DatePicker } from '@mui/x-date-pickers/DatePicker';
65
65
  import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
66
66
  import dayjs from 'dayjs';
67
+ import CalculateIcon from '@mui/icons-material/Calculate';
67
68
  import { ThemeProvider, createTheme } from '@mui/material/styles';
68
69
 
69
70
  function _extends() {
@@ -1574,6 +1575,39 @@ const ReportingProvider = ({
1574
1575
  }, children);
1575
1576
  };
1576
1577
 
1578
+ /**
1579
+ * useReportingContext - Hook to access reporting context
1580
+ *
1581
+ * @returns {Object} Context value with parameters, api, and setter functions
1582
+ * @returns {Object} return.parameters - Current parameters object
1583
+ * @returns {Function} return.setParameters - Function to update parameters
1584
+ * @returns {Object} return.api - Current API configuration
1585
+ * @returns {Function} return.setApi - Function to update API configuration
1586
+ * @returns {Function} return.setReportingContext - Function to update both parameters and api
1587
+ *
1588
+ * @example
1589
+ * const { parameters, setParameters, api, setApi, setReportingContext } = useReportingContext();
1590
+ *
1591
+ * // Update parameters only
1592
+ * setParameters({ base_currency: 'USD', merchants: [...] });
1593
+ *
1594
+ * // Update API only
1595
+ * setApi({ token: 'new-token', base_url: 'https://api.example.com' });
1596
+ *
1597
+ * // Update both at once
1598
+ * setReportingContext({
1599
+ * parameters: { base_currency: 'EUR' },
1600
+ * api: { token: 'token', base_url: 'https://api.example.com' }
1601
+ * });
1602
+ */
1603
+ const useReportingContext = () => {
1604
+ const context = useContext(ReportingContext);
1605
+ if (context === undefined) {
1606
+ throw new Error('useReportingContext must be used within a ReportingProvider');
1607
+ }
1608
+ return context;
1609
+ };
1610
+
1577
1611
  /**
1578
1612
  * useReportingContextOptional - Hook to optionally access reporting context
1579
1613
  * Returns null if used outside of ReportingProvider (useful for optional fallback)
@@ -2495,6 +2529,140 @@ const ProviderSelection = ({
2495
2529
  }, renderSelectionChain()));
2496
2530
  };
2497
2531
 
2532
+ // Shared drag-and-drop primitives for the index-identified, arrow-reorderable
2533
+ // lists used across the report builder (Dimensions, Metrics, Column Order).
2534
+ // Previously duplicated per-tab; consolidated here so all three behave and
2535
+ // look identical and only need to be fixed in one place.
2536
+
2537
+ const useSortableListSensors = () => useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, {
2538
+ coordinateGetter: sortableKeyboardCoordinates
2539
+ }));
2540
+
2541
+ // Builds drag/move handlers for a list where dnd-kit item ids are the array
2542
+ // index. `onReorder` receives the whole reordered list.
2543
+ const makeListReorderHandlers = (list, onReorder) => {
2544
+ const handleDragEnd = ({
2545
+ active,
2546
+ over
2547
+ }) => {
2548
+ if (over && active.id !== over.id) {
2549
+ const oldIndex = list.findIndex((_, i) => i === active.id);
2550
+ const newIndex = list.findIndex((_, i) => i === over.id);
2551
+ onReorder(arrayMove(list, oldIndex, newIndex));
2552
+ }
2553
+ };
2554
+ const handleMoveUp = index => {
2555
+ if (index > 0) {
2556
+ onReorder(arrayMove(list, index, index - 1));
2557
+ }
2558
+ };
2559
+ const handleMoveDown = index => {
2560
+ if (index < list.length - 1) {
2561
+ onReorder(arrayMove(list, index, index + 1));
2562
+ }
2563
+ };
2564
+ return {
2565
+ handleDragEnd,
2566
+ handleMoveUp,
2567
+ handleMoveDown
2568
+ };
2569
+ };
2570
+
2571
+ // Wraps DndContext + SortableContext + the vertical list container.
2572
+ // `itemIds` must line up with the ids passed to each item's useSortable().
2573
+ const SortableListContext = ({
2574
+ sensors,
2575
+ itemIds,
2576
+ onDragEnd,
2577
+ children
2578
+ }) => /*#__PURE__*/React__default.createElement(DndContext, {
2579
+ sensors: sensors,
2580
+ collisionDetection: closestCenter,
2581
+ onDragEnd: onDragEnd
2582
+ }, /*#__PURE__*/React__default.createElement(SortableContext, {
2583
+ items: itemIds,
2584
+ strategy: verticalListSortingStrategy
2585
+ }, /*#__PURE__*/React__default.createElement(Box$1, {
2586
+ sx: {
2587
+ display: "flex",
2588
+ flexDirection: "column",
2589
+ gap: 1
2590
+ }
2591
+ }, children)));
2592
+
2593
+ // Per-row useSortable() wiring shared by every sortable chip/row component.
2594
+ const useSortableRow = id => {
2595
+ const {
2596
+ attributes,
2597
+ listeners,
2598
+ setNodeRef,
2599
+ transform,
2600
+ transition,
2601
+ isDragging
2602
+ } = useSortable({
2603
+ id
2604
+ });
2605
+ const style = {
2606
+ transform: CSS.Transform.toString(transform),
2607
+ transition,
2608
+ opacity: isDragging ? 0.5 : 1,
2609
+ display: "flex",
2610
+ alignItems: "center",
2611
+ width: "100%"
2612
+ };
2613
+ return {
2614
+ attributes,
2615
+ listeners,
2616
+ setNodeRef,
2617
+ style
2618
+ };
2619
+ };
2620
+ const DragHandle = ({
2621
+ listeners
2622
+ }) => /*#__PURE__*/React__default.createElement(Box$1, _extends({}, listeners, {
2623
+ sx: {
2624
+ display: "flex",
2625
+ alignItems: "center",
2626
+ cursor: "grab",
2627
+ "&:active": {
2628
+ cursor: "grabbing"
2629
+ }
2630
+ }
2631
+ }), /*#__PURE__*/React__default.createElement(DragIndicatorIcon, {
2632
+ sx: {
2633
+ cursor: "grab",
2634
+ color: "rgba(110, 110, 110, 0.62)"
2635
+ }
2636
+ }));
2637
+ const MoveButtons = ({
2638
+ onMoveUp,
2639
+ onMoveDown,
2640
+ isFirst,
2641
+ isLast
2642
+ }) => /*#__PURE__*/React__default.createElement(Box$1, {
2643
+ className: "hover-icons",
2644
+ sx: {
2645
+ display: "flex",
2646
+ gap: 0.5,
2647
+ opacity: 0,
2648
+ transition: "opacity 0.2s"
2649
+ }
2650
+ }, /*#__PURE__*/React__default.createElement(IconButton$1, {
2651
+ size: "small",
2652
+ onClick: onMoveUp,
2653
+ disabled: isFirst,
2654
+ "aria-label": "move up"
2655
+ }, /*#__PURE__*/React__default.createElement(ArrowUpwardIcon, {
2656
+ fontSize: "small"
2657
+ })), /*#__PURE__*/React__default.createElement(IconButton$1, {
2658
+ size: "small",
2659
+ onClick: onMoveDown,
2660
+ disabled: isLast,
2661
+ "aria-label": "move down"
2662
+ }, /*#__PURE__*/React__default.createElement(ArrowDownwardIcon, {
2663
+ fontSize: "small"
2664
+ })));
2665
+
2498
2666
  // Replace {{key}} placeholders in a title with values from the report
2499
2667
  // parameters (e.g. "Balance ({{base_currency}})" -> "Balance (EUR)").
2500
2668
  // Unknown keys are left as-is so missing parameters remain visible.
@@ -2506,6 +2674,50 @@ const interpolateTitle = (template, params) => {
2506
2674
  });
2507
2675
  };
2508
2676
 
2677
+ // Resolves the final column display order for the results grid: dimensions,
2678
+ // computed dimensions, and metrics combined into a single list.
2679
+ //
2680
+ // `columnOrder` is the optional, explicitly-arranged order (raw entries
2681
+ // `{ type: 'dimension' | 'computed' | 'metric', key }`) set on the Column
2682
+ // Order tab. When present, its entries come first, in that order. Anything
2683
+ // not covered by it (new fields added since it was last touched, or when
2684
+ // it's empty/undefined because no custom order has ever been defined) is
2685
+ // appended in the pre-existing default order: dimensions/computed (in their
2686
+ // own `dimensionOrder`) first, then metrics (in their own array order) —
2687
+ // this keeps behavior unchanged for reports that never define a column order.
2688
+ const resolveColumnOrder = (columnOrder, orderedDimensionItems, metrics) => {
2689
+ // Normalise every candidate item to a uniform { kind, key, data } shape
2690
+ // up front, so callers (e.g. the Column Order tab) can always read
2691
+ // `.key` regardless of where the item came from.
2692
+ const normalizedDimensionItems = orderedDimensionItems.map(item => ({
2693
+ kind: item.kind,
2694
+ key: item.kind === 'computed' ? item.data.name : item.data.fullPath,
2695
+ data: item.data
2696
+ }));
2697
+ const normalizedMetricItems = metrics.map(m => ({
2698
+ kind: 'metric',
2699
+ key: m.fullPath,
2700
+ data: m
2701
+ }));
2702
+ const byKey = Object.fromEntries([...normalizedDimensionItems, ...normalizedMetricItems].map(item => [`${item.kind}:${item.key}`, item]));
2703
+ const resolved = [];
2704
+ const seen = new Set();
2705
+ (columnOrder || []).forEach(entry => {
2706
+ const seenKey = `${entry.type}:${entry.key}`;
2707
+ if (seen.has(seenKey) || !byKey[seenKey]) return;
2708
+ resolved.push(byKey[seenKey]);
2709
+ seen.add(seenKey);
2710
+ });
2711
+ [...normalizedDimensionItems, ...normalizedMetricItems].forEach(item => {
2712
+ const seenKey = `${item.kind}:${item.key}`;
2713
+ if (!seen.has(seenKey)) {
2714
+ resolved.push(item);
2715
+ seen.add(seenKey);
2716
+ }
2717
+ });
2718
+ return resolved;
2719
+ };
2720
+
2509
2721
  // Sortable Chip Component
2510
2722
  const SortableChip$1 = ({
2511
2723
  id,
@@ -2534,20 +2746,8 @@ const SortableChip$1 = ({
2534
2746
  attributes,
2535
2747
  listeners,
2536
2748
  setNodeRef,
2537
- transform,
2538
- transition,
2539
- isDragging
2540
- } = useSortable({
2541
- id
2542
- });
2543
- const style = {
2544
- transform: CSS.Transform.toString(transform),
2545
- transition,
2546
- opacity: isDragging ? 0.5 : 1,
2547
- display: "flex",
2548
- alignItems: "center",
2549
- width: "100%"
2550
- };
2749
+ style
2750
+ } = useSortableRow(id);
2551
2751
 
2552
2752
  // Focus input when entering edit mode
2553
2753
  useEffect(() => {
@@ -2656,21 +2856,9 @@ const SortableChip$1 = ({
2656
2856
  opacity: 1 // show icons on hover
2657
2857
  }
2658
2858
  }
2659
- }, /*#__PURE__*/React__default.createElement(Box$1, _extends({}, listeners, {
2660
- sx: {
2661
- display: "flex",
2662
- alignItems: "center",
2663
- cursor: "grab",
2664
- "&:active": {
2665
- cursor: "grabbing"
2666
- }
2667
- }
2668
- }), /*#__PURE__*/React__default.createElement(DragIndicatorIcon, {
2669
- sx: {
2670
- cursor: "grab",
2671
- color: "rgba(110, 110, 110, 0.62)"
2672
- }
2673
- })), !isEditing ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(Box$1, {
2859
+ }, /*#__PURE__*/React__default.createElement(DragHandle, {
2860
+ listeners: listeners
2861
+ }), !isEditing ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(Box$1, {
2674
2862
  sx: {
2675
2863
  minWidth: 0
2676
2864
  }
@@ -2733,29 +2921,12 @@ const SortableChip$1 = ({
2733
2921
  sx: {
2734
2922
  flex: 1
2735
2923
  }
2736
- }), /*#__PURE__*/React__default.createElement(Box$1, {
2737
- className: "hover-icons",
2738
- sx: {
2739
- display: "flex",
2740
- gap: 0.5,
2741
- opacity: 0,
2742
- transition: "opacity 0.2s"
2743
- }
2744
- }, /*#__PURE__*/React__default.createElement(IconButton$1, {
2745
- size: "small",
2746
- onClick: onMoveUp,
2747
- disabled: isFirst,
2748
- "aria-label": "move up"
2749
- }, /*#__PURE__*/React__default.createElement(ArrowUpwardIcon, {
2750
- fontSize: "small"
2751
- })), /*#__PURE__*/React__default.createElement(IconButton$1, {
2752
- size: "small",
2753
- onClick: onMoveDown,
2754
- disabled: isLast,
2755
- "aria-label": "move down"
2756
- }, /*#__PURE__*/React__default.createElement(ArrowDownwardIcon, {
2757
- fontSize: "small"
2758
- })))) : /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(TextField, {
2924
+ }), /*#__PURE__*/React__default.createElement(MoveButtons, {
2925
+ onMoveUp: onMoveUp,
2926
+ onMoveDown: onMoveDown,
2927
+ isFirst: isFirst,
2928
+ isLast: isLast
2929
+ })) : /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(TextField, {
2759
2930
  inputRef: inputRef,
2760
2931
  value: editValue,
2761
2932
  onChange: e => setEditValue(e.target.value),
@@ -2817,7 +2988,6 @@ const SortableChip$1 = ({
2817
2988
  // the top-of-page Add form, so it stays visible regardless of scroll position.
2818
2989
  const SortableComputedChip = ({
2819
2990
  id,
2820
- index,
2821
2991
  name,
2822
2992
  value,
2823
2993
  onDelete,
@@ -2832,24 +3002,12 @@ const SortableComputedChip = ({
2832
3002
  attributes,
2833
3003
  listeners,
2834
3004
  setNodeRef,
2835
- transform,
2836
- transition,
2837
- isDragging
2838
- } = useSortable({
2839
- id
2840
- });
3005
+ style
3006
+ } = useSortableRow(id);
2841
3007
  const [isEditing, setIsEditing] = useState(false);
2842
3008
  const [editName, setEditName] = useState("");
2843
3009
  const [editValue, setEditValue] = useState("");
2844
3010
  const containerRef = useRef(null);
2845
- const style = {
2846
- transform: CSS.Transform.toString(transform),
2847
- transition,
2848
- opacity: isDragging ? 0.5 : 1,
2849
- display: "flex",
2850
- alignItems: "center",
2851
- width: "100%"
2852
- };
2853
3011
 
2854
3012
  // Handle click outside to cancel
2855
3013
  useEffect(() => {
@@ -2870,13 +3028,13 @@ const SortableComputedChip = ({
2870
3028
  setEditValue(value);
2871
3029
  setIsEditing(true);
2872
3030
  };
2873
- const nameError = editName.trim() !== "" && isNameTaken(editName.trim(), index);
3031
+ const nameError = editName.trim() !== "" && isNameTaken(editName.trim(), name);
2874
3032
  const handleSave = () => {
2875
3033
  const trimmedName = editName.trim();
2876
3034
  const trimmedValue = editValue.trim();
2877
3035
  if (!trimmedName || !trimmedValue) return;
2878
- if (isNameTaken(trimmedName, index)) return;
2879
- onUpdate(index, {
3036
+ if (isNameTaken(trimmedName, name)) return;
3037
+ onUpdate(name, {
2880
3038
  name: trimmedName,
2881
3039
  value: trimmedValue
2882
3040
  });
@@ -2912,21 +3070,9 @@ const SortableComputedChip = ({
2912
3070
  opacity: 1
2913
3071
  }
2914
3072
  }
2915
- }, /*#__PURE__*/React__default.createElement(Box$1, _extends({}, listeners, {
2916
- sx: {
2917
- display: "flex",
2918
- alignItems: "center",
2919
- cursor: "grab",
2920
- "&:active": {
2921
- cursor: "grabbing"
2922
- }
2923
- }
2924
- }), /*#__PURE__*/React__default.createElement(DragIndicatorIcon, {
2925
- sx: {
2926
- cursor: "grab",
2927
- color: "rgba(110, 110, 110, 0.62)"
2928
- }
2929
- })), !isEditing ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(Chip, {
3073
+ }, /*#__PURE__*/React__default.createElement(DragHandle, {
3074
+ listeners: listeners
3075
+ }), !isEditing ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(Chip, {
2930
3076
  icon: /*#__PURE__*/React__default.createElement(FunctionsIcon, {
2931
3077
  sx: {
2932
3078
  fontSize: "15px !important"
@@ -2990,29 +3136,12 @@ const SortableComputedChip = ({
2990
3136
  sx: {
2991
3137
  flex: 1
2992
3138
  }
2993
- }), /*#__PURE__*/React__default.createElement(Box$1, {
2994
- className: "hover-icons",
2995
- sx: {
2996
- display: "flex",
2997
- gap: 0.5,
2998
- opacity: 0,
2999
- transition: "opacity 0.2s"
3000
- }
3001
- }, /*#__PURE__*/React__default.createElement(IconButton$1, {
3002
- size: "small",
3003
- onClick: onMoveUp,
3004
- disabled: isFirst,
3005
- "aria-label": "move up"
3006
- }, /*#__PURE__*/React__default.createElement(ArrowUpwardIcon, {
3007
- fontSize: "small"
3008
- })), /*#__PURE__*/React__default.createElement(IconButton$1, {
3009
- size: "small",
3010
- onClick: onMoveDown,
3011
- disabled: isLast,
3012
- "aria-label": "move down"
3013
- }, /*#__PURE__*/React__default.createElement(ArrowDownwardIcon, {
3014
- fontSize: "small"
3015
- })))) : /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(TextField, {
3139
+ }), /*#__PURE__*/React__default.createElement(MoveButtons, {
3140
+ onMoveUp: onMoveUp,
3141
+ onMoveDown: onMoveDown,
3142
+ isFirst: isFirst,
3143
+ isLast: isLast
3144
+ })) : /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(TextField, {
3016
3145
  value: editName,
3017
3146
  onChange: e => setEditName(e.target.value),
3018
3147
  onKeyDown: handleKeyDown,
@@ -3074,7 +3203,7 @@ const Dimensions = ({
3074
3203
  savedDimensions = [],
3075
3204
  onSaveDimension,
3076
3205
  onRemoveDimension,
3077
- onReorderDimensions,
3206
+ onUpdateDimensionSortOrder,
3078
3207
  titleOverrides = {},
3079
3208
  onUpdateTitle,
3080
3209
  onResetTitle,
@@ -3084,7 +3213,8 @@ const Dimensions = ({
3084
3213
  onSaveComputedDimension,
3085
3214
  onUpdateComputedDimension,
3086
3215
  onRemoveComputedDimension,
3087
- onReorderComputedDimensions
3216
+ dimensionOrder = [],
3217
+ onReorderDimensionItems
3088
3218
  }) => {
3089
3219
  const reportingContext = useReportingContextOptional();
3090
3220
  const parameters = reportingContext?.parameters;
@@ -3096,53 +3226,38 @@ const Dimensions = ({
3096
3226
  const [computedValue, setComputedValue] = useState("");
3097
3227
 
3098
3228
  // Setup drag and drop sensors
3099
- const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, {
3100
- coordinateGetter: sortableKeyboardCoordinates
3101
- }));
3102
-
3103
- // Handle drag end
3104
- const handleDragEnd = event => {
3105
- const {
3106
- active,
3107
- over
3108
- } = event;
3109
- if (over && active.id !== over.id) {
3110
- const oldIndex = savedDimensions.findIndex((_, i) => i === active.id);
3111
- const newIndex = savedDimensions.findIndex((_, i) => i === over.id);
3112
- const newOrder = arrayMove(savedDimensions, oldIndex, newIndex);
3113
- onReorderDimensions(newOrder);
3114
- }
3115
- };
3116
-
3117
- // Handle move up
3118
- const handleMoveUp = index => {
3119
- if (index > 0) {
3120
- const newOrder = arrayMove(savedDimensions, index, index - 1);
3121
- onReorderDimensions(newOrder);
3122
- }
3123
- };
3124
-
3125
- // Handle move down
3126
- const handleMoveDown = index => {
3127
- if (index < savedDimensions.length - 1) {
3128
- const newOrder = arrayMove(savedDimensions, index, index + 1);
3129
- onReorderDimensions(newOrder);
3130
- }
3131
- };
3229
+ const sensors = useSortableListSensors();
3230
+
3231
+ // Resolve the unified dimensionOrder into the actual dimension / computed
3232
+ // dimension objects, so dimensions and computed dimensions can be shown
3233
+ // (and dragged) as a single list while staying stored in their own arrays.
3234
+ const dimensionByFullPath = Object.fromEntries(savedDimensions.map(d => [d.fullPath, d]));
3235
+ const computedDimensionByName = Object.fromEntries(savedComputedDimensions.map(cd => [cd.name, cd]));
3236
+ const orderedItems = dimensionOrder.map(entry => entry.type === "dimension" ? {
3237
+ kind: "dimension",
3238
+ key: entry.key,
3239
+ data: dimensionByFullPath[entry.key]
3240
+ } : {
3241
+ kind: "computed",
3242
+ key: entry.key,
3243
+ data: computedDimensionByName[entry.key]
3244
+ }).filter(item => item.data);
3245
+
3246
+ // Handle drag/move for the unified list
3247
+ const {
3248
+ handleDragEnd,
3249
+ handleMoveUp,
3250
+ handleMoveDown
3251
+ } = makeListReorderHandlers(dimensionOrder, onReorderDimensionItems);
3132
3252
 
3133
- // Handle sort order change
3134
- const handleSortOrderChange = (index, sortOrder) => {
3135
- const newDimensions = [...savedDimensions];
3136
- newDimensions[index] = {
3137
- ...newDimensions[index],
3138
- sortOrder
3139
- };
3140
- onReorderDimensions(newDimensions);
3253
+ // Handle sort order change (dimensions only - computed dimensions have no order_by)
3254
+ const handleSortOrderChange = (fullPath, sortOrder) => {
3255
+ onUpdateDimensionSortOrder(fullPath, sortOrder);
3141
3256
  };
3142
3257
 
3143
3258
  // Computed Dimensions: { name, value } pairs independent of any provider.
3144
3259
  // Their `value` is a raw SQL literal/expression sent to the API as-is.
3145
- const isComputedNameTaken = (name, excludeIndex = null) => savedComputedDimensions.some((cd, i) => cd.name === name && i !== excludeIndex);
3260
+ const isComputedNameTaken = (name, excludeName = null) => savedComputedDimensions.some(cd => cd.name === name) && name !== excludeName;
3146
3261
  const computedNameError = computedName.trim() !== "" && isComputedNameTaken(computedName.trim());
3147
3262
  const handleAddComputedClick = () => {
3148
3263
  setIsAddingComputed(true);
@@ -3166,27 +3281,6 @@ const Dimensions = ({
3166
3281
  });
3167
3282
  handleCancelComputed();
3168
3283
  };
3169
- const handleComputedDragEnd = event => {
3170
- const {
3171
- active,
3172
- over
3173
- } = event;
3174
- if (over && active.id !== over.id) {
3175
- const oldIndex = savedComputedDimensions.findIndex((_, i) => i === active.id);
3176
- const newIndex = savedComputedDimensions.findIndex((_, i) => i === over.id);
3177
- onReorderComputedDimensions(arrayMove(savedComputedDimensions, oldIndex, newIndex));
3178
- }
3179
- };
3180
- const handleMoveComputedUp = index => {
3181
- if (index > 0) {
3182
- onReorderComputedDimensions(arrayMove(savedComputedDimensions, index, index - 1));
3183
- }
3184
- };
3185
- const handleMoveComputedDown = index => {
3186
- if (index < savedComputedDimensions.length - 1) {
3187
- onReorderComputedDimensions(arrayMove(savedComputedDimensions, index, index + 1));
3188
- }
3189
- };
3190
3284
 
3191
3285
  // Get the current provider based on selection chain
3192
3286
  const getCurrentProvider = () => {
@@ -3629,7 +3723,7 @@ const Dimensions = ({
3629
3723
  boxShadow: "none"
3630
3724
  }
3631
3725
  }
3632
- }, "Save Dimension"))), savedDimensions.length > 0 && /*#__PURE__*/React__default.createElement(Box$1, {
3726
+ }, "Save Dimension"))), orderedItems.length > 0 && /*#__PURE__*/React__default.createElement(Box$1, {
3633
3727
  sx: {
3634
3728
  marginTop: 0
3635
3729
  }
@@ -3647,81 +3741,40 @@ const Dimensions = ({
3647
3741
  color: "#666",
3648
3742
  marginBottom: 2
3649
3743
  }
3650
- }, "Drag to reorder or use arrows"), /*#__PURE__*/React__default.createElement(DndContext, {
3744
+ }, "Drag to reorder or use arrows"), /*#__PURE__*/React__default.createElement(SortableListContext, {
3651
3745
  sensors: sensors,
3652
- collisionDetection: closestCenter,
3746
+ itemIds: orderedItems.map((_, index) => index),
3653
3747
  onDragEnd: handleDragEnd
3654
- }, /*#__PURE__*/React__default.createElement(SortableContext, {
3655
- items: savedDimensions.map((_, index) => index),
3656
- strategy: verticalListSortingStrategy
3657
- }, /*#__PURE__*/React__default.createElement(Box$1, {
3658
- sx: {
3659
- display: "flex",
3660
- flexDirection: "column",
3661
- gap: 1
3662
- }
3663
- }, savedDimensions.map((dim, index) => /*#__PURE__*/React__default.createElement(SortableChip$1, {
3664
- key: index,
3748
+ }, orderedItems.map((item, index) => item.kind === "dimension" ? /*#__PURE__*/React__default.createElement(SortableChip$1, {
3749
+ key: `dim-${item.key}`,
3665
3750
  id: index,
3666
- label: dim.dimensionTitle,
3667
- fullLabel: `${formatProviderPath(dim)} → ${dim.dimensionTitle} (${dim.fullPath})`,
3668
- onDelete: () => onRemoveDimension(index),
3751
+ label: item.data.dimensionTitle,
3752
+ fullLabel: `${formatProviderPath(item.data)} → ${item.data.dimensionTitle} (${item.data.fullPath})`,
3753
+ onDelete: () => onRemoveDimension(item.key),
3669
3754
  onMoveUp: () => handleMoveUp(index),
3670
3755
  onMoveDown: () => handleMoveDown(index),
3671
3756
  isFirst: index === 0,
3672
- isLast: index === savedDimensions.length - 1,
3673
- sortOrder: dim.sortOrder || null,
3674
- onSortOrderChange: sortOrder => handleSortOrderChange(index, sortOrder),
3675
- fullPath: dim.fullPath,
3676
- defaultTitle: dim.dimensionTitle,
3677
- customTitle: titleOverrides[dim.fullPath],
3757
+ isLast: index === orderedItems.length - 1,
3758
+ sortOrder: item.data.sortOrder || null,
3759
+ onSortOrderChange: sortOrder => handleSortOrderChange(item.key, sortOrder),
3760
+ fullPath: item.data.fullPath,
3761
+ defaultTitle: item.data.dimensionTitle,
3762
+ customTitle: titleOverrides[item.data.fullPath],
3678
3763
  onUpdateTitle: onUpdateTitle,
3679
3764
  onResetTitle: onResetTitle
3680
- })))))), savedComputedDimensions.length > 0 && /*#__PURE__*/React__default.createElement(Box$1, {
3681
- sx: {
3682
- marginTop: 2
3683
- }
3684
- }, /*#__PURE__*/React__default.createElement(Typography$1, {
3685
- variant: "h6",
3686
- sx: {
3687
- fontSize: "16px",
3688
- fontWeight: 600,
3689
- marginTop: 2,
3690
- color: "rgb(37, 37, 37)"
3691
- }
3692
- }, "Saved Computed Dimensions"), /*#__PURE__*/React__default.createElement(Typography$1, {
3693
- sx: {
3694
- fontSize: "13px",
3695
- color: "#666",
3696
- marginBottom: 2
3697
- }
3698
- }, "Drag to reorder or use arrows"), /*#__PURE__*/React__default.createElement(DndContext, {
3699
- sensors: sensors,
3700
- collisionDetection: closestCenter,
3701
- onDragEnd: handleComputedDragEnd
3702
- }, /*#__PURE__*/React__default.createElement(SortableContext, {
3703
- items: savedComputedDimensions.map((_, index) => index),
3704
- strategy: verticalListSortingStrategy
3705
- }, /*#__PURE__*/React__default.createElement(Box$1, {
3706
- sx: {
3707
- display: "flex",
3708
- flexDirection: "column",
3709
- gap: 1
3710
- }
3711
- }, savedComputedDimensions.map((cd, index) => /*#__PURE__*/React__default.createElement(SortableComputedChip, {
3712
- key: index,
3765
+ }) : /*#__PURE__*/React__default.createElement(SortableComputedChip, {
3766
+ key: `comp-${item.key}`,
3713
3767
  id: index,
3714
- index: index,
3715
- name: cd.name,
3716
- value: cd.value,
3768
+ name: item.data.name,
3769
+ value: item.data.value,
3717
3770
  onUpdate: onUpdateComputedDimension,
3718
3771
  isNameTaken: isComputedNameTaken,
3719
- onDelete: () => onRemoveComputedDimension(index),
3720
- onMoveUp: () => handleMoveComputedUp(index),
3721
- onMoveDown: () => handleMoveComputedDown(index),
3772
+ onDelete: () => onRemoveComputedDimension(item.key),
3773
+ onMoveUp: () => handleMoveUp(index),
3774
+ onMoveDown: () => handleMoveDown(index),
3722
3775
  isFirst: index === 0,
3723
- isLast: index === savedComputedDimensions.length - 1
3724
- })))))));
3776
+ isLast: index === orderedItems.length - 1
3777
+ })))));
3725
3778
  };
3726
3779
 
3727
3780
  const MetricSourceIcon = ({
@@ -3796,20 +3849,8 @@ const SortableChip = ({
3796
3849
  attributes,
3797
3850
  listeners,
3798
3851
  setNodeRef,
3799
- transform,
3800
- transition,
3801
- isDragging
3802
- } = useSortable({
3803
- id
3804
- });
3805
- const style = {
3806
- transform: CSS.Transform.toString(transform),
3807
- transition,
3808
- opacity: isDragging ? 0.5 : 1,
3809
- display: 'flex',
3810
- alignItems: 'center',
3811
- width: '100%'
3812
- };
3852
+ style
3853
+ } = useSortableRow(id);
3813
3854
 
3814
3855
  // Focus input when entering edit mode
3815
3856
  useEffect(() => {
@@ -3887,21 +3928,9 @@ const SortableChip = ({
3887
3928
  opacity: 1 // show icons on hover
3888
3929
  }
3889
3930
  }
3890
- }, /*#__PURE__*/React__default.createElement(Box$1, _extends({}, listeners, {
3891
- sx: {
3892
- display: "flex",
3893
- alignItems: "center",
3894
- cursor: "grab",
3895
- "&:active": {
3896
- cursor: "grabbing"
3897
- }
3898
- }
3899
- }), /*#__PURE__*/React__default.createElement(DragIndicatorIcon, {
3900
- sx: {
3901
- cursor: "grab",
3902
- color: "rgba(110, 110, 110, 0.62)"
3903
- }
3904
- })), !isEditing ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, source && /*#__PURE__*/React__default.createElement(Box$1, {
3931
+ }, /*#__PURE__*/React__default.createElement(DragHandle, {
3932
+ listeners: listeners
3933
+ }), !isEditing ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, source && /*#__PURE__*/React__default.createElement(Box$1, {
3905
3934
  sx: {
3906
3935
  display: 'flex',
3907
3936
  alignItems: 'center',
@@ -3963,29 +3992,12 @@ const SortableChip = ({
3963
3992
  sx: {
3964
3993
  flex: 1
3965
3994
  }
3966
- }), /*#__PURE__*/React__default.createElement(Box$1, {
3967
- className: "hover-icons",
3968
- sx: {
3969
- display: "flex",
3970
- gap: 0.5,
3971
- opacity: 0,
3972
- transition: "opacity 0.2s"
3973
- }
3974
- }, /*#__PURE__*/React__default.createElement(IconButton$1, {
3975
- size: "small",
3976
- onClick: onMoveUp,
3977
- disabled: isFirst,
3978
- "aria-label": "move up"
3979
- }, /*#__PURE__*/React__default.createElement(ArrowUpwardIcon, {
3980
- fontSize: "small"
3981
- })), /*#__PURE__*/React__default.createElement(IconButton$1, {
3982
- size: "small",
3983
- onClick: onMoveDown,
3984
- disabled: isLast,
3985
- "aria-label": "move down"
3986
- }, /*#__PURE__*/React__default.createElement(ArrowDownwardIcon, {
3987
- fontSize: "small"
3988
- })))) : /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(TextField, {
3995
+ }), /*#__PURE__*/React__default.createElement(MoveButtons, {
3996
+ onMoveUp: onMoveUp,
3997
+ onMoveDown: onMoveDown,
3998
+ isFirst: isFirst,
3999
+ isLast: isLast
4000
+ })) : /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(TextField, {
3989
4001
  inputRef: inputRef,
3990
4002
  value: editValue,
3991
4003
  onChange: e => setEditValue(e.target.value),
@@ -4060,39 +4072,14 @@ const Metrics = ({
4060
4072
  const [selectedMetric, setSelectedMetric] = useState('');
4061
4073
 
4062
4074
  // Setup drag and drop sensors
4063
- const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, {
4064
- coordinateGetter: sortableKeyboardCoordinates
4065
- }));
4066
-
4067
- // Handle drag end
4068
- const handleDragEnd = event => {
4069
- const {
4070
- active,
4071
- over
4072
- } = event;
4073
- if (over && active.id !== over.id) {
4074
- const oldIndex = savedMetrics.findIndex((_, i) => i === active.id);
4075
- const newIndex = savedMetrics.findIndex((_, i) => i === over.id);
4076
- const newOrder = arrayMove(savedMetrics, oldIndex, newIndex);
4077
- onReorderMetrics(newOrder);
4078
- }
4079
- };
4080
-
4081
- // Handle move up
4082
- const handleMoveUp = index => {
4083
- if (index > 0) {
4084
- const newOrder = arrayMove(savedMetrics, index, index - 1);
4085
- onReorderMetrics(newOrder);
4086
- }
4087
- };
4075
+ const sensors = useSortableListSensors();
4088
4076
 
4089
- // Handle move down
4090
- const handleMoveDown = index => {
4091
- if (index < savedMetrics.length - 1) {
4092
- const newOrder = arrayMove(savedMetrics, index, index + 1);
4093
- onReorderMetrics(newOrder);
4094
- }
4095
- };
4077
+ // Handle drag/move
4078
+ const {
4079
+ handleDragEnd,
4080
+ handleMoveUp,
4081
+ handleMoveDown
4082
+ } = makeListReorderHandlers(savedMetrics, onReorderMetrics);
4096
4083
 
4097
4084
  // Get the current provider based on selection chain
4098
4085
  const getCurrentProvider = () => {
@@ -4431,19 +4418,10 @@ const Metrics = ({
4431
4418
  marginBottom: 1,
4432
4419
  color: "rgb(37, 37, 37)"
4433
4420
  }
4434
- }, "Saved Metrics"), /*#__PURE__*/React__default.createElement(DndContext, {
4421
+ }, "Saved Metrics"), /*#__PURE__*/React__default.createElement(SortableListContext, {
4435
4422
  sensors: sensors,
4436
- collisionDetection: closestCenter,
4423
+ itemIds: savedMetrics.map((_, index) => index),
4437
4424
  onDragEnd: handleDragEnd
4438
- }, /*#__PURE__*/React__default.createElement(SortableContext, {
4439
- items: savedMetrics.map((_, index) => index),
4440
- strategy: verticalListSortingStrategy
4441
- }, /*#__PURE__*/React__default.createElement(Box$1, {
4442
- sx: {
4443
- display: 'flex',
4444
- flexDirection: 'column',
4445
- gap: 1
4446
- }
4447
4425
  }, savedMetrics.map((metric, index) => /*#__PURE__*/React__default.createElement(SortableChip, {
4448
4426
  key: index,
4449
4427
  id: index,
@@ -4460,7 +4438,7 @@ const Metrics = ({
4460
4438
  onUpdateTitle: onUpdateTitle,
4461
4439
  onResetTitle: onResetTitle,
4462
4440
  source: metric.metric?.source
4463
- })))))));
4441
+ })))));
4464
4442
  };
4465
4443
 
4466
4444
  const Filters = ({
@@ -5687,9 +5665,208 @@ const Filters = ({
5687
5665
  }))));
5688
5666
  };
5689
5667
 
5690
- // Default numeral.js formats applied when a dimension/metric definition has a
5691
- // known type but no explicit `format` set on the provider.
5692
- const DEFAULT_FORMATS_BY_TYPE = {
5668
+ const KIND_LABEL = {
5669
+ dimension: "Dimension",
5670
+ computed: "Computed",
5671
+ metric: "Metric"
5672
+ };
5673
+ const KindChip = ({
5674
+ kind,
5675
+ source
5676
+ }) => {
5677
+ if (kind === "metric") {
5678
+ return /*#__PURE__*/React__default.createElement(Box$1, {
5679
+ sx: {
5680
+ display: "flex",
5681
+ alignItems: "center",
5682
+ flexShrink: 0
5683
+ }
5684
+ }, /*#__PURE__*/React__default.createElement(MetricSourceIcon, {
5685
+ source: source
5686
+ }));
5687
+ }
5688
+ return /*#__PURE__*/React__default.createElement(Chip, {
5689
+ icon: kind === "computed" ? /*#__PURE__*/React__default.createElement(CalculateIcon, {
5690
+ sx: {
5691
+ fontSize: "15px !important"
5692
+ }
5693
+ }) : undefined,
5694
+ label: KIND_LABEL[kind],
5695
+ size: "small",
5696
+ sx: {
5697
+ fontWeight: 500,
5698
+ backgroundColor: "rgba(70, 134, 128, 0.1)",
5699
+ color: "rgb(70, 134, 128)",
5700
+ flexShrink: 0
5701
+ }
5702
+ });
5703
+ };
5704
+
5705
+ // Read-only sortable row: this tab only reorders columns. Adding, removing,
5706
+ // and renaming stay on the Dimensions/Metrics tabs so there's one place each
5707
+ // field is managed.
5708
+ const SortableColumnRow = ({
5709
+ id,
5710
+ label,
5711
+ fullPath,
5712
+ kind,
5713
+ source,
5714
+ onMoveUp,
5715
+ onMoveDown,
5716
+ isFirst,
5717
+ isLast
5718
+ }) => {
5719
+ const reportingContext = useReportingContextOptional();
5720
+ const parameters = reportingContext?.parameters;
5721
+ const {
5722
+ attributes,
5723
+ listeners,
5724
+ setNodeRef,
5725
+ style
5726
+ } = useSortableRow(id);
5727
+ const displayLabel = interpolateTitle(label, parameters);
5728
+ return /*#__PURE__*/React__default.createElement("div", _extends({
5729
+ ref: setNodeRef,
5730
+ style: style
5731
+ }, attributes), /*#__PURE__*/React__default.createElement(Box$1, {
5732
+ sx: {
5733
+ display: "flex",
5734
+ alignItems: "center",
5735
+ width: "100%",
5736
+ gap: 1,
5737
+ backgroundColor: "white",
5738
+ border: "1px solid #e0e0e0",
5739
+ borderRadius: 2,
5740
+ padding: 1,
5741
+ transition: "transform 0.2s ease, box-shadow 0.2s ease",
5742
+ "&:hover .hover-icons": {
5743
+ opacity: 1
5744
+ }
5745
+ }
5746
+ }, /*#__PURE__*/React__default.createElement(DragHandle, {
5747
+ listeners: listeners
5748
+ }), /*#__PURE__*/React__default.createElement(KindChip, {
5749
+ kind: kind,
5750
+ source: source
5751
+ }), /*#__PURE__*/React__default.createElement(Tooltip$1, {
5752
+ title: fullPath,
5753
+ arrow: true,
5754
+ placement: "top"
5755
+ }, /*#__PURE__*/React__default.createElement(Typography$1, {
5756
+ variant: "h6",
5757
+ sx: {
5758
+ fontWeight: 500,
5759
+ color: "#1a1a1a",
5760
+ fontSize: "14px",
5761
+ whiteSpace: "nowrap",
5762
+ overflow: "hidden",
5763
+ textOverflow: "ellipsis"
5764
+ }
5765
+ }, displayLabel)), /*#__PURE__*/React__default.createElement(Box$1, {
5766
+ sx: {
5767
+ flex: 1
5768
+ }
5769
+ }), /*#__PURE__*/React__default.createElement(MoveButtons, {
5770
+ onMoveUp: onMoveUp,
5771
+ onMoveDown: onMoveDown,
5772
+ isFirst: isFirst,
5773
+ isLast: isLast
5774
+ })));
5775
+ };
5776
+ const ColumnOrder = ({
5777
+ orderedDimensionItems = [],
5778
+ metrics = [],
5779
+ columnOrder = [],
5780
+ onReorderColumnItems,
5781
+ titleOverrides = {
5782
+ dimensions: {},
5783
+ metrics: {}
5784
+ }
5785
+ }) => {
5786
+ const isCustomized = columnOrder.length > 0;
5787
+ const resolvedItems = resolveColumnOrder(columnOrder, orderedDimensionItems, metrics);
5788
+ const sensors = useSortableListSensors();
5789
+ const reorderResolved = newResolvedItems => {
5790
+ onReorderColumnItems(newResolvedItems.map(item => ({
5791
+ type: item.kind,
5792
+ key: item.key
5793
+ })));
5794
+ };
5795
+ const {
5796
+ handleDragEnd,
5797
+ handleMoveUp,
5798
+ handleMoveDown
5799
+ } = makeListReorderHandlers(resolvedItems, reorderResolved);
5800
+ const labelFor = item => {
5801
+ if (item.kind === "computed") return item.data.name;
5802
+ if (item.kind === "metric") {
5803
+ return titleOverrides.metrics[item.key] || item.data.metricTitle || item.key;
5804
+ }
5805
+ return titleOverrides.dimensions[item.key] || item.data.dimensionTitle || item.key;
5806
+ };
5807
+ return /*#__PURE__*/React__default.createElement("div", null, /*#__PURE__*/React__default.createElement(Box$1, {
5808
+ sx: {
5809
+ display: "flex",
5810
+ justifyContent: "space-between",
5811
+ alignItems: "flex-start",
5812
+ mb: 2,
5813
+ gap: 2
5814
+ }
5815
+ }, /*#__PURE__*/React__default.createElement(Typography$1, {
5816
+ sx: {
5817
+ fontSize: "13px",
5818
+ color: "#666"
5819
+ }
5820
+ }, "Optional: define a single order for all columns (dimensions, computed dimensions and metrics together) in the results grid. Drag to reorder or use the arrows. Newly added dimensions/metrics are appended to the end automatically. Without a custom order, columns follow the Dimensions tab order, then the Metrics tab order."), isCustomized && /*#__PURE__*/React__default.createElement(Tooltip$1, {
5821
+ title: "Discard the custom order and go back to the default (Dimensions, then Metrics)",
5822
+ arrow: true
5823
+ }, /*#__PURE__*/React__default.createElement(Button, {
5824
+ variant: "outlined",
5825
+ size: "small",
5826
+ startIcon: /*#__PURE__*/React__default.createElement(RestartAltIcon, null),
5827
+ onClick: () => onReorderColumnItems([]),
5828
+ sx: {
5829
+ height: "36px",
5830
+ flexShrink: 0,
5831
+ fontFamily: "system-ui",
5832
+ fontSize: "13px",
5833
+ fontWeight: 500,
5834
+ borderRadius: "8px",
5835
+ boxShadow: "none",
5836
+ textTransform: "none",
5837
+ borderColor: "#e0e0e0",
5838
+ color: "rgb(37, 37, 37)",
5839
+ "&:hover": {
5840
+ backgroundColor: "#f5f5f5",
5841
+ borderColor: "#d0d0d0"
5842
+ }
5843
+ }
5844
+ }, "Reset to Default"))), resolvedItems.length === 0 ? /*#__PURE__*/React__default.createElement(Typography$1, {
5845
+ sx: {
5846
+ fontSize: "13px",
5847
+ color: "#999"
5848
+ }
5849
+ }, "Add dimensions or metrics in the other tabs first.") : /*#__PURE__*/React__default.createElement(SortableListContext, {
5850
+ sensors: sensors,
5851
+ itemIds: resolvedItems.map((_, index) => index),
5852
+ onDragEnd: handleDragEnd
5853
+ }, resolvedItems.map((item, index) => /*#__PURE__*/React__default.createElement(SortableColumnRow, {
5854
+ key: `${item.kind}-${item.key}`,
5855
+ id: index,
5856
+ label: labelFor(item),
5857
+ fullPath: item.kind === "computed" ? item.data.value : item.key,
5858
+ kind: item.kind,
5859
+ source: item.kind === "metric" ? item.data.metric?.source : undefined,
5860
+ onMoveUp: () => handleMoveUp(index),
5861
+ onMoveDown: () => handleMoveDown(index),
5862
+ isFirst: index === 0,
5863
+ isLast: index === resolvedItems.length - 1
5864
+ }))));
5865
+ };
5866
+
5867
+ // Default numeral.js formats applied when a dimension/metric definition has a
5868
+ // known type but no explicit `format` set on the provider.
5869
+ const DEFAULT_FORMATS_BY_TYPE = {
5693
5870
  currency: '0,0.00',
5694
5871
  integer: '0,0'
5695
5872
  };
@@ -5701,11 +5878,107 @@ const formatValue = (value, def) => {
5701
5878
  return def?.prefix ? `${def.prefix} ${formatted}` : formatted;
5702
5879
  };
5703
5880
  const isNumericType = type => type === 'integer' || type === 'currency';
5881
+
5882
+ // Builds a single DataGrid column definition for one resolved column item
5883
+ // (a dimension, computed dimension, or metric). Field naming logic:
5884
+ // - Dimensions from base provider: baseAlias.field -> baseAlias_field
5885
+ // - Dimensions from nested provider: baseAlias_rel1_rel2.field -> rel1_rel2_field (drops base alias)
5886
+ // - Computed dimensions have no provider/relations chain, so the API returns
5887
+ // each row keyed by the computed dimension's own `name`.
5888
+ // - Metrics: API returns metric keys in different forms depending on path depth
5889
+ // (see comment inline below), normalised to match the row-key normalisation
5890
+ // done when building `rows`.
5891
+ const buildColumn = (item, titleOverrides, parameters) => {
5892
+ if (item.kind === 'computed') {
5893
+ const cd = item.data;
5894
+ return {
5895
+ field: cd.name,
5896
+ headerName: cd.name,
5897
+ flex: 1,
5898
+ minWidth: 150,
5899
+ type: 'string'
5900
+ };
5901
+ }
5902
+ if (item.kind === 'metric') {
5903
+ const metric = item.data;
5904
+ const metricDef = metric.metric;
5905
+ let fieldName;
5906
+ const dotCount = (metric.fullPath.match(/\./g) || []).length;
5907
+ if (dotCount >= 2) {
5908
+ // 3+-part namespaced path: API returns the full dotted path as the key.
5909
+ // Normalise dots to underscores to match the row key normalisation below.
5910
+ // Example: "mc.dkpi.activeAgreementsCount" -> "mc_dkpi_activeAgreementsCount"
5911
+ fieldName = metric.fullPath.replace(/\./g, '_');
5912
+ } else if (metric.relations && metric.relations.length > 0) {
5913
+ // 2-part path from a nested provider: API drops the base provider alias.
5914
+ // Example: "mc_fa.total_amount" -> "fa_total_amount"
5915
+ const parts = metric.fullPath.split('.');
5916
+ const pathWithoutField = parts[0]; // e.g., "mc_fa"
5917
+ const field = parts[1]; // e.g., "total_amount"
5918
+
5919
+ const pathParts = pathWithoutField.split('_');
5920
+ pathParts.shift(); // remove base provider alias
5921
+ const pathWithoutBase = pathParts.join('_');
5922
+ fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
5923
+ } else {
5924
+ // 2-part path from the base provider: API returns just the metric name.
5925
+ // Example: "mc.record_count" -> "record_count"
5926
+ fieldName = metric.metricName;
5927
+ }
5928
+ const headerName = interpolateTitle(titleOverrides.metrics[metric.fullPath] || metric.metricTitle || fieldName, parameters);
5929
+ return {
5930
+ field: fieldName,
5931
+ headerName: headerName,
5932
+ flex: 1,
5933
+ minWidth: 150,
5934
+ type: isNumericType(metricDef?.type) ? 'number' : 'string',
5935
+ renderHeader: () => /*#__PURE__*/React__default.createElement(Box$1, {
5936
+ sx: {
5937
+ display: 'flex',
5938
+ alignItems: 'center',
5939
+ gap: 0.5
5940
+ }
5941
+ }, /*#__PURE__*/React__default.createElement(MetricSourceIcon, {
5942
+ source: metricDef?.source
5943
+ }), /*#__PURE__*/React__default.createElement("span", null, headerName)),
5944
+ valueFormatter: value => formatValue(value, metricDef)
5945
+ };
5946
+ }
5947
+
5948
+ // Dimension
5949
+ const dim = item.data;
5950
+ let fieldName;
5951
+ if (dim.relations && dim.relations.length > 0) {
5952
+ // From nested provider: drop the base provider alias
5953
+ // Example: ft_fa_db.currency -> fa_db_currency
5954
+ const parts = dim.fullPath.split('.');
5955
+ const pathWithoutField = parts[0]; // e.g., "ft_fa_db"
5956
+ const field = parts[1]; // e.g., "currency"
5957
+
5958
+ const pathParts = pathWithoutField.split('_');
5959
+ pathParts.shift(); // Remove base provider alias
5960
+ const pathWithoutBase = pathParts.join('_'); // e.g., "fa_db"
5961
+
5962
+ fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
5963
+ } else {
5964
+ // From base provider: keep the full path with underscore
5965
+ // Example: ba.created_at -> ba_created_at
5966
+ fieldName = dim.fullPath.replace('.', '_');
5967
+ }
5968
+ const headerName = interpolateTitle(titleOverrides.dimensions[dim.fullPath] || dim.dimensionTitle || fieldName, parameters);
5969
+ const dimDef = dim.dimension;
5970
+ return {
5971
+ field: fieldName,
5972
+ headerName: headerName,
5973
+ flex: 1,
5974
+ minWidth: 150,
5975
+ type: isNumericType(dimDef?.type) ? 'number' : 'string',
5976
+ valueFormatter: value => formatValue(value, dimDef)
5977
+ };
5978
+ };
5704
5979
  const ReportDataGrid = ({
5705
5980
  reportData,
5706
- dimensions,
5707
- metrics,
5708
- computedDimensions = [],
5981
+ columnItems = [],
5709
5982
  loading,
5710
5983
  onPageChange,
5711
5984
  totalRows = 0,
@@ -5721,124 +5994,10 @@ const ReportDataGrid = ({
5721
5994
  pageSize: 50
5722
5995
  });
5723
5996
 
5724
- // Generate columns dynamically from dimensions and metrics
5725
- const columns = React__default.useMemo(() => {
5726
- const cols = [];
5727
-
5728
- // Add dimension columns
5729
- // Field naming logic:
5730
- // - If from base provider: baseAlias.field -> baseAlias_field
5731
- // - If from nested provider: baseAlias_rel1_rel2.field -> rel1_rel2_field (drops base alias)
5732
- dimensions.forEach(dim => {
5733
- let fieldName;
5734
- let headerName;
5735
-
5736
- // Check if there are relations (nested providers)
5737
- if (dim.relations && dim.relations.length > 0) {
5738
- // From nested provider: drop the base provider alias
5739
- // Example: ft_fa_db.currency -> fa_db_currency
5740
- const parts = dim.fullPath.split('.');
5741
- const pathWithoutField = parts[0]; // e.g., "ft_fa_db"
5742
- const field = parts[1]; // e.g., "currency"
5743
-
5744
- // Remove the base provider alias (first part before first underscore)
5745
- const pathParts = pathWithoutField.split('_');
5746
- pathParts.shift(); // Remove base provider alias
5747
- const pathWithoutBase = pathParts.join('_'); // e.g., "fa_db"
5748
-
5749
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
5750
- } else {
5751
- // From base provider: keep the full path with underscore
5752
- // Example: ba.created_at -> ba_created_at
5753
- fieldName = dim.fullPath.replace('.', '_');
5754
- }
5755
-
5756
- // Check for title override, otherwise use the friendly dimension title from provider
5757
- headerName = titleOverrides.dimensions[dim.fullPath] || dim.dimensionTitle || fieldName;
5758
- headerName = interpolateTitle(headerName, parameters);
5759
- const dimDef = dim.dimension;
5760
- cols.push({
5761
- field: fieldName,
5762
- headerName: headerName,
5763
- flex: 1,
5764
- minWidth: 150,
5765
- type: isNumericType(dimDef?.type) ? 'number' : 'string',
5766
- valueFormatter: value => formatValue(value, dimDef)
5767
- });
5768
- });
5769
-
5770
- // Add computed dimension columns. These have no provider/relations chain,
5771
- // so the API returns each row keyed by the computed dimension's own `name`.
5772
- computedDimensions.forEach(cd => {
5773
- cols.push({
5774
- field: cd.name,
5775
- headerName: cd.name,
5776
- flex: 1,
5777
- minWidth: 150,
5778
- type: 'string'
5779
- });
5780
- });
5781
-
5782
- // Add metric columns
5783
- // The API returns metric keys in two different forms depending on the path depth:
5784
- //
5785
- // • 2-part path "mc.record_count" → API key: "record_count"
5786
- // • 2-part path "mc_fa.total_amount" (nested) → API key: "fa_total_amount"
5787
- // • 3+-part path "mc.dkpi.activeAgreementsCount"→ API key: "mc.dkpi.activeAgreementsCount" (dots)
5788
- //
5789
- // For 3+-part paths the API key contains dots. MUI DataGrid interprets dots
5790
- // in field names as nested-object accessors, so the rows useMemo normalises
5791
- // those keys (dots → underscores). The column field must match that normalised key.
5792
- metrics.forEach(metric => {
5793
- const metricDef = metric.metric;
5794
- let fieldName;
5795
- let headerName;
5796
- const dotCount = (metric.fullPath.match(/\./g) || []).length;
5797
- if (dotCount >= 2) {
5798
- // 3+-part namespaced path: API returns the full dotted path as the key.
5799
- // Normalise dots to underscores to match the row key normalisation below.
5800
- // Example: "mc.dkpi.activeAgreementsCount" -> "mc_dkpi_activeAgreementsCount"
5801
- fieldName = metric.fullPath.replace(/\./g, '_');
5802
- } else if (metric.relations && metric.relations.length > 0) {
5803
- // 2-part path from a nested provider: API drops the base provider alias.
5804
- // Example: "mc_fa.total_amount" -> "fa_total_amount"
5805
- const parts = metric.fullPath.split('.');
5806
- const pathWithoutField = parts[0]; // e.g., "mc_fa"
5807
- const field = parts[1]; // e.g., "total_amount"
5808
-
5809
- const pathParts = pathWithoutField.split('_');
5810
- pathParts.shift(); // remove base provider alias
5811
- const pathWithoutBase = pathParts.join('_');
5812
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
5813
- } else {
5814
- // 2-part path from the base provider: API returns just the metric name.
5815
- // Example: "mc.record_count" -> "record_count"
5816
- fieldName = metric.metricName;
5817
- }
5818
-
5819
- // Check for title override, otherwise use the friendly metric title from provider
5820
- headerName = titleOverrides.metrics[metric.fullPath] || metric.metricTitle || fieldName;
5821
- headerName = interpolateTitle(headerName, parameters);
5822
- cols.push({
5823
- field: fieldName,
5824
- headerName: headerName,
5825
- flex: 1,
5826
- minWidth: 150,
5827
- type: isNumericType(metricDef?.type) ? 'number' : 'string',
5828
- renderHeader: () => /*#__PURE__*/React__default.createElement(Box$1, {
5829
- sx: {
5830
- display: 'flex',
5831
- alignItems: 'center',
5832
- gap: 0.5
5833
- }
5834
- }, /*#__PURE__*/React__default.createElement(MetricSourceIcon, {
5835
- source: metricDef?.source
5836
- }), /*#__PURE__*/React__default.createElement("span", null, headerName)),
5837
- valueFormatter: value => formatValue(value, metricDef)
5838
- });
5839
- });
5840
- return cols;
5841
- }, [dimensions, metrics, computedDimensions, titleOverrides, parameters]);
5997
+ // Generate columns dynamically, in the final resolved column order
5998
+ // (custom Column Order tab arrangement when defined, otherwise
5999
+ // dimensions/computed then metrics).
6000
+ const columns = React__default.useMemo(() => columnItems.map(item => buildColumn(item, titleOverrides, parameters)), [columnItems, titleOverrides, parameters]);
5842
6001
 
5843
6002
  // Transform report data to rows with unique IDs.
5844
6003
  // Keys are normalised by replacing dots with underscores so that MUI DataGrid
@@ -5945,7 +6104,15 @@ const ReportBuilder = ({
5945
6104
  dimensions: [],
5946
6105
  metrics: [],
5947
6106
  filters: {},
5948
- computedDimensions: []
6107
+ computedDimensions: [],
6108
+ // Unified display/execution order across dimensions + computedDimensions.
6109
+ // Entries: { type: 'dimension', key: fullPath } | { type: 'computed', key: name }
6110
+ dimensionOrder: [],
6111
+ // Optional unified display order across dimensions + computedDimensions +
6112
+ // metrics, set on the Column Order tab. Empty means "not defined" — the
6113
+ // results grid falls back to dimensionOrder followed by metrics order.
6114
+ // Entries: { type: 'dimension' | 'computed' | 'metric', key }
6115
+ columnOrder: []
5949
6116
  });
5950
6117
  const [titleOverrides, setTitleOverrides] = useState({
5951
6118
  dimensions: {},
@@ -5991,6 +6158,16 @@ const ReportBuilder = ({
5991
6158
  }
5992
6159
  }, [autoRun, reportLoaded]);
5993
6160
 
6161
+ // Re-run the currently displayed report when base_currency changes in context
6162
+ const currentBaseCurrency = reportingContext?.parameters?.base_currency;
6163
+ const prevBaseCurrencyRef = useRef(currentBaseCurrency);
6164
+ useEffect(() => {
6165
+ if (reportData && prevBaseCurrencyRef.current !== undefined && prevBaseCurrencyRef.current !== currentBaseCurrency) {
6166
+ runReportWithPagination(currentPage, pageSize);
6167
+ }
6168
+ prevBaseCurrencyRef.current = currentBaseCurrency;
6169
+ }, [currentBaseCurrency]);
6170
+
5994
6171
  // Utility function to reconstruct dimension object from fullPath
5995
6172
  const reconstructDimensionFromPath = (fullPath, providersData, rootProvider) => {
5996
6173
  try {
@@ -6120,6 +6297,92 @@ const ReportBuilder = ({
6120
6297
  return null;
6121
6298
  }
6122
6299
  };
6300
+
6301
+ // Build the unified display/execution order for dimensions + computed
6302
+ // dimensions. Prefers the persisted `ordered_dimensions` field; falls back
6303
+ // to "dimensions first, then computed dimensions" for report definitions
6304
+ // saved before this field existed.
6305
+ const buildDimensionOrder = (rawOrderedDimensions, dimensions, computedDimensions) => {
6306
+ const dimensionOrder = [];
6307
+ const seenDimensionKeys = new Set();
6308
+ const seenComputedKeys = new Set();
6309
+ if (Array.isArray(rawOrderedDimensions)) {
6310
+ rawOrderedDimensions.forEach(entry => {
6311
+ if (entry.type === "dimension" && dimensions.some(d => d.fullPath === entry.ref)) {
6312
+ dimensionOrder.push({
6313
+ type: "dimension",
6314
+ key: entry.ref
6315
+ });
6316
+ seenDimensionKeys.add(entry.ref);
6317
+ } else if (entry.type === "computed" && computedDimensions.some(cd => cd.name === entry.ref)) {
6318
+ dimensionOrder.push({
6319
+ type: "computed",
6320
+ key: entry.ref
6321
+ });
6322
+ seenComputedKeys.add(entry.ref);
6323
+ }
6324
+ });
6325
+ }
6326
+
6327
+ // Append anything not covered by ordered_dimensions (older reports, or drift)
6328
+ dimensions.forEach(d => {
6329
+ if (!seenDimensionKeys.has(d.fullPath)) {
6330
+ dimensionOrder.push({
6331
+ type: "dimension",
6332
+ key: d.fullPath
6333
+ });
6334
+ }
6335
+ });
6336
+ computedDimensions.forEach(cd => {
6337
+ if (!seenComputedKeys.has(cd.name)) {
6338
+ dimensionOrder.push({
6339
+ type: "computed",
6340
+ key: cd.name
6341
+ });
6342
+ }
6343
+ });
6344
+ return dimensionOrder;
6345
+ };
6346
+
6347
+ // Build the optional unified column order (dimensions + computed
6348
+ // dimensions + metrics) from the persisted `ordered_columns` field.
6349
+ // Unlike buildDimensionOrder, entries that no longer resolve are simply
6350
+ // dropped rather than backfilled — an empty/partial result just means "no
6351
+ // custom order defined (yet)", which resolveColumnOrder treats as
6352
+ // "fall back to the default order".
6353
+ const buildColumnOrder = (rawOrderedColumns, dimensions, computedDimensions, metrics) => {
6354
+ if (!Array.isArray(rawOrderedColumns)) return [];
6355
+ const dimensionPaths = new Set(dimensions.map(d => d.fullPath));
6356
+ const computedNames = new Set(computedDimensions.map(cd => cd.name));
6357
+ const metricPaths = new Set(metrics.map(m => m.fullPath));
6358
+ const columnOrder = [];
6359
+ rawOrderedColumns.forEach(entry => {
6360
+ if (entry.type === "dimension" && dimensionPaths.has(entry.ref)) {
6361
+ columnOrder.push({
6362
+ type: "dimension",
6363
+ key: entry.ref
6364
+ });
6365
+ } else if (entry.type === "computed" && computedNames.has(entry.ref)) {
6366
+ columnOrder.push({
6367
+ type: "computed",
6368
+ key: entry.ref
6369
+ });
6370
+ } else if (entry.type === "metric" && metricPaths.has(entry.ref)) {
6371
+ columnOrder.push({
6372
+ type: "metric",
6373
+ key: entry.ref
6374
+ });
6375
+ }
6376
+ });
6377
+ return columnOrder;
6378
+ };
6379
+
6380
+ // Auto-append a newly added dimension/computed dimension/metric to the end
6381
+ // of columnOrder — but only once a custom order actually exists. While
6382
+ // columnOrder is empty (no custom order defined) it's left untouched, so
6383
+ // the results grid keeps falling back to the default dimensions-then-
6384
+ // metrics order until the user visits the Column Order tab.
6385
+ const appendToColumnOrder = (columnOrder, entry) => columnOrder.length > 0 ? [...columnOrder, entry] : columnOrder;
6123
6386
  const loadReportDefinition = async id => {
6124
6387
  try {
6125
6388
  console.log("Loading report definition:", id);
@@ -6215,13 +6478,17 @@ const ReportBuilder = ({
6215
6478
  // Computed dimensions are self-contained { name, value } pairs with no
6216
6479
  // provider/relation chain, so they're loaded as-is (no reconstruction needed)
6217
6480
  const computedDimensions = reportDef.definition?.doc?.query?.computed_dimensions || [];
6481
+ const dimensionOrder = buildDimensionOrder(reportDef.definition?.doc?.query?.ordered_dimensions, reconstructedDimensions, computedDimensions);
6482
+ const columnOrder = buildColumnOrder(reportDef.definition?.doc?.query?.ordered_columns, reconstructedDimensions, computedDimensions, reconstructedMetrics);
6218
6483
 
6219
6484
  // Set the report state
6220
6485
  setReport({
6221
6486
  dimensions: reconstructedDimensions,
6222
6487
  metrics: reconstructedMetrics,
6223
6488
  filters: loadedFilters,
6224
- computedDimensions
6489
+ computedDimensions,
6490
+ dimensionOrder,
6491
+ columnOrder
6225
6492
  });
6226
6493
 
6227
6494
  // Set title overrides
@@ -6330,13 +6597,17 @@ const ReportBuilder = ({
6330
6597
  // Computed dimensions are self-contained { name, value } pairs with no
6331
6598
  // provider/relation chain, so they're loaded as-is (no reconstruction needed)
6332
6599
  const computedDimensions = reportDef.definition?.doc?.query?.computed_dimensions || [];
6600
+ const dimensionOrder = buildDimensionOrder(reportDef.definition?.doc?.query?.ordered_dimensions, reconstructedDimensions, computedDimensions);
6601
+ const columnOrder = buildColumnOrder(reportDef.definition?.doc?.query?.ordered_columns, reconstructedDimensions, computedDimensions, reconstructedMetrics);
6333
6602
 
6334
6603
  // Set the report state
6335
6604
  setReport({
6336
6605
  dimensions: reconstructedDimensions,
6337
6606
  metrics: reconstructedMetrics,
6338
6607
  filters: loadedFilters,
6339
- computedDimensions
6608
+ computedDimensions,
6609
+ dimensionOrder,
6610
+ columnOrder
6340
6611
  });
6341
6612
 
6342
6613
  // Set title overrides
@@ -6369,7 +6640,9 @@ const ReportBuilder = ({
6369
6640
  dimensions: [],
6370
6641
  metrics: [],
6371
6642
  filters: {},
6372
- computedDimensions: []
6643
+ computedDimensions: [],
6644
+ dimensionOrder: [],
6645
+ columnOrder: []
6373
6646
  });
6374
6647
  // Reset title overrides
6375
6648
  setTitleOverrides({
@@ -6446,58 +6719,93 @@ const ReportBuilder = ({
6446
6719
  setReport(prev => {
6447
6720
  const newReport = {
6448
6721
  ...prev,
6449
- dimensions: [...prev.dimensions, dimensionData]
6722
+ dimensions: [...prev.dimensions, dimensionData],
6723
+ dimensionOrder: [...prev.dimensionOrder, {
6724
+ type: "dimension",
6725
+ key: dimensionData.fullPath
6726
+ }],
6727
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
6728
+ type: "dimension",
6729
+ key: dimensionData.fullPath
6730
+ })
6450
6731
  };
6451
6732
  console.log("Dimension saved:", dimensionData);
6452
6733
  console.log("Complete report:", newReport);
6453
6734
  return newReport;
6454
6735
  });
6455
6736
  };
6456
- const handleRemoveDimension = index => {
6737
+ const handleRemoveDimension = fullPath => {
6457
6738
  setReport(prev => ({
6458
6739
  ...prev,
6459
- dimensions: prev.dimensions.filter((_, i) => i !== index)
6740
+ dimensions: prev.dimensions.filter(d => d.fullPath !== fullPath),
6741
+ dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "dimension" && entry.key === fullPath)),
6742
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "dimension" && entry.key === fullPath))
6460
6743
  }));
6461
6744
  };
6462
- const handleReorderDimensions = newOrder => {
6745
+ const handleUpdateDimensionSortOrder = (fullPath, sortOrder) => {
6463
6746
  setReport(prev => ({
6464
6747
  ...prev,
6465
- dimensions: newOrder
6748
+ dimensions: prev.dimensions.map(d => d.fullPath === fullPath ? {
6749
+ ...d,
6750
+ sortOrder
6751
+ } : d)
6466
6752
  }));
6467
6753
  };
6468
- const handleSaveComputedDimension = computedDimensionData => {
6754
+
6755
+ // Reorders the single unified dimensions + computed-dimensions list;
6756
+ // `dimensions`/`computedDimensions` arrays themselves are unordered from
6757
+ // this perspective, so only `dimensionOrder` needs to change.
6758
+ const handleReorderDimensionItems = newDimensionOrder => {
6469
6759
  setReport(prev => ({
6470
6760
  ...prev,
6471
- computedDimensions: [...prev.computedDimensions, computedDimensionData]
6761
+ dimensionOrder: newDimensionOrder
6472
6762
  }));
6473
6763
  };
6474
- const handleUpdateComputedDimension = (index, computedDimensionData) => {
6475
- setReport(prev => {
6476
- const newComputedDimensions = [...prev.computedDimensions];
6477
- newComputedDimensions[index] = computedDimensionData;
6478
- return {
6479
- ...prev,
6480
- computedDimensions: newComputedDimensions
6481
- };
6482
- });
6764
+ const handleSaveComputedDimension = computedDimensionData => {
6765
+ setReport(prev => ({
6766
+ ...prev,
6767
+ computedDimensions: [...prev.computedDimensions, computedDimensionData],
6768
+ dimensionOrder: [...prev.dimensionOrder, {
6769
+ type: "computed",
6770
+ key: computedDimensionData.name
6771
+ }],
6772
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
6773
+ type: "computed",
6774
+ key: computedDimensionData.name
6775
+ })
6776
+ }));
6483
6777
  };
6484
- const handleRemoveComputedDimension = index => {
6778
+ const handleUpdateComputedDimension = (name, computedDimensionData) => {
6485
6779
  setReport(prev => ({
6486
6780
  ...prev,
6487
- computedDimensions: prev.computedDimensions.filter((_, i) => i !== index)
6781
+ computedDimensions: prev.computedDimensions.map(cd => cd.name === name ? computedDimensionData : cd),
6782
+ dimensionOrder: prev.dimensionOrder.map(entry => entry.type === "computed" && entry.key === name ? {
6783
+ ...entry,
6784
+ key: computedDimensionData.name
6785
+ } : entry),
6786
+ columnOrder: prev.columnOrder.map(entry => entry.type === "computed" && entry.key === name ? {
6787
+ ...entry,
6788
+ key: computedDimensionData.name
6789
+ } : entry)
6488
6790
  }));
6489
6791
  };
6490
- const handleReorderComputedDimensions = newOrder => {
6792
+ const handleRemoveComputedDimension = name => {
6491
6793
  setReport(prev => ({
6492
6794
  ...prev,
6493
- computedDimensions: newOrder
6795
+ computedDimensions: prev.computedDimensions.filter(cd => cd.name !== name),
6796
+ dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "computed" && entry.key === name)),
6797
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "computed" && entry.key === name))
6494
6798
  }));
6495
6799
  };
6496
6800
  const handleSaveMetric = metricData => {
6497
6801
  setReport(prev => {
6498
6802
  const newReport = {
6499
6803
  ...prev,
6500
- metrics: [...prev.metrics, metricData]
6804
+ metrics: [...prev.metrics, metricData],
6805
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
6806
+ type: "metric",
6807
+ key: metricData.fullPath
6808
+ })
6501
6809
  };
6502
6810
  console.log("Metric saved:", metricData);
6503
6811
  console.log("Complete report:", newReport);
@@ -6505,15 +6813,29 @@ const ReportBuilder = ({
6505
6813
  });
6506
6814
  };
6507
6815
  const handleRemoveMetric = index => {
6816
+ setReport(prev => {
6817
+ const fullPath = prev.metrics[index]?.fullPath;
6818
+ return {
6819
+ ...prev,
6820
+ metrics: prev.metrics.filter((_, i) => i !== index),
6821
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "metric" && entry.key === fullPath))
6822
+ };
6823
+ });
6824
+ };
6825
+ const handleReorderMetrics = newOrder => {
6508
6826
  setReport(prev => ({
6509
6827
  ...prev,
6510
- metrics: prev.metrics.filter((_, i) => i !== index)
6828
+ metrics: newOrder
6511
6829
  }));
6512
6830
  };
6513
- const handleReorderMetrics = newOrder => {
6831
+
6832
+ // Sets the unified column order shown on the Column Order tab. An empty
6833
+ // array (e.g. via "Reset to Default") clears the customization, and the
6834
+ // results grid falls back to dimensionOrder followed by metrics order.
6835
+ const handleReorderColumnItems = newColumnOrder => {
6514
6836
  setReport(prev => ({
6515
6837
  ...prev,
6516
- metrics: newOrder
6838
+ columnOrder: newColumnOrder
6517
6839
  }));
6518
6840
  };
6519
6841
  const handleSaveFilter = (fullPath, filterData) => {
@@ -6546,10 +6868,20 @@ const ReportBuilder = ({
6546
6868
  });
6547
6869
  };
6548
6870
 
6549
- // Convert internal report structure to API format
6550
- const convertReportToApiFormat = (page = 0, size = 50) => {
6871
+ // Builds the query object shared between running (paginated) and saving
6872
+ // (unpaginated) a report. Dimension and computed-dimension order both
6873
+ // derive from the single unified `report.dimensionOrder` list, so the
6874
+ // saved `dimensions`/`computed_dimensions` arrays, `order_by`, and the
6875
+ // persisted `ordered_dimensions` field all stay consistent with whatever
6876
+ // order the user arranged on screen.
6877
+ const buildQueryObject = () => {
6878
+ const dimensionByPath = Object.fromEntries(report.dimensions.map(d => [d.fullPath, d]));
6879
+ const computedByName = Object.fromEntries(report.computedDimensions.map(cd => [cd.name, cd]));
6880
+ const orderedDimensionsOnly = report.dimensionOrder.filter(entry => entry.type === "dimension").map(entry => dimensionByPath[entry.key]).filter(Boolean);
6881
+ const orderedComputedOnly = report.dimensionOrder.filter(entry => entry.type === "computed").map(entry => computedByName[entry.key]).filter(Boolean);
6882
+
6551
6883
  // Build order_by array - include all dimensions, add desc property only when needed
6552
- const orderBy = report.dimensions.map(dim => {
6884
+ const orderBy = orderedDimensionsOnly.map(dim => {
6553
6885
  const orderItem = {
6554
6886
  name: dim.fullPath
6555
6887
  };
@@ -6605,21 +6937,40 @@ const ReportBuilder = ({
6605
6937
  titles.filters = titleOverrides.filters;
6606
6938
  }
6607
6939
  const queryObj = {
6608
- dimensions: report.dimensions.map(dim => dim.fullPath),
6940
+ dimensions: orderedDimensionsOnly.map(dim => dim.fullPath),
6609
6941
  metrics: report.metrics.map(metric => metric.fullPath),
6610
- order_by: orderBy,
6611
- limit: size,
6612
- offset: page * size
6942
+ order_by: orderBy
6613
6943
  };
6614
6944
 
6615
6945
  // Only add computed_dimensions if there are any
6616
- if (report.computedDimensions && report.computedDimensions.length > 0) {
6617
- queryObj.computed_dimensions = report.computedDimensions.map(cd => ({
6946
+ if (orderedComputedOnly.length > 0) {
6947
+ queryObj.computed_dimensions = orderedComputedOnly.map(cd => ({
6618
6948
  name: cd.name,
6619
6949
  value: cd.value
6620
6950
  }));
6621
6951
  }
6622
6952
 
6953
+ // Persist the unified dimension + computed-dimension order so it can be
6954
+ // reconstructed on reload and so executed reports render columns in the
6955
+ // same order they were arranged in the builder.
6956
+ if (report.dimensionOrder.length > 0) {
6957
+ queryObj.ordered_dimensions = report.dimensionOrder.map(entry => ({
6958
+ type: entry.type,
6959
+ ref: entry.key
6960
+ }));
6961
+ }
6962
+
6963
+ // Persist the optional unified column order (Column Order tab) so the
6964
+ // results grid can reproduce it on reload. Absent when no custom order
6965
+ // has been defined, so old/untouched reports keep their current
6966
+ // dimensions-then-metrics column order unchanged.
6967
+ if (report.columnOrder.length > 0) {
6968
+ queryObj.ordered_columns = report.columnOrder.map(entry => ({
6969
+ type: entry.type,
6970
+ ref: entry.key
6971
+ }));
6972
+ }
6973
+
6623
6974
  // Only add titles if there are any overrides
6624
6975
  if (Object.keys(titles).length > 0) {
6625
6976
  queryObj.titles = titles;
@@ -6629,6 +6980,14 @@ const ReportBuilder = ({
6629
6980
  if (filter) {
6630
6981
  queryObj.filter = filter;
6631
6982
  }
6983
+ return queryObj;
6984
+ };
6985
+
6986
+ // Convert internal report structure to API format
6987
+ const convertReportToApiFormat = (page = 0, size = 50) => {
6988
+ const queryObj = buildQueryObject();
6989
+ queryObj.limit = size;
6990
+ queryObj.offset = page * size;
6632
6991
 
6633
6992
  // Get parameters from context if available, otherwise use default
6634
6993
  const parameters = reportingContext?.parameters || {
@@ -6671,8 +7030,8 @@ const ReportBuilder = ({
6671
7030
  const handleRunReport = async () => {
6672
7031
  setCurrentPage(0);
6673
7032
  await runReportWithPagination(0, pageSize);
6674
- // Switch to Results tab after running report (now index 3 after adding Filters tab)
6675
- setActiveTab(3);
7033
+ // Switch to Results tab after running report (index 4: Dimensions, Metrics, Filters, Column Order, Results)
7034
+ setActiveTab(4);
6676
7035
  };
6677
7036
  const handleDownloadReport = async () => {
6678
7037
  try {
@@ -6722,77 +7081,7 @@ const ReportBuilder = ({
6722
7081
  }
6723
7082
  try {
6724
7083
  setSaving(true);
6725
-
6726
- // Build order_by array - include all dimensions, add desc property only when needed
6727
- const orderBy = report.dimensions.map(dim => {
6728
- const orderItem = {
6729
- name: dim.fullPath
6730
- };
6731
- if (dim.sortOrder === "desc") {
6732
- orderItem.desc = true;
6733
- }
6734
- // For 'asc' or null, just include { name: fullPath } without desc property
6735
- return orderItem;
6736
- });
6737
-
6738
- // Build titles object - only include overridden titles
6739
- const titles = {};
6740
- if (Object.keys(titleOverrides.dimensions).length > 0) {
6741
- titles.dimensions = titleOverrides.dimensions;
6742
- }
6743
- if (Object.keys(titleOverrides.metrics).length > 0) {
6744
- titles.metrics = titleOverrides.metrics;
6745
- }
6746
- if (Object.keys(titleOverrides.filters).length > 0) {
6747
- titles.filters = titleOverrides.filters;
6748
- }
6749
- const queryObj = {
6750
- dimensions: report.dimensions.map(dim => dim.fullPath),
6751
- metrics: report.metrics.map(metric => metric.fullPath),
6752
- order_by: orderBy
6753
- };
6754
-
6755
- // Only add computed_dimensions if there are any
6756
- if (report.computedDimensions && report.computedDimensions.length > 0) {
6757
- queryObj.computed_dimensions = report.computedDimensions.map(cd => ({
6758
- name: cd.name,
6759
- value: cd.value
6760
- }));
6761
- }
6762
-
6763
- // Only add titles if there are any overrides
6764
- if (Object.keys(titles).length > 0) {
6765
- queryObj.titles = titles;
6766
- }
6767
-
6768
- // Add filter if there are any - NEW API format with top-level 'and' operator
6769
- // NEW format: { and: [ { "ft.currency": ["USD", "EUR"] }, { "ft_ba.created_at": {gte: "2025-05-01", lte: "2025-12-31"} } ] }
6770
- if (Object.keys(report.filters).length > 0) {
6771
- const conditions = [];
6772
- Object.entries(report.filters).forEach(([fullPath, filterData]) => {
6773
- if (filterData.values) {
6774
- // Check if values is an object (date range) or array (regular filter)
6775
- if (Array.isArray(filterData.values)) {
6776
- // Regular filter - only add if array has values
6777
- if (filterData.values.length > 0) {
6778
- conditions.push({
6779
- [fullPath]: filterData.values
6780
- });
6781
- }
6782
- } else if (typeof filterData.values === "object") {
6783
- // Date range filter - add the object as-is
6784
- conditions.push({
6785
- [fullPath]: filterData.values
6786
- });
6787
- }
6788
- }
6789
- });
6790
- if (conditions.length > 0) {
6791
- queryObj.filter = {
6792
- and: conditions
6793
- };
6794
- }
6795
- }
7084
+ const queryObj = buildQueryObject();
6796
7085
 
6797
7086
  // Build the doc object
6798
7087
  const docObj = {
@@ -6837,6 +7126,23 @@ const ReportBuilder = ({
6837
7126
  }
6838
7127
  };
6839
7128
  const canSaveReport = selectedProvider && reportTitle.trim() && (report.dimensions.length > 0 || report.metrics.length > 0 || report.computedDimensions.length > 0);
7129
+
7130
+ // Resolve the unified dimensionOrder into actual dimension/computed-dimension
7131
+ // objects so the results grid can render columns in that same order.
7132
+ const dimensionByFullPath = Object.fromEntries(report.dimensions.map(d => [d.fullPath, d]));
7133
+ const computedDimensionByName = Object.fromEntries(report.computedDimensions.map(cd => [cd.name, cd]));
7134
+ const orderedDimensionItems = report.dimensionOrder.map(entry => entry.type === "dimension" ? {
7135
+ kind: "dimension",
7136
+ data: dimensionByFullPath[entry.key]
7137
+ } : {
7138
+ kind: "computed",
7139
+ data: computedDimensionByName[entry.key]
7140
+ }).filter(item => item.data);
7141
+
7142
+ // Final column list for the results grid: the explicit Column Order tab
7143
+ // arrangement when defined, otherwise dimensions/computed (in their own
7144
+ // order) followed by metrics (in their own order) — today's behavior.
7145
+ const columnItems = resolveColumnOrder(report.columnOrder, orderedDimensionItems, report.metrics);
6840
7146
  return /*#__PURE__*/React__default.createElement(Box$1, {
6841
7147
  sx: {
6842
7148
  p: 3,
@@ -7091,9 +7397,36 @@ const ReportBuilder = ({
7091
7397
  }
7092
7398
  }
7093
7399
  }), /*#__PURE__*/React__default.createElement(Tab, {
7094
- label: reportData ? "Results" : "Results (Run report first)",
7400
+ label: /*#__PURE__*/React__default.createElement(Badge, {
7401
+ variant: "dot",
7402
+ invisible: report.columnOrder.length === 0,
7403
+ sx: {
7404
+ "& .MuiBadge-dot": {
7405
+ backgroundColor: "rgb(70, 134, 128)"
7406
+ }
7407
+ }
7408
+ }, /*#__PURE__*/React__default.createElement("span", {
7409
+ style: {
7410
+ marginRight: report.columnOrder.length > 0 ? "8px" : "0"
7411
+ }
7412
+ }, "Column Order")),
7095
7413
  id: "report-tab-3",
7096
7414
  "aria-controls": "report-tabpanel-3",
7415
+ sx: {
7416
+ height: "41px",
7417
+ fontFamily: "system-ui",
7418
+ borderRadius: "0.5rem",
7419
+ boxShadow: "none",
7420
+ textTransform: "none",
7421
+ color: "rgb(37, 37, 37)",
7422
+ "&.Mui-selected": {
7423
+ color: "rgb(70, 134, 128)"
7424
+ }
7425
+ }
7426
+ }), /*#__PURE__*/React__default.createElement(Tab, {
7427
+ label: reportData ? "Results" : "Results (Run report first)",
7428
+ id: "report-tab-4",
7429
+ "aria-controls": "report-tabpanel-4",
7097
7430
  disabled: !reportData,
7098
7431
  sx: {
7099
7432
  height: "41px",
@@ -7115,7 +7448,7 @@ const ReportBuilder = ({
7115
7448
  savedDimensions: report.dimensions,
7116
7449
  onSaveDimension: handleSaveDimension,
7117
7450
  onRemoveDimension: handleRemoveDimension,
7118
- onReorderDimensions: handleReorderDimensions,
7451
+ onUpdateDimensionSortOrder: handleUpdateDimensionSortOrder,
7119
7452
  titleOverrides: titleOverrides.dimensions,
7120
7453
  onUpdateTitle: handleUpdateDimensionTitle,
7121
7454
  onResetTitle: handleResetDimensionTitle,
@@ -7125,7 +7458,8 @@ const ReportBuilder = ({
7125
7458
  onSaveComputedDimension: handleSaveComputedDimension,
7126
7459
  onUpdateComputedDimension: handleUpdateComputedDimension,
7127
7460
  onRemoveComputedDimension: handleRemoveComputedDimension,
7128
- onReorderComputedDimensions: handleReorderComputedDimensions
7461
+ dimensionOrder: report.dimensionOrder,
7462
+ onReorderDimensionItems: handleReorderDimensionItems
7129
7463
  })), /*#__PURE__*/React__default.createElement(TabPanel, {
7130
7464
  value: activeTab,
7131
7465
  index: 1
@@ -7159,11 +7493,18 @@ const ReportBuilder = ({
7159
7493
  })), /*#__PURE__*/React__default.createElement(TabPanel, {
7160
7494
  value: activeTab,
7161
7495
  index: 3
7496
+ }, /*#__PURE__*/React__default.createElement(ColumnOrder, {
7497
+ orderedDimensionItems: orderedDimensionItems,
7498
+ metrics: report.metrics,
7499
+ columnOrder: report.columnOrder,
7500
+ onReorderColumnItems: handleReorderColumnItems,
7501
+ titleOverrides: titleOverrides
7502
+ })), /*#__PURE__*/React__default.createElement(TabPanel, {
7503
+ value: activeTab,
7504
+ index: 4
7162
7505
  }, reportData && /*#__PURE__*/React__default.createElement(ReportDataGrid, {
7163
7506
  reportData: reportData,
7164
- dimensions: report.dimensions,
7165
- metrics: report.metrics,
7166
- computedDimensions: report.computedDimensions,
7507
+ columnItems: columnItems,
7167
7508
  loading: loading,
7168
7509
  onPageChange: handlePageChange,
7169
7510
  totalRows: totalRows,
@@ -7226,6 +7567,50 @@ const ReportDefinitionsManager = () => {
7226
7567
  });
7227
7568
  };
7228
7569
 
7570
+ // TODO: replace with currencies fetched from the API once that's available.
7571
+ const CURRENCY_OPTIONS = [{
7572
+ key: "USD",
7573
+ value: "USD"
7574
+ }, {
7575
+ key: "EUR",
7576
+ value: "EUR"
7577
+ }, {
7578
+ key: "SEK",
7579
+ value: "SEK"
7580
+ }, {
7581
+ key: "GBP",
7582
+ value: "GBP"
7583
+ }, {
7584
+ key: "DKK",
7585
+ value: "DKK"
7586
+ }, {
7587
+ key: "NOK",
7588
+ value: "NOK"
7589
+ }];
7590
+ const CurrencySelector = () => {
7591
+ const {
7592
+ parameters,
7593
+ setParameters
7594
+ } = useReportingContext();
7595
+
7596
+ // Hidden by default; only shown when a `cur` query parameter is present in the top-level URL.
7597
+ const showCurrencySelector = new URLSearchParams(window.location.search).has("cur");
7598
+ if (!showCurrencySelector) {
7599
+ return null;
7600
+ }
7601
+ return /*#__PURE__*/React__default.createElement(SingleSelect, {
7602
+ items: CURRENCY_OPTIONS,
7603
+ value: parameters.base_currency,
7604
+ label: "Currency",
7605
+ onChange: e => setParameters({
7606
+ ...parameters,
7607
+ base_currency: e.target.value
7608
+ }),
7609
+ sx: {
7610
+ width: 110
7611
+ }
7612
+ });
7613
+ };
7229
7614
  const ReportApp = ({
7230
7615
  params,
7231
7616
  api
@@ -7292,7 +7677,13 @@ const ReportApp = ({
7292
7677
  }
7293
7678
  }
7294
7679
  })
7295
- }, /*#__PURE__*/React__default.createElement(ReportDefinitionsManager, null))));
7680
+ }, /*#__PURE__*/React__default.createElement(Box, {
7681
+ sx: {
7682
+ display: "flex",
7683
+ justifyContent: "flex-end",
7684
+ p: 1
7685
+ }
7686
+ }, /*#__PURE__*/React__default.createElement(CurrencySelector, null)), /*#__PURE__*/React__default.createElement(ReportDefinitionsManager, null))));
7296
7687
  };
7297
7688
 
7298
7689
  var index = {