@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.
@@ -67,6 +67,7 @@ var LocalizationProvider = require('@mui/x-date-pickers/LocalizationProvider');
67
67
  var DatePicker = require('@mui/x-date-pickers/DatePicker');
68
68
  var AdapterDayjs = require('@mui/x-date-pickers/AdapterDayjs');
69
69
  var dayjs = require('dayjs');
70
+ var CalculateIcon = require('@mui/icons-material/Calculate');
70
71
  var styles = require('@mui/material/styles');
71
72
 
72
73
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
@@ -144,6 +145,7 @@ var BoltIcon__default = /*#__PURE__*/_interopDefault(BoltIcon);
144
145
  var FilterAltIcon__default = /*#__PURE__*/_interopDefault(FilterAltIcon);
145
146
  var InfoOutlinedIcon__default = /*#__PURE__*/_interopDefault(InfoOutlinedIcon);
146
147
  var dayjs__default = /*#__PURE__*/_interopDefault(dayjs);
148
+ var CalculateIcon__default = /*#__PURE__*/_interopDefault(CalculateIcon);
147
149
 
148
150
  function _extends() {
149
151
  return _extends = Object.assign ? Object.assign.bind() : function (n) {
@@ -2607,6 +2609,140 @@ const ProviderSelection = ({
2607
2609
  }, renderSelectionChain()));
2608
2610
  };
2609
2611
 
2612
+ // Shared drag-and-drop primitives for the index-identified, arrow-reorderable
2613
+ // lists used across the report builder (Dimensions, Metrics, Column Order).
2614
+ // Previously duplicated per-tab; consolidated here so all three behave and
2615
+ // look identical and only need to be fixed in one place.
2616
+
2617
+ const useSortableListSensors = () => core.useSensors(core.useSensor(core.PointerSensor), core.useSensor(core.KeyboardSensor, {
2618
+ coordinateGetter: sortable.sortableKeyboardCoordinates
2619
+ }));
2620
+
2621
+ // Builds drag/move handlers for a list where dnd-kit item ids are the array
2622
+ // index. `onReorder` receives the whole reordered list.
2623
+ const makeListReorderHandlers = (list, onReorder) => {
2624
+ const handleDragEnd = ({
2625
+ active,
2626
+ over
2627
+ }) => {
2628
+ if (over && active.id !== over.id) {
2629
+ const oldIndex = list.findIndex((_, i) => i === active.id);
2630
+ const newIndex = list.findIndex((_, i) => i === over.id);
2631
+ onReorder(sortable.arrayMove(list, oldIndex, newIndex));
2632
+ }
2633
+ };
2634
+ const handleMoveUp = index => {
2635
+ if (index > 0) {
2636
+ onReorder(sortable.arrayMove(list, index, index - 1));
2637
+ }
2638
+ };
2639
+ const handleMoveDown = index => {
2640
+ if (index < list.length - 1) {
2641
+ onReorder(sortable.arrayMove(list, index, index + 1));
2642
+ }
2643
+ };
2644
+ return {
2645
+ handleDragEnd,
2646
+ handleMoveUp,
2647
+ handleMoveDown
2648
+ };
2649
+ };
2650
+
2651
+ // Wraps DndContext + SortableContext + the vertical list container.
2652
+ // `itemIds` must line up with the ids passed to each item's useSortable().
2653
+ const SortableListContext = ({
2654
+ sensors,
2655
+ itemIds,
2656
+ onDragEnd,
2657
+ children
2658
+ }) => /*#__PURE__*/React__namespace.default.createElement(core.DndContext, {
2659
+ sensors: sensors,
2660
+ collisionDetection: core.closestCenter,
2661
+ onDragEnd: onDragEnd
2662
+ }, /*#__PURE__*/React__namespace.default.createElement(sortable.SortableContext, {
2663
+ items: itemIds,
2664
+ strategy: sortable.verticalListSortingStrategy
2665
+ }, /*#__PURE__*/React__namespace.default.createElement(material.Box, {
2666
+ sx: {
2667
+ display: "flex",
2668
+ flexDirection: "column",
2669
+ gap: 1
2670
+ }
2671
+ }, children)));
2672
+
2673
+ // Per-row useSortable() wiring shared by every sortable chip/row component.
2674
+ const useSortableRow = id => {
2675
+ const {
2676
+ attributes,
2677
+ listeners,
2678
+ setNodeRef,
2679
+ transform,
2680
+ transition,
2681
+ isDragging
2682
+ } = sortable.useSortable({
2683
+ id
2684
+ });
2685
+ const style = {
2686
+ transform: utilities.CSS.Transform.toString(transform),
2687
+ transition,
2688
+ opacity: isDragging ? 0.5 : 1,
2689
+ display: "flex",
2690
+ alignItems: "center",
2691
+ width: "100%"
2692
+ };
2693
+ return {
2694
+ attributes,
2695
+ listeners,
2696
+ setNodeRef,
2697
+ style
2698
+ };
2699
+ };
2700
+ const DragHandle = ({
2701
+ listeners
2702
+ }) => /*#__PURE__*/React__namespace.default.createElement(material.Box, _extends({}, listeners, {
2703
+ sx: {
2704
+ display: "flex",
2705
+ alignItems: "center",
2706
+ cursor: "grab",
2707
+ "&:active": {
2708
+ cursor: "grabbing"
2709
+ }
2710
+ }
2711
+ }), /*#__PURE__*/React__namespace.default.createElement(DragIndicatorIcon__default.default, {
2712
+ sx: {
2713
+ cursor: "grab",
2714
+ color: "rgba(110, 110, 110, 0.62)"
2715
+ }
2716
+ }));
2717
+ const MoveButtons = ({
2718
+ onMoveUp,
2719
+ onMoveDown,
2720
+ isFirst,
2721
+ isLast
2722
+ }) => /*#__PURE__*/React__namespace.default.createElement(material.Box, {
2723
+ className: "hover-icons",
2724
+ sx: {
2725
+ display: "flex",
2726
+ gap: 0.5,
2727
+ opacity: 0,
2728
+ transition: "opacity 0.2s"
2729
+ }
2730
+ }, /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
2731
+ size: "small",
2732
+ onClick: onMoveUp,
2733
+ disabled: isFirst,
2734
+ "aria-label": "move up"
2735
+ }, /*#__PURE__*/React__namespace.default.createElement(ArrowUpwardIcon__default.default, {
2736
+ fontSize: "small"
2737
+ })), /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
2738
+ size: "small",
2739
+ onClick: onMoveDown,
2740
+ disabled: isLast,
2741
+ "aria-label": "move down"
2742
+ }, /*#__PURE__*/React__namespace.default.createElement(ArrowDownwardIcon__default.default, {
2743
+ fontSize: "small"
2744
+ })));
2745
+
2610
2746
  // Replace {{key}} placeholders in a title with values from the report
2611
2747
  // parameters (e.g. "Balance ({{base_currency}})" -> "Balance (EUR)").
2612
2748
  // Unknown keys are left as-is so missing parameters remain visible.
@@ -2618,6 +2754,50 @@ const interpolateTitle = (template, params) => {
2618
2754
  });
2619
2755
  };
2620
2756
 
