@adminforth/dashboard 1.0.0 → 1.1.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 (41) hide show
  1. package/README.md +116 -54
  2. package/custom/api/dashboardApi.ts +9 -0
  3. package/custom/model/dashboard.types.ts +158 -1
  4. package/custom/queries/useWidgetData.ts +8 -4
  5. package/custom/runtime/WidgetShell.vue +8 -4
  6. package/custom/widgets/chart/chart.utils.ts +2 -2
  7. package/custom/widgets/gauge-card/GaugeCardWidget.vue +94 -12
  8. package/custom/widgets/pivot-table/PivotTableWidget.vue +27 -5
  9. package/custom/widgets/table/TableWidget.vue +155 -30
  10. package/dist/custom/api/dashboardApi.d.ts +7 -1
  11. package/dist/custom/api/dashboardApi.js +4 -6
  12. package/dist/custom/api/dashboardApi.ts +9 -0
  13. package/dist/custom/model/dashboard.types.d.ts +45 -0
  14. package/dist/custom/model/dashboard.types.js +82 -1
  15. package/dist/custom/model/dashboard.types.ts +158 -1
  16. package/dist/custom/queries/useDashboardConfig.d.ts +42 -0
  17. package/dist/custom/queries/useWidgetData.d.ts +44 -1
  18. package/dist/custom/queries/useWidgetData.js +3 -3
  19. package/dist/custom/queries/useWidgetData.ts +8 -4
  20. package/dist/custom/runtime/WidgetShell.vue +8 -4
  21. package/dist/custom/widgets/chart/chart.utils.d.ts +1 -1
  22. package/dist/custom/widgets/chart/chart.utils.js +2 -2
  23. package/dist/custom/widgets/chart/chart.utils.ts +2 -2
  24. package/dist/custom/widgets/gauge-card/GaugeCardWidget.vue +94 -12
  25. package/dist/custom/widgets/pivot-table/PivotTableWidget.vue +27 -5
  26. package/dist/custom/widgets/table/TableWidget.vue +155 -30
  27. package/dist/endpoint/widgets.d.ts +6 -1
  28. package/dist/endpoint/widgets.js +22 -4
  29. package/dist/schema/api.d.ts +882 -212
  30. package/dist/schema/api.js +11 -2
  31. package/dist/schema/widget.d.ts +542 -4
  32. package/dist/schema/widget.js +111 -1
  33. package/dist/services/widgetConfigValidator.js +32 -6
  34. package/dist/services/widgetDataService.d.ts +8 -6
  35. package/dist/services/widgetDataService.js +133 -11
  36. package/endpoint/widgets.ts +31 -4
  37. package/package.json +1 -1
  38. package/schema/api.ts +11 -1
  39. package/schema/widget.ts +114 -1
  40. package/services/widgetConfigValidator.ts +45 -6
  41. package/services/widgetDataService.ts +201 -19
package/README.md CHANGED
@@ -2,58 +2,120 @@
2
2
 
3
3
  Dashboard plugin for AdminForth.
4
4
 
