@bygd/nc-report-ui 0.1.41 → 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() {
@@ -2528,6 +2529,140 @@ const ProviderSelection = ({
2528
2529
  }, renderSelectionChain()));
2529
2530
  };
2530
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
+
2531
2666
  // Replace {{key}} placeholders in a title with values from the report
2532
2667
  // parameters (e.g. "Balance ({{base_currency}})" -> "Balance (EUR)").
2533
2668
  // Unknown keys are left as-is so missing parameters remain visible.
@@ -2539,6 +2674,50 @@ const interpolateTitle = (template, params) => {
2539
2674
  });
2540
2675
  };
2541
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
+
2542
2721
  // Sortable Chip Component
2543
2722
  const SortableChip$1 = ({
2544
2723
  id,
@@ -2567,20 +2746,8 @@ const SortableChip$1 = ({
2567
2746
  attributes,
2568
2747
  listeners,
2569
2748
  setNodeRef,
2570
- transform,
2571
- transition,
2572
- isDragging
2573
- } = useSortable({
2574
- id
2575
- });
2576
- const style = {
2577
- transform: CSS.Transform.toString(transform),
2578
- transition,
2579
- opacity: isDragging ? 0.5 : 1,
2580
- display: "flex",
2581
- alignItems: "center",
2582
- width: "100%"
2583
- };
2749
+ style
2750
+ } = useSortableRow(id);
2584
2751
 
2585
2752
  // Focus input when entering edit mode
2586
2753
  useEffect(() => {
@@ -2689,21 +2856,9 @@ const SortableChip$1 = ({
2689
2856
  opacity: 1 // show icons on hover
2690
2857
  }
2691
2858
  }
2692
- }, /*#__PURE__*/React__default.createElement(Box$1, _extends({}, listeners, {
2693
- sx: {
2694
- display: "flex",
2695
- alignItems: "center",
2696
- cursor: "grab",
2697
- "&:active": {
2698
- cursor: "grabbing"
2699
- }
2700
- }
2701
- }), /*#__PURE__*/React__default.createElement(DragIndicatorIcon, {
2702
- sx: {
2703
- cursor: "grab",
2704
- color: "rgba(110, 110, 110, 0.62)"
2705
- }
2706
- })), !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, {
2707
2862
  sx: {
2708
2863
  minWidth: 0
2709
2864
  }
@@ -2766,29 +2921,12 @@ const SortableChip$1 = ({
2766
2921
  sx: {
2767
2922
  flex: 1
2768
2923
  }
2769
- }), /*#__PURE__*/React__default.createElement(Box$1, {
2770
- className: "hover-icons",
2771
- sx: {
2772
- display: "flex",
2773
- gap: 0.5,
2774
- opacity: 0,
2775
- transition: "opacity 0.2s"
2776
- }
2777
- }, /*#__PURE__*/React__default.createElement(IconButton$1, {
2778
- size: "small",
2779
- onClick: onMoveUp,
2780
- disabled: isFirst,
2781
- "aria-label": "move up"
2782
- }, /*#__PURE__*/React__default.createElement(ArrowUpwardIcon, {
2783
- fontSize: "small"
2784
- })), /*#__PURE__*/React__default.createElement(IconButton$1, {
2785
- size: "small",
2786
- onClick: onMoveDown,
2787
- disabled: isLast,
2788
- "aria-label": "move down"
2789
- }, /*#__PURE__*/React__default.createElement(ArrowDownwardIcon, {
2790
- fontSize: "small"
2791
- })))) : /*#__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, {
2792
2930
  inputRef: inputRef,
2793
2931
  value: editValue,
2794
2932
  onChange: e => setEditValue(e.target.value),
@@ -2864,24 +3002,12 @@ const SortableComputedChip = ({
2864
3002
  attributes,
2865
3003
  listeners,
2866
3004
  setNodeRef,
2867
- transform,
2868
- transition,
2869
- isDragging
2870
- } = useSortable({
2871
- id
2872
- });
3005
+ style
3006
+ } = useSortableRow(id);
2873
3007
  const [isEditing, setIsEditing] = useState(false);
2874
3008
  const [editName, setEditName] = useState("");
2875
3009
  const [editValue, setEditValue] = useState("");
2876
3010
  const containerRef = useRef(null);
2877
- const style = {
2878
- transform: CSS.Transform.toString(transform),
2879
- transition,
2880
- opacity: isDragging ? 0.5 : 1,
2881
- display: "flex",
2882
- alignItems: "center",
2883
- width: "100%"
2884
- };
2885
3011
 
2886
3012
  // Handle click outside to cancel
2887
3013
  useEffect(() => {
@@ -2944,21 +3070,9 @@ const SortableComputedChip = ({
2944
3070
  opacity: 1
2945
3071
  }
2946
3072
  }
2947
- }, /*#__PURE__*/React__default.createElement(Box$1, _extends({}, listeners, {
2948
- sx: {
2949
- display: "flex",
2950
- alignItems: "center",
2951
- cursor: "grab",
2952
- "&:active": {
2953
- cursor: "grabbing"
2954
- }
2955
- }
2956
- }), /*#__PURE__*/React__default.createElement(DragIndicatorIcon, {
2957
- sx: {
2958
- cursor: "grab",
2959
- color: "rgba(110, 110, 110, 0.62)"
2960
- }
2961
- })), !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, {
2962
3076
  icon: /*#__PURE__*/React__default.createElement(FunctionsIcon, {
2963
3077
  sx: {
2964
3078
  fontSize: "15px !important"
@@ -3022,29 +3136,12 @@ const SortableComputedChip = ({
3022
3136
  sx: {
3023
3137
  flex: 1
3024
3138
  }
3025
- }), /*#__PURE__*/React__default.createElement(Box$1, {
3026
- className: "hover-icons",
3027
- sx: {
3028
- display: "flex",
3029
- gap: 0.5,
3030
- opacity: 0,
3031
- transition: "opacity 0.2s"
3032
- }
3033
- }, /*#__PURE__*/React__default.createElement(IconButton$1, {
3034
- size: "small",
3035
- onClick: onMoveUp,
3036
- disabled: isFirst,
3037
- "aria-label": "move up"
3038
- }, /*#__PURE__*/React__default.createElement(ArrowUpwardIcon, {
3039
- fontSize: "small"
3040
- })), /*#__PURE__*/React__default.createElement(IconButton$1, {
3041
- size: "small",
3042
- onClick: onMoveDown,
3043
- disabled: isLast,
3044
- "aria-label": "move down"
3045
- }, /*#__PURE__*/React__default.createElement(ArrowDownwardIcon, {
3046
- fontSize: "small"
3047
- })))) : /*#__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, {
3048
3145
  value: editName,
3049
3146
  onChange: e => setEditName(e.target.value),
3050
3147
  onKeyDown: handleKeyDown,
@@ -3129,9 +3226,7 @@ const Dimensions = ({
3129
3226
  const [computedValue, setComputedValue] = useState("");
3130
3227
 
3131
3228
  // Setup drag and drop sensors
3132
- const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, {
3133
- coordinateGetter: sortableKeyboardCoordinates
3134
- }));
3229
+ const sensors = useSortableListSensors();
3135
3230
 
3136
3231
  // Resolve the unified dimensionOrder into the actual dimension / computed
3137
3232
  // dimension objects, so dimensions and computed dimensions can be shown
@@ -3148,32 +3243,12 @@ const Dimensions = ({
3148
3243
  data: computedDimensionByName[entry.key]
3149
3244
  }).filter(item => item.data);
3150
3245
 
3151
- // Handle drag end for the unified list
3152
- const handleDragEnd = event => {
3153
- const {
3154
- active,
3155
- over
3156
- } = event;
3157
- if (over && active.id !== over.id) {
3158
- const oldIndex = dimensionOrder.findIndex((_, i) => i === active.id);
3159
- const newIndex = dimensionOrder.findIndex((_, i) => i === over.id);
3160
- onReorderDimensionItems(arrayMove(dimensionOrder, oldIndex, newIndex));
3161
- }
3162
- };
3163
-
3164
- // Handle move up
3165
- const handleMoveUp = index => {
3166
- if (index > 0) {
3167
- onReorderDimensionItems(arrayMove(dimensionOrder, index, index - 1));
3168
- }
3169
- };
3170
-
3171
- // Handle move down
3172
- const handleMoveDown = index => {
3173
- if (index < dimensionOrder.length - 1) {
3174
- onReorderDimensionItems(arrayMove(dimensionOrder, index, index + 1));
3175
- }
3176
- };
3246
+ // Handle drag/move for the unified list
3247
+ const {
3248
+ handleDragEnd,
3249
+ handleMoveUp,
3250
+ handleMoveDown
3251
+ } = makeListReorderHandlers(dimensionOrder, onReorderDimensionItems);
3177
3252
 
3178
3253
  // Handle sort order change (dimensions only - computed dimensions have no order_by)
3179
3254
  const handleSortOrderChange = (fullPath, sortOrder) => {
@@ -3666,19 +3741,10 @@ const Dimensions = ({
3666
3741
  color: "#666",
3667
3742
  marginBottom: 2
3668
3743
  }
3669
- }, "Drag to reorder or use arrows"), /*#__PURE__*/React__default.createElement(DndContext, {
3744
+ }, "Drag to reorder or use arrows"), /*#__PURE__*/React__default.createElement(SortableListContext, {
3670
3745
  sensors: sensors,
3671
- collisionDetection: closestCenter,
3746
+ itemIds: orderedItems.map((_, index) => index),
3672
3747
  onDragEnd: handleDragEnd
3673
- }, /*#__PURE__*/React__default.createElement(SortableContext, {
3674
- items: orderedItems.map((_, index) => index),
3675
- strategy: verticalListSortingStrategy
3676
- }, /*#__PURE__*/React__default.createElement(Box$1, {
3677
- sx: {
3678
- display: "flex",
3679
- flexDirection: "column",
3680
- gap: 1
3681
- }
3682
3748
  }, orderedItems.map((item, index) => item.kind === "dimension" ? /*#__PURE__*/React__default.createElement(SortableChip$1, {
3683
3749
  key: `dim-${item.key}`,
3684
3750
  id: index,
@@ -3708,7 +3774,7 @@ const Dimensions = ({
3708
3774
  onMoveDown: () => handleMoveDown(index),
3709
3775
  isFirst: index === 0,
3710
3776
  isLast: index === orderedItems.length - 1
3711
- })))))));
3777
+ })))));
3712
3778
  };