2757
+ // Resolves the final column display order for the results grid: dimensions,
2758
+ // computed dimensions, and metrics combined into a single list.
2759
+ //
2760
+ // `columnOrder` is the optional, explicitly-arranged order (raw entries
2761
+ // `{ type: 'dimension' | 'computed' | 'metric', key }`) set on the Column
2762
+ // Order tab. When present, its entries come first, in that order. Anything
2763
+ // not covered by it (new fields added since it was last touched, or when
2764
+ // it's empty/undefined because no custom order has ever been defined) is
2765
+ // appended in the pre-existing default order: dimensions/computed (in their
2766
+ // own `dimensionOrder`) first, then metrics (in their own array order) —
2767
+ // this keeps behavior unchanged for reports that never define a column order.
2768
+ const resolveColumnOrder = (columnOrder, orderedDimensionItems, metrics) => {
2769
+ // Normalise every candidate item to a uniform { kind, key, data } shape
2770
+ // up front, so callers (e.g. the Column Order tab) can always read
2771
+ // `.key` regardless of where the item came from.
2772
+ const normalizedDimensionItems = orderedDimensionItems.map(item => ({
2773
+ kind: item.kind,
2774
+ key: item.kind === 'computed' ? item.data.name : item.data.fullPath,
2775
+ data: item.data
2776
+ }));
2777
+ const normalizedMetricItems = metrics.map(m => ({
2778
+ kind: 'metric',
2779
+ key: m.fullPath,
2780
+ data: m
2781
+ }));
2782
+ const byKey = Object.fromEntries([...normalizedDimensionItems, ...normalizedMetricItems].map(item => [`${item.kind}:${item.key}`, item]));
2783
+ const resolved = [];
2784
+ const seen = new Set();
2785
+ (columnOrder || []).forEach(entry => {
2786
+ const seenKey = `${entry.type}:${entry.key}`;
2787
+ if (seen.has(seenKey) || !byKey[seenKey]) return;
2788
+ resolved.push(byKey[seenKey]);
2789
+ seen.add(seenKey);
2790
+ });
2791
+ [...normalizedDimensionItems, ...normalizedMetricItems].forEach(item => {
2792
+ const seenKey = `${item.kind}:${item.key}`;
2793
+ if (!seen.has(seenKey)) {
2794
+ resolved.push(item);
2795
+ seen.add(seenKey);
2796
+ }
2797
+ });
2798
+ return resolved;
2799
+ };
2800
+
2621
2801
  // Sortable Chip Component
2622
2802
  const SortableChip$1 = ({
2623
2803
  id,
@@ -2646,20 +2826,8 @@ const SortableChip$1 = ({
2646
2826
  attributes,
2647
2827
  listeners,
2648
2828
  setNodeRef,
2649
- transform,
2650
- transition,
2651
- isDragging
2652
- } = sortable.useSortable({
2653
- id
2654
- });
2655
- const style = {
2656
- transform: utilities.CSS.Transform.toString(transform),
2657
- transition,
2658
- opacity: isDragging ? 0.5 : 1,
2659
- display: "flex",
2660
- alignItems: "center",
2661
- width: "100%"
2662
- };
2829
+ style
2830
+ } = useSortableRow(id);
2663
2831
 
2664
2832
  // Focus input when entering edit mode
2665
2833
  React.useEffect(() => {
@@ -2768,21 +2936,9 @@ const SortableChip$1 = ({
2768
2936
  opacity: 1 // show icons on hover
2769
2937
  }
2770
2938
  }
2771
- }, /*#__PURE__*/React__namespace.default.createElement(material.Box, _extends({}, listeners, {
2772
- sx: {
2773
- display: "flex",
2774
- alignItems: "center",
2775
- cursor: "grab",
2776
- "&:active": {
2777
- cursor: "grabbing"
2778
- }
2779
- }
2780
- }), /*#__PURE__*/React__namespace.default.createElement(DragIndicatorIcon__default.default, {
2781
- sx: {
2782
- cursor: "grab",
2783
- color: "rgba(110, 110, 110, 0.62)"
2784
- }
2785
- })), !isEditing ? /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.Box, {
2939
+ }, /*#__PURE__*/React__namespace.default.createElement(DragHandle, {
2940
+ listeners: listeners
2941
+ }), !isEditing ? /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.Box, {
2786
2942
  sx: {
2787
2943
  minWidth: 0
2788
2944
  }
@@ -2845,29 +3001,12 @@ const SortableChip$1 = ({
2845
3001
  sx: {
2846
3002
  flex: 1
2847
3003
  }
2848
- }), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
2849
- className: "hover-icons",
2850
- sx: {
2851
- display: "flex",
2852
- gap: 0.5,
2853
- opacity: 0,
2854
- transition: "opacity 0.2s"
2855
- }
2856
- }, /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
2857
- size: "small",
2858
- onClick: onMoveUp,
2859
- disabled: isFirst,
2860
- "aria-label": "move up"
2861
- }, /*#__PURE__*/React__namespace.default.createElement(ArrowUpwardIcon__default.default, {
2862
- fontSize: "small"
2863
- })), /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
2864
- size: "small",
2865
- onClick: onMoveDown,
2866
- disabled: isLast,
2867
- "aria-label": "move down"
2868
- }, /*#__PURE__*/React__namespace.default.createElement(ArrowDownwardIcon__default.default, {
2869
- fontSize: "small"
2870
- })))) : /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
3004
+ }), /*#__PURE__*/React__namespace.default.createElement(MoveButtons, {
3005
+ onMoveUp: onMoveUp,
3006
+ onMoveDown: onMoveDown,
3007
+ isFirst: isFirst,
3008
+ isLast: isLast
3009
+ })) : /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
2871
3010
  inputRef: inputRef,
2872
3011
  value: editValue,
2873
3012
  onChange: e => setEditValue(e.target.value),
@@ -2943,24 +3082,12 @@ const SortableComputedChip = ({
2943
3082
  attributes,
2944
3083
  listeners,
2945
3084
  setNodeRef,
2946
- transform,
2947
- transition,
2948
- isDragging
2949
- } = sortable.useSortable({
2950
- id
2951
- });
3085
+ style
3086
+ } = useSortableRow(id);
2952
3087
  const [isEditing, setIsEditing] = React.useState(false);
2953
3088
  const [editName, setEditName] = React.useState("");
2954
3089
  const [editValue, setEditValue] = React.useState("");
2955
3090
  const containerRef = React.useRef(null);
2956
- const style = {
2957
- transform: utilities.CSS.Transform.toString(transform),
2958
- transition,
2959
- opacity: isDragging ? 0.5 : 1,
2960
- display: "flex",
2961
- alignItems: "center",
2962
- width: "100%"
2963
- };
2964
3091
 
2965
3092
  // Handle click outside to cancel
2966
3093
  React.useEffect(() => {
@@ -3023,21 +3150,9 @@ const SortableComputedChip = ({
3023
3150
  opacity: 1
3024
3151
  }
3025
3152
  }
3026
- }, /*#__PURE__*/React__namespace.default.createElement(material.Box, _extends({}, listeners, {
3027
- sx: {
3028
- display: "flex",
3029
- alignItems: "center",
3030
- cursor: "grab",
3031
- "&:active": {
3032
- cursor: "grabbing"
3033
- }
3034
- }
3035
- }), /*#__PURE__*/React__namespace.default.createElement(DragIndicatorIcon__default.default, {
3036
- sx: {
3037
- cursor: "grab",
3038
- color: "rgba(110, 110, 110, 0.62)"
3039
- }
3040
- })), !isEditing ? /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.Chip, {
3153
+ }, /*#__PURE__*/React__namespace.default.createElement(DragHandle, {
3154
+ listeners: listeners
3155
+ }), !isEditing ? /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.Chip, {
3041
3156
  icon: /*#__PURE__*/React__namespace.default.createElement(FunctionsIcon__default.default, {
3042
3157
  sx: {
3043
3158
  fontSize: "15px !important"
@@ -3101,29 +3216,12 @@ const SortableComputedChip = ({
3101
3216
  sx: {
3102
3217
  flex: 1
3103
3218
  }
3104
- }), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
3105
- className: "hover-icons",
3106
- sx: {
3107
- display: "flex",
3108
- gap: 0.5,
3109
- opacity: 0,
3110
- transition: "opacity 0.2s"
3111
- }
3112
- }, /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
3113
- size: "small",
3114
- onClick: onMoveUp,
3115
- disabled: isFirst,
3116
- "aria-label": "move up"
3117
- }, /*#__PURE__*/React__namespace.default.createElement(ArrowUpwardIcon__default.default, {
3118
- fontSize: "small"
3119
- })), /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
3120
- size: "small",
3121
- onClick: onMoveDown,
3122
- disabled: isLast,
3123
- "aria-label": "move down"
3124
- }, /*#__PURE__*/React__namespace.default.createElement(ArrowDownwardIcon__default.default, {
3125
- fontSize: "small"
3126
- })))) : /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
3219
+ }), /*#__PURE__*/React__namespace.default.createElement(MoveButtons, {
3220
+ onMoveUp: onMoveUp,
3221
+ onMoveDown: onMoveDown,
3222
+ isFirst: isFirst,
3223
+ isLast: isLast
3224
+ })) : /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
3127
3225
  value: editName,
