@adminforth/dashboard 1.0.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 (107) hide show
  1. package/.woodpecker/buildRelease.sh +13 -0
  2. package/.woodpecker/buildSlackNotify.sh +46 -0
  3. package/.woodpecker/release.yml +57 -0
  4. package/README.md +59 -0
  5. package/custom/api/dashboardApi.ts +213 -0
  6. package/custom/composables/useElementSize.ts +41 -0
  7. package/custom/model/dashboard.types.ts +73 -0
  8. package/custom/package.json +9 -0
  9. package/custom/pnpm-lock.yaml +24 -0
  10. package/custom/queries/useDashboardConfig.ts +51 -0
  11. package/custom/queries/useWidgetData.ts +51 -0
  12. package/custom/runtime/DashboardGroup.vue +185 -0
  13. package/custom/runtime/DashboardPage.vue +122 -0
  14. package/custom/runtime/DashboardRuntime.vue +435 -0
  15. package/custom/runtime/WidgetRenderer.vue +60 -0
  16. package/custom/runtime/WidgetShell.vue +152 -0
  17. package/custom/skills/adminforth-dashboard/SKILL.md +125 -0
  18. package/custom/widgets/chart/ChartWidget.vue +188 -0
  19. package/custom/widgets/chart/bar/BarChart.vue +167 -0
  20. package/custom/widgets/chart/chart.types.ts +34 -0
  21. package/custom/widgets/chart/chart.utils.ts +54 -0
  22. package/custom/widgets/chart/funnel/FunnelChart.vue +197 -0
  23. package/custom/widgets/chart/histogram/HistogramChart.vue +21 -0
  24. package/custom/widgets/chart/line/LineChart.vue +175 -0
  25. package/custom/widgets/chart/pie/PieChart.vue +161 -0
  26. package/custom/widgets/chart/stacked-bar/StackedBarChart.vue +256 -0
  27. package/custom/widgets/gauge-card/GaugeCardWidget.vue +107 -0
  28. package/custom/widgets/kpi-card/KpiCardWidget.vue +73 -0
  29. package/custom/widgets/pivot-table/PivotTableWidget.vue +122 -0
  30. package/custom/widgets/registry.ts +51 -0
  31. package/custom/widgets/table/TableWidget.vue +110 -0
  32. package/dist/custom/api/dashboardApi.d.ts +32 -0
  33. package/dist/custom/api/dashboardApi.js +179 -0
  34. package/dist/custom/api/dashboardApi.ts +213 -0
  35. package/dist/custom/composables/useElementSize.d.ts +8 -0
  36. package/dist/custom/composables/useElementSize.js +30 -0
  37. package/dist/custom/composables/useElementSize.ts +41 -0
  38. package/dist/custom/model/dashboard.types.d.ts +45 -0
  39. package/dist/custom/model/dashboard.types.js +14 -0
  40. package/dist/custom/model/dashboard.types.ts +73 -0
  41. package/dist/custom/package.json +9 -0
  42. package/dist/custom/pnpm-lock.yaml +24 -0
  43. package/dist/custom/queries/useDashboardConfig.d.ts +112 -0
  44. package/dist/custom/queries/useDashboardConfig.js +57 -0
  45. package/dist/custom/queries/useDashboardConfig.ts +51 -0
  46. package/dist/custom/queries/useWidgetData.d.ts +90 -0
  47. package/dist/custom/queries/useWidgetData.js +57 -0
  48. package/dist/custom/queries/useWidgetData.ts +51 -0
  49. package/dist/custom/runtime/DashboardGroup.vue +185 -0
  50. package/dist/custom/runtime/DashboardPage.vue +122 -0
  51. package/dist/custom/runtime/DashboardRuntime.vue +435 -0
  52. package/dist/custom/runtime/WidgetRenderer.vue +60 -0
  53. package/dist/custom/runtime/WidgetShell.vue +152 -0
  54. package/dist/custom/skills/adminforth-dashboard/SKILL.md +125 -0
  55. package/dist/custom/widgets/chart/ChartWidget.vue +188 -0
  56. package/dist/custom/widgets/chart/bar/BarChart.vue +167 -0
  57. package/dist/custom/widgets/chart/chart.types.d.ts +25 -0
  58. package/dist/custom/widgets/chart/chart.types.js +2 -0
  59. package/dist/custom/widgets/chart/chart.types.ts +34 -0
  60. package/dist/custom/widgets/chart/chart.utils.d.ts +5 -0
  61. package/dist/custom/widgets/chart/chart.utils.js +52 -0
  62. package/dist/custom/widgets/chart/chart.utils.ts +54 -0
  63. package/dist/custom/widgets/chart/funnel/FunnelChart.vue +197 -0
  64. package/dist/custom/widgets/chart/histogram/HistogramChart.vue +21 -0
  65. package/dist/custom/widgets/chart/line/LineChart.vue +175 -0
  66. package/dist/custom/widgets/chart/pie/PieChart.vue +161 -0
  67. package/dist/custom/widgets/chart/stacked-bar/StackedBarChart.vue +256 -0
  68. package/dist/custom/widgets/gauge-card/GaugeCardWidget.vue +107 -0
  69. package/dist/custom/widgets/kpi-card/KpiCardWidget.vue +73 -0
  70. package/dist/custom/widgets/pivot-table/PivotTableWidget.vue +122 -0
  71. package/dist/custom/widgets/registry.d.ts +11 -0
  72. package/dist/custom/widgets/registry.js +47 -0
  73. package/dist/custom/widgets/registry.ts +51 -0
  74. package/dist/custom/widgets/table/TableWidget.vue +110 -0
  75. package/dist/endpoint/dashboard.d.ts +7 -0
  76. package/dist/endpoint/dashboard.js +29 -0
  77. package/dist/endpoint/groups.d.ts +30 -0
  78. package/dist/endpoint/groups.js +131 -0
  79. package/dist/endpoint/widgets.d.ts +15 -0
  80. package/dist/endpoint/widgets.js +182 -0
  81. package/dist/index.d.ts +13 -0
  82. package/dist/index.js +124 -0
  83. package/dist/schema/api.d.ts +1205 -0
  84. package/dist/schema/api.js +84 -0
  85. package/dist/schema/widget.d.ts +514 -0
  86. package/dist/schema/widget.js +133 -0
  87. package/dist/services/dashboardConfigService.d.ts +35 -0
  88. package/dist/services/dashboardConfigService.js +79 -0
  89. package/dist/services/widgetConfigValidator.d.ts +8 -0
  90. package/dist/services/widgetConfigValidator.js +65 -0
  91. package/dist/services/widgetDataService.d.ts +20 -0
  92. package/dist/services/widgetDataService.js +32 -0
  93. package/dist/types.d.ts +8 -0
  94. package/dist/types.js +1 -0
  95. package/endpoint/dashboard.ts +32 -0
  96. package/endpoint/groups.ts +213 -0
  97. package/endpoint/widgets.ts +255 -0
  98. package/index.ts +141 -0
  99. package/package.json +64 -0
  100. package/schema/api.ts +99 -0
  101. package/schema/widget.ts +159 -0
  102. package/services/dashboardConfigService.ts +136 -0
  103. package/services/widgetConfigValidator.ts +93 -0
  104. package/services/widgetDataService.ts +57 -0
  105. package/shims-vue.d.ts +5 -0
  106. package/tsconfig.json +18 -0
  107. package/types.ts +8 -0
