@ngbplatform/ui 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +44 -0
  3. package/package.json +61 -0
  4. package/public/favicon.svg +6 -0
  5. package/public/silent-check-sso.html +8 -0
  6. package/src/assets/ngb_logo.svg +17 -0
  7. package/src/assets/ngb_logo_color.svg +17 -0
  8. package/src/index.ts +1023 -0
  9. package/src/ngb/README.md +27 -0
  10. package/src/ngb/accounting/NgbAccountingPeriodClosingPage.vue +1157 -0
  11. package/src/ngb/accounting/NgbChartOfAccountEditor.vue +590 -0
  12. package/src/ngb/accounting/NgbChartOfAccountsPage.vue +420 -0
  13. package/src/ngb/accounting/NgbGeneralJournalEntryEditPage.vue +796 -0
  14. package/src/ngb/accounting/NgbGeneralJournalEntryLinesEditor.vue +454 -0
  15. package/src/ngb/accounting/NgbGeneralJournalEntryListPage.vue +186 -0
  16. package/src/ngb/accounting/api.ts +79 -0
  17. package/src/ngb/accounting/generalJournalEntry.ts +127 -0
  18. package/src/ngb/accounting/generalJournalEntryApi.ts +112 -0
  19. package/src/ngb/accounting/generalJournalEntryTypes.ts +155 -0
  20. package/src/ngb/accounting/navigation.ts +83 -0
  21. package/src/ngb/accounting/periodClosing.ts +75 -0
  22. package/src/ngb/accounting/periodClosingApi.ts +60 -0
  23. package/src/ngb/accounting/periodClosingTypes.ts +73 -0
  24. package/src/ngb/accounting/types.ts +57 -0
  25. package/src/ngb/api/audit.ts +23 -0
  26. package/src/ngb/api/catalogs.ts +50 -0
  27. package/src/ngb/api/contracts.ts +285 -0
  28. package/src/ngb/api/documents.ts +170 -0
  29. package/src/ngb/api/http.ts +446 -0
  30. package/src/ngb/api/lookups.ts +14 -0
  31. package/src/ngb/api/types.ts +6 -0
  32. package/src/ngb/auth/index.ts +13 -0
  33. package/src/ngb/auth/keycloak.ts +374 -0
  34. package/src/ngb/auth/router.ts +35 -0
  35. package/src/ngb/auth/types.ts +12 -0
  36. package/src/ngb/auth/useAuthStore.ts +209 -0
  37. package/src/ngb/command-palette/NgbCommandPaletteDialog.vue +342 -0
  38. package/src/ngb/command-palette/NgbCommandPaletteEmptyState.vue +55 -0
  39. package/src/ngb/command-palette/NgbCommandPaletteFooterHints.vue +29 -0
  40. package/src/ngb/command-palette/api.ts +10 -0
  41. package/src/ngb/command-palette/config.ts +16 -0
  42. package/src/ngb/command-palette/presentation.ts +85 -0
  43. package/src/ngb/command-palette/search.ts +130 -0
  44. package/src/ngb/command-palette/staticItems.ts +243 -0
  45. package/src/ngb/command-palette/storage.ts +45 -0
  46. package/src/ngb/command-palette/store.ts +716 -0
  47. package/src/ngb/command-palette/types.ts +120 -0
  48. package/src/ngb/command-palette/useCommandPaletteHotkeys.ts +27 -0
  49. package/src/ngb/command-palette/useCommandPalettePageContext.ts +18 -0
  50. package/src/ngb/components/NgbConfirmDialog.vue +63 -0
  51. package/src/ngb/components/NgbDialog.vue +44 -0
  52. package/src/ngb/components/NgbDrawer.vue +156 -0
  53. package/src/ngb/components/NgbHeaderActionCluster.vue +140 -0
  54. package/src/ngb/components/NgbModalShell.vue +99 -0
  55. package/src/ngb/components/NgbToolbar.vue +14 -0
  56. package/src/ngb/components/forms/NgbFormLayout.vue +13 -0
  57. package/src/ngb/components/forms/NgbFormRow.vue +21 -0
  58. package/src/ngb/components/forms/NgbFormSection.vue +16 -0
  59. package/src/ngb/components/forms/NgbValidationSummary.vue +18 -0
  60. package/src/ngb/components/navigation/NgbNavigationTree.vue +68 -0
  61. package/src/ngb/components/navigation/NgbTreeNode.vue +94 -0
  62. package/src/ngb/components/register/NgbColumnChooser.vue +101 -0
  63. package/src/ngb/components/register/NgbRegisterGrid.vue +473 -0
  64. package/src/ngb/components/register/registerTypes.ts +51 -0
  65. package/src/ngb/components/register/useRegisterColumnResize.ts +99 -0
  66. package/src/ngb/components/register/useRegisterColumnState.ts +220 -0
  67. package/src/ngb/components/register/useRegisterRows.ts +209 -0
  68. package/src/ngb/components/register/useRegisterViewport.ts +56 -0
  69. package/src/ngb/editor/NgbDocumentEffectsPage.vue +512 -0
  70. package/src/ngb/editor/NgbDocumentFlowPage.vue +409 -0
  71. package/src/ngb/editor/NgbDocumentPrintPage.vue +712 -0
  72. package/src/ngb/editor/NgbEditorDiscardDialog.vue +40 -0
  73. package/src/ngb/editor/NgbEntityAuditSidebar.vue +288 -0
  74. package/src/ngb/editor/NgbEntityEditor.vue +250 -0
  75. package/src/ngb/editor/NgbEntityEditorDrawerActions.vue +88 -0
  76. package/src/ngb/editor/NgbEntityEditorHeader.vue +114 -0
  77. package/src/ngb/editor/catalogNavigation.ts +13 -0
  78. package/src/ngb/editor/config.ts +252 -0
  79. package/src/ngb/editor/documentCopyDraft.ts +245 -0
  80. package/src/ngb/editor/documentEffects.ts +43 -0
  81. package/src/ngb/editor/documentEffectsDateFormatting.ts +18 -0
  82. package/src/ngb/editor/documentNavigation.ts +115 -0
  83. package/src/ngb/editor/documentStatus.ts +45 -0
  84. package/src/ngb/editor/entityEditorErrors.ts +162 -0
  85. package/src/ngb/editor/entityEditorPersistence.ts +241 -0
  86. package/src/ngb/editor/extensions.ts +40 -0
  87. package/src/ngb/editor/types.ts +295 -0
  88. package/src/ngb/editor/useConfiguredEntityEditorDocumentActions.ts +183 -0
  89. package/src/ngb/editor/useDocumentEditorDrawerState.ts +152 -0
  90. package/src/ngb/editor/useEditorDrawerState.ts +88 -0
  91. package/src/ngb/editor/useEntityEditorBusinessContext.ts +97 -0
  92. package/src/ngb/editor/useEntityEditorCapabilities.ts +190 -0
  93. package/src/ngb/editor/useEntityEditorCommandPalette.ts +154 -0
  94. package/src/ngb/editor/useEntityEditorCommitHandlers.ts +102 -0
  95. package/src/ngb/editor/useEntityEditorHeaderActions.ts +253 -0
  96. package/src/ngb/editor/useEntityEditorLeaveGuard.ts +66 -0
  97. package/src/ngb/editor/useEntityEditorNavigationActions.ts +235 -0
  98. package/src/ngb/editor/useEntityEditorOutputs.ts +59 -0
  99. package/src/ngb/editor/useEntityEditorPageActions.ts +57 -0
  100. package/src/ngb/editor/useRouteQueryEditorDrawer.ts +173 -0
  101. package/src/ngb/env/runtimeConfig.ts +25 -0
  102. package/src/ngb/lookup/config.ts +42 -0
  103. package/src/ngb/lookup/defaultConfig.ts +136 -0
  104. package/src/ngb/lookup/navigation.ts +76 -0
  105. package/src/ngb/lookup/prefetch.ts +40 -0
  106. package/src/ngb/lookup/store.ts +261 -0
  107. package/src/ngb/metadata/NgbDocumentListFiltersDrawer.vue +78 -0
  108. package/src/ngb/metadata/NgbDocumentPeriodFilter.vue +53 -0
  109. package/src/ngb/metadata/NgbEntityForm.vue +84 -0
  110. package/src/ngb/metadata/NgbEntityFormFieldsBlock.vue +110 -0
  111. package/src/ngb/metadata/NgbEntityListPageHeader.vue +80 -0
  112. package/src/ngb/metadata/NgbFilterFieldControl.vue +104 -0
  113. package/src/ngb/metadata/NgbMetadataCatalogEditPage.vue +42 -0
  114. package/src/ngb/metadata/NgbMetadataCatalogListPage.vue +269 -0
  115. package/src/ngb/metadata/NgbMetadataDocumentEditPage.vue +68 -0
  116. package/src/ngb/metadata/NgbMetadataDocumentListPage.vue +353 -0
  117. package/src/ngb/metadata/NgbMetadataFieldRenderer.vue +124 -0
  118. package/src/ngb/metadata/NgbMetadataLookupControl.vue +92 -0
  119. package/src/ngb/metadata/NgbRecycleBinFilter.vue +53 -0
  120. package/src/ngb/metadata/NgbRegisterPageLayout.vue +143 -0
  121. package/src/ngb/metadata/config.ts +28 -0
  122. package/src/ngb/metadata/dataTypes.ts +27 -0
  123. package/src/ngb/metadata/entityForm.ts +114 -0
  124. package/src/ngb/metadata/entityModel.ts +34 -0
  125. package/src/ngb/metadata/entityValueFormatting.ts +83 -0
  126. package/src/ngb/metadata/fieldRendererState.ts +84 -0
  127. package/src/ngb/metadata/filtering.ts +153 -0
  128. package/src/ngb/metadata/lookup.ts +25 -0
  129. package/src/ngb/metadata/monthPagedListQuery.ts +108 -0
  130. package/src/ngb/metadata/normalization.ts +100 -0
  131. package/src/ngb/metadata/referenceHydration.ts +99 -0
  132. package/src/ngb/metadata/register.ts +135 -0
  133. package/src/ngb/metadata/routePages.ts +86 -0
  134. package/src/ngb/metadata/store.ts +35 -0
  135. package/src/ngb/metadata/types.ts +276 -0
  136. package/src/ngb/metadata/useMetadataListFilters.ts +306 -0
  137. package/src/ngb/metadata/useMetadataRegisterPageData.ts +246 -0
  138. package/src/ngb/metadata/useValidationFocus.ts +49 -0
  139. package/src/ngb/primitives/NgbBadge.vue +31 -0
  140. package/src/ngb/primitives/NgbButton.vue +53 -0
  141. package/src/ngb/primitives/NgbCheckbox.vue +33 -0
  142. package/src/ngb/primitives/NgbDatePicker.vue +141 -0
  143. package/src/ngb/primitives/NgbIcon.vue +545 -0
  144. package/src/ngb/primitives/NgbInput.vue +57 -0
  145. package/src/ngb/primitives/NgbLookup.vue +264 -0
  146. package/src/ngb/primitives/NgbMonthPicker.vue +132 -0
  147. package/src/ngb/primitives/NgbMultiSelect.vue +179 -0
  148. package/src/ngb/primitives/NgbPeriodPicker.vue +125 -0
  149. package/src/ngb/primitives/NgbPickerNavButton.vue +31 -0
  150. package/src/ngb/primitives/NgbPickerPopover.vue +79 -0
  151. package/src/ngb/primitives/NgbRadioGroup.vue +53 -0
  152. package/src/ngb/primitives/NgbSelect.vue +108 -0
  153. package/src/ngb/primitives/NgbStatusIcon.vue +63 -0
  154. package/src/ngb/primitives/NgbSwitch.vue +42 -0
  155. package/src/ngb/primitives/NgbTabs.vue +116 -0
  156. package/src/ngb/primitives/NgbToastHost.vue +49 -0
  157. package/src/ngb/primitives/NgbTooltip.vue +27 -0
  158. package/src/ngb/primitives/iconNames.ts +83 -0
  159. package/src/ngb/primitives/toast.ts +66 -0
  160. package/src/ngb/primitives/useAsyncComboboxQuery.ts +85 -0
  161. package/src/ngb/primitives/useFloatingDropdownPosition.ts +129 -0
  162. package/src/ngb/reporting/NgbReportComposerCollectionSection.vue +106 -0
  163. package/src/ngb/reporting/NgbReportComposerPanel.vue +1187 -0
  164. package/src/ngb/reporting/NgbReportDateRangeFilter.vue +54 -0
  165. package/src/ngb/reporting/NgbReportPage.vue +1124 -0
  166. package/src/ngb/reporting/NgbReportSheet.vue +467 -0
  167. package/src/ngb/reporting/api.ts +35 -0
  168. package/src/ngb/reporting/composer.ts +904 -0
  169. package/src/ngb/reporting/config.ts +120 -0
  170. package/src/ngb/reporting/defaultConfig.ts +15 -0
  171. package/src/ngb/reporting/lookupFilters.ts +43 -0
  172. package/src/ngb/reporting/navigation.ts +181 -0
  173. package/src/ngb/reporting/pageHelpers.ts +73 -0
  174. package/src/ngb/reporting/pageSession.ts +92 -0
  175. package/src/ngb/reporting/paging.ts +66 -0
  176. package/src/ngb/reporting/types.ts +333 -0
  177. package/src/ngb/router/backNavigation.ts +122 -0
  178. package/src/ngb/router/queryParams.ts +129 -0
  179. package/src/ngb/router/queryState.ts +121 -0
  180. package/src/ngb/router/routeAliases.ts +66 -0
  181. package/src/ngb/router/routeParams.ts +15 -0
  182. package/src/ngb/router/shareLink.ts +39 -0
  183. package/src/ngb/security/NgbAccessAuditPanel.vue +32 -0
  184. package/src/ngb/security/NgbAccessDeniedState.vue +27 -0
  185. package/src/ngb/security/NgbEffectiveAccessPanel.vue +60 -0
  186. package/src/ngb/security/NgbPermissionMatrix.vue +160 -0
  187. package/src/ngb/security/NgbRoleEditorPage.vue +319 -0
  188. package/src/ngb/security/NgbRolesPage.vue +125 -0
  189. package/src/ngb/security/NgbUserEditorPage.vue +615 -0
  190. package/src/ngb/security/NgbUsersPage.vue +125 -0
  191. package/src/ngb/security/api.ts +91 -0
  192. package/src/ngb/security/audit.ts +21 -0
  193. package/src/ngb/security/permissions.ts +65 -0
  194. package/src/ngb/security/types.ts +157 -0
  195. package/src/ngb/security/useAccessStore.ts +69 -0
  196. package/src/ngb/site/NgbDashboardAsOfToolbar.vue +100 -0
  197. package/src/ngb/site/NgbDashboardStatusBanner.vue +91 -0
  198. package/src/ngb/site/NgbLogo.vue +44 -0
  199. package/src/ngb/site/NgbPageHeader.vue +49 -0
  200. package/src/ngb/site/NgbSiteShell.vue +286 -0
  201. package/src/ngb/site/NgbSiteSidebar.vue +270 -0
  202. package/src/ngb/site/NgbTopBar.vue +425 -0
  203. package/src/ngb/site/NgbTrendChart.vue +224 -0
  204. package/src/ngb/site/dashboardData.ts +324 -0
  205. package/src/ngb/site/mainMenuStore.ts +49 -0
  206. package/src/ngb/site/types.ts +31 -0
  207. package/src/ngb/site/useDashboardPageState.ts +98 -0
  208. package/src/ngb/site/useTheme.ts +95 -0
  209. package/src/ngb/utils/clone.ts +43 -0
  210. package/src/ngb/utils/dateValues.ts +107 -0
  211. package/src/ngb/utils/errorMessage.ts +14 -0
  212. package/src/ngb/utils/guid.ts +21 -0
  213. package/src/ngb/utils/stableValue.ts +21 -0
  214. package/src/ngb/utils/storage.ts +135 -0
  215. package/src/styles/tailwind.css +165 -0
  216. package/src/vite/publicAssets.ts +104 -0
  217. package/vite-public-assets.js +68 -0