3128
3226
  onChange: e => setEditName(e.target.value),
3129
3227
  onKeyDown: handleKeyDown,
@@ -3208,9 +3306,7 @@ const Dimensions = ({
3208
3306
  const [computedValue, setComputedValue] = React.useState("");
3209
3307
 
3210
3308
  // Setup drag and drop sensors
3211
- const sensors = core.useSensors(core.useSensor(core.PointerSensor), core.useSensor(core.KeyboardSensor, {
3212
- coordinateGetter: sortable.sortableKeyboardCoordinates
3213
- }));
3309
+ const sensors = useSortableListSensors();
3214
3310
 
3215
3311
  // Resolve the unified dimensionOrder into the actual dimension / computed
3216
3312
  // dimension objects, so dimensions and computed dimensions can be shown
@@ -3227,32 +3323,12 @@ const Dimensions = ({
3227
3323
  data: computedDimensionByName[entry.key]
3228
3324
  }).filter(item => item.data);
3229
3325
 
3230
- // Handle drag end for the unified list
3231
- const handleDragEnd = event => {
3232
- const {
3233
- active,
3234
- over
3235
- } = event;
3236
- if (over && active.id !== over.id) {
3237
- const oldIndex = dimensionOrder.findIndex((_, i) => i === active.id);
3238
- const newIndex = dimensionOrder.findIndex((_, i) => i === over.id);
3239
- onReorderDimensionItems(sortable.arrayMove(dimensionOrder, oldIndex, newIndex));
3240
- }
3241
- };
3242
-
3243
- // Handle move up
3244
- const handleMoveUp = index => {
3245
- if (index > 0) {
3246
- onReorderDimensionItems(sortable.arrayMove(dimensionOrder, index, index - 1));
3247
- }
3248
- };
3249
-
3250
- // Handle move down
3251
- const handleMoveDown = index => {
3252
- if (index < dimensionOrder.length - 1) {
3253
- onReorderDimensionItems(sortable.arrayMove(dimensionOrder, index, index + 1));
3254
- }
3255
- };
3326
+ // Handle drag/move for the unified list
3327
+ const {
3328
+ handleDragEnd,
3329
+ handleMoveUp,
3330
+ handleMoveDown
3331
+ } = makeListReorderHandlers(dimensionOrder, onReorderDimensionItems);
3256
3332
 
3257
3333
  // Handle sort order change (dimensions only - computed dimensions have no order_by)
3258
3334
  const handleSortOrderChange = (fullPath, sortOrder) => {
@@ -3745,19 +3821,10 @@ const Dimensions = ({
3745
3821
  color: "#666",
3746
3822
  marginBottom: 2
3747
3823
  }
3748
- }, "Drag to reorder or use arrows"), /*#__PURE__*/React__namespace.default.createElement(core.DndContext, {
3824
+ }, "Drag to reorder or use arrows"), /*#__PURE__*/React__namespace.default.createElement(SortableListContext, {
3749
3825
  sensors: sensors,
3750
- collisionDetection: core.closestCenter,
3826
+ itemIds: orderedItems.map((_, index) => index),
3751
3827
  onDragEnd: handleDragEnd
3752
- }, /*#__PURE__*/React__namespace.default.createElement(sortable.SortableContext, {
3753
- items: orderedItems.map((_, index) => index),
3754
- strategy: sortable.verticalListSortingStrategy
3755
- }, /*#__PURE__*/React__namespace.default.createElement(material.Box, {
3756
- sx: {
3757
- display: "flex",
3758
- flexDirection: "column",
3759
- gap: 1
3760
- }
3761
3828
  }, orderedItems.map((item, index) => item.kind === "dimension" ? /*#__PURE__*/React__namespace.default.createElement(SortableChip$1, {
3762
3829
  key: `dim-${item.key}`,
3763
3830
  id: index,
@@ -3787,7 +3854,7 @@ const Dimensions = ({
3787
3854
  onMoveDown: () => handleMoveDown(index),
3788
3855
  isFirst: index === 0,
3789
3856
  isLast: index === orderedItems.length - 1
3790
- })))))));
3857
+ })))));
3791
3858
  };
3792
3859
 
3793
3860
  const MetricSourceIcon = ({
@@ -3862,20 +3929,8 @@ const SortableChip = ({
3862
3929
  attributes,
3863
3930
  listeners,
3864
3931
  setNodeRef,
3865
- transform,
3866
- transition,
3867
- isDragging
3868
- } = sortable.useSortable({
3869
- id
3870
- });
3871
- const style = {
3872
- transform: utilities.CSS.Transform.toString(transform),
3873
- transition,
3874
- opacity: isDragging ? 0.5 : 1,
3875
- display: 'flex',
3876
- alignItems: 'center',
3877
- width: '100%'
3878
- };
3932
+ style
3933
+ } = useSortableRow(id);
3879
3934
 
3880
3935
  // Focus input when entering edit mode
3881
3936
  React.useEffect(() => {
@@ -3953,21 +4008,9 @@ const SortableChip = ({
3953
4008
  opacity: 1 // show icons on hover
3954
4009
  }
3955
4010
  }
3956
- }, /*#__PURE__*/React__namespace.default.createElement(material.Box, _extends({}, listeners, {
3957
- sx: {
3958
- display: "flex",
3959
- alignItems: "center",
3960
- cursor: "grab",
3961
- "&:active": {
3962
- cursor: "grabbing"
3963
- }
3964
- }
3965
- }), /*#__PURE__*/React__namespace.default.createElement(DragIndicatorIcon__default.default, {
3966
- sx: {
3967
- cursor: "grab",
3968
- color: "rgba(110, 110, 110, 0.62)"
3969
- }
3970
- })), !isEditing ? /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, source && /*#__PURE__*/React__namespace.default.createElement(material.Box, {
4011
+ }, /*#__PURE__*/React__namespace.default.createElement(DragHandle, {
4012
+ listeners: listeners
4013
+ }), !isEditing ? /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, source && /*#__PURE__*/React__namespace.default.createElement(material.Box, {
3971
4014
  sx: {
3972
4015
  display: 'flex',
3973
4016
  alignItems: 'center',
@@ -4029,29 +4072,12 @@ const SortableChip = ({
4029
4072
  sx: {
4030
4073
  flex: 1
4031
4074
  }
4032
- }), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
4033
- className: "hover-icons",
4034
- sx: {
4035
- display: "flex",
4036
- gap: 0.5,
4037
- opacity: 0,
4038
- transition: "opacity 0.2s"
4039
- }
4040
- }, /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
4041
- size: "small",
4042
- onClick: onMoveUp,
4043
- disabled: isFirst,
4044
- "aria-label": "move up"
4045
- }, /*#__PURE__*/React__namespace.default.createElement(ArrowUpwardIcon__default.default, {
4046
- fontSize: "small"
4047
- })), /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
4048
- size: "small",
4049
- onClick: onMoveDown,
4050
- disabled: isLast,
4051
- "aria-label": "move down"
4052
- }, /*#__PURE__*/React__namespace.default.createElement(ArrowDownwardIcon__default.default, {
4053
- fontSize: "small"
4054
- })))) : /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
4075
+ }), /*#__PURE__*/React__namespace.default.createElement(MoveButtons, {
4076
+ onMoveUp: onMoveUp,
4077
+ onMoveDown: onMoveDown,
4078
+ isFirst: isFirst,
4079
+ isLast: isLast
4080
+ })) : /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
4055
4081
  inputRef: inputRef,
4056
4082
  value: editValue,
4057
4083
  onChange: e => setEditValue(e.target.value),