3713
3779
 
3714
3780
  const MetricSourceIcon = ({
@@ -3783,20 +3849,8 @@ const SortableChip = ({
3783
3849
  attributes,
3784
3850
  listeners,
3785
3851
  setNodeRef,
3786
- transform,
3787
- transition,
3788
- isDragging
3789
- } = useSortable({
3790
- id
3791
- });
3792
- const style = {
3793
- transform: CSS.Transform.toString(transform),
3794
- transition,
3795
- opacity: isDragging ? 0.5 : 1,
3796
- display: 'flex',
3797
- alignItems: 'center',
3798
- width: '100%'
3799
- };
3852
+ style
3853
+ } = useSortableRow(id);
3800
3854
 
3801
3855
  // Focus input when entering edit mode
3802
3856
  useEffect(() => {
@@ -3874,21 +3928,9 @@ const SortableChip = ({
3874
3928
  opacity: 1 // show icons on hover
3875
3929
  }
3876
3930
  }
3877
- }, /*#__PURE__*/React__default.createElement(Box$1, _extends({}, listeners, {
3878
- sx: {
3879
- display: "flex",
3880
- alignItems: "center",
3881
- cursor: "grab",
3882
- "&:active": {
3883
- cursor: "grabbing"
3884
- }
3885
- }
3886
- }), /*#__PURE__*/React__default.createElement(DragIndicatorIcon, {
3887
- sx: {
3888
- cursor: "grab",
3889
- color: "rgba(110, 110, 110, 0.62)"
3890
- }
3891
- })), !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, {
3892
3934
  sx: {
3893
3935
  display: 'flex',
3894
3936
  alignItems: 'center',
@@ -3950,29 +3992,12 @@ const SortableChip = ({
3950
3992
  sx: {
3951
3993
  flex: 1
3952
3994
  }
3953
- }), /*#__PURE__*/React__default.createElement(Box$1, {
3954
- className: "hover-icons",
3955
- sx: {
3956
- display: "flex",
3957
- gap: 0.5,
3958
- opacity: 0,
3959
- transition: "opacity 0.2s"
3960
- }
3961
- }, /*#__PURE__*/React__default.createElement(IconButton$1, {
3962
- size: "small",
3963
- onClick: onMoveUp,
3964
- disabled: isFirst,
3965
- "aria-label": "move up"
3966
- }, /*#__PURE__*/React__default.createElement(ArrowUpwardIcon, {
3967
- fontSize: "small"
3968
- })), /*#__PURE__*/React__default.createElement(IconButton$1, {
3969
- size: "small",
3970
- onClick: onMoveDown,
3971
- disabled: isLast,
3972
- "aria-label": "move down"
3973
- }, /*#__PURE__*/React__default.createElement(ArrowDownwardIcon, {
3974
- fontSize: "small"
3975
- })))) : /*#__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, {
3976
4001
  inputRef: inputRef,
3977
4002
  value: editValue,
3978
4003
  onChange: e => setEditValue(e.target.value),
@@ -4047,39 +4072,14 @@ const Metrics = ({
4047
4072
  const [selectedMetric, setSelectedMetric] = useState('');
4048
4073
 
4049
4074
  // Setup drag and drop sensors
4050
- const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, {
4051
- coordinateGetter: sortableKeyboardCoordinates
4052
- }));
4053
-
4054
- // Handle drag end
4055
- const handleDragEnd = event => {
4056
- const {
4057
- active,
4058
- over
4059
- } = event;
4060
- if (over && active.id !== over.id) {
4061
- const oldIndex = savedMetrics.findIndex((_, i) => i === active.id);
4062
- const newIndex = savedMetrics.findIndex((_, i) => i === over.id);
4063
- const newOrder = arrayMove(savedMetrics, oldIndex, newIndex);
4064
- onReorderMetrics(newOrder);
4065
- }
4066
- };
4075
+ const sensors = useSortableListSensors();
4067
4076
 
4068
- // Handle move up
4069
- const handleMoveUp = index => {
4070
- if (index > 0) {
4071
- const newOrder = arrayMove(savedMetrics, index, index - 1);
4072
- onReorderMetrics(newOrder);
4073
- }
4074
- };
4075
-
4076
- // Handle move down
4077
- const handleMoveDown = index => {
4078
- if (index < savedMetrics.length - 1) {
4079
- const newOrder = arrayMove(savedMetrics, index, index + 1);
4080
- onReorderMetrics(newOrder);
4081
- }
4082
- };
4077
+ // Handle drag/move
4078
+ const {
4079
+ handleDragEnd,
4080
+ handleMoveUp,
4081
+ handleMoveDown
4082
+ } = makeListReorderHandlers(savedMetrics, onReorderMetrics);
4083
4083
 
4084
4084
  // Get the current provider based on selection chain
4085
4085
  const getCurrentProvider = () => {
@@ -4418,19 +4418,10 @@ const Metrics = ({
4418
4418
  marginBottom: 1,
4419
4419
  color: "rgb(37, 37, 37)"
4420
4420
  }
4421
- }, "Saved Metrics"), /*#__PURE__*/React__default.createElement(DndContext, {
4421
+ }, "Saved Metrics"), /*#__PURE__*/React__default.createElement(SortableListContext, {
4422
4422
  sensors: sensors,
4423
- collisionDetection: closestCenter,
4423
+ itemIds: savedMetrics.map((_, index) => index),
4424
4424
  onDragEnd: handleDragEnd
4425
- }, /*#__PURE__*/React__default.createElement(SortableContext, {
4426
- items: savedMetrics.map((_, index) => index),
4427
- strategy: verticalListSortingStrategy
4428
- }, /*#__PURE__*/React__default.createElement(Box$1, {
4429
- sx: {
4430
- display: 'flex',
4431
- flexDirection: 'column',
4432
- gap: 1
4433
- }
4434
4425
  }, savedMetrics.map((metric, index) => /*#__PURE__*/React__default.createElement(SortableChip, {
4435
4426
  key: index,
4436
4427
  id: index,
@@ -4447,7 +4438,7 @@ const Metrics = ({
4447
4438
  onUpdateTitle: onUpdateTitle,
4448
4439
  onResetTitle: onResetTitle,
4449
4440
  source: metric.metric?.source
4450
- })))))));
4441
+ })))));
4451
4442
  };