@@ -0,0 +1,1187 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { useRoute, useRouter } from 'vue-router'
4
+ import NgbButton from '../primitives/NgbButton.vue'
5
+ import NgbDatePicker from '../primitives/NgbDatePicker.vue'
6
+ import NgbIcon from '../primitives/NgbIcon.vue'
7
+ import NgbInput from '../primitives/NgbInput.vue'
8
+ import NgbSelect from '../primitives/NgbSelect.vue'
9
+ import NgbSwitch from '../primitives/NgbSwitch.vue'
10
+ import NgbTabs from '../primitives/NgbTabs.vue'
11
+ import NgbFormLayout from '../components/forms/NgbFormLayout.vue'
12
+ import NgbFormRow from '../components/forms/NgbFormRow.vue'
13
+ import FilterFieldControl from '../metadata/NgbFilterFieldControl.vue'
14
+
15
+ import {
16
+ ReportSortDirection,
17
+ ReportTimeGrain,
18
+ type ReportComposerDraft,
19
+ type ReportComposerLookupItem,
20
+ type ReportDefinitionDto,
21
+ type ReportFieldDto,
22
+ type ReportFilterFieldDto,
23
+ type ReportOptionItem,
24
+ } from './types'
25
+ import {
26
+ coerceReportAggregationKind,
27
+ coerceReportComposerLookupItems,
28
+ coerceReportSortDirection,
29
+ coerceReportTimeGrain,
30
+ cloneComposerDraft,
31
+ getAggregationOptions,
32
+ getReportComposerFilterState,
33
+ getSelectedReportComposerFilterItem,
34
+ getGroupableFields,
35
+ getMeasureOptions,
36
+ getSelectableFields,
37
+ getSortableFields,
38
+ getTimeGrainOptions,
39
+ normalizeComposerDraft,
40
+ resolveDefaultAggregation,
41
+ resolveMeasureLabel,
42
+ sortDirectionOptions,
43
+ } from './composer'
44
+ import { resolveReportLookupTarget } from './config'
45
+ import ReportComposerCollectionSection from './NgbReportComposerCollectionSection.vue'
46
+
47
+ const props = defineProps<{
48
+ definition: ReportDefinitionDto
49
+ modelValue: ReportComposerDraft
50
+ lookupItemsByFilterCode: Record<string, ReportComposerLookupItem[]>
51
+ running?: boolean
52
+ variantOptions?: ReportOptionItem[]
53
+ selectedVariantCode?: string
54
+ variantSummary?: string
55
+ variantDisabled?: boolean
56
+ createVariantDisabled?: boolean
57
+ editVariantDisabled?: boolean
58
+ saveVariantDisabled?: boolean
59
+ deleteVariantDisabled?: boolean
60
+ resetVariantDisabled?: boolean
61
+ loadVariantDisabled?: boolean
62
+ }>()
63
+
64
+ const emit = defineEmits<{
65
+ (e: 'update:modelValue', value: ReportComposerDraft): void
66
+ (e: 'run'): void
67
+ (e: 'close'): void
68
+ (e: 'filter-query', payload: { fieldCode: string; query: string }): void
69
+ (e: 'update:selectedVariantCode', value: string): void
70
+ (e: 'create-variant'): void
71
+ (e: 'edit-variant'): void
72
+ (e: 'save-variant'): void
73
+ (e: 'delete-variant'): void
74
+ (e: 'reset-variant'): void
75
+ (e: 'load-variant'): void
76
+ }>()
77
+
78
+ type ComposerTabKey = 'general' | 'grouping' | 'filters' | 'fields' | 'sorting' | 'variant'
79
+ type ComposerDragSection = 'rowGroups' | 'columnGroups' | 'measures' | 'detailFields' | 'sorts'
80
+ type GroupingEntry = ReportComposerDraft['rowGroups'][number]
81
+ type MeasureEntry = ReportComposerDraft['measures'][number]
82
+ type SortEntry = ReportComposerDraft['sorts'][number]
83
+
84
+ const groupableFields = computed(() => getGroupableFields(props.definition))
85
+ const selectableFields = computed(() => getSelectableFields(props.definition))
86
+ const sortableFields = computed(() => getSortableFields(props.definition))
87
+ const measures = computed(() => getMeasureOptions(props.definition))
88
+ const route = useRoute()
89
+ const router = useRouter()
90
+ const sortDirections = sortDirectionOptions()
91
+ const groupingSectionColumns = [
92
+ { title: 'Field' },
93
+ { title: 'Time grain', width: '220px' },
94
+ ]
95
+ const measureSectionColumns = [
96
+ { title: 'Measure' },
97
+ { title: 'Aggregation', width: '132px' },
98
+ { title: 'Label', width: '132px' },
99
+ ]
100
+ const detailFieldSectionColumns = [
101
+ { title: 'Field' },
102
+ ]
103
+ const sortingSectionColumns = [
104
+ { title: 'Field', width: '120px' },
105
+ { title: 'Axis', width: '120px' },
106
+ { title: 'Direction', width: '132px' },
107
+ { title: 'Time grain', width: '100px' },
108
+ ]
109
+ const allowsRowGroups = computed(() => !!props.definition.capabilities?.allowsRowGroups)
110
+ const allowsColumnGroups = computed(() => !!props.definition.capabilities?.allowsColumnGroups)
111
+ const allowsMeasures = computed(() => props.definition.capabilities?.allowsMeasures !== false)
112
+ const allowsDetailFields = computed(() => !!props.definition.capabilities?.allowsDetailFields)
113
+ const allowsSorting = computed(() => !!props.definition.capabilities?.allowsSorting)
114
+ const allowsShowDetails = computed(() => !!props.definition.capabilities?.allowsShowDetails)
115
+ const allowsSubtotals = computed(() => !!props.definition.capabilities?.allowsSubtotals)
116
+ const allowsSeparateRowSubtotals = computed(() => !!props.definition.capabilities?.allowsSeparateRowSubtotals)
117
+ const allowsGrandTotals = computed(() => props.definition.capabilities?.allowsGrandTotals !== false)
118
+ const allowsVariants = computed(() => props.definition.capabilities?.allowsVariants !== false)
119
+
120
+ type SortTarget = {
121
+ fieldCode: string
122
+ timeGrain: ReportTimeGrain | null
123
+ appliesToColumnAxis: boolean
124
+ }
125
+
126
+ const timeGrainDrillOrder = [
127
+ ReportTimeGrain.Year,
128
+ ReportTimeGrain.Quarter,
129
+ ReportTimeGrain.Month,
130
+ ReportTimeGrain.Week,
131
+ ReportTimeGrain.Day,
132
+ ]
133
+
134
+ const groupFieldOptions = computed(() => groupableFields.value.map((field) => ({ value: field.code, label: field.label })))
135
+ const selectedSortableFields = computed(() => {
136
+ const selected = new Map<string, ReportFieldDto>()
137
+
138
+ for (const group of props.modelValue.rowGroups) {
139
+ const field = fieldByCode(group.fieldCode)
140
+ if (field?.isSortable) selected.set(field.code, field)
141
+ }
142
+
143
+ for (const group of props.modelValue.columnGroups) {
144
+ const field = fieldByCode(group.fieldCode)
145
+ if (field?.isSortable) selected.set(field.code, field)
146
+ }
147
+
148
+ for (const fieldCode of props.modelValue.detailFields) {
149
+ const field = fieldByCode(fieldCode)
150
+ if (field?.isSortable) selected.set(field.code, field)
151
+ }
152
+
153
+ return [...selected.values()]
154
+ })
155
+
156
+ const sortFieldOptions = computed(() => selectedSortableFields.value.map((field) => ({ value: field.code, label: field.label })))
157
+ const availableSortTargets = computed<SortTarget[]>(() => {
158
+ const targets: SortTarget[] = []
159
+ const seen = new Set<string>()
160
+
161
+ for (const group of props.modelValue.rowGroups) {
162
+ const field = fieldByCode(group.fieldCode)
163
+ if (!field?.isSortable) continue
164
+
165
+ const key = buildAxisSortTargetKey(group.fieldCode, group.timeGrain ?? null, false)
166
+ if (seen.has(key)) continue
167
+ seen.add(key)
168
+ targets.push({
169
+ fieldCode: group.fieldCode,
170
+ timeGrain: group.timeGrain ?? null,
171
+ appliesToColumnAxis: false,
172
+ })
173
+ }
174
+
175
+ for (const group of props.modelValue.columnGroups) {
176
+ const field = fieldByCode(group.fieldCode)
177
+ if (!field?.isSortable) continue
178
+
179
+ const key = buildAxisSortTargetKey(group.fieldCode, group.timeGrain ?? null, true)
180
+ if (seen.has(key)) continue
181
+ seen.add(key)
182
+ targets.push({
183
+ fieldCode: group.fieldCode,
184
+ timeGrain: group.timeGrain ?? null,
185
+ appliesToColumnAxis: true,
186
+ })
187
+ }
188
+
189
+ for (const fieldCode of props.modelValue.detailFields) {
190
+ const field = fieldByCode(fieldCode)
191
+ if (!field?.isSortable) continue
192
+
193
+ const key = buildAxisSortTargetKey(fieldCode, null, false)
194
+ if (seen.has(key)) continue
195
+ seen.add(key)
196
+ targets.push({
197
+ fieldCode,
198
+ timeGrain: null,
199
+ appliesToColumnAxis: false,
200
+ })
201
+ }
202
+
203
+ return targets
204
+ })
205
+ const measureOptions = computed(() => measures.value.map((measure) => ({ value: measure.code, label: measure.label })))
206
+ const selectableFieldOptions = computed(() => selectableFields.value.map((field) => ({ value: field.code, label: field.label, meta: field.description ?? undefined })))
207
+ const sortingHelpLines = computed(() => {
208
+ if (!allowsSorting.value || sortableFields.value.length === 0) return []
209
+
210
+ const lines = ['Sort only by fields already selected in row groups, column groups, or detail fields.']
211
+ const hasTimeGrouping = [...props.modelValue.rowGroups, ...props.modelValue.columnGroups]
212
+ .some((group) => {
213
+ const field = fieldByCode(group.fieldCode)
214
+ return (field?.supportedTimeGrains?.length ?? 0) > 0
215
+ })
216
+
217
+ if (hasTimeGrouping) {
218
+ lines.push('For grouped time fields, the time grain must match the selected grouping axis.')
219
+ }
220
+
221
+ return lines
222
+ })
223
+
224
+ const hasGeneralTab = computed(() => (props.definition.parameters?.length ?? 0) > 0 || hasFormattingContent.value)
225
+ const hasGroupingTab = computed(() => (allowsRowGroups.value || allowsColumnGroups.value) && groupableFields.value.length > 0)
226
+ const hasFiltersTab = computed(() => (props.definition.filters?.length ?? 0) > 0)
227
+ const hasFieldsTab = computed(() => (allowsMeasures.value && measureOptions.value.length > 0) || (allowsDetailFields.value && selectableFieldOptions.value.length > 0))
228
+ const hasSortingTab = computed(() => allowsSorting.value && sortableFields.value.length > 0)
229
+ const hasFormattingContent = computed(() => allowsShowDetails.value || allowsSubtotals.value || allowsGrandTotals.value)
230
+ const hasVariantTab = computed(() => allowsVariants.value)
231
+
232
+ const tabs = computed(() => {
233
+ const items: { key: ComposerTabKey; label: string }[] = []
234
+ if (hasGeneralTab.value) items.push({ key: 'general', label: 'General' })
235
+ if (hasGroupingTab.value) items.push({ key: 'grouping', label: 'Grouping' })
236
+ if (hasFiltersTab.value) items.push({ key: 'filters', label: 'Filters' })
237
+ if (hasFieldsTab.value) items.push({ key: 'fields', label: 'Fields' })
238
+ if (hasSortingTab.value) items.push({ key: 'sorting', label: 'Sorting' })
239
+ if (hasVariantTab.value) items.push({ key: 'variant', label: 'Variant' })
240
+ return items
241
+ })
242
+
243
+ const activeTab = ref<ComposerTabKey>('general')
244
+ const dragState = ref<{ section: ComposerDragSection; index: number } | null>(null)
245
+
246
+ watch(
247
+ tabs,
248
+ (nextTabs) => {
249
+ const first = nextTabs[0]?.key ?? 'general'
250
+ if (!nextTabs.some((tab) => tab.key === activeTab.value)) activeTab.value = first
251
+ },
252
+ { immediate: true },
253
+ )
254
+
255
+ function updateDraft(mutator: (draft: ReportComposerDraft) => void) {
256
+ const next = cloneComposerDraft(props.modelValue)
257
+ mutator(next)
258
+ emit('update:modelValue', normalizeComposerDraft(props.definition, next))
259
+ }
260
+
261
+ function rowGroupingKey(group: GroupingEntry, index: number): string {
262
+ return `row:${group.fieldCode}:${index}`
263
+ }
264
+
265
+ function columnGroupingKey(group: GroupingEntry, index: number): string {
266
+ return `column:${group.fieldCode}:${index}`
267
+ }
268
+
269
+ function measureKey(measure: MeasureEntry, index: number): string {
270
+ return `${measure.measureCode}:${index}`
271
+ }
272
+
273
+ function detailFieldKey(fieldCode: string, index: number): string {
274
+ return `${fieldCode}:${index}`
275
+ }
276
+
277
+ function sortKey(sort: SortEntry, index: number): string {
278
+ return `${sort.fieldCode}:${sort.timeGrain ?? 'none'}:${index}`
279
+ }
280
+
281
+ const rowGroupingRowKey = (item: unknown, index: number) => rowGroupingKey(item as GroupingEntry, index)
282
+ const columnGroupingRowKey = (item: unknown, index: number) => columnGroupingKey(item as GroupingEntry, index)
283
+ const measureRowKey = (item: unknown, index: number) => measureKey(item as MeasureEntry, index)
284
+ const detailFieldRowKey = (item: unknown, index: number) => detailFieldKey(String(item ?? ''), index)
285
+ const sortRowKey = (item: unknown, index: number) => sortKey(item as SortEntry, index)
286
+
287
+ function groupingTimeGrainOptions(fieldCode: string): ReportOptionItem[] {
288
+ return [{ value: '', label: 'No time grain' }, ...getTimeGrainOptions(fieldByCode(fieldCode))]
289
+ }
290
+
291
+ function hasGroupingTimeGrainOptions(fieldCode: string): boolean {
292
+ return getTimeGrainOptions(fieldByCode(fieldCode)).length > 0
293
+ }
294
+
295
+ function measureAggregationOptions(measureCode: string): ReportOptionItem[] {
296
+ return getAggregationOptions(props.definition, measureCode)
297
+ }
298
+
299
+ function measureAggregationValue(measure: MeasureEntry) {
300
+ return measure.aggregation ?? resolveDefaultAggregation(props.definition, measure.measureCode)
301
+ }
302
+
303
+ function handleCollectionDragStart(payload: { section: string; index: number; event: DragEvent }) {
304
+ onComposerDragStart(payload.section as ComposerDragSection, payload.index, payload.event)
305
+ }
306
+
307
+ function handleCollectionDrop(payload: { section: string; index: number; event: DragEvent }) {
308
+ onComposerDrop(payload.section as ComposerDragSection, payload.index, payload.event)
309
+ }
310
+
311
+ function filterState(field: ReportFilterFieldDto) {
312
+ return getReportComposerFilterState(props.modelValue, field)
313
+ }
314
+
315
+ function selectedFilterItem(field: ReportFilterFieldDto): ReportComposerLookupItem | null {
316
+ return getSelectedReportComposerFilterItem(props.modelValue, field)
317
+ }
318
+
319
+ function normalizeParameterDataType(dataType: string): string {
320
+ return dataType.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_')
321
+ }
322
+
323
+ function isDateParameter(dataType: string) {
324
+ const normalized = normalizeParameterDataType(dataType)
325
+ return normalized === 'date' || normalized === 'date_only' || normalized === 'date_time_utc'
326
+ }
327
+
328
+ function parameterInputType(dataType: string): string {
329
+ const normalized = dataType.trim().toLowerCase()
330
+ if (normalized.includes('int') || normalized.includes('decimal') || normalized.includes('number')) return 'number'
331
+ return 'text'
332
+ }
333
+
334
+ function normalizeDateParameterValue(value: string | null | undefined): string | null {
335
+ const text = String(value ?? '').trim()
336
+ const match = text.match(/^(\d{4}-\d{2}-\d{2})/)
337
+ return match?.[1] ?? null
338
+ }
339
+
340
+ function fieldByCode(fieldCode: string): ReportFieldDto | undefined {
341
+ return props.definition.dataset?.fields?.find((field) => field.code === fieldCode)
342
+ }
343
+
344
+ function buildSortTargetKey(fieldCode: string, timeGrain: ReportTimeGrain | null): string {
345
+ return `${fieldCode}|${timeGrain ?? ''}`
346
+ }
347
+
348
+ function buildAxisSortTargetKey(fieldCode: string, timeGrain: ReportTimeGrain | null, appliesToColumnAxis: boolean): string {
349
+ return `${fieldCode}|${appliesToColumnAxis ? 'column' : 'row'}|${timeGrain ?? ''}`
350
+ }
351
+
352
+ function nextFinerTimeGrain(field: ReportFieldDto | undefined, current: ReportTimeGrain | null): ReportTimeGrain | null {
353
+ if (!field || current === null) return null
354
+
355
+ const supported = new Set(getTimeGrainOptions(field).map((option) => option.value))
356
+ const currentIndex = timeGrainDrillOrder.indexOf(current)
357
+ if (currentIndex < 0) return null
358
+
359
+ for (let index = currentIndex + 1; index < timeGrainDrillOrder.length; index += 1) {
360
+ const grain = timeGrainDrillOrder[index]!
361
+ if (supported.has(grain)) return grain
362
+ }
363
+
364
+ return null
365
+ }
366
+
367
+ function nextDrillGrouping(axis: 'rowGroups' | 'columnGroups'): { fieldCode: string; timeGrain: ReportTimeGrain | null } | null {
368
+ const groups = props.modelValue[axis]
369
+ const last = groups[groups.length - 1]
370
+ if (!last) return null
371
+
372
+ const field = fieldByCode(last.fieldCode)
373
+ const nextGrain = nextFinerTimeGrain(field, last.timeGrain)
374
+ if (nextGrain === null) return null
375
+
376
+ const existing = new Set(groups.map((entry) => buildSortTargetKey(entry.fieldCode, entry.timeGrain ?? null)))
377
+ const key = buildSortTargetKey(last.fieldCode, nextGrain)
378
+ if (existing.has(key)) return null
379
+
380
+ return {
381
+ fieldCode: last.fieldCode,
382
+ timeGrain: nextGrain,
383
+ }
384
+ }
385
+
386
+ function sortAxisOptionsForField(fieldCode: string): ReportOptionItem[] {
387
+ const options: ReportOptionItem[] = []
388
+
389
+ if (props.modelValue.rowGroups.some((group) => group.fieldCode === fieldCode))
390
+ options.push({ value: 'row', label: 'Rows' })
391
+
392
+ if (props.modelValue.columnGroups.some((group) => group.fieldCode === fieldCode))
393
+ options.push({ value: 'column', label: 'Columns' })
394
+
395
+ if (props.modelValue.detailFields.includes(fieldCode))
396
+ options.push({ value: 'detail', label: 'Details' })
397
+
398
+ return options
399
+ }
400
+
401
+ function resolveSortAxis(sort: { fieldCode: string; appliesToColumnAxis?: boolean }): 'row' | 'column' | 'detail' {
402
+ const available = sortAxisOptionsForField(sort.fieldCode).map((option) => String(option.value))
403
+
404
+ if (sort.appliesToColumnAxis && available.includes('column')) return 'column'
405
+ if (!sort.appliesToColumnAxis && available.includes('row')) return 'row'
406
+ if (!sort.appliesToColumnAxis && available.includes('detail')) return 'detail'
407
+ if (available.includes('column')) return 'column'
408
+ if (available.includes('detail')) return 'detail'
409
+ return 'row'
410
+ }
411
+
412
+ function sortTimeGrainOptionsForFieldAndAxis(fieldCode: string, axis: 'row' | 'column' | 'detail'): ReportOptionItem[] {
413
+ const field = fieldByCode(fieldCode)
414
+ if (!field || axis === 'detail') return []
415
+
416
+ const groups = axis === 'column' ? props.modelValue.columnGroups : props.modelValue.rowGroups
417
+
418
+ const groupedTimeGrains = Array.from(new Set(
419
+ groups
420
+ .filter((group): group is typeof group & { timeGrain: ReportTimeGrain } => group.fieldCode === fieldCode && group.timeGrain !== null)
421
+ .map((group) => group.timeGrain),
422
+ ))
423
+
424
+ if (groupedTimeGrains.length === 0) return []
425
+
426
+ return groupedTimeGrains.map((grain) => ({
427
+ value: grain,
428
+ label: getTimeGrainOptions(field).find((option) => option.value === grain)?.label ?? 'No time grain',
429
+ }))
430
+ }
431
+
432
+ function addGrouping(axis: 'rowGroups' | 'columnGroups') {
433
+ const fallback = groupableFields.value[0]
434
+ if (!fallback) return
435
+
436
+ const drill = nextDrillGrouping(axis)
437
+ if (drill) {
438
+ updateDraft((draft) => {
439
+ draft[axis].push({
440
+ fieldCode: drill.fieldCode,
441
+ timeGrain: drill.timeGrain,
442
+ })
443
+ })
444
+ return
445
+ }
446
+
447
+ const existing = new Set(props.modelValue[axis].map((entry) => entry.fieldCode))
448
+ const chosen = groupableFields.value.find((field) => !existing.has(field.code)) ?? fallback
449
+
450
+ updateDraft((draft) => {
451
+ draft[axis].push({
452
+ fieldCode: chosen.code,
453
+ timeGrain: null,
454
+ })
455
+ })
456
+ }
457
+
458
+ function moveGrouping(axis: 'rowGroups' | 'columnGroups', index: number, delta: number) {
459
+ updateDraft((draft) => {
460
+ const target = index + delta
461
+ if (target < 0 || target >= draft[axis].length) return
462
+ const [entry] = draft[axis].splice(index, 1)
463
+ draft[axis].splice(target, 0, entry!)
464
+ })
465
+ }
466
+
467
+ function removeGrouping(axis: 'rowGroups' | 'columnGroups', index: number) {
468
+ updateDraft((draft) => {
469
+ draft[axis].splice(index, 1)
470
+ })
471
+ }
472
+
473
+ function setGroupingField(axis: 'rowGroups' | 'columnGroups', index: number, fieldCode: string) {
474
+ updateDraft((draft) => {
475
+ const entry = draft[axis][index]
476
+ if (!entry) return
477
+ entry.fieldCode = fieldCode
478
+ const field = fieldByCode(fieldCode)
479
+ if (!field?.supportedTimeGrains?.length) entry.timeGrain = null
480
+ })
481
+ }
482
+
483
+ function setGroupingTimeGrain(axis: 'rowGroups' | 'columnGroups', index: number, value: unknown) {
484
+ updateDraft((draft) => {
485
+ const entry = draft[axis][index]
486
+ if (!entry) return
487
+ entry.timeGrain = coerceReportTimeGrain(value)
488
+ })
489
+ }
490
+
491
+ function addMeasure() {
492
+ const fallback = measures.value[0]
493
+ if (!fallback) return
494
+
495
+ const existing = new Set(props.modelValue.measures.map((entry) => entry.measureCode))
496
+ const chosen = measures.value.find((measure) => !existing.has(measure.code)) ?? fallback
497
+
498
+ updateDraft((draft) => {
499
+ draft.measures.push({
500
+ measureCode: chosen.code,
501
+ aggregation: resolveDefaultAggregation(props.definition, chosen.code),
502
+ labelOverride: null,
503
+ })
504
+ })
505
+ }
506
+
507
+ function addSort() {
508
+ const fallback = availableSortTargets.value[0]
509
+ if (!fallback) return
510
+
511
+ const existing = new Set(props.modelValue.sorts.map((entry) => buildAxisSortTargetKey(entry.fieldCode, entry.timeGrain ?? null, !!entry.appliesToColumnAxis)))
512
+ const chosen = availableSortTargets.value.find((target) => !existing.has(buildAxisSortTargetKey(target.fieldCode, target.timeGrain, target.appliesToColumnAxis))) ?? fallback
513
+
514
+ updateDraft((draft) => {
515
+ draft.sorts.push({
516
+ fieldCode: chosen.fieldCode,
517
+ appliesToColumnAxis: chosen.appliesToColumnAxis,
518
+ groupKey: null,
519
+ direction: ReportSortDirection.Asc,
520
+ timeGrain: chosen.timeGrain,
521
+ })
522
+ })
523
+ }
524
+
525
+ function moveSort(index: number, delta: number) {
526
+ updateDraft((draft) => {
527
+ const target = index + delta
528
+ if (target < 0 || target >= draft.sorts.length) return
529
+ const [entry] = draft.sorts.splice(index, 1)
530
+ draft.sorts.splice(target, 0, entry!)
531
+ })
532
+ }
533
+
534
+ function removeMeasure(index: number) {
535
+ updateDraft((draft) => {
536
+ draft.measures.splice(index, 1)
537
+ })
538
+ }
539
+
540
+ function removeSort(index: number) {
541
+ updateDraft((draft) => {
542
+ draft.sorts.splice(index, 1)
543
+ })
544
+ }
545
+
546
+ function setParameter(code: string, value: string) {
547
+ updateDraft((draft) => {
548
+ draft.parameters[code] = value
549
+ })
550
+ }
551
+
552
+ function setFilterRaw(fieldCode: string, value: string) {
553
+ updateDraft((draft) => {
554
+ const state = draft.filters[fieldCode]
555
+ if (!state) return
556
+ state.raw = value
557
+ })
558
+ }
559
+
560
+ function setFilterItems(fieldCode: string, value: unknown) {
561
+ const items = coerceReportComposerLookupItems(value)
562
+ updateDraft((draft) => {
563
+ const state = draft.filters[fieldCode]
564
+ if (!state) return
565
+ state.items = items.map((item) => ({ ...item }))
566
+ if (items.length > 0) state.raw = ''
567
+ })
568
+ }
569
+
570
+ async function openFilterItem(field: ReportFilterFieldDto) {
571
+ const target = await resolveReportLookupTarget({
572
+ hint: field.lookup ?? null,
573
+ value: selectedFilterItem(field),
574
+ routeFullPath: route.fullPath,
575
+ })
576
+
577
+ if (!target) return
578
+ await router.push(target)
579
+ }
580
+
581
+ function setFilterIncludeDescendants(fieldCode: string, value: boolean) {
582
+ updateDraft((draft) => {
583
+ const state = draft.filters[fieldCode]
584
+ if (!state) return
585
+ state.includeDescendants = value
586
+ })
587
+ }
588
+
589
+ function setMeasureCode(index: number, measureCode: string) {
590
+ updateDraft((draft) => {
591
+ const entry = draft.measures[index]
592
+ if (!entry) return
593
+ entry.measureCode = measureCode
594
+ entry.aggregation = resolveDefaultAggregation(props.definition, measureCode)
595
+ entry.labelOverride = null
596
+ })
597
+ }
598
+
599
+ function setMeasureAggregation(index: number, value: unknown) {
600
+ updateDraft((draft) => {
601
+ const entry = draft.measures[index]
602
+ if (!entry) return
603
+ entry.aggregation = coerceReportAggregationKind(value)
604
+ })
605
+ }
606
+
607
+ function measureLabelValue(index: number): string {
608
+ const entry = props.modelValue.measures[index]
609
+ return entry ? resolveMeasureLabel(props.definition, entry) : ''
610
+ }
611
+
612
+ function setMeasureLabel(index: number, value: string) {
613
+ updateDraft((draft) => {
614
+ const entry = draft.measures[index]
615
+ if (!entry) return
616
+
617
+ const trimmed = String(value ?? '').trim()
618
+ const autoLabel = resolveMeasureLabel(props.definition, { ...entry, labelOverride: null })
619
+ entry.labelOverride = trimmed.length === 0 || trimmed === autoLabel ? null : trimmed
620
+ })
621
+ }
622
+
623
+ function addDetailField() {
624
+ const fallback = selectableFields.value[0]
625
+ if (!fallback) return
626
+
627
+ const existing = new Set(props.modelValue.detailFields)
628
+ const chosen = selectableFields.value.find((field) => !existing.has(field.code)) ?? fallback
629
+
630
+ updateDraft((draft) => {
631
+ draft.detailFields.push(chosen.code)
632
+ })
633
+ }
634
+
635
+ function removeDetailField(index: number) {
636
+ updateDraft((draft) => {
637
+ draft.detailFields.splice(index, 1)
638
+ })
639
+ }
640
+
641
+ function detailFieldOptionsFor(currentFieldCode: string) {
642
+ const selected = new Set(props.modelValue.detailFields.filter((fieldCode) => fieldCode !== currentFieldCode))
643
+ return selectableFieldOptions.value.filter((option) => !selected.has(String(option.value)))
644
+ }
645
+
646
+ function sortFieldOptionsFor(_currentFieldCode: string) {
647
+ return sortFieldOptions.value
648
+ }
649
+
650
+ function isSortTimeGrainLocked(fieldCode: string, axis: 'row' | 'column' | 'detail'): boolean {
651
+ return sortTimeGrainOptionsForFieldAndAxis(fieldCode, axis).length <= 1
652
+ }
653
+
654
+ function setDetailField(index: number, fieldCode: string) {
655
+ updateDraft((draft) => {
656
+ if (fieldCode.trim().length === 0) return
657
+ const duplicateIndex = draft.detailFields.findIndex((entry, entryIndex) => entry === fieldCode && entryIndex !== index)
658
+ if (duplicateIndex >= 0) return
659
+ draft.detailFields[index] = fieldCode
660
+ })
661
+ }
662
+
663
+ function setSortField(index: number, fieldCode: string) {
664
+ updateDraft((draft) => {
665
+ const entry = draft.sorts[index]
666
+ if (!entry) return
667
+ entry.fieldCode = fieldCode
668
+ entry.groupKey = null
669
+ const firstAxis = String(sortAxisOptionsForField(fieldCode)[0]?.value ?? 'row')
670
+ const axis = firstAxis === 'column' ? 'column' : firstAxis === 'detail' ? 'detail' : 'row'
671
+ entry.appliesToColumnAxis = axis === 'column'
672
+ const options = sortTimeGrainOptionsForFieldAndAxis(fieldCode, axis)
673
+ entry.timeGrain = axis === 'detail' ? null : coerceReportTimeGrain(options[0]?.value ?? null)
674
+ })
675
+ }
676
+
677
+ function setSortAxis(index: number, value: string) {
678
+ updateDraft((draft) => {
679
+ const entry = draft.sorts[index]
680
+ if (!entry) return
681
+
682
+ const axis = value === 'column' ? 'column' : value === 'detail' ? 'detail' : 'row'
683
+ entry.appliesToColumnAxis = axis === 'column'
684
+ entry.groupKey = null
685
+ const options = sortTimeGrainOptionsForFieldAndAxis(entry.fieldCode, axis)
686
+ entry.timeGrain = axis === 'detail' ? null : coerceReportTimeGrain(options[0]?.value ?? null)
687
+ })
688
+ }
689
+
690
+ function setSortDirection(index: number, value: unknown) {
691
+ updateDraft((draft) => {
692
+ const entry = draft.sorts[index]
693
+ if (!entry) return
694
+ entry.direction = coerceReportSortDirection(value) ?? ReportSortDirection.Asc
695
+ })
696
+ }
697
+
698
+ function setSortTimeGrain(index: number, value: unknown) {
699
+ updateDraft((draft) => {
700
+ const entry = draft.sorts[index]
701
+ if (!entry) return
702
+ entry.timeGrain = coerceReportTimeGrain(value)
703
+ })
704
+ }
705
+
706
+ function moveItem<T>(items: T[], from: number, to: number) {
707
+ if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) return
708
+ const [entry] = items.splice(from, 1)
709
+ items.splice(to, 0, entry!)
710
+ }
711
+
712
+ function onComposerDragStart(section: ComposerDragSection, index: number, e: DragEvent) {
713
+ dragState.value = { section, index }
714
+ try {
715
+ e.dataTransfer?.setData('text/plain', String(index))
716
+ e.dataTransfer?.setDragImage(new Image(), 0, 0)
717
+ } catch {
718
+ // ignore
719
+ }
720
+ }
721
+
722
+ function onComposerDragOver(e: DragEvent) {
723
+ e.preventDefault()
724
+ }
725
+
726
+ function onComposerDrop(section: ComposerDragSection, index: number, e: DragEvent) {
727
+ e.preventDefault()
728
+
729
+ const active = dragState.value
730
+ const from = active?.section === section
731
+ ? active.index
732
+ : Number(e.dataTransfer?.getData('text/plain') ?? NaN)
733
+
734
+ if (!Number.isFinite(from) || from === index) {
735
+ dragState.value = null
736
+ return
737
+ }
738
+
739
+ updateDraft((draft) => {
740
+ switch (section) {
741
+ case 'rowGroups':
742
+ moveItem(draft.rowGroups, from, index)
743
+ break
744
+ case 'columnGroups':
745
+ moveItem(draft.columnGroups, from, index)
746
+ break
747
+ case 'measures':
748
+ moveItem(draft.measures, from, index)
749
+ break
750
+ case 'detailFields':
751
+ moveItem(draft.detailFields, from, index)
752
+ break
753
+ case 'sorts':
754
+ moveItem(draft.sorts, from, index)
755
+ break
756
+ }
757
+ })
758
+
759
+ dragState.value = null
760
+ }
761
+
762
+ function setFlag(flag: 'showDetails' | 'showSubtotals' | 'showSubtotalsOnSeparateRows' | 'showGrandTotals', value: boolean) {
763
+ updateDraft((draft) => {
764
+ draft[flag] = value
765
+ })
766
+ }
767
+ </script>
768
+
769
+ <template>
770
+ <div data-testid="report-composer-panel" class="flex h-full flex-col">
771
+ <div class="flex items-center gap-3 border-b border-ngb-border px-5 py-4">
772
+ <div class="min-w-0 flex-1">
773
+ <div class="truncate text-base font-semibold text-ngb-text">{{ definition.name }}</div>
774
+ <div v-if="definition.description" class="mt-1 text-sm text-ngb-muted">{{ definition.description }}</div>
775
+ </div>
776
+
777
+ <div class="flex items-center gap-2">
778
+ <button type="button" class="ngb-iconbtn" :disabled="running" title="Run" @click="emit('run')">
779
+ <NgbIcon name="play" :size="50" />
780
+ </button>
781
+ <button type="button" class="ngb-iconbtn" title="Close" @click="emit('close')">
782
+ <NgbIcon name="x" />
783
+ </button>
784
+ </div>
785
+ </div>
786
+
787
+ <div class="flex-1 overflow-auto px-5 py-4">
788
+ <NgbTabs v-model="activeTab" :tabs="tabs" full-width-bar>
789
+ <template #default="{ active }">
790
+ <div class="space-y-6">
791
+ <section v-if="active === 'general' && hasGeneralTab" class="space-y-6">
792
+ <NgbFormLayout v-if="(definition.parameters?.length ?? 0) > 0">
793
+ <NgbFormRow
794
+ v-for="parameter in definition.parameters ?? []"
795
+ :key="parameter.code"
796
+ :label="parameter.label ?? parameter.code"
797
+ :hint="parameter.description ?? undefined"
798
+ dense
799
+ >
800
+ <NgbDatePicker
801
+ v-if="isDateParameter(parameter.dataType)"
802
+ :model-value="normalizeDateParameterValue(modelValue.parameters[parameter.code])"
803
+ @update:model-value="setParameter(parameter.code, $event ?? '')"
804
+ />
805
+
806
+ <NgbInput
807
+ v-else
808
+ :model-value="modelValue.parameters[parameter.code] ?? ''"
809
+ :type="parameterInputType(parameter.dataType)"
810
+ :placeholder="parameter.dataType"
811
+ @update:model-value="setParameter(parameter.code, String($event ?? ''))"
812
+ />
813
+ </NgbFormRow>
814
+ </NgbFormLayout>
815
+
816
+ <div v-if="hasFormattingContent" class="grid gap-3">
817
+ <div v-if="allowsShowDetails" class="flex items-center justify-between gap-4 rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card p-4 shadow-card">
818
+ <div>
819
+ <div class="text-sm font-medium text-ngb-text">Show details</div>
820
+ <div class="mt-1 text-xs text-ngb-muted">Ask the engine to include detail rows under groups when supported.</div>
821
+ </div>
822
+ <NgbSwitch :model-value="modelValue.showDetails" @update:model-value="setFlag('showDetails', $event)" />
823
+ </div>
824
+
825
+ <div v-if="allowsSubtotals" class="flex items-center justify-between gap-4 rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card p-4 shadow-card">
826
+ <div>
827
+ <div class="text-sm font-medium text-ngb-text">Show subtotals</div>
828
+ <div class="mt-1 text-xs text-ngb-muted">Render subtotal values for grouped output and row-axis pivot slices.</div>
829
+ </div>
830
+ <NgbSwitch :model-value="modelValue.showSubtotals" @update:model-value="setFlag('showSubtotals', $event)" />
831
+ </div>
832
+
833
+ <div v-if="allowsSeparateRowSubtotals" class="flex items-center justify-between gap-4 rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card p-4 shadow-card">
834
+ <div>
835
+ <div class="text-sm font-medium text-ngb-text">Show subtotals on separate rows</div>
836
+ <div class="mt-1 text-xs text-ngb-muted">Keep the hierarchy column, move subtotals to dedicated rows.</div>
837
+ </div>
838
+ <NgbSwitch
839
+ :model-value="modelValue.showSubtotalsOnSeparateRows"
840
+ :disabled="!modelValue.showSubtotals"
841
+ @update:model-value="setFlag('showSubtotalsOnSeparateRows', $event)"
842
+ />
843
+ </div>
844
+
845
+ <div v-if="allowsGrandTotals" class="flex items-center justify-between gap-4 rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card p-4 shadow-card">
846
+ <div>
847
+ <div class="text-sm font-medium text-ngb-text">Show totals</div>
848
+ <div class="mt-1 text-xs text-ngb-muted">Render trailing total columns and the final total row for pivot layouts.</div>
849
+ </div>
850
+ <NgbSwitch :model-value="modelValue.showGrandTotals" @update:model-value="setFlag('showGrandTotals', $event)" />
851
+ </div>
852
+ </div>
853
+
854
+ <div v-if="sortingHelpLines.length > 0" class="space-y-1 text-xs text-ngb-muted">
855
+ <p v-for="line in sortingHelpLines" :key="line">{{ line }}</p>
856
+ </div>
857
+ </section>
858
+
859
+ <section v-if="active === 'grouping' && hasGroupingTab" class="space-y-6">
860
+ <ReportComposerCollectionSection
861
+ v-if="allowsRowGroups"
862
+ title="Rows"
863
+ add-label="Add row"
864
+ :items="modelValue.rowGroups"
865
+ :columns="groupingSectionColumns"
866
+ empty-message="No row groups selected."
867
+ section="rowGroups"
868
+ :row-key="rowGroupingRowKey"
869
+ @add="addGrouping('rowGroups')"
870
+ @remove="removeGrouping('rowGroups', $event)"
871
+ @dragstart="handleCollectionDragStart"
872
+ @dragover="onComposerDragOver"
873
+ @drop="handleCollectionDrop"
874
+ >
875
+ <template #cells="{ item: group, index }">
876
+ <td class="border-r border-dotted border-ngb-border px-0 py-1 align-top">
877
+ <div class="px-1">
878
+ <NgbSelect
879
+ variant="grid"
880
+ :model-value="group.fieldCode"
881
+ :options="groupFieldOptions"
882
+ @update:model-value="setGroupingField('rowGroups', index, String($event ?? ''))"
883
+ />
884
+ </div>
885
+ </td>
886
+ <td class="border-r border-dotted border-ngb-border px-0 py-1 align-top">
887
+ <div class="px-1">
888
+ <NgbSelect
889
+ variant="grid"
890
+ :model-value="group.timeGrain ?? ''"
891
+ :options="groupingTimeGrainOptions(group.fieldCode)"
892
+ :disabled="!hasGroupingTimeGrainOptions(group.fieldCode)"
893
+ @update:model-value="setGroupingTimeGrain('rowGroups', index, $event)"
894
+ />
895
+ </div>
896
+ </td>
897
+ </template>
898
+ </ReportComposerCollectionSection>
899
+
900
+ <ReportComposerCollectionSection
901
+ v-if="allowsColumnGroups"
902
+ title="Columns"
903
+ add-label="Add column"
904
+ :items="modelValue.columnGroups"
905
+ :columns="groupingSectionColumns"
906
+ empty-message="No column groups selected. Add one to switch the sheet into pivot mode."
907
+ section="columnGroups"
908
+ :row-key="columnGroupingRowKey"
909
+ @add="addGrouping('columnGroups')"
910
+ @remove="removeGrouping('columnGroups', $event)"
911
+ @dragstart="handleCollectionDragStart"
912
+ @dragover="onComposerDragOver"
913
+ @drop="handleCollectionDrop"
914
+ >
915
+ <template #cells="{ item: group, index }">
916
+ <td class="border-r border-dotted border-ngb-border px-0 py-1 align-top">
917
+ <div class="px-1">
918
+ <NgbSelect
919
+ variant="grid"
920
+ :model-value="group.fieldCode"
921
+ :options="groupFieldOptions"
922
+ @update:model-value="setGroupingField('columnGroups', index, String($event ?? ''))"
923
+ />
924
+ </div>
925
+ </td>
926
+ <td class="border-r border-dotted border-ngb-border px-0 py-1 align-top">
927
+ <div class="px-1">
928
+ <NgbSelect
929
+ variant="grid"
930
+ :model-value="group.timeGrain ?? ''"
931
+ :options="groupingTimeGrainOptions(group.fieldCode)"
932
+ :disabled="!hasGroupingTimeGrainOptions(group.fieldCode)"
933
+ @update:model-value="setGroupingTimeGrain('columnGroups', index, $event)"
934
+ />
935
+ </div>
936
+ </td>
937
+ </template>
938
+ </ReportComposerCollectionSection>
939
+ </section>
940
+
941
+ <section v-if="active === 'filters' && hasFiltersTab" class="space-y-4">
942
+ <NgbFormLayout>
943
+ <NgbFormRow
944
+ v-for="field in definition.filters ?? []"
945
+ :key="field.fieldCode"
946
+ :label="field.label"
947
+ :hint="field.description ?? undefined"
948
+ dense
949
+ >
950
+ <FilterFieldControl
951
+ :field="field"
952
+ :state="filterState(field)"
953
+ :lookup-items="lookupItemsByFilterCode[field.fieldCode] ?? []"
954
+ select-empty-label="All"
955
+ :show-open="!!selectedFilterItem(field)"
956
+ :show-clear="!!selectedFilterItem(field)"
957
+ allow-include-descendants
958
+ @lookup-query="emit('filter-query', { fieldCode: field.fieldCode, query: $event })"
959
+ @update:items="setFilterItems(field.fieldCode, $event)"
960
+ @update:raw="setFilterRaw(field.fieldCode, $event)"
961
+ @update:include-descendants="setFilterIncludeDescendants(field.fieldCode, $event)"
962
+ @open="void openFilterItem(field)"
963
+ />
964
+ </NgbFormRow>
965
+ </NgbFormLayout>
966
+ </section>
967
+
968
+ <section v-if="active === 'fields' && hasFieldsTab" class="space-y-6">
969
+ <ReportComposerCollectionSection
970
+ v-if="allowsMeasures && measureOptions.length > 0"
971
+ title="Measures"
972
+ add-label="Add measure"
973
+ :items="modelValue.measures"
974
+ :columns="measureSectionColumns"
975
+ empty-message="No measures selected."
976
+ section="measures"
977
+ :row-key="measureRowKey"
978
+ @add="addMeasure"
979
+ @remove="removeMeasure($event)"
980
+ @dragstart="handleCollectionDragStart"
981
+ @dragover="onComposerDragOver"
982
+ @drop="handleCollectionDrop"
983
+ >
984
+ <template #cells="{ item: measure, index }">
985
+ <td class="border-r border-dotted border-ngb-border px-0 py-1 align-top">
986
+ <div class="px-1">
987
+ <NgbSelect
988
+ variant="grid"
989
+ :model-value="measure.measureCode"
990
+ :options="measureOptions"
991
+ @update:model-value="setMeasureCode(index, String($event ?? ''))"
992
+ />
993
+ </div>
994
+ </td>
995
+ <td class="border-r border-dotted border-ngb-border px-0 py-1 align-top">
996
+ <div class="px-1">
997
+ <NgbSelect
998
+ variant="grid"
999
+ :model-value="measureAggregationValue(measure)"
1000
+ :options="measureAggregationOptions(measure.measureCode)"
1001
+ @update:model-value="setMeasureAggregation(index, $event)"
1002
+ />
1003
+ </div>
1004
+ </td>
1005
+ <td class="border-r border-dotted border-ngb-border px-0 py-1 align-top">
1006
+ <div class="px-1">
1007
+ <NgbInput
1008
+ variant="grid"
1009
+ :model-value="measureLabelValue(index)"
1010
+ @update:model-value="setMeasureLabel(index, String($event ?? ''))"
1011
+ />
1012
+ </div>
1013
+ </td>
1014
+ </template>
1015
+ </ReportComposerCollectionSection>
1016
+
1017
+ <ReportComposerCollectionSection
1018
+ v-if="allowsDetailFields && selectableFieldOptions.length > 0"
1019
+ title="Detail fields"
1020
+ add-label="Add field"
1021
+ :items="modelValue.detailFields"
1022
+ :columns="detailFieldSectionColumns"
1023
+ empty-message="No detail fields selected."
1024
+ section="detailFields"
1025
+ :row-key="detailFieldRowKey"
1026
+ @add="addDetailField"
1027
+ @remove="removeDetailField($event)"
1028
+ @dragstart="handleCollectionDragStart"
1029
+ @dragover="onComposerDragOver"
1030
+ @drop="handleCollectionDrop"
1031
+ >
1032
+ <template #cells="{ item: fieldCode, index }">
1033
+ <td class="border-r border-dotted border-ngb-border px-0 py-1 align-top">
1034
+ <div class="px-1">
1035
+ <NgbSelect
1036
+ variant="grid"
1037
+ :model-value="fieldCode"
1038
+ :options="detailFieldOptionsFor(fieldCode)"
1039
+ @update:model-value="setDetailField(index, String($event ?? ''))"
1040
+ />
1041
+ </div>
1042
+ </td>
1043
+ </template>
1044
+ </ReportComposerCollectionSection>
1045
+ </section>
1046
+
1047
+ <section v-if="active === 'sorting' && hasSortingTab" class="space-y-4">
1048
+ <ReportComposerCollectionSection
1049
+ title="Sorting"
1050
+ add-label="Add sort"
1051
+ :items="modelValue.sorts"
1052
+ :columns="sortingSectionColumns"
1053
+ empty-message="No sorting selected."
1054
+ section="sorts"
1055
+ :row-key="sortRowKey"
1056
+ :add-disabled="availableSortTargets.length === 0"
1057
+ table-class="min-w-[520px] w-full table-fixed text-sm"
1058
+ @add="addSort"
1059
+ @remove="removeSort($event)"
1060
+ @dragstart="handleCollectionDragStart"
1061
+ @dragover="onComposerDragOver"
1062
+ @drop="handleCollectionDrop"
1063
+ >
1064
+ <template #cells="{ item: sort, index }">
1065
+ <td class="border-r border-dotted border-ngb-border px-0 py-1 align-top">
1066
+ <div class="px-1">
1067
+ <NgbSelect
1068
+ variant="grid"
1069
+ :model-value="sort.fieldCode"
1070
+ :options="sortFieldOptionsFor(sort.fieldCode)"
1071
+ @update:model-value="setSortField(index, String($event ?? ''))"
1072
+ />
1073
+ </div>
1074
+ </td>
1075
+ <td class="border-r border-dotted border-ngb-border px-0 py-1 align-top">
1076
+ <div class="px-1">
1077
+ <NgbSelect
1078
+ variant="grid"
1079
+ :model-value="resolveSortAxis(sort)"
1080
+ :options="sortAxisOptionsForField(sort.fieldCode)"
1081
+ @update:model-value="setSortAxis(index, String($event ?? 'row'))"
1082
+ />
1083
+ </div>
1084
+ </td>
1085
+ <td class="border-r border-dotted border-ngb-border px-0 py-1 align-top">
1086
+ <div class="px-1">
1087
+ <NgbSelect
1088
+ variant="grid"
1089
+ :model-value="sort.direction"
1090
+ :options="sortDirections"
1091
+ @update:model-value="setSortDirection(index, $event)"
1092
+ />
1093
+ </div>
1094
+ </td>
1095
+ <td class="border-r border-dotted border-ngb-border px-0 py-1 align-top">
1096
+ <div class="px-1">
1097
+ <NgbSelect
1098
+ variant="grid"
1099
+ :model-value="sort.timeGrain ?? ''"
1100
+ :options="sortTimeGrainOptionsForFieldAndAxis(sort.fieldCode, resolveSortAxis(sort))"
1101
+ :disabled="sortTimeGrainOptionsForFieldAndAxis(sort.fieldCode, resolveSortAxis(sort)).length === 0 || isSortTimeGrainLocked(sort.fieldCode, resolveSortAxis(sort))"
1102
+ @update:model-value="setSortTimeGrain(index, $event)"
1103
+ />
1104
+ </div>
1105
+ </td>
1106
+ </template>
1107
+ </ReportComposerCollectionSection>
1108
+
1109
+ <div v-if="sortingHelpLines.length > 0" class="space-y-1 text-xs text-ngb-muted">
1110
+ <p v-for="line in sortingHelpLines" :key="line">{{ line }}</p>
1111
+ </div>
1112
+ </section>
1113
+
1114
+ <section v-if="active === 'variant' && hasVariantTab" class="space-y-4">
1115
+ <div class="space-y-3">
1116
+ <div class="flex items-center justify-end gap-2">
1117
+ <button
1118
+ type="button"
1119
+ class="ngb-iconbtn"
1120
+ :disabled="variantDisabled || createVariantDisabled || running"
1121
+ title="Create variant"
1122
+ @click="emit('create-variant')"
1123
+ >
1124
+ <NgbIcon name="plus" />
1125
+ </button>
1126
+ <button
1127
+ type="button"
1128
+ class="ngb-iconbtn"
1129
+ :disabled="variantDisabled || editVariantDisabled || running"
1130
+ title="Edit variant"
1131
+ @click="emit('edit-variant')"
1132
+ >
1133
+ <NgbIcon name="edit" />
1134
+ </button>
1135
+ <button
1136
+ type="button"
1137
+ class="ngb-iconbtn"
1138
+ :disabled="variantDisabled || saveVariantDisabled || running"
1139
+ title="Save variant"
1140
+ @click="emit('save-variant')"
1141
+ >
1142
+ <NgbIcon name="save" />
1143
+ </button>
1144
+ <button
1145
+ type="button"
1146
+ class="ngb-iconbtn"
1147
+ :disabled="variantDisabled || deleteVariantDisabled || running"
1148
+ title="Delete variant"
1149
+ @click="emit('delete-variant')"
1150
+ >
1151
+ <NgbIcon name="trash" />
1152
+ </button>
1153
+ <button
1154
+ type="button"
1155
+ class="ngb-iconbtn"
1156
+ :disabled="variantDisabled || resetVariantDisabled || running"
1157
+ title="Reset variant"
1158
+ @click="emit('reset-variant')"
1159
+ >
1160
+ <NgbIcon name="undo" />
1161
+ </button>
1162
+ <button
1163
+ type="button"
1164
+ class="ngb-iconbtn"
1165
+ :disabled="variantDisabled || loadVariantDisabled || running"
1166
+ title="Load variant"
1167
+ @click="emit('load-variant')"
1168
+ >
1169
+ <NgbIcon name="load-variant" />
1170
+ </button>
1171
+ </div>
1172
+
1173
+ <NgbSelect
1174
+ :model-value="selectedVariantCode ?? ''"
1175
+ :options="variantOptions ?? []"
1176
+ @update:model-value="emit('update:selectedVariantCode', String($event ?? ''))"
1177
+ />
1178
+
1179
+ <div class="text-sm text-ngb-muted">{{ variantSummary ?? 'Using the report definition default layout and filters.' }}</div>
1180
+ </div>
1181
+ </section>
1182
+ </div>
1183
+ </template>
1184
+ </NgbTabs>
1185
+ </div>
1186
+ </div>
1187
+ </template>