@@ -4126,39 +4152,14 @@ const Metrics = ({
4126
4152
  const [selectedMetric, setSelectedMetric] = React.useState('');
4127
4153
 
4128
4154
  // Setup drag and drop sensors
4129
- const sensors = core.useSensors(core.useSensor(core.PointerSensor), core.useSensor(core.KeyboardSensor, {
4130
- coordinateGetter: sortable.sortableKeyboardCoordinates
4131
- }));
4132
-
4133
- // Handle drag end
4134
- const handleDragEnd = event => {
4135
- const {
4136
- active,
4137
- over
4138
- } = event;
4139
- if (over && active.id !== over.id) {
4140
- const oldIndex = savedMetrics.findIndex((_, i) => i === active.id);
4141
- const newIndex = savedMetrics.findIndex((_, i) => i === over.id);
4142
- const newOrder = sortable.arrayMove(savedMetrics, oldIndex, newIndex);
4143
- onReorderMetrics(newOrder);
4144
- }
4145
- };
4155
+ const sensors = useSortableListSensors();
4146
4156
 
4147
- // Handle move up
4148
- const handleMoveUp = index => {
4149
- if (index > 0) {
4150
- const newOrder = sortable.arrayMove(savedMetrics, index, index - 1);
4151
- onReorderMetrics(newOrder);
4152
- }
4153
- };
4154
-
4155
- // Handle move down
4156
- const handleMoveDown = index => {
4157
- if (index < savedMetrics.length - 1) {
4158
- const newOrder = sortable.arrayMove(savedMetrics, index, index + 1);
4159
- onReorderMetrics(newOrder);
4160
- }
4161
- };
4157
+ // Handle drag/move
4158
+ const {
4159
+ handleDragEnd,
4160
+ handleMoveUp,
4161
+ handleMoveDown
4162
+ } = makeListReorderHandlers(savedMetrics, onReorderMetrics);
4162
4163
 
4163
4164
  // Get the current provider based on selection chain
4164
4165
  const getCurrentProvider = () => {
@@ -4497,19 +4498,10 @@ const Metrics = ({
4497
4498
  marginBottom: 1,
4498
4499
  color: "rgb(37, 37, 37)"
4499
4500
  }
4500
- }, "Saved Metrics"), /*#__PURE__*/React__namespace.default.createElement(core.DndContext, {
4501
+ }, "Saved Metrics"), /*#__PURE__*/React__namespace.default.createElement(SortableListContext, {
4501
4502
  sensors: sensors,
4502
- collisionDetection: core.closestCenter,
4503
+ itemIds: savedMetrics.map((_, index) => index),
4503
4504
  onDragEnd: handleDragEnd
4504
- }, /*#__PURE__*/React__namespace.default.createElement(sortable.SortableContext, {
4505
- items: savedMetrics.map((_, index) => index),
4506
- strategy: sortable.verticalListSortingStrategy
4507
- }, /*#__PURE__*/React__namespace.default.createElement(material.Box, {
4508
- sx: {
4509
- display: 'flex',
4510
- flexDirection: 'column',
4511
- gap: 1
4512
- }
4513
4505
  }, savedMetrics.map((metric, index) => /*#__PURE__*/React__namespace.default.createElement(SortableChip, {
4514
4506
  key: index,
4515
4507
  id: index,
@@ -4526,7 +4518,7 @@ const Metrics = ({
4526
4518
  onUpdateTitle: onUpdateTitle,
4527
4519
  onResetTitle: onResetTitle,
4528
4520
  source: metric.metric?.source
4529
- })))))));
4521
+ })))));
4530
4522
  };
4531
4523
 
4532
4524
  const Filters = ({
@@ -5753,6 +5745,205 @@ const Filters = ({
5753
5745
  }))));
5754
5746
  };
5755
5747
 
5748
+ const KIND_LABEL = {
5749
+ dimension: "Dimension",
5750
+ computed: "Computed",
5751
+ metric: "Metric"
5752
+ };
5753
+ const KindChip = ({
5754
+ kind,
5755
+ source
5756
+ }) => {
5757
+ if (kind === "metric") {
5758
+ return /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5759
+ sx: {
5760
+ display: "flex",
5761
+ alignItems: "center",
5762
+ flexShrink: 0
5763
+ }
5764
+ }, /*#__PURE__*/React__namespace.default.createElement(MetricSourceIcon, {
5765
+ source: source
5766
+ }));
5767
+ }
5768
+ return /*#__PURE__*/React__namespace.default.createElement(material.Chip, {
5769
+ icon: kind === "computed" ? /*#__PURE__*/React__namespace.default.createElement(CalculateIcon__default.default, {
5770
+ sx: {
5771
+ fontSize: "15px !important"
5772
+ }
5773
+ }) : undefined,
5774
+ label: KIND_LABEL[kind],
5775
+ size: "small",
5776
+ sx: {
5777
+ fontWeight: 500,
5778
+ backgroundColor: "rgba(70, 134, 128, 0.1)",
5779
+ color: "rgb(70, 134, 128)",
5780
+ flexShrink: 0
5781
+ }
5782
+ });
5783
+ };
5784
+
5785
+ // Read-only sortable row: this tab only reorders columns. Adding, removing,
5786
+ // and renaming stay on the Dimensions/Metrics tabs so there's one place each
5787
+ // field is managed.
5788
+ const SortableColumnRow = ({
5789
+ id,
5790
+ label,
5791
+ fullPath,
5792
+ kind,
5793
+ source,
5794
+ onMoveUp,
5795
+ onMoveDown,
5796
+ isFirst,
5797
+ isLast
5798
+ }) => {
5799
+ const reportingContext = useReportingContextOptional();
5800
+ const parameters = reportingContext?.parameters;
5801
+ const {
5802
+ attributes,
5803
+ listeners,
5804
+ setNodeRef,
5805
+ style
5806
+ } = useSortableRow(id);
5807
+ const displayLabel = interpolateTitle(label, parameters);
5808
+ return /*#__PURE__*/React__namespace.default.createElement("div", _extends({
5809
+ ref: setNodeRef,
5810
+ style: style
5811
+ }, attributes), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5812
+ sx: {
5813
+ display: "flex",
5814
+ alignItems: "center",
5815
+ width: "100%",
5816
+ gap: 1,
5817
+ backgroundColor: "white",
5818
+ border: "1px solid #e0e0e0",
5819
+ borderRadius: 2,
5820
+ padding: 1,
5821
+ transition: "transform 0.2s ease, box-shadow 0.2s ease",
5822
+ "&:hover .hover-icons": {
5823
+ opacity: 1
5824
+ }
5825
+ }
5826
+ }, /*#__PURE__*/React__namespace.default.createElement(DragHandle, {
5827
+ listeners: listeners
5828
+ }), /*#__PURE__*/React__namespace.default.createElement(KindChip, {
5829
+ kind: kind,
5830
+ source: source
5831
+ }), /*#__PURE__*/React__namespace.default.createElement(material.Tooltip, {
5832
+ title: fullPath,
5833
+ arrow: true,
5834
+ placement: "top"
5835
+ }, /*#__PURE__*/React__namespace.default.createElement(material.Typography, {
5836
+ variant: "h6",
5837
+ sx: {
5838
+ fontWeight: 500,
5839
+ color: "#1a1a1a",
5840
+ fontSize: "14px",
5841
+ whiteSpace: "nowrap",
5842
+ overflow: "hidden",
5843
+ textOverflow: "ellipsis"
5844
+ }
5845
+ }, displayLabel)), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5846
+ sx: {
5847
+ flex: 1
5848
+ }
5849
+ }), /*#__PURE__*/React__namespace.default.createElement(MoveButtons, {
5850
+ onMoveUp: onMoveUp,
5851
+ onMoveDown: onMoveDown,
5852
+ isFirst: isFirst,
5853
+ isLast: isLast
5854
+ })));
5855
+ };
5856
+ const ColumnOrder = ({
5857
+ orderedDimensionItems = [],
5858
+ metrics = [],
5859
+ columnOrder = [],
5860
+ onReorderColumnItems,
5861
+ titleOverrides = {
5862
+ dimensions: {},
5863
+ metrics: {}
5864
+ }
5865
+ }) => {
5866
+ const isCustomized = columnOrder.length > 0;
5867
+ const resolvedItems = resolveColumnOrder(columnOrder, orderedDimensionItems, metrics);
5868
+ const sensors = useSortableListSensors();
5869
+ const reorderResolved = newResolvedItems => {
5870
+ onReorderColumnItems(newResolvedItems.map(item => ({
5871
+ type: item.kind,
5872
+ key: item.key
5873
+ })));
5874
+ };
5875
+ const {
5876
+ handleDragEnd,
5877
+ handleMoveUp,
5878
+ handleMoveDown
5879
+ } = makeListReorderHandlers(resolvedItems, reorderResolved);
5880
+ const labelFor = item => {
5881
+ if (item.kind === "computed") return item.data.name;
5882
+ if (item.kind === "metric") {
5883
+ return titleOverrides.metrics[item.key] || item.data.metricTitle || item.key;
5884
+ }
5885
+ return titleOverrides.dimensions[item.key] || item.data.dimensionTitle || item.key;
5886
+ };
5887
+ return /*#__PURE__*/React__namespace.default.createElement("div", null, /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5888
+ sx: {
5889
+ display: "flex",
5890
+ justifyContent: "space-between",
5891
+ alignItems: "flex-start",
5892
+ mb: 2,
5893
+ gap: 2
5894
+ }
5895
+ }, /*#__PURE__*/React__namespace.default.createElement(material.Typography, {
5896
+ sx: {
5897
+ fontSize: "13px",
5898
+ color: "#666"
5899
+ }
5900
+ }, "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__namespace.default.createElement(material.Tooltip, {
5901
+ title: "Discard the custom order and go back to the default (Dimensions, then Metrics)",
5902
+ arrow: true
5903
+ }, /*#__PURE__*/React__namespace.default.createElement(material.Button, {
5904
+ variant: "outlined",
5905
+ size: "small",
5906
+ startIcon: /*#__PURE__*/React__namespace.default.createElement(RestartAltIcon__default.default, null),
5907
+ onClick: () => onReorderColumnItems([]),
5908
+ sx: {
5909
+ height: "36px",
5910
+ flexShrink: 0,
5911
+ fontFamily: "system-ui",
5912
+ fontSize: "13px",
5913
+ fontWeight: 500,
5914
+ borderRadius: "8px",
5915
+ boxShadow: "none",
5916
+ textTransform: "none",
5917
+ borderColor: "#e0e0e0",
5918
+ color: "rgb(37, 37, 37)",
5919
+ "&:hover": {
5920
+ backgroundColor: "#f5f5f5",
5921
+ borderColor: "#d0d0d0"
5922
+ }
5923
+ }
5924
+ }, "Reset to Default"))), resolvedItems.length === 0 ? /*#__PURE__*/React__namespace.default.createElement(material.Typography, {
5925
+ sx: {
5926
+ fontSize: "13px",
5927
+ color: "#999"
5928
+ }
5929
+ }, "Add dimensions or metrics in the other tabs first.") : /*#__PURE__*/React__namespace.default.createElement(SortableListContext, {
5930
+ sensors: sensors,
5931
+ itemIds: resolvedItems.map((_, index) => index),
5932
+ onDragEnd: handleDragEnd
5933
+ }, resolvedItems.map((item, index) => /*#__PURE__*/React__namespace.default.createElement(SortableColumnRow, {
5934
+ key: `${item.kind}-${item.key}`,
5935
+ id: index,
5936
+ label: labelFor(item),
5937
+ fullPath: item.kind === "computed" ? item.data.value : item.key,
5938
+ kind: item.kind,
5939
+ source: item.kind === "metric" ? item.data.metric?.source : undefined,
5940
+ onMoveUp: () => handleMoveUp(index),
5941
+ onMoveDown: () => handleMoveDown(index),
5942
+ isFirst: index === 0,
5943
+ isLast: index === resolvedItems.length - 1
5944
+ }))));
5945
+ };
5946
+
5756
5947
  // Default numeral.js formats applied when a dimension/metric definition has a