4452
4443
 
4453
4444
  const Filters = ({
@@ -5674,6 +5665,205 @@ const Filters = ({
5674
5665
  }))));
5675
5666
  };
5676
5667
 
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
+
5677
5867
  // Default numeral.js formats applied when a dimension/metric definition has a
5678
5868
  // known type but no explicit `format` set on the provider.
5679
5869
  const DEFAULT_FORMATS_BY_TYPE = {
@@ -5688,10 +5878,107 @@ const formatValue = (value, def) => {
5688
5878
  return def?.prefix ? `${def.prefix} ${formatted}` : formatted;
5689
5879
  };
5690
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
+ };
5691
5979
  const ReportDataGrid = ({
5692
5980
  reportData,
5693
- orderedDimensionItems = [],
5694
- metrics,
5981
+ columnItems = [],
5695
5982
  loading,
5696
5983
  onPageChange,
5697
5984
  totalRows = 0,
@@ -5707,127 +5994,10 @@ const ReportDataGrid = ({
5707
5994
  pageSize: 50
5708
5995
  });
5709
5996
 
5710
- // Generate columns dynamically from dimensions and metrics
5711
- const columns = React__default.useMemo(() => {
5712
- const cols = [];
5713
-
5714
- // Add dimension + computed-dimension columns, in the single unified
5715
- // order the user arranged them in on the Dimensions tab.
5716
- // Field naming logic (dimensions):
5717
- // - If from base provider: baseAlias.field -> baseAlias_field
5718
- // - If from nested provider: baseAlias_rel1_rel2.field -> rel1_rel2_field (drops base alias)
5719
- // Computed dimensions have no provider/relations chain, so the API
5720
- // returns each row keyed by the computed dimension's own `name`.
5721
- orderedDimensionItems.forEach(item => {
5722
- if (item.kind === 'computed') {
5723
- const cd = item.data;
5724
- cols.push({
5725
- field: cd.name,
5726
- headerName: cd.name,
5727
- flex: 1,
5728
- minWidth: 150,
5729
- type: 'string'
5730
- });
5731
- return;
5732
- }
5733
- const dim = item.data;
5734
- let fieldName;
5735
- let headerName;
5736
-
5737
- // Check if there are relations (nested providers)
5738
- if (dim.relations && dim.relations.length > 0) {
5739
- // From nested provider: drop the base provider alias
5740
- // Example: ft_fa_db.currency -> fa_db_currency
5741
- const parts = dim.fullPath.split('.');
5742
- const pathWithoutField = parts[0]; // e.g., "ft_fa_db"
5743
- const field = parts[1]; // e.g., "currency"
5744
-
5745
- // Remove the base provider alias (first part before first underscore)
5746
- const pathParts = pathWithoutField.split('_');
5747
- pathParts.shift(); // Remove base provider alias
5748
- const pathWithoutBase = pathParts.join('_'); // e.g., "fa_db"
5749
-
5750
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
5751
- } else {
5752
- // From base provider: keep the full path with underscore
5753
- // Example: ba.created_at -> ba_created_at
5754
- fieldName = dim.fullPath.replace('.', '_');
5755
- }
5756
-
5757
- // Check for title override, otherwise use the friendly dimension title from provider
5758
- headerName = titleOverrides.dimensions[dim.fullPath] || dim.dimensionTitle || fieldName;
5759
- headerName = interpolateTitle(headerName, parameters);
5760
- const dimDef = dim.dimension;
5761
- cols.push({
5762
- field: fieldName,
5763
- headerName: headerName,
5764
- flex: 1,
5765
- minWidth: 150,
5766
- type: isNumericType(dimDef?.type) ? 'number' : 'string',
5767
- valueFormatter: value => formatValue(value, dimDef)
5768
- });
5769
- });
5770
-
5771
- // Add metric columns
5772
- // The API returns metric keys in two different forms depending on the path depth:
5773
- //
5774
- // • 2-part path "mc.record_count" → API key: "record_count"
5775
- // • 2-part path "mc_fa.total_amount" (nested) → API key: "fa_total_amount"
5776
- // • 3+-part path "mc.dkpi.activeAgreementsCount"→ API key: "mc.dkpi.activeAgreementsCount" (dots)
5777
- //
5778
- // For 3+-part paths the API key contains dots. MUI DataGrid interprets dots
5779
- // in field names as nested-object accessors, so the rows useMemo normalises
5780
- // those keys (dots → underscores). The column field must match that normalised key.
5781
- metrics.forEach(metric => {
5782
- const metricDef = metric.metric;
5783
- let fieldName;
5784
- let headerName;
5785
- const dotCount = (metric.fullPath.match(/\./g) || []).length;
5786
- if (dotCount >= 2) {
5787
- // 3+-part namespaced path: API returns the full dotted path as the key.
5788
- // Normalise dots to underscores to match the row key normalisation below.
5789
- // Example: "mc.dkpi.activeAgreementsCount" -> "mc_dkpi_activeAgreementsCount"
5790
- fieldName = metric.fullPath.replace(/\./g, '_');
5791
- } else if (metric.relations && metric.relations.length > 0) {
5792
- // 2-part path from a nested provider: API drops the base provider alias.
5793
- // Example: "mc_fa.total_amount" -> "fa_total_amount"
5794
- const parts = metric.fullPath.split('.');
5795
- const pathWithoutField = parts[0]; // e.g., "mc_fa"
5796
- const field = parts[1]; // e.g., "total_amount"
5797
-
5798
- const pathParts = pathWithoutField.split('_');
5799
- pathParts.shift(); // remove base provider alias
5800
- const pathWithoutBase = pathParts.join('_');
5801
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
5802
- } else {
5803
- // 2-part path from the base provider: API returns just the metric name.
5804
- // Example: "mc.record_count" -> "record_count"
5805
- fieldName = metric.metricName;
5806
- }
5807
-
5808
- // Check for title override, otherwise use the friendly metric title from provider
5809
- headerName = titleOverrides.metrics[metric.fullPath] || metric.metricTitle || fieldName;
5810
- headerName = interpolateTitle(headerName, parameters);
5811
- cols.push({
5812
- field: fieldName,
5813
- headerName: headerName,
5814
- flex: 1,
5815
- minWidth: 150,
5816
- type: isNumericType(metricDef?.type) ? 'number' : 'string',
5817
- renderHeader: () => /*#__PURE__*/React__default.createElement(Box$1, {
5818
- sx: {
5819
- display: 'flex',
5820
- alignItems: 'center',
5821
- gap: 0.5
5822
- }
5823
- }, /*#__PURE__*/React__default.createElement(MetricSourceIcon, {
5824
- source: metricDef?.source
5825
- }), /*#__PURE__*/React__default.createElement("span", null, headerName)),
5826
- valueFormatter: value => formatValue(value, metricDef)
5827
- });
5828
- });
5829
- return cols;
5830
- }, [orderedDimensionItems, metrics, 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]);
5831
6001
 
5832
6002
  // Transform report data to rows with unique IDs.
5833
6003
  // Keys are normalised by replacing dots with underscores so that MUI DataGrid
@@ -5937,7 +6107,12 @@ const ReportBuilder = ({
5937
6107
  computedDimensions: [],
5938
6108
  // Unified display/execution order across dimensions + computedDimensions.
5939
6109
  // Entries: { type: 'dimension', key: fullPath } | { type: 'computed', key: name }
5940
- dimensionOrder: []
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: []
5941
6116
  });
5942
6117
  const [titleOverrides, setTitleOverrides] = useState({
5943
6118
  dimensions: {},
@@ -6168,6 +6343,46 @@ const ReportBuilder = ({
6168
6343
  });
6169
6344
  return dimensionOrder;
6170
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;
6171
6386
  const loadReportDefinition = async id => {
6172
6387
  try {
6173
6388
  console.log("Loading report definition:", id);
@@ -6264,6 +6479,7 @@ const ReportBuilder = ({
6264
6479
  // provider/relation chain, so they're loaded as-is (no reconstruction needed)
6265
6480
  const computedDimensions = reportDef.definition?.doc?.query?.computed_dimensions || [];
6266
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);
6267
6483
 
6268
6484
  // Set the report state
6269
6485
  setReport({
@@ -6271,7 +6487,8 @@ const ReportBuilder = ({
6271
6487
  metrics: reconstructedMetrics,
6272
6488
  filters: loadedFilters,
6273
6489
  computedDimensions,
6274
- dimensionOrder
6490
+ dimensionOrder,
6491
+ columnOrder
6275
6492
  });
6276
6493
 
6277
6494
  // Set title overrides
@@ -6381,6 +6598,7 @@ const ReportBuilder = ({
6381
6598
  // provider/relation chain, so they're loaded as-is (no reconstruction needed)
6382
6599
  const computedDimensions = reportDef.definition?.doc?.query?.computed_dimensions || [];
6383
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);
6384
6602
 
6385
6603
  // Set the report state
6386
6604
  setReport({
@@ -6388,7 +6606,8 @@ const ReportBuilder = ({
6388
6606
  metrics: reconstructedMetrics,
6389
6607
  filters: loadedFilters,
6390
6608
  computedDimensions,
6391
- dimensionOrder
6609
+ dimensionOrder,
6610
+ columnOrder
6392
6611
  });
6393
6612
 
6394
6613
  // Set title overrides
@@ -6422,7 +6641,8 @@ const ReportBuilder = ({
6422
6641
  metrics: [],
6423
6642
  filters: {},
6424
6643
  computedDimensions: [],
6425
- dimensionOrder: []
6644
+ dimensionOrder: [],
6645
+ columnOrder: []
6426
6646
  });
6427
6647
  // Reset title overrides
6428
6648
  setTitleOverrides({
@@ -6503,7 +6723,11 @@ const ReportBuilder = ({
6503
6723
  dimensionOrder: [...prev.dimensionOrder, {
6504
6724
  type: "dimension",
6505
6725
  key: dimensionData.fullPath
6506
- }]
6726
+ }],
6727
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
6728
+ type: "dimension",
6729
+ key: dimensionData.fullPath
6730
+ })
6507
6731
  };
6508
6732
  console.log("Dimension saved:", dimensionData);
6509
6733
  console.log("Complete report:", newReport);
@@ -6514,7 +6738,8 @@ const ReportBuilder = ({
6514
6738
  setReport(prev => ({
6515
6739
  ...prev,
6516
6740
  dimensions: prev.dimensions.filter(d => d.fullPath !== fullPath),
6517
- dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "dimension" && entry.key === 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))
6518
6743
  }));
6519
6744
  };
6520
6745
  const handleUpdateDimensionSortOrder = (fullPath, sortOrder) => {
@@ -6543,7 +6768,11 @@ const ReportBuilder = ({
6543
6768
  dimensionOrder: [...prev.dimensionOrder, {
6544
6769
  type: "computed",
6545
6770
  key: computedDimensionData.name
6546
- }]
6771
+ }],
6772
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
6773
+ type: "computed",
6774
+ key: computedDimensionData.name
6775
+ })
6547
6776
  }));
6548
6777
  };
6549
6778
  const handleUpdateComputedDimension = (name, computedDimensionData) => {
@@ -6553,6 +6782,10 @@ const ReportBuilder = ({
6553
6782
  dimensionOrder: prev.dimensionOrder.map(entry => entry.type === "computed" && entry.key === name ? {
6554
6783
  ...entry,
6555
6784
  key: computedDimensionData.name
6785
+ } : entry),
6786
+ columnOrder: prev.columnOrder.map(entry => entry.type === "computed" && entry.key === name ? {
6787
+ ...entry,
6788
+ key: computedDimensionData.name
6556
6789
  } : entry)
6557
6790
  }));
6558
6791
  };
@@ -6560,14 +6793,19 @@ const ReportBuilder = ({
6560
6793
  setReport(prev => ({
6561
6794
  ...prev,
6562
6795
  computedDimensions: prev.computedDimensions.filter(cd => cd.name !== name),
6563
- dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "computed" && entry.key === 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))
6564
6798
  }));
6565
6799
  };
6566
6800
  const handleSaveMetric = metricData => {
6567
6801
  setReport(prev => {
6568
6802
  const newReport = {
6569
6803
  ...prev,
6570
- metrics: [...prev.metrics, metricData]
6804
+ metrics: [...prev.metrics, metricData],
6805
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
6806
+ type: "metric",
6807
+ key: metricData.fullPath
6808
+ })
6571
6809
  };
6572
6810
  console.log("Metric saved:", metricData);
6573
6811
  console.log("Complete report:", newReport);
@@ -6575,15 +6813,29 @@ const ReportBuilder = ({
6575
6813
  });
6576
6814
  };
6577
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 => {
6578
6826
  setReport(prev => ({
6579
6827
  ...prev,
6580
- metrics: prev.metrics.filter((_, i) => i !== index)
6828
+ metrics: newOrder
6581
6829
  }));
6582
6830
  };
6583
- 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 => {
6584
6836
  setReport(prev => ({
6585
6837
  ...prev,
6586
- metrics: newOrder
6838
+ columnOrder: newColumnOrder
6587
6839
  }));
6588
6840
  };
6589
6841
  const handleSaveFilter = (fullPath, filterData) => {
@@ -6708,6 +6960,17 @@ const ReportBuilder = ({
6708
6960
  }));
6709
6961
  }
6710
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
+
6711
6974
  // Only add titles if there are any overrides
6712
6975
  if (Object.keys(titles).length > 0) {
6713
6976
  queryObj.titles = titles;
@@ -6767,8 +7030,8 @@ const ReportBuilder = ({
6767
7030
  const handleRunReport = async () => {
6768
7031
  setCurrentPage(0);
6769
7032
  await runReportWithPagination(0, pageSize);
6770
- // Switch to Results tab after running report (now index 3 after adding Filters tab)
6771
- setActiveTab(3);
7033
+ // Switch to Results tab after running report (index 4: Dimensions, Metrics, Filters, Column Order, Results)
7034
+ setActiveTab(4);
6772
7035
  };
6773
7036
  const handleDownloadReport = async () => {
6774
7037
  try {
@@ -6875,6 +7138,11 @@ const ReportBuilder = ({
6875
7138
  kind: "computed",
6876
7139
  data: computedDimensionByName[entry.key]
6877
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);
6878
7146
  return /*#__PURE__*/React__default.createElement(Box$1, {
6879
7147
  sx: {
6880
7148
  p: 3,
@@ -7129,9 +7397,36 @@ const ReportBuilder = ({
7129
7397
  }
7130
7398
  }
7131
7399
  }), /*#__PURE__*/React__default.createElement(Tab, {
7132
- 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")),
7133
7413
  id: "report-tab-3",
7134
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",
7135
7430
  disabled: !reportData,
7136
7431
  sx: {
7137
7432
  height: "41px",
@@ -7198,10 +7493,18 @@ const ReportBuilder = ({
7198
7493
  })), /*#__PURE__*/React__default.createElement(TabPanel, {
7199
7494
  value: activeTab,
7200
7495
  index: 3
7201
- }, reportData && /*#__PURE__*/React__default.createElement(ReportDataGrid, {
7202
- reportData: reportData,
7496
+ }, /*#__PURE__*/React__default.createElement(ColumnOrder, {
7203
7497
  orderedDimensionItems: orderedDimensionItems,
7204
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
7505
+ }, reportData && /*#__PURE__*/React__default.createElement(ReportDataGrid, {
7506
+ reportData: reportData,
7507
+ columnItems: columnItems,
7205
7508
  loading: loading,
7206
7509
  onPageChange: handlePageChange,
7207
7510
  totalRows: totalRows,
@@ -7289,6 +7592,12 @@ const CurrencySelector = () => {
7289
7592
  parameters,
7290
7593
  setParameters
7291
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
+ }
7292
7601
  return /*#__PURE__*/React__default.createElement(SingleSelect, {
7293
7602
  items: CURRENCY_OPTIONS,
7294
7603
  value: parameters.base_currency,