@@ -0,0 +1,256 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { useElementSize } from '../../../composables/useElementSize.js'
4
+ import type { ChartWidgetSeriesConfig } from '../chart.types.js'
5
+ import { CHART_COLORS, formatChartAxisLabel, formatChartLabel, formatChartValue, toFiniteNumber } from '../chart.utils.js'
6
+
7
+ const props = withDefaults(defineProps<{
8
+ rows: Record<string, unknown>[]
9
+ xField: string
10
+ series: ChartWidgetSeriesConfig[]
11
+ colors?: string[]
12
+ height?: number
13
+ }>(), {
14
+ height: 280,
15
+ })
16
+
17
+ const { el: rootEl, width: rootWidth } = useElementSize<HTMLDivElement>()
18
+ const { el: svgEl, width: svgWidth, height: svgHeight } = useElementSize<HTMLDivElement>()
19
+
20
+ const padding = {
21
+ top: 24,
22
+ right: 6,
23
+ bottom: 34,
24
+ left: 38,
25
+ }
26
+ const barGap = 10
27
+ const normalizedSeries = computed(() => props.series.map((series, index) => ({
28
+ ...series,
29
+ color: series.color || props.colors?.[index] || CHART_COLORS[index % CHART_COLORS.length],
30
+ })))
31
+ const showLegend = computed(() => normalizedSeries.value.length > 0)
32
+ const isCompact = computed(() => rootWidth.value > 0 && rootWidth.value < 420)
33
+ const chartWidth = computed(() => Math.max(svgWidth.value, 1))
34
+ const chartHeight = computed(() => {
35
+ if (svgHeight.value > 0) {
36
+ return Math.max(svgHeight.value, 1)
37
+ }
38
+
39
+ return Math.max(props.height - (showLegend.value ? 28 : 0), 96)
40
+ })
41
+ const innerWidth = computed(() => Math.max(chartWidth.value - padding.left - padding.right, 1))
42
+ const innerHeight = computed(() => Math.max(chartHeight.value - padding.top - padding.bottom, 1))
43
+ const groupedRows = computed(() => {
44
+ const grouped = new Map<string, Record<string, unknown>>()
45
+
46
+ for (const row of props.rows) {
47
+ const label = formatChartLabel(row[props.xField])
48
+ const item = grouped.get(label) ?? { [props.xField]: label }
49
+
50
+ for (const series of normalizedSeries.value) {
51
+ item[series.name] = toFiniteNumber(item[series.name])
52
+ + getSeriesContribution(row[series.field], series.name)
53
+ }
54
+
55
+ grouped.set(label, item)
56
+ }
57
+
58
+ return Array.from(grouped.values())
59
+ })
60
+ const barWidth = computed(() => {
61
+ const count = Math.max(groupedRows.value.length, 1)
62
+ return Math.max(Math.min((innerWidth.value - barGap * (count - 1)) / count, 80), 4)
63
+ })
64
+ const totalChartWidth = computed(() => {
65
+ const count = Math.max(groupedRows.value.length, 1)
66
+ return count * barWidth.value + (count - 1) * barGap
67
+ })
68
+ const chartStartX = computed(() => padding.left + Math.max((innerWidth.value - totalChartWidth.value) / 2, 0))
69
+ const totals = computed(() => groupedRows.value.map((row) => normalizedSeries.value.reduce(
70
+ (sum, series) => sum + toFiniteNumber(row[series.name]),
71
+ 0,
72
+ )))
73
+ const maxTotal = computed(() => Math.max(...totals.value, 1))
74
+
75
+ const bars = computed(() => groupedRows.value.map((row, rowIndex) => {
76
+ let y = padding.top + innerHeight.value
77
+
78
+ return {
79
+ label: String(row[props.xField]),
80
+ axisLabel: formatChartAxisLabel(row[props.xField]),
81
+ x: chartStartX.value + rowIndex * (barWidth.value + barGap),
82
+ total: totals.value[rowIndex],
83
+ segments: normalizedSeries.value.map((series) => {
84
+ const value = toFiniteNumber(row[series.name])
85
+ const height = (value / maxTotal.value) * innerHeight.value
86
+ y -= height
87
+
88
+ return {
89
+ id: `${rowIndex}-${series.name}`,
90
+ name: series.name,
91
+ value,
92
+ color: series.color,
93
+ y,
94
+ height,
95
+ }
96
+ }),
97
+ }
98
+ }))
99
+
100
+ const visibleLabelIndexes = computed(() => {
101
+ const count = bars.value.length
102
+ const approxLabelWidth = 52
103
+ const maxLabels = Math.max(2, Math.floor(innerWidth.value / approxLabelWidth))
104
+
105
+ if (count <= maxLabels || barWidth.value >= 44) {
106
+ return new Set(bars.value.map((_, index) => index))
107
+ }
108
+
109
+ const indexes = new Set<number>()
110
+ const step = (count - 1) / (maxLabels - 1)
111
+
112
+ for (let index = 0; index < maxLabels; index += 1) {
113
+ indexes.add(Math.round(index * step))
114
+ }
115
+
116
+ return indexes
117
+ })
118
+
119
+ const yTicks = computed(() => [0, 0.5, 1].map((ratio) => ({
120
+ value: maxTotal.value * (1 - ratio),
121
+ y: padding.top + innerHeight.value * ratio,
122
+ })))
123
+
124
+ function getSeriesContribution(value: unknown, seriesName: string) {
125
+ if (typeof value === 'boolean') {
126
+ return value === getBooleanSeriesValue(seriesName) ? 1 : 0
127
+ }
128
+
129
+ if (typeof value === 'string' && ['true', 'false'].includes(value.toLowerCase())) {
130
+ return (value.toLowerCase() === 'true') === getBooleanSeriesValue(seriesName) ? 1 : 0
131
+ }
132
+
133
+ return toFiniteNumber(value)
134
+ }
135
+
136
+ function getBooleanSeriesValue(seriesName: string) {
137
+ const normalizedName = seriesName.toLowerCase()
138
+
139
+ if (normalizedName.includes('unlisted') || normalizedName.includes('false')) {
140
+ return false
141
+ }
142
+
143
+ return true
144
+ }
145
+
146
+ function getBarTooltip(bar: { label: string, total: number, segments: Array<{ name: string, value: number }> }) {
147
+ const percentFormatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 })
148
+
149
+ const segmentLines = bar.segments.map((segment) => {
150
+ const share = bar.total > 0
151
+ ? (segment.value / bar.total) * 100
152
+ : 0
153
+
154
+ return `${segment.name}: ${formatChartValue(segment.value)} (${percentFormatter.format(share)}%)`
155
+ })
156
+
157
+ return [
158
+ `${bar.label}`,
159
+ `Total: ${formatChartValue(bar.total)}`,
160
+ ...segmentLines,
161
+ ].join('\n')
162
+ }
163
+ </script>
164
+
165
+ <template>
166
+ <div
167
+ ref="rootEl"
168
+ class="grid h-full min-h-0 w-full grid-rows-[auto_minmax(0,1fr)] gap-3 overflow-hidden"
169
+ >
170
+ <div
171
+ v-if="showLegend"
172
+ class="flex flex-wrap items-center gap-3 text-xs text-lightListTableText dark:text-darkListTableText"
173
+ :class="isCompact ? 'justify-start' : 'justify-end'"
174
+ >
175
+ <div
176
+ v-for="series in normalizedSeries"
177
+ :key="series.name"
178
+ class="flex items-center gap-1.5"
179
+ >
180
+ <span
181
+ class="h-2.5 w-2.5 rounded-full"
182
+ :style="{ backgroundColor: series.color }"
183
+ />
184
+ <span>{{ series.name }}</span>
185
+ </div>
186
+ </div>
187
+
188
+ <div
189
+ ref="svgEl"
190
+ class="min-h-0 overflow-hidden"
191
+ >
192
+ <svg
193
+ v-if="chartWidth > 0 && chartHeight > 0"
194
+ class="block h-full w-full"
195
+ :viewBox="`0 0 ${chartWidth} ${chartHeight}`"
196
+ role="img"
197
+ :aria-label="xField"
198
+ >
199
+ <g class="text-lightListTableText dark:text-darkListTableText">
200
+ <line
201
+ v-for="tick in yTicks"
202
+ :key="tick.y"
203
+ :x1="padding.left"
204
+ :x2="chartWidth - padding.right"
205
+ :y1="tick.y"
206
+ :y2="tick.y"
207
+ stroke="currentColor"
208
+ stroke-opacity="0.14"
209
+ />
210
+ <text
211
+ v-for="tick in yTicks"
212
+ :key="`label-${tick.y}`"
213
+ :x="padding.left - 8"
214
+ :y="tick.y + 4"
215
+ fill="currentColor"
216
+ font-size="11"
217
+ text-anchor="end"
218
+ >
219
+ {{ formatChartValue(tick.value) }}
220
+ </text>
221
+ </g>
222
+
223
+ <g
224
+ v-for="(bar, barIndex) in bars"
225
+ :key="bar.label"
226
+ >
227
+ <rect
228
+ v-for="segment in bar.segments"
229
+ :key="segment.id"
230
+ v-show="segment.height > 0"
231
+ :x="bar.x"
232
+ :y="segment.y"
233
+ :width="barWidth"
234
+ :height="segment.height"
235
+ :fill="segment.color"
236
+ rx="3"
237
+ >
238
+ <title>{{ getBarTooltip(bar) }}</title>
239
+ </rect>
240
+
241
+ <text
242
+ v-if="visibleLabelIndexes.has(barIndex)"
243
+ :x="bar.x + barWidth / 2"
244
+ :y="padding.top + innerHeight + 24"
245
+ fill="currentColor"
246
+ font-size="11"
247
+ text-anchor="middle"
248
+ class="text-lightListTableText dark:text-darkListTableText"
249
+ >
250
+ {{ bar.axisLabel }}
251
+ </text>
252
+ </g>
253
+ </svg>
254
+ </div>
255
+ </div>
256
+ </template>
@@ -0,0 +1,107 @@
1
+ <script setup lang="ts">
2
+ import { computed, watch } from 'vue'
3
+ import { useWidgetData } from '../../queries/useWidgetData.js'
4
+ import type { DashboardWidgetConfig, DashboardWidgetTableData } from '../../model/dashboard.types.js'
5
+ import { CHART_COLORS, formatChartValue, toFiniteNumber } from '../chart/chart.utils.js'
6
+
7
+ const props = defineProps<{
8
+ dashboardSlug: string
9
+ widget: DashboardWidgetConfig
10
+ }>()
11
+
12
+ const dashboardSlugRef = computed(() => props.dashboardSlug)
13
+ const widgetIdRef = computed(() => props.widget.id)
14
+ const {
15
+ data,
16
+ isLoading,
17
+ error,
18
+ refetch,
19
+ } = useWidgetData(dashboardSlugRef, widgetIdRef)
20
+
21
+ watch(
22
+ () => props.widget,
23
+ () => {
24
+ void refetch()
25
+ },
26
+ { deep: true },
27
+ )
28
+
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)
36
+ const widgetData = computed(() => data.value?.data as DashboardWidgetTableData | null)
37
+ const columns = computed(() => widgetData.value?.columns ?? [])
38
+ 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)
42
+ const value = computed(() => toFiniteNumber(firstRow.value[valueField.value]))
43
+ const progress = computed(() => {
44
+ const range = maxValue.value - minValue.value
45
+ return range > 0 ? Math.min(Math.max((value.value - minValue.value) / range, 0), 1) : 0
46
+ })
47
+ const radius = 72
48
+ const circumference = Math.PI * radius
49
+ const strokeDashoffset = computed(() => circumference * (1 - progress.value))
50
+ const gaugeColor = computed(() => gaugeConfig.value?.color || CHART_COLORS[0])
51
+ </script>
52
+
53
+ <template>
54
+ <div class="mt-3 rounded-lg border border-lightListBorder bg-lightTableBackground p-4 dark:border-darkListBorder dark:bg-darkTableBackground">
55
+ <div
56
+ v-if="isLoading"
57
+ class="text-sm text-lightListTableText dark:text-darkListTableText"
58
+ >
59
+ Loading...
60
+ </div>
61
+
62
+ <div
63
+ v-else-if="error"
64
+ class="text-sm text-lightInputErrorColor"
65
+ >
66
+ Failed to load gauge data
67
+ </div>
68
+
69
+ <div
70
+ v-else
71
+ class="flex flex-col items-center gap-2"
72
+ >
73
+ <svg
74
+ width="180"
75
+ height="104"
76
+ viewBox="0 0 180 104"
77
+ role="img"
78
+ :aria-label="valueField"
79
+ >
80
+ <path
81
+ d="M18 90a72 72 0 0 1 144 0"
82
+ class="text-lightListBorder dark:text-darkListBorder"
83
+ fill="none"
84
+ stroke="currentColor"
85
+ stroke-linecap="round"
86
+ stroke-width="18"
87
+ />
88
+ <path
89
+ d="M18 90a72 72 0 0 1 144 0"
90
+ fill="none"
91
+ :stroke="gaugeColor"
92
+ stroke-linecap="round"
93
+ stroke-width="18"
94
+ :stroke-dasharray="circumference"
95
+ :stroke-dashoffset="strokeDashoffset"
96
+ />
97
+ </svg>
98
+
99
+ <div class="text-3xl font-bold text-lightNavbarText dark:text-darkNavbarText">
100
+ {{ formatChartValue(value) }}{{ gaugeConfig?.suffix ?? '' }}
101
+ </div>
102
+ <div class="text-sm text-lightListTableText dark:text-darkListTableText">
103
+ {{ formatChartValue(minValue) }} - {{ formatChartValue(maxValue) }}
104
+ </div>
105
+ </div>
106
+ </div>
107
+ </template>
@@ -0,0 +1,73 @@
1
+ <script setup lang="ts">
2
+ import { computed, watch } from 'vue'
3
+ import { useWidgetData } from '../../queries/useWidgetData.js'
4
+ import type { DashboardWidgetConfig, DashboardWidgetTableData } from '../../model/dashboard.types.js'
5
+ import { formatChartValue, toFiniteNumber } from '../chart/chart.utils.js'
6
+
7
+ const props = defineProps<{
8
+ dashboardSlug: string
9
+ widget: DashboardWidgetConfig
10
+ }>()
11
+
12
+ const dashboardSlugRef = computed(() => props.dashboardSlug)
13
+ const widgetIdRef = computed(() => props.widget.id)
14
+ const {
15
+ data,
16
+ isLoading,
17
+ error,
18
+ refetch,
19
+ } = useWidgetData(dashboardSlugRef, widgetIdRef)
20
+
21
+ watch(
22
+ () => props.widget,
23
+ () => {
24
+ void refetch()
25
+ },
26
+ { deep: true },
27
+ )
28
+
29
+ const kpiConfig = computed(() => props.widget.kpi_card as {
30
+ value_field?: string
31
+ label_field?: string
32
+ prefix?: string
33
+ suffix?: string
34
+ } | undefined)
35
+ const widgetData = computed(() => data.value?.data as DashboardWidgetTableData | null)
36
+ const columns = computed(() => widgetData.value?.columns ?? [])
37
+ const firstRow = computed(() => widgetData.value?.rows[0] ?? {})
38
+ const valueField = computed(() => kpiConfig.value?.value_field || columns.value[0])
39
+ const labelField = computed(() => kpiConfig.value?.label_field)
40
+ const value = computed(() => toFiniteNumber(firstRow.value[valueField.value]))
41
+ const label = computed(() => labelField.value ? String(firstRow.value[labelField.value]) : props.widget.label)
42
+ const formattedValue = computed(() => `${kpiConfig.value?.prefix ?? ''}${formatChartValue(value.value)}${kpiConfig.value?.suffix ?? ''}`)
43
+ </script>
44
+
45
+ <template>
46
+ <div class="mt-3 rounded-lg border border-lightListBorder bg-lightTableBackground p-4 dark:border-darkListBorder dark:bg-darkTableBackground">
47
+ <div
48
+ v-if="isLoading"
49
+ class="text-sm text-lightListTableText dark:text-darkListTableText"
50
+ >
51
+ Loading...
52
+ </div>
53
+
54
+ <div
55
+ v-else-if="error"
56
+ class="text-sm text-lightInputErrorColor"
57
+ >
58
+ Failed to load KPI data
59
+ </div>
60
+
61
+ <div
62
+ v-else
63
+ class="grid gap-1"
64
+ >
65
+ <div class="text-3xl font-bold text-lightNavbarText dark:text-darkNavbarText">
66
+ {{ formattedValue }}
67
+ </div>
68
+ <div class="text-sm text-lightListTableText dark:text-darkListTableText">
69
+ {{ label }}
70
+ </div>
71
+ </div>
72
+ </div>
73
+ </template>
@@ -0,0 +1,122 @@
1
+ <script setup lang="ts">
2
+ import { computed, watch } from 'vue'
3
+ import { useWidgetData } from '../../queries/useWidgetData.js'
4
+ import type { DashboardWidgetConfig, DashboardWidgetTableData } from '../../model/dashboard.types.js'
5
+ import { formatChartLabel, formatChartValue, toFiniteNumber } from '../chart/chart.utils.js'
6
+
7
+ const props = defineProps<{
8
+ dashboardSlug: string
9
+ widget: DashboardWidgetConfig
10
+ }>()
11
+
12
+ const dashboardSlugRef = computed(() => props.dashboardSlug)
13
+ const widgetIdRef = computed(() => props.widget.id)
14
+ const {
15
+ data,
16
+ isLoading,
17
+ error,
18
+ refetch,
19
+ } = useWidgetData(dashboardSlugRef, widgetIdRef)
20
+
21
+ watch(
22
+ () => props.widget,
23
+ () => {
24
+ void refetch()
25
+ },
26
+ { deep: true },
27
+ )
28
+
29
+ const pivotConfig = computed(() => props.widget.pivot_table as {
30
+ row_field?: string
31
+ column_field?: string
32
+ value_field?: string
33
+ aggregation?: 'count' | 'sum'
34
+ } | undefined)
35
+ const widgetData = computed(() => data.value?.data as DashboardWidgetTableData | null)
36
+ const rows = computed(() => widgetData.value?.rows ?? [])
37
+ const columns = computed(() => widgetData.value?.columns ?? [])
38
+ const rowField = computed(() => pivotConfig.value?.row_field || columns.value[0])
39
+ const columnField = computed(() => pivotConfig.value?.column_field || columns.value[1])
40
+ const valueField = computed(() => pivotConfig.value?.value_field || columns.value[2])
41
+ 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])))))
43
+ const pivotRows = computed(() => {
44
+ const rowMap = new Map<string, Record<string, number | string>>()
45
+
46
+ for (const row of rows.value) {
47
+ const rowLabel = formatChartLabel(row[rowField.value])
48
+ const columnLabel = formatChartLabel(row[columnField.value])
49
+ const item = rowMap.get(rowLabel) ?? { label: rowLabel }
50
+ const currentValue = typeof item[columnLabel] === 'number' ? item[columnLabel] : 0
51
+ item[columnLabel] = currentValue + (aggregation.value === 'count' ? 1 : toFiniteNumber(row[valueField.value]))
52
+ rowMap.set(rowLabel, item)
53
+ }
54
+
55
+ return Array.from(rowMap.values())
56
+ })
57
+ </script>
58
+
59
+ <template>
60
+ <div class="mt-3 flex h-full min-h-0 flex-col overflow-hidden rounded-lg border border-lightListBorder bg-lightTableBackground dark:border-darkListBorder dark:bg-darkTableBackground">
61
+ <div
62
+ v-if="isLoading"
63
+ class="p-4 text-sm text-lightListTableText dark:text-darkListTableText"
64
+ >
65
+ Loading...
66
+ </div>
67
+
68
+ <div
69
+ v-else-if="error"
70
+ class="p-4 text-sm text-lightInputErrorColor"
71
+ >
72
+ Failed to load pivot data
73
+ </div>
74
+
75
+ <div
76
+ v-else-if="!pivotRows.length"
77
+ class="p-4 text-sm text-lightListTableText dark:text-darkListTableText"
78
+ >
79
+ No data available
80
+ </div>
81
+
82
+ <div
83
+ v-else
84
+ class="min-h-0 flex-1 overflow-auto"
85
+ >
86
+ <table class="min-w-max w-full border-collapse text-left text-sm">
87
+ <thead class="bg-lightTableHeadingBackground text-xs uppercase text-lightTableHeadingText dark:bg-darkTableHeadingBackground dark:text-darkTableHeadingText">
88
+ <tr>
89
+ <th class="px-3 py-2 font-semibold">
90
+ {{ rowField }}
91
+ </th>
92
+ <th
93
+ v-for="column in pivotColumnLabels"
94
+ :key="column"
95
+ class="px-3 py-2 text-right font-semibold"
96
+ >
97
+ {{ column }}
98
+ </th>
99
+ </tr>
100
+ </thead>
101
+ <tbody>
102
+ <tr
103
+ v-for="row in pivotRows"
104
+ :key="String(row.label)"
105
+ class="border-t border-lightListBorder odd:bg-lightTableOddBackground even:bg-lightTableEvenBackground dark:border-darkListBorder odd:dark:bg-darkTableOddBackground even:dark:bg-darkTableEvenBackground"
106
+ >
107
+ <td class="px-3 py-2 font-medium text-lightNavbarText dark:text-darkNavbarText">
108
+ {{ row.label }}
109
+ </td>
110
+ <td
111
+ v-for="column in pivotColumnLabels"
112
+ :key="column"
113
+ class="px-3 py-2 text-right text-lightListTableText dark:text-darkListTableText"
114
+ >
115
+ {{ formatChartValue(typeof row[column] === 'number' ? row[column] : 0) }}
116
+ </td>
117
+ </tr>
118
+ </tbody>
119
+ </table>
120
+ </div>
121
+ </div>
122
+ </template>
@@ -0,0 +1,51 @@
1
+ import type { Component } from 'vue'
2
+ import type { DashboardWidgetTarget } from '../model/dashboard.types.js'
3
+ import ChartWidget from './chart/ChartWidget.vue'
4
+ import GaugeCardWidget from './gauge-card/GaugeCardWidget.vue'
5
+ import KpiCardWidget from './kpi-card/KpiCardWidget.vue'
6
+ import PivotTableWidget from './pivot-table/PivotTableWidget.vue'
7
+ import TableWidget from './table/TableWidget.vue'
8
+
9
+ export type DashboardWidgetType = DashboardWidgetTarget
10
+
11
+ export type DashboardWidgetRegistration = {
12
+ type: DashboardWidgetType
13
+ label: string
14
+ component?: Component
15
+ }
16
+
17
+ export const widgetRegistry: DashboardWidgetRegistration[] = [
18
+ {
19
+ type: 'table',
20
+ label: 'Table',
21
+ component: TableWidget,
22
+ },
23
+ {
24
+ type: 'chart',
25
+ label: 'Chart',
26
+ component: ChartWidget,
27
+ },
28
+ {
29
+ type: 'kpi_card',
30
+ label: 'KPI Card',
31
+ component: KpiCardWidget,
32
+ },
33
+ {
34
+ type: 'pivot_table',
35
+ label: 'Pivot Table',
36
+ component: PivotTableWidget,
37
+ },
38
+ {
39
+ type: 'gauge_card',
40
+ label: 'Gauge Card',
41
+ component: GaugeCardWidget,
42
+ },
43
+ ]
44
+
45
+ export function getWidgetRegistration(type: DashboardWidgetType) {
46
+ return widgetRegistry.find((widget) => widget.type === type)
47
+ }
48
+
49
+ export function getWidgetLabel(type: DashboardWidgetType) {
50
+ return getWidgetRegistration(type)?.label || type.replaceAll('_', ' ')
51
+ }