5757
5948
  // known type but no explicit `format` set on the provider.
5758
5949
  const DEFAULT_FORMATS_BY_TYPE = {
@@ -5767,10 +5958,107 @@ const formatValue = (value, def) => {
5767
5958
  return def?.prefix ? `${def.prefix} ${formatted}` : formatted;
5768
5959
  };
5769
5960
  const isNumericType = type => type === 'integer' || type === 'currency';
5961
+
5962
+ // Builds a single DataGrid column definition for one resolved column item
5963
+ // (a dimension, computed dimension, or metric). Field naming logic:
5964
+ // - Dimensions from base provider: baseAlias.field -> baseAlias_field
5965
+ // - Dimensions from nested provider: baseAlias_rel1_rel2.field -> rel1_rel2_field (drops base alias)
5966
+ // - Computed dimensions have no provider/relations chain, so the API returns
5967
+ // each row keyed by the computed dimension's own `name`.
5968
+ // - Metrics: API returns metric keys in different forms depending on path depth
5969
+ // (see comment inline below), normalised to match the row-key normalisation
5970
+ // done when building `rows`.
5971
+ const buildColumn = (item, titleOverrides, parameters) => {
5972
+ if (item.kind === 'computed') {
5973
+ const cd = item.data;
5974
+ return {
5975
+ field: cd.name,
5976
+ headerName: cd.name,
5977
+ flex: 1,
5978
+ minWidth: 150,
5979
+ type: 'string'
5980
+ };
5981
+ }
5982
+ if (item.kind === 'metric') {
5983
+ const metric = item.data;
5984
+ const metricDef = metric.metric;
5985
+ let fieldName;
5986
+ const dotCount = (metric.fullPath.match(/\./g) || []).length;
5987
+ if (dotCount >= 2) {
5988
+ // 3+-part namespaced path: API returns the full dotted path as the key.
5989
+ // Normalise dots to underscores to match the row key normalisation below.
5990
+ // Example: "mc.dkpi.activeAgreementsCount" -> "mc_dkpi_activeAgreementsCount"
5991
+ fieldName = metric.fullPath.replace(/\./g, '_');
5992
+ } else if (metric.relations && metric.relations.length > 0) {
5993
+ // 2-part path from a nested provider: API drops the base provider alias.
5994
+ // Example: "mc_fa.total_amount" -> "fa_total_amount"
5995
+ const parts = metric.fullPath.split('.');
5996
+ const pathWithoutField = parts[0]; // e.g., "mc_fa"
5997
+ const field = parts[1]; // e.g., "total_amount"
5998
+
5999
+ const pathParts = pathWithoutField.split('_');
6000
+ pathParts.shift(); // remove base provider alias
6001
+ const pathWithoutBase = pathParts.join('_');
6002
+ fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
6003
+ } else {
6004
+ // 2-part path from the base provider: API returns just the metric name.
6005
+ // Example: "mc.record_count" -> "record_count"
6006
+ fieldName = metric.metricName;
6007
+ }
6008
+ const headerName = interpolateTitle(titleOverrides.metrics[metric.fullPath] || metric.metricTitle || fieldName, parameters);
6009
+ return {
6010
+ field: fieldName,
6011
+ headerName: headerName,
6012
+ flex: 1,
6013
+ minWidth: 150,
6014
+ type: isNumericType(metricDef?.type) ? 'number' : 'string',
6015
+ renderHeader: () => /*#__PURE__*/React__namespace.default.createElement(material.Box, {
6016
+ sx: {
6017
+ display: 'flex',
6018
+ alignItems: 'center',
6019
+ gap: 0.5
6020
+ }
6021
+ }, /*#__PURE__*/React__namespace.default.createElement(MetricSourceIcon, {
6022
+ source: metricDef?.source
6023
+ }), /*#__PURE__*/React__namespace.default.createElement("span", null, headerName)),
6024
+ valueFormatter: value => formatValue(value, metricDef)
6025
+ };
6026
+ }
6027
+
6028
+ // Dimension
6029
+ const dim = item.data;
6030
+ let fieldName;
6031
+ if (dim.relations && dim.relations.length > 0) {
6032
+ // From nested provider: drop the base provider alias
6033
+ // Example: ft_fa_db.currency -> fa_db_currency
6034
+ const parts = dim.fullPath.split('.');
6035
+ const pathWithoutField = parts[0]; // e.g., "ft_fa_db"
6036
+ const field = parts[1]; // e.g., "currency"
6037
+
6038
+ const pathParts = pathWithoutField.split('_');
6039
+ pathParts.shift(); // Remove base provider alias
6040
+ const pathWithoutBase = pathParts.join('_'); // e.g., "fa_db"
6041
+
6042
+ fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
6043
+ } else {
6044
+ // From base provider: keep the full path with underscore
6045
+ // Example: ba.created_at -> ba_created_at
6046
+ fieldName = dim.fullPath.replace('.', '_');
6047
+ }
6048
+ const headerName = interpolateTitle(titleOverrides.dimensions[dim.fullPath] || dim.dimensionTitle || fieldName, parameters);
6049
+ const dimDef = dim.dimension;
6050
+ return {
6051
+ field: fieldName,
6052
+ headerName: headerName,
6053
+ flex: 1,
6054
+ minWidth: 150,
6055
+ type: isNumericType(dimDef?.type) ? 'number' : 'string',
6056
+ valueFormatter: value => formatValue(value, dimDef)
6057
+ };
6058
+ };
5770
6059
  const ReportDataGrid = ({
5771
6060
  reportData,
5772
- orderedDimensionItems = [],
5773
- metrics,
6061
+ columnItems = [],
5774
6062
  loading,
5775
6063
  onPageChange,
5776
6064
  totalRows = 0,
@@ -5786,127 +6074,10 @@ const ReportDataGrid = ({
5786
6074
  pageSize: 50
5787
6075
  });
5788
6076
 
5789
- // Generate columns dynamically from dimensions and metrics
5790
- const columns = React__namespace.default.useMemo(() => {
5791
- const cols = [];
5792
-
5793
- // Add dimension + computed-dimension columns, in the single unified
5794
- // order the user arranged them in on the Dimensions tab.
5795
- // Field naming logic (dimensions):
5796
- // - If from base provider: baseAlias.field -> baseAlias_field
5797
- // - If from nested provider: baseAlias_rel1_rel2.field -> rel1_rel2_field (drops base alias)
5798
- // Computed dimensions have no provider/relations chain, so the API
5799
- // returns each row keyed by the computed dimension's own `name`.
5800
- orderedDimensionItems.forEach(item => {
5801
- if (item.kind === 'computed') {
5802
- const cd = item.data;
5803
- cols.push({
5804
- field: cd.name,
5805
- headerName: cd.name,
5806
- flex: 1,
5807
- minWidth: 150,
5808
- type: 'string'
5809
- });
5810
- return;
5811
- }
5812
- const dim = item.data;
5813
- let fieldName;
5814
- let headerName;
5815
-
5816
- // Check if there are relations (nested providers)
5817
- if (dim.relations && dim.relations.length > 0) {
5818
- // From nested provider: drop the base provider alias
5819
- // Example: ft_fa_db.currency -> fa_db_currency
5820
- const parts = dim.fullPath.split('.');
5821
- const pathWithoutField = parts[0]; // e.g., "ft_fa_db"
5822
- const field = parts[1]; // e.g., "currency"
5823
-
5824
- // Remove the base provider alias (first part before first underscore)
5825
- const pathParts = pathWithoutField.split('_');
5826
- pathParts.shift(); // Remove base provider alias
5827
- const pathWithoutBase = pathParts.join('_'); // e.g., "fa_db"
5828
-
5829
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
5830
- } else {
5831
- // From base provider: keep the full path with underscore
5832
- // Example: ba.created_at -> ba_created_at
5833
- fieldName = dim.fullPath.replace('.', '_');
5834
- }
5835
-
5836
- // Check for title override, otherwise use the friendly dimension title from provider
5837
- headerName = titleOverrides.dimensions[dim.fullPath] || dim.dimensionTitle || fieldName;
5838
- headerName = interpolateTitle(headerName, parameters);
5839
- const dimDef = dim.dimension;
5840
- cols.push({
5841
- field: fieldName,
5842
- headerName: headerName,
5843
- flex: 1,
5844
- minWidth: 150,
5845
- type: isNumericType(dimDef?.type) ? 'number' : 'string',
5846
- valueFormatter: value => formatValue(value, dimDef)
5847
- });
5848
- });
5849
-
5850
- // Add metric columns
5851
- // The API returns metric keys in two different forms depending on the path depth:
5852
- //
5853
- // • 2-part path "mc.record_count" → API key: "record_count"
5854
- // • 2-part path "mc_fa.total_amount" (nested) → API key: "fa_total_amount"
5855
- // • 3+-part path "mc.dkpi.activeAgreementsCount"→ API key: "mc.dkpi.activeAgreementsCount" (dots)
5856
- //
5857
- // For 3+-part paths the API key contains dots. MUI DataGrid interprets dots
5858
- // in field names as nested-object accessors, so the rows useMemo normalises
5859
- // those keys (dots → underscores). The column field must match that normalised key.
5860
- metrics.forEach(metric => {
5861
- const metricDef = metric.metric;
5862
- let fieldName;
5863
- let headerName;
5864
- const dotCount = (metric.fullPath.match(/\./g) || []).length;
5865
- if (dotCount >= 2) {
5866
- // 3+-part namespaced path: API returns the full dotted path as the key.
5867
- // Normalise dots to underscores to match the row key normalisation below.
5868
- // Example: "mc.dkpi.activeAgreementsCount" -> "mc_dkpi_activeAgreementsCount"
5869
- fieldName = metric.fullPath.replace(/\./g, '_');
5870
- } else if (metric.relations && metric.relations.length > 0) {
5871
- // 2-part path from a nested provider: API drops the base provider alias.
5872
- // Example: "mc_fa.total_amount" -> "fa_total_amount"
5873
- const parts = metric.fullPath.split('.');
5874
- const pathWithoutField = parts[0]; // e.g., "mc_fa"
5875
- const field = parts[1]; // e.g., "total_amount"
5876
-
5877
- const pathParts = pathWithoutField.split('_');
5878
- pathParts.shift(); // remove base provider alias
5879
- const pathWithoutBase = pathParts.join('_');
5880
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
5881
- } else {
5882
- // 2-part path from the base provider: API returns just the metric name.
5883
- // Example: "mc.record_count" -> "record_count"
5884
- fieldName = metric.metricName;
5885
- }
5886
-
5887
- // Check for title override, otherwise use the friendly metric title from provider
5888
- headerName = titleOverrides.metrics[metric.fullPath] || metric.metricTitle || fieldName;
5889
- headerName = interpolateTitle(headerName, parameters);
5890
- cols.push({
5891
- field: fieldName,
5892
- headerName: headerName,
5893
- flex: 1,
5894
- minWidth: 150,
5895
- type: isNumericType(metricDef?.type) ? 'number' : 'string',
5896
- renderHeader: () => /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5897
- sx: {
5898
- display: 'flex',
5899
- alignItems: 'center',
5900
- gap: 0.5
5901
- }
5902
- }, /*#__PURE__*/React__namespace.default.createElement(MetricSourceIcon, {
5903
- source: metricDef?.source
5904
- }), /*#__PURE__*/React__namespace.default.createElement("span", null, headerName)),
5905
- valueFormatter: value => formatValue(value, metricDef)
5906
- });
5907
- });
5908
- return cols;
5909
- }, [orderedDimensionItems, metrics, titleOverrides, parameters]);
6077
+ // Generate columns dynamically, in the final resolved column order
6078
+ // (custom Column Order tab arrangement when defined, otherwise
6079
+ // dimensions/computed then metrics).
6080
+ const columns = React__namespace.default.useMemo(() => columnItems.map(item => buildColumn(item, titleOverrides, parameters)), [columnItems, titleOverrides, parameters]);
5910
6081
 
5911
6082
  // Transform report data to rows with unique IDs.
5912
6083
  // Keys are normalised by replacing dots with underscores so that MUI DataGrid
@@ -6016,7 +6187,12 @@ const ReportBuilder = ({
6016
6187
  computedDimensions: [],
6017
6188
  // Unified display/execution order across dimensions + computedDimensions.
6018
6189
  // Entries: { type: 'dimension', key: fullPath } | { type: 'computed', key: name }
6019
- dimensionOrder: []
6190
+ dimensionOrder: [],
6191
+ // Optional unified display order across dimensions + computedDimensions +
6192
+ // metrics, set on the Column Order tab. Empty means "not defined" — the
6193
+ // results grid falls back to dimensionOrder followed by metrics order.
6194
+ // Entries: { type: 'dimension' | 'computed' | 'metric', key }
6195
+ columnOrder: []
6020
6196
  });
6021
6197
  const [titleOverrides, setTitleOverrides] = React.useState({
6022
6198
  dimensions: {},
@@ -6247,6 +6423,46 @@ const ReportBuilder = ({
6247
6423
  });
6248
6424
  return dimensionOrder;
6249
6425
  };
6426
+
6427
+ // Build the optional unified column order (dimensions + computed
6428
+ // dimensions + metrics) from the persisted `ordered_columns` field.
6429
+ // Unlike buildDimensionOrder, entries that no longer resolve are simply
6430
+ // dropped rather than backfilled — an empty/partial result just means "no
6431
+ // custom order defined (yet)", which resolveColumnOrder treats as
6432
+ // "fall back to the default order".
6433
+ const buildColumnOrder = (rawOrderedColumns, dimensions, computedDimensions, metrics) => {
6434
+ if (!Array.isArray(rawOrderedColumns)) return [];
6435
+ const dimensionPaths = new Set(dimensions.map(d => d.fullPath));
6436
+ const computedNames = new Set(computedDimensions.map(cd => cd.name));
6437
+ const metricPaths = new Set(metrics.map(m => m.fullPath));
6438
+ const columnOrder = [];
6439
+ rawOrderedColumns.forEach(entry => {
6440
+ if (entry.type === "dimension" && dimensionPaths.has(entry.ref)) {
6441
+ columnOrder.push({
6442
+ type: "dimension",
6443
+ key: entry.ref
6444
+ });
6445
+ } else if (entry.type === "computed" && computedNames.has(entry.ref)) {
6446
+ columnOrder.push({
6447
+ type: "computed",
6448
+ key: entry.ref
6449
+ });
6450
+ } else if (entry.type === "metric" && metricPaths.has(entry.ref)) {
6451
+ columnOrder.push({
6452
+ type: "metric",
6453
+ key: entry.ref
6454
+ });
6455
+ }
6456
+ });
6457
+ return columnOrder;
6458
+ };
6459
+
6460
+ // Auto-append a newly added dimension/computed dimension/metric to the end
6461
+ // of columnOrder — but only once a custom order actually exists. While
6462
+ // columnOrder is empty (no custom order defined) it's left untouched, so
6463
+ // the results grid keeps falling back to the default dimensions-then-
6464
+ // metrics order until the user visits the Column Order tab.
6465
+ const appendToColumnOrder = (columnOrder, entry) => columnOrder.length > 0 ? [...columnOrder, entry] : columnOrder;
6250
6466
  const loadReportDefinition = async id => {
6251
6467
  try {
6252
6468
  console.log("Loading report definition:", id);
@@ -6343,6 +6559,7 @@ const ReportBuilder = ({
6343
6559
  // provider/relation chain, so they're loaded as-is (no reconstruction needed)
6344
6560
  const computedDimensions = reportDef.definition?.doc?.query?.computed_dimensions || [];
6345
6561
  const dimensionOrder = buildDimensionOrder(reportDef.definition?.doc?.query?.ordered_dimensions, reconstructedDimensions, computedDimensions);
6562
+ const columnOrder = buildColumnOrder(reportDef.definition?.doc?.query?.ordered_columns, reconstructedDimensions, computedDimensions, reconstructedMetrics);
6346
6563
 
6347
6564
  // Set the report state
6348
6565
  setReport({
@@ -6350,7 +6567,8 @@ const ReportBuilder = ({
6350
6567
  metrics: reconstructedMetrics,
6351
6568
  filters: loadedFilters,
6352
6569
  computedDimensions,
6353
- dimensionOrder
6570
+ dimensionOrder,
6571
+ columnOrder
6354
6572
  });
6355
6573
 
6356
6574
  // Set title overrides
@@ -6460,6 +6678,7 @@ const ReportBuilder = ({
6460
6678
  // provider/relation chain, so they're loaded as-is (no reconstruction needed)
6461
6679
  const computedDimensions = reportDef.definition?.doc?.query?.computed_dimensions || [];
6462
6680
  const dimensionOrder = buildDimensionOrder(reportDef.definition?.doc?.query?.ordered_dimensions, reconstructedDimensions, computedDimensions);
6681
+ const columnOrder = buildColumnOrder(reportDef.definition?.doc?.query?.ordered_columns, reconstructedDimensions, computedDimensions, reconstructedMetrics);
6463
6682
 
6464
6683
  // Set the report state
6465
6684
  setReport({
@@ -6467,7 +6686,8 @@ const ReportBuilder = ({
6467
6686
  metrics: reconstructedMetrics,
6468
6687
  filters: loadedFilters,
6469
6688
  computedDimensions,
6470
- dimensionOrder
6689
+ dimensionOrder,
6690
+ columnOrder
6471
6691
  });
6472
6692
 
6473
6693
  // Set title overrides
@@ -6501,7 +6721,8 @@ const ReportBuilder = ({
6501
6721
  metrics: [],
6502
6722
  filters: {},
6503
6723
  computedDimensions: [],
6504
- dimensionOrder: []
6724
+ dimensionOrder: [],
6725
+ columnOrder: []
6505
6726
  });
6506
6727
  // Reset title overrides
6507
6728
  setTitleOverrides({
@@ -6582,7 +6803,11 @@ const ReportBuilder = ({
6582
6803
  dimensionOrder: [...prev.dimensionOrder, {
6583
6804
  type: "dimension",
6584
6805
  key: dimensionData.fullPath
6585
- }]
6806
+ }],
6807
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
6808
+ type: "dimension",
6809
+ key: dimensionData.fullPath
6810
+ })
6586
6811
  };
6587
6812
  console.log("Dimension saved:", dimensionData);
6588
6813
  console.log("Complete report:", newReport);
@@ -6593,7 +6818,8 @@ const ReportBuilder = ({
6593
6818
  setReport(prev => ({
6594
6819
  ...prev,
6595
6820
  dimensions: prev.dimensions.filter(d => d.fullPath !== fullPath),
6596
- dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "dimension" && entry.key === fullPath))
6821
+ dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "dimension" && entry.key === fullPath)),
6822
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "dimension" && entry.key === fullPath))
6597
6823
  }));
6598
6824
  };
6599
6825
  const handleUpdateDimensionSortOrder = (fullPath, sortOrder) => {
@@ -6622,7 +6848,11 @@ const ReportBuilder = ({
6622
6848
  dimensionOrder: [...prev.dimensionOrder, {
6623
6849
  type: "computed",
6624
6850
  key: computedDimensionData.name
6625
- }]
6851
+ }],
6852
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
6853
+ type: "computed",
6854
+ key: computedDimensionData.name
6855
+ })
6626
6856
  }));
6627
6857
  };
6628
6858
  const handleUpdateComputedDimension = (name, computedDimensionData) => {
@@ -6632,6 +6862,10 @@ const ReportBuilder = ({
6632
6862
  dimensionOrder: prev.dimensionOrder.map(entry => entry.type === "computed" && entry.key === name ? {
6633
6863
  ...entry,
6634
6864
  key: computedDimensionData.name
6865
+ } : entry),
6866
+ columnOrder: prev.columnOrder.map(entry => entry.type === "computed" && entry.key === name ? {
6867
+ ...entry,
6868
+ key: computedDimensionData.name
6635
6869
  } : entry)
6636
6870
  }));
6637
6871
  };