5
+ It adds configurable dashboard pages backed by an AdminForth resource. Dashboard records define groups and widgets, the plugin renders them under `/dashboard/:slug`, contributes a **Dashboards** sidebar group, and exposes endpoints for editing groups and widgets from the AdminForth UI.
6
+
7
+ Full setup guide: https://adminforth.dev/docs/tutorial/Plugins/dashboard/
8
+
9
+ ## Dashboard Config Shape
10
+
11
+ ```ts
12
+ type DashboardConfig = {
13
+ version: number
14
+ groups: {
15
+ id: string
16
+ label: string
17
+ order: number
18
+ }[]
19
+ widgets: DashboardWidgetConfig[]
20
+ }
21
+ ```
22
+
23
+ Each widget has common fields:
24
+
25
+ | Field | Description |
26
+ | --- | --- |
27
+ | `id` | Persisted widget id. |
28
+ | `group_id` | Group where the widget is rendered. |
29
+ | `label` | Optional widget title. |
30
+ | `target` | Widget type: `table`, `chart`, `kpi_card`, `pivot_table`, or `gauge_card`. |
31
+ | `order` | Widget order inside its group. |
32
+ | `size` | Preset width: `small`, `medium`, `large`, `wide`, or `full`. |
33
+ | `width`, `height`, `minWidth`, `maxWidth` | Optional explicit layout constraints. |
34
+ | `dataSource` | Optional resource or aggregate data source definition. |
35
+ | `query` | Optional AdminForth resource query used to load widget data. |
36
+
37
+ ## Widget Support Matrix
38
+
39
+ | Widget target | Config field | Main settings | Data usage |
40
+ | --- | --- | --- | --- |
41
+ | `table` | `table` | `columns`, `pagination`, `pageSize` | Uses `dataSource.type = 'resource'` or legacy `query` to display resource rows with backend pagination unless `pagination` is `false`. |
42
+ | `chart` | `chart` | `type`, `x_field`, `y_field`, `label_field`, `value_field`, `bucket_field`, `buckets`, `series`, `series_name`, `color`, `colors` | Supports legacy `query` or `dataSource.type = 'aggregate'` with `groupBy`. |
43
+ | `kpi_card` | `kpi_card` | `value_field`, `label_field`, `prefix`, `suffix` | Reads the first row or aggregate values and formats one numeric value. |
44
+ | `gauge_card` | `gauge_card` | `value_field`, `min`, `max`, `min_field`, `max_field`, `suffix`, `color` | Reads the first row or aggregate values and renders progress between static or field-driven bounds. |
45
+ | `pivot_table` | `pivot_table` | `row_field`, `column_field`, `value_field`, `aggregation` | Supports legacy row-based queries and grouped aggregate rows. `aggregation` supports `count` and `sum`. |
46
+
47
+ Chart widget types:
48
+
49
+ | Chart type | Notes |
50
+ | --- | --- |
51
+ | `line` | Uses `x_field` and `y_field`; optional `series_name` and `color`. |
52
+ | `pie` | Uses `label_field` and optional `value_field`; without `value_field`, rows are counted by label. |
53
+ | `bar` | Uses `label_field` and `value_field`, or `bucket_field` with `buckets`. |
54
+ | `stacked_bar` | Uses `x_field` and `series`; if `series` is omitted, non-x columns become series. |
55
+ | `funnel` | Uses `label_field`, `value_field`, and optional `colors`. |
56
+ | `histogram` | Uses the same bucket settings as `bar`. |
57
+
58
+ ## Query Shape
59
+
60
+ ```ts
61
+ type DashboardWidgetQuery = {
62
+ resource: string
63
+ select?: string[]
64
+ order?: {
65
+ field: string
66
+ direction: 'asc' | 'desc'
67
+ }
68
+ limit?: number
69
+ }
70
+ ```
71
+
72
+ `resource` is an AdminForth `resourceId`. The query is executed through AdminForth resources, so widgets use the same resource contracts as the rest of the application.
73
+
74
+ ## Data Source Shape
75
+
76
+ ```ts
77
+ type WidgetDataSource =
78
+ | {
79
+ type: 'resource'
80
+ resourceId: string
81
+ columns?: string[]
82
+ filters?: unknown
83
+ sort?: unknown
84
+ }
85
+ | {
86
+ type: 'aggregate'
87
+ resourceId: string
88
+ aggregations: Record<string, {
89
+ operation: 'sum' | 'count' | 'avg' | 'min' | 'max' | 'median'
90
+ field?: string
91
+ }>
92
+ groupBy?:
93
+ | { type: 'field'; field: string }
94
+ | {
95
+ type: 'date_trunc'
96
+ field: string
97
+ truncation: 'day' | 'week' | 'month' | 'year'
98
+ timezone?: string
99
+ }
100
+ filters?: unknown
101
+ }
102
+ ```
103
+
104
+ `query` remains supported for backwards compatibility. When both are present, widgets prefer `dataSource`.
105
+
106
+ ## Runtime Structure
107
+
108
+ ```text
5
109
  DashboardPage.vue
6
- └── DashboardRuntime.vue
7
- ├── DashboardGroup.vue
8
- └── WidgetShell.vue
9
- └── WidgetRenderer.vue
10
- ├── TableWidget.vue
11
- ├── ChartWidget.vue
12
- ├── KpiCardWidget.vue
13
- ├── PivotTableWidget.vue
14
- └── GaugeCardWidget.vue
15
- └── DashboardEditorPanel.vue
16
-
17
- src/features/dashboards/
18
-
19
- runtime/
20
- DashboardPage.vue
21
- DashboardRuntime.vue
22
- DashboardGroup.vue
23
- WidgetShell.vue
24
- WidgetRenderer.vue
25
-
26
- widgets/
27
- registry.ts
28
-
29
- table/
30
- TableWidget.vue
31
- TableWidgetEditor.vue
32
- table.adapter.ts
33
-
34
- chart/
35
- ChartWidget.vue
36
- ChartWidgetEditor.vue
37
- chart.adapter.ts
38
- charts/
39
- PieChart.vue
40
- LineChart.vue
41
- BarChart.vue
42
- StackedBarChart.vue
43
- FunnelChart.vue
44
- HistogramChart.vue
45
-
46
- kpi-card/
47
- KpiCardWidget.vue
48
- KpiCardWidgetEditor.vue
49
- kpi.adapter.ts
50
-
51
- pivot-table/
52
- PivotTableWidget.vue
53
- PivotTableWidgetEditor.vue
54
- pivot.adapter.ts
55
-
56
- gauge-card/
57
- GaugeCardWidget.vue
58
- GaugeCardWidgetEditor.vue
59
- gauge.adapter.ts
110
+ └── DashboardRuntime.vue
111
+ └── DashboardGroup.vue
112
+ └── WidgetShell.vue
113
+ └── WidgetRenderer.vue
114
+ ├── TableWidget.vue
115
+ ├── ChartWidget.vue
116
+ ├── KpiCardWidget.vue
117
+ ├── PivotTableWidget.vue
118
+ └── GaugeCardWidget.vue
119
+ ```
120
+
121
+ `DashboardPage.vue` loads a dashboard by slug, `DashboardRuntime.vue` renders ordered groups, `WidgetShell.vue` provides the widget frame and editor actions, and `WidgetRenderer.vue` selects the widget component by `target`.
@@ -24,6 +24,13 @@ export type DashboardWidgetDataResponse = {
24
24
  data: unknown
25
25
  }