@@ -6639,14 +6873,19 @@ const ReportBuilder = ({
6639
6873
  setReport(prev => ({
6640
6874
  ...prev,
6641
6875
  computedDimensions: prev.computedDimensions.filter(cd => cd.name !== name),
6642
- dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "computed" && entry.key === name))
6876
+ dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "computed" && entry.key === name)),
6877
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "computed" && entry.key === name))
6643
6878
  }));
6644
6879
  };
6645
6880
  const handleSaveMetric = metricData => {
6646
6881
  setReport(prev => {
6647
6882
  const newReport = {
6648
6883
  ...prev,
6649
- metrics: [...prev.metrics, metricData]
6884
+ metrics: [...prev.metrics, metricData],
6885
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
6886
+ type: "metric",
6887
+ key: metricData.fullPath
6888
+ })
6650
6889
  };
6651
6890
  console.log("Metric saved:", metricData);
6652
6891
  console.log("Complete report:", newReport);
@@ -6654,15 +6893,29 @@ const ReportBuilder = ({
6654
6893
  });
6655
6894
  };
6656
6895
  const handleRemoveMetric = index => {
6896
+ setReport(prev => {
6897
+ const fullPath = prev.metrics[index]?.fullPath;
6898
+ return {
6899
+ ...prev,
6900
+ metrics: prev.metrics.filter((_, i) => i !== index),
6901
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "metric" && entry.key === fullPath))
6902
+ };
6903
+ });
6904
+ };
6905
+ const handleReorderMetrics = newOrder => {
6657
6906
  setReport(prev => ({
6658
6907
  ...prev,
6659
- metrics: prev.metrics.filter((_, i) => i !== index)
6908
+ metrics: newOrder
6660
6909
  }));
6661
6910
  };
6662
- const handleReorderMetrics = newOrder => {
6911
+
6912
+ // Sets the unified column order shown on the Column Order tab. An empty
6913
+ // array (e.g. via "Reset to Default") clears the customization, and the
6914
+ // results grid falls back to dimensionOrder followed by metrics order.
6915
+ const handleReorderColumnItems = newColumnOrder => {
6663
6916
  setReport(prev => ({
6664
6917
  ...prev,
6665
- metrics: newOrder
6918
+ columnOrder: newColumnOrder
6666
6919
  }));
6667
6920
  };
6668
6921
  const handleSaveFilter = (fullPath, filterData) => {
@@ -6787,6 +7040,17 @@ const ReportBuilder = ({
6787
7040
  }));
6788
7041
  }
6789
7042
 
7043
+ // Persist the optional unified column order (Column Order tab) so the
7044
+ // results grid can reproduce it on reload. Absent when no custom order
7045
+ // has been defined, so old/untouched reports keep their current
7046
+ // dimensions-then-metrics column order unchanged.
7047
+ if (report.columnOrder.length > 0) {
7048
+ queryObj.ordered_columns = report.columnOrder.map(entry => ({
7049
+ type: entry.type,
7050
+ ref: entry.key
7051
+ }));
7052
+ }
7053
+
6790
7054
  // Only add titles if there are any overrides