26
26
 
27
+ export type DashboardWidgetDataRequest = {
28
+ pagination?: {
29
+ page: number
30
+ pageSize: number
31
+ }
32
+ }
33
+
27
34
  export class DashboardApiError extends Error {
28
35
  validationErrors: DashboardWidgetConfigValidationError[]
29
36
 
@@ -204,10 +211,12 @@ export const dashboardApi = {
204
211
  async getDashboardWidgetData(
205
212
  slug: string,
206
213
  widgetId: string,
214
+ request: DashboardWidgetDataRequest = {},
207
215
  ): Promise<DashboardWidgetDataResponse> {
208
216
  return callDashboardWidgetDataApi('/adminapi/v1/dashboard/get_dashboard_widget_data', {
209
217
  slug,
210
218
  widgetId,
219
+ ...request,
211
220
  })
212
221
  },
213
222
  }
@@ -1,5 +1,42 @@
1
1
  import type { ChartWidgetConfig } from '../widgets/chart/chart.types.js'
2
2
 
3
+ export type AggregationOperation = 'sum' | 'count' | 'avg' | 'min' | 'max' | 'median'
4
+
5
+ export type AggregationRule = {
6
+ operation: AggregationOperation
7
+ field?: string
8
+ }
9
+
10
+ export type GroupByRule =
11
+ | {
12
+ type: 'field'
13
+ field: string
14
+ }
15
+ | {
16
+ type: 'date_trunc'
17
+ field: string
18
+ truncation: 'day' | 'week' | 'month' | 'year'
19
+ timezone?: string
20
+ }
21
+
22
+ export type ResourceWidgetDataSource = {
23
+ type: 'resource'
24
+ resourceId: string
25
+ columns?: string[]
26
+ sort?: unknown
27
+ filters?: unknown
28
+ }
29
+
30
+ export type AggregateWidgetDataSource = {
31
+ type: 'aggregate'
32
+ resourceId: string
33
+ aggregations: Record<string, AggregationRule>
34
+ groupBy?: GroupByRule
35
+ filters?: unknown
36
+ }
37
+
38
+ export type WidgetDataSource = ResourceWidgetDataSource | AggregateWidgetDataSource
39
+
3
40
  export type DashboardConfig = {
4
41
  version: number
5
42
  groups: DashboardGroupConfig[]
@@ -45,6 +82,7 @@ export type DashboardWidgetConfig = {
45
82
  maxWidth?: number | null
46
83
  order: number
47
84
  target: DashboardWidgetTarget
85
+ dataSource?: WidgetDataSource
48
86
  chart?: ChartWidgetConfig
49
87
  table?: unknown
50
88
  kpi_card?: unknown
@@ -54,20 +92,139 @@ export type DashboardWidgetConfig = {
54
92
  }
55
93
 
56
94
  export type DashboardWidgetTableData = {
95
+ kind?: 'table'
57
96
  columns: string[]
58
97
  rows: Record<string, unknown>[]
98
+ pagination?: {
99
+ page: number
100
+ pageSize: number
101
+ total: number
102
+ totalPages: number
103
+ }
59
104
  }
60
105
 
106
+ export type DashboardWidgetAggregateData = {
107
+ kind: 'aggregate'
108
+ columns: string[]
109
+ rows: Record<string, unknown>[]
110
+ values?: Record<string, unknown>
111
+ }
112
+
113
+ export type DashboardWidgetData = DashboardWidgetTableData | DashboardWidgetAggregateData
114
+
61
115
  export function normalizeDashboardConfig(config: unknown): DashboardConfig {
62
116
  const value = isRecord(config) ? config : {}
63
117
 
64
118
  return {
65
119
  version: typeof value.version === 'number' ? value.version : 1,
66
120
  groups: Array.isArray(value.groups) ? (value.groups as DashboardGroupConfig[]) : [],
67
- widgets: Array.isArray(value.widgets) ? (value.widgets as DashboardWidgetConfig[]) : [],
121
+ widgets: Array.isArray(value.widgets)
122
+ ? value.widgets.map((widget) => normalizeDashboardWidgetConfig(widget) as DashboardWidgetConfig)
123
+ : [],
68
124
  }
69
125
  }
70
126
 
127
+ export function normalizeDashboardWidgetConfig(config: unknown) {
128
+ if (!isRecord(config)) {
129
+ return config
130
+ }
131
+
132
+ const normalized: Record<string, unknown> = { ...config }
133
+ const target = normalizeDashboardWidgetTarget(normalized.target ?? normalized.type)
134
+
135
+ if (target && normalized.target === undefined) {
136
+ normalized.target = target
137
+ }
138
+
139
+ if (target === 'kpi_card') {
140
+ const kpiCardConfig = normalizeKpiCardConfig(normalized)
141
+
142
+ if (kpiCardConfig !== undefined) {
143
+ normalized.kpi_card = kpiCardConfig
144
+ }
145
+ }
146
+
147
+ if (target === 'gauge_card') {
148
+ const gaugeCardConfig = normalizeGaugeCardConfig(normalized)
149
+
150
+ if (gaugeCardConfig !== undefined) {
151
+ normalized.gauge_card = gaugeCardConfig
152
+ }
153
+ }
154
+
155
+ return normalized
156
+ }
157
+
158
+ function normalizeDashboardWidgetTarget(value: unknown): DashboardWidgetTarget | undefined {
159
+ switch (value) {
160
+ case 'empty':
161
+ case 'table':
162
+ case 'chart':
163
+ case 'kpi_card':
164
+ case 'pivot_table':
165
+ case 'gauge_card':
166
+ return value
167
+ default:
168
+ return undefined
169
+ }
170
+ }
171
+
172
+ function normalizeKpiCardConfig(value: Record<string, unknown>) {
173
+ const config = isRecord(value.kpi_card) ? { ...value.kpi_card } : {}
174
+
175
+ if (typeof value.valueField === 'string' && config.value_field === undefined) {
176
+ config.value_field = value.valueField
177
+ }
178
+
179
+ if (typeof value.labelField === 'string' && config.label_field === undefined) {
180
+ config.label_field = value.labelField
181
+ }
182
+
183
+ if (typeof value.prefix === 'string' && config.prefix === undefined) {
184
+ config.prefix = value.prefix
185
+ }
186
+
187
+ if (typeof value.suffix === 'string' && config.suffix === undefined) {
188
+ config.suffix = value.suffix
189
+ }
190
+
191
+ return Object.keys(config).length ? config : value.kpi_card
192
+ }
193
+
194
+ function normalizeGaugeCardConfig(value: Record<string, unknown>) {
195
+ const config = isRecord(value.gauge_card) ? { ...value.gauge_card } : {}
196
+
197
+ if (typeof value.valueField === 'string' && config.value_field === undefined) {
198
+ config.value_field = value.valueField
199
+ }
200
+
201
+ if (value.min !== undefined && config.min === undefined) {
202
+ config.min = value.min
203
+ }
204
+
205
+ if (value.max !== undefined && config.max === undefined) {
206
+ config.max = value.max
207
+ }
208
+
209
+ if (typeof value.minField === 'string' && config.min_field === undefined) {
210
+ config.min_field = value.minField
211
+ }
212
+
213
+ if (typeof value.maxField === 'string' && config.max_field === undefined) {
214
+ config.max_field = value.maxField
215
+ }
216
+
217
+ if (typeof value.suffix === 'string' && config.suffix === undefined) {
218
+ config.suffix = value.suffix
219
+ }
220
+
221
+ if (typeof value.color === 'string' && config.color === undefined) {
222
+ config.color = value.color
223
+ }
224
+
225
+ return Object.keys(config).length ? config : value.gauge_card
226
+ }
227
+
71
228
  function isRecord(value: unknown): value is Record<string, unknown> {
72
229
  return typeof value === 'object' && value !== null
73
230
  }
@@ -1,7 +1,11 @@
1
1
  import { ref, watch, type Ref } from 'vue'
2
- import { dashboardApi } from '../api/dashboardApi.js'
2
+ import { dashboardApi, type DashboardWidgetDataRequest } from '../api/dashboardApi.js'
3
3
 
4
- export function useWidgetData(slug: Ref<string>, widgetId: Ref<string>) {
4
+ export function useWidgetData(
5
+ slug: Ref<string>,
6
+ widgetId: Ref<string>,
7
+ request?: Ref<DashboardWidgetDataRequest>,
8
+ ) {
5
9
  const data = ref<Awaited<ReturnType<typeof dashboardApi.getDashboardWidgetData>> | null>(null)
6
10
  const isLoading = ref(false)
7
11
  const isFetching = ref(false)
@@ -20,7 +24,7 @@ export function useWidgetData(slug: Ref<string>, widgetId: Ref<string>) {
20
24
  }
21
25
 
22
26
  try {
23
- const response = await dashboardApi.getDashboardWidgetData(slug.value, widgetId.value)
27
+ const response = await dashboardApi.getDashboardWidgetData(slug.value, widgetId.value, request?.value)
24
28
  data.value = response
25
29
  error.value = null
26
30
  return response
@@ -34,7 +38,7 @@ export function useWidgetData(slug: Ref<string>, widgetId: Ref<string>) {
34
38
  }
35
39
 
36
40
  watch(
37
- [slug, widgetId],
41
+ request ? [slug, widgetId, request] : [slug, widgetId],
38
42
  () => {
39
43
  void refetch()
40
44
  },
@@ -135,11 +135,11 @@ const widgetLayoutVars = computed<CSSProperties>(() => {
135
135
  const fixedWidth = formatWidth(props.layout?.width)
136
136
 
137
137
  return {
138
- '--widget-basis': fixedWidth ?? basis,
139
- '--widget-min-width': fixedWidth ?? formatWidth(props.layout?.minWidth) ?? basis,
138
+ '--widget-basis': clampToContainerWidth(fixedWidth ?? basis),
139
+ '--widget-min-width': clampToContainerWidth(fixedWidth ?? formatWidth(props.layout?.minWidth) ?? basis),
140
140
  '--widget-max-width': props.layout?.maxWidth === null
141
- ? 'none'
142
- : fixedWidth ?? formatWidth(props.layout?.maxWidth) ?? 'none',
141
+ ? '100%'
142
+ : clampToContainerWidth(fixedWidth ?? formatWidth(props.layout?.maxWidth) ?? '100%'),
143
143
  height: formatWidth(props.layout?.height ?? DEFAULT_WIDGET_HEIGHT),
144
144
  }
145
145
  })
@@ -149,4 +149,8 @@ function formatWidth(value: number | undefined) {
149
149
  return `${value}px`
150
150
  }
151
151
  }
152
+
153
+ function clampToContainerWidth(value: string) {
154
+ return `min(${value}, 100%)`
155
+ }
152
156
  </script>
@@ -14,8 +14,8 @@ export function toFiniteNumber(value: unknown) {
14
14
  return Number.isFinite(numberValue) ? numberValue : 0
15
15
  }
16
16
 
17
- export function formatChartValue(value: number) {
18
- return new Intl.NumberFormat().format(value)
17
+ export function formatChartValue(value: number, options: Intl.NumberFormatOptions = {}) {
18
+ return new Intl.NumberFormat(undefined, options).format(value)
19
19
  }
20
20
 
21
21
  export function formatChartLabel(value: unknown) {
@@ -18,6 +18,66 @@ const {
18
18
  refetch,
19
19
  } = useWidgetData(dashboardSlugRef, widgetIdRef)
20
20
 
21
+ type GaugeCardConfig = {
22
+ value_field?: string
23
+ valueField?: string
24
+ min?: number | string
25
+ max?: number | string
26
+ min_field?: string
27
+ minField?: string
28
+ max_field?: string
29
+ maxField?: string
30
+ suffix?: string
31
+ color?: string
32
+ }
33
+
34
+ function isRecord(value: unknown): value is Record<string, unknown> {
35
+ return typeof value === 'object' && value !== null
36
+ }
37
+
38
+ function parseGaugeCardConfig(value: unknown): GaugeCardConfig | undefined {
39
+ if (isRecord(value)) {
40
+ return value as GaugeCardConfig
41
+ }
42
+
43
+ if (typeof value !== 'string') {
44
+ return undefined
45
+ }
46
+
47
+ try {
48
+ const parsed = JSON.parse(value) as unknown
49
+ return isRecord(parsed) ? parsed as GaugeCardConfig : undefined
50
+ } catch {
51
+ return undefined
52
+ }
53
+ }
54
+
55
+ function parseOptionalNumber(value: unknown): number | undefined {
56
+ if (value === null || value === undefined || value === '') {
57
+ return undefined
58
+ }
59
+
60
+ const parsed = typeof value === 'number' ? value : Number(value)
61
+ return Number.isFinite(parsed) ? parsed : undefined
62
+ }
63
+
64
+ function countFractionDigits(value: number) {
65
+ if (!Number.isFinite(value)) {
66
+ return 0
67
+ }
68
+
69
+ const normalizedValue = value.toString().toLowerCase()
70
+ const [coefficient, exponentValue] = normalizedValue.split('e')
71
+ const exponent = exponentValue ? Number(exponentValue) : 0
72
+ const decimalDigits = coefficient.split('.')[1]?.length ?? 0
73
+
74
+ return Math.max(decimalDigits - exponent, 0)
75
+ }
76
+
77
+ function normalizeDisplayValue(value: number, useWholeNumbers: boolean) {
78
+ return useWholeNumbers ? Math.trunc(value) : value
79
+ }
80
+
21
81
  watch(
22
82
  () => props.widget,
23
83
  () => {
@@ -26,20 +86,42 @@ watch(
26
86
  { deep: true },
27
87
  )
28
88
 
29
- const gaugeConfig = computed(() => props.widget.gauge_card as {
30
- value_field?: string
31
- min?: number
32
- max?: number
33
- suffix?: string
34
- color?: string
35
- } | undefined)
89
+ const gaugeConfig = computed(() => parseGaugeCardConfig(props.widget.gauge_card))
36
90
  const widgetData = computed(() => data.value?.data as DashboardWidgetTableData | null)
37
91
  const columns = computed(() => widgetData.value?.columns ?? [])
38
92
  const firstRow = computed(() => widgetData.value?.rows[0] ?? {})
39
- const valueField = computed(() => gaugeConfig.value?.value_field || columns.value[0])
40
- const minValue = computed(() => gaugeConfig.value?.min ?? 0)
41
- const maxValue = computed(() => gaugeConfig.value?.max ?? 100)
93
+ const valueField = computed(() => gaugeConfig.value?.value_field || gaugeConfig.value?.valueField || columns.value[0])
94
+ const minField = computed(() => gaugeConfig.value?.min_field || gaugeConfig.value?.minField)
95
+ const maxField = computed(() => gaugeConfig.value?.max_field || gaugeConfig.value?.maxField)
96
+ const minValue = computed(() => {
97
+ const dynamicMin = minField.value ? parseOptionalNumber(firstRow.value[minField.value]) : undefined
98
+ return dynamicMin ?? parseOptionalNumber(gaugeConfig.value?.min) ?? 0
99
+ })
100
+ const maxValue = computed(() => {
101
+ const dynamicMax = maxField.value ? parseOptionalNumber(firstRow.value[maxField.value]) : undefined
102
+ return dynamicMax ?? parseOptionalNumber(gaugeConfig.value?.max) ?? 100
103
+ })
42
104
  const value = computed(() => toFiniteNumber(firstRow.value[valueField.value]))
105
+ const fractionDigits = computed(() => Math.min([
106
+ value.value,
107
+ minValue.value,
108
+ maxValue.value,
109
+ ].reduce((maxDigits, currentValue) => Math.max(maxDigits, countFractionDigits(currentValue)), 0), 3))
110
+ const shouldUseWholeNumbers = computed(() => Math.abs(maxValue.value) >= 1000)
111
+ const formattedValue = computed(() => formatChartValue(normalizeDisplayValue(value.value, shouldUseWholeNumbers.value), {
112
+ minimumFractionDigits: shouldUseWholeNumbers.value ? 0 : fractionDigits.value,
113
+ maximumFractionDigits: shouldUseWholeNumbers.value ? 0 : fractionDigits.value,
114
+ }))
115
+ const formattedMinValue = computed(() => formatChartValue(normalizeDisplayValue(minValue.value, shouldUseWholeNumbers.value), {
116
+ minimumFractionDigits: shouldUseWholeNumbers.value ? 0 : fractionDigits.value,
117
+ maximumFractionDigits: shouldUseWholeNumbers.value ? 0 : fractionDigits.value,
118
+ }))
119
+ const formattedMaxValue = computed(() => {
120
+ return formatChartValue(normalizeDisplayValue(maxValue.value, shouldUseWholeNumbers.value), {
121
+ minimumFractionDigits: shouldUseWholeNumbers.value ? 0 : fractionDigits.value,
122
+ maximumFractionDigits: shouldUseWholeNumbers.value ? 0 : fractionDigits.value,
123
+ })
124
+ })
43
125
  const progress = computed(() => {
44
126
  const range = maxValue.value - minValue.value
45
127
  return range > 0 ? Math.min(Math.max((value.value - minValue.value) / range, 0), 1) : 0
@@ -97,10 +179,10 @@ const gaugeColor = computed(() => gaugeConfig.value?.color || CHART_COLORS[0])
97
179
  </svg>
98
180
 
99
181
  <div class="text-3xl font-bold text-lightNavbarText dark:text-darkNavbarText">
100
- {{ formatChartValue(value) }}{{ gaugeConfig?.suffix ?? '' }}
182
+ {{ formattedValue }}{{ gaugeConfig?.suffix ?? '' }}
101
183
  </div>
102
184
  <div class="text-sm text-lightListTableText dark:text-darkListTableText">
103
- {{ formatChartValue(minValue) }} - {{ formatChartValue(maxValue) }}
185
+ {{ formattedMinValue }} - {{ formattedMaxValue }}
104
186
  </div>
105
187
  </div>
106
188
  </div>
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, watch } from 'vue'
3
3
  import { useWidgetData } from '../../queries/useWidgetData.js'
4
- import type { DashboardWidgetConfig, DashboardWidgetTableData } from '../../model/dashboard.types.js'
4
+ import type { DashboardWidgetConfig, DashboardWidgetData } from '../../model/dashboard.types.js'
5
5
  import { formatChartLabel, formatChartValue, toFiniteNumber } from '../chart/chart.utils.js'
6
6
 
7
7
  const props = defineProps<{
@@ -32,15 +32,37 @@ const pivotConfig = computed(() => props.widget.pivot_table as {
32
32
  value_field?: string
33
33
  aggregation?: 'count' | 'sum'
34
34
  } | undefined)
35
- const widgetData = computed(() => data.value?.data as DashboardWidgetTableData | null)
35
+ const widgetData = computed(() => data.value?.data as DashboardWidgetData | null)
36
36
  const rows = computed(() => widgetData.value?.rows ?? [])
37
37
  const columns = computed(() => widgetData.value?.columns ?? [])
38
- const rowField = computed(() => pivotConfig.value?.row_field || columns.value[0])
38
+ const isAggregateData = computed(() => widgetData.value?.kind === 'aggregate')
39
+ const shouldRenderAggregateMatrix = computed(() => isAggregateData.value && !pivotConfig.value?.column_field)
40
+ const rowField = computed(() => pivotConfig.value?.row_field || (isAggregateData.value ? 'group' : columns.value[0]))
39
41
  const columnField = computed(() => pivotConfig.value?.column_field || columns.value[1])
40
- const valueField = computed(() => pivotConfig.value?.value_field || columns.value[2])
42
+ const valueField = computed(() => pivotConfig.value?.value_field || columns.value[2] || columns.value[1])
41
43
  const aggregation = computed(() => pivotConfig.value?.aggregation || (valueField.value ? 'sum' : 'count'))
42
- const pivotColumnLabels = computed(() => Array.from(new Set(rows.value.map((row) => formatChartLabel(row[columnField.value])))))
44
+ const pivotColumnLabels = computed(() => {
45
+ if (shouldRenderAggregateMatrix.value) {
46
+ return columns.value.filter((column) => column !== rowField.value)
47
+ }
48
+
49
+ return Array.from(new Set(rows.value.map((row) => formatChartLabel(row[columnField.value]))))
50
+ })
43
51
  const pivotRows = computed(() => {
52
+ if (shouldRenderAggregateMatrix.value) {
53
+ return rows.value.map((row) => {
54
+ const item: Record<string, number | string> = {
55
+ label: formatChartLabel(row[rowField.value]),
56
+ }
57
+
58
+ for (const column of pivotColumnLabels.value) {
59
+ item[column] = toFiniteNumber(row[column])
60
+ }
61
+
62
+ return item
63
+ })
64
+ }
65
+
44
66
  const rowMap = new Map<string, Record<string, number | string>>()
45
67
 
46
68
  for (const row of rows.value) {