6791
7055
  if (Object.keys(titles).length > 0) {
6792
7056
  queryObj.titles = titles;
@@ -6846,8 +7110,8 @@ const ReportBuilder = ({
6846
7110
  const handleRunReport = async () => {
6847
7111
  setCurrentPage(0);
6848
7112
  await runReportWithPagination(0, pageSize);
6849
- // Switch to Results tab after running report (now index 3 after adding Filters tab)
6850
- setActiveTab(3);
7113
+ // Switch to Results tab after running report (index 4: Dimensions, Metrics, Filters, Column Order, Results)
7114
+ setActiveTab(4);
6851
7115
  };
6852
7116
  const handleDownloadReport = async () => {
6853
7117
  try {
@@ -6954,6 +7218,11 @@ const ReportBuilder = ({
6954
7218
  kind: "computed",
6955
7219
  data: computedDimensionByName[entry.key]
6956
7220
  }).filter(item => item.data);
7221
+
7222
+ // Final column list for the results grid: the explicit Column Order tab
7223
+ // arrangement when defined, otherwise dimensions/computed (in their own
7224
+ // order) followed by metrics (in their own order) — today's behavior.
7225
+ const columnItems = resolveColumnOrder(report.columnOrder, orderedDimensionItems, report.metrics);
6957
7226
  return /*#__PURE__*/React__namespace.default.createElement(material.Box, {
6958
7227
  sx: {
6959
7228
  p: 3,
@@ -7208,9 +7477,36 @@ const ReportBuilder = ({
7208
7477
  }
7209
7478
  }
7210
7479
  }), /*#__PURE__*/React__namespace.default.createElement(material.Tab, {
7211
- label: reportData ? "Results" : "Results (Run report first)",
7480
+ label: /*#__PURE__*/React__namespace.default.createElement(material.Badge, {
7481
+ variant: "dot",
7482
+ invisible: report.columnOrder.length === 0,
7483
+ sx: {
7484
+ "& .MuiBadge-dot": {
7485
+ backgroundColor: "rgb(70, 134, 128)"
7486
+ }
7487
+ }
7488
+ }, /*#__PURE__*/React__namespace.default.createElement("span", {
7489
+ style: {
7490
+ marginRight: report.columnOrder.length > 0 ? "8px" : "0"
7491
+ }
7492
+ }, "Column Order")),
7212
7493
  id: "report-tab-3",
7213
7494
  "aria-controls": "report-tabpanel-3",
7495
+ sx: {
7496
+ height: "41px",
7497
+ fontFamily: "system-ui",
7498
+ borderRadius: "0.5rem",
7499
+ boxShadow: "none",
7500
+ textTransform: "none",
7501
+ color: "rgb(37, 37, 37)",
7502
+ "&.Mui-selected": {
7503
+ color: "rgb(70, 134, 128)"
7504
+ }
7505
+ }
7506
+ }), /*#__PURE__*/React__namespace.default.createElement(material.Tab, {
7507
+ label: reportData ? "Results" : "Results (Run report first)",
7508
+ id: "report-tab-4",
7509
+ "aria-controls": "report-tabpanel-4",
7214
7510
  disabled: !reportData,
7215
7511
  sx: {
7216
7512
  height: "41px",
@@ -7277,10 +7573,18 @@ const ReportBuilder = ({
7277
7573
  })), /*#__PURE__*/React__namespace.default.createElement(TabPanel, {
7278
7574
  value: activeTab,
7279
7575
  index: 3
7280
- }, reportData && /*#__PURE__*/React__namespace.default.createElement(ReportDataGrid, {
7281
- reportData: reportData,
7576
+ }, /*#__PURE__*/React__namespace.default.createElement(ColumnOrder, {
7282
7577
  orderedDimensionItems: orderedDimensionItems,
7283
7578
  metrics: report.metrics,
7579
+ columnOrder: report.columnOrder,
7580
+ onReorderColumnItems: handleReorderColumnItems,
7581
+ titleOverrides: titleOverrides
7582
+ })), /*#__PURE__*/React__namespace.default.createElement(TabPanel, {
7583
+ value: activeTab,
7584
+ index: 4
7585
+ }, reportData && /*#__PURE__*/React__namespace.default.createElement(ReportDataGrid, {
7586
+ reportData: reportData,
7587
+ columnItems: columnItems,
7284
7588
  loading: loading,
7285
7589
  onPageChange: handlePageChange,
7286
7590
  totalRows: totalRows,
@@ -7368,6 +7672,12 @@ const CurrencySelector = () => {
7368
7672
  parameters,
7369
7673
  setParameters
7370
7674
  } = useReportingContext();
7675
+
7676
+ // Hidden by default; only shown when a `cur` query parameter is present in the top-level URL.
7677
+ const showCurrencySelector = new URLSearchParams(window.location.search).has("cur");
7678
+ if (!showCurrencySelector) {
7679
+ return null;
7680
+ }
7371
7681
  return /*#__PURE__*/React__namespace.default.createElement(SingleSelect, {
7372
7682
  items: CURRENCY_OPTIONS,
7373
7683
  value: parameters.base